diff --git a/builtin/logical/database/backend.go b/builtin/logical/database/backend.go index 5e6bfada625e..9fd89cb516c6 100644 --- a/builtin/logical/database/backend.go +++ b/builtin/logical/database/backend.go @@ -228,6 +228,20 @@ func (b *databaseBackend) StaticRole(ctx context.Context, s logical.Storage, rol return b.roleAtPath(ctx, s, roleName, databaseStaticRolePath) } +func (b *databaseBackend) StoreStaticRole(ctx context.Context, s logical.Storage, r *roleEntry) error { + logger := b.Logger().With("role", r.Name, "database", r.DBName) + entry, err := logical.StorageEntryJSON(databaseStaticRolePath+r.Name, r) + if err != nil { + logger.Error("unable to encode entry for storage", "error", err) + return err + } + if err := s.Put(ctx, entry); err != nil { + logger.Error("unable to write to storage", "error", err) + return err + } + return nil +} + func (b *databaseBackend) roleAtPath(ctx context.Context, s logical.Storage, roleName string, pathPrefix string) (*roleEntry, error) { entry, err := s.Get(ctx, pathPrefix+roleName) if err != nil { @@ -247,6 +261,11 @@ func (b *databaseBackend) roleAtPath(ctx context.Context, s logical.Storage, rol return nil, err } + // handle upgrade for new field Name + if result.Name == "" { + result.Name = roleName + } + switch { case upgradeCh.Statements != nil: var stmts v4.Statements diff --git a/builtin/logical/database/backend_test.go b/builtin/logical/database/backend_test.go index 1573d2146ac9..ed39f2221b2e 100644 --- a/builtin/logical/database/backend_test.go +++ b/builtin/logical/database/backend_test.go @@ -97,6 +97,7 @@ func TestBackend_RoleUpgrade(t *testing.T) { backend := &databaseBackend{} roleExpected := &roleEntry{ + Name: "test", Statements: v4.Statements{ CreationStatements: "test", Creation: []string{"test"}, @@ -211,6 +212,7 @@ func TestBackend_config_connection(t *testing.T) { "password_policy": "", "plugin_version": "", "verify_connection": false, + "skip_static_role_import_rotation": false, } configReq.Operation = logical.ReadOperation resp, err = b.HandleRequest(namespace.RootContext(nil), configReq) @@ -266,6 +268,7 @@ func TestBackend_config_connection(t *testing.T) { "password_policy": "", "plugin_version": "", "verify_connection": false, + "skip_static_role_import_rotation": false, } configReq.Operation = logical.ReadOperation resp, err = b.HandleRequest(namespace.RootContext(nil), configReq) @@ -310,6 +313,7 @@ func TestBackend_config_connection(t *testing.T) { "password_policy": "", "plugin_version": "", "verify_connection": false, + "skip_static_role_import_rotation": false, } configReq.Operation = logical.ReadOperation resp, err = b.HandleRequest(namespace.RootContext(nil), configReq) @@ -417,7 +421,7 @@ func TestBackend_basic(t *testing.T) { defer b.Cleanup(context.Background()) cleanup, connURL := postgreshelper.PrepareTestContainer(t) - defer cleanup() + t.Cleanup(cleanup) // Configure a connection data := map[string]interface{}{ @@ -768,6 +772,7 @@ func TestBackend_connectionCrud(t *testing.T) { "password_policy": "", "plugin_version": "", "verify_connection": false, + "skip_static_role_import_rotation": false, } resp, err = client.Read("database/config/plugin-test") if err != nil { diff --git a/builtin/logical/database/path_config_connection.go b/builtin/logical/database/path_config_connection.go index 0f373f371d74..17cc6eea3a74 100644 --- a/builtin/logical/database/path_config_connection.go +++ b/builtin/logical/database/path_config_connection.go @@ -42,6 +42,13 @@ type DatabaseConfig struct { PasswordPolicy string `json:"password_policy" structs:"password_policy" mapstructure:"password_policy"` VerifyConnection bool `json:"verify_connection" structs:"verify_connection" mapstructure:"verify_connection"` + + // SkipStaticRoleImportRotation is a flag to toggle wether or not a given + // static account's password should be rotated on creation of the static + // roles associated with this DB config. This can be overridden at the + // role-level by the role's skip_import_rotation field. The default is + // false. Enterprise only. + SkipStaticRoleImportRotation bool `json:"skip_static_role_import_rotation" structs:"skip_static_role_import_rotation" mapstructure:"skip_static_role_import_rotation"` } func (c *DatabaseConfig) SupportsCredentialType(credentialType v5.CredentialType) bool { @@ -205,57 +212,60 @@ func (b *databaseBackend) reloadPlugin() framework.OperationFunc { // pathConfigurePluginConnection returns a configured framework.Path setup to // operate on plugins. func pathConfigurePluginConnection(b *databaseBackend) *framework.Path { - return &framework.Path{ - Pattern: fmt.Sprintf("config/%s", framework.GenericNameRegex("name")), - - DisplayAttrs: &framework.DisplayAttributes{ - OperationPrefix: operationPrefixDatabase, + fields := map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: "Name of this database connection", }, - Fields: map[string]*framework.FieldSchema{ - "name": { - Type: framework.TypeString, - Description: "Name of this database connection", - }, - - "plugin_name": { - Type: framework.TypeString, - Description: `The name of a builtin or previously registered + "plugin_name": { + Type: framework.TypeString, + Description: `The name of a builtin or previously registered plugin known to vault. This endpoint will create an instance of that plugin type.`, - }, + }, - "plugin_version": { - Type: framework.TypeString, - Description: `The version of the plugin to use.`, - }, + "plugin_version": { + Type: framework.TypeString, + Description: `The version of the plugin to use.`, + }, - "verify_connection": { - Type: framework.TypeBool, - Default: true, - Description: `If true, the connection details are verified by + "verify_connection": { + Type: framework.TypeBool, + Default: true, + Description: `If true, the connection details are verified by actually connecting to the database. Defaults to true.`, - }, + }, - "allowed_roles": { - Type: framework.TypeCommaStringSlice, - Description: `Comma separated string or array of the role names + "allowed_roles": { + Type: framework.TypeCommaStringSlice, + Description: `Comma separated string or array of the role names allowed to get creds from this database connection. If empty no roles are allowed. If "*" all roles are allowed.`, - }, + }, - "root_rotation_statements": { - Type: framework.TypeStringSlice, - Description: `Specifies the database statements to be executed + "root_rotation_statements": { + Type: framework.TypeStringSlice, + Description: `Specifies the database statements to be executed to rotate the root user's credentials. See the plugin's API page for more information on support and formatting for this parameter.`, - }, - "password_policy": { - Type: framework.TypeString, - Description: `Password policy to use when generating passwords.`, - }, }, + "password_policy": { + Type: framework.TypeString, + Description: `Password policy to use when generating passwords.`, + }, + } + AddConnectionFieldsEnt(fields) + + return &framework.Path{ + Pattern: fmt.Sprintf("config/%s", framework.GenericNameRegex("name")), + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixDatabase, + }, + + Fields: fields, ExistenceCheck: b.connectionExistenceCheck(), @@ -480,6 +490,10 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc { config.PasswordPolicy = passwordPolicyRaw.(string) } + if skipImportRotationRaw, ok := data.GetOk("skip_static_role_import_rotation"); ok { + config.SkipStaticRoleImportRotation = skipImportRotationRaw.(bool) + } + // Remove these entries from the data before we store it keyed under // ConnectionDetails. delete(data.Raw, "name") @@ -489,6 +503,7 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc { delete(data.Raw, "verify_connection") delete(data.Raw, "root_rotation_statements") delete(data.Raw, "password_policy") + delete(data.Raw, "skip_static_role_import_rotation") id, err := uuid.GenerateUUID() if err != nil { diff --git a/builtin/logical/database/path_config_connection_ce.go b/builtin/logical/database/path_config_connection_ce.go new file mode 100644 index 000000000000..eed10e166a2f --- /dev/null +++ b/builtin/logical/database/path_config_connection_ce.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build !enterprise + +package database + +import "github.com/hashicorp/vault/sdk/framework" + +// AddConnectionFieldsEnt is a no-op for community edition +func AddConnectionFieldsEnt(fields map[string]*framework.FieldSchema) { + // no-op +} diff --git a/builtin/logical/database/path_roles.go b/builtin/logical/database/path_roles.go index a53988498000..a2344e6b3b6c 100644 --- a/builtin/logical/database/path_roles.go +++ b/builtin/logical/database/path_roles.go @@ -217,12 +217,15 @@ func staticFields() map[string]*framework.FieldSchema { this functionality. See the plugin's API page for more information on support and formatting for this parameter.`, }, + // Deprecated: use 'password' instead "self_managed_password": { Type: framework.TypeString, Description: `Used to connect to a self-managed static account. Must be provided by the user when root credentials are not provided.`, + Deprecated: true, }, } + AddStaticFieldsEnt(fields) return fields } @@ -303,9 +306,10 @@ func (b *databaseBackend) pathStaticRoleRead(ctx context.Context, req *logical.R } data := map[string]interface{}{ - "db_name": role.DBName, - "rotation_statements": role.Statements.Rotation, - "credential_type": role.CredentialType.String(), + "db_name": role.DBName, + "rotation_statements": role.Statements.Rotation, + "credential_type": role.CredentialType.String(), + "skip_import_rotation": role.SkipImportRotation, } // guard against nil StaticAccount; shouldn't happen but we'll be safe @@ -530,7 +534,7 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l return logical.ErrorResponse("Role and Static Role names must be unique"), nil } - role, err := b.StaticRole(ctx, req.Storage, data.Get("name").(string)) + role, err := b.StaticRole(ctx, req.Storage, name) if err != nil { return nil, err } @@ -541,6 +545,7 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l createRole := (req.Operation == logical.CreateOperation) if role == nil { role = &roleEntry{ + Name: name, StaticAccount: &staticAccount{}, } createRole = true @@ -633,8 +638,42 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l } } + dbConfig, err := b.DatabaseConfig(ctx, req.Storage, role.DBName) + if err != nil { + return nil, err + } + + lastVaultRotation := role.StaticAccount.LastVaultRotation + if passwordRaw, ok := data.GetOk("password"); ok { + // We will allow users to update the password until the point where + // Vault assumes management of the account so that we don't break the + // promise of Vault being the source of truth. + updateAllowed := lastVaultRotation.IsZero() + if updateAllowed { + role.StaticAccount.Password = passwordRaw.(string) + if selfManaged, ok := dbConfig.ConnectionDetails["self_managed"].(bool); ok && selfManaged { + // Password and SelfManagedPassword should map to the same value + role.StaticAccount.SelfManagedPassword = passwordRaw.(string) + } + } else { + return logical.ErrorResponse("updating password not allowed after rotation: role=%s, lastVaultRotation=%s", name, lastVaultRotation), nil + } + } + if smPasswordRaw, ok := data.GetOk("self_managed_password"); ok && createRole { + // Password and SelfManagedPassword should map to the same value role.StaticAccount.SelfManagedPassword = smPasswordRaw.(string) + role.StaticAccount.Password = smPasswordRaw.(string) + } + + if skipImportRotationRaw, ok := data.GetOk("skip_import_rotation"); ok { + if !createRole { + response.AddWarning("skip_import_rotation has no effect on updates") + } else { + role.SkipImportRotation = skipImportRotationRaw.(bool) + } + } else if createRole { + role.SkipImportRotation = dbConfig.SkipStaticRoleImportRotation } var credentialConfig map[string]string @@ -647,62 +686,74 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l return logical.ErrorResponse("credential_config validation failed: %s", err), nil } - // lvr represents the roles' LastVaultRotation - lvr := role.StaticAccount.LastVaultRotation - - // Only call setStaticAccount if we're creating the role for the - // first time + // Only call setStaticAccount if we're creating the role for the first time var item *queue.Item switch req.Operation { case logical.CreateOperation: - // setStaticAccount calls Storage.Put and saves the role to storage - resp, err := b.setStaticAccount(ctx, req.Storage, &setStaticAccountInput{ - RoleName: name, - Role: role, - }) - if err != nil { - if resp != nil && resp.WALID != "" { - b.Logger().Debug("deleting WAL for failed role creation", "WAL ID", resp.WALID, "role", name) - walDeleteErr := framework.DeleteWAL(ctx, req.Storage, resp.WALID) - if walDeleteErr != nil { - b.Logger().Debug("failed to delete WAL for failed role creation", "WAL ID", resp.WALID, "error", walDeleteErr) - var merr *multierror.Error - merr = multierror.Append(merr, err) - merr = multierror.Append(merr, fmt.Errorf("failed to clean up WAL from failed role creation: %w", walDeleteErr)) - err = merr.ErrorOrNil() - } + if role.SkipImportRotation { + b.Logger().Trace("skipping static role import rotation", "role", name) + // synthetically set lastVaultRotation to now, so that it gets + // queued correctly + lastVaultRotation = time.Now() + // we intentionally do not set role.StaticAccount.LastVaultRotation + // because the zero value indicates Vault has not rotated the + // password yet + role.StaticAccount.SetNextVaultRotation(lastVaultRotation) + // we were told to not rotate, just add the entry + err := b.StoreStaticRole(ctx, req.Storage, role) + if err != nil { + return nil, err } + } else { + // setStaticAccount calls Storage.Put and saves the role to storage + resp, err := b.setStaticAccount(ctx, req.Storage, &setStaticAccountInput{ + RoleName: name, + Role: role, + }) + if err != nil { + if resp != nil && resp.WALID != "" { + b.Logger().Debug("deleting WAL for failed role creation", "WAL ID", resp.WALID, "role", name) + walDeleteErr := framework.DeleteWAL(ctx, req.Storage, resp.WALID) + if walDeleteErr != nil { + b.Logger().Debug("failed to delete WAL for failed role creation", "WAL ID", resp.WALID, "error", walDeleteErr) + var merr *multierror.Error + merr = multierror.Append(merr, err) + merr = multierror.Append(merr, fmt.Errorf("failed to clean up WAL from failed role creation: %w", walDeleteErr)) + err = merr.ErrorOrNil() + } + } - return nil, err + return nil, err + } + // guard against RotationTime not being set or zero-value + lastVaultRotation = resp.RotationTime } - // guard against RotationTime not being set or zero-value - lvr = resp.RotationTime + item = &queue.Item{ Key: name, } case logical.UpdateOperation: // store updated Role - entry, err := logical.StorageEntryJSON(databaseStaticRolePath+name, role) + err := b.StoreStaticRole(ctx, req.Storage, role) if err != nil { return nil, err } - if err := req.Storage.Put(ctx, entry); err != nil { - return nil, err - } + item, err = b.popFromRotationQueueByKey(name) if err != nil { return nil, err } } + var next time.Time if rotationPeriodOk { - b.logger.Debug("init priority for RotationPeriod", "lvr", lvr, "next", lvr.Add(role.StaticAccount.RotationPeriod)) - item.Priority = lvr.Add(role.StaticAccount.RotationPeriod).Unix() + next = lastVaultRotation.Add(role.StaticAccount.RotationPeriod) + item.Priority = next.Unix() } else if rotationScheduleOk { - next := role.StaticAccount.Schedule.Next(lvr) - b.logger.Debug("init priority for Schedule", "lvr", lvr, "next", next) + next = role.StaticAccount.Schedule.Next(lastVaultRotation) item.Priority = next.Unix() } + b.logger.Trace("initialized priority", "role", name, "lastVaultRotation", lastVaultRotation, "next", next) // Add their rotation to the queue if err := b.pushItem(item); err != nil { @@ -713,6 +764,7 @@ func (b *databaseBackend) pathStaticRoleCreateUpdate(ctx context.Context, req *l } type roleEntry struct { + Name string `json:"name"` DBName string `json:"db_name"` Statements v4.Statements `json:"statements"` DefaultTTL time.Duration `json:"default_ttl"` @@ -720,6 +772,12 @@ type roleEntry struct { CredentialType v5.CredentialType `json:"credential_type"` CredentialConfig map[string]interface{} `json:"credential_config"` StaticAccount *staticAccount `json:"static_account" mapstructure:"static_account"` + + // SkipImportRotation is a flag to toggle wether or not the static + // account's password should be rotated on creation of the static role. + // This overrides the config-level field skip_static_role_import_rotation. + // The default is false. Enterprise only. + SkipImportRotation bool `json:"skip_import_rotation"` } // setCredentialType sets the credential type for the role given its string form. @@ -812,7 +870,8 @@ type staticAccount struct { // CredentialTypeRSAPrivateKey. PrivateKey []byte `json:"private_key"` - // LastVaultRotation represents the last time Vault rotated the password + // LastVaultRotation represents the last time Vault rotated the password. + // A zero value indicates that Vault has not rotated this password yet. LastVaultRotation time.Time `json:"last_vault_rotation"` // NextVaultRotation represents the next time Vault is expected to rotate diff --git a/builtin/logical/database/path_roles_ce.go b/builtin/logical/database/path_roles_ce.go new file mode 100644 index 000000000000..63cdb102ad40 --- /dev/null +++ b/builtin/logical/database/path_roles_ce.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build !enterprise + +package database + +import "github.com/hashicorp/vault/sdk/framework" + +// AddStaticFieldsEnt is a no-op for comminuty edition +func AddStaticFieldsEnt(fields map[string]*framework.FieldSchema) { + // no-op +} diff --git a/builtin/logical/database/path_rotate_credentials.go b/builtin/logical/database/path_rotate_credentials.go index f2f7fa321ea9..d5faf3b3fec1 100644 --- a/builtin/logical/database/path_rotate_credentials.go +++ b/builtin/logical/database/path_rotate_credentials.go @@ -258,7 +258,6 @@ func (b *databaseBackend) pathRotateRoleCredentialsUpdate() framework.OperationF "continue in the background but it is also safe to retry manually: %w", err) } - // return any err from the setStaticAccount call return nil, nil } } diff --git a/builtin/logical/database/rotation.go b/builtin/logical/database/rotation.go index 3d460915a6c0..804771b670ee 100644 --- a/builtin/logical/database/rotation.go +++ b/builtin/logical/database/rotation.go @@ -234,13 +234,8 @@ func (b *databaseBackend) rotateCredential(ctx context.Context, s logical.Storag // write to storage after updating NextVaultRotation so the next // time this item is checked for rotation our role that we retrieve // from storage reflects that change - entry, err := logical.StorageEntryJSON(databaseStaticRolePath+input.RoleName, input.Role) + err := b.StoreStaticRole(ctx, s, input.Role) if err != nil { - logger.Error("unable to encode entry for storage", "error", err) - return false - } - if err := s.Put(ctx, entry); err != nil { - logger.Error("unable to write to storage", "error", err) return false } } @@ -349,9 +344,9 @@ type setStaticAccountOutput struct { // setStaticAccount sets the credential for a static account associated with a // Role. This method does many things: -// - verifies role exists and is in the allowed roles list -// - loads an existing WAL entry if WALID input is given, otherwise creates a -// new WAL entry +// - verifies role exists and is in the allowed roles list +// - loads an existing WAL entry if WALID input is given, otherwise creates a +// new WAL entry // - gets a database connection // - accepts an input credential, otherwise generates a new one for // the role's credential type @@ -559,11 +554,7 @@ func (b *databaseBackend) setStaticAccount(ctx context.Context, s logical.Storag input.Role.StaticAccount.SetNextVaultRotation(lvr) output.RotationTime = lvr - entry, err := logical.StorageEntryJSON(databaseStaticRolePath+input.RoleName, input.Role) - if err != nil { - return output, err - } - if err := s.Put(ctx, entry); err != nil { + if err := b.StoreStaticRole(ctx, s, input.Role); err != nil { return output, err } diff --git a/builtin/logical/database/rotation_test.go b/builtin/logical/database/rotation_test.go index 99fc3ddf004b..7ab4311d1255 100644 --- a/builtin/logical/database/rotation_test.go +++ b/builtin/logical/database/rotation_test.go @@ -64,7 +64,7 @@ func TestBackend_StaticRole_Rotation_basic(t *testing.T) { b.schedule = &TestSchedule{} cleanup, connURL := postgreshelper.PrepareTestContainer(t) - defer cleanup() + t.Cleanup(cleanup) // create the database user createTestPGUser(t, connURL, dbUser, dbUserDefaultPassword, testRoleStaticCreate)