From 977b74df94dbf86f3a639afef97e1c1a998fc34f Mon Sep 17 00:00:00 2001 From: jadeidev <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 07:40:30 -0700 Subject: [PATCH 01/43] Add password field and self-managed flag to static account schema --- path_static_roles.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/path_static_roles.go b/path_static_roles.go index d7bbf2e5..ca51e01f 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -135,6 +135,10 @@ 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.", + }, "rotation_period": { Type: framework.TypeDurationSecond, Description: "Period for automatic credential rotation of the given entry.", @@ -289,6 +293,10 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R role.StaticAccount.DN = dn } + if passwordRaw, ok := data.GetOk("password"); ok { + role.StaticAccount.Password = passwordRaw.(string) + role.StaticAccount.SelfManaged = true + } rotationPeriodSecondsRaw, ok := data.GetOk("rotation_period") if !ok && isCreate { @@ -460,6 +468,10 @@ 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"` } // NextRotationTime calculates the next rotation by adding the Rotation Period From 2d07e22b68e8865803b09a44ee8ea9a5f57dda30 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:51:53 -0700 Subject: [PATCH 02/43] update inputs --- path_static_roles.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index ca51e01f..44cba595 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -147,6 +147,11 @@ 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, + }, } return fields } @@ -293,11 +298,26 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R role.StaticAccount.DN = dn } + passwordInput := "" if passwordRaw, ok := data.GetOk("password"); ok { - role.StaticAccount.Password = passwordRaw.(string) - role.StaticAccount.SelfManaged = true + passwordInput = passwordRaw.(string) + } + 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 + } + // only set password provided if it is self_managed + if sm && passwordInput != "" { + role.StaticAccount.Password = passwordInput + } else if sm && passwordInput == "" { + return logical.ErrorResponse("password is required for self-managed static accounts"), nil + } else if !sm && passwordInput != "" { + return logical.ErrorResponse("cannot set password for non-self-managed static accounts"), nil + } + // If user explicitly set false while also providing password, honor explicit false. + role.StaticAccount.SelfManaged = sm } - rotationPeriodSecondsRaw, ok := data.GetOk("rotation_period") if !ok && isCreate { return logical.ErrorResponse("rotation_period is required to create static accounts"), nil From c507e45697724c667644e4ed6eab05018a456c9e Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:36:31 -0700 Subject: [PATCH 03/43] Enhance self-managed static account handling: - Mark password field as sensitive in schema. - Add validation for distinguished name (DN) when setting self-managed accounts. - Improve error handling for password rotation with self-managed accounts. --- path_static_roles.go | 7 ++++++- rotation.go | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 44cba595..6a42ca23 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -138,6 +138,9 @@ func staticFields() 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, @@ -308,10 +311,12 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R return logical.ErrorResponse("cannot change self_managed after creation"), nil } // only set password provided if it is self_managed - if sm && passwordInput != "" { + if sm && passwordInput != "" && role.StaticAccount.DN != "" { role.StaticAccount.Password = passwordInput } else if sm && passwordInput == "" { return logical.ErrorResponse("password is required for self-managed static accounts"), nil + } else if sm && role.StaticAccount.DN == "" { + return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil } else if !sm && passwordInput != "" { return logical.ErrorResponse("cannot set password for non-self-managed static accounts"), nil } diff --git a/rotation.go b/rotation.go index 8865dbb9..96b48769 100644 --- a/rotation.go +++ b/rotation.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/hashicorp/go-secure-stdlib/base62" @@ -345,6 +346,8 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag if config == nil { return output, errors.New("the config is currently unset") } + // Create a copy of the config to modify for rotation + rotateConfig := *config.LDAP var newPassword string var usedCredentialFromPreviousRotation bool @@ -402,16 +405,37 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag } } + // 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) + } + // change the config to use the static account + rotateConfig.BindDN = input.Role.StaticAccount.DN + rotateConfig.BindPassword = input.Role.StaticAccount.Password + } // 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) + err = b.client.UpdateDNPassword(&rotateConfig, input.Role.StaticAccount.DN, newPassword) } else { - err = b.client.UpdateUserPassword(config.LDAP, input.Role.StaticAccount.Username, newPassword) + err = b.client.UpdateUserPassword(&rotateConfig, 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). + // Keep the WAL (if any) so we can retry after correction; just return error. + b.Logger().Error("self-managed rotation failed due to invalid current password; WAL retained", + "role", input.RoleName, "WAL ID", output.WALID, "error", err) + 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 { @@ -453,6 +477,15 @@ 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") +} + func (b *backend) GeneratePassword(ctx context.Context, cfg *config) (string, error) { if cfg.PasswordPolicy == "" { if cfg.PasswordLength == 0 { From 05583172142b624514e4d52779e2b55754a2af9c Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:14:33 -0700 Subject: [PATCH 04/43] Add tests for self-managed static role creation and validation --- path_static_roles_test.go | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 232174fc..376a2cce 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -433,6 +433,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) { From 66b53a9220ae2adb20c7c15c8e0debbe926edeb0 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 20:44:04 -0700 Subject: [PATCH 05/43] Add tests for self-managed role password rotation and policy updates --- path_static_roles_test.go | 119 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 376a2cce..194ac1f3 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -68,6 +68,18 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { }, wantUpdateErr: true, }, + { + 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: "including skip_import_rotation is an update error", createData: map[string]interface{}{ @@ -599,6 +611,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) { From 56130b4972d165718b1f3d58d00ee15b14aca754 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Thu, 18 Sep 2025 22:15:12 -0700 Subject: [PATCH 06/43] change call for password change --- client.go | 26 ++++++++++++++++++++++++++ rotation.go | 26 +++++++++++++++----------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/client.go b/client.go index 4c94228e..8834edb8 100644 --- a/client.go +++ b/client.go @@ -17,6 +17,7 @@ import ( type ldapClient interface { UpdateDNPassword(conf *client.Config, dn string, newPassword string) error UpdateUserPassword(conf *client.Config, user, newPassword string) error + UpdateSelfDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) error } @@ -87,6 +88,31 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas return c.ldap.UpdatePassword(conf, conf.UserDN, ldap.ScopeWholeSubtree, newValues, filters) } +func (c *Client) UpdateSelfDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error { + if dn == "" { + // Optionally implement a search to resolve DN from username, userdn, userattr in cfg. + return fmt.Errorf("user DN resolution not implemented") + } + + // newValues, err := client.GetSchemaFieldRegistry(conf, newPassword) + // if err != nil { + // return fmt.Errorf("error updating password: %s", err) + // } + conn, err := c.ldap.DialLDAP(conf.ConfigEntry) + if err != nil { + return err + } + defer conn.Close() + + // Use Password Modify Extended Operation (RFC 3062) + req := ldap.NewPasswordModifyRequest(dn, currentPassword, newPassword) + _, err = conn.PasswordModify(req) + if err != nil { + return fmt.Errorf("password modify failed: %w", err) + } + return nil +} + func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { return c.ldap.Execute(conf, entries, continueOnError) } diff --git a/rotation.go b/rotation.go index 96b48769..b6e4a7f4 100644 --- a/rotation.go +++ b/rotation.go @@ -414,18 +414,22 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag 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) } - // change the config to use the static account - rotateConfig.BindDN = input.Role.StaticAccount.DN - rotateConfig.BindPassword = input.Role.StaticAccount.Password - } - // 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(&rotateConfig, input.Role.StaticAccount.DN, newPassword) + err = b.client.UpdateSelfDNPassword( + config.LDAP, + input.Role.StaticAccount.DN, + input.Role.StaticAccount.Password, + newPassword, + ) } else { - err = b.client.UpdateUserPassword(&rotateConfig, 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(&rotateConfig, input.Role.StaticAccount.DN, newPassword) + } else { + err = b.client.UpdateUserPassword(&rotateConfig, input.Role.StaticAccount.Username, newPassword) + } } if err != nil { // Special handling for self-managed invalid credential errors: From 140d2f84b62cbf7d0d27bd9c9bb24a26a2b31d4d Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:51:58 -0700 Subject: [PATCH 07/43] Implement self-managed password update functionality and related tests --- backend_test.go | 8 ++++++ client.go | 28 ++++++++++---------- client/client.go | 62 +++++++++++++++++++++++++++++++++++++++++++++ mocks_test.go | 5 ++++ path_rotate_test.go | 4 +++ 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/backend_test.go b/backend_test.go index 67a0b77d..0cb34d9a 100644 --- a/backend_test.go +++ b/backend_test.go @@ -118,6 +118,14 @@ func (f *fakeLdapClient) UpdateDNPassword(_ *client.Config, _ string, _ string) return err } +func (f *fakeLdapClient) UpdateSelfDNPassword(_ *client.Config, _ string, _ string, _ string) error { + var err error + if f.throwErrs { + err = errors.New("forced error") + } + 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 8834edb8..36ed3cea 100644 --- a/client.go +++ b/client.go @@ -88,29 +88,29 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas return c.ldap.UpdatePassword(conf, conf.UserDN, ldap.ScopeWholeSubtree, newValues, filters) } -func (c *Client) UpdateSelfDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error { +func (c *Client) UpdateSelfDNPassword(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 fmt.Errorf("user DN resolution not implemented") } - // newValues, err := client.GetSchemaFieldRegistry(conf, newPassword) - // if err != nil { - // return fmt.Errorf("error updating password: %s", err) - // } - conn, err := c.ldap.DialLDAP(conf.ConfigEntry) + scope := ldap.ScopeBaseObject + filters := map[*client.Field][]string{ + client.FieldRegistry.ObjectClass: {"*"}, + } + currentValues, err := client.GetSchemaFieldRegistry(conf, currentPassword) if err != nil { - return err + return fmt.Errorf("error updating password: %s", err) } - defer conn.Close() - - // Use Password Modify Extended Operation (RFC 3062) - req := ldap.NewPasswordModifyRequest(dn, currentPassword, newPassword) - _, err = conn.PasswordModify(req) + newValues, err := client.GetSchemaFieldRegistry(conf, newPassword) if err != nil { - return fmt.Errorf("password modify failed: %w", err) + return fmt.Errorf("error updating password: %s", err) } - return nil + rotationConf := *conf + rotationConf.BindDN = dn + rotationConf.BindPassword = currentPassword + + return c.ldap.UpdateSelManagedPassword(&rotationConf, scope, currentValues, newValues, filters) } func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { diff --git a/client/client.go b/client/client.go index 683d9124..d71c3893 100644 --- a/client/client.go +++ b/client/client.go @@ -121,6 +121,68 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues return c.UpdateEntry(cfg, baseDN, scope, filters, newValues) } +// ChangePassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. +// This is for usage of self managed password since Replace modification like the one done in `UpdateEntry` requires extra permissions. +func (c *Client) UpdateSelManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { + entries, err := c.Search(cfg, cfg.BindDN, 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(cfg.ConfigEntry) + if err != nil { + return err + } + defer conn.Close() + + if err := bind(cfg, conn); err != nil { + return err + } + switch cfg.Schema { + case SchemaAD: + modifyReq := &ldap.ModifyRequest{ + DN: entries[0].DN, + } + + for field, vals := range currentValues { + modifyReq.Delete(field.String(), vals) + } + for field, vals := range newValues { + modifyReq.Add(field.String(), vals) + } + return conn.Modify(modifyReq) + case SchemaOpenLDAP: + var oldPassword, newPassword string + for f, vals := range currentValues { + if f == FieldRegistry.UserPassword && len(vals) > 0 { + oldPassword = vals[0] + } + } + for f, vals := range newValues { + if f == FieldRegistry.UserPassword && len(vals) > 0 { + newPassword = vals[0] + } + } + if oldPassword == "" || newPassword == "" { + return errors.New("both current and new password must be provided") + } + req := ldap.NewPasswordModifyRequest(entries[0].DN, oldPassword, newPassword) + 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(cfg, cfg.BindDN, scope, filters, newValues) + } + _, err = pmConn.PasswordModify(req) + return err + default: + return fmt.Errorf("configured schema %s not valid", cfg.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/mocks_test.go b/mocks_test.go index 19446794..1981e16e 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) UpdateSelfDNPassword(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..5fa69f03 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) UpdateSelfDNPassword(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") } From c51d394e3bb5c7aba3e6e667d51c2f96b7e83802 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:53:17 -0700 Subject: [PATCH 08/43] update doc --- client/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/client.go b/client/client.go index d71c3893..dd032c2f 100644 --- a/client/client.go +++ b/client/client.go @@ -121,7 +121,7 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues return c.UpdateEntry(cfg, baseDN, scope, filters, newValues) } -// ChangePassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. +// UpdateSelManagedPassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. // This is for usage of self managed password since Replace modification like the one done in `UpdateEntry` requires extra permissions. func (c *Client) UpdateSelManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { entries, err := c.Search(cfg, cfg.BindDN, scope, filters) From a0100753032c1c5aafa75d3d93e04adcaaf7cce6 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:38:49 -0700 Subject: [PATCH 09/43] Implement self-managed password update functionality and add related tests --- client.go | 9 +- client/client.go | 20 ++--- client/client_test.go | 193 ++++++++++++++++++++++++++++++++++++++++++ ldapifc/fakes.go | 14 ++- 4 files changed, 221 insertions(+), 15 deletions(-) diff --git a/client.go b/client.go index 36ed3cea..5294947f 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ package openldap import ( + "errors" "fmt" "github.com/go-ldap/ldap/v3" @@ -91,7 +92,10 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas func (c *Client) UpdateSelfDNPassword(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 fmt.Errorf("user DN resolution not implemented") + 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 @@ -106,11 +110,12 @@ func (c *Client) UpdateSelfDNPassword(conf *client.Config, dn string, currentPas if err != nil { return fmt.Errorf("error updating password: %s", err) } + // Use a copy of the config to avoid modifying the original with the bind dn/password for rotation rotationConf := *conf rotationConf.BindDN = dn rotationConf.BindPassword = currentPassword - return c.ldap.UpdateSelManagedPassword(&rotationConf, scope, currentValues, newValues, filters) + return c.ldap.UpdateSelfManagedPassword(&rotationConf, scope, currentValues, newValues, filters) } func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { diff --git a/client/client.go b/client/client.go index dd032c2f..04683676 100644 --- a/client/client.go +++ b/client/client.go @@ -121,9 +121,10 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues return c.UpdateEntry(cfg, baseDN, scope, filters, newValues) } -// UpdateSelManagedPassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. +// UpdateSelfManagedPassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. // This is for usage of self managed password since Replace modification like the one done in `UpdateEntry` requires extra permissions. -func (c *Client) UpdateSelManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { +func (c *Client) UpdateSelfManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { + // perform self search to validate account exists and current password is correct entries, err := c.Search(cfg, cfg.BindDN, scope, filters) if err != nil { return err @@ -154,21 +155,18 @@ func (c *Client) UpdateSelManagedPassword(cfg *Config, scope int, currentValues } return conn.Modify(modifyReq) case SchemaOpenLDAP: - var oldPassword, newPassword string + var currentPassword, newPassword string for f, vals := range currentValues { - if f == FieldRegistry.UserPassword && len(vals) > 0 { - oldPassword = vals[0] + if f == FieldRegistry.UserPassword && len(vals) == 1 { + currentPassword = vals[0] } } for f, vals := range newValues { - if f == FieldRegistry.UserPassword && len(vals) > 0 { + if f == FieldRegistry.UserPassword && len(vals) == 1 { newPassword = vals[0] } } - if oldPassword == "" || newPassword == "" { - return errors.New("both current and new password must be provided") - } - req := ldap.NewPasswordModifyRequest(entries[0].DN, oldPassword, newPassword) + req := ldap.NewPasswordModifyRequest(entries[0].DN, currentPassword, newPassword) pmConn, ok := conn.(interface { PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) }) @@ -178,6 +176,8 @@ func (c *Client) UpdateSelManagedPassword(cfg *Config, scope int, currentValues } _, err = pmConn.PasswordModify(req) return err + case SchemaRACF: + return c.UpdateEntry(cfg, cfg.BindDN, scope, filters, newValues) default: return fmt.Errorf("configured schema %s not valid", cfg.Schema) } diff --git a/client/client_test.go b/client/client_test.go index 64c46461..2e4ba183 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -267,6 +267,199 @@ func TestUpdatePasswordAD(t *testing.T) { } } +func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { + testPass := "hell0$catz*" + currentPass := "dogs" + + config := emptyConfig() + dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" + config.BindDN = dn + config.BindPassword = currentPass + + 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: {"*"}, + } + + config.Schema = SchemaOpenLDAP + currentValues, err := GetSchemaFieldRegistry(config, currentPass) + if err != nil { + t.Fatal(err) + } + newValues, err := GetSchemaFieldRegistry(config, testPass) + if err != nil { + t.Fatal(err) + } + + if err := client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, filters); err != nil { + t.Fatal(err) + } +} + +func TestUpdateSelfManagedPasswordRACF(t *testing.T) { + testPassword := "pass1234" + testPhrase := "this is a much longer passphrase for racfPassPhrase" + + tests := []struct { + name string + password string + credentialType CredentialType + expectedFields map[*Field][]string + }{ + { + name: "password", + password: testPassword, + credentialType: CredentialTypePassword, + expectedFields: map[*Field][]string{ + FieldRegistry.RACFPassword: {testPassword}, + FieldRegistry.RACFAttributes: {"noexpired"}, + }, + }, + { + name: "passphrase", + password: testPhrase, + credentialType: CredentialTypePhrase, + expectedFields: map[*Field][]string{ + FieldRegistry.RACFPassphrase: {testPhrase}, + FieldRegistry.RACFAttributes: {"noexpired"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + currentPass := "dogs" + dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" + config := emptyConfig() + config.BindDN = dn + config.BindPassword = currentPass + config.Schema = SchemaRACF + config.CredentialType = tt.credentialType + + conn := &ldapifc.FakeLDAPConnection{ + SearchRequestToExpect: testSearchRequest(), + SearchResultToReturn: testSearchResult(), + } + + conn.ModifyRequestToExpect = &ldap.ModifyRequest{ + DN: dn, + } + + // Set up expected modifications based on the test case + for field, values := range tt.expectedFields { + conn.ModifyRequestToExpect.Replace(field.String(), values) + } + + ldapClient := &ldaputil.Client{ + Logger: hclog.NewNullLogger(), + LDAP: &ldapifc.FakeLDAPClient{conn}, + } + + client := &Client{ldapClient} + + filters := map[*Field][]string{ + FieldRegistry.ObjectClass: {"*"}, + } + + currentValues, err := GetSchemaFieldRegistry(config, currentPass) + require.NoError(t, err) + + newValues, err := GetSchemaFieldRegistry(config, tt.password) + require.NoError(t, err) + + // verify that the fields are set correctly in newValues + for field, expectedValues := range tt.expectedFields { + actualValues, exists := newValues[field] + if !exists { + t.Fatalf("Expected field %s to exist in newValues", field.String()) + } + + require.Equal(t, expectedValues, actualValues) + } + + err = client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, filters) + require.NoError(t, err) + }) + } +} + +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.BindDN = dn + config.BindPassword = currentPass + + 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: {"*"}, + } + + config.Schema = SchemaAD + currentValues, err := GetSchemaFieldRegistry(config, currentPass) + if err != nil { + t.Fatal(err) + } + newValues, err := GetSchemaFieldRegistry(config, testPass) + if err != nil { + t.Fatal(err) + } + if p, ok := newValues[FieldRegistry.UnicodePassword]; !ok { + t.Fatal("Expected unicodePwd field to be populated") + } else if len(p) != 1 { + t.Fatalf("Expected exactly one entry for unicodePwd but got %d", len(p)) + } else if p[0] != encodedTestPass { + t.Fatalf("Expected unicodePwd field equal to %q but got %q", encodedTestPass, p[0]) + } + + if err := client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, 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/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 +} From 85831f0a34d7143d957662197f5c0d5dcb6fb11f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:35:44 -0700 Subject: [PATCH 10/43] Rename UpdateSelfDNPassword to UpdateSelfManagedDNPassword across client and test files --- backend_test.go | 2 +- client.go | 4 ++-- client_test.go | 37 +++++++++++++++++++++++++++++++++++++ mocks_test.go | 2 +- path_rotate_test.go | 2 +- rotation.go | 2 +- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/backend_test.go b/backend_test.go index 0cb34d9a..816f82d5 100644 --- a/backend_test.go +++ b/backend_test.go @@ -118,7 +118,7 @@ func (f *fakeLdapClient) UpdateDNPassword(_ *client.Config, _ string, _ string) return err } -func (f *fakeLdapClient) UpdateSelfDNPassword(_ *client.Config, _ string, _ string, _ string) error { +func (f *fakeLdapClient) UpdateSelfManagedDNPassword(_ *client.Config, _ string, _ string, _ string) error { var err error if f.throwErrs { err = errors.New("forced error") diff --git a/client.go b/client.go index 5294947f..67827c72 100644 --- a/client.go +++ b/client.go @@ -18,7 +18,7 @@ import ( type ldapClient interface { UpdateDNPassword(conf *client.Config, dn string, newPassword string) error UpdateUserPassword(conf *client.Config, user, newPassword string) error - UpdateSelfDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error + UpdateSelfManagedDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) error } @@ -89,7 +89,7 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas return c.ldap.UpdatePassword(conf, conf.UserDN, ldap.ScopeWholeSubtree, newValues, filters) } -func (c *Client) UpdateSelfDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { +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") diff --git a/client_test.go b/client_test.go index 60cd8d28..248b103e 100644 --- a/client_test.go +++ b/client_test.go @@ -213,6 +213,43 @@ 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, + } + + c := NewClient(hclog.NewNullLogger()) + newPassword := "newpassword" + err := c.UpdateSelfManagedDNPassword(config, "cn=admin,dc=example,dc=org", "admin", newPassword) + assert.NoError(t, err) +} + func setupDockerLDAP(t *testing.T) string { t.Helper() pool, err := dockertest.NewPool("") diff --git a/mocks_test.go b/mocks_test.go index 1981e16e..81056d3c 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -28,7 +28,7 @@ func (m *mockLDAPClient) UpdateUserPassword(conf *client.Config, user string, ne return args.Error(0) } -func (m *mockLDAPClient) UpdateSelfDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { +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) } diff --git a/path_rotate_test.go b/path_rotate_test.go index 5fa69f03..6af08670 100644 --- a/path_rotate_test.go +++ b/path_rotate_test.go @@ -213,7 +213,7 @@ func (f *failingRollbackClient) UpdateDNPassword(conf *client.Config, dn string, return fmt.Errorf("some error") } -func (f *failingRollbackClient) UpdateSelfDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { +func (f *failingRollbackClient) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error { panic("nope") } diff --git a/rotation.go b/rotation.go index b6e4a7f4..510e6d01 100644 --- a/rotation.go +++ b/rotation.go @@ -414,7 +414,7 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag 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.UpdateSelfDNPassword( + err = b.client.UpdateSelfManagedDNPassword( config.LDAP, input.Role.StaticAccount.DN, input.Role.StaticAccount.Password, From e1ab72b2331df45df8520bdf468a648da1242d50 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:12:02 -0700 Subject: [PATCH 11/43] add self managed to read response --- path_static_roles.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 6a42ca23..7ae8d14f 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -234,8 +234,9 @@ 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, } data["rotation_period"] = role.StaticAccount.RotationPeriod.Seconds() From a88554cb599ea010437c387a4db50ac35b4e048a Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:29:54 -0700 Subject: [PATCH 12/43] Add self-managed max invalid attempts configuration and handling --- path_static_roles.go | 28 ++++++++++++++++++--- rotation.go | 60 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 7ae8d14f..21bfd93f 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 @@ -155,6 +157,11 @@ func staticFields() map[string]*framework.FieldSchema { 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 less than or equal to 0 means use the default. Immutable after creation.", + Default: defaultSelfManagedMaxInvalidAttempts, + }, } return fields } @@ -234,9 +241,10 @@ func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, } data := map[string]interface{}{ - "dn": role.StaticAccount.DN, - "username": role.StaticAccount.Username, - "self_managed": role.StaticAccount.SelfManaged, + "dn": role.StaticAccount.DN, + "username": role.StaticAccount.Username, + "self_managed": role.StaticAccount.SelfManaged, + "self_managed_max_invalid_attempts": role.StaticAccount.SelfManagedMaxInvalidAttempts, } data["rotation_period"] = role.StaticAccount.RotationPeriod.Seconds() @@ -324,6 +332,16 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // If user explicitly set false while also providing password, honor explicit false. role.StaticAccount.SelfManaged = sm } + if maxInvalidRaw, ok := data.GetOk("self_managed_max_invalid_attempts"); ok { + maxInvalid := maxInvalidRaw.(int) + if !isCreate && maxInvalid != role.StaticAccount.SelfManagedMaxInvalidAttempts { + return logical.ErrorResponse("cannot change self_managed_max_invalid_attempts after creation"), nil + } + if maxInvalid <= 0 { + maxInvalid = defaultSelfManagedMaxInvalidAttempts + } + 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 @@ -498,6 +516,10 @@ type staticAccount struct { // 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"` } // NextRotationTime calculates the next rotation by adding the Rotation Period diff --git a/rotation.go b/rotation.go index 510e6d01..2859c5a3 100644 --- a/rotation.go +++ b/rotation.go @@ -28,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 @@ -148,6 +150,8 @@ 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"` + MaxAttempts int `json:"max_attempts" mapstructure:"max_attempts"` // Private fields which will not be included in json.Marshal/Unmarshal. walID string @@ -230,6 +234,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) { + b.Logger().Error("max rotation attempts reached; suppressing further automatic rotations", "role", item.Key) + // Do not requeue (admin must intervene: reset attempts by deleting WAL or forcing manual rotation) + return true + } b.Logger().Error("unable to rotate credentials in periodic function", "name", item.Key, "error", err) // Increment the priority enough so that the next call to this method // likely will not attempt to rotate it, as a back-off of sorts @@ -351,24 +360,26 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag 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) - // Generate a new WAL entry and credential output.WALID = "" + case input.Role.StaticAccount.SelfManaged && wal.MaxAttempts > 0 && wal.Attempt >= wal.MaxAttempts: + b.Logger().Error("max rotation attempts reached for role; suppressing further automatic rotations", "role", input.RoleName, "WAL ID", output.WALID, "attempts", wal.Attempt) + return output, ErrMaxRotationAttempts case wal.NewPassword != "" && wal.PasswordPolicy != config.PasswordPolicy: b.Logger().Debug("password policy changed, generating new password", "role", input.RoleName, "WAL ID", output.WALID) if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) } - // Generate a new WAL entry and credential output.WALID = "" default: @@ -383,18 +394,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, + MaxAttempts: input.Role.StaticAccount.SelfManagedMaxInvalidAttempts, + } + 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 } if newPassword == "" { @@ -435,9 +450,28 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag // 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). - // Keep the WAL (if any) so we can retry after correction; just return error. - b.Logger().Error("self-managed rotation failed due to invalid current password; WAL retained", - "role", input.RoleName, "WAL ID", output.WALID, "error", err) + if currentWAL != nil && currentWAL.walID != "" { + if currentWAL.Attempt < currentWAL.MaxAttempts { // 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", currentWAL.MaxAttempts, "error", err) + // Update Attempt count in WAL + newID, 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", newID, "attempt", currentWAL.Attempt) + output.WALID = newID + } + } else { // Max attempts reached + // delete wal so that is user pushes new updated password it will be used + if delErr := framework.DeleteWAL(ctx, s, output.WALID); delErr != nil { + b.Logger().Warn("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) + // returning this error stops further automatic rotations + return output, ErrMaxRotationAttempts + } + } return output, err } if usedCredentialFromPreviousRotation { @@ -490,6 +524,16 @@ func isInvalidCredErr(err error) bool { 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 { From 8737e5065a5f7ddaa038a7265f4e6f1b29e2dc4f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:59:04 -0700 Subject: [PATCH 13/43] Add handling for externally modified passwords in self-managed accounts --- path_static_roles.go | 11 +++++++++++ rotation.go | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/path_static_roles.go b/path_static_roles.go index 21bfd93f..c32009d1 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -332,6 +332,11 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // If user explicitly set false while also providing password, honor explicit false. role.StaticAccount.SelfManaged = sm } + if role.StaticAccount.SelfManaged && !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { + role.StaticAccount.PasswordModifiedExternally = true + } else { + role.StaticAccount.PasswordModifiedExternally = false + } if maxInvalidRaw, ok := data.GetOk("self_managed_max_invalid_attempts"); ok { maxInvalid := maxInvalidRaw.(int) if !isCreate && maxInvalid != role.StaticAccount.SelfManagedMaxInvalidAttempts { @@ -520,6 +525,12 @@ type staticAccount struct { // 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"` + + // PasswordModifiedExternally is true when Vault has detected (or has been + // informed) that the LDAP account password was modified outside of Vault's + // managed rotation workflow (an out-of-band / external change). When set, + // reconciliation logic can re-assume management or trigger a fresh rotation. + PasswordModifiedExternally bool `json:"password_modified_externally"` } // NextRotationTime calculates the next rotation by adding the Rotation Period diff --git a/rotation.go b/rotation.go index 2859c5a3..da007950 100644 --- a/rotation.go +++ b/rotation.go @@ -372,7 +372,13 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag b.Logger().Error("expected role to have WAL, but WAL not found in storage", "role", input.RoleName, "WAL ID", output.WALID) // Generate a new WAL entry and credential output.WALID = "" - case input.Role.StaticAccount.SelfManaged && wal.MaxAttempts > 0 && wal.Attempt >= wal.MaxAttempts: + case input.Role.StaticAccount.SelfManaged && input.Role.StaticAccount.PasswordModifiedExternally: + b.Logger().Info("detected password change outside of Vault; proceeding with rotation using new password", "role", input.RoleName, "WAL ID", output.WALID) + if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { + b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) + } + output.WALID = "" + case input.Role.StaticAccount.SelfManaged && !input.Role.StaticAccount.PasswordModifiedExternally && wal.Attempt >= wal.MaxAttempts: b.Logger().Error("max rotation attempts reached for role; suppressing further automatic rotations", "role", input.RoleName, "WAL ID", output.WALID, "attempts", wal.Attempt) return output, ErrMaxRotationAttempts case wal.NewPassword != "" && wal.PasswordPolicy != config.PasswordPolicy: @@ -494,6 +500,7 @@ 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 + input.Role.StaticAccount.PasswordModifiedExternally = false output.RotationTime = lvr entry, err := logical.StorageEntryJSON(staticRolePath+input.RoleName, input.Role) From f1f10ff0a400b70397221800c8426f8ad55b2ee4 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 07:57:36 -0700 Subject: [PATCH 14/43] Update rotation.go --- rotation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rotation.go b/rotation.go index da007950..b9707b73 100644 --- a/rotation.go +++ b/rotation.go @@ -378,7 +378,7 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) } output.WALID = "" - case input.Role.StaticAccount.SelfManaged && !input.Role.StaticAccount.PasswordModifiedExternally && wal.Attempt >= wal.MaxAttempts: + case input.Role.StaticAccount.SelfManaged && wal.Attempt >= wal.MaxAttempts: b.Logger().Error("max rotation attempts reached for role; suppressing further automatic rotations", "role", input.RoleName, "WAL ID", output.WALID, "attempts", wal.Attempt) return output, ErrMaxRotationAttempts case wal.NewPassword != "" && wal.PasswordPolicy != config.PasswordPolicy: From fb278a640fb389f8e52720becf1980181688f066 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 20:57:48 -0700 Subject: [PATCH 15/43] Refine self-managed password handling and update WAL logic for external modifications --- path_static_roles.go | 97 ++++++++++++++++++++++++++++---------------- rotation.go | 30 ++++++-------- 2 files changed, 74 insertions(+), 53 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index c32009d1..ba552325 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -159,7 +159,7 @@ func staticFields() map[string]*framework.FieldSchema { }, "self_managed_max_invalid_attempts": { Type: framework.TypeInt, - Description: "Maximum number of invalid current-password attempts for self-managed accounts. A value less than or equal to 0 means use the default. Immutable after creation.", + 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.", Default: defaultSelfManagedMaxInvalidAttempts, }, } @@ -207,28 +207,11 @@ 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) - } - } - } - return nil, merr.ErrorOrNil() + return nil, err } func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -314,6 +297,11 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R if passwordRaw, ok := data.GetOk("password"); ok { passwordInput = passwordRaw.(string) } + passwordModifiedExternally := false + if !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { + b.Logger().Debug("external password change for static role", "role", name) + passwordModifiedExternally = true + } if smRaw, ok := data.GetOk("self_managed"); ok { sm := smRaw.(bool) if !isCreate && sm != role.StaticAccount.SelfManaged { @@ -332,17 +320,9 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // If user explicitly set false while also providing password, honor explicit false. role.StaticAccount.SelfManaged = sm } - if role.StaticAccount.SelfManaged && !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { - role.StaticAccount.PasswordModifiedExternally = true - } else { - role.StaticAccount.PasswordModifiedExternally = false - } if maxInvalidRaw, ok := data.GetOk("self_managed_max_invalid_attempts"); ok { maxInvalid := maxInvalidRaw.(int) - if !isCreate && maxInvalid != role.StaticAccount.SelfManagedMaxInvalidAttempts { - return logical.ErrorResponse("cannot change self_managed_max_invalid_attempts after creation"), nil - } - if maxInvalid <= 0 { + if maxInvalid == 0 { maxInvalid = defaultSelfManagedMaxInvalidAttempts } role.StaticAccount.SelfManagedMaxInvalidAttempts = maxInvalid @@ -447,6 +427,14 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R } } case logical.UpdateOperation: + if passwordModifiedExternally && role.StaticAccount.SelfManaged { + // If the password was modified 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 + } + } // if lastVaultRotation is zero, the role had `skip_import_rotation` set if lastVaultRotation.IsZero() { lastVaultRotation = time.Now() @@ -485,6 +473,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"` } @@ -525,12 +540,6 @@ type staticAccount struct { // 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"` - - // PasswordModifiedExternally is true when Vault has detected (or has been - // informed) that the LDAP account password was modified outside of Vault's - // managed rotation workflow (an out-of-band / external change). When set, - // reconciliation logic can re-assume management or trigger a fresh rotation. - PasswordModifiedExternally bool `json:"password_modified_externally"` } // NextRotationTime calculates the next rotation by adding the Rotation Period @@ -607,9 +616,27 @@ 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. ` const staticRolesListHelpDescription = ` diff --git a/rotation.go b/rotation.go index b9707b73..c2f925c2 100644 --- a/rotation.go +++ b/rotation.go @@ -357,6 +357,7 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag } // Create a copy of the config to modify for rotation rotateConfig := *config.LDAP + selfManagedMaxInvalidAttempts := input.Role.StaticAccount.SelfManagedMaxInvalidAttempts var newPassword string var usedCredentialFromPreviousRotation bool @@ -370,22 +371,15 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag 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) + // Generate a new WAL entry and credential output.WALID = "" - case input.Role.StaticAccount.SelfManaged && input.Role.StaticAccount.PasswordModifiedExternally: - b.Logger().Info("detected password change outside of Vault; proceeding with rotation using new password", "role", input.RoleName, "WAL ID", output.WALID) - if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { - b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) - } - output.WALID = "" - case input.Role.StaticAccount.SelfManaged && wal.Attempt >= wal.MaxAttempts: - b.Logger().Error("max rotation attempts reached for role; suppressing further automatic rotations", "role", input.RoleName, "WAL ID", output.WALID, "attempts", wal.Attempt) - return output, ErrMaxRotationAttempts case wal.NewPassword != "" && wal.PasswordPolicy != config.PasswordPolicy: b.Logger().Debug("password policy changed, generating new password", "role", input.RoleName, "WAL ID", output.WALID) if err := framework.DeleteWAL(ctx, s, output.WALID); err != nil { b.Logger().Warn("failed to delete WAL", "error", err, "WAL ID", output.WALID) } + // Generate a new WAL entry and credential output.WALID = "" default: @@ -408,7 +402,6 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag LastVaultRotation: input.Role.StaticAccount.LastVaultRotation, PasswordPolicy: config.PasswordPolicy, Attempt: 0, - MaxAttempts: input.Role.StaticAccount.SelfManagedMaxInvalidAttempts, } output.WALID, err = framework.PutWAL(ctx, s, staticWALKey, wal) b.Logger().Debug("wrote WAL", "role", input.RoleName, "WAL ID", output.WALID) @@ -457,21 +450,23 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag if input.Role.StaticAccount.SelfManaged && isInvalidCredErr(err) { // Likely the stored current password is stale (changed out-of-band). if currentWAL != nil && currentWAL.walID != "" { - if currentWAL.Attempt < currentWAL.MaxAttempts { // Retry allowed + 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", currentWAL.MaxAttempts, "error", err) + "role", input.RoleName, "WAL ID", output.WALID, "attempt", currentWAL.Attempt, "max_attempts", selfManagedMaxInvalidAttempts, "error", err) // Update Attempt count in WAL - newID, uErr := updateAttemptsInWAL(ctx, s, currentWAL, b) + 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", newID, "attempt", currentWAL.Attempt) - output.WALID = newID + 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 is user pushes new updated password it will be used + // 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().Warn("failed deleting WAL after max attempts", "role", input.RoleName, "WAL ID", output.WALID, "error", delErr) + 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) // returning this error stops further automatic rotations @@ -500,7 +495,6 @@ 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 - input.Role.StaticAccount.PasswordModifiedExternally = false output.RotationTime = lvr entry, err := logical.StorageEntryJSON(staticRolePath+input.RoleName, input.Role) From 38a08a676db1ba3f6099c52bd3b0f4b39ffeba6c Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:12:34 -0700 Subject: [PATCH 16/43] update doc --- rotation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rotation.go b/rotation.go index c2f925c2..5e914ea0 100644 --- a/rotation.go +++ b/rotation.go @@ -236,7 +236,7 @@ func (b *backend) rotateCredential(ctx context.Context, s logical.Storage) bool if err != nil { if errors.Is(err, ErrMaxRotationAttempts) { b.Logger().Error("max rotation attempts reached; suppressing further automatic rotations", "role", item.Key) - // Do not requeue (admin must intervene: reset attempts by deleting WAL or forcing manual rotation) + // 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) From c29a92d82e09a2daa3a1b1fd542a96ce86a20023 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:43:52 -0700 Subject: [PATCH 17/43] Refactor self-managed account password handling and validation logic --- path_static_roles.go | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index ba552325..8eb580b1 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -293,32 +293,30 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R role.StaticAccount.DN = dn } - passwordInput := "" - if passwordRaw, ok := data.GetOk("password"); ok { - passwordInput = passwordRaw.(string) - } - passwordModifiedExternally := false - if !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { - b.Logger().Debug("external password change for static role", "role", name) - passwordModifiedExternally = true - } 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 } - // only set password provided if it is self_managed - if sm && passwordInput != "" && role.StaticAccount.DN != "" { + role.StaticAccount.SelfManaged = sm + } + if role.StaticAccount.SelfManaged && role.StaticAccount.DN == "" { + return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil + } + passwordModifiedExternally := false + if passwordRaw, ok := data.GetOk("password"); ok { + passwordInput := passwordRaw.(string) + if !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { + b.Logger().Debug("external password change for static role", "role", name) + passwordModifiedExternally = true + } + if role.StaticAccount.SelfManaged && passwordInput != "" && role.StaticAccount.DN != "" { role.StaticAccount.Password = passwordInput - } else if sm && passwordInput == "" { - return logical.ErrorResponse("password is required for self-managed static accounts"), nil - } else if sm && role.StaticAccount.DN == "" { - return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil - } else if !sm && passwordInput != "" { + } else if role.StaticAccount.SelfManaged && passwordInput == "" { // 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 && passwordInput != "" { return logical.ErrorResponse("cannot set password for non-self-managed static accounts"), nil } - // If user explicitly set false while also providing password, honor explicit false. - role.StaticAccount.SelfManaged = sm } if maxInvalidRaw, ok := data.GetOk("self_managed_max_invalid_attempts"); ok { maxInvalid := maxInvalidRaw.(int) From 8295bcadcaff72478a2a59d48b1de38a0335a152 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:48:20 -0700 Subject: [PATCH 18/43] Enforce distinguished name requirement for self-managed accounts and prevent modification after creation --- path_static_roles.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 8eb580b1..adbd133c 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -282,26 +282,26 @@ 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. + // For self-managed: DN is required if dnRaw, ok := data.GetOk("dn"); ok { dn := dnRaw.(string) if !isCreate && dn != "" && dn != role.StaticAccount.DN { return logical.ErrorResponse("cannot update static account distinguished name (dn)"), nil } - - role.StaticAccount.DN = dn - } - 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 + if role.StaticAccount.SelfManaged && role.StaticAccount.DN == "" { + return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil } - role.StaticAccount.SelfManaged = sm - } - if role.StaticAccount.SelfManaged && role.StaticAccount.DN == "" { - return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil + role.StaticAccount.DN = dn } passwordModifiedExternally := false if passwordRaw, ok := data.GetOk("password"); ok { From 6ebbcc77e1e4fa937d591bf4dbb0859bb4b04edf Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 21:59:44 -0700 Subject: [PATCH 19/43] Enforce required DN and password fields for self-managed static accounts during creation --- path_static_roles.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index adbd133c..ade5565d 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -293,7 +293,11 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // cannot be modified after creation. If given, it will take precedence // over username for LDAP search during password rotation. // For self-managed: DN is required - if dnRaw, ok := data.GetOk("dn"); ok { + 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 @@ -304,7 +308,11 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R role.StaticAccount.DN = dn } passwordModifiedExternally := false - if passwordRaw, ok := data.GetOk("password"); ok { + 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 { passwordInput := passwordRaw.(string) if !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { b.Logger().Debug("external password change for static role", "role", name) From eba6f3a2fd6a95b39d38e088afa141f11a2a8b52 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 22:29:34 -0700 Subject: [PATCH 20/43] update path roles --- path_static_roles.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index ade5565d..e6c65ec2 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -292,7 +292,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // 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. - // For self-managed: DN is required + // 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 @@ -318,7 +318,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R b.Logger().Debug("external password change for static role", "role", name) passwordModifiedExternally = true } - if role.StaticAccount.SelfManaged && passwordInput != "" && role.StaticAccount.DN != "" { + if role.StaticAccount.SelfManaged && passwordInput != "" { role.StaticAccount.Password = passwordInput } else if role.StaticAccount.SelfManaged && passwordInput == "" { // dont allow to provide empty password for self-managed accounts return logical.ErrorResponse("cannot provide empty password parameter for self-managed accounts"), nil From bedd751015333ac742161f62f3e7368558e30202 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 20 Sep 2025 23:04:01 -0700 Subject: [PATCH 21/43] remove unused attr --- rotation.go | 1 - 1 file changed, 1 deletion(-) diff --git a/rotation.go b/rotation.go index 5e914ea0..9a7abf0c 100644 --- a/rotation.go +++ b/rotation.go @@ -151,7 +151,6 @@ type setCredentialsWAL struct { 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"` - MaxAttempts int `json:"max_attempts" mapstructure:"max_attempts"` // Private fields which will not be included in json.Marshal/Unmarshal. walID string From 944f59ab7b85e4cf1c09987efe28cad40f3c113f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 10:08:26 -0700 Subject: [PATCH 22/43] add tests for static role lc --- path_static_roles.go | 2 +- path_static_roles_test.go | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/path_static_roles.go b/path_static_roles.go index e6c65ec2..6beffc24 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -302,7 +302,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R if !isCreate && dn != "" && dn != role.StaticAccount.DN { return logical.ErrorResponse("cannot update static account distinguished name (dn)"), nil } - if role.StaticAccount.SelfManaged && role.StaticAccount.DN == "" { + if role.StaticAccount.SelfManaged && dn == "" { return logical.ErrorResponse("cannot set self_managed to true without a distinguished name (dn)"), nil } role.StaticAccount.DN = dn diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 194ac1f3..7aef458b 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -152,6 +152,66 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { "rotation_period": float64(25), }, }, + { + 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) { @@ -186,6 +246,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) @@ -215,6 +278,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) From 8e07efbca8f811c60a2239f6a25be0ee31e76909 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:30:18 +0000 Subject: [PATCH 23/43] Refactor password handling for self-managed static accounts and set default max invalid attempts --- path_static_roles.go | 15 ++++++--------- rotation.go | 3 +++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 6beffc24..e8d36951 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -313,24 +313,21 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R return logical.ErrorResponse("password is a required field to assume management of a self-managed static account"), nil } if ok { - passwordInput := passwordRaw.(string) - if !isCreate && passwordInput != "" && passwordInput != role.StaticAccount.Password { + password := passwordRaw.(string) + if !isCreate && password != "" && password != role.StaticAccount.Password { b.Logger().Debug("external password change for static role", "role", name) passwordModifiedExternally = true } - if role.StaticAccount.SelfManaged && passwordInput != "" { - role.StaticAccount.Password = passwordInput - } else if role.StaticAccount.SelfManaged && passwordInput == "" { // dont allow to provide empty password for self-managed accounts + 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 && passwordInput != "" { + } 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 maxInvalid == 0 { - maxInvalid = defaultSelfManagedMaxInvalidAttempts - } role.StaticAccount.SelfManagedMaxInvalidAttempts = maxInvalid } rotationPeriodSecondsRaw, ok := data.GetOk("rotation_period") diff --git a/rotation.go b/rotation.go index 9a7abf0c..89db29a0 100644 --- a/rotation.go +++ b/rotation.go @@ -357,6 +357,9 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag // Create a copy of the config to modify for rotation rotateConfig := *config.LDAP selfManagedMaxInvalidAttempts := input.Role.StaticAccount.SelfManagedMaxInvalidAttempts + if selfManagedMaxInvalidAttempts == 0 { + selfManagedMaxInvalidAttempts = defaultSelfManagedMaxInvalidAttempts + } var newPassword string var usedCredentialFromPreviousRotation bool From bbcf555a403fae657cd7183d02abe196c2409664 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:41:01 +0000 Subject: [PATCH 24/43] Add error handling for invalid credentials in fakeLdapClient --- backend_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend_test.go b/backend_test.go index 816f82d5..67ae473a 100644 --- a/backend_test.go +++ b/backend_test.go @@ -100,6 +100,7 @@ var _ ldapClient = (*fakeLdapClient)(nil) type fakeLdapClient struct { throwErrs bool + throwsInvalidCredentialsErr bool } func (f *fakeLdapClient) UpdateUserPassword(_ *client.Config, _ string, _ string) error { @@ -122,6 +123,8 @@ func (f *fakeLdapClient) UpdateSelfManagedDNPassword(_ *client.Config, _ string, var err error if f.throwErrs { err = errors.New("forced error") + } else if f.throwsInvalidCredentialsErr { + err = errors.New("invalid credentials") } return err } From b662e6db238412d36c4ec2b3345396d1b0925389 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 18:24:40 -0700 Subject: [PATCH 25/43] update tests --- path_static_roles_test.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 7aef458b..73cbe7e4 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -68,18 +68,6 @@ func Test_backend_pathStaticRoleLifecycle(t *testing.T) { }, wantUpdateErr: true, }, - { - 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: "including skip_import_rotation is an update error", createData: map[string]interface{}{ @@ -152,6 +140,28 @@ 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{}{ From bb2f25578e4a7b717dd7f42c2640858800888680 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 19:34:12 -0700 Subject: [PATCH 26/43] Add tests for WAL deletion behavior on self-managed password updates --- backend_test.go | 2 +- path_static_roles_test.go | 81 +++++++++++++++++++++++++++++++++++++++ rotation_test.go | 20 ++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/backend_test.go b/backend_test.go index 67ae473a..778d098a 100644 --- a/backend_test.go +++ b/backend_test.go @@ -99,7 +99,7 @@ func testBackendConfig() *logical.BackendConfig { var _ ldapClient = (*fakeLdapClient)(nil) type fakeLdapClient struct { - throwErrs bool + throwErrs bool throwsInvalidCredentialsErr bool } diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 73cbe7e4..76fcdbb7 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -988,6 +988,87 @@ 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 roles + generateWALFromInvalidCredRotation(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) +} + +// ofr 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 + generateWALFromInvalidCredRotation(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) + + // Update the self-managed static role's with same password + updateStaticRoleWithData(t, b, storage, roleName, map[string]interface{}{ + "password": "initialPassword!23", + }) + + // 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_test.go b/rotation_test.go index c99d0e72..6e7ed4a0 100644 --- a/rotation_test.go +++ b/rotation_test.go @@ -772,6 +772,26 @@ func generateWALFromFailedRotation(t *testing.T, b *backend, storage logical.Sto } } +func generateWALFromInvalidCredRotation(t *testing.T, b *backend, storage logical.Storage, roleName string) { + t.Helper() + // Fail to rotate the roles + ldapClient := b.client.(*fakeLdapClient) + originalValue := ldapClient.throwsInvalidCredentialsErr + ldapClient.throwsInvalidCredentialsErr = true + defer func() { + ldapClient.throwsInvalidCredentialsErr = originalValue + }() + + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.UpdateOperation, + Path: "rotate-role/" + roleName, + Storage: storage, + }) + if err == nil { + t.Fatal("expected error") + } +} + // returns a slice of the WAL IDs in storage func requireWALs(t *testing.T, storage logical.Storage, expectedCount int) []string { t.Helper() From e3e6639ccdf7a0a88aee4cc4b3d28860989404ae Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:06:03 -0700 Subject: [PATCH 27/43] update sta role test --- path_static_roles_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 76fcdbb7..911886dd 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -1009,8 +1009,8 @@ func TestWALsDeletedOnSelfManagedPasswordUpdate(t *testing.T) { t.Fatalf("err:%s resp:%#v\n", err, resp) } - // Fail to rotate the roles - generateWALFromInvalidCredRotation(t, b, storage, roleName) + // Fail to rotate the role + generateWALFromFailedRotation(t, b, storage, roleName) // Should have 1 WAL hanging around requireWALs(t, storage, 1) @@ -1047,7 +1047,7 @@ func TestWALsNotDeletedOnSelfManagedUpdate(t *testing.T) { } // Fail to rotate the roles - generateWALFromInvalidCredRotation(t, b, storage, roleName) + generateWALFromFailedRotation(t, b, storage, roleName) // Should have 1 WAL hanging around requireWALs(t, storage, 1) From 6d0d905589540ca7a827e7a1df7f7ed3cbbebe1c Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 21:55:52 -0700 Subject: [PATCH 28/43] Enhance self-managed role handling: address empty queue and implement max invalid attempts for auto-rotation --- path_static_roles.go | 7 ++- rotation.go | 1 + rotation_test.go | 119 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index e8d36951..7c37bc76 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -461,7 +461,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 err.Error() == "queue is empty" && passwordModifiedExternally && 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() diff --git a/rotation.go b/rotation.go index 89db29a0..cd4376c2 100644 --- a/rotation.go +++ b/rotation.go @@ -411,6 +411,7 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag return output, fmt.Errorf("error writing WAL entry: %w", err) } currentWAL = wal + currentWAL.walID = output.WALID } if newPassword == "" { diff --git a/rotation_test.go b/rotation_test.go index 6e7ed4a0..255b526d 100644 --- a/rotation_test.go +++ b/rotation_test.go @@ -752,34 +752,119 @@ func TestDeletesOlderWALsOnLoad(t *testing.T) { requireWALs(t, storage, 1) } -func generateWALFromFailedRotation(t *testing.T, b *backend, storage logical.Storage, roleName string) { - t.Helper() - // Fail to rotate the roles +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) + } + // TODO validate rotation worked on import + + // Make LDAP client return "invalid credentials" ldapClient := b.client.(*fakeLdapClient) - originalValue := ldapClient.throwErrs - ldapClient.throwErrs = true - defer func() { - ldapClient.throwErrs = originalValue - }() + ldapClient.throwsInvalidCredentialsErr = true - _, err := b.HandleRequest(context.Background(), &logical.Request{ + // 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) + + // update role password externally to simulate user fixing invalid password + updateReq := &logical.Request{ Operation: logical.UpdateOperation, - Path: "rotate-role/" + roleName, + Path: staticRolePath + roleName, Storage: storage, - }) - if err == nil { - t.Fatal("expected error") + 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) } + ldapClient.throwsInvalidCredentialsErr = false + // Ensure role queued 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) } -func generateWALFromInvalidCredRotation(t *testing.T, b *backend, storage logical.Storage, roleName string) { +func generateWALFromFailedRotation(t *testing.T, b *backend, storage logical.Storage, roleName string) { t.Helper() // Fail to rotate the roles ldapClient := b.client.(*fakeLdapClient) - originalValue := ldapClient.throwsInvalidCredentialsErr - ldapClient.throwsInvalidCredentialsErr = true + originalValue := ldapClient.throwErrs + ldapClient.throwErrs = true defer func() { - ldapClient.throwsInvalidCredentialsErr = originalValue + ldapClient.throwErrs = originalValue }() _, err := b.HandleRequest(context.Background(), &logical.Request{ From c172417a4f856c6bf16409478f336e6145e9288f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:05:07 -0700 Subject: [PATCH 29/43] Fix self-managed role handling: ensure item is nil check for empty queue and update test assertions for password validation --- path_static_roles.go | 2 +- rotation_test.go | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index 7c37bc76..0b686565 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -461,7 +461,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // TODO: Add retry logic item, err = b.popFromRotationQueueByKey(name) if err != nil { - if err.Error() == "queue is empty" && passwordModifiedExternally && role.StaticAccount.SelfManaged { + if item == nil && err.Error() == "queue is empty" && passwordModifiedExternally && 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 { diff --git a/rotation_test.go b/rotation_test.go index 255b526d..eab55a0c 100644 --- a/rotation_test.go +++ b/rotation_test.go @@ -778,7 +778,11 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { if err != nil || (resp != nil && resp.IsError()) { t.Fatalf("err:%s resp:%#v\n", err, resp) } - // TODO validate rotation worked on import + // 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) @@ -835,7 +839,7 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { t.Fatalf("err:%s resp:%#v\n", err, resp) } ldapClient.throwsInvalidCredentialsErr = false - // Ensure role queued after update + // Ensure role readded to queue after update item, err = b.popFromRotationQueueByKey(roleName) require.NoError(t, err) require.NotNil(t, item) @@ -851,7 +855,7 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { require.NoError(t, err) require.NotNil(t, item) // validate password was changed - role, err := b.staticRole(ctx, storage, roleName) + role, err = b.staticRole(ctx, storage, roleName) require.NoError(t, err) require.NotNil(t, role) require.NotEqual(t, "NewValidPassw0rd!", role.StaticAccount.Password) From 3a8e3353b3da0694f264e8c13e998ddef13223d2 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:10:05 -0700 Subject: [PATCH 30/43] make max invalide immutable --- path_static_roles.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/path_static_roles.go b/path_static_roles.go index 0b686565..ee3f989b 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -159,7 +159,7 @@ func staticFields() map[string]*framework.FieldSchema { }, "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.", + 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, }, } @@ -328,6 +328,12 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R } 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") @@ -645,6 +651,7 @@ the "skip_import_rotation" parameter is set to true. The password is not returne 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. ` const staticRolesListHelpDescription = ` From e6f728ec9133617ac19d3b4283d0e907356c0a36 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sun, 21 Sep 2025 22:18:33 -0700 Subject: [PATCH 31/43] update doc --- rotation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rotation.go b/rotation.go index cd4376c2..023ac381 100644 --- a/rotation.go +++ b/rotation.go @@ -233,8 +233,8 @@ 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) { - b.Logger().Error("max rotation attempts reached; suppressing further automatic rotations", "role", item.Key) + 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 } From 4b6aee200b6d39674fea3ed925fa8f89f8294a89 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 08:59:26 -0700 Subject: [PATCH 32/43] update set externally --- path_static_roles.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/path_static_roles.go b/path_static_roles.go index ee3f989b..e3c6e42f 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -307,16 +307,16 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R } role.StaticAccount.DN = dn } - passwordModifiedExternally := false + 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 != "" && password != role.StaticAccount.Password { + if !isCreate && password != "" { b.Logger().Debug("external password change for static role", "role", name) - passwordModifiedExternally = true + hasExternalPasswordBeenSet = true } if role.StaticAccount.SelfManaged && password != "" { role.StaticAccount.Password = password @@ -436,8 +436,8 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R } } case logical.UpdateOperation: - if passwordModifiedExternally && role.StaticAccount.SelfManaged { - // If the password was modified outside of Vault, and this is a self-managed role + 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 { @@ -467,7 +467,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R // TODO: Add retry logic item, err = b.popFromRotationQueueByKey(name) if err != nil { - if item == nil && err.Error() == "queue is empty" && passwordModifiedExternally && role.StaticAccount.SelfManaged { + 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 { From 5b18554cfcd11b345997a3dbe77d7ffc99fce51f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:16:31 +0000 Subject: [PATCH 33/43] Update parameters in Test_UpdateSelfManagedDNPassword for clarity --- client_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client_test.go b/client_test.go index 248b103e..3dd1d39d 100644 --- a/client_test.go +++ b/client_test.go @@ -243,10 +243,9 @@ func Test_UpdateSelfManagedDNPassword(t *testing.T) { }, Schema: client.SchemaOpenLDAP, } - + // self rotate this user c := NewClient(hclog.NewNullLogger()) - newPassword := "newpassword" - err := c.UpdateSelfManagedDNPassword(config, "cn=admin,dc=example,dc=org", "admin", newPassword) + err := c.UpdateSelfManagedDNPassword(config, "cn=User1,dc=example,dc=org", "password1", "newpassword") assert.NoError(t, err) } From 1add9b6fe1976d291f378ca869e07cecdbdfdc4b Mon Sep 17 00:00:00 2001 From: jadeidev <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:08:11 -0700 Subject: [PATCH 34/43] Add support for self-managed static LDAP accounts and update credential rotation method --- .changes/unreleased/FEATURES-20250922-114712.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20250922-114712.yaml diff --git a/.changes/unreleased/FEATURES-20250922-114712.yaml b/.changes/unreleased/FEATURES-20250922-114712.yaml new file mode 100644 index 00000000..0d729935 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250922-114712.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'Added support for self-managed static roles. Vault can now rotate credentials using the roles own password, without requiring a privileged bind DN. +time: 2025-09-22T11:47:12.201827-07:00 +custom: + Issue: "212" From 013215f7f26e1b288fbdeb358eee38c2ae0f9dfd Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:04:48 -0700 Subject: [PATCH 35/43] update to assure config arent changed --- client.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 67827c72..035174de 100644 --- a/client.go +++ b/client.go @@ -111,10 +111,11 @@ func (c *Client) UpdateSelfManagedDNPassword(conf *client.Config, dn string, cur return fmt.Errorf("error updating password: %s", err) } // Use a copy of the config to avoid modifying the original with the bind dn/password for rotation + rotationConfEntry := *conf.ConfigEntry + rotationConfEntry.BindDN = dn + rotationConfEntry.BindPassword = currentPassword rotationConf := *conf - rotationConf.BindDN = dn - rotationConf.BindPassword = currentPassword - + rotationConf.ConfigEntry = &rotationConfEntry return c.ldap.UpdateSelfManagedPassword(&rotationConf, scope, currentValues, newValues, filters) } From c50296c6efd22262d6aba22c30f3edb312f4c05d Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:38:13 -0700 Subject: [PATCH 36/43] add validation in Test_UpdateSelfManagedDNPassword --- client_test.go | 3 +++ rotation.go | 7 +++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client_test.go b/client_test.go index 3dd1d39d..bb7466d4 100644 --- a/client_test.go +++ b/client_test.go @@ -247,6 +247,9 @@ func Test_UpdateSelfManagedDNPassword(t *testing.T) { 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 { diff --git a/rotation.go b/rotation.go index 023ac381..b1834d33 100644 --- a/rotation.go +++ b/rotation.go @@ -354,8 +354,7 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag if config == nil { return output, errors.New("the config is currently unset") } - // Create a copy of the config to modify for rotation - rotateConfig := *config.LDAP + selfManagedMaxInvalidAttempts := input.Role.StaticAccount.SelfManagedMaxInvalidAttempts if selfManagedMaxInvalidAttempts == 0 { selfManagedMaxInvalidAttempts = defaultSelfManagedMaxInvalidAttempts @@ -443,9 +442,9 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag // 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(&rotateConfig, input.Role.StaticAccount.DN, newPassword) + err = b.client.UpdateDNPassword(config.LDAP, input.Role.StaticAccount.DN, newPassword) } else { - err = b.client.UpdateUserPassword(&rotateConfig, input.Role.StaticAccount.Username, newPassword) + err = b.client.UpdateUserPassword(config.LDAP, input.Role.StaticAccount.Username, newPassword) } } if err != nil { From f3722e510a183bac7070af54a5aa37562bcd131f Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:33:33 -0700 Subject: [PATCH 37/43] update docs --- client/client.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/client.go b/client/client.go index 04683676..429ae241 100644 --- a/client/client.go +++ b/client/client.go @@ -121,8 +121,13 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues return c.UpdateEntry(cfg, baseDN, scope, filters, newValues) } -// UpdateSelfManagedPassword uses a Modify call under the hood for AD with Delete/Add and NewPasswordModifyRequest for OpenLDAP. -// This is for usage of self managed password since Replace modification like the one done in `UpdateEntry` requires extra permissions. +// 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: falls back to UpdateEntry. func (c *Client) UpdateSelfManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { // perform self search to validate account exists and current password is correct entries, err := c.Search(cfg, cfg.BindDN, scope, filters) From 0514ee0d71147af856caa685a34925d64ad5006d Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Tue, 23 Sep 2025 04:54:45 +0000 Subject: [PATCH 38/43] Ravise test --- path_static_roles_test.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/path_static_roles_test.go b/path_static_roles_test.go index 911886dd..80fd05e0 100644 --- a/path_static_roles_test.go +++ b/path_static_roles_test.go @@ -1024,7 +1024,7 @@ func TestWALsDeletedOnSelfManagedPasswordUpdate(t *testing.T) { requireWALs(t, storage, 0) } -// ofr self managed account if it is an update witn no new password then dont delete wal +// 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) @@ -1059,14 +1059,6 @@ func TestWALsNotDeletedOnSelfManagedUpdate(t *testing.T) { // 1 WAL should still be there requireWALs(t, storage, 1) - - // Update the self-managed static role's with same password - updateStaticRoleWithData(t, b, storage, roleName, map[string]interface{}{ - "password": "initialPassword!23", - }) - - // 1 WAL should still be there - requireWALs(t, storage, 1) } func configureOpenLDAPMount(t *testing.T, b *backend, storage logical.Storage) { From 50787923c3d00c16d982770525dca861b47cfc38 Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Sat, 4 Oct 2025 03:32:07 +0000 Subject: [PATCH 39/43] - Introduced a new field `RotationSuspended` to track if automatic rotation is suspended due to invalid password attempts. - Updated logic in password setting to mark rotation as suspended when max attempts are reached. - Cleared the suspended flag upon successful password rotation. - Enhanced tests to validate the behavior of the rotation_suspended flag. --- path_static_roles.go | 13 +++++++++++++ rotation.go | 9 +++++++++ rotation_test.go | 13 +++++++++++++ 3 files changed, 35 insertions(+) diff --git a/path_static_roles.go b/path_static_roles.go index e3c6e42f..e840dd9d 100644 --- a/path_static_roles.go +++ b/path_static_roles.go @@ -229,6 +229,9 @@ func (b *backend) pathStaticRoleRead(ctx context.Context, req *logical.Request, "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() if !role.StaticAccount.LastVaultRotation.IsZero() { @@ -443,6 +446,7 @@ func (b *backend) pathStaticRoleCreateUpdate(ctx context.Context, req *logical.R 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() { @@ -554,6 +558,13 @@ type staticAccount struct { // 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 @@ -652,6 +663,8 @@ The "self_managed_max_invalid_attempts" parameter is optional and configures the 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/rotation.go b/rotation.go index b1834d33..2d398d4f 100644 --- a/rotation.go +++ b/rotation.go @@ -471,6 +471,13 @@ func (b *backend) setStaticAccountPassword(ctx context.Context, s logical.Storag 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 } @@ -497,6 +504,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) diff --git a/rotation_test.go b/rotation_test.go index eab55a0c..075b77d2 100644 --- a/rotation_test.go +++ b/rotation_test.go @@ -824,6 +824,11 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { // 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{ @@ -838,6 +843,12 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { 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) @@ -859,6 +870,8 @@ func TestSelfManagedMaxInvalidAttemptsStopsAutoRotation(t *testing.T) { 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) { From b53c6bea7882cafe824d6108bfa4c25161aa99fa Mon Sep 17 00:00:00 2001 From: Jade <32917209+jadeidev@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:47:40 -0700 Subject: [PATCH 40/43] Delete .changes/unreleased/FEATURES-20250922-114712.yaml --- .changes/unreleased/FEATURES-20250922-114712.yaml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changes/unreleased/FEATURES-20250922-114712.yaml diff --git a/.changes/unreleased/FEATURES-20250922-114712.yaml b/.changes/unreleased/FEATURES-20250922-114712.yaml deleted file mode 100644 index 0d729935..00000000 --- a/.changes/unreleased/FEATURES-20250922-114712.yaml +++ /dev/null @@ -1,5 +0,0 @@ -kind: FEATURES -body: 'Added support for self-managed static roles. Vault can now rotate credentials using the roles own password, without requiring a privileged bind DN. -time: 2025-09-22T11:47:12.201827-07:00 -custom: - Issue: "212" From 102ba7957e7473fe6ae2f53c7e8204561220f185 Mon Sep 17 00:00:00 2001 From: jadeidev <32917209+jadeidev@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:38:44 -0800 Subject: [PATCH 41/43] refactor: simplify UpdateSelfManagedPassword function and improve test cases --- client.go | 16 +----- client/client.go | 45 ++++++++------- client/client_test.go | 125 ++---------------------------------------- 3 files changed, 30 insertions(+), 156 deletions(-) diff --git a/client.go b/client.go index 035174de..85c81f83 100644 --- a/client.go +++ b/client.go @@ -102,21 +102,7 @@ func (c *Client) UpdateSelfManagedDNPassword(conf *client.Config, dn string, cur filters := map[*client.Field][]string{ client.FieldRegistry.ObjectClass: {"*"}, } - currentValues, err := client.GetSchemaFieldRegistry(conf, currentPassword) - if err != nil { - return fmt.Errorf("error updating password: %s", err) - } - newValues, err := client.GetSchemaFieldRegistry(conf, newPassword) - if err != nil { - return fmt.Errorf("error updating password: %s", err) - } - // Use a copy of the config to avoid modifying the original with the bind dn/password for rotation - rotationConfEntry := *conf.ConfigEntry - rotationConfEntry.BindDN = dn - rotationConfEntry.BindPassword = currentPassword - rotationConf := *conf - rotationConf.ConfigEntry = &rotationConfEntry - return c.ldap.UpdateSelfManagedPassword(&rotationConf, scope, currentValues, newValues, filters) + return c.ldap.UpdateSelfManagedPassword(conf, dn, scope, currentPassword, newPassword, filters) } func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) { diff --git a/client/client.go b/client/client.go index 429ae241..765d9ca7 100644 --- a/client/client.go +++ b/client/client.go @@ -127,10 +127,19 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues // 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: falls back to UpdateEntry. -func (c *Client) UpdateSelfManagedPassword(cfg *Config, scope int, currentValues map[*Field][]string, newValues map[*Field][]string, filters map[*Field][]string) error { +// - 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(cfg, cfg.BindDN, scope, filters) + entries, err := c.Search(cfg, dn, scope, filters) if err != nil { return err } @@ -146,43 +155,39 @@ func (c *Client) UpdateSelfManagedPassword(cfg *Config, scope int, currentValues if err := bind(cfg, conn); err != nil { return err } + currentSchemaValues, err := GetSchemaFieldRegistry(cfg, currentValue) + if err != nil { + return fmt.Errorf("error updating password: %s", err) + } + newSchemaValues, err := GetSchemaFieldRegistry(cfg, newValue) + if err != nil { + return fmt.Errorf("error updating password: %s", err) + } switch cfg.Schema { case SchemaAD: modifyReq := &ldap.ModifyRequest{ DN: entries[0].DN, } - - for field, vals := range currentValues { + for field, vals := range currentSchemaValues { modifyReq.Delete(field.String(), vals) } - for field, vals := range newValues { + for field, vals := range newSchemaValues { modifyReq.Add(field.String(), vals) } return conn.Modify(modifyReq) case SchemaOpenLDAP: - var currentPassword, newPassword string - for f, vals := range currentValues { - if f == FieldRegistry.UserPassword && len(vals) == 1 { - currentPassword = vals[0] - } - } - for f, vals := range newValues { - if f == FieldRegistry.UserPassword && len(vals) == 1 { - newPassword = vals[0] - } - } - req := ldap.NewPasswordModifyRequest(entries[0].DN, currentPassword, newPassword) + 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(cfg, cfg.BindDN, scope, filters, newValues) + return c.UpdateEntry(&rotationConf, dn, scope, filters, newSchemaValues) } _, err = pmConn.PasswordModify(req) return err case SchemaRACF: - return c.UpdateEntry(cfg, cfg.BindDN, scope, filters, newValues) + return fmt.Errorf("self managed password changes not supported for RACF schema") default: return fmt.Errorf("configured schema %s not valid", cfg.Schema) } diff --git a/client/client_test.go b/client/client_test.go index 2e4ba183..2ff23e98 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -272,9 +272,8 @@ func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { currentPass := "dogs" config := emptyConfig() + config.Schema = SchemaOpenLDAP dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" - config.BindDN = dn - config.BindPassword = currentPass conn := &ldapifc.FakeLDAPConnection{ SearchRequestToExpect: testSearchRequest(), @@ -296,109 +295,11 @@ func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { filters := map[*Field][]string{ FieldRegistry.ObjectClass: {"*"}, } - - config.Schema = SchemaOpenLDAP - currentValues, err := GetSchemaFieldRegistry(config, currentPass) - if err != nil { - t.Fatal(err) - } - newValues, err := GetSchemaFieldRegistry(config, testPass) - if err != nil { - t.Fatal(err) - } - - if err := client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, filters); err != nil { + if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil { t.Fatal(err) } } -func TestUpdateSelfManagedPasswordRACF(t *testing.T) { - testPassword := "pass1234" - testPhrase := "this is a much longer passphrase for racfPassPhrase" - - tests := []struct { - name string - password string - credentialType CredentialType - expectedFields map[*Field][]string - }{ - { - name: "password", - password: testPassword, - credentialType: CredentialTypePassword, - expectedFields: map[*Field][]string{ - FieldRegistry.RACFPassword: {testPassword}, - FieldRegistry.RACFAttributes: {"noexpired"}, - }, - }, - { - name: "passphrase", - password: testPhrase, - credentialType: CredentialTypePhrase, - expectedFields: map[*Field][]string{ - FieldRegistry.RACFPassphrase: {testPhrase}, - FieldRegistry.RACFAttributes: {"noexpired"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - currentPass := "dogs" - dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com" - config := emptyConfig() - config.BindDN = dn - config.BindPassword = currentPass - config.Schema = SchemaRACF - config.CredentialType = tt.credentialType - - conn := &ldapifc.FakeLDAPConnection{ - SearchRequestToExpect: testSearchRequest(), - SearchResultToReturn: testSearchResult(), - } - - conn.ModifyRequestToExpect = &ldap.ModifyRequest{ - DN: dn, - } - - // Set up expected modifications based on the test case - for field, values := range tt.expectedFields { - conn.ModifyRequestToExpect.Replace(field.String(), values) - } - - ldapClient := &ldaputil.Client{ - Logger: hclog.NewNullLogger(), - LDAP: &ldapifc.FakeLDAPClient{conn}, - } - - client := &Client{ldapClient} - - filters := map[*Field][]string{ - FieldRegistry.ObjectClass: {"*"}, - } - - currentValues, err := GetSchemaFieldRegistry(config, currentPass) - require.NoError(t, err) - - newValues, err := GetSchemaFieldRegistry(config, tt.password) - require.NoError(t, err) - - // verify that the fields are set correctly in newValues - for field, expectedValues := range tt.expectedFields { - actualValues, exists := newValues[field] - if !exists { - t.Fatalf("Expected field %s to exist in newValues", field.String()) - } - - require.Equal(t, expectedValues, actualValues) - } - - err = client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, filters) - require.NoError(t, err) - }) - } -} - func TestUpdateSelfManagedPasswordAD(t *testing.T) { testPass := "hell0$catz*" currentPass := "dogs" @@ -413,8 +314,7 @@ func TestUpdateSelfManagedPasswordAD(t *testing.T) { } config := emptyConfig() - config.BindDN = dn - config.BindPassword = currentPass + config.Schema = SchemaAD conn := &ldapifc.FakeLDAPConnection{ SearchRequestToExpect: testSearchRequest(), @@ -438,24 +338,7 @@ func TestUpdateSelfManagedPasswordAD(t *testing.T) { FieldRegistry.ObjectClass: {"*"}, } - config.Schema = SchemaAD - currentValues, err := GetSchemaFieldRegistry(config, currentPass) - if err != nil { - t.Fatal(err) - } - newValues, err := GetSchemaFieldRegistry(config, testPass) - if err != nil { - t.Fatal(err) - } - if p, ok := newValues[FieldRegistry.UnicodePassword]; !ok { - t.Fatal("Expected unicodePwd field to be populated") - } else if len(p) != 1 { - t.Fatalf("Expected exactly one entry for unicodePwd but got %d", len(p)) - } else if p[0] != encodedTestPass { - t.Fatalf("Expected unicodePwd field equal to %q but got %q", encodedTestPass, p[0]) - } - - if err := client.UpdateSelfManagedPassword(config, ldap.ScopeBaseObject, currentValues, newValues, filters); err != nil { + if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil { t.Fatal(err) } } From a2bfcc9fb0ce018a021d080a17c65c674c9be649 Mon Sep 17 00:00:00 2001 From: jadeidev <32917209+jadeidev@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:48:21 -0800 Subject: [PATCH 42/43] update test --- client/client_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/client_test.go b/client/client_test.go index 2ff23e98..1ed9d82b 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -273,6 +273,8 @@ func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { 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{ @@ -298,6 +300,9 @@ func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) { 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) { From 2dfb441ca65f95ecf4fb7ceb8e2d52583636537a Mon Sep 17 00:00:00 2001 From: jadeidev <32917209+jadeidev@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:08:28 -0800 Subject: [PATCH 43/43] refactor: update UpdateSelfManagedPassword to use rotation configuration for LDAP operations --- client/client.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/client.go b/client/client.go index 765d9ca7..bf4031c4 100644 --- a/client/client.go +++ b/client/client.go @@ -139,31 +139,31 @@ func (c *Client) UpdateSelfManagedPassword(cfg *Config, dn string, scope int, cu rotationConf := *cfg rotationConf.ConfigEntry = &rotationConfEntry // perform self search to validate account exists and current password is correct - entries, err := c.Search(cfg, dn, scope, filters) + 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(cfg.ConfigEntry) + conn, err := c.ldap.DialLDAP(rotationConf.ConfigEntry) if err != nil { return err } defer conn.Close() - if err := bind(cfg, conn); err != nil { + if err := bind(&rotationConf, conn); err != nil { return err } - currentSchemaValues, err := GetSchemaFieldRegistry(cfg, currentValue) + currentSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, currentValue) if err != nil { return fmt.Errorf("error updating password: %s", err) } - newSchemaValues, err := GetSchemaFieldRegistry(cfg, newValue) + newSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, newValue) if err != nil { return fmt.Errorf("error updating password: %s", err) } - switch cfg.Schema { + switch rotationConf.Schema { case SchemaAD: modifyReq := &ldap.ModifyRequest{ DN: entries[0].DN, @@ -189,7 +189,7 @@ func (c *Client) UpdateSelfManagedPassword(cfg *Config, dn string, scope int, cu case SchemaRACF: return fmt.Errorf("self managed password changes not supported for RACF schema") default: - return fmt.Errorf("configured schema %s not valid", cfg.Schema) + return fmt.Errorf("configured schema %s not valid", rotationConf.Schema) } }