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/ui/lib/config-ui/addon/components/messages/tab-page-header.hbs b/ui/lib/config-ui/addon/components/messages/tab-page-header.hbs index 56bded4928..90c4a7f091 100644 --- a/ui/lib/config-ui/addon/components/messages/tab-page-header.hbs +++ b/ui/lib/config-ui/addon/components/messages/tab-page-header.hbs @@ -27,7 +27,7 @@ After user logs in @@ -37,7 +37,7 @@ On login page diff --git a/ui/tests/acceptance/config-ui/messages/messages-test.js b/ui/tests/acceptance/config-ui/messages/messages-test.js index 49a5f23126..0dfed9db25 100644 --- a/ui/tests/acceptance/config-ui/messages/messages-test.js +++ b/ui/tests/acceptance/config-ui/messages/messages-test.js @@ -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); 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,