diff --git a/builtin/logical/database/backend.go b/builtin/logical/database/backend.go index 3da9f44c6e..cb7e646792 100644 --- a/builtin/logical/database/backend.go +++ b/builtin/logical/database/backend.go @@ -131,14 +131,7 @@ func Backend(conf *logical.BackendConfig) *databaseBackend { WALRollback: b.walRollback, WALRollbackMinAge: minRootCredRollbackAge, BackendType: logical.TypeLogical, - RotateCredential: func(ctx context.Context, request *logical.Request) error { - name, err := b.getDatabaseConfigNameFromRotationID(request.RotationID) - if err != nil { - return err - } - _, err = b.rotateRootCredentials(ctx, request, name) - return err - }, + RotateCredential: b.rotateRootCredential, } b.logger = conf.Logger diff --git a/builtin/logical/database/path_creds_create.go b/builtin/logical/database/path_creds_create.go index e61d47a37c..e9b2812106 100644 --- a/builtin/logical/database/path_creds_create.go +++ b/builtin/logical/database/path_creds_create.go @@ -156,21 +156,9 @@ func (b *databaseBackend) pathCredsCreateRead() framework.OperationFunc { // Generate the credential based on the role's credential type switch role.CredentialType { case v5.CredentialTypePassword: - generator, err := newPasswordGenerator(role.CredentialConfig) + password, err := b.generateNewPassword(ctx, role.CredentialConfig, dbConfig.PasswordPolicy, dbi) if err != nil { - return nil, fmt.Errorf("failed to construct credential generator: %s", err) - } - - // Fall back to database config-level password policy if not set on role - if generator.PasswordPolicy == "" { - generator.PasswordPolicy = dbConfig.PasswordPolicy - } - - // Generate the password - password, err := generator.generate(ctx, b, dbi.database) - if err != nil { - b.CloseIfShutdown(dbi, err) - return nil, fmt.Errorf("failed to generate password: %s", err) + return nil, err } // Set input credential @@ -178,15 +166,9 @@ func (b *databaseBackend) pathCredsCreateRead() framework.OperationFunc { newUserReq.Password = password case v5.CredentialTypeRSAPrivateKey: - generator, err := newRSAKeyGenerator(role.CredentialConfig) + public, private, err := b.generateNewKeypair(role.CredentialConfig) if err != nil { - return nil, fmt.Errorf("failed to construct credential generator: %s", err) - } - - // Generate the RSA key pair - public, private, err := generator.generate(b.GetRandomReader()) - if err != nil { - return nil, fmt.Errorf("failed to generate RSA key pair: %s", err) + return nil, err } // Set input credential diff --git a/builtin/logical/database/path_roles_test.go b/builtin/logical/database/path_roles_test.go index f3cf01ae8d..5a0fcbe627 100644 --- a/builtin/logical/database/path_roles_test.go +++ b/builtin/logical/database/path_roles_test.go @@ -173,7 +173,7 @@ func TestBackend_Roles_CredentialTypes(t *testing.T) { Storage: config.StorageView, Data: map[string]interface{}{ "db_name": "test-database", - "creation_statements": "CREATE USER {{name}}", + "creation_statements": `CREATE USER "{{name}}"`, "credential_type": tt.args.credentialType.String(), "credential_config": tt.args.credentialConfig, }, diff --git a/builtin/logical/database/path_rotate_credentials.go b/builtin/logical/database/path_rotate_credentials.go index 1aeb122d31..17708abfa9 100644 --- a/builtin/logical/database/path_rotate_credentials.go +++ b/builtin/logical/database/path_rotate_credentials.go @@ -74,10 +74,21 @@ func pathRotateRootCredentials(b *databaseBackend) []*framework.Path { } } +func (b *databaseBackend) rotateRootCredential(ctx context.Context, req *logical.Request) error { + name, err := b.getDatabaseConfigNameFromRotationID(req.RotationID) + if err != nil { + return err + } + + _, err = b.performRootRotation(ctx, req, name) + + return err +} + func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationFunc { return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (resp *logical.Response, err error) { name := data.Get("name").(string) - resp, err = b.rotateRootCredentials(ctx, req, name) + resp, err = b.performRootRotation(ctx, req, name) if err != nil { b.Logger().Error("failed to rotate root credential on user request", "path", req.Path, "error", err.Error()) } else { @@ -87,7 +98,7 @@ func (b *databaseBackend) pathRotateRootCredentialsUpdate() framework.OperationF } } -func (b *databaseBackend) rotateRootCredentials(ctx context.Context, req *logical.Request, name string) (resp *logical.Response, err error) { +func (b *databaseBackend) performRootRotation(ctx context.Context, req *logical.Request, name string) (resp *logical.Response, err error) { if name == "" { return logical.ErrorResponse(respErrEmptyName), nil } @@ -118,21 +129,37 @@ func (b *databaseBackend) rotateRootCredentials(ctx context.Context, req *logica } }() - rootUsername, ok := config.ConnectionDetails["username"].(string) - if !ok || rootUsername == "" { + rootUsername, userOk := config.ConnectionDetails["username"].(string) + if !userOk || rootUsername == "" { return nil, fmt.Errorf("unable to rotate root credentials: no username in configuration") } - rootPassword, ok := config.ConnectionDetails["password"].(string) - if !ok || rootPassword == "" { - return nil, fmt.Errorf("unable to rotate root credentials: no password in configuration") - } - dbi, err := b.GetConnection(ctx, req.Storage, name) if err != nil { return nil, err } + dbType, err := dbi.database.Type() + if err != nil { + return nil, fmt.Errorf("unable to determine database type: %w", err) + } + + rootPassword, passOk := config.ConnectionDetails["password"].(string) + isPasswordSet := passOk && rootPassword != "" + + rootPrivateKey, pkeyOk := config.ConnectionDetails["private_key"].(string) + isPrivateKeySet := pkeyOk && rootPrivateKey != "" + + // If both are unset, return an error. If we get past this, we know at least one is set. + if !isPasswordSet && !isPrivateKeySet { + return nil, fmt.Errorf("unable to rotate root credentials: both private_key and password fields are missing from the configuration") + } + + // If both are set, return an error. + if isPasswordSet && isPrivateKeySet { + return nil, fmt.Errorf("unable to rotate root credentials: both private_key and password fields are set in the configuration") + } + // Take the write lock on the instance dbi.Lock() defer func() { @@ -148,42 +175,67 @@ func (b *databaseBackend) rotateRootCredentials(ctx context.Context, req *logica } }() - generator, err := newPasswordGenerator(nil) - if err != nil { - return nil, fmt.Errorf("failed to construct credential generator: %s", err) - } - generator.PasswordPolicy = config.PasswordPolicy + var walEntry *rotateRootCredentialsWAL + var updateReq v5.UpdateUserRequest + // If private key is set, use it. This takes precedence over password. + if isPrivateKeySet { + // For now snowflake is the only database type to support private key rotation. + if dbType == "snowflake" { + newKeypairCredentialConfig := map[string]interface{}{ + "format": "pkcs8", + "key_bits": 4096, + } + newPublicKey, newPrivateKey, err := b.generateNewKeypair(newKeypairCredentialConfig) + if err != nil { + return nil, err + } + config.ConnectionDetails["private_key"] = string(newPrivateKey) - // Generate new credentials - oldPassword := config.ConnectionDetails["password"].(string) - newPassword, err := generator.generate(ctx, b, dbi.database) - if err != nil { - b.CloseIfShutdown(dbi, err) - return nil, fmt.Errorf("failed to generate password: %s", err) + oldPrivateKey := config.ConnectionDetails["private_key"].(string) + walEntry = NewRotateRootCredentialsWALPrivateKeyEntry(name, rootUsername, string(newPublicKey), string(newPrivateKey), oldPrivateKey) + updateReq = v5.UpdateUserRequest{ + Username: rootUsername, + CredentialType: v5.CredentialTypeRSAPrivateKey, + PublicKey: &v5.ChangePublicKey{ + NewPublicKey: newPublicKey, + Statements: v5.Statements{ + Commands: config.RootCredentialsRotateStatements, + }, + }, + } + } + } else { + // Private key isn't set so we're using the password field. + newPassword, err := b.generateNewPassword(ctx, nil, config.PasswordPolicy, dbi) + if err != nil { + return nil, err + } + config.ConnectionDetails["password"] = newPassword + + oldPassword := config.ConnectionDetails["password"].(string) + walEntry = NewRotateRootCredentialsWALPasswordEntry(name, rootUsername, newPassword, oldPassword) + updateReq = v5.UpdateUserRequest{ + Username: rootUsername, + CredentialType: v5.CredentialTypePassword, + Password: &v5.ChangePassword{ + NewPassword: newPassword, + Statements: v5.Statements{ + Commands: config.RootCredentialsRotateStatements, + }, + }, + } + } + + if walEntry == nil { + return nil, fmt.Errorf("unable to rotate root credentials: no valid credential type found") } - config.ConnectionDetails["password"] = newPassword // Write a WAL entry - walID, err := framework.PutWAL(ctx, req.Storage, rotateRootWALKey, &rotateRootCredentialsWAL{ - ConnectionName: name, - UserName: rootUsername, - OldPassword: oldPassword, - NewPassword: newPassword, - }) + walID, err := framework.PutWAL(ctx, req.Storage, rotateRootWALKey, walEntry) if err != nil { return nil, err } - updateReq := v5.UpdateUserRequest{ - Username: rootUsername, - CredentialType: v5.CredentialTypePassword, - Password: &v5.ChangePassword{ - NewPassword: newPassword, - Statements: v5.Statements{ - Commands: config.RootCredentialsRotateStatements, - }, - }, - } newConfigDetails, err := dbi.database.UpdateUser(ctx, updateReq, true) if err != nil { return nil, fmt.Errorf("failed to update user: %w", err) diff --git a/builtin/logical/database/path_rotate_credentials_test.go b/builtin/logical/database/path_rotate_credentials_test.go new file mode 100644 index 0000000000..1b08b47cc6 --- /dev/null +++ b/builtin/logical/database/path_rotate_credentials_test.go @@ -0,0 +1,171 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package database + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/hashicorp/vault/helper/namespace" + postgreshelper "github.com/hashicorp/vault/helper/testhelpers/postgresql" + "github.com/hashicorp/vault/sdk/logical" +) + +// TestRotateRootPassword_Postgres tests database secrets root rotation for password credentials with Postgres. +func TestRotateRootPassword_Postgres(t *testing.T) { + cleanup, connURL := postgreshelper.PrepareTestContainer(t) + defer cleanup() + + cluster, sys := getClusterWithFactory(t, Factory) + defer cluster.Cleanup() + + connURL = strings.Replace(connURL, "postgres:secret", "{{username}}:{{password}}", 1) + + testRotateRoot(t, sys, true, connURL, "postgresql-database-plugin", "postgres", "secret") +} + +// TestRotateRootKeypair_Snowflake_Acc tests database secrets root rotation for private key credentials with Snowflake. +func TestRotateRootKeypair_Snowflake_Acc(t *testing.T) { + // SNOWFLAKE_PRIVATE_KEY is the path to the private key file. + privateKeyPath, ok := os.LookupEnv("VAULT_SNOWFLAKE_PRIVATE_KEY") + if !ok { + t.Skip("VAULT_SNOWFLAKE_PRIVATE_KEY not set, skipping test") + } + + keyFile, err := os.ReadFile(privateKeyPath) + if err != nil { + t.Fatalf("failed to read private key file: %s", err) + } + + connURL, ok := os.LookupEnv("VAULT_SNOWFLAKE_CONNECTION_URL") + if !ok { + t.Skip("VAULT_SNOWFLAKE_CONNECTION_URL not set, skipping test") + } + + username, ok := os.LookupEnv("VAULT_SNOWFLAKE_USERNAME") + if !ok { + t.Skip("VAULT_SNOWFLAKE_USERNAME not set, skipping test") + } + + cluster, sys := getClusterWithFactory(t, Factory) + defer cluster.Cleanup() + + testRotateRoot(t, sys, false, connURL, "snowflake-database-plugin", username, string(keyFile)) +} + +// Helper function to run rotate root tests. +func testRotateRoot(t *testing.T, sys logical.SystemView, isPassword bool, connURL string, pluginName, username, credential string) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = sys + + lb, err := Factory(context.Background(), config) + if err != nil { + t.Fatal(err) + } + b, ok := lb.(*databaseBackend) + if !ok { + t.Fatal("could not convert to db backend") + } + defer b.Cleanup(context.Background()) + + // Configure a connection + configData := map[string]any{ + "connection_url": connURL, + "plugin_name": pluginName, + "verify_connection": false, + "allowed_roles": []string{"*"}, + "name": "plugin-test", + "username": username, + } + if isPassword { + configData["password"] = credential + } else { + configData["private_key"] = credential + } + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/plugin-test", + Storage: config.StorageView, + Data: configData, + } + resp, err := b.HandleRequest(namespace.RootContext(context.Background()), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Create a dynamic role + req = &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/test", + Storage: config.StorageView, + Data: map[string]interface{}{ + "db_name": "plugin-test", + "creation_statements": `CREATE USER "{{name}}"`, + }, + } + resp, err = b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Read some creds to validate the connection + req = &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/test", + Storage: config.StorageView, + } + resp, err = b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Rotate the root credentials + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "rotate-root/plugin-test", + Storage: config.StorageView, + } + resp, err = b.HandleRequest(namespace.RootContext(context.Background()), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Read creds a second time to validate the root rotation still works + req = &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/test", + Storage: config.StorageView, + } + resp, err = b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Write back the original credential to ensure it no longer works + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/plugin-test", + Storage: config.StorageView, + Data: configData, + } + resp, err = b.HandleRequest(namespace.RootContext(context.Background()), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Read some more creds again and expect an error] + // Note: For Snowflake, this step may fail if you are using the account's 2nd private key + req = &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/test", + Storage: config.StorageView, + } + resp, err = b.HandleRequest(context.Background(), req) + if err == nil || (resp != nil && !resp.IsError()) { + t.Fatalf("expected authentication error but did not receive an error, resp:%#v\n", resp) + } +} diff --git a/builtin/logical/database/rollback.go b/builtin/logical/database/rollback.go index 6e1b1dc484..0e5084eab5 100644 --- a/builtin/logical/database/rollback.go +++ b/builtin/logical/database/rollback.go @@ -22,8 +22,32 @@ const rotateRootWALKey = "rotateRootWALKey" type rotateRootCredentialsWAL struct { ConnectionName string UserName string - NewPassword string - OldPassword string + + NewPassword string + OldPassword string + + NewPublicKey string + NewPrivateKey string + OldPrivateKey string +} + +func NewRotateRootCredentialsWALPasswordEntry(connectionName, userName, newPassword, oldPassword string) *rotateRootCredentialsWAL { + return &rotateRootCredentialsWAL{ + ConnectionName: connectionName, + UserName: userName, + NewPassword: newPassword, + OldPassword: oldPassword, + } +} + +func NewRotateRootCredentialsWALPrivateKeyEntry(connectionName, userName, newPublicKey, newPrivateKey, oldPrivateKey string) *rotateRootCredentialsWAL { + return &rotateRootCredentialsWAL{ + ConnectionName: connectionName, + UserName: userName, + NewPublicKey: newPublicKey, + NewPrivateKey: newPrivateKey, + OldPrivateKey: oldPrivateKey, + } } // walRollback handles WAL entries that result from partial failures diff --git a/builtin/logical/database/rollback_test.go b/builtin/logical/database/rollback_test.go index 47c7683742..8e533aca9b 100644 --- a/builtin/logical/database/rollback_test.go +++ b/builtin/logical/database/rollback_test.go @@ -129,12 +129,7 @@ func TestBackend_RotateRootCredentials_WAL_rollback(t *testing.T) { } // Put a WAL entry that will be used for rolling back the database password - walEntry := &rotateRootCredentialsWAL{ - ConnectionName: "plugin-test", - UserName: databaseUser, - OldPassword: defaultPassword, - NewPassword: "newSecret", - } + walEntry := NewRotateRootCredentialsWALPasswordEntry("plugin-test", databaseUser, "newSecret", defaultPassword) _, err = framework.PutWAL(context.Background(), config.StorageView, rotateRootWALKey, walEntry) if err != nil { t.Fatal(err) @@ -235,12 +230,7 @@ func TestBackend_RotateRootCredentials_WAL_no_rollback_1(t *testing.T) { } // Put a WAL entry - walEntry := &rotateRootCredentialsWAL{ - ConnectionName: "plugin-test", - UserName: databaseUser, - OldPassword: defaultPassword, - NewPassword: "newSecret", - } + walEntry := NewRotateRootCredentialsWALPasswordEntry("plugin-test", databaseUser, "newSecret", defaultPassword) _, err = framework.PutWAL(context.Background(), config.StorageView, rotateRootWALKey, walEntry) if err != nil { t.Fatal(err) @@ -391,12 +381,7 @@ func TestBackend_RotateRootCredentials_WAL_no_rollback_2(t *testing.T) { } // Put a WAL entry - walEntry := &rotateRootCredentialsWAL{ - ConnectionName: "plugin-test", - UserName: databaseUser, - OldPassword: defaultPassword, - NewPassword: "newSecret", - } + walEntry := NewRotateRootCredentialsWALPasswordEntry("plugin-test", databaseUser, "newSecret", defaultPassword) _, err = framework.PutWAL(context.Background(), config.StorageView, rotateRootWALKey, walEntry) if err != nil { t.Fatal(err) diff --git a/builtin/logical/database/rotation.go b/builtin/logical/database/rotation.go index bd2236a1ef..c5b717cd0d 100644 --- a/builtin/logical/database/rotation.go +++ b/builtin/logical/database/rotation.go @@ -498,21 +498,9 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag switch input.Role.CredentialType { case v5.CredentialTypePassword: - generator, err := newPasswordGenerator(input.Role.CredentialConfig) + newPassword, err := b.generateNewPassword(ctx, input.Role.CredentialConfig, dbConfig.PasswordPolicy, dbi) if err != nil { - return output, fmt.Errorf("failed to construct credential generator: %s", err) - } - - // Fall back to database config-level password policy if not set on role - if generator.PasswordPolicy == "" { - generator.PasswordPolicy = dbConfig.PasswordPolicy - } - - // Generate the password - newPassword, err := generator.generate(ctx, b, dbi.database) - if err != nil { - b.CloseIfShutdown(dbi, err) - return output, fmt.Errorf("failed to generate password: %s", err) + return output, err } // Set new credential in WAL entry and update user request @@ -526,15 +514,9 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag // Set new credential in static account input.Role.StaticAccount.Password = newPassword case v5.CredentialTypeRSAPrivateKey: - generator, err := newRSAKeyGenerator(input.Role.CredentialConfig) + public, private, err := b.generateNewKeypair(input.Role.CredentialConfig) if err != nil { - return output, fmt.Errorf("failed to construct credential generator: %s", err) - } - - // Generate the RSA key pair - public, private, err := generator.generate(b.GetRandomReader()) - if err != nil { - return output, fmt.Errorf("failed to generate RSA key pair: %s", err) + return output, err } // Set new credential in WAL entry and update user request @@ -600,6 +582,44 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag return &setStaticAccountOutput{RotationTime: lvr}, nil } +// Returns a new password, error. +func (b *databaseBackend) generateNewPassword(ctx context.Context, credentialConfig map[string]interface{}, passwordPolicy string, dbi *dbPluginInstance) (string, error) { + generator, err := newPasswordGenerator(credentialConfig) + if err != nil { + return "", fmt.Errorf("failed to construct credential generator: %s", err) + } + + // Fall back to database config-level password policy if not set on role + if generator.PasswordPolicy == "" { + generator.PasswordPolicy = passwordPolicy + } + + // Generate the password + newPassword, err := generator.generate(ctx, b, dbi.database) + if err != nil { + b.CloseIfShutdown(dbi, err) + return "", fmt.Errorf("failed to generate password: %s", err) + } + + return newPassword, nil +} + +// Returns a new public key, private key, error. +func (b *databaseBackend) generateNewKeypair(credentialConfig map[string]interface{}) ([]byte, []byte, error) { + generator, err := newRSAKeyGenerator(credentialConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to construct credential generator: %s", err) + } + + // Generate the RSA key pair + public, private, err := generator.generate(b.GetRandomReader()) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate RSA key pair: %s", err) + } + + return public, private, nil +} + // initQueue preforms the necessary checks and initializations needed to perform // automatic credential rotation for roles associated with static accounts. This // method verifies if a queue is needed (primary server or local mount), and if diff --git a/changelog/_9432.txt b/changelog/_9432.txt new file mode 100644 index 0000000000..5f3a57cf4d --- /dev/null +++ b/changelog/_9432.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/database: Add root rotation support for Snowflake database secrets engines using key-pair credentials. +```