mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-01 11:08:10 +00:00
Add x509 Client Auth to MongoDB Database Plugin (#8329)
* Mark deprecated plugins as deprecated * Add redaction capability to database plugins * Add x509 client auth * Update vendored files * Add integration test for x509 client auth * Remove redaction logic pending further discussion * Update vendored files * Minor updates from code review * Updated docs with x509 client auth * Roles are required * Disable x509 test because it doesn't work in CircleCI * Add timeouts for container lifetime
This commit is contained in:
@@ -112,18 +112,18 @@ func newRegistry() *registry {
|
|||||||
"alicloud": logicalAlicloud.Factory,
|
"alicloud": logicalAlicloud.Factory,
|
||||||
"aws": logicalAws.Factory,
|
"aws": logicalAws.Factory,
|
||||||
"azure": logicalAzure.Factory,
|
"azure": logicalAzure.Factory,
|
||||||
"cassandra": logicalCass.Factory,
|
"cassandra": logicalCass.Factory, // Deprecated
|
||||||
"consul": logicalConsul.Factory,
|
"consul": logicalConsul.Factory,
|
||||||
"gcp": logicalGcp.Factory,
|
"gcp": logicalGcp.Factory,
|
||||||
"gcpkms": logicalGcpKms.Factory,
|
"gcpkms": logicalGcpKms.Factory,
|
||||||
"kv": logicalKv.Factory,
|
"kv": logicalKv.Factory,
|
||||||
"mongodb": logicalMongo.Factory,
|
"mongodb": logicalMongo.Factory, // Deprecated
|
||||||
"mongodbatlas": logicalMongoAtlas.Factory,
|
"mongodbatlas": logicalMongoAtlas.Factory,
|
||||||
"mssql": logicalMssql.Factory,
|
"mssql": logicalMssql.Factory, // Deprecated
|
||||||
"mysql": logicalMysql.Factory,
|
"mysql": logicalMysql.Factory, // Deprecated
|
||||||
"nomad": logicalNomad.Factory,
|
"nomad": logicalNomad.Factory,
|
||||||
"pki": logicalPki.Factory,
|
"pki": logicalPki.Factory,
|
||||||
"postgresql": logicalPostgres.Factory,
|
"postgresql": logicalPostgres.Factory, // Deprecated
|
||||||
"rabbitmq": logicalRabbit.Factory,
|
"rabbitmq": logicalRabbit.Factory,
|
||||||
"ssh": logicalSsh.Factory,
|
"ssh": logicalSsh.Factory,
|
||||||
"totp": logicalTotp.Factory,
|
"totp": logicalTotp.Factory,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func PrepareTestContainerWithDatabase(t *testing.T, version, dbName string) (cle
|
|||||||
// exponential backoff-retry
|
// exponential backoff-retry
|
||||||
if err = pool.Retry(func() error {
|
if err = pool.Retry(func() error {
|
||||||
var err error
|
var err error
|
||||||
dialInfo, err := parseMongoURL(retURL)
|
dialInfo, err := ParseMongoURL(retURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ func PrepareTestContainerWithDatabase(t *testing.T, version, dbName string) (cle
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMongoURL will parse a connection string and return a configured dialer
|
// ParseMongoURL will parse a connection string and return a configured dialer
|
||||||
func parseMongoURL(rawURL string) (*mgo.DialInfo, error) {
|
func ParseMongoURL(rawURL string) (*mgo.DialInfo, error) {
|
||||||
url, err := url.Parse(rawURL)
|
url, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
12
plugins/database/mongodb/README.md
Normal file
12
plugins/database/mongodb/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# MongoDB Tests
|
||||||
|
The test `TestInit_clientTLS` cannot be run within CircleCI in its current form. This is because [it's not
|
||||||
|
possible to use volume mounting with the docker executor](https://support.circleci.com/hc/en-us/articles/360007324514-How-can-I-mount-volumes-to-docker-containers-).
|
||||||
|
|
||||||
|
Because of this, the test is skipped. Running this locally shouldn't present any issues as long as you have
|
||||||
|
docker set up to allow volume mounting from this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -v -run Init_clientTLS
|
||||||
|
```
|
||||||
|
|
||||||
|
This may be able to be fixed if we mess with the entrypoint or the command arguments.
|
||||||
242
plugins/database/mongodb/cert_helpers_test.go
Normal file
242
plugins/database/mongodb/cert_helpers_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package mongodb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"io/ioutil"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type certBuilder struct {
|
||||||
|
tmpl *x509.Certificate
|
||||||
|
parentTmpl *x509.Certificate
|
||||||
|
|
||||||
|
selfSign bool
|
||||||
|
parentKey *rsa.PrivateKey
|
||||||
|
|
||||||
|
isCA bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type certOpt func(*certBuilder) error
|
||||||
|
|
||||||
|
func commonName(cn string) certOpt {
|
||||||
|
return func(builder *certBuilder) error {
|
||||||
|
builder.tmpl.Subject.CommonName = cn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parent(parent certificate) certOpt {
|
||||||
|
return func(builder *certBuilder) error {
|
||||||
|
builder.parentKey = parent.privKey.privKey
|
||||||
|
builder.parentTmpl = parent.template
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCA(isCA bool) certOpt {
|
||||||
|
return func(builder *certBuilder) error {
|
||||||
|
builder.isCA = isCA
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfSign() certOpt {
|
||||||
|
return func(builder *certBuilder) error {
|
||||||
|
builder.selfSign = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dns(dns ...string) certOpt {
|
||||||
|
return func(builder *certBuilder) error {
|
||||||
|
builder.tmpl.DNSNames = dns
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCert(t *testing.T, opts ...certOpt) (cert certificate) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
builder := certBuilder{
|
||||||
|
tmpl: &x509.Certificate{
|
||||||
|
SerialNumber: makeSerial(t),
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: makeCommonName(),
|
||||||
|
},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(1 * time.Hour),
|
||||||
|
IsCA: false,
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature |
|
||||||
|
x509.KeyUsageKeyEncipherment |
|
||||||
|
x509.KeyUsageKeyAgreement,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
err := opt(&builder)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to set up certificate builder: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := newPrivateKey(t)
|
||||||
|
|
||||||
|
builder.tmpl.SubjectKeyId = getSubjKeyID(t, key.privKey)
|
||||||
|
|
||||||
|
tmpl := builder.tmpl
|
||||||
|
parent := builder.parentTmpl
|
||||||
|
publicKey := key.privKey.Public()
|
||||||
|
signingKey := builder.parentKey
|
||||||
|
|
||||||
|
if builder.selfSign {
|
||||||
|
parent = tmpl
|
||||||
|
signingKey = key.privKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if builder.isCA {
|
||||||
|
tmpl.IsCA = true
|
||||||
|
tmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageCRLSign
|
||||||
|
tmpl.ExtKeyUsage = nil
|
||||||
|
} else {
|
||||||
|
tmpl.KeyUsage = x509.KeyUsageDigitalSignature |
|
||||||
|
x509.KeyUsageKeyEncipherment |
|
||||||
|
x509.KeyUsageKeyAgreement
|
||||||
|
tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, tmpl, parent, publicKey, signingKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to generate certificate: %s", err)
|
||||||
|
}
|
||||||
|
certPem := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certBytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
tlsCert, err := tls.X509KeyPair(certPem, key.pem)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to parse X509 key pair: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificate{
|
||||||
|
template: tmpl,
|
||||||
|
privKey: key,
|
||||||
|
tlsCert: tlsCert,
|
||||||
|
rawCert: certBytes,
|
||||||
|
pem: certPem,
|
||||||
|
isCA: builder.isCA,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Private Key
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
type keyWrapper struct {
|
||||||
|
privKey *rsa.PrivateKey
|
||||||
|
pem []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPrivateKey(t *testing.T) (key keyWrapper) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to generate key for cert: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privKeyPem := pem.EncodeToMemory(
|
||||||
|
&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
key = keyWrapper{
|
||||||
|
privKey: privKey,
|
||||||
|
pem: privKeyPem,
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Certificate
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
type certificate struct {
|
||||||
|
privKey keyWrapper
|
||||||
|
template *x509.Certificate
|
||||||
|
tlsCert tls.Certificate
|
||||||
|
rawCert []byte
|
||||||
|
pem []byte
|
||||||
|
isCA bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cert certificate) CombinedPEM() []byte {
|
||||||
|
if cert.isCA {
|
||||||
|
return cert.pem
|
||||||
|
}
|
||||||
|
return bytes.Join([][]byte{cert.privKey.pem, cert.pem}, []byte{'\n'})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Writing to file
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
func writeFile(t *testing.T, filename string, data []byte, perms os.FileMode) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := ioutil.WriteFile(filename, data, perms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to write to file [%s]: %s", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Helpers
|
||||||
|
// ////////////////////////////////////////////////////////////////////////////
|
||||||
|
func makeSerial(t *testing.T) *big.Int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
v := &big.Int{}
|
||||||
|
serialNumberLimit := v.Lsh(big.NewInt(1), 128)
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to generate serial number: %s", err)
|
||||||
|
}
|
||||||
|
return serialNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulled from sdk/helper/certutil & slightly modified for test usage
|
||||||
|
func getSubjKeyID(t *testing.T, privateKey crypto.Signer) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if privateKey == nil {
|
||||||
|
t.Fatalf("passed-in private key is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
marshaledKey, err := x509.MarshalPKIXPublicKey(privateKey.Public())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error marshalling public key: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subjKeyID := sha1.Sum(marshaledKey)
|
||||||
|
|
||||||
|
return subjKeyID[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCommonName() (cn string) {
|
||||||
|
return strings.ReplaceAll(time.Now().Format("20060102T150405.000"), ".", "")
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package mongodb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,8 +25,12 @@ import (
|
|||||||
type mongoDBConnectionProducer struct {
|
type mongoDBConnectionProducer struct {
|
||||||
ConnectionURL string `json:"connection_url" structs:"connection_url" mapstructure:"connection_url"`
|
ConnectionURL string `json:"connection_url" structs:"connection_url" mapstructure:"connection_url"`
|
||||||
WriteConcern string `json:"write_concern" structs:"write_concern" mapstructure:"write_concern"`
|
WriteConcern string `json:"write_concern" structs:"write_concern" mapstructure:"write_concern"`
|
||||||
Username string `json:"username" structs:"username" mapstructure:"username"`
|
|
||||||
Password string `json:"password" structs:"password" mapstructure:"password"`
|
Username string `json:"username" structs:"username" mapstructure:"username"`
|
||||||
|
Password string `json:"password" structs:"password" mapstructure:"password"`
|
||||||
|
|
||||||
|
TLSCertificateKeyData []byte `json:"tls_certificate_key" structs:"-" mapstructure:"tls_certificate_key"`
|
||||||
|
TLSCAData []byte `json:"tls_ca" structs:"-" mapstructure:"tls_ca"`
|
||||||
|
|
||||||
Initialized bool
|
Initialized bool
|
||||||
RawConfig map[string]interface{}
|
RawConfig map[string]interface{}
|
||||||
@@ -64,58 +70,19 @@ func (c *mongoDBConnectionProducer) Init(ctx context.Context, conf map[string]in
|
|||||||
return nil, fmt.Errorf("connection_url cannot be empty")
|
return nil, fmt.Errorf("connection_url cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.ConnectionURL = dbutil.QueryHelper(c.ConnectionURL, map[string]string{
|
writeOpts, err := c.getWriteConcern()
|
||||||
"username": c.Username,
|
if err != nil {
|
||||||
"password": c.Password,
|
return nil, err
|
||||||
})
|
|
||||||
|
|
||||||
if c.WriteConcern != "" {
|
|
||||||
input := c.WriteConcern
|
|
||||||
|
|
||||||
// Try to base64 decode the input. If successful, consider the decoded
|
|
||||||
// value as input.
|
|
||||||
inputBytes, err := base64.StdEncoding.DecodeString(input)
|
|
||||||
if err == nil {
|
|
||||||
input = string(inputBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
concern := &writeConcern{}
|
|
||||||
err = json.Unmarshal([]byte(input), concern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errwrap.Wrapf("error unmarshalling write_concern: {{err}}", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate write concern to mongo options
|
|
||||||
var w writeconcern.Option
|
|
||||||
switch {
|
|
||||||
case concern.W != 0:
|
|
||||||
w = writeconcern.W(concern.W)
|
|
||||||
case concern.WMode != "":
|
|
||||||
w = writeconcern.WTagSet(concern.WMode)
|
|
||||||
default:
|
|
||||||
w = writeconcern.WMajority()
|
|
||||||
}
|
|
||||||
|
|
||||||
var j writeconcern.Option
|
|
||||||
switch {
|
|
||||||
case concern.FSync:
|
|
||||||
j = writeconcern.J(concern.FSync)
|
|
||||||
case concern.J:
|
|
||||||
j = writeconcern.J(concern.J)
|
|
||||||
default:
|
|
||||||
j = writeconcern.J(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeConcern := writeconcern.New(
|
|
||||||
w,
|
|
||||||
j,
|
|
||||||
writeconcern.WTimeout(time.Duration(concern.WTimeout)*time.Millisecond))
|
|
||||||
|
|
||||||
c.clientOptions = &options.ClientOptions{
|
|
||||||
WriteConcern: writeConcern,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authOpts, err := c.getTLSAuth()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ConnectionURL = c.getConnectionURL()
|
||||||
|
c.clientOptions = options.MergeClientOptions(writeOpts, authOpts)
|
||||||
|
|
||||||
// Set initialized to true at this point since all fields are set,
|
// Set initialized to true at this point since all fields are set,
|
||||||
// and the connection can be established at a later time.
|
// and the connection can be established at a later time.
|
||||||
c.Initialized = true
|
c.Initialized = true
|
||||||
@@ -157,7 +124,8 @@ func (c *mongoDBConnectionProducer) Connection(ctx context.Context) (interface{}
|
|||||||
c.clientOptions.SetConnectTimeout(1 * time.Minute)
|
c.clientOptions.SetConnectTimeout(1 * time.Minute)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
c.client, err = mongo.Connect(ctx, c.clientOptions.ApplyURI(c.ConnectionURL))
|
opts := c.clientOptions.ApplyURI(c.ConnectionURL)
|
||||||
|
c.client, err = mongo.Connect(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -187,3 +155,98 @@ func (c *mongoDBConnectionProducer) secretValues() map[string]interface{} {
|
|||||||
c.Password: "[password]",
|
c.Password: "[password]",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mongoDBConnectionProducer) getConnectionURL() (connURL string) {
|
||||||
|
connURL = dbutil.QueryHelper(c.ConnectionURL, map[string]string{
|
||||||
|
"username": c.Username,
|
||||||
|
"password": c.Password,
|
||||||
|
})
|
||||||
|
return connURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mongoDBConnectionProducer) getWriteConcern() (opts *options.ClientOptions, err error) {
|
||||||
|
if c.WriteConcern == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
input := c.WriteConcern
|
||||||
|
|
||||||
|
// Try to base64 decode the input. If successful, consider the decoded
|
||||||
|
// value as input.
|
||||||
|
inputBytes, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err == nil {
|
||||||
|
input = string(inputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
concern := &writeConcern{}
|
||||||
|
err = json.Unmarshal([]byte(input), concern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errwrap.Wrapf("error unmarshalling write_concern: {{err}}", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate write concern to mongo options
|
||||||
|
var w writeconcern.Option
|
||||||
|
switch {
|
||||||
|
case concern.W != 0:
|
||||||
|
w = writeconcern.W(concern.W)
|
||||||
|
case concern.WMode != "":
|
||||||
|
w = writeconcern.WTagSet(concern.WMode)
|
||||||
|
default:
|
||||||
|
w = writeconcern.WMajority()
|
||||||
|
}
|
||||||
|
|
||||||
|
var j writeconcern.Option
|
||||||
|
switch {
|
||||||
|
case concern.FSync:
|
||||||
|
j = writeconcern.J(concern.FSync)
|
||||||
|
case concern.J:
|
||||||
|
j = writeconcern.J(concern.J)
|
||||||
|
default:
|
||||||
|
j = writeconcern.J(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeConcern := writeconcern.New(
|
||||||
|
w,
|
||||||
|
j,
|
||||||
|
writeconcern.WTimeout(time.Duration(concern.WTimeout)*time.Millisecond))
|
||||||
|
|
||||||
|
opts = options.Client()
|
||||||
|
opts.SetWriteConcern(writeConcern)
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mongoDBConnectionProducer) getTLSAuth() (opts *options.ClientOptions, err error) {
|
||||||
|
if len(c.TLSCAData) == 0 && len(c.TLSCertificateKeyData) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = options.Client()
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{}
|
||||||
|
|
||||||
|
if len(c.TLSCAData) > 0 {
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
|
|
||||||
|
ok := tlsConfig.RootCAs.AppendCertsFromPEM(c.TLSCAData)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to append CA to client options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.TLSCertificateKeyData) > 0 {
|
||||||
|
certificate, err := tls.X509KeyPair(c.TLSCertificateKeyData, c.TLSCertificateKeyData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to load tls_certificate_key_data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.SetAuth(options.Credential{
|
||||||
|
AuthMechanism: "MONGODB-X509",
|
||||||
|
Username: c.Username,
|
||||||
|
})
|
||||||
|
|
||||||
|
tlsConfig.Certificates = append(tlsConfig.Certificates, certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.SetTLSConfig(tlsConfig)
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|||||||
303
plugins/database/mongodb/connection_producer_test.go
Normal file
303
plugins/database/mongodb/connection_producer_test.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package mongodb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
paths "path"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/helper/testhelpers/mongodb"
|
||||||
|
"github.com/ory/dockertest"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/readpref"
|
||||||
|
"gopkg.in/mgo.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInit_clientTLS(t *testing.T) {
|
||||||
|
t.Skip("Skipping this test because CircleCI can't mount the files we need without further investigation: " +
|
||||||
|
"https://support.circleci.com/hc/en-us/articles/360007324514-How-can-I-mount-volumes-to-docker-containers-")
|
||||||
|
|
||||||
|
// Set up temp directory so we can mount it to the docker container
|
||||||
|
confDir := makeTempDir(t)
|
||||||
|
defer os.RemoveAll(confDir)
|
||||||
|
|
||||||
|
// Create certificates for Mongo authentication
|
||||||
|
caCert := newCert(t,
|
||||||
|
commonName("test certificate authority"),
|
||||||
|
isCA(true),
|
||||||
|
selfSign(),
|
||||||
|
)
|
||||||
|
serverCert := newCert(t,
|
||||||
|
commonName("server"),
|
||||||
|
dns("localhost"),
|
||||||
|
parent(caCert),
|
||||||
|
)
|
||||||
|
clientCert := newCert(t,
|
||||||
|
commonName("client"),
|
||||||
|
dns("client"),
|
||||||
|
parent(caCert),
|
||||||
|
)
|
||||||
|
|
||||||
|
writeFile(t, paths.Join(confDir, "ca.pem"), caCert.CombinedPEM(), 0644)
|
||||||
|
writeFile(t, paths.Join(confDir, "server.pem"), serverCert.CombinedPEM(), 0644)
|
||||||
|
writeFile(t, paths.Join(confDir, "client.pem"), clientCert.CombinedPEM(), 0644)
|
||||||
|
|
||||||
|
// //////////////////////////////////////////////////////
|
||||||
|
// Set up Mongo config file
|
||||||
|
rawConf := `
|
||||||
|
net:
|
||||||
|
tls:
|
||||||
|
mode: preferTLS
|
||||||
|
certificateKeyFile: /etc/mongo/server.pem
|
||||||
|
CAFile: /etc/mongo/ca.pem
|
||||||
|
allowInvalidHostnames: true`
|
||||||
|
|
||||||
|
writeFile(t, paths.Join(confDir, "mongod.conf"), []byte(rawConf), 0644)
|
||||||
|
|
||||||
|
// //////////////////////////////////////////////////////
|
||||||
|
// Start Mongo container
|
||||||
|
retURL, cleanup := startMongoWithTLS(t, "latest", confDir)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// //////////////////////////////////////////////////////
|
||||||
|
// Set up x509 user
|
||||||
|
mClient := connect(t, retURL)
|
||||||
|
|
||||||
|
setUpX509User(t, mClient, clientCert)
|
||||||
|
|
||||||
|
// //////////////////////////////////////////////////////
|
||||||
|
// Test
|
||||||
|
mongo := new()
|
||||||
|
|
||||||
|
conf := map[string]interface{}{
|
||||||
|
"connection_url": retURL,
|
||||||
|
"allowed_roles": "*",
|
||||||
|
"tls_certificate_key": clientCert.CombinedPEM(),
|
||||||
|
"tls_ca": caCert.pem,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := mongo.Init(ctx, conf, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to initialize mongo engine: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialization complete. The connection was established, but we need to ensure
|
||||||
|
// that we're connected as the right user
|
||||||
|
whoamiCmd := map[string]interface{}{
|
||||||
|
"connectionStatus": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mongo.getConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to make connection to Mongo: %s", err)
|
||||||
|
}
|
||||||
|
result := client.Database("test").RunCommand(ctx, whoamiCmd)
|
||||||
|
if result.Err() != nil {
|
||||||
|
t.Fatalf("Unable to connect to Mongo: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := connStatus{
|
||||||
|
AuthInfo: authInfo{
|
||||||
|
AuthenticatedUsers: []user{
|
||||||
|
{
|
||||||
|
User: fmt.Sprintf("CN=%s", clientCert.template.Subject.CommonName),
|
||||||
|
DB: "$external",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthenticatedUserRoles: []role{
|
||||||
|
{
|
||||||
|
Role: "readWrite",
|
||||||
|
DB: "test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "userAdminAnyDatabase",
|
||||||
|
DB: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Ok: 1,
|
||||||
|
}
|
||||||
|
// Sort the AuthenticatedUserRoles because Mongo doesn't return them in the same order every time
|
||||||
|
// Thanks Mongo! /tableflip
|
||||||
|
sort.Sort(expected.AuthInfo.AuthenticatedUserRoles)
|
||||||
|
|
||||||
|
actual := connStatus{}
|
||||||
|
err = result.Decode(&actual)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to decode connection status: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(actual.AuthInfo.AuthenticatedUserRoles)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
|
t.Fatalf("Actual:%#v\nExpected:\n%#v", actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTempDir(t *testing.T) (confDir string) {
|
||||||
|
confDir, err := ioutil.TempDir(".", "mongodb-test-data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to make temp directory: %s", err)
|
||||||
|
}
|
||||||
|
// Convert the directory to an absolute path because docker needs it when mounting
|
||||||
|
confDir, err = filepath.Abs(filepath.Clean(confDir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to determine where temp directory is on absolute path: %s", err)
|
||||||
|
}
|
||||||
|
return confDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMongoWithTLS(t *testing.T, version string, confDir string) (retURL string, cleanup func()) {
|
||||||
|
if os.Getenv("MONGODB_URL") != "" {
|
||||||
|
return os.Getenv("MONGODB_URL"), func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := dockertest.NewPool("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to connect to docker: %s", err)
|
||||||
|
}
|
||||||
|
pool.MaxWait = 30 * time.Second
|
||||||
|
|
||||||
|
containerName := "mongo-unit-test"
|
||||||
|
|
||||||
|
// Remove previously running container if it is still running because cleanup failed
|
||||||
|
err = pool.RemoveContainerByName(containerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to remove old running containers: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runOpts := &dockertest.RunOptions{
|
||||||
|
Name: containerName,
|
||||||
|
Repository: "mongo",
|
||||||
|
Tag: version,
|
||||||
|
Cmd: []string{"mongod", "--config", "/etc/mongo/mongod.conf"},
|
||||||
|
// Mount the directory from local filesystem into the container
|
||||||
|
Mounts: []string{
|
||||||
|
fmt.Sprintf("%s:/etc/mongo", confDir),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, err := pool.RunWithOptions(runOpts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not start local mongo docker container: %s", err)
|
||||||
|
}
|
||||||
|
resource.Expire(30)
|
||||||
|
|
||||||
|
cleanup = func() {
|
||||||
|
err := pool.Purge(resource)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to cleanup local container: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := url.URL{
|
||||||
|
Scheme: "mongodb",
|
||||||
|
Host: fmt.Sprintf("localhost:%s", resource.GetPort("27017/tcp")),
|
||||||
|
}
|
||||||
|
retURL = uri.String()
|
||||||
|
|
||||||
|
// exponential backoff-retry
|
||||||
|
err = pool.Retry(func() error {
|
||||||
|
var err error
|
||||||
|
dialInfo, err := mongodb.ParseMongoURL(retURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := mgo.DialWithInfo(dialInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
session.SetSyncTimeout(1 * time.Minute)
|
||||||
|
session.SetSocketTimeout(1 * time.Minute)
|
||||||
|
return session.Ping()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("Could not connect to mongo docker container: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return retURL, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(t *testing.T, uri string) (client *mongo.Client) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to make connection to Mongo: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Ping(ctx, readpref.Primary())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to ping Mongo server: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUpX509User(t *testing.T, client *mongo.Client, cert certificate) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
username := fmt.Sprintf("CN=%s", cert.template.Subject.CommonName)
|
||||||
|
|
||||||
|
cmd := &createUserCommand{
|
||||||
|
Username: username,
|
||||||
|
Roles: []interface{}{
|
||||||
|
mongodbRole{
|
||||||
|
Role: "readWrite",
|
||||||
|
DB: "test",
|
||||||
|
},
|
||||||
|
mongodbRole{
|
||||||
|
Role: "userAdminAnyDatabase",
|
||||||
|
DB: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := client.Database("$external").RunCommand(ctx, cmd)
|
||||||
|
err := result.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create x509 user in database: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type connStatus struct {
|
||||||
|
AuthInfo authInfo `bson:"authInfo"`
|
||||||
|
Ok int `bson:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authInfo struct {
|
||||||
|
AuthenticatedUsers []user `bson:"authenticatedUsers"`
|
||||||
|
AuthenticatedUserRoles roles `bson:"authenticatedUserRoles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type user struct {
|
||||||
|
User string `bson:"user"`
|
||||||
|
DB string `bson:"db"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type role struct {
|
||||||
|
Role string `bson:"role"`
|
||||||
|
DB string `bson:"db"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roles []role
|
||||||
|
|
||||||
|
func (r roles) Len() int { return len(r) }
|
||||||
|
func (r roles) Less(i, j int) bool { return r[i].Role < r[j].Role }
|
||||||
|
func (r roles) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
|
||||||
@@ -2,7 +2,10 @@ package mongodb
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -233,3 +236,100 @@ func testCreateDBUser(t testing.TB, connURL, db, username, password string) {
|
|||||||
t.Fatal(result.Err())
|
t.Fatal(result.Err())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTLSAuth(t *testing.T) {
|
||||||
|
ca := newCert(t,
|
||||||
|
commonName("certificate authority"),
|
||||||
|
isCA(true),
|
||||||
|
selfSign(),
|
||||||
|
)
|
||||||
|
cert := newCert(t,
|
||||||
|
commonName("test cert"),
|
||||||
|
parent(ca),
|
||||||
|
)
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
username string
|
||||||
|
tlsCAData []byte
|
||||||
|
tlsKeyData []byte
|
||||||
|
|
||||||
|
expectOpts *options.ClientOptions
|
||||||
|
expectErr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := map[string]testCase{
|
||||||
|
"no TLS data set": {
|
||||||
|
expectOpts: nil,
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
"bad CA": {
|
||||||
|
tlsCAData: []byte("foobar"),
|
||||||
|
|
||||||
|
expectOpts: nil,
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
"bad key": {
|
||||||
|
tlsKeyData: []byte("foobar"),
|
||||||
|
|
||||||
|
expectOpts: nil,
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
"good ca": {
|
||||||
|
tlsCAData: cert.pem,
|
||||||
|
|
||||||
|
expectOpts: options.Client().
|
||||||
|
SetTLSConfig(
|
||||||
|
&tls.Config{
|
||||||
|
RootCAs: appendToCertPool(t, x509.NewCertPool(), cert.pem),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
"good key": {
|
||||||
|
username: "unittest",
|
||||||
|
tlsKeyData: cert.CombinedPEM(),
|
||||||
|
|
||||||
|
expectOpts: options.Client().
|
||||||
|
SetTLSConfig(
|
||||||
|
&tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert.tlsCert},
|
||||||
|
},
|
||||||
|
).
|
||||||
|
SetAuth(options.Credential{
|
||||||
|
AuthMechanism: "MONGODB-X509",
|
||||||
|
Username: "unittest",
|
||||||
|
}),
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, test := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
c := new()
|
||||||
|
c.Username = test.username
|
||||||
|
c.TLSCAData = test.tlsCAData
|
||||||
|
c.TLSCertificateKeyData = test.tlsKeyData
|
||||||
|
|
||||||
|
actual, err := c.getTLSAuth()
|
||||||
|
if test.expectErr && err == nil {
|
||||||
|
t.Fatalf("err expected, got nil")
|
||||||
|
}
|
||||||
|
if !test.expectErr && err != nil {
|
||||||
|
t.Fatalf("no error expected, got: %s", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(actual, test.expectOpts) {
|
||||||
|
t.Fatalf("Actual:\n%#v\nExpected:\n%#v", actual, test.expectOpts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendToCertPool(t *testing.T, pool *x509.CertPool, caPem []byte) *x509.CertPool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ok := pool.AppendCertsFromPEM(caPem)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Unable to append cert to cert pool")
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "go.mongodb.org/mongo-driver/mongo/writeconcern"
|
|||||||
|
|
||||||
type createUserCommand struct {
|
type createUserCommand struct {
|
||||||
Username string `bson:"createUser"`
|
Username string `bson:"createUser"`
|
||||||
Password string `bson:"pwd"`
|
Password string `bson:"pwd,omitempty"`
|
||||||
Roles []interface{} `bson:"roles"`
|
Roles []interface{} `bson:"roles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ has a number of parameters to further configure a connection.
|
|||||||
map to the values in the [Safe][mgo-safe] struct from the mgo driver.
|
map to the values in the [Safe][mgo-safe] struct from the mgo driver.
|
||||||
- `username` `(string: "")` - The root credential username used in the connection URL.
|
- `username` `(string: "")` - The root credential username used in the connection URL.
|
||||||
- `password` `(string: "")` - The root credential password used in the connection URL.
|
- `password` `(string: "")` - The root credential password used in the connection URL.
|
||||||
|
- `tls_certificate_key` `(string: "")` - x509 certificate for connecting to the database.
|
||||||
|
This must be a PEM encoded version of the private key and the certificate combined.
|
||||||
|
- `tls_ca` `(string: "")` - x509 CA file for validating the certificate presented by the
|
||||||
|
MongoDB server. Must be PEM encoded.
|
||||||
|
|
||||||
|
|
||||||
### Sample Payload
|
### Sample Payload
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ more information about setting up the database secrets engine.
|
|||||||
$ vault write database/config/my-mongodb-database \
|
$ vault write database/config/my-mongodb-database \
|
||||||
plugin_name=mongodb-database-plugin \
|
plugin_name=mongodb-database-plugin \
|
||||||
allowed_roles="my-role" \
|
allowed_roles="my-role" \
|
||||||
connection_url="mongodb://{{username}}:{{password}}@mongodb.acme.com:27017/admin?ssl=true" \
|
connection_url="mongodb://{{username}}:{{password}}@mongodb.acme.com:27017/admin?tls=true" \
|
||||||
username="admin" \
|
username="admin" \
|
||||||
password="Password!"
|
password="Password!"
|
||||||
```
|
```
|
||||||
@@ -71,6 +71,27 @@ the proper permission, it can generate credentials.
|
|||||||
username v-root-e2978cd0-
|
username v-root-e2978cd0-
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Client x509 Certificate Authentication
|
||||||
|
|
||||||
|
This plugin supports using MongoDB's [x509 Client-side Certificate Authentication](https://docs.mongodb.com/manual/core/security-x.509/)
|
||||||
|
|
||||||
|
To use this authentication mechanism, configure the plugin:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ vault write database/config/my-mongodb-database \
|
||||||
|
plugin_name=mongodb-database-plugin \
|
||||||
|
allowed_roles="my-role" \
|
||||||
|
connection_url="mongodb://@mongodb.acme.com:27017/admin" \
|
||||||
|
tls_certificate_key=@/path/to/client.pem \
|
||||||
|
tls_ca=@/path/to/client.ca
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `tls_certificate_key` and `tls_ca` map to [`tlsCertificateKeyFile`](https://docs.mongodb.com/manual/reference/program/mongo/#cmdoption-mongo-tlscertificatekeyfile)
|
||||||
|
and [`tlsCAFile`](https://docs.mongodb.com/manual/reference/program/mongo/#cmdoption-mongo-tlscafile) configuration options
|
||||||
|
from MongoDB with the exception that the Vault parameters are the contents of those files, not filenames. As such,
|
||||||
|
the two options are independent of each other. See the [MongoDB Configuration Options](https://docs.mongodb.com/manual/reference/program/mongo/)
|
||||||
|
for more information.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
The full list of configurable options can be seen in the [MongoDB database
|
The full list of configurable options can be seen in the [MongoDB database
|
||||||
|
|||||||
Reference in New Issue
Block a user