Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
977b74d
Add password field and self-managed flag to static account schema
jadeidev Sep 18, 2025
2d07e22
update inputs
jadeidev Sep 18, 2025
c507e45
Enhance self-managed static account handling:
jadeidev Sep 18, 2025
0558317
Add tests for self-managed static role creation and validation
jadeidev Sep 18, 2025
66b53a9
Add tests for self-managed role password rotation and policy updates
jadeidev Sep 19, 2025
56130b4
change call for password change
jadeidev Sep 19, 2025
140d2f8
Implement self-managed password update functionality and related tests
jadeidev Sep 19, 2025
c51d394
update doc
jadeidev Sep 19, 2025
a010075
Implement self-managed password update functionality and add related …
jadeidev Sep 19, 2025
85831f0
Rename UpdateSelfDNPassword to UpdateSelfManagedDNPassword across cli…
jadeidev Sep 19, 2025
e1ab72b
add self managed to read response
jadeidev Sep 19, 2025
a88554c
Add self-managed max invalid attempts configuration and handling
jadeidev Sep 20, 2025
8737e50
Add handling for externally modified passwords in self-managed accounts
jadeidev Sep 20, 2025
f1f10ff
Update rotation.go
jadeidev Sep 20, 2025
fb278a6
Refine self-managed password handling and update WAL logic for extern…
jadeidev Sep 21, 2025
38a08a6
update doc
jadeidev Sep 21, 2025
c29a92d
Refactor self-managed account password handling and validation logic
jadeidev Sep 21, 2025
8295bca
Enforce distinguished name requirement for self-managed accounts and …
jadeidev Sep 21, 2025
6ebbcc7
Enforce required DN and password fields for self-managed static accou…
jadeidev Sep 21, 2025
eba6f3a
update path roles
jadeidev Sep 21, 2025
bedd751
remove unused attr
jadeidev Sep 21, 2025
944f59a
add tests for static role lc
jadeidev Sep 21, 2025
8e07efb
Refactor password handling for self-managed static accounts and set d…
jadeidev Sep 21, 2025
bbcf555
Add error handling for invalid credentials in fakeLdapClient
jadeidev Sep 21, 2025
b662e6d
update tests
jadeidev Sep 22, 2025
bb2f255
Add tests for WAL deletion behavior on self-managed password updates
jadeidev Sep 22, 2025
e3e6639
update sta role test
jadeidev Sep 22, 2025
6d0d905
Enhance self-managed role handling: address empty queue and implement…
jadeidev Sep 22, 2025
c172417
Fix self-managed role handling: ensure item is nil check for empty qu…
jadeidev Sep 22, 2025
3a8e335
make max invalide immutable
jadeidev Sep 22, 2025
e6f728e
update doc
jadeidev Sep 22, 2025
4b6aee2
update set externally
jadeidev Sep 22, 2025
5b18554
Update parameters in Test_UpdateSelfManagedDNPassword for clarity
jadeidev Sep 22, 2025
1add9b6
Add support for self-managed static LDAP accounts and update credenti…
jadeidev Sep 22, 2025
013215f
update to assure config arent changed
jadeidev Sep 22, 2025
c50296c
add validation in Test_UpdateSelfManagedDNPassword
jadeidev Sep 22, 2025
f3722e5
update docs
jadeidev Sep 23, 2025
0514ee0
Ravise test
jadeidev Sep 23, 2025
5078792
- Introduced a new field `RotationSuspended` to track if automatic ro…
jadeidev Oct 4, 2025
e8e72fa
Merge branch 'main' into enable-self-rotation
jadeidev Oct 14, 2025
5772ad8
Merge branch 'main' into enable-self-rotation
jadeidev Oct 14, 2025
def4a96
Merge branch 'main' into enable-self-rotation
jadeidev Oct 17, 2025
b53c6be
Delete .changes/unreleased/FEATURES-20250922-114712.yaml
jadeidev Oct 17, 2025
102ba79
refactor: simplify UpdateSelfManagedPassword function and improve tes…
jadeidev Nov 10, 2025
a2bfcc9
update test
jadeidev Nov 10, 2025
2dfb441
refactor: update UpdateSelfManagedPassword to use rotation configurat…
jadeidev Nov 10, 2025
e9de693
Merge branch 'main' into enable-self-rotation
jadeidev Nov 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ func testBackendConfig() *logical.BackendConfig {
var _ ldapClient = (*fakeLdapClient)(nil)

type fakeLdapClient struct {
throwErrs bool
throwErrs bool
throwsInvalidCredentialsErr bool
}

func (f *fakeLdapClient) UpdateUserPassword(_ *client.Config, _ string, _ string) error {
Expand All @@ -119,6 +120,16 @@ func (f *fakeLdapClient) UpdateDNPassword(_ *client.Config, _ string, _ string)
return err
}

func (f *fakeLdapClient) UpdateSelfManagedDNPassword(_ *client.Config, _ string, _ string, _ string) error {
var err error
if f.throwErrs {
err = errors.New("forced error")
} else if f.throwsInvalidCredentialsErr {
err = errors.New("invalid credentials")
}
return err
}

func (f *fakeLdapClient) Execute(_ *client.Config, _ []*ldif.Entry, _ bool) error {
var err error
if f.throwErrs {
Expand Down
18 changes: 18 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package openldap

import (
"errors"
"fmt"

"github.com/go-ldap/ldap/v3"
Expand All @@ -17,6 +18,7 @@ import (
type ldapClient interface {
UpdateDNPassword(conf *client.Config, dn string, newPassword string) error
UpdateUserPassword(conf *client.Config, user, newPassword string) error
UpdateSelfManagedDNPassword(conf *client.Config, dn, currentPassword, newPassword string) error
Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) error
}

Expand Down Expand Up @@ -87,6 +89,22 @@ func (c *Client) UpdateUserPassword(conf *client.Config, username string, newPas
return c.ldap.UpdatePassword(conf, conf.UserDN, ldap.ScopeWholeSubtree, newValues, filters)
}

func (c *Client) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error {
if dn == "" {
// Optionally implement a search to resolve DN from username, userdn, userattr in cfg.
return errors.New("user DN resolution not implemented")
}
if currentPassword == "" || newPassword == "" {
return fmt.Errorf("both current and new password must be provided for self-managed password changes on dn: %s", dn)
}

scope := ldap.ScopeBaseObject
filters := map[*client.Field][]string{
client.FieldRegistry.ObjectClass: {"*"},
}
return c.ldap.UpdateSelfManagedPassword(conf, dn, scope, currentPassword, newPassword, filters)
}

func (c *Client) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) {
return c.ldap.Execute(conf, entries, continueOnError)
}
72 changes: 72 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,78 @@ func (c *Client) UpdatePassword(cfg *Config, baseDN string, scope int, newValues
return c.UpdateEntry(cfg, baseDN, scope, filters, newValues)
}

// UpdateSelfManagedPassword performs a least‑privilege, self‑service password change.
// Behavior by schema:
// - Active Directory: issues a Delete (current value) followed by an Add (new value)
// because a direct Replace (like the one done in `UpdateEntry`) typically requires elevated privileges.
// - OpenLDAP: uses the RFC 3062 PasswordModify extended operation. If the underlying
// connection does not support it, falls back to UpdateEntry (privileged replace).
// - RACF: not implamneted yet.
func (c *Client) UpdateSelfManagedPassword(cfg *Config, dn string, scope int, currentValue string, newValue string, filters map[*Field][]string) error {
if currentValue == "" || newValue == "" {
return fmt.Errorf("both current and new password must be provided for self-managed password changes on dn: %s", dn)
}
// Use a copy of the config to avoid modifying the original with the bind dn/password for rotation
rotationConfEntry := *cfg.ConfigEntry
rotationConfEntry.BindDN = dn
rotationConfEntry.BindPassword = currentValue
rotationConf := *cfg
rotationConf.ConfigEntry = &rotationConfEntry
// perform self search to validate account exists and current password is correct
entries, err := c.Search(&rotationConf, dn, scope, filters)
if err != nil {
return err
}
if len(entries) != 1 {
return fmt.Errorf("expected one matching entry, but received %d", len(entries))
}
conn, err := c.ldap.DialLDAP(rotationConf.ConfigEntry)
if err != nil {
return err
}
defer conn.Close()

if err := bind(&rotationConf, conn); err != nil {
return err
}
currentSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, currentValue)
if err != nil {
return fmt.Errorf("error updating password: %s", err)
}
newSchemaValues, err := GetSchemaFieldRegistry(&rotationConf, newValue)
if err != nil {
return fmt.Errorf("error updating password: %s", err)
}
switch rotationConf.Schema {
case SchemaAD:
modifyReq := &ldap.ModifyRequest{
DN: entries[0].DN,
}
for field, vals := range currentSchemaValues {
modifyReq.Delete(field.String(), vals)
}
for field, vals := range newSchemaValues {
modifyReq.Add(field.String(), vals)
}
return conn.Modify(modifyReq)
case SchemaOpenLDAP:
req := ldap.NewPasswordModifyRequest(entries[0].DN, currentValue, newValue)
pmConn, ok := conn.(interface {
PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error)
})
if !ok {
// Fallback: try privileged replace (may fail if self-change perms required)
return c.UpdateEntry(&rotationConf, dn, scope, filters, newSchemaValues)
}
_, err = pmConn.PasswordModify(req)
return err
case SchemaRACF:
return fmt.Errorf("self managed password changes not supported for RACF schema")
default:
return fmt.Errorf("configured schema %s not valid", rotationConf.Schema)
}
}

// toString turns the following map of filters into LDAP search filter strings
// For example: "(cn=Ellen Jones)"
// when multiple filters are applied, they get AND'ed together.
Expand Down
81 changes: 81 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,87 @@ func TestUpdatePasswordAD(t *testing.T) {
}
}

func TestUpdateSelfManagedPasswordOpenLDAP(t *testing.T) {
testPass := "hell0$catz*"
currentPass := "dogs"

config := emptyConfig()
config.Schema = SchemaOpenLDAP
config.BindDN = "cn=admin,dc=example,dc=org"
config.BindPassword = "admin"
dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com"

conn := &ldapifc.FakeLDAPConnection{
SearchRequestToExpect: testSearchRequest(),
SearchResultToReturn: testSearchResult(),
}

conn.PasswordModifyRequestToExpect = &ldap.PasswordModifyRequest{
UserIdentity: dn,
OldPassword: currentPass,
NewPassword: testPass,
}
ldapClient := &ldaputil.Client{
Logger: hclog.NewNullLogger(),
LDAP: &ldapifc.FakeLDAPClient{conn},
}

client := &Client{ldapClient}

filters := map[*Field][]string{
FieldRegistry.ObjectClass: {"*"},
}
if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil {
t.Fatal(err)
}
// validate client didnt change
assert.Equal(t, "cn=admin,dc=example,dc=org", config.BindDN)
assert.Equal(t, "admin", config.BindPassword)
}

func TestUpdateSelfManagedPasswordAD(t *testing.T) {
testPass := "hell0$catz*"
currentPass := "dogs"
encodedCurrentPass, err := formatPassword(currentPass)
if err != nil {
t.Fatal(err)
}
dn := "CN=Jim H.. Jones,OU=Vault,OU=Engineering,DC=example,DC=com"
encodedTestPass, err := formatPassword(testPass)
if err != nil {
t.Fatal(err)
}

config := emptyConfig()
config.Schema = SchemaAD

conn := &ldapifc.FakeLDAPConnection{
SearchRequestToExpect: testSearchRequest(),
SearchResultToReturn: testSearchResult(),
}

conn.ModifyRequestToExpect = &ldap.ModifyRequest{
DN: dn,
}
conn.ModifyRequestToExpect.Delete("unicodePwd", []string{encodedCurrentPass})
conn.ModifyRequestToExpect.Add("unicodePwd", []string{encodedTestPass})

ldapClient := &ldaputil.Client{
Logger: hclog.NewNullLogger(),
LDAP: &ldapifc.FakeLDAPClient{conn},
}

client := &Client{ldapClient}

filters := map[*Field][]string{
FieldRegistry.ObjectClass: {"*"},
}

if err := client.UpdateSelfManagedPassword(config, dn, ldap.ScopeBaseObject, currentPass, testPass, filters); err != nil {
t.Fatal(err)
}
}

// TestUpdateRootPassword mimics the UpdateRootPassword in the SecretsClient.
// However, this test must be located within this package because when the
// "client" is instantiated below, the "ldapClient" is being added to an
Expand Down
39 changes: 39 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,45 @@ func Test_UpdateUserPassword(t *testing.T) {
assert.NoError(t, err)
}

func Test_UpdateSelfManagedDNPassword_MissingPrameters(t *testing.T) {
config := &client.Config{
ConfigEntry: &ldaputil.ConfigEntry{
Url: "ldap://ldap.com:389",
BindDN: "cn=admin,dc=example,dc=org",
BindPassword: "admin",
},
Schema: client.SchemaOpenLDAP,
}

c := NewClient(hclog.NewNullLogger())
newPassword := "newpassword"
err := c.UpdateSelfManagedDNPassword(config, "cn=user1,dc=example,dc=org", "", newPassword)
assert.Error(t, err)
err = c.UpdateSelfManagedDNPassword(config, "", "currentpassword", newPassword)
assert.Error(t, err)
err = c.UpdateSelfManagedDNPassword(config, "cn=user1,dc=example,dc=org", "currentpassword", "")
assert.Error(t, err)
}

func Test_UpdateSelfManagedDNPassword(t *testing.T) {
ldapServer := setupDockerLDAP(t)
config := &client.Config{
ConfigEntry: &ldaputil.ConfigEntry{
Url: ldapServer,
BindDN: "cn=admin,dc=example,dc=org",
BindPassword: "admin",
},
Schema: client.SchemaOpenLDAP,
}
// self rotate this user
c := NewClient(hclog.NewNullLogger())
err := c.UpdateSelfManagedDNPassword(config, "cn=User1,dc=example,dc=org", "password1", "newpassword")
assert.NoError(t, err)
// validate client didnt change
assert.Equal(t, "cn=admin,dc=example,dc=org", config.BindDN)
assert.Equal(t, "admin", config.BindPassword)
}

func setupDockerLDAP(t *testing.T) string {
t.Helper()
pool, err := dockertest.NewPool("")
Expand Down
14 changes: 11 additions & 3 deletions ldapifc/fakes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions mocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ func (m *mockLDAPClient) UpdateUserPassword(conf *client.Config, user string, ne
return args.Error(0)
}

func (m *mockLDAPClient) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error {
args := m.Called(conf, dn, currentPassword, newPassword)
return args.Error(0)
}

func (m *mockLDAPClient) Execute(conf *client.Config, entries []*ldif.Entry, continueOnError bool) (err error) {
args := m.Called(conf, entries, continueOnError)
return args.Error(0)
Expand Down
4 changes: 4 additions & 0 deletions path_rotate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ func (f *failingRollbackClient) UpdateDNPassword(conf *client.Config, dn string,
return fmt.Errorf("some error")
}

func (f *failingRollbackClient) UpdateSelfManagedDNPassword(conf *client.Config, dn string, currentPassword string, newPassword string) error {
panic("nope")
}

func (f *failingRollbackClient) UpdateUserPassword(conf *client.Config, user, newPassword string) error {
panic("nope")
}
Expand Down
Loading