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 namespace;
get baseNs() {
get fullCurrentNamespace() {
const currentNs = this.namespace.path;
return this.chrootNamespace
? `${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
* @returns {boolean} whether the user's policy includes wildcard access to NS
*/
hasWildcardAccess(globPaths = {}) {
// First check if the wildcard path is in the globPaths object
if (!Object.keys(globPaths).includes(this.wildcardPath)) return false;
hasWildcardNsAccess(targetNs, globPaths = {}) {
const nsParts = sanitizePath(targetNs).split('/');
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
return this.namespace.path.startsWith(this.wildcardPath);
// if there is a match make sure the capabilities do not include deny
return !this.isDenied(globPaths[matchKey]);
}
// 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;
return;
}
const namespace = this.baseNs;
const namespace = this.fullCurrentNamespace;
const allowed =
// 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
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
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;
}
@@ -200,7 +213,7 @@ export default class PermissionsService extends Service {
}
pathNameWithNamespace(pathName) {
const namespace = this.baseNs;
const namespace = this.fullCurrentNamespace;
if (namespace) {
return `${sanitizePath(namespace)}/${sanitizeStart(pathName)}`;
} else {

View File

@@ -8,6 +8,7 @@ import { setupTest } from 'ember-qunit';
import Service from '@ember/service';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { PERMISSIONS_BANNER_STATES } from 'vault/services/permissions';
const PERMISSIONS_RESPONSE = {
data: {
@@ -246,58 +247,9 @@ module('Unit | Service | permissions', function (hooks) {
});
});
module('wildcardPath 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.
module('permissions banner calculates correctly', function () {
[
// First set: no chroot or user root
{
scenario: 'when root wildcard in root namespace',
chroot: null,
@@ -306,18 +258,140 @@ module('Unit | Service | permissions', function (hooks) {
globs: {
'': { 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',
chroot: 'admin/',
userRoot: '',
currentNs: 'admin/child',
currentNs: 'child',
globs: {
'admin/': { capabilities: ['read'] },
},
expected: {
wildcard: true,
banner: null,
fullNs: 'admin/child',
},
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',
chroot: null,
@@ -326,56 +400,89 @@ module('Unit | Service | permissions', function (hooks) {
globs: {
'bar/': { capabilities: ['read'] },
},
expectedAccess: true,
},
{
scenario: 'when namespace wildcard in child ns & chroot',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'foo/bar/baz',
globs: {
'foo/bar/': { capabilities: ['read'] },
expected: {
wildcard: true,
banner: null,
fullNs: 'bar/baz',
},
expectedAccess: true,
},
{
scenario: 'when namespace wildcard in different ns with chroot & user root',
chroot: 'foo/',
userRoot: 'bar',
currentNs: 'foo/bing',
globs: {
'foo/bar/': { capabilities: ['read'] },
},
expectedAccess: false,
},
{
scenario: 'when namespace wildcard in different ns without chroot',
scenario: 'when namespace wildcard in different ns',
chroot: null,
userRoot: 'bar',
currentNs: 'foo/bing',
globs: {
'bar/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: PERMISSIONS_BANNER_STATES.noAccess,
fullNs: 'foo/bing',
},
expectedAccess: false,
},
{
scenario: 'when globs is empty',
chroot: 'foo/',
scenario: 'when access granted via parent namespace in child ns',
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',
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',
globs: {},
expectedAccess: false,
globs: {
'foo/bing/kv/data/': { capabilities: ['read'] },
},
expected: {
wildcard: false,
banner: null,
fullNs: 'foo/bing',
},
},
].forEach((testCase) => {
test(`when ${testCase.scenario}`, function (assert) {
test(`${testCase.scenario}`, async function (assert) {
const namespaceService = Service.extend({
path: testCase.currentNs,
userRootNamespace: testCase.userRoot,
path: testCase.currentNs,
});
this.owner.register('service:namespace', namespaceService);
this.service.set('chrootNamespace', testCase.chroot);
const result = this.service.hasWildcardAccess(testCase.globs);
assert.strictEqual(result, testCase.expectedAccess);
this.service.setPaths({
data: {
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);
});
});
});