Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 152 additions & 11 deletions plugins/database/mongodb/mongodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"

log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-secure-stdlib/strutil"
dbplugin "github.com/hashicorp/vault/sdk/database/dbplugin/v5"
"github.com/hashicorp/vault/sdk/database/helper/dbutil"
"github.com/hashicorp/vault/sdk/helper/template"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/readpref"
"go.mongodb.org/mongo-driver/mongo/writeconcern"
Expand Down Expand Up @@ -156,38 +158,177 @@ func (m *MongoDB) NewUser(ctx context.Context, req dbplugin.NewUserRequest) (dbp

func (m *MongoDB) UpdateUser(ctx context.Context, req dbplugin.UpdateUserRequest) (dbplugin.UpdateUserResponse, error) {
if req.Password != nil {
err := m.changeUserPassword(ctx, req.Username, req.Password.NewPassword)
err := m.changeUserPassword(ctx, req.Username, req.Password.NewPassword, req.Password.Statements.Commands)
return dbplugin.UpdateUserResponse{}, err
}
return dbplugin.UpdateUserResponse{}, nil
}

func (m *MongoDB) changeUserPassword(ctx context.Context, username, password string) error {
func (m *MongoDB) changeUserPassword(ctx context.Context, username, password string, rotateStatements []string) error {
connURL := m.getConnectionURL()
cs, err := connstring.Parse(connURL)
if err != nil {
return err
}

// Currently doesn't support custom statements for changing the user's password
changeUserCmd := &updateUserCommand{
Username: username,
Password: password,
}

database := cs.Database
if database == "" {
database = "admin"
}

err = m.runCommandWithRetry(ctx, database, changeUserCmd)
if err != nil {
return err
if len(rotateStatements) == 0 {
changeUserCmd := &updateUserCommand{
Username: username,
Password: password,
}

if err := m.runCommandWithRetry(ctx, database, changeUserCmd); err != nil {
return err
}
return nil
}

queryMap := map[string]string{
"name": username,
"username": username,
"password": password,
}

for i, stmt := range rotateStatements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}

stmt = dbutil.QueryHelper(stmt, queryMap)

var rotationStmt mongoDBRotationStatement
if err := json.Unmarshal([]byte(stmt), &rotationStmt); err != nil {
return fmt.Errorf("failed to parse rotation statement %d as JSON: %w", i, err)
}

if err := m.executeRotationStatement(ctx, i, database, rotationStmt); err != nil {
return err
}
}

return nil
}

func (m *MongoDB) executeRotationStatement(ctx context.Context, idx int, defaultDB string, rotationStmt mongoDBRotationStatement) error {
db := rotationStmt.DB
if db == "" {
db = defaultDB
}
if db == "" {
db = "admin"
}

if len(rotationStmt.Command) == 0 {
return fmt.Errorf("rotation statement %d must contain a non-empty command object", idx)
}

var cmd bson.D
if err := bson.UnmarshalExtJSON(rotationStmt.Command, true, &cmd); err != nil {
return fmt.Errorf("rotation statement %d has invalid command JSON: %w", idx, err)
}
if len(cmd) == 0 {
return fmt.Errorf("rotation statement %d must contain a non-empty command object", idx)
}

if err := m.runCommandWithRetry(ctx, db, cmd); err != nil {
if shouldIgnoreRotationError(err, rotationStmt.IgnoreErrors) {
return nil
}
return fmt.Errorf("rotation statement %d failed: %w", idx, err)
}
return nil
}

func shouldIgnoreRotationError(err error, ignoreErrors []string) bool {
if err == nil || len(ignoreErrors) == 0 {
return false
}

// Allow ergonomic ignore tokens that map to well-known codes.
// This avoids forcing users to configure numeric codes like "51003".
wellKnownErrorCodes := map[string][]int32{
// createUser: "User \"...\" already exists"
"UserAlreadyExists": {51003},
// Common duplicate key codes
"DuplicateKey": {11000, 11001},
}

cErr, ok := err.(mongo.CommandError)
if ok {
errText := strings.ToLower(err.Error())
msgText := strings.ToLower(cErr.Message)

for _, token := range ignoreErrors {
token = strings.TrimSpace(token)
if token == "" {
continue
}
// Match by command error name, when available.
if cErr.Name != "" && cErr.Name == token {
return true
}
// Also allow matching by numeric error code, e.g. "51003".
if code, convErr := strconv.Atoi(token); convErr == nil && int32(code) == cErr.Code {
return true
}
// Allow matching by well-known symbolic token -> code mapping.
if codes, ok := wellKnownErrorCodes[token]; ok {
for _, c := range codes {
if cErr.Code == c {
return true
}
}
}
// As a last resort, allow ignoring by message semantics for common cases.
// Some server errors expose a code but no stable "Name".
switch token {
case "UserAlreadyExists":
if strings.Contains(msgText, "already exists") || strings.Contains(errText, "already exists") {
return true
}
}
}
return false
}

// Some drivers may return a WriteException; allow matching by write error codes too.
if wErr, ok := err.(mongo.WriteException); ok {
for _, token := range ignoreErrors {
token = strings.TrimSpace(token)
if token == "" {
continue
}
code, convErr := strconv.Atoi(token)
if convErr != nil {
// Also allow well-known symbolic token -> code mapping.
if codes, ok := wellKnownErrorCodes[token]; ok {
for _, c := range codes {
for _, we := range wErr.WriteErrors {
if int32(we.Code) == c {
return true
}
}
}
}
continue
}
for _, we := range wErr.WriteErrors {
if int32(we.Code) == int32(code) {
return true
}
}
}
}

return false
}

func (m *MongoDB) DeleteUser(ctx context.Context, req dbplugin.DeleteUserRequest) (dbplugin.DeleteUserResponse, error) {
// If no revocation statements provided, pass in empty JSON
var revocationStatement string
Expand Down
73 changes: 73 additions & 0 deletions plugins/database/mongodb/mongodb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,79 @@ func TestMongoDB_UpdateUser_Password(t *testing.T) {
assertCredsExist(t, dbUser, newPassword, connURL)
}

func TestMongoDB_UpdateUser_Password_RotationStatements_UpdateUser(t *testing.T) {
cleanup, connURL := mongodb.PrepareTestContainer(t, "latest")
defer cleanup()

connURL = connURL + "/test"
db := new()
defer dbtesting.AssertClose(t, db)

initReq := dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
},
VerifyConnection: true,
}
dbtesting.AssertInitialize(t, db, initReq)

dbUser := "testmongouser"
startingPassword := "password"
createDBUser(t, connURL, "test", dbUser, startingPassword)

newPassword := "myreallysecurecredentials"

updateReq := dbplugin.UpdateUserRequest{
Username: dbUser,
Password: &dbplugin.ChangePassword{
NewPassword: newPassword,
Statements: dbplugin.Statements{
Commands: []string{
`{"db":"admin","command":{"updateUser":"{{username}}","pwd":"{{password}}"}}`,
},
},
},
}
dbtesting.AssertUpdateUser(t, db, updateReq)

assertCredsExist(t, dbUser, newPassword, connURL)
}

func TestMongoDB_UpdateUser_Password_RotationStatements_CreateUserIfMissing(t *testing.T) {
cleanup, connURL := mongodb.PrepareTestContainer(t, "latest")
defer cleanup()

// Use admin as auth DB (no /test), to match provided statement db:"admin"
db := new()
defer dbtesting.AssertClose(t, db)

initReq := dbplugin.InitializeRequest{
Config: map[string]interface{}{
"connection_url": connURL,
},
VerifyConnection: true,
}
dbtesting.AssertInitialize(t, db, initReq)

dbUser := "user-does-not-exist-yet"
newPassword := "myreallysecurecredentials"

updateReq := dbplugin.UpdateUserRequest{
Username: dbUser,
Password: &dbplugin.ChangePassword{
NewPassword: newPassword,
Statements: dbplugin.Statements{
Commands: []string{
`{"db":"admin","command":{"createUser":"{{username}}","pwd":"{{password}}","roles":[{"role":"readWrite","db":"app"}]}}`,
},
},
},
}
dbtesting.AssertUpdateUser(t, db, updateReq)

assertCredsExist(t, dbUser, newPassword, connURL)
}

func TestMongoDB_RotateRoot_NonAdminDB(t *testing.T) {
cleanup, connURL := mongodb.PrepareTestContainer(t, "latest")
defer cleanup()
Expand Down
54 changes: 54 additions & 0 deletions plugins/database/mongodb/rotation_ignore_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1

package mongodb

import (
"testing"

"go.mongodb.org/mongo-driver/mongo"
)

func TestShouldIgnoreRotationError(t *testing.T) {
tests := map[string]struct {
err error
ignore []string
expectIgnore bool
}{
"nil error": {
err: nil,
ignore: []string{"UserNotFound"},
expectIgnore: false,
},
"non-command error": {
err: mongo.ErrClientDisconnected,
ignore: []string{"UserNotFound"},
expectIgnore: false,
},
"command error name matches": {
err: mongo.CommandError{Name: "UserNotFound"},
ignore: []string{"UserNotFound"},
expectIgnore: true,
},
"command error name does not match": {
err: mongo.CommandError{Name: "UserNotFound"},
ignore: []string{"DuplicateKey"},
expectIgnore: false,
},
"ignore list contains blanks": {
err: mongo.CommandError{Name: "UserAlreadyExists"},
ignore: []string{"", "UserAlreadyExists"},
expectIgnore: true,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := shouldIgnoreRotationError(tc.err, tc.ignore)
if got != tc.expectIgnore {
t.Fatalf("got %v, expected %v", got, tc.expectIgnore)
}
})
}
}

12 changes: 11 additions & 1 deletion plugins/database/mongodb/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

package mongodb

import "go.mongodb.org/mongo-driver/mongo/writeconcern"
import (
"encoding/json"

"go.mongodb.org/mongo-driver/mongo/writeconcern"
)

type createUserCommand struct {
Username string `bson:"createUser"`
Expand Down Expand Up @@ -33,6 +37,12 @@ type mongoDBStatement struct {
Roles mongodbRoles `json:"roles"`
}

type mongoDBRotationStatement struct {
DB string `json:"db"`
Command json.RawMessage `json:"command"`
IgnoreErrors []string `json:"ignore_errors,omitempty"`
}

// Convert array of role documents like:
//
// [ { "role": "readWrite" }, { "role": "readWrite", "db": "test" } ]
Expand Down