SECVULN-41436 - Gate merge identity API behind sudo permission (#14436) (#14477)

* add sudo permission for identity merge

---------

Co-authored-by: Michael Stott <michael.stott@hashicorp.com>
Co-authored-by: mstott2 <michael.stott@hashicorp.com`>
Co-authored-by: Violet Hynes <violet.hynes@hashicorp.com>
This commit is contained in:
Vault Automation 2026-05-07 09:07:03 -06:00 committed by GitHub
parent 16268e4e70
commit 79639b7122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 86 additions and 0 deletions

View File

@ -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$`),

3
changelog/_14436.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:change
identity: Require `sudo` capability to invoke the identity entity merge API endpoint (`identity/entity/merge`).
```

View File

@ -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)
}

View File

@ -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,