diff --git a/api/sudo_paths.go b/api/sudo_paths.go index f6b89f107f..7c7138000c 100644 --- a/api/sudo_paths.go +++ b/api/sudo_paths.go @@ -49,6 +49,7 @@ var sudoPaths = map[string]*regexp.Regexp{ "/sys/rotate": regexp.MustCompile(`^/sys/rotate$`), "/sys/seal": regexp.MustCompile(`^/sys/seal$`), "/sys/step-down": regexp.MustCompile(`^/sys/step-down$`), + "/identity/entity/merge": regexp.MustCompile(`^/identity/entity/merge/?$`), // enterprise-only paths "/sys/replication/dr/primary/secondary-token": regexp.MustCompile(`^/sys/replication/dr/primary/secondary-token$`), diff --git a/changelog/_14436.txt b/changelog/_14436.txt new file mode 100644 index 0000000000..14c96c4ea2 --- /dev/null +++ b/changelog/_14436.txt @@ -0,0 +1,3 @@ +```release-note:change +identity: Require `sudo` capability to invoke the identity entity merge API endpoint (`identity/entity/merge`). +``` diff --git a/vault/external_tests/identity/entities_test.go b/vault/external_tests/identity/entities_test.go index 5d905d6d93..760b7cbd9b 100644 --- a/vault/external_tests/identity/entities_test.go +++ b/vault/external_tests/identity/entities_test.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/helper/testhelpers/minimal" "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" ) func TestIdentityStore_EntityDisabled(t *testing.T) { @@ -353,3 +354,79 @@ func TestIdentityStore_EntityPoliciesInInitialAuth(t *testing.T) { t.Fatalf("policy mismatch, got policies: %v", policies) } } + +// TestIdentity_EntityMerge_RequiresSudo verifies the identity entity merge API endpoint requires the sudo capability (in addition to update) when called via the HTTP API. +func TestIdentity_EntityMerge_RequiresSudo(t *testing.T) { + t.Parallel() + + cluster := minimal.NewTestSoloCluster(t, nil) + client := cluster.Cores[0].Client + rootToken := client.Token() + + // Create two entities as root to merge. + toEnt, err := client.Logical().Write("identity/entity", nil) + require.NoError(t, err) + require.NotNil(t, toEnt) + toID, ok := toEnt.Data["id"].(string) + require.True(t, ok) + require.NotEmpty(t, toID) + + fromEnt, err := client.Logical().Write("identity/entity", nil) + require.NoError(t, err) + require.NotNil(t, fromEnt) + fromID, ok := fromEnt.Data["id"].(string) + require.True(t, ok) + require.NotEmpty(t, fromID) + + // Token with update but without sudo should be denied. + require.NoError(t, client.Sys().PutPolicy("identity-merge-no-sudo", ` +path "identity/entity/merge" { + capabilities = ["update"] +} +`)) + + noSudoTok, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"identity-merge-no-sudo"}, + }) + require.NoError(t, err) + require.NotNil(t, noSudoTok) + require.NotNil(t, noSudoTok.Auth) + require.NotEmpty(t, noSudoTok.Auth.ClientToken) + + client.SetToken(noSudoTok.Auth.ClientToken) + _, err = client.Logical().Write("identity/entity/merge", map[string]interface{}{ + "to_entity_id": toID, + "from_entity_ids": []string{fromID}, + }) + require.Error(t, err) + require.ErrorContains(t, err, logical.ErrPermissionDenied.Error()) + + // Token with update+sudo should succeed. + client.SetToken(rootToken) + require.NoError(t, client.Sys().PutPolicy("identity-merge-with-sudo", ` +path "identity/entity/merge" { + capabilities = ["update", "sudo"] +} +`)) + + sudoTok, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"identity-merge-with-sudo"}, + }) + require.NoError(t, err) + require.NotNil(t, sudoTok) + require.NotNil(t, sudoTok.Auth) + require.NotEmpty(t, sudoTok.Auth.ClientToken) + + client.SetToken(sudoTok.Auth.ClientToken) + _, err = client.Logical().Write("identity/entity/merge", map[string]interface{}{ + "to_entity_id": toID, + "from_entity_ids": []string{fromID}, + }) + require.NoError(t, err) + + // Verify the merge happened by checking the from entity was deleted. + client.SetToken(rootToken) + deleted, err := client.Logical().Read("identity/entity/id/" + fromID) + require.NoError(t, err) + require.Nil(t, deleted) +} diff --git a/vault/identity_store.go b/vault/identity_store.go index 7dfdf91c5b..3a7d039f82 100644 --- a/vault/identity_store.go +++ b/vault/identity_store.go @@ -133,6 +133,11 @@ func NewIdentityStore(ctx context.Context, core *Core, config *logical.BackendCo InitializeFunc: iStore.initialize, ActivationFunc: iStore.activate, PathsSpecial: &logical.Paths{ + // Root paths require the token have sudo capability. + Root: []string{ + // Entity merge is destructive and can operate on every entity, so requires a higher privilege as a result + "entity/merge*", + }, Unauthenticated: unauthenticatedPaths, LocalStorage: []string{ localAliasesBucketsPrefix,