diff --git a/builtin/credential/ldap/backend.go b/builtin/credential/ldap/backend.go index d938a4fea9..3f203fb13b 100644 --- a/builtin/credential/ldap/backend.go +++ b/builtin/credential/ldap/backend.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "strings" + "sync" "github.com/hashicorp/cap/ldap" "github.com/hashicorp/go-secure-stdlib/strutil" @@ -17,8 +18,9 @@ import ( ) const ( - operationPrefixLDAP = "ldap" - errUserBindFailed = "ldap operation failed: failed to bind as user" + operationPrefixLDAP = "ldap" + errUserBindFailed = "ldap operation failed: failed to bind as user" + defaultPasswordLength = 64 // length to use for configured root password on rotations by default ) func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { @@ -51,6 +53,7 @@ func Backend() *backend { pathUsers(&b), pathUsersList(&b), pathLogin(&b), + pathConfigRotateRoot(&b), }, AuthRenew: b.pathLoginRenew, @@ -62,6 +65,8 @@ func Backend() *backend { type backend struct { *framework.Backend + + mu sync.RWMutex } func (b *backend) Login(ctx context.Context, req *logical.Request, username string, password string, usernameAsAlias bool) (string, []string, *logical.Response, []string, error) { diff --git a/builtin/credential/ldap/path_config.go b/builtin/credential/ldap/path_config.go index f6e7a152df..e24d04b295 100644 --- a/builtin/credential/ldap/path_config.go +++ b/builtin/credential/ldap/path_config.go @@ -48,6 +48,12 @@ func pathConfig(b *backend) *framework.Path { tokenutil.AddTokenFields(p.Fields) p.Fields["token_policies"].Description += ". This will apply to all tokens generated by this auth method, in addition to any configured for specific users/groups." + + p.Fields["password_policy"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "Password policy to use to rotate the root password", + } + return p } @@ -118,6 +124,9 @@ func (b *backend) Config(ctx context.Context, req *logical.Request) (*ldapConfig } func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + b.mu.RLock() + defer b.mu.RUnlock() + cfg, err := b.Config(ctx, req) if err != nil { return nil, err @@ -128,6 +137,7 @@ func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, d *f data := cfg.PasswordlessMap() cfg.PopulateTokenData(data) + data["password_policy"] = cfg.PasswordPolicy resp := &logical.Response{ Data: data, @@ -164,6 +174,9 @@ func (b *backend) checkConfigUserFilter(cfg *ldapConfigEntry) []string { } func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + b.mu.Lock() + defer b.mu.Unlock() + cfg, err := b.Config(ctx, req) if err != nil { return nil, err @@ -194,6 +207,10 @@ func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d * return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest } + if passwordPolicy, ok := d.GetOk("password_policy"); ok { + cfg.PasswordPolicy = passwordPolicy.(string) + } + entry, err := logical.StorageEntryJSON("config", cfg) if err != nil { return nil, err @@ -234,6 +251,8 @@ func (b *backend) getConfigFieldData() (*framework.FieldData, error) { type ldapConfigEntry struct { tokenutil.TokenParams *ldaputil.ConfigEntry + + PasswordPolicy string `json:"password_policy"` } const pathConfigHelpSyn = ` diff --git a/builtin/credential/ldap/path_config_rotate_root.go b/builtin/credential/ldap/path_config_rotate_root.go new file mode 100644 index 0000000000..1aa008f4dd --- /dev/null +++ b/builtin/credential/ldap/path_config_rotate_root.go @@ -0,0 +1,115 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-2.0 + +package ldap + +import ( + "context" + + "github.com/go-ldap/ldap/v3" + + "github.com/hashicorp/vault/sdk/helper/base62" + "github.com/hashicorp/vault/sdk/helper/ldaputil" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathConfigRotateRoot(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config/rotate-root", + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixLDAP, + OperationVerb: "rotate", + OperationSuffix: "root-credentials", + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathConfigRotateRootUpdate, + ForwardPerformanceSecondary: true, + ForwardPerformanceStandby: true, + }, + }, + + HelpSynopsis: pathConfigRotateRootHelpSyn, + HelpDescription: pathConfigRotateRootHelpDesc, + } +} + +func (b *backend) pathConfigRotateRootUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + // lock the backend's state - really just the config state - for mutating + b.mu.Lock() + defer b.mu.Unlock() + + cfg, err := b.Config(ctx, req) + if err != nil { + return nil, err + } + if cfg == nil { + return logical.ErrorResponse("attempted to rotate root on an undefined config"), nil + } + + u, p := cfg.BindDN, cfg.BindPassword + if u == "" || p == "" { + return logical.ErrorResponse("auth is not using authenticated search, no root to rotate"), nil + } + + // grab our ldap client + client := ldaputil.Client{ + Logger: b.Logger(), + LDAP: ldaputil.NewLDAP(), + } + + conn, err := client.DialLDAP(cfg.ConfigEntry) + if err != nil { + return nil, err + } + + err = conn.Bind(u, p) + if err != nil { + return nil, err + } + + lreq := &ldap.ModifyRequest{ + DN: cfg.BindDN, + } + + var newPassword string + if cfg.PasswordPolicy != "" { + newPassword, err = b.System().GeneratePasswordFromPolicy(ctx, cfg.PasswordPolicy) + } else { + newPassword, err = base62.Random(defaultPasswordLength) + } + if err != nil { + return nil, err + } + + lreq.Replace("userPassword", []string{newPassword}) + + err = conn.Modify(lreq) + if err != nil { + return nil, err + } + // update config with new password + cfg.BindPassword = newPassword + entry, err := logical.StorageEntryJSON("config", cfg) + if err != nil { + return nil, err + } + if err := req.Storage.Put(ctx, entry); err != nil { + // we might have to roll-back the password here? + return nil, err + } + + return nil, nil +} + +const pathConfigRotateRootHelpSyn = ` +Request to rotate the LDAP credentials used by Vault +` + +const pathConfigRotateRootHelpDesc = ` +This path attempts to rotate the LDAP bindpass used by Vault for this mount. +` diff --git a/builtin/credential/ldap/path_config_rotate_root_test.go b/builtin/credential/ldap/path_config_rotate_root_test.go new file mode 100644 index 0000000000..65073472ca --- /dev/null +++ b/builtin/credential/ldap/path_config_rotate_root_test.go @@ -0,0 +1,66 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-2.0 + +package ldap + +import ( + "context" + "os" + "testing" + + "github.com/hashicorp/vault/helper/testhelpers/ldap" + logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical" + "github.com/hashicorp/vault/sdk/logical" +) + +// This test relies on a docker ldap server with a suitable person object (cn=admin,dc=planetexpress,dc=com) +// with bindpassword "admin". `PrepareTestContainer` does this for us. - see the backend_test for more details +func TestRotateRoot(t *testing.T) { + if os.Getenv(logicaltest.TestEnvVar) == "" { + t.Skip("skipping rotate root tests because VAULT_ACC is unset") + } + ctx := context.Background() + + b, store := createBackendWithStorage(t) + cleanup, cfg := ldap.PrepareTestContainer(t, "latest") + defer cleanup() + // set up auth config + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config", + Storage: store, + Data: map[string]interface{}{ + "url": cfg.Url, + "binddn": cfg.BindDN, + "bindpass": cfg.BindPassword, + "userdn": cfg.UserDN, + }, + } + + resp, err := b.HandleRequest(ctx, req) + if err != nil { + t.Fatalf("failed to initialize ldap auth config: %s", err) + } + if resp != nil && resp.IsError() { + t.Fatalf("failed to initialize ldap auth config: %s", resp.Data["error"]) + } + + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/rotate-root", + Storage: store, + } + + _, err = b.HandleRequest(ctx, req) + if err != nil { + t.Fatalf("failed to rotate password: %s", err) + } + + newCFG, err := b.Config(ctx, req) + if newCFG.BindDN != cfg.BindDN { + t.Fatalf("a value in config that should have stayed the same changed: %s", cfg.BindDN) + } + if newCFG.BindPassword == cfg.BindPassword { + t.Fatalf("the password should have changed, but it didn't") + } +} diff --git a/changelog/24099.txt b/changelog/24099.txt new file mode 100644 index 0000000000..bc33a184f9 --- /dev/null +++ b/changelog/24099.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Rotate Root for LDAP auth**: Rotate root operations are now supported for the LDAP auth engine. +```