UI: Don't show resultant-ACL banner in nested namespace if ancestor wildcard policy (#27263)

* Refactor hasWildcardAccess to check for ancestors of current namespace in globPaths

* Add extra test coverage

* remove tests for removed getter

* changelog + test update

* update test coverage to also check resulting permissionsBanner state

* rename hasWildcardAccess for clarity, add isDenied check on namespace access check

* Update changelog/27263.txt

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Remove redundant check

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Chelsea Shaw
2024-05-30 14:30:10 -05:00
committed by GitHub
parent 5c275e7d88
commit 83949e8023
3 changed files with 225 additions and 102 deletions

3
changelog/27263.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: Do not show resultant-ACL banner when ancestor namespace grants wildcard access.
```

View File

@@ -99,7 +99,7 @@ export default class PermissionsService extends Service {
@service store; @service store;
@service namespace; @service namespace;
get baseNs() { get fullCurrentNamespace() {
const currentNs = this.namespace.path; const currentNs = this.namespace.path;
return this.chrootNamespace return this.chrootNamespace
? `${sanitizePath(this.chrootNamespace)}/${sanitizePath(currentNs)}` ? `${sanitizePath(this.chrootNamespace)}/${sanitizePath(currentNs)}`
@@ -122,24 +122,37 @@ export default class PermissionsService extends Service {
} }
} }
get wildcardPath() {
const ns = [sanitizePath(this.chrootNamespace), sanitizePath(this.namespace.userRootNamespace)].join('/');
// wildcard path comes back from root namespace as empty string,
// but within a namespace it's the namespace itself ending with a slash
return ns === '/' ? '' : `${sanitizePath(ns)}/`;
}
/** /**
* hasWildcardAccess checks if the user has a wildcard policy * hasWildcardNsAccess checks if the user has a wildcard access to target namespace
* via full glob path or any ancestors of the target namespace
* @param {string} targetNs is the current/target namespace that we are checking access for
* @param {object} globPaths key is path, value is object with capabilities * @param {object} globPaths key is path, value is object with capabilities
* @returns {boolean} whether the user's policy includes wildcard access to NS * @returns {boolean} whether the user's policy includes wildcard access to NS
*/ */
hasWildcardAccess(globPaths = {}) { hasWildcardNsAccess(targetNs, globPaths = {}) {
// First check if the wildcard path is in the globPaths object const nsParts = sanitizePath(targetNs).split('/');
if (!Object.keys(globPaths).includes(this.wildcardPath)) return false; let matchKey = null;
// For each section of the namespace, check if there is a matching wildcard path
while (nsParts.length > 0) {
// glob paths always end in a slash
const test = `${nsParts.join('/')}/`;
if (Object.keys(globPaths).includes(test)) {
matchKey = test;
break;
}
nsParts.pop();
}
// Finally, check if user has wildcard access to the root namespace
// which is represented by an empty string
if (!matchKey && Object.keys(globPaths).includes('')) {
matchKey = '';
}
if (null === matchKey) {
return false;
}
// if so, make sure the current namespace is a child of the wildcard path // if there is a match make sure the capabilities do not include deny
return this.namespace.path.startsWith(this.wildcardPath); return !this.isDenied(globPaths[matchKey]);
} }
// This method is called to recalculate whether to show the permissionsBanner when the namespace changes // This method is called to recalculate whether to show the permissionsBanner when the namespace changes
@@ -148,14 +161,14 @@ export default class PermissionsService extends Service {
this.permissionsBanner = null; this.permissionsBanner = null;
return; return;
} }
const namespace = this.baseNs; const namespace = this.fullCurrentNamespace;
const allowed = const allowed =
// check if the user has wildcard access to the relative root namespace // check if the user has wildcard access to the relative root namespace
this.hasWildcardAccess(this.globPaths) || this.hasWildcardNsAccess(namespace, this.globPaths) ||
// or if any of their glob paths start with the namespace // or if any of their glob paths start with the namespace
Object.keys(this.globPaths).any((k) => k.startsWith(namespace)) || Object.keys(this.globPaths).any((k) => k.startsWith(namespace) && !this.isDenied(this.globPaths[k])) ||
// or if any of their exact paths start with the namespace // or if any of their exact paths start with the namespace
Object.keys(this.exactPaths).any((k) => k.startsWith(namespace)); Object.keys(this.exactPaths).any((k) => k.startsWith(namespace) && !this.isDenied(this.exactPaths[k]));
this.permissionsBanner = allowed ? null : PERMISSIONS_BANNER_STATES.noAccess; this.permissionsBanner = allowed ? null : PERMISSIONS_BANNER_STATES.noAccess;
} }
@@ -200,7 +213,7 @@ export default class PermissionsService extends Service {
} }
pathNameWithNamespace(pathName) { pathNameWithNamespace(pathName) {
const namespace = this.baseNs; const namespace = this.fullCurrentNamespace;
if (namespace) { if (namespace) {
return `${sanitizePath(namespace)}/${sanitizeStart(pathName)}`; return `${sanitizePath(namespace)}/${sanitizeStart(pathName)}`;
} else { } else {

View File

@@ -8,6 +8,7 @@ import { setupTest } from 'ember-qunit';
import Service from '@ember/service'; import Service from '@ember/service';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { overrideResponse } from 'vault/tests/helpers/stubs'; import { overrideResponse } from 'vault/tests/helpers/stubs';
import { PERMISSIONS_BANNER_STATES } from 'vault/services/permissions';
const PERMISSIONS_RESPONSE = { const PERMISSIONS_RESPONSE = {
data: { data: {
@@ -246,58 +247,9 @@ module('Unit | Service | permissions', function (hooks) {
}); });
}); });
module('wildcardPath calculates correctly', function () { module('permissions banner calculates correctly', function () {
[
{
scenario: 'no user root or chroot',
userRoot: '',
chroot: null,
expectedPath: '',
},
{
scenario: 'user root = child ns and no chroot',
userRoot: 'bar',
chroot: null,
expectedPath: 'bar/',
},
{
scenario: 'user root = child ns and chroot set',
userRoot: 'bar',
chroot: 'admin/',
expectedPath: 'admin/bar/',
},
{
scenario: 'no user root and chroot set',
userRoot: '',
chroot: 'admin/',
expectedPath: 'admin/',
},
].forEach((testCase) => {
test(`when ${testCase.scenario}`, function (assert) {
const namespaceService = Service.extend({
userRootNamespace: testCase.userRoot,
path: 'current/path/does/not/matter',
});
this.owner.register('service:namespace', namespaceService);
this.service.set('chrootNamespace', testCase.chroot);
assert.strictEqual(this.service.wildcardPath, testCase.expectedPath);
});
});
test('when user root =child ns and chroot set', function (assert) {
const namespaceService = Service.extend({
path: 'bar/baz',
userRootNamespace: 'bar',
});
this.owner.register('service:namespace', namespaceService);
this.service.set('chrootNamespace', 'admin/');
assert.strictEqual(this.service.wildcardPath, 'admin/bar/');
});
});
module('hasWildcardAccess calculates correctly', function () {
// The resultant-acl endpoint returns paths with chroot and
// relative root prefixed on all paths.
[ [
// First set: no chroot or user root
{ {
scenario: 'when root wildcard in root namespace', scenario: 'when root wildcard in root namespace',
chroot: null, chroot: null,
@@ -306,18 +258,140 @@ module('Unit | Service | permissions', function (hooks) {
globs: { globs: {
'': { capabilities: ['read'] }, '': { capabilities: ['read'] },
}, },
expectedAccess: true, expected: {
wildcard: true,
banner: null,
fullNs: 'foo/bar',
},
}, },
{
scenario: 'when nested access granted in root namespace',
chroot: null,
userRoot: '',
currentNs: 'foo/bing',
globs: {
'foo/': { capabilities: ['read'] },
},
expected: {
wildcard: true,
banner: null,
fullNs: 'foo/bing',
},
},
{
scenario: 'when engine access granted',
chroot: null,
userRoot: '',
currentNs: 'foo/bing',
globs: {
'foo/bing/kv/data/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: null,
fullNs: 'foo/bing',
},
},
// Second set: chroot and user root (currentNs excludes chroot)
{
scenario: 'when namespace wildcard in child ns & chroot',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'bar/baz',
globs: {
'foo/bar/': { capabilities: ['read'] },
},
expected: {
wildcard: true,
banner: null,
fullNs: 'foo/bar/baz',
},
},
{
scenario: 'when namespace wildcard in different ns than user root',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'bing',
globs: {
'foo/bar/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: PERMISSIONS_BANNER_STATES.noAccess,
fullNs: 'foo/bing',
},
},
{
scenario: 'when engine access granted with chroot and user root',
chroot: 'foo/',
userRoot: 'bing',
currentNs: 'bing',
globs: {
'foo/bing/kv/data/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: null,
fullNs: 'foo/bing',
},
},
// Third set: chroot only (currentNs excludes chroot)
{ {
scenario: 'when root wildcard in chroot ns', scenario: 'when root wildcard in chroot ns',
chroot: 'admin/', chroot: 'admin/',
userRoot: '', userRoot: '',
currentNs: 'admin/child', currentNs: 'child',
globs: { globs: {
'admin/': { capabilities: ['read'] }, 'admin/': { capabilities: ['read'] },
}, },
expected: {
wildcard: true,
banner: null,
fullNs: 'admin/child',
},
expectedAccess: true, expectedAccess: true,
}, },
{
scenario: 'when nested access granted in root namespace and chroot',
chroot: 'foo/',
userRoot: '',
currentNs: 'bing/baz',
globs: {
'foo/bing/': { capabilities: ['read'] },
},
expected: {
wildcard: true,
banner: null,
fullNs: 'foo/bing/baz',
},
},
{
scenario: 'when engine access granted with chroot',
chroot: 'foo/',
userRoot: '',
currentNs: 'bing',
globs: {
'foo/bing/kv/data/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: null,
fullNs: 'foo/bing',
},
},
// Fourth set: user root, no chroot
{
scenario: 'when globs is empty',
chroot: null,
userRoot: 'foo',
currentNs: 'foo/bing',
globs: {},
expected: {
wildcard: false,
banner: PERMISSIONS_BANNER_STATES.noAccess,
fullNs: 'foo/bing',
},
},
{ {
scenario: 'when namespace wildcard in child ns', scenario: 'when namespace wildcard in child ns',
chroot: null, chroot: null,
@@ -326,56 +400,89 @@ module('Unit | Service | permissions', function (hooks) {
globs: { globs: {
'bar/': { capabilities: ['read'] }, 'bar/': { capabilities: ['read'] },
}, },
expectedAccess: true, expected: {
}, wildcard: true,
{ banner: null,
scenario: 'when namespace wildcard in child ns & chroot', fullNs: 'bar/baz',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'foo/bar/baz',
globs: {
'foo/bar/': { capabilities: ['read'] },
}, },
expectedAccess: true,
}, },
{ {
scenario: 'when namespace wildcard in different ns with chroot & user root', scenario: 'when namespace wildcard in different ns',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'foo/bing',
globs: {
'foo/bar/': { capabilities: ['read'] },
},
expectedAccess: false,
},
{
scenario: 'when namespace wildcard in different ns without chroot',
chroot: null, chroot: null,
userRoot: 'bar', userRoot: 'bar',
currentNs: 'foo/bing', currentNs: 'foo/bing',
globs: { globs: {
'bar/': { capabilities: ['read'] }, 'bar/': { capabilities: ['read'] },
}, },
expected: {
wildcard: false,
banner: PERMISSIONS_BANNER_STATES.noAccess,
fullNs: 'foo/bing',
},
expectedAccess: false, expectedAccess: false,
}, },
{ {
scenario: 'when globs is empty', scenario: 'when access granted via parent namespace in child ns',
chroot: 'foo/', chroot: null,
userRoot: 'foo',
currentNs: 'foo/bing/baz',
globs: {
'foo/bing/': { capabilities: ['read'] },
},
expected: {
wildcard: true,
banner: null,
fullNs: 'foo/bing/baz',
},
},
{
scenario: 'when namespace access denied for child ns',
chroot: null,
userRoot: 'bar', userRoot: 'bar',
currentNs: 'bar/baz/bin',
globs: {
'bar/': { capabilities: ['read'] },
'bar/baz/': { capabilities: ['deny'] },
},
expected: {
wildcard: false,
banner: PERMISSIONS_BANNER_STATES.noAccess,
fullNs: 'bar/baz/bin',
},
},
{
scenario: 'when engine access granted with user root',
chroot: null,
userRoot: 'foo',
currentNs: 'foo/bing', currentNs: 'foo/bing',
globs: {}, globs: {
expectedAccess: false, 'foo/bing/kv/data/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: null,
fullNs: 'foo/bing',
},
}, },
].forEach((testCase) => { ].forEach((testCase) => {
test(`when ${testCase.scenario}`, function (assert) { test(`${testCase.scenario}`, async function (assert) {
const namespaceService = Service.extend({ const namespaceService = Service.extend({
path: testCase.currentNs,
userRootNamespace: testCase.userRoot, userRootNamespace: testCase.userRoot,
path: testCase.currentNs,
}); });
this.owner.register('service:namespace', namespaceService); this.owner.register('service:namespace', namespaceService);
this.service.set('chrootNamespace', testCase.chroot); this.service.setPaths({
const result = this.service.hasWildcardAccess(testCase.globs); data: {
assert.strictEqual(result, testCase.expectedAccess); glob_paths: testCase.globs,
exact_paths: {},
chroot_namespace: testCase.chroot,
},
});
const fullNamespace = this.service.fullCurrentNamespace;
assert.strictEqual(fullNamespace, testCase.expected.fullNs);
const wildcardResult = this.service.hasWildcardNsAccess(fullNamespace, testCase.globs);
assert.strictEqual(wildcardResult, testCase.expected.wildcard);
assert.strictEqual(this.service.permissionsBanner, testCase.expected.banner);
}); });
}); });
}); });