Merge remote-tracking branch 'remotes/from/ce/main'

This commit is contained in:
hc-github-team-secure-vault-core 2026-05-07 15:44:57 +00:00
commit 0490a964f9
6 changed files with 147 additions and 3 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

@ -27,7 +27,7 @@
<LinkTo
class={{if @authenticated "active"}}
@route="messages"
@query={{hash authenticated=true page=1}}
@query={{hash authenticated=true page=1 pageFilter=null status=null type=null}}
data-test-custom-messages-tab="After user logs in"
>
After user logs in
@ -37,7 +37,7 @@
<LinkTo
class={{unless @authenticated "active"}}
@route="messages"
@query={{hash authenticated=false page=1}}
@query={{hash authenticated=false page=1 pageFilter=null status=null type=null}}
data-test-custom-messages-tab="On login page"
>
On login page

View File

@ -6,7 +6,7 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, visit, fillIn, findAll, waitFor } from '@ember/test-helpers';
import { click, visit, fillIn, findAll, waitFor, currentURL } from '@ember/test-helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { runCmd } from 'vault/tests/helpers/commands';
import { format, addDays, startOfDay } from 'date-fns';
@ -205,6 +205,64 @@ module('Acceptance | Enterprise | config-ui/message', function (hooks) {
await this.deleteMessages();
});
test('it should clear filter params when switching between tabs', async function (assert) {
await this.createMessageRepl({ title: 'tab-switch-test-1', type: 'banner', authenticated: true });
await this.createMessageRepl({ title: 'tab-switch-test-2', type: 'modal', authenticated: false });
// Start on authenticated tab with filters applied
await visit('vault/config-ui/messages?authenticated=true&pageFilter=test&status=active&type=banner');
// Verify filters are applied
assert.dom(GENERAL.filter('pageFilter')).hasValue('test', 'pageFilter is set');
assert.dom(GENERAL.filter('status')).hasValue('active', 'status filter is set');
assert.dom(GENERAL.filter('type')).hasValue('banner', 'type filter is set');
// Switch to unauthenticated tab
await click(CUSTOM_MESSAGES.tab('On login page'));
// Verify filters are cleared after tab switch
assert.dom(GENERAL.filter('pageFilter')).hasValue('', 'pageFilter is cleared after tab switch');
assert.dom(GENERAL.filter('status')).hasValue('', 'status filter is cleared after tab switch');
assert.dom(GENERAL.filter('type')).hasValue('', 'type filter is cleared after tab switch');
// Verify URL params are cleared (except authenticated and page)
const unauthenticatedUrl = currentURL();
assert.true(unauthenticatedUrl.includes('authenticated=false'), 'authenticated param is set to false');
assert.false(unauthenticatedUrl.includes('pageFilter'), 'pageFilter param is not in URL');
assert.false(unauthenticatedUrl.includes('status'), 'status param is not in URL');
assert.false(unauthenticatedUrl.includes('type'), 'type param is not in URL');
// Apply filters on unauthenticated tab
await fillIn(GENERAL.filter('pageFilter'), 'modal-test');
await fillIn(GENERAL.filter('type'), 'modal');
await click(GENERAL.submitButton);
// Verify filters are applied
assert
.dom(GENERAL.filter('pageFilter'))
.hasValue('modal-test', 'pageFilter is set on unauthenticated tab');
assert.dom(GENERAL.filter('type')).hasValue('modal', 'type filter is set on unauthenticated tab');
// Switch back to authenticated tab
await click(CUSTOM_MESSAGES.tab('After user logs in'));
// Verify filters are cleared again
assert.dom(GENERAL.filter('pageFilter')).hasValue('', 'pageFilter is cleared when switching back');
assert.dom(GENERAL.filter('type')).hasValue('', 'type filter is cleared when switching back');
// Verify URL params are cleared
const authenticated = currentURL();
const authenticatedParam =
authenticated.includes('authenticated=true') || !authenticated.includes('authenticated=false');
assert.true(authenticatedParam, 'authenticated param is set to true or uses default');
assert.false(authenticated.includes('pageFilter'), 'pageFilter param is not in URL after switching back');
assert.false(authenticated.includes('status'), 'status param is not in URL after switching back');
assert.false(authenticated.includes('type'), 'type param is not in URL after switching back');
// delete the created messages
await this.deleteMessages();
});
test('it should display preview a message when all required fields are filled out', async function (assert) {
await click(GENERAL.navLink('Operational tools'));
await click(CUSTOM_MESSAGES.navLink);

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,