Add root rotation for snowflake database secrets keypair configurations (#9432) (#9851)

* Initial implementation

* Use rotation_statements, handle both password and private_key

* Remove debug prints

* Merge in main

* Remove duplicated error text

* Rename keypair root rotation function

* Use NewRotateRootCredentialsWALPasswordEntry

* Add changelog file

* Move back to original file for now, for review

* put generatePassword into function

* Fix names, call helper for generatePassword

* Generalize the rotation flow and keypair path

* Fix conditional check, remove new file

* Fix changelog

* Add test file

* Fix username check var name

* Fix name variable

* Return an error when both fields are set during rotation, and return an error if somehow walEntry is nil

* Fix test godoc

* Remove print

* change rotated key bits to 4096

Co-authored-by: Robert <17119716+robmonte@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-10-03 17:34:42 -04:00 committed by GitHub
parent 45e3f36c28
commit 23fd7533aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 340 additions and 110 deletions

View file

@ -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

View file

@ -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

View file

@ -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,
},

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)

View file

@ -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

3
changelog/_9432.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
secrets/database: Add root rotation support for Snowflake database secrets engines using key-pair credentials.
```