From f96f4eebfc04b2a0751f20b61529f7ee90df1786 Mon Sep 17 00:00:00 2001 From: Michael Golowka <72365+pcman312@users.noreply.github.com> Date: Thu, 13 Feb 2020 15:54:00 -0700 Subject: [PATCH] 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 --- helper/builtinplugins/registry.go | 10 +- helper/testhelpers/mongodb/mongodbhelper.go | 6 +- plugins/database/mongodb/README.md | 12 + plugins/database/mongodb/cert_helpers_test.go | 242 ++++++++++++++ .../database/mongodb/connection_producer.go | 169 +++++++--- .../mongodb/connection_producer_test.go | 303 ++++++++++++++++++ plugins/database/mongodb/mongodb_test.go | 100 ++++++ plugins/database/mongodb/util.go | 2 +- .../api-docs/secret/databases/mongodb.mdx | 5 + .../pages/docs/secrets/databases/mongodb.mdx | 23 +- 10 files changed, 809 insertions(+), 63 deletions(-) create mode 100644 plugins/database/mongodb/README.md create mode 100644 plugins/database/mongodb/cert_helpers_test.go create mode 100644 plugins/database/mongodb/connection_producer_test.go diff --git a/helper/builtinplugins/registry.go b/helper/builtinplugins/registry.go index 5d3b51679f..9bf7719bd7 100644 --- a/helper/builtinplugins/registry.go +++ b/helper/builtinplugins/registry.go @@ -112,18 +112,18 @@ func newRegistry() *registry { "alicloud": logicalAlicloud.Factory, "aws": logicalAws.Factory, "azure": logicalAzure.Factory, - "cassandra": logicalCass.Factory, + "cassandra": logicalCass.Factory, // Deprecated "consul": logicalConsul.Factory, "gcp": logicalGcp.Factory, "gcpkms": logicalGcpKms.Factory, "kv": logicalKv.Factory, - "mongodb": logicalMongo.Factory, + "mongodb": logicalMongo.Factory, // Deprecated "mongodbatlas": logicalMongoAtlas.Factory, - "mssql": logicalMssql.Factory, - "mysql": logicalMysql.Factory, + "mssql": logicalMssql.Factory, // Deprecated + "mysql": logicalMysql.Factory, // Deprecated "nomad": logicalNomad.Factory, "pki": logicalPki.Factory, - "postgresql": logicalPostgres.Factory, + "postgresql": logicalPostgres.Factory, // Deprecated "rabbitmq": logicalRabbit.Factory, "ssh": logicalSsh.Factory, "totp": logicalTotp.Factory, diff --git a/helper/testhelpers/mongodb/mongodbhelper.go b/helper/testhelpers/mongodb/mongodbhelper.go index 681a9fb30c..48ab47552c 100644 --- a/helper/testhelpers/mongodb/mongodbhelper.go +++ b/helper/testhelpers/mongodb/mongodbhelper.go @@ -54,7 +54,7 @@ func PrepareTestContainerWithDatabase(t *testing.T, version, dbName string) (cle // exponential backoff-retry if err = pool.Retry(func() error { var err error - dialInfo, err := parseMongoURL(retURL) + dialInfo, err := ParseMongoURL(retURL) if err != nil { return err } @@ -75,8 +75,8 @@ func PrepareTestContainerWithDatabase(t *testing.T, version, dbName string) (cle return } -// parseMongoURL will parse a connection string and return a configured dialer -func parseMongoURL(rawURL string) (*mgo.DialInfo, error) { +// ParseMongoURL will parse a connection string and return a configured dialer +func ParseMongoURL(rawURL string) (*mgo.DialInfo, error) { url, err := url.Parse(rawURL) if err != nil { return nil, err diff --git a/plugins/database/mongodb/README.md b/plugins/database/mongodb/README.md new file mode 100644 index 0000000000..cd252ca4c0 --- /dev/null +++ b/plugins/database/mongodb/README.md @@ -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. diff --git a/plugins/database/mongodb/cert_helpers_test.go b/plugins/database/mongodb/cert_helpers_test.go new file mode 100644 index 0000000000..200f997c3a --- /dev/null +++ b/plugins/database/mongodb/cert_helpers_test.go @@ -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"), ".", "") +} diff --git a/plugins/database/mongodb/connection_producer.go b/plugins/database/mongodb/connection_producer.go index 577863a2fe..bb0d8aa7a2 100644 --- a/plugins/database/mongodb/connection_producer.go +++ b/plugins/database/mongodb/connection_producer.go @@ -2,6 +2,8 @@ package mongodb import ( "context" + "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/json" "fmt" @@ -23,8 +25,12 @@ import ( type mongoDBConnectionProducer struct { ConnectionURL string `json:"connection_url" structs:"connection_url" mapstructure:"connection_url"` 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 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") } - c.ConnectionURL = dbutil.QueryHelper(c.ConnectionURL, map[string]string{ - "username": c.Username, - "password": c.Password, - }) - - 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, - } + writeOpts, err := c.getWriteConcern() + if err != nil { + return nil, err } + 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, // and the connection can be established at a later time. c.Initialized = true @@ -157,7 +124,8 @@ func (c *mongoDBConnectionProducer) Connection(ctx context.Context) (interface{} c.clientOptions.SetConnectTimeout(1 * time.Minute) 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 { return nil, err } @@ -187,3 +155,98 @@ func (c *mongoDBConnectionProducer) secretValues() map[string]interface{} { 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 +} diff --git a/plugins/database/mongodb/connection_producer_test.go b/plugins/database/mongodb/connection_producer_test.go new file mode 100644 index 0000000000..087d556891 --- /dev/null +++ b/plugins/database/mongodb/connection_producer_test.go @@ -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] } diff --git a/plugins/database/mongodb/mongodb_test.go b/plugins/database/mongodb/mongodb_test.go index 7cafef10e6..1fe93cce39 100644 --- a/plugins/database/mongodb/mongodb_test.go +++ b/plugins/database/mongodb/mongodb_test.go @@ -2,7 +2,10 @@ package mongodb import ( "context" + "crypto/tls" + "crypto/x509" "fmt" + "reflect" "strings" "testing" "time" @@ -233,3 +236,100 @@ func testCreateDBUser(t testing.TB, connURL, db, username, password string) { 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 +} diff --git a/plugins/database/mongodb/util.go b/plugins/database/mongodb/util.go index a53d3513b9..a12828f503 100644 --- a/plugins/database/mongodb/util.go +++ b/plugins/database/mongodb/util.go @@ -4,7 +4,7 @@ import "go.mongodb.org/mongo-driver/mongo/writeconcern" type createUserCommand struct { Username string `bson:"createUser"` - Password string `bson:"pwd"` + Password string `bson:"pwd,omitempty"` Roles []interface{} `bson:"roles"` } diff --git a/website/pages/api-docs/secret/databases/mongodb.mdx b/website/pages/api-docs/secret/databases/mongodb.mdx index a540133c6f..568c85a75c 100644 --- a/website/pages/api-docs/secret/databases/mongodb.mdx +++ b/website/pages/api-docs/secret/databases/mongodb.mdx @@ -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. - `username` `(string: "")` - The root credential username 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 diff --git a/website/pages/docs/secrets/databases/mongodb.mdx b/website/pages/docs/secrets/databases/mongodb.mdx index 82f8e84d89..ba1d939c0d 100644 --- a/website/pages/docs/secrets/databases/mongodb.mdx +++ b/website/pages/docs/secrets/databases/mongodb.mdx @@ -35,7 +35,7 @@ more information about setting up the database secrets engine. $ vault write database/config/my-mongodb-database \ plugin_name=mongodb-database-plugin \ 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" \ password="Password!" ``` @@ -71,6 +71,27 @@ the proper permission, it can generate credentials. 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 The full list of configurable options can be seen in the [MongoDB database