diff --git a/backend_test.go b/backend_test.go index 90d1347d..bcaaaf28 100644 --- a/backend_test.go +++ b/backend_test.go @@ -100,7 +100,8 @@ func testBackendConfig() *logical.BackendConfig { var _ ldapClient = (*fakeLdapClient)(nil) type fakeLdapClient struct { - throwErrs bool + throwErrs bool + throwsInvalidCredentialsErr bool } func (f *fakeLdapClient) UpdateUserPassword(_ *client.Config, _ string, _ string) error { @@ -119,6 +120,16 @@ func (f *fakeLdapClient) UpdateDNPassword(_ *client.Config, _ string, _ string) return err } +func (f *fakeLdapClient) UpdateSelfManagedDNPassword(_ *client.Config, _ string, _ string, _ string) error { + var err error + if f.throwErrs { + err = errors.New("forced error") + } else if f.throwsInvalidCredentialsErr { + err = errors.New("invalid credentials") + } + return err +} + func (f *fakeLdapClient) Execute(_ *client.Config, _ []*ldif.Entry, _ bool) error { var err error if f.throwErrs { diff --git a/client.go b/client.go index 4c94228e..85c81f83 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ package openldap import ( + "errors" "fmt" "github.com/go-ldap/ldap/v3" @@ -17,6 +18,7 @@ import ( type ldapClient interface { UpdateDNPassword(conf *client.Config, dn string, newPassword string) error UpdateUserPassword(conf *client.Config, user, newPassword string) error + UpdateSelfManagedDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) error } @@ -87,6 +89,22 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas return c.ldap.UpdatePassword(conf, conf.UserDN, ldap.ScopeWholeSubtree, newValues, filters) } +func (c *Client) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { + if dn == "" { + // Optionally implement a search to resolve DN from username, userdn, userattr in cfg. + return errors.New("user DN resolution not implemented") + } + if currentPassword == "" || newPassword == "" { + return fmt.Errorf("both current and new password must be provided for self-managed password changes on dn: %s", dn) + } + + scope := ldap.ScopeBaseObject + filters := map[*client.Field][]string{ + client.FieldRegistry.ObjectClass: {"*"}, + } + return c.ldap.UpdateSelfManagedPassword(conf, dn, scope, currentPassword, newPassword, filters) +} + func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { return c.ldap.Execute(conf, entries, continueOnError) } diff --git a/client/client.go b/client/client.go index 683d9124..bf4031c4 100644 --- a/client/client.go +++ b/client/client.go @@ -121,6 +121,78 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues return c.UpdateEntry(cfg, baseDN, scope, filters, newValues) } +// UpdateSelfManagedPassword performs a least‑privilege, self‑service password change. +// Behavior by schema: +// - Active Directory: issues a Delete (current value) followed by an Add (new value) +// because a direct Replace (like the one done in `UpdateEntry`) typically requires elevated privileges. +// - OpenLDAP: uses the RFC 3062 PasswordModify extended operation. If the underlying +// connection does not support it, falls back to UpdateEntry (privileged replace). +// - RACF: not implamneted yet. +func (c *Client) UpdateSelfManagedPassword(cfg *Config, dn string, scope int, currentValue string, newValue string, filters map[*Field][]string) error { + if currentValue == "" || newValue == "" { + return fmt.Errorf("both current and new password must be provided for self-managed password changes on dn: %s", dn) + } + // Use a copy of the config to avoid modifying the original with the bind dn/password for rotation + rotationConfEntry := *cfg.ConfigEntry + rotationConfEntry.BindDN = dn + rotationConfEntry.BindPassword = currentValue + rotationConf := *cfg + rotationConf.ConfigEntry = &rotationConfEntry + // perform self search to validate account exists and current password is correct + entries, err := c.Search(&rotationConf, dn, scope, filters) + if err != nil { + return err + } + if len(entries) != 1 { + return fmt.Errorf("expected one matching entry, but received %d", len(entries)) + } + conn, err := c.ldap.DialLDAP(rotationConf.ConfigEntry) + if err != nil { + return err + } + defer conn.Close() + + if err := bind(&rotationConf, conn); err != nil { + return err + } + currentSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, currentValue) + if err != nil { + return fmt.Errorf("error updating password: %s", err) + } + newSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, newValue) + if err != nil { + return fmt.Errorf("error updating password: %s", err) + } + switch rotationConf.Schema { + case SchemaAD: + modifyReq := &ldap.ModifyRequest{ + DN: entries[0].DN, + } + for field, vals := range currentSchemaValues { + modifyReq.Delete(field.String(), vals) + } + for field, vals := range newSchemaValues { + modifyReq.Add(field.String(), vals) + } + return conn.Modify(modifyReq) + case SchemaOpenLDAP: + req := ldap.NewPasswordModifyRequest(entries[0].DN, currentValue, newValue) + pmConn, ok := conn.(interface { + PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) + }) + if !ok { + // Fallback: try privileged replace (may fail if self-change perms required) + return c.UpdateEntry(&rotationConf, dn, scope, filters, newSchemaValues) + } + _, err = pmConn.PasswordModify(req) + return err + case SchemaRACF: + return fmt.Errorf("self managed password changes not supported for RACF schema") + default: + return fmt.Errorf("configured schema %s not valid", rotationConf.Schema) + } +} + // toString turns the following map of filters into LDAP search filter strings // For example: "(cn=Ellen Jones)" // when multiple filters are applied, they get AND'ed together. diff --git a/client/client_test.go b/client/client_test.go index 64c46461..1ed9d82b 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -267,6 +267,87 @@ func TestUpdatePasswordAD(t *testing.T) { } } +func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { + testPass := "hell0$catz*" + currentPass := "dogs" + + config := emptyConfig() + config.Schema = SchemaOpenLDAP + config.BindDN = "cn=admin,dc=example,dc=org" + config.BindPassword = "admin" + dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" + + conn := &ldapifc.FakeLDAPConnection{ + SearchRequestToExpect: testSearchRequest(), + SearchResultToReturn: testSearchResult(), + } + + conn.PasswordModifyRequestToExpect = &ldap.PasswordModifyRequest{ + UserIdentity: dn, + OldPassword: currentPass, + NewPassword: testPass, + } + ldapClient := &ldaputil.Client{ + Logger: hclog.NewNullLogger(), + LDAP: &ldapifc.FakeLDAPClient{conn}, + } + + client := &Client{ldapClient} + + filters := map[*Field][]string{ + FieldRegistry.ObjectClass: {"*"}, + } + if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil { + t.Fatal(err) + } + // validate client didnt change + assert.Equal(t, "cn=admin,dc=example,dc=org", config.BindDN) + assert.Equal(t, "admin", config.BindPassword) +} + +func TestUpdateSelfManagedPasswordAD(t *testing.T) { + testPass := "hell0$catz*" + currentPass := "dogs" + encodedCurrentPass, err := formatPassword(currentPass) + if err != nil { + t.Fatal(err) + } + dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" + encodedTestPass, err := formatPassword(testPass) + if err != nil { + t.Fatal(err) + } + + config := emptyConfig() + config.Schema = SchemaAD + + conn := &ldapifc.FakeLDAPConnection{ + SearchRequestToExpect: testSearchRequest(), + SearchResultToReturn: testSearchResult(), + } + + conn.ModifyRequestToExpect = &ldap.ModifyRequest{ + DN: dn, + } + conn.ModifyRequestToExpect.Delete("unicodePwd", []string{encodedCurrentPass}) + conn.ModifyRequestToExpect.Add("unicodePwd", []string{encodedTestPass}) + + ldapClient := &ldaputil.Client{ + Logger: hclog.NewNullLogger(), + LDAP: &ldapifc.FakeLDAPClient{conn}, + } + + client := &Client{ldapClient} + + filters := map[*Field][]string{ + FieldRegistry.ObjectClass: {"*"}, + } + + if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil { + t.Fatal(err) + } +} + // TestUpdateRootPassword mimics the UpdateRootPassword in the SecretsClient. // However, this test must be located within this package because when the // "client" is instantiated below, the "ldapClient" is being added to an diff --git a/client_test.go b/client_test.go index 60cd8d28..bb7466d4 100644 --- a/client_test.go +++ b/client_test.go @@ -213,6 +213,45 @@ func Test_UpdateUserPassword(t *testing.T) { assert.NoError(t, err) } +func Test_UpdateSelfManagedDNPassword_MissingPrameters(t *testing.T) { + config := &client.Config{ + ConfigEntry: &ldaputil.ConfigEntry{ + Url: "ldap://ldap.com:389", + BindDN: "cn=admin,dc=example,dc=org", + BindPassword: "admin", + }, + Schema: client.SchemaOpenLDAP, + } + + c := NewClient(hclog.NewNullLogger()) + newPassword := "newpassword" + err := c.UpdateSelfManagedDNPassword(config, "cn=user1,dc=example,dc=org", "", newPassword) + assert.Error(t, err) + err = c.UpdateSelfManagedDNPassword(config, "", "currentpassword", newPassword) + assert.Error(t, err) + err = c.UpdateSelfManagedDNPassword(config, "cn=user1,dc=example,dc=org", "currentpassword", "") + assert.Error(t, err) +} + +func Test_UpdateSelfManagedDNPassword(t *testing.T) { + ldapServer := setupDockerLDAP(t) + config := &client.Config{ + ConfigEntry: &ldaputil.ConfigEntry{ + Url: ldapServer, + BindDN: "cn=admin,dc=example,dc=org", + BindPassword: "admin", + }, + Schema: client.SchemaOpenLDAP, + } + // self rotate this user + c := NewClient(hclog.NewNullLogger()) + err := c.UpdateSelfManagedDNPassword(config, "cn=User1,dc=example,dc=org", "password1", "newpassword") + assert.NoError(t, err) + // validate client didnt change + assert.Equal(t, "cn=admin,dc=example,dc=org", config.BindDN) + assert.Equal(t, "admin", config.BindPassword) +} + func setupDockerLDAP(t *testing.T) string { t.Helper() pool, err := dockertest.NewPool("") diff --git a/ldapifc/fakes.go b/ldapifc/fakes.go index 55c79a55..eb9a6c60 100644 --- a/ldapifc/fakes.go +++ b/ldapifc/fakes.go @@ -25,9 +25,10 @@ func (f *FakeLDAPClient) DialURL(addr string, opts ...ldap.DialOpt) (ldaputil.Co } type FakeLDAPConnection struct { - ModifyRequestToExpect *ldap.ModifyRequest - SearchRequestToExpect *ldap.SearchRequest - SearchResultToReturn *ldap.SearchResult + ModifyRequestToExpect *ldap.ModifyRequest + PasswordModifyRequestToExpect *ldap.PasswordModifyRequest + SearchRequestToExpect *ldap.SearchRequest + SearchResultToReturn *ldap.SearchResult } func (f *FakeLDAPConnection) Bind(username, password string) error { @@ -81,3 +82,10 @@ func (f *FakeLDAPConnection) Add(request *ldap.AddRequest) error { func (f *FakeLDAPConnection) Del(request *ldap.DelRequest) error { return nil } + +func (f *FakeLDAPConnection) PasswordModify(modifyRequest *ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) { + if !reflect.DeepEqual(f.PasswordModifyRequestToExpect, modifyRequest) { + return nil, fmt.Errorf("Actual modify request: %#v\nExpected: %#v", modifyRequest, f.PasswordModifyRequestToExpect) + } + return &ldap.PasswordModifyResult{}, nil +} diff --git a/mocks_test.go b/mocks_test.go index 19446794..81056d3c 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -28,6 +28,11 @@ func (m *mockLDAPClient) UpdateUserPassword(conf *client.Config, user string, ne return args.Error(0) } +func (m *mockLDAPClient) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { + args := m.Called(conf, dn, currentPassword, newPassword) + return args.Error(0) +} + func (m *mockLDAPClient) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { args := m.Called(conf, entries, continueOnError) return args.Error(0) diff --git a/path_rotate_test.go b/path_rotate_test.go index 78a885d1..6af08670 100644 --- a/path_rotate_test.go +++ b/path_rotate_test.go @@ -213,6 +213,10 @@ func (f *failingRollbackClient) UpdateDNPassword(conf *client.Config, dn string, return fmt.Errorf("some error") } +func (f *failingRollbackClient) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { + panic("nope") +} + func (f *failingRollbackClient) UpdateUserPassword(conf *client.Config, user, newPassword string) error { panic("nope") } diff --git a/path_static_roles.go b/path_static_roles.go index 384fc243..46503784 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -18,6 +18,8 @@ import ( const ( staticRolePath = "static-role/" + // Default max consecutive invalid current-password attempts for self-managed accounts (if role field unset) + defaultSelfManagedMaxInvalidAttempts = 5 ) // genericNameWithForwardSlashRegex is a regex which requires a role name. The @@ -135,6 +137,13 @@ func fieldsForType(roleType string) map[string]*framework.FieldSchema { // only to static roles func staticFields() map[string]*framework.FieldSchema { fields := map[string]*framework.FieldSchema{ + "password": { + Type: framework.TypeString, + Description: "Password for the static account. This is required for Vault to manage an existing account and enable rotation.", + DisplayAttrs: &framework.DisplayAttributes{ + Sensitive: true, + }, + }, "rotation_period": { Type: framework.TypeDurationSecond, Description: "Period for automatic credential rotation of the given entry.", @@ -143,6 +152,16 @@ func staticFields() map[string]*framework.FieldSchema { Type: framework.TypeBool, Description: "Skip the initial pasword rotation on import (has no effect on updates)", }, + "self_managed": { + Type: framework.TypeBool, + Description: "If true, Vault performs rotations by authenticating as this account using its current password (no privileged bind DN). Immutable after creation. Requires password on create.", + Default: false, + }, + "self_managed_max_invalid_attempts": { + Type: framework.TypeInt, + Description: "Maximum number of invalid current-password attempts for self-managed accounts. A value equal to 0 means use the default, and a negative value means unlimited attempts. Immutable after creation.", + Default: defaultSelfManagedMaxInvalidAttempts, + }, } return fields } @@ -188,31 +207,13 @@ func (b *backend) pathStaticRoleDelete(ctx context.Context, req *logical.Request defer b.managedUserLock.Unlock() delete(b.managedUsers, role.StaticAccount.Username) - walIDs, err := framework.ListWAL(ctx, req.Storage) - if err != nil { + if err := deleteWALsForRole(ctx, b, req.Storage, name); err != nil { return nil, err } - var merr *multierror.Error - for _, walID := range walIDs { - wal, err := b.findStaticWAL(ctx, req.Storage, walID) - if err != nil { - merr = multierror.Append(merr, err) - continue - } - if wal != nil && name == wal.RoleName { - b.Logger().Debug("deleting WAL for deleted role", "WAL ID", walID, "role", name) - err = framework.DeleteWAL(ctx, req.Storage, walID) - if err != nil { - b.Logger().Debug("failed to delete WAL for deleted role", "WAL ID", walID, "error", err) - merr = multierror.Append(merr, err) - } - } - } - // Send event notification for static role delete b.ldapEvent(ctx, "static-role-delete", req.Path, name, true) - return nil, merr.ErrorOrNil() + return nil, err } func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -225,8 +226,13 @@ func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, } data := map[string]interface{}{ - "dn": role.StaticAccount.DN, - "username": role.StaticAccount.Username, + "dn": role.StaticAccount.DN, + "username": role.StaticAccount.Username, + "self_managed": role.StaticAccount.SelfManaged, + "self_managed_max_invalid_attempts": role.StaticAccount.SelfManagedMaxInvalidAttempts, + } + if role.StaticAccount.SelfManaged { + data["rotation_suspended"] = role.StaticAccount.RotationSuspended } data["rotation_period"] = role.StaticAccount.RotationPeriod.Seconds() @@ -281,18 +287,60 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R role.StaticAccount.Username = username } - // DN is optional. Unless it is unset via providing the empty string, it + if smRaw, ok := data.GetOk("self_managed"); ok { + sm := smRaw.(bool) + if !isCreate && sm != role.StaticAccount.SelfManaged { + return logical.ErrorResponse("cannot change self_managed after creation"), nil + } + role.StaticAccount.SelfManaged = sm + } + // For non-self managed: DN is optional Unless it is unset via providing the empty string, it // cannot be modified after creation. If given, it will take precedence // over username for LDAP search during password rotation. - if dnRaw, ok := data.GetOk("dn"); ok { + // For self-managed: DN is required. + dnRaw, ok := data.GetOk("dn") + if !ok && isCreate && role.StaticAccount.SelfManaged { + return logical.ErrorResponse("dn is a required field to assume management of a self-managed static account"), nil + } + if ok { dn := dnRaw.(string) if !isCreate && dn != "" && dn != role.StaticAccount.DN { return logical.ErrorResponse("cannot update static account distinguished name (dn)"), nil } - + if role.StaticAccount.SelfManaged && dn == "" { + return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil + } role.StaticAccount.DN = dn } - + hasExternalPasswordBeenSet := false + passwordRaw, ok := data.GetOk("password") + if !ok && isCreate && role.StaticAccount.SelfManaged { + return logical.ErrorResponse("password is a required field to assume management of a self-managed static account"), nil + } + if ok { + password := passwordRaw.(string) + if !isCreate && password != "" { + b.Logger().Debug("external password change for static role", "role", name) + hasExternalPasswordBeenSet = true + } + if role.StaticAccount.SelfManaged && password != "" { + role.StaticAccount.Password = password + } else if role.StaticAccount.SelfManaged && password == "" { // dont allow to provide empty password for self-managed accounts + return logical.ErrorResponse("cannot provide empty password parameter for self-managed accounts"), nil + } else if !role.StaticAccount.SelfManaged && password != "" { + return logical.ErrorResponse("cannot set password for non-self-managed static accounts"), nil + } + } + if maxInvalidRaw, ok := data.GetOk("self_managed_max_invalid_attempts"); ok { + maxInvalid := maxInvalidRaw.(int) + if !role.StaticAccount.SelfManaged { + return logical.ErrorResponse("cannot set self_managed_max_invalid_attempts for non-self-managed static accounts"), nil + } + if !isCreate && maxInvalid != role.StaticAccount.SelfManagedMaxInvalidAttempts { + return logical.ErrorResponse("cannot change self_managed_max_invalid_attempts after creation"), nil + } + role.StaticAccount.SelfManagedMaxInvalidAttempts = maxInvalid + } rotationPeriodSecondsRaw, ok := data.GetOk("rotation_period") if !ok && isCreate { return logical.ErrorResponse("rotation_period is required to create static accounts"), nil @@ -393,6 +441,15 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R } } case logical.UpdateOperation: + if hasExternalPasswordBeenSet && role.StaticAccount.SelfManaged { + // If the password was set outside of Vault, and this is a self-managed role + // we should cleanup any existing WALs so that we re-assume management with the new password and zero attempts + // on the next rotation. + if err := deleteWALsForRole(ctx, b, req.Storage, name); err != nil { + return nil, err + } + role.StaticAccount.RotationSuspended = false // reset rotation suspension on password change + } // if lastVaultRotation is zero, the role had `skip_import_rotation` set if lastVaultRotation.IsZero() { lastVaultRotation = time.Now() @@ -416,7 +473,12 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // TODO: Add retry logic item, err = b.popFromRotationQueueByKey(name) if err != nil { - return nil, err + if item == nil && err.Error() == "queue is empty" && hasExternalPasswordBeenSet && role.StaticAccount.SelfManaged { + b.Logger().Debug("detected that self-managed role is not queued likely due to invalid credentials supression, re-adding to queue", "role", name) + item = &queue.Item{Key: name} + } else { + return nil, err + } } } item.Priority = role.StaticAccount.NextVaultRotation.Unix() @@ -434,6 +496,33 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R return nil, nil } +// deleteWALsForRole deletes all Write-Ahead-Log (WAL) entries associated with the given role name. +// It returns any errors encountered during the process. +func deleteWALsForRole(ctx context.Context, b *backend, storage logical.Storage, name string) error { + walIDs, err := framework.ListWAL(ctx, storage) + if err != nil { + return err + } + var merr *multierror.Error + for _, walID := range walIDs { + wal, err := b.findStaticWAL(ctx, storage, walID) + if err != nil { + merr = multierror.Append(merr, err) + continue + } + if wal != nil && name == wal.RoleName { + b.Logger().Debug("deleting WAL for deleted role", "WAL ID", walID, "role", name) + err = framework.DeleteWAL(ctx, storage, walID) + if err != nil { + b.Logger().Debug("failed to delete WAL for deleted role", "WAL ID", walID, "error", err) + merr = multierror.Append(merr, err) + } + } + } + + return merr.ErrorOrNil() +} + type roleEntry struct { StaticAccount *staticAccount `json:"static_account" mapstructure:"static_account"` } @@ -466,6 +555,21 @@ type staticAccount struct { // "time to live". This value is compared to the LastVaultRotation to // determine if a password needs to be rotated RotationPeriod time.Duration `json:"rotation_period"` + + // whether the account is self-managed or Vault-managed (i.e. rotated by a privileged bind account). + // this is currently only set at account creation time and cannot be changed + SelfManaged bool `json:"self_managed"` + + // SelfManagedMaxInvalidAttempts is the maximum number of invalid attempts allowed for self-managed accounts. + // A value less than or equal to 0 means use the default (or unlimited if negative). + SelfManagedMaxInvalidAttempts int `json:"self_managed_max_invalid_attempts"` + + // RotationSuspended indicates that automatic rotation is currently suspended + // for this static account due to too many invalid current-password attempts + // on self-managed accounts. + // It is returned on read operations to indicate the current state of the + // account. + RotationSuspended bool `json:"rotation_suspended,omitempty"` } // NextRotationTime calculates the next rotation by adding the Rotation Period @@ -542,9 +646,30 @@ when searching for the user can be configured with the "userattr" configuration The "dn" parameter is optional and configures the distinguished name to use when managing the existing entry. If the "dn" parameter is set, it will take precedence over the "username" when LDAP searches are performed. +This parameter is required if "self_managed" is true. The "rotation_period' parameter is required and configures how often, in seconds, the credentials should be automatically rotated by Vault. The minimum is 5 seconds (5s). + +The "skip_import_rotation" parameter is optional and only has effect during role creation. +If true, Vault will skip the initial password rotation when creating the role, and will manage the existing password. +If false (the default), Vault will rotate the password when the role is created. This parameter has no effect during role updates. + +The "self_managed" parameter is optional and indicates whether the role manages its own password rotation. +If true, Vault will perform rotations by authenticating as this account using its current password (no privileged bind DN). +This requires the "password" parameter to be set on creation, and the "dn" parameter to be set as well. +This field is immutable after creation. If false (the default), Vault will use the configured bind DN to perform rotations. + +The "password" parameter is required only if "self_managed" is true, and configures the current password for the entry. +This allows Vault to assume management of an existing account. The password will be rotated on creation unless +the "skip_import_rotation" parameter is set to true. The password is not returned in read operations. + +The "self_managed_max_invalid_attempts" parameter is optional and configures the maximum number of invalid current-password attempts for self-managed accounts. +A value equal to 0 means use the default (5), and a negative value means unlimited attempts. +When the maximum number of attempts is reached, automatic rotation is suspended until the password is updated via the "password" parameter. +This field is immutable after creation. + +The "rotation_suspended" field is only returned in read operations and indicates whether automatic rotation is currently suspended for this static account due to too many invalid current-password attempts on self-managed accounts. ` const staticRolesListHelpDescription = ` diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 232174fc..80fd05e0 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -140,6 +140,88 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { "rotation_period": float64(25), }, }, + { + name: "successful creation of self-managed static role", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "self_managed": true, + "password": "InitialPassword!23", + }, + }, + { + name: "modified self_managed results in update error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + }, + updateData: map[string]interface{}{ + "self_managed": true, + }, + wantUpdateErr: true, + }, + { + name: "modified self-managed static role with empty dn results in update error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "self_managed": true, + "password": "InitialPassword!23", + }, + updateData: map[string]interface{}{ + "dn": "", + }, + wantUpdateErr: true, + }, + { + name: "create self-managed static role with empty dn results in create error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "", + "rotation_period": float64(5), + "self_managed": true, + "password": "InitialPassword!23", + }, + wantCreateErr: true, + }, + { + name: "create self-managed static role with empty password results in create error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "self_managed": true, + "password": "", + }, + wantCreateErr: true, + }, + { + name: "modified self-managed static role with empty password results in update error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "self_managed": true, + "password": "InitialPassword!23", + }, + updateData: map[string]interface{}{ + "password": "", + }, + wantUpdateErr: true, + }, + { + name: "create non self-managed static role with empty password results in create error", + createData: map[string]interface{}{ + "username": "bob", + "dn": "uid=bob,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "password": "InitialPassword!23", + }, + wantCreateErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -174,6 +256,9 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { // assert response has expected fields for key, expected := range tt.createData { + if key == "password" { + continue + } actual := resp.Data[key] if actual != expected { t.Fatalf("expected %v to be %v, got %v", key, expected, actual) @@ -203,6 +288,9 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { // assert response has expected fields for key, expected := range tt.updateData { + if key == "password" { + continue + } actual := resp.Data[key] if actual != expected { t.Fatalf("expected %v to be %v, got %v", key, expected, actual) @@ -433,6 +521,67 @@ func TestRoles(t *testing.T) { assertReadStaticRole(t, b, storage, role, data) } }) + + t.Run("happy path self managed", func(t *testing.T) { + b, storage := getBackend(false) + defer b.Cleanup(context.Background()) + + configureOpenLDAPMount(t, b, storage) + + roleName := "hashicorp" + data := map[string]interface{}{ + "dn": "uid=hashicorp,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "username": "hashicorp", + "password": "initialPassword!23", + "self_managed": true, + } + + resp, err := createStaticRoleWithData(t, b, storage, roleName, data) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + assertReadStaticRole(t, b, storage, roleName, data) + }) + + t.Run("self managed missing password", func(t *testing.T) { + b, storage := getBackend(false) + defer b.Cleanup(context.Background()) + + configureOpenLDAPMount(t, b, storage) + + data := map[string]interface{}{ + "dn": "uid=hashicorp,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "username": "hashicorp", + "self_managed": true, + } + + resp, _ := createStaticRoleWithData(t, b, storage, "hashicorp", data) + if resp == nil || !resp.IsError() { + t.Fatal("expected error") + } + }) + + t.Run("self managed missing dn", func(t *testing.T) { + b, storage := getBackend(false) + defer b.Cleanup(context.Background()) + + configureOpenLDAPMount(t, b, storage) + + data := map[string]interface{}{ + "rotation_period": float64(5), + "username": "hashicorp", + "password": "initialPassword!23", + "self_managed": true, + } + resp, _ := createStaticRoleWithData(t, b, storage, "hashicorp", data) + if resp == nil || !resp.IsError() { + t.Fatal("expected error") + } + }) + } func TestRoles_NewPasswordGeneration(t *testing.T) { @@ -538,6 +687,113 @@ func TestRoles_NewPasswordGeneration(t *testing.T) { walIDs = requireWALs(t, storage, 0) }) } +func TestRoles_SelfManaged_NewPasswordGeneration(t *testing.T) { + ctx := context.Background() + b, storage := getBackend(false) + defer b.Cleanup(ctx) + configureOpenLDAPMount(t, b, storage) + + // Create the role + roleName := "hashicorp-sm" + data := map[string]interface{}{ + "username": roleName, + "dn": "uid=hashicorp,ou=users,dc=hashicorp,dc=com", + "rotation_period": "86400s", + "password": "initialPassword!23", + "self_managed": true, + } + createStaticRoleWithData(t, b, storage, roleName, data) + + t.Run("self managed account rotation failures should generate new password on retry", func(t *testing.T) { + // Fail to rotate the role + generateWALFromFailedRotation(t, b, storage, roleName) + + // Get WAL + walIDs := requireWALs(t, storage, 1) + wal, err := b.findStaticWAL(ctx, storage, walIDs[0]) + if err != nil || wal == nil { + t.Fatal(err) + } + // Store password + initialPassword := wal.NewPassword + + // Rotate role manually and fail again with same password + generateWALFromFailedRotation(t, b, storage, roleName) + // Ensure WAL is deleted since retrying initial password failed + requireWALs(t, storage, 0) + + // Successfully rotate the role + _, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.UpdateOperation, + Path: "rotate-role/" + roleName, + Storage: storage, + }) + if err != nil { + t.Fatal(err) + } + + // Ensure WAL is flushed since request was successful + requireWALs(t, storage, 0) + + // Read the credential + resp := readStaticCred(t, b, storage, roleName) + + // Confirm successful rotation used new credential + // Assert previous failing credential is not being used + if resp.Data["password"] == initialPassword { + t.Fatalf("expected password to be different after second retry") + } + }) + t.Run("updating password policy should generate new password", func(t *testing.T) { + // Fail to rotate the role + generateWALFromFailedRotation(t, b, storage, roleName) + + // Get WAL + walIDs := requireWALs(t, storage, 1) + wal, err := b.findStaticWAL(ctx, storage, walIDs[0]) + if err != nil || wal == nil { + t.Fatal(err) + } + + expectedPassword := wal.NewPassword + + // Update Password Policy + configureOpenLDAPMountWithPasswordPolicy(t, b, storage, testPasswordPolicy1, true) + + // Rotate role manually and fail again + generateWALFromFailedRotation(t, b, storage, roleName) + // Get WAL + walIDs = requireWALs(t, storage, 1) + wal, err = b.findStaticWAL(ctx, storage, walIDs[0]) + if err != nil || wal == nil { + t.Fatal(err) + } + + // confirm new password is generated and is different from previous password + newPassword := wal.NewPassword + if expectedPassword == newPassword { + t.Fatalf("expected password to be different on second retry") + } + + // confirm new password uses policy + if newPassword != testPasswordFromPolicy1 { + t.Fatalf("expected password %s, got %s", testPasswordFromPolicy1, newPassword) + } + + // Successfully rotate the role + _, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.UpdateOperation, + Path: "rotate-role/" + roleName, + Storage: storage, + }) + if err != nil { + t.Fatal(err) + } + + // Ensure WAL is flushed + walIDs = requireWALs(t, storage, 0) + }) +} func TestListRoles(t *testing.T) { t.Run("list roles", func(t *testing.T) { @@ -732,6 +988,79 @@ func TestWALsDeletedOnRoleDeletion(t *testing.T) { requireWALs(t, storage, 1) } +func TestWALsDeletedOnSelfManagedPasswordUpdate(t *testing.T) { + ctx := context.Background() + b, storage := getBackend(false) + defer b.Cleanup(ctx) + configureOpenLDAPMount(t, b, storage) + + roleName := "hashicorp" + data := map[string]interface{}{ + "dn": "uid=hashicorp,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "username": "hashicorp", + "password": "initialPassword!23", + "self_managed": true, + "skip_import_rotation": true, // so we can validate wal is deleted on update + } + + resp, err := createStaticRoleWithData(t, b, storage, roleName, data) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Fail to rotate the role + generateWALFromFailedRotation(t, b, storage, roleName) + + // Should have 1 WAL hanging around + requireWALs(t, storage, 1) + + // Update the self-managed static role's password + updateStaticRoleWithData(t, b, storage, roleName, map[string]interface{}{ + "password": "NewValidPassword!23", + }) + + // 1 WAL should be cleared by the delete + requireWALs(t, storage, 0) +} + +// for self managed account if it is an update witn no new password then dont delete wal +func TestWALsNotDeletedOnSelfManagedUpdate(t *testing.T) { + ctx := context.Background() + b, storage := getBackend(false) + defer b.Cleanup(ctx) + configureOpenLDAPMount(t, b, storage) + + roleName := "hashicorp" + data := map[string]interface{}{ + "dn": "uid=hashicorp,ou=users,dc=hashicorp,dc=com", + "rotation_period": float64(5), + "username": "hashicorp", + "password": "initialPassword!23", + "self_managed": true, + "skip_import_rotation": true, // so we can validate wal is not deleted on update if password not changed + } + + resp, err := createStaticRoleWithData(t, b, storage, roleName, data) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + // Fail to rotate the roles + generateWALFromFailedRotation(t, b, storage, roleName) + + // Should have 1 WAL hanging around + requireWALs(t, storage, 1) + + // Update the self-managed static role's password + updateStaticRoleWithData(t, b, storage, roleName, map[string]interface{}{ + "rotation_period": float64(10), + }) + + // 1 WAL should still be there + requireWALs(t, storage, 1) +} + func configureOpenLDAPMount(t *testing.T, b *backend, storage logical.Storage) { t.Helper() diff --git a/rotation.go b/rotation.go index d656584f..71981798 100644 --- a/rotation.go +++ b/rotation.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/hashicorp/go-secure-stdlib/base62" @@ -27,6 +28,8 @@ const ( staticWALKey = "staticRotationKey" ) +var ErrMaxRotationAttempts = errors.New("max rotation attempts reached") + // populateQueue loads the priority queue with existing static accounts. This // occurs at initialization, after any WAL entries of failed or interrupted // rotations have been processed. It lists the roles from storage and searches @@ -147,6 +150,7 @@ type setCredentialsWAL struct { DN string `json:"dn" mapstructure:"dn"` PasswordPolicy string `json:"password_policy" mapstructure:"password_policy"` LastVaultRotation time.Time `json:"last_vault_rotation" mapstructure:"last_vault_rotation"` + Attempt int `json:"attempt" mapstructure:"attempt"` // Private fields which will not be included in json.Marshal/Unmarshal. walID string @@ -229,6 +233,11 @@ func (b *backend) rotateCredential(ctx context.Context, s logical.Storage) bool resp, err := b.setStaticAccountPassword(ctx, s, input) if err != nil { + if errors.Is(err, ErrMaxRotationAttempts) && input.Role.StaticAccount.SelfManaged { + b.Logger().Error("self-managed role max rotation attempts reached; suppressing further automatic rotations", "role", item.Key) + // Do not requeue this one and go to next item + return true + } b.Logger().Error("unable to rotate credentials in periodic function", "name", item.Key, "error", err) b.ldapEvent(ctx, "rotate-fail", "", item.Key, false) // Increment the priority enough so that the next call to this method @@ -348,14 +357,20 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag return output, errors.New("the config is currently unset") } + selfManagedMaxInvalidAttempts := input.Role.StaticAccount.SelfManagedMaxInvalidAttempts + if selfManagedMaxInvalidAttempts == 0 { + selfManagedMaxInvalidAttempts = defaultSelfManagedMaxInvalidAttempts + } + var newPassword string var usedCredentialFromPreviousRotation bool + var currentWAL *setCredentialsWAL if output.WALID != "" { wal, err := b.findStaticWAL(ctx, s, output.WALID) if err != nil { return output, fmt.Errorf("error retrieving WAL entry: %w", err) } - + currentWAL = wal switch { case wal == nil: b.Logger().Error("expected role to have WAL, but WAL not found in storage", "role", input.RoleName, "WAL ID", output.WALID) @@ -382,18 +397,22 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag if err != nil { return output, err } - output.WALID, err = framework.PutWAL(ctx, s, staticWALKey, &setCredentialsWAL{ + wal := &setCredentialsWAL{ RoleName: input.RoleName, Username: input.Role.StaticAccount.Username, DN: input.Role.StaticAccount.DN, NewPassword: newPassword, LastVaultRotation: input.Role.StaticAccount.LastVaultRotation, PasswordPolicy: config.PasswordPolicy, - }) + Attempt: 0, + } + output.WALID, err = framework.PutWAL(ctx, s, staticWALKey, wal) b.Logger().Debug("wrote WAL", "role", input.RoleName, "WAL ID", output.WALID) if err != nil { return output, fmt.Errorf("error writing WAL entry: %w", err) } + currentWAL = wal + currentWAL.walID = output.WALID } if newPassword == "" { @@ -404,16 +423,69 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag } } - // Perform the LDAP search with the DN if it's configured. DN-based search - // targets the object directly. Otherwise, search using the userdn, userattr, - // and username. UserDN-based search targets the object by searching the whole - // subtree rooted at the userDN. - if input.Role.StaticAccount.DN != "" { - err = b.client.UpdateDNPassword(config.LDAP, input.Role.StaticAccount.DN, newPassword) + // Perform password update: + if input.Role.StaticAccount.SelfManaged { + // Preconditions for self-managed rotation + if input.Role.StaticAccount.Password == "" { + return output, fmt.Errorf("self-managed static role %q has no stored current password", input.Role.StaticAccount.Username) + } + if input.Role.StaticAccount.DN == "" { + return output, fmt.Errorf("self-managed static role %q requires DN for rotation (no search path implemented)", input.Role.StaticAccount.Username) + } + err = b.client.UpdateSelfManagedDNPassword( + config.LDAP, + input.Role.StaticAccount.DN, + input.Role.StaticAccount.Password, + newPassword, + ) } else { - err = b.client.UpdateUserPassword(config.LDAP, input.Role.StaticAccount.Username, newPassword) + // Perform the LDAP search with the DN if it's configured. DN-based search + // targets the object directly. Otherwise, search using the userdn, userattr, + // and username. UserDN-based search targets the object by searching the whole + // subtree rooted at the userDN. + if input.Role.StaticAccount.DN != "" { + err = b.client.UpdateDNPassword(config.LDAP, input.Role.StaticAccount.DN, newPassword) + } else { + err = b.client.UpdateUserPassword(config.LDAP, input.Role.StaticAccount.Username, newPassword) + } } if err != nil { + // Special handling for self-managed invalid credential errors: + if input.Role.StaticAccount.SelfManaged && isInvalidCredErr(err) { + // Likely the stored current password is stale (changed out-of-band). + if currentWAL != nil && currentWAL.walID != "" { + if selfManagedMaxInvalidAttempts < 0 { // Unlimited attempts allowed + b.Logger().Warn("self-managed rotation failed due to invalid current password; will retry (unlimited attempts allowed)") + } else if currentWAL.Attempt < selfManagedMaxInvalidAttempts { // Retry allowed + b.Logger().Warn("self-managed rotation failed due to invalid current password; will retry", + "role", input.RoleName, "WAL ID", output.WALID, "attempt", currentWAL.Attempt, "max_attempts", selfManagedMaxInvalidAttempts, "error", err) + // Update Attempt count in WAL + newWALID, uErr := updateAttemptsInWAL(ctx, s, currentWAL, b) + if uErr != nil { + b.Logger().Warn("failed to update rotation attempt count in WAL", "role", input.RoleName, "error", uErr) + } else { + b.Logger().Debug("updated rotation attempt count in WAL", "role", input.RoleName, "WAL ID", output.WALID, "new WAL ID", newWALID, "attempt", currentWAL.Attempt) + output.WALID = newWALID + } + } else { // Max attempts reached + // delete wal so that if user pushes new updated password it won't be used + if delErr := framework.DeleteWAL(ctx, s, output.WALID); delErr != nil { + b.Logger().Error("failed deleting WAL after max attempts", "role", input.RoleName, "WAL ID", output.WALID, "error", delErr) + } + b.Logger().Error("max rotation attempts reached for self-managed role; suppressing further automatic rotations", "role", input.RoleName, "WAL ID", output.WALID, "attempts", currentWAL.Attempt) + input.Role.StaticAccount.RotationSuspended = true + entry, err := logical.StorageEntryJSON(staticRolePath+input.RoleName, input.Role) + if err != nil { + b.Logger().Warn("failed to build write storage entry to mark rotation suspended", "error", err, "role", input.RoleName) + } else if err := s.Put(ctx, entry); err != nil { + b.Logger().Warn("failed to write storage entry to mark rotation suspended", "error", err, "role", input.RoleName) + } + // returning this error stops further automatic rotations + return output, ErrMaxRotationAttempts + } + } + return output, err + } if usedCredentialFromPreviousRotation { b.Logger().Debug("password stored in WAL failed, deleting WAL", "role", input.RoleName, "WAL ID", output.WALID) if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { @@ -434,6 +506,8 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag input.Role.StaticAccount.SetNextVaultRotation(lvr) input.Role.StaticAccount.LastPassword = input.Role.StaticAccount.Password input.Role.StaticAccount.Password = newPassword + // Clear rotation suspended flag on successful rotation + input.Role.StaticAccount.RotationSuspended = false output.RotationTime = lvr entry, err := logical.StorageEntryJSON(staticRolePath+input.RoleName, input.Role) @@ -455,6 +529,25 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag return &setStaticAccountOutput{RotationTime: lvr}, nil } +func isInvalidCredErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "invalid credentials") || + strings.Contains(msg, "ldap result code 49") +} + +// Rewrite WAL with incremented Attempts (delete old, create new) +func updateAttemptsInWAL(ctx context.Context, s logical.Storage, wal *setCredentialsWAL, b *backend) (string, error) { + if delErr := framework.DeleteWAL(ctx, s, wal.walID); delErr != nil { + b.Logger().Warn("failed deleting WAL prior to rewrite", "role", wal.RoleName, "WAL ID", wal.walID, "error", delErr) + return wal.walID, delErr + } + wal.Attempt++ + return framework.PutWAL(ctx, s, staticWALKey, wal) +} + func (b *backend) GeneratePassword(ctx context.Context, cfg *config) (string, error) { if cfg.PasswordPolicy == "" { if cfg.PasswordLength == 0 { diff --git a/rotation_test.go b/rotation_test.go index c99d0e72..075b77d2 100644 --- a/rotation_test.go +++ b/rotation_test.go @@ -752,6 +752,128 @@ func TestDeletesOlderWALsOnLoad(t *testing.T) { requireWALs(t, storage, 1) } +func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { + ctx := context.Background() + b, storage := getBackend(false) + defer b.Cleanup(ctx) + + // Configure backend + configureOpenLDAPMount(t, b, storage) + + roleName := "self-managed" + createReq := &logical.Request{ + Operation: logical.CreateOperation, + Path: staticRolePath + roleName, + Storage: storage, + Data: map[string]interface{}{ + "username": roleName, + "dn": "uid=self-managed,ou=users,dc=hashicorp,dc=com", + "rotation_period": "1m", + "self_managed": true, + "self_managed_max_invalid_attempts": 1, // allow a single retry (attempt sequence: 0 -> 1 -> stop) + "password": "CurrentPassw0rd!", + }, + } + resp, err := b.HandleRequest(ctx, createReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + // validate password was changed on import + role, err := b.staticRole(ctx, storage, roleName) + require.NoError(t, err) + require.NotNil(t, role) + require.NotEqual(t, "CurrentPassw0rd!", role.StaticAccount.Password) + + // Make LDAP client return "invalid credentials" + ldapClient := b.client.(*fakeLdapClient) + ldapClient.throwsInvalidCredentialsErr = true + + // Ensure role queued + item, err := b.popFromRotationQueueByKey(roleName) + require.NoError(t, err) + require.NotNil(t, item) + // Force immediate rotation attempt + item.Priority = time.Now().Unix() - 1 + require.NoError(t, b.pushItem(item)) + + // First automatic rotation attempt (attempt 0 -> 1, WAL persisted & requeued) + b.rotateCredentials(ctx, storage) + + wals := requireWALs(t, storage, 1) + wal1, err := b.findStaticWAL(ctx, storage, wals[0]) + require.NoError(t, err) + require.Equal(t, 1, wal1.Attempt, "first failure should increment attempt to 1") + + // Fetch queued item, lower priority to trigger second attempt immediately + item, err = b.popFromRotationQueueByKey(roleName) + require.NoError(t, err) + require.NotNil(t, item) + item.Priority = time.Now().Unix() - 1 + require.NoError(t, b.pushItem(item)) + + // Second automatic rotation attempt should hit max attempts and NOT requeue + b.rotateCredentials(ctx, storage) + + // WAL should be deleted after max attempts + requireWALs(t, storage, 0) + + // Queue should no longer contain the role + _, err = b.popFromRotationQueueByKey(roleName) + require.Error(t, err, "expected role to be removed from queue after max attempts") + + // Additional ticks should do nothing because queue is empty + b.rotateCredentials(ctx, storage) + requireWALs(t, storage, 0) + // validate rotation_suspended flag set + role, err = b.staticRole(ctx, storage, roleName) + require.NoError(t, err) + require.NotNil(t, role) + require.True(t, role.StaticAccount.RotationSuspended) + + // update role password externally to simulate user fixing invalid password + updateReq := &logical.Request{ + Operation: logical.UpdateOperation, + Path: staticRolePath + roleName, + Storage: storage, + Data: map[string]interface{}{ + "password": "NewValidPassw0rd!", + }, + } + resp, err = b.HandleRequest(ctx, updateReq) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + // validate rotation_suspended flag set + role, err = b.staticRole(ctx, storage, roleName) + require.NoError(t, err) + require.NotNil(t, role) + require.False(t, role.StaticAccount.RotationSuspended) + // Make LDAP client work again + ldapClient.throwsInvalidCredentialsErr = false + // Ensure role readded to queue after update + item, err = b.popFromRotationQueueByKey(roleName) + require.NoError(t, err) + require.NotNil(t, item) + // Force immediate rotation attempt + item.Priority = time.Now().Unix() - 1 + require.NoError(t, b.pushItem(item)) + // First automatic rotation attempt + b.rotateCredentials(ctx, storage) + // validate no wal after successful rotation + requireWALs(t, storage, 0) + // validate requeued again + item, err = b.popFromRotationQueueByKey(roleName) + require.NoError(t, err) + require.NotNil(t, item) + // validate password was changed + role, err = b.staticRole(ctx, storage, roleName) + require.NoError(t, err) + require.NotNil(t, role) + require.NotEqual(t, "NewValidPassw0rd!", role.StaticAccount.Password) + // validate rotation_suspended flag cleared + require.False(t, role.StaticAccount.RotationSuspended) +} + func generateWALFromFailedRotation(t *testing.T, b *backend, storage logical.Storage, roleName string) { t.Helper() // Fail to rotate the roles