diff --git a/changelog/24492.txt b/changelog/24492.txt new file mode 100644 index 00000000000..d61c901a2c1 --- /dev/null +++ b/changelog/24492.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: fix navigation items shown to user when chroot_namespace configured +``` diff --git a/ui/app/components/sidebar/nav/cluster.hbs b/ui/app/components/sidebar/nav/cluster.hbs index 2b0ecff88dd..33dd4f2e58a 100644 --- a/ui/app/components/sidebar/nav/cluster.hbs +++ b/ui/app/components/sidebar/nav/cluster.hbs @@ -50,9 +50,7 @@ {{#if (or - (and - this.namespace.inRootNamespace (has-permission "status" routeParams=(array "replication" "raft" "license" "seal")) - ) + (and this.isRootNamespace (has-permission "status" routeParams=(array "replication" "raft" "license" "seal"))) (has-permission "clients" routeParams="activity") ) }} @@ -61,7 +59,7 @@ {{#if (and this.version.isEnterprise - this.namespace.inRootNamespace + this.isRootNamespace (not this.cluster.replicationRedacted) (has-permission "status" routeParams="replication") ) @@ -73,7 +71,7 @@ @hasSubItems={{true}} /> {{/if}} - {{#if (and this.cluster.usingRaft this.namespace.inRootNamespace (has-permission "status" routeParams="raft"))}} + {{#if (and this.cluster.usingRaft this.isRootNamespace (has-permission "status" routeParams="raft"))}} {{/if}} - {{#if (and this.namespace.inRootNamespace (has-permission "status" routeParams="seal") (not this.cluster.dr.isSecondary))}} + {{#if (and this.isRootNamespace (has-permission "status" routeParams="seal") (not this.cluster.dr.isSecondary))}} diff --git a/ui/lib/core/addon/utils/sanitize-path.js b/ui/lib/core/addon/utils/sanitize-path.js index 7ca66c651de..88a7bbc9fe3 100644 --- a/ui/lib/core/addon/utils/sanitize-path.js +++ b/ui/lib/core/addon/utils/sanitize-path.js @@ -9,6 +9,12 @@ export function sanitizePath(path) { return path.trim().replace(/^\/+|\/+$/g, ''); } +export function sanitizeStart(path) { + if (!path) return ''; + //remove leading slashes + return path.trim().replace(/^\/+/, ''); +} + export function ensureTrailingSlash(path) { return path.replace(/(\w+[^/]$)/g, '$1/'); } diff --git a/ui/mirage/handlers/chroot-namespace.js b/ui/mirage/handlers/chroot-namespace.js index 97a17e10074..6a6fb5d7eca 100644 --- a/ui/mirage/handlers/chroot-namespace.js +++ b/ui/mirage/handlers/chroot-namespace.js @@ -4,6 +4,7 @@ */ import { Response } from 'miragejs'; +import modifyPassthroughResponse from '../helpers/modify-passthrough-response'; /* These are mocked responses to mimic what we get from the server @@ -12,4 +13,7 @@ import { Response } from 'miragejs'; export default function (server) { server.get('sys/health', () => new Response(400, {}, { errors: ['unsupported path'] })); server.get('sys/replication/status', () => new Response(400, {}, { errors: ['unsupported path'] })); + server.get('sys/internal/ui/resultant-acl', (schema, req) => + modifyPassthroughResponse(req, { chroot_namespace: 'my-ns' }) + ); } diff --git a/ui/tests/acceptance/chroot-namespace-test.js b/ui/tests/acceptance/chroot-namespace-test.js index 5bca573f31c..48fe749ef8e 100644 --- a/ui/tests/acceptance/chroot-namespace-test.js +++ b/ui/tests/acceptance/chroot-namespace-test.js @@ -9,6 +9,11 @@ import { currentRouteName } from '@ember/test-helpers'; import authPage from 'vault/tests/pages/auth'; import { setupMirage } from 'ember-cli-mirage/test-support'; import ENV from 'vault/config/environment'; +import { createTokenCmd, runCmd, tokenWithPolicyCmd } from '../helpers/commands'; + +const navLink = (title) => `[data-test-sidebar-nav-link="${title}"]`; +// Matches the chroot namespace on the mirage handler +const namespace = 'my-ns'; module('Acceptance | chroot-namespace enterprise ui', function (hooks) { setupApplicationTest(hooks); @@ -26,4 +31,97 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) { assert.strictEqual(currentRouteName(), 'vault.cluster.dashboard', 'goes to dashboard page'); assert.dom('[data-test-badge-namespace]').includesText('root', 'Shows root namespace badge'); }); + + test('a user with default policy should see nav items', async function (assert) { + await authPage.login(); + // Create namespace + await runCmd(`write sys/namespaces/${namespace} -f`, false); + // Create user within the namespace + await authPage.loginNs(namespace); + const userDefault = await runCmd(createTokenCmd()); + + await authPage.loginNs(namespace, userDefault); + ['Dashboard', 'Secrets Engines', 'Access', 'Tools'].forEach((nav) => { + assert.dom(navLink(nav)).exists(`Shows ${nav} nav item for user with default policy`); + }); + ['Policies', 'Client Count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => { + assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item for user with default policy`); + }); + + // cleanup namespace + await authPage.login(); + await runCmd(`delete sys/namespaces/${namespace}`); + }); + + test('a user with read policy should see nav items', async function (assert) { + await authPage.login(); + // Create namespace + await runCmd(`write sys/namespaces/${namespace} -f`, false); + // Create user within the namespace + await authPage.loginNs(namespace); + const reader = await runCmd( + tokenWithPolicyCmd( + 'read-all', + ` + path "sys/*" { + capabilities = ["read"] + } + ` + ) + ); + + await authPage.loginNs(namespace, reader); + ['Dashboard', 'Secrets Engines', 'Access', 'Policies', 'Tools', 'Client Count'].forEach((nav) => { + assert.dom(navLink(nav)).exists(`Shows ${nav} nav item for user with read access policy`); + }); + ['Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => { + assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item for user with read access policy`); + }); + + // cleanup namespace + await authPage.login(); + await runCmd(`delete sys/namespaces/${namespace}`); + }); + + test('it works within a child namespace', async function (assert) { + await authPage.login(); + // Create namespace + await runCmd(`write sys/namespaces/${namespace} -f`, false); + // Create user within the namespace + await authPage.loginNs(namespace); + const childReader = await runCmd( + tokenWithPolicyCmd( + 'read-child', + ` + path "child/sys/*" { + capabilities = ["read"] + } + ` + ) + ); + // Create child namespace + await runCmd(`write sys/namespaces/child -f`, false); + + await authPage.loginNs(namespace, childReader); + ['Dashboard', 'Secrets Engines', 'Access', 'Tools'].forEach((nav) => { + assert.dom(navLink(nav)).exists(`Shows ${nav} nav item`); + }); + ['Policies', 'Client Count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => { + assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item`); + }); + + await authPage.loginNs(`${namespace}/child`, childReader); + ['Dashboard', 'Secrets Engines', 'Access', 'Policies', 'Tools', 'Client Count'].forEach((nav) => { + assert.dom(navLink(nav)).exists(`Shows ${nav} nav item within child namespace`); + }); + ['Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => { + assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item within child namespace`); + }); + + // cleanup namespaces + await authPage.loginNs(namespace); + await runCmd(`delete sys/namespaces/child`); + await authPage.login(); + await runCmd(`delete sys/namespaces/${namespace}`); + }); }); diff --git a/ui/tests/unit/services/permissions-test.js b/ui/tests/unit/services/permissions-test.js index ceb7cf89429..c9483fc365a 100644 --- a/ui/tests/unit/services/permissions-test.js +++ b/ui/tests/unit/services/permissions-test.js @@ -53,66 +53,11 @@ module('Unit | Service | permissions', function (hooks) { assert.deepEqual(this.service.get('globPaths'), PERMISSIONS_RESPONSE.data.glob_paths); }); - test('returns true if a policy includes access to an exact path', function (assert) { - this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); - assert.true(this.service.hasPermission('foo')); - }); - - test('returns true if a paths prefix is included in the policys exact paths', function (assert) { - this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); - assert.true(this.service.hasPermission('bar')); - }); - - test('it returns true if a policy includes access to a glob path', function (assert) { - this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); - assert.true(this.service.hasPermission('baz/biz/hi')); - }); - - test('it returns true if a policy includes access to the * glob path', function (assert) { - const splatPath = { '': {} }; - this.service.set('globPaths', splatPath); - assert.true(this.service.hasPermission('hi')); - }); - - test('it returns false if the matched path includes the deny capability', function (assert) { - this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); - assert.false(this.service.hasPermission('boo')); - }); - - test('it returns true if passed path does not end in a slash but globPath does', function (assert) { - this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); - assert.true(this.service.hasPermission('ends/in/slash'), 'matches without slash'); - assert.true(this.service.hasPermission('ends/in/slash/'), 'matches with slash'); - }); - - test('it returns false if a policy does not includes access to a path', function (assert) { - assert.false(this.service.hasPermission('danger')); - }); - test('sets the root token', function (assert) { this.service.setPaths({ data: { root: true } }); assert.true(this.service.canViewAll); }); - test('returns true with the root token', function (assert) { - this.service.set('canViewAll', true); - assert.true(this.service.hasPermission('hi')); - }); - - test('it returns true if a policy has the specified capabilities on a path', function (assert) { - this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); - this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); - assert.true(this.service.hasPermission('bar/bee', ['create', 'list'])); - assert.true(this.service.hasPermission('baz/biz', ['read'])); - }); - - test('it returns false if a policy does not have the specified capabilities on a path', function (assert) { - this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); - this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); - assert.false(this.service.hasPermission('bar/bee', ['create', 'delete'])); - assert.false(this.service.hasPermission('foo', ['create'])); - }); - test('defaults to show all items when policy cannot be found', async function (assert) { this.server.get('/v1/sys/internal/ui/resultant-acl', () => { return [403, { 'Content-Type': 'application/json' }]; @@ -148,77 +93,161 @@ module('Unit | Service | permissions', function (hooks) { assert.deepEqual(this.service.navPathParams('access'), expected); }); - test('hasNavPermission returns true if a policy includes the required capabilities for at least one path', function (assert) { - const accessPaths = { - 'sys/auth': { - capabilities: ['deny'], - }, - 'identity/group/id': { - capabilities: ['list', 'read'], - }, - }; - this.service.set('exactPaths', accessPaths); - assert.true(this.service.hasNavPermission('access', 'groups')); - }); + module('hasPermission', function () { + test('returns true if a policy includes access to an exact path', function (assert) { + this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + assert.true(this.service.hasPermission('foo')); + }); - test('hasNavPermission returns false if a policy does not include the required capabilities for at least one path', function (assert) { - const accessPaths = { - 'sys/auth': { - capabilities: ['deny'], - }, - 'identity/group/id': { - capabilities: ['read'], - }, - }; - this.service.set('exactPaths', accessPaths); - assert.false(this.service.hasNavPermission('access', 'groups')); + test('returns true if a paths prefix is included in the policys exact paths', function (assert) { + this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + assert.true(this.service.hasPermission('bar')); + }); + + test('it returns true if a policy includes access to a glob path', function (assert) { + this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.true(this.service.hasPermission('baz/biz/hi')); + }); + + test('it returns true if a policy includes access to the * glob path', function (assert) { + const splatPath = { '': {} }; + this.service.set('globPaths', splatPath); + assert.true(this.service.hasPermission('hi')); + }); + + test('it returns false if the matched path includes the deny capability', function (assert) { + this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.false(this.service.hasPermission('boo')); + }); + + test('it returns true if passed path does not end in a slash but globPath does', function (assert) { + this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.true(this.service.hasPermission('ends/in/slash'), 'matches without slash'); + assert.true(this.service.hasPermission('ends/in/slash/'), 'matches with slash'); + }); + + test('it returns false if a policy does not includes access to a path', function (assert) { + assert.false(this.service.hasPermission('danger')); + }); + test('returns true with the root token', function (assert) { + this.service.set('canViewAll', true); + assert.true(this.service.hasPermission('hi')); + }); + test('it returns true if a policy has the specified capabilities on a path', function (assert) { + this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.true(this.service.hasPermission('bar/bee', ['create', 'list'])); + assert.true(this.service.hasPermission('baz/biz', ['read'])); + }); + + test('it returns false if a policy does not have the specified capabilities on a path', function (assert) { + this.service.set('exactPaths', PERMISSIONS_RESPONSE.data.exact_paths); + this.service.set('globPaths', PERMISSIONS_RESPONSE.data.glob_paths); + assert.false(this.service.hasPermission('bar/bee', ['create', 'delete'])); + assert.false(this.service.hasPermission('foo', ['create'])); + }); }); - test('hasNavPermission should handle routeParams as array', function (assert) { - const getPaths = (override) => ({ - 'sys/auth': { - capabilities: [override || 'read'], - }, - 'identity/mfa/method': { - capabilities: [override || 'read'], - }, - 'identity/oidc/client': { - capabilities: [override || 'deny'], - }, + module('hasNavPermission', function () { + test('returns true if a policy includes the required capabilities for at least one path', function (assert) { + const accessPaths = { + 'sys/auth': { + capabilities: ['deny'], + }, + 'identity/group/id': { + capabilities: ['list', 'read'], + }, + }; + this.service.set('exactPaths', accessPaths); + assert.true(this.service.hasNavPermission('access', 'groups')); }); - this.service.set('exactPaths', getPaths()); - assert.true( - this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc']), - 'hasNavPermission returns true for array of route params when any route is permitted' - ); - assert.false( - this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), - 'hasNavPermission returns false for array of route params when any route is not permitted and requireAll is passed' - ); - - this.service.set('exactPaths', getPaths('read')); - assert.true( - this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), - 'hasNavPermission returns true for array of route params when all routes are permitted and requireAll is passed' - ); - - this.service.set('exactPaths', getPaths('deny')); - assert.false( - this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc']), - 'hasNavPermission returns false for array of route params when no routes are permitted' - ); - assert.false( - this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), - 'hasNavPermission returns false for array of route params when no routes are permitted and requireAll is passed' - ); + test('returns false if a policy does not include the required capabilities for at least one path', function (assert) { + const accessPaths = { + 'sys/auth': { + capabilities: ['deny'], + }, + 'identity/group/id': { + capabilities: ['read'], + }, + }; + this.service.set('exactPaths', accessPaths); + assert.false(this.service.hasNavPermission('access', 'groups')); + }); + + test('should handle routeParams as array', function (assert) { + const getPaths = (override) => ({ + 'sys/auth': { + capabilities: [override || 'read'], + }, + 'identity/mfa/method': { + capabilities: [override || 'read'], + }, + 'identity/oidc/client': { + capabilities: [override || 'deny'], + }, + }); + + this.service.set('exactPaths', getPaths()); + assert.true( + this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc']), + 'hasNavPermission returns true for array of route params when any route is permitted' + ); + assert.false( + this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), + 'hasNavPermission returns false for array of route params when any route is not permitted and requireAll is passed' + ); + + this.service.set('exactPaths', getPaths('read')); + assert.true( + this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), + 'hasNavPermission returns true for array of route params when all routes are permitted and requireAll is passed' + ); + + this.service.set('exactPaths', getPaths('deny')); + assert.false( + this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc']), + 'hasNavPermission returns false for array of route params when no routes are permitted' + ); + assert.false( + this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc'], true), + 'hasNavPermission returns false for array of route params when no routes are permitted and requireAll is passed' + ); + }); }); - test('appends the namespace to the path if there is one', function (assert) { - const namespaceService = Service.extend({ - path: 'marketing', + module('pathWithNamespace', function () { + test('appends the namespace to the path if there is one', function (assert) { + const namespaceService = Service.extend({ + path: 'marketing', + }); + this.owner.register('service:namespace', namespaceService); + assert.strictEqual(this.service.pathNameWithNamespace('sys/auth'), 'marketing/sys/auth'); + }); + + test('appends the chroot and namespace when both present', function (assert) { + const namespaceService = Service.extend({ + path: 'marketing', + }); + this.owner.register('service:namespace', namespaceService); + this.service.set('chrootNamespace', 'admin/'); + assert.strictEqual(this.service.pathNameWithNamespace('sys/auth'), 'admin/marketing/sys/auth'); + }); + test('appends the chroot when no namespace', function (assert) { + this.service.set('chrootNamespace', 'admin'); + assert.strictEqual(this.service.pathNameWithNamespace('sys/auth'), 'admin/sys/auth'); + }); + test('handles superfluous slashes', function (assert) { + const namespaceService = Service.extend({ + path: '/marketing', + }); + this.owner.register('service:namespace', namespaceService); + this.service.set('chrootNamespace', '/admin/'); + assert.strictEqual(this.service.pathNameWithNamespace('/sys/auth'), 'admin/marketing/sys/auth'); + assert.strictEqual( + this.service.pathNameWithNamespace('/sys/policies/'), + 'admin/marketing/sys/policies/' + ); }); - this.owner.register('service:namespace', namespaceService); - assert.strictEqual(this.service.pathNameWithNamespace('sys/auth'), 'marketing/sys/auth'); }); }); diff --git a/ui/tests/unit/utils/sanitize-path-test.js b/ui/tests/unit/utils/sanitize-path-test.js index 7107d737f2c..fe4fa998403 100644 --- a/ui/tests/unit/utils/sanitize-path-test.js +++ b/ui/tests/unit/utils/sanitize-path-test.js @@ -4,7 +4,7 @@ */ import { module, test } from 'qunit'; -import { ensureTrailingSlash, getRelativePath, sanitizePath } from 'core/utils/sanitize-path'; +import { ensureTrailingSlash, getRelativePath, sanitizePath, sanitizeStart } from 'core/utils/sanitize-path'; module('Unit | Utility | sanitize-path', function () { test('it removes spaces and slashes from either side', function (assert) { @@ -29,4 +29,18 @@ module('Unit | Utility | sanitize-path', function () { assert.strictEqual(getRelativePath('/recipes/cookies/choc-chip/', 'recipes/cookies'), 'choc-chip'); assert.strictEqual(getRelativePath('/admin/bop/boop/admin_foo/baz/', 'admin'), 'bop/boop/admin_foo/baz'); }); + + test('#sanitizeStart', function (assert) { + assert.strictEqual( + sanitizeStart(' /foo/bar/baz/ '), + 'foo/bar/baz/', + 'trims spaces and removes slashes only from beginning' + ); + assert.strictEqual( + sanitizeStart('//foo/bar/baz/'), + 'foo/bar/baz/', + 'removes more than one slash from start' + ); + assert.strictEqual(sanitizeStart(undefined), '', 'handles falsey values'); + }); });