mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
Backport UI Fix: UI permissions banner and side bar nav gating respect Vault glob semantics (+, deny precedence) into ce/main (#9800)
* UI Fix: UI permissions banner and side bar nav gating respect Vault glob semantics (+, deny precedence) (#9522) * add in empty states when no permissions error but no list values found. * wip * wip cont. * a lot closer... I think * looking good, now to smoke test (Again) * welp revert fix to adapter that borked it. * add changelog * test coverage—a lot * fix some issues with root vs fallback show sidebar nav * address pr comments and clean up comments and left over duplicate methods in permission service * Apply suggestion from @hellobontempo Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * add resultant-acl in canary paths * remove from canary and use capability check instead inside permissionsBanner * clean up * fix merge things --------- Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> * add conditional for enterprise vs ce --------- Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com> Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Co-authored-by: Angel Garbarino <argarbarino@gmail.com>
This commit is contained in:
parent
5d05b1d023
commit
79bab3edd1
12 changed files with 1028 additions and 549 deletions
2
changelog/_9522.txt
Normal file
2
changelog/_9522.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
```release-note:bug
|
||||
ui: Fixes permissions for hiding and showing sidebar navigation items for policies that include special characters: `+`, `*`
|
||||
|
|
@ -167,6 +167,8 @@
|
|||
</Hds::Dropdown>
|
||||
</div>
|
||||
</LinkedBlock>
|
||||
{{else}}
|
||||
<EmptyState @title="No Secrets engines found" />
|
||||
{{/each}}
|
||||
|
||||
{{#if this.engineToDisable}}
|
||||
|
|
|
|||
|
|
@ -3,48 +3,21 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/* eslint-disable ember/no-observers */
|
||||
import { service } from '@ember/service';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import Controller from '@ember/controller';
|
||||
import { observer } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default Controller.extend({
|
||||
auth: service(),
|
||||
store: service(),
|
||||
media: service(),
|
||||
router: service(),
|
||||
permissions: service(),
|
||||
namespaceService: service('namespace'),
|
||||
flashMessages: service(),
|
||||
customMessages: service(),
|
||||
export default class VaultClusterController extends Controller {
|
||||
@service auth;
|
||||
@service permissions;
|
||||
@service customMessages;
|
||||
@service flashMessages;
|
||||
@service('version') vaultVersion;
|
||||
|
||||
vaultVersion: service('version'),
|
||||
console: service(),
|
||||
queryParams = [{ namespaceQueryParam: { as: 'namespace' } }];
|
||||
@tracked namespaceQueryParam = '';
|
||||
|
||||
queryParams: [
|
||||
{
|
||||
namespaceQueryParam: {
|
||||
scope: 'controller',
|
||||
as: 'namespace',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
namespaceQueryParam: '',
|
||||
|
||||
onQPChange: observer('namespaceQueryParam', function () {
|
||||
this.namespaceService.setNamespace(this.namespaceQueryParam);
|
||||
}),
|
||||
|
||||
consoleOpen: alias('console.isOpen'),
|
||||
activeCluster: alias('auth.activeCluster'),
|
||||
|
||||
permissionBanner: alias('permissions.permissionsBanner'),
|
||||
|
||||
actions: {
|
||||
toggleConsole() {
|
||||
this.toggleProperty('consoleOpen');
|
||||
},
|
||||
},
|
||||
});
|
||||
get activeCluster() {
|
||||
return this.auth.activeCluster;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,5 +92,9 @@ export default class VaultClusterAccessMethodsController extends Controller {
|
|||
}
|
||||
|
||||
// template helper
|
||||
sortMethods = (methods) => sortObjects(methods.slice(), 'path');
|
||||
sortMethods = (methods) => {
|
||||
// make sure there are methods to sort otherwise slice with throw an error
|
||||
if (!Array.isArray(methods) || methods.length === 0) return [];
|
||||
return sortObjects(methods.slice(), 'path');
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,19 +10,22 @@ import { observer } from '@ember/object';
|
|||
|
||||
export default Helper.extend({
|
||||
permissions: service(),
|
||||
namespace: service(),
|
||||
|
||||
// Recompute when either ACL OR namespace path changes
|
||||
onPermissionsChange: observer(
|
||||
'permissions.exactPaths',
|
||||
'permissions.globPaths',
|
||||
'permissions.canViewAll',
|
||||
'permissions.chrootNamespace',
|
||||
'namespace.path',
|
||||
function () {
|
||||
this.recompute();
|
||||
}
|
||||
),
|
||||
|
||||
compute([route], params) {
|
||||
const { routeParams, requireAll } = params;
|
||||
const permissions = this.permissions;
|
||||
|
||||
return permissions.hasNavPermission(route, routeParams, requireAll);
|
||||
const { routeParams, requireAll } = params || {};
|
||||
return this.permissions.hasNavPermission(route, routeParams, requireAll);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,66 @@ import { tracked } from '@glimmer/tracking';
|
|||
import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
/**
|
||||
* PermissionsService
|
||||
* ---------------------------------------------------------------------------
|
||||
* What it does
|
||||
* - Gates sidebar visibility and the “Resultant ACL” banner.
|
||||
* - Consumes one resultant-acl payload: { exact_paths, glob_paths, root, chroot_namespace }.
|
||||
* - Evaluates permissions against the fully-qualified path:
|
||||
* <chroot>/<currentNamespace>/<apiPath> (omitting empty parts).
|
||||
*
|
||||
* Policy semantics (brief)
|
||||
* - '+' = exactly one segment; '*' = wildcard; '/' = include base; '/*' = children-only.
|
||||
* - Globpaths return policy paths with * if the path includes a wildcard. Meaning,
|
||||
* sys/admin/* returns as sys/admin/, but +/sys/admin/* returns as +/sys/admin/*
|
||||
* the implication is that a plus sign make the * a non-greedy match (e.g. children only), but without a plus
|
||||
* a policy path is greedy (e.g. base + children)
|
||||
* - Most-specific (longest) match wins; any explicit 'deny' beats allow.
|
||||
* Docs: https://developer.hashicorp.com/vault/docs/concepts/policies#priority-matching
|
||||
*
|
||||
* Sidebar flow
|
||||
* - Templates → has-permissions → hasNavPermission() → hasPermission() on resolved API paths.
|
||||
*
|
||||
* Banner flow
|
||||
* - permissionsBanner getter computes effective namespace and returns:
|
||||
* • null if root, read-failed if ACL fetch failed, or if any canary path is allowed here
|
||||
* • no-ns-access otherwise
|
||||
* - “Canary paths” are a small set of UI-centric endpoints we probe in the current namespace.
|
||||
* - They build off API_PATHS, which also drives sidebar nav items.
|
||||
* - If you ever wanted to add to a canary probe list, either expand API_PATHS
|
||||
* or add to CANARY_PATHS directly.
|
||||
*
|
||||
* Chroot note
|
||||
* - When chroot_namespace is present, backend keys are already prefixed.
|
||||
* We mirror this by composing fullCurrentNamespace for all checks.
|
||||
*
|
||||
* Example resultant-acl payload:
|
||||
* {
|
||||
* "exact_paths": {
|
||||
* "sys/policies/acl": { "capabilities": ["read", "list"] },
|
||||
* "sys/policies/acl/my-policy": { "capabilities": ["read"] },
|
||||
* "sys/auth": { "capabilities": ["deny"] }
|
||||
* },
|
||||
* "glob_paths": {
|
||||
* "secret/data/finance/+/payroll": { "capabilities": ["create", "update", "read", "list"] },
|
||||
* "secret/data/engineering/*": { "capabilities": ["read", "list"] },
|
||||
* "secret/data/hr/": { "capabilities": ["read", "list"] },
|
||||
* "+/auth/*": { "capabilities": ["deny"] },
|
||||
* "": { "capabilities": ["read", "list"] } // baseline allow-all
|
||||
* },
|
||||
* "root": false,
|
||||
* "chroot_namespace": "ns1/child"
|
||||
* }
|
||||
*/
|
||||
|
||||
export const PERMISSIONS_BANNER_STATES = {
|
||||
readFailed: 'read-failed',
|
||||
noAccess: 'no-ns-access',
|
||||
};
|
||||
|
||||
export const RESULTANT_ACL_PATH = 'sys/internal/ui/resultant-acl'; // export for tests
|
||||
|
||||
const API_PATHS = {
|
||||
access: {
|
||||
methods: 'sys/auth',
|
||||
|
|
@ -71,43 +127,51 @@ const API_PATHS_TO_ROUTE_PARAMS = {
|
|||
'identity/oidc/client': { route: 'vault.cluster.access.oidc', models: [] },
|
||||
};
|
||||
|
||||
/*
|
||||
The Permissions service is used to gate top navigation and sidebar items.
|
||||
It fetches a users' policy from the resultant-acl endpoint and stores their
|
||||
allowed exact and glob paths as state. It also has methods for checking whether
|
||||
a user has permission for a given path.
|
||||
The data from the resultant-acl endpoint has the following shape:
|
||||
{
|
||||
exact_paths: {
|
||||
[key: string]: {
|
||||
capabilities: string[];
|
||||
};
|
||||
};
|
||||
glob_paths: {
|
||||
[key: string]: {
|
||||
capabilities: string[];
|
||||
};
|
||||
};
|
||||
root: boolean;
|
||||
chroot_namespace?: string;
|
||||
};
|
||||
There are a couple nuances to be aware of about this response. When a
|
||||
chroot_namespace is set, all of the paths in the response will be prefixed
|
||||
with that namespace. Additionally, this endpoint is only added to the default
|
||||
policy in the user's root namespace, so we make the call to the user's root
|
||||
namespace (the namespace where the user's auth method is mounted) no matter
|
||||
what the current namespace is.
|
||||
*/
|
||||
// Canary endpoints: quick check for “meaningful UI access” in the *current* namespace.
|
||||
// If the token has any non-deny capability on any canary here, we suppress the banner.
|
||||
// This does not try to cover all possible paths (e.g. secrets engines only such as `+/kv/data/*`),
|
||||
// by design—probing everything is infeasible. Keep the list small and UI-centric.
|
||||
//
|
||||
// IMPORTANT NOTE: A user scoped only to say a secrets engine path
|
||||
// (e.g. `+/kv/data/*`) would still trigger the banner, since iterating over all
|
||||
// possible paths is not feasible.
|
||||
//
|
||||
// This may be the situation for Namespace-tenancy setups where users are
|
||||
// confined to secrets engines in a child namespace and given no management of
|
||||
// endpoints there.
|
||||
const CANARY_PATHS = [
|
||||
...Object.values(API_PATHS.access),
|
||||
...Object.values(API_PATHS.policies),
|
||||
...Object.values(API_PATHS.tools),
|
||||
...Object.values(API_PATHS.status),
|
||||
...Object.values(API_PATHS.clients),
|
||||
...Object.values(API_PATHS.settings),
|
||||
...Object.values(API_PATHS.sync),
|
||||
...Object.values(API_PATHS.monitoring),
|
||||
];
|
||||
|
||||
export default class PermissionsService extends Service {
|
||||
@tracked exactPaths = null;
|
||||
@tracked globPaths = null;
|
||||
@tracked canViewAll = null;
|
||||
@tracked permissionsBanner = null;
|
||||
@tracked hasFallbackAccess = false;
|
||||
@tracked isRoot = false;
|
||||
@tracked _aclLoadFailed = false;
|
||||
@tracked chrootNamespace = null;
|
||||
|
||||
@service store;
|
||||
@service namespace;
|
||||
|
||||
// isAclLoaded:
|
||||
// - True if we know the caller is actual root (resp.data.root === true),
|
||||
// or if we’ve received any exact/glob paths from the resultant-acl payload.
|
||||
// - Starts false until the first ACL fetch.
|
||||
// - On fetch failure, we deliberately keep isRoot=false (since the backend
|
||||
// didn’t confirm) and set hasFallbackAccess=true instead. This way the
|
||||
// sidebar remains usable but the banner shows "read-failed".
|
||||
get isAclLoaded() {
|
||||
return this.isRoot || this.exactPaths !== null || this.globPaths !== null;
|
||||
}
|
||||
|
||||
get fullCurrentNamespace() {
|
||||
const currentNs = this.namespace.path;
|
||||
return this.chrootNamespace
|
||||
|
|
@ -115,171 +179,305 @@ export default class PermissionsService extends Service {
|
|||
: sanitizePath(currentNs);
|
||||
}
|
||||
|
||||
@task *getPaths() {
|
||||
if (this.paths) {
|
||||
return;
|
||||
// Banner logic: only show when we *know* the user has no meaningful UI access
|
||||
// in the current namespace. Precedence:
|
||||
// 1) Root token → never show a banner.
|
||||
// 2) ACL fetch failed (_aclLoadFailed) → show "read-failed" (sidebar may still
|
||||
// render via hasFallbackAccess; the banner explains missing ACL data).
|
||||
// 3) ACLs not loaded yet (!isAclLoaded) → suppress banner (avoid flicker while loading).
|
||||
// 4) Explicit UI check: if the caller can READ "sys/internal/ui/resultant-acl" in the
|
||||
// effective namespace, treat that as minimal UI access → suppress banner.
|
||||
// History: This was the original intent (CE PR #23503). We temporarily removed reliance
|
||||
// on this signal (CE PR #25256) because our old banner logic used string prefix checks
|
||||
// and the resultant-acl payload did not preserve namespace-segment wildcards (`+`), so
|
||||
// policies like `+/sys/...` or `+/+/sys/...` in child namespaces could be missed.
|
||||
// Today the backend returns `+` in `glob_paths` and this service matches against the
|
||||
// fully-qualified path using proper glob semantics, so this explicit check is reliable again.
|
||||
// 5) Heuristic canaries: if any curated UI-centric path is non-deny, suppress the banner.
|
||||
// 6) Otherwise → "no-ns-access".
|
||||
get permissionsBanner() {
|
||||
// 1) Real root never sees a banner
|
||||
if (this.isRoot) return null;
|
||||
|
||||
// 2) Fetch failed → explain
|
||||
if (this._aclLoadFailed) return PERMISSIONS_BANNER_STATES.readFailed;
|
||||
|
||||
// 3) Still loading → stay quiet
|
||||
if (!this.isAclLoaded) return null;
|
||||
|
||||
// 4) Explicit resultant-acl read → minimal UI access confirmed
|
||||
if (this.hasPermission(RESULTANT_ACL_PATH, ['read'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5) Heuristic: any canary with any non-deny capability
|
||||
const anyAllowed = CANARY_PATHS.some((p) => this.hasPermission(p));
|
||||
|
||||
// 6) Final decision
|
||||
return anyAllowed ? null : PERMISSIONS_BANNER_STATES.noAccess;
|
||||
}
|
||||
|
||||
// Load resultant-ACL used by sidebar + banner.
|
||||
// Success:
|
||||
// • Clear _aclLoadFailed, then hydrate via setPaths(resp).
|
||||
// Failure (network/403/etc):
|
||||
// • Mark _aclLoadFailed so the banner explains missing ACL data.
|
||||
// • Enable hasFallbackAccess so the sidebar remains usable (legacy behavior).
|
||||
// • Clear exact/glob to avoid using stale ACL from a prior success.
|
||||
@task *getPaths() {
|
||||
try {
|
||||
const resp = yield this.store.adapterFor('permissions').query();
|
||||
this._aclLoadFailed = false;
|
||||
this.setPaths(resp);
|
||||
return;
|
||||
} catch (err) {
|
||||
// If no policy can be found, default to showing all nav items.
|
||||
this.canViewAll = true;
|
||||
this.permissionsBanner = PERMISSIONS_BANNER_STATES.readFailed;
|
||||
this._aclLoadFailed = true;
|
||||
this.isRoot = false;
|
||||
this.hasFallbackAccess = true; // fallback so nav stays visible
|
||||
|
||||
// Avoid stale ACL from a previous successful load
|
||||
this.exactPaths = null;
|
||||
this.globPaths = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 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
|
||||
calcNsAccess() {
|
||||
if (this.canViewAll) {
|
||||
this.permissionsBanner = null;
|
||||
return;
|
||||
}
|
||||
const namespace = this.fullCurrentNamespace;
|
||||
const allowed =
|
||||
// check if the user has wildcard access to the relative root namespace
|
||||
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) && !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) && !this.isDenied(this.exactPaths[k]));
|
||||
this.permissionsBanner = allowed ? null : PERMISSIONS_BANNER_STATES.noAccess;
|
||||
}
|
||||
|
||||
// Populate tracked state from a successful resultant-ACL response.
|
||||
// • exact_paths / glob_paths → used for all permission checks
|
||||
// • root (boolean) → authoritative “real root” from backend
|
||||
// • hasFallbackAccess → always false on success (only true if ACL fetch fails)
|
||||
// • chroot_namespace → prefix applied to all checks
|
||||
setPaths(resp) {
|
||||
this.exactPaths = resp.data.exact_paths;
|
||||
this.globPaths = resp.data.glob_paths;
|
||||
this.canViewAll = resp.data.root;
|
||||
this.isRoot = resp.data.root; // true root per backend
|
||||
this.hasFallbackAccess = false; // nav gating shortcut (true root behaves like “show all”). Only set true if ACL fail.
|
||||
this.chrootNamespace = resp.data.chroot_namespace;
|
||||
this.calcNsAccess();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.exactPaths = null;
|
||||
this.globPaths = null;
|
||||
this.canViewAll = null;
|
||||
this.hasFallbackAccess = false;
|
||||
this.chrootNamespace = null;
|
||||
this.permissionsBanner = null;
|
||||
this._aclLoadFailed = false;
|
||||
}
|
||||
|
||||
// ===== Matching helpers (STRICT) ==========================================
|
||||
|
||||
// _globKeyToRegex
|
||||
// Converts a Vault glob-style policy key into a regex:
|
||||
// • Trailing "/" → include base + any children
|
||||
// • Trailing "/*" → children-only (≥1 child; base does NOT match)
|
||||
// • "+" → exactly one segment
|
||||
// • "*" → greedy segment matcher
|
||||
// • "" (empty key) → baseline allow-all, equivalent to `path "*" { … }`
|
||||
//
|
||||
// PERF NOTE: Consider caching compiled regexes if ACLs are large or called frequently.
|
||||
_globKeyToRegex(globKey) {
|
||||
// Empty key → allow-all
|
||||
if (globKey === '') {
|
||||
return /^.*$/;
|
||||
}
|
||||
|
||||
const endsWithChildrenOnly = /\/\*$/.test(globKey); // "/*" → children-only
|
||||
const endsWithIncludeBase = !endsWithChildrenOnly && /\/$/.test(globKey); // "/" → include base
|
||||
const trimmed = globKey
|
||||
.replace(/\/\*$/, '') // strip "/*"
|
||||
.replace(/\/$/, ''); // strip trailing "/"
|
||||
|
||||
const parts = trimmed.split('/').map((seg) => {
|
||||
if (seg === '+') return '[^/]+'; // exactly one segment
|
||||
if (seg === '*') return '.*'; // greedy tail matcher
|
||||
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape literal
|
||||
});
|
||||
|
||||
if (endsWithChildrenOnly) {
|
||||
// require ≥1 child segment
|
||||
return new RegExp('^' + parts.join('/') + '/.+' + '$');
|
||||
}
|
||||
|
||||
if (endsWithIncludeBase) {
|
||||
// match base OR any children
|
||||
return new RegExp('^' + parts.join('/') + '(?:/.*)?' + '$');
|
||||
}
|
||||
|
||||
// exact match only
|
||||
return new RegExp('^' + parts.join('/') + '$');
|
||||
}
|
||||
|
||||
// _matchExact
|
||||
// Example: needle = "sys/auth"
|
||||
// • "sys/auth" → matches directly
|
||||
// • "sys/auth/methods" → chosen over "sys/auth" (more specific child)
|
||||
//
|
||||
// Resolves an "exact_paths" match with Vault policy semantics:
|
||||
// • Normalize trailing "/" for both needle and keys
|
||||
// • Exact equality takes precedence
|
||||
// • Otherwise, choose the longest child key (most specific) under the base
|
||||
_matchExact(fullPath) {
|
||||
const exact = this.exactPaths;
|
||||
if (!exact) return null;
|
||||
|
||||
const needle = fullPath.replace(/\/$/, '');
|
||||
|
||||
// Build [orig, norm] pairs so we can match on norm but return orig
|
||||
const tuples = Object.keys(exact).map((k) => [k, k.replace(/\/$/, '')]);
|
||||
|
||||
// Exact equality wins outright
|
||||
const eq = tuples.find(([, norm]) => norm === needle);
|
||||
if (eq) {
|
||||
const [orig] = eq;
|
||||
return { key: orig, entry: exact[orig] };
|
||||
}
|
||||
|
||||
// Otherwise, pick the longest child (most specific) under the base
|
||||
const child = tuples
|
||||
.filter(([, norm]) => norm.startsWith(needle + '/'))
|
||||
.sort((a, b) => b[1].length - a[1].length)[0];
|
||||
|
||||
return child ? { key: child[0], entry: exact[child[0]] } : null;
|
||||
}
|
||||
|
||||
// _matchGlob
|
||||
// Example: fullPath = "sys/auth/methods"
|
||||
// • glob "*" → matches (baseline allow-all)
|
||||
// • glob "+/sys/*" → matches, less specific
|
||||
// • glob "+/sys/auth/*" → matches, more specific → chosen
|
||||
// • glob "sys/auth/methods" → exact equality handled in _matchExact
|
||||
//
|
||||
// Glob match precedence:
|
||||
// • Empty key "" is a special baseline = allow all (overridable by denies)
|
||||
// • Among regex matches, the longest key (most specific) wins
|
||||
// • Final allow/deny resolution is deferred to _decide()
|
||||
_matchGlob(fullPath) {
|
||||
const globPaths = this.globPaths;
|
||||
if (!globPaths) return null;
|
||||
|
||||
let matchKey = null;
|
||||
|
||||
// Treat empty-root ('') as baseline "allow all" if present
|
||||
if (Object.prototype.hasOwnProperty.call(globPaths, '')) {
|
||||
matchKey = '';
|
||||
}
|
||||
|
||||
for (const k of Object.keys(globPaths)) {
|
||||
if (k === '') continue; // already accounted for
|
||||
const re = this._globKeyToRegex(k);
|
||||
if (re.test(fullPath)) {
|
||||
if (matchKey === null || k.length > matchKey.length) {
|
||||
matchKey = k; // longest wins
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchKey !== null ? { key: matchKey, entry: globPaths[matchKey] } : null;
|
||||
}
|
||||
|
||||
// _decide
|
||||
// Resolution rule across matchers:
|
||||
// - If any matched entry is 'deny' → false (authoritative).
|
||||
// - Otherwise, ALL requested capabilities must be satisfied by at least one matched non-deny entry.
|
||||
// • The 'capabilities' arg comes from the caller (via hasPermission):
|
||||
// - ["list"] for identity pages
|
||||
// - ["read", "update"] if a UI flow requires those
|
||||
// - [null] means "any non-deny capability is enough" (common for sidebar/nav checks).
|
||||
_decide(fullPath, capabilities = [null]) {
|
||||
// Collect matches from BOTH exact and glob, then apply precedence:
|
||||
// 1. If ANY matching entry is an explicit deny → false.
|
||||
// 2. If no deny, allow if every requested capability is satisfied by ≥1 non-deny match.
|
||||
// 3. Otherwise → false.
|
||||
const exactMatch = this._matchExact(fullPath);
|
||||
const globMatch = this._matchGlob(fullPath);
|
||||
|
||||
// If any deny match exists (exact or glob), short-circuit to false
|
||||
if ((exactMatch && this.isDenied(exactMatch.entry)) || (globMatch && this.isDenied(globMatch.entry))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now check capability satisfaction across the best exact/glob
|
||||
const candidates = [exactMatch?.entry, globMatch?.entry].filter(Boolean);
|
||||
|
||||
return capabilities.every((cap) => {
|
||||
// Null means “any non-deny capability”
|
||||
if (cap === null) return candidates.some((e) => !this.isDenied(e));
|
||||
return candidates.some((e) => !this.isDenied(e) && this.hasCapability(e, cap));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Public checks =======================================================
|
||||
|
||||
// Entry point used by the has-permissions helper (and tests).
|
||||
// If `routeParams` is an array and `requireAll` is true, *all* params must
|
||||
// be permitted; otherwise any one is enough.
|
||||
// For identity entities/groups pages we require 'list'; other pages only need
|
||||
// “not denied”, so we pass `[null]` to mean “any non-deny capability suffices”.
|
||||
// Determine if a nav item should be shown.
|
||||
// - Resolves one or more API paths from API_PATHS[navItem] (optionally using routeParams).
|
||||
// - If routeParams is an array:
|
||||
// • requireAll=true → every param must be permitted (Array.every)
|
||||
// • requireAll=false → at least one must be permitted (Array.some)
|
||||
// - Capability policy:
|
||||
// • 'entities' / 'groups' require ["list"]
|
||||
// • everything else uses [null] → “any non-deny capability is enough”
|
||||
// - Delegates to hasPermission(path, capabilities) per resolved path.
|
||||
hasNavPermission(navItem, routeParams, requireAll) {
|
||||
if (routeParams) {
|
||||
// check that the user has permission to access all (requireAll = true) or any of the routes when array is passed
|
||||
// useful for hiding nav headings when user does not have access to any of the links
|
||||
const params = Array.isArray(routeParams) ? routeParams : [routeParams];
|
||||
const evalMethod = !Array.isArray(routeParams) || requireAll ? 'every' : 'some';
|
||||
return params[evalMethod]((param) => {
|
||||
// viewing the entity and groups pages require the list capability, while the others require the default, which is anything other than deny
|
||||
const capability = param === 'entities' || param === 'groups' ? ['list'] : [null];
|
||||
return this.hasPermission(API_PATHS[navItem][param], capability);
|
||||
});
|
||||
}
|
||||
// No params → show if any canonical path for the nav item is permitted.
|
||||
return Object.values(API_PATHS[navItem]).some((path) => this.hasPermission(path));
|
||||
}
|
||||
|
||||
// Compute route params (models) for the *first* accessible path of a nav item.
|
||||
// - Uses hasPermission(...) to pick the first allowed path in API_PATHS[navItem].
|
||||
// - For 'policies' and 'tools', the model is the last path segment (e.g., 'acl', 'hash').
|
||||
// - Otherwise, returns the pre-mapped route + models from API_PATHS_TO_ROUTE_PARAMS.
|
||||
navPathParams(navItem) {
|
||||
const path = Object.values(API_PATHS[navItem]).find((path) => this.hasPermission(path));
|
||||
if (['policies', 'tools'].includes(navItem)) {
|
||||
return { models: [path.split('/').lastObject] };
|
||||
const last = path.split('/').pop();
|
||||
return { models: [last] };
|
||||
}
|
||||
|
||||
return API_PATHS_TO_ROUTE_PARAMS[path];
|
||||
}
|
||||
|
||||
// Build a fully-qualified API path scoped to the effective namespace.
|
||||
// - Prefixes <chroot>/<currentNamespace> when present.
|
||||
// - sanitizePath/sanitizeStart prevent accidental double slashes and leading '/' issues.
|
||||
// Examples:
|
||||
// ns="team", chroot=null, path="sys/auth" → "team/sys/auth"
|
||||
// ns="team/child", chroot="admin", path="/sys/auth/" → "admin/team/child/sys/auth/"
|
||||
pathNameWithNamespace(pathName) {
|
||||
const namespace = this.fullCurrentNamespace;
|
||||
if (namespace) {
|
||||
return `${sanitizePath(namespace)}/${sanitizeStart(pathName)}`;
|
||||
} else {
|
||||
return pathName;
|
||||
}
|
||||
const ns = this.fullCurrentNamespace;
|
||||
return ns ? `${sanitizePath(ns)}/${sanitizeStart(pathName)}` : pathName;
|
||||
}
|
||||
|
||||
// Core permission check used by both sidebar gating and banner canary probes.
|
||||
// - Root short-circuit: real root (isRoot) → true.
|
||||
// - Otherwise, compose the fully-qualified path and delegate to _decide(full, capabilities).
|
||||
// - 'capabilities' is the caller’s requirement:
|
||||
// • [null] → “any non-deny capability is enough” (most nav checks)
|
||||
// • ['list'] for identity entities/groups
|
||||
// • or a concrete set like ['read','update'] for specific flows
|
||||
hasPermission(pathName, capabilities = [null]) {
|
||||
if (this.canViewAll) {
|
||||
return true;
|
||||
}
|
||||
const path = this.pathNameWithNamespace(pathName);
|
||||
|
||||
return capabilities.every(
|
||||
(capability) =>
|
||||
this.hasMatchingExactPath(path, capability) || this.hasMatchingGlobPath(path, capability)
|
||||
);
|
||||
if (this.isRoot) return true;
|
||||
const full = this.pathNameWithNamespace(pathName);
|
||||
return this._decide(full, capabilities);
|
||||
}
|
||||
|
||||
hasMatchingExactPath(pathName, capability) {
|
||||
const exactPaths = this.exactPaths;
|
||||
if (exactPaths) {
|
||||
const prefix = Object.keys(exactPaths).find((path) => path.startsWith(pathName));
|
||||
const hasMatchingPath = prefix && !this.isDenied(exactPaths[prefix]);
|
||||
|
||||
if (prefix && capability) {
|
||||
return this.hasCapability(exactPaths[prefix], capability) && hasMatchingPath;
|
||||
}
|
||||
|
||||
return hasMatchingPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
hasMatchingGlobPath(pathName, capability) {
|
||||
const globPaths = this.globPaths;
|
||||
if (globPaths) {
|
||||
const matchingPath = Object.keys(globPaths).find((k) => {
|
||||
return pathName.includes(k) || pathName.includes(k.replace(/\/$/, ''));
|
||||
});
|
||||
const hasMatchingPath =
|
||||
(matchingPath && !this.isDenied(globPaths[matchingPath])) ||
|
||||
Object.prototype.hasOwnProperty.call(globPaths, '');
|
||||
|
||||
if (matchingPath && capability) {
|
||||
return this.hasCapability(globPaths[matchingPath], capability) && hasMatchingPath;
|
||||
}
|
||||
|
||||
return hasMatchingPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ===== capability helpers ==================================================
|
||||
|
||||
// If a specific capability is requested, ensure it’s present.
|
||||
// For the “any non-deny” case we pass `[null]` and gate on `isDenied(...)` only.
|
||||
hasCapability(path, capability) {
|
||||
return path.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
// A deny anywhere is authoritative.
|
||||
isDenied(path) {
|
||||
return path.capabilities.includes('deny');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,9 +81,12 @@
|
|||
|
||||
{{#if this.auth.isActiveSession}}
|
||||
<TokenExpireWarning @expirationDate={{this.auth.tokenExpirationDate}} @allowingExpiration={{this.auth.allowExpiration}}>
|
||||
{{#if this.permissionBanner}}
|
||||
{{#if this.permissions.permissionsBanner}}
|
||||
<div class="has-top-margin-m">
|
||||
<ResultantAclBanner @isEnterprise={{this.activeCluster.version.isEnterprise}} @failType={{this.permissionBanner}} />
|
||||
<ResultantAclBanner
|
||||
@isEnterprise={{this.activeCluster.version.isEnterprise}}
|
||||
@failType={{this.permissions.permissionsBanner}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{outlet}}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@
|
|||
</div>
|
||||
</div>
|
||||
</LinkedBlock>
|
||||
{{else}}
|
||||
<EmptyState @title="No Authentication Methods found" />
|
||||
{{/each}}
|
||||
|
||||
{{#if this.methodToDisable}}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ module('Acceptance | cluster', function (hooks) {
|
|||
assert.dom('[data-test-sidebar-nav-link="Policies"]').doesNotExist();
|
||||
});
|
||||
|
||||
test('it hides mfa setup if user has not entityId (ex: is a root user)', async function (assert) {
|
||||
test('it hides mfa setup if user does not have entityId (ex: is a root user)', async function (assert) {
|
||||
const username = 'end-user';
|
||||
const password = 'mypassword';
|
||||
const path = `cluster-userpass-${uuidv4()}`;
|
||||
|
|
@ -76,20 +76,35 @@ module('Acceptance | cluster', function (hooks) {
|
|||
});
|
||||
|
||||
test('shows error banner if resultant-acl check fails', async function (assert) {
|
||||
const version = this.owner.lookup('service:version');
|
||||
const login_only = `
|
||||
path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
},
|
||||
`;
|
||||
// note: the default policy is attached to a user unless you add the no_default_policy=true flag
|
||||
// you can confirm this by running `vault token lookup` on the generated token
|
||||
const noDefaultPolicyUser = await runCmd([
|
||||
`write sys/policies/acl/login-only policy=${btoa(login_only)}`,
|
||||
`write -field=client_token auth/token/create no_default_policy=true policies="login-only"`,
|
||||
]);
|
||||
|
||||
assert.dom('[data-test-resultant-acl-banner]').doesNotExist('Resultant ACL banner does not show as root');
|
||||
assert
|
||||
.dom('[data-test-resultant-acl-banner]')
|
||||
.doesNotExist('Resultant ACL banner does not show as root user with access to everything');
|
||||
|
||||
await logout();
|
||||
assert.dom('[data-test-resultant-acl-banner]').doesNotExist('Does not show on login page');
|
||||
assert
|
||||
.dom('[data-test-resultant-acl-banner]')
|
||||
.doesNotExist('Resultant ACL banner does not show on login page');
|
||||
|
||||
await login(noDefaultPolicyUser);
|
||||
assert.dom('[data-test-resultant-acl-banner]').includesText('Resultant ACL check failed');
|
||||
const expectedText = version.isEnterprise
|
||||
? "Resultant ACL check failed Links might be shown that you don't have access to. Contact your administrator to update your policy. Log into root namespace"
|
||||
: "Resultant ACL check failed Links might be shown that you don't have access to. Contact your administrator to update your policy.";
|
||||
|
||||
assert
|
||||
.dom('[data-test-resultant-acl-banner]')
|
||||
.includesText(expectedText, 'Resultant ACL banner shows appropriate message for OSS/Enterprise');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,62 +6,122 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, settled } from '@ember/test-helpers';
|
||||
import { run } from '@ember/runloop';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const Permissions = Service.extend({
|
||||
globPaths: null,
|
||||
hasNavPermission() {
|
||||
return this.globPaths ? true : false;
|
||||
},
|
||||
});
|
||||
class PermissionsService extends Service {
|
||||
@tracked globPaths = null;
|
||||
@tracked exactPaths = null;
|
||||
@tracked canViewAll = false;
|
||||
@tracked chrootNamespace = null;
|
||||
|
||||
hasNavPermission(...args) {
|
||||
const [route, routeParams, requireAll] = args;
|
||||
void (typeof route === 'string');
|
||||
void (routeParams === undefined || Array.isArray(routeParams));
|
||||
void (requireAll === undefined || typeof requireAll === 'boolean');
|
||||
|
||||
if (this.canViewAll) return true;
|
||||
if (this.globPaths || this.exactPaths) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class NamespaceService extends Service {
|
||||
@tracked path = '';
|
||||
}
|
||||
|
||||
module('Integration | Helper | has-permission', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:permissions', Permissions);
|
||||
this.owner.register('service:permissions', PermissionsService);
|
||||
this.owner.register('service:namespace', NamespaceService);
|
||||
this.permissions = this.owner.lookup('service:permissions');
|
||||
this.namespace = this.owner.lookup('service:namespace');
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
test('it recomputes when globPaths change', async function (assert) {
|
||||
await render(hbs`{{#if (has-permission)}}Yes{{else}}No{{/if}}`);
|
||||
|
||||
assert.dom(this.element).hasText('No');
|
||||
await run(() => {
|
||||
this.permissions.set('globPaths', { 'test/': { capabilities: ['update'] } });
|
||||
});
|
||||
this.permissions.globPaths = { 'test/': { capabilities: ['update'] } };
|
||||
await settled();
|
||||
assert.dom(this.element).hasText('Yes', 'the helper re-computes when globPaths changes');
|
||||
});
|
||||
|
||||
test('it recomputes when exactPaths changes', async function (assert) {
|
||||
await render(hbs`{{#if (has-permission)}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No');
|
||||
this.permissions.exactPaths = { 'test/': { capabilities: ['update'] } };
|
||||
await settled();
|
||||
assert.dom(this.element).hasText('Yes', 'the helper re-computes when exactPaths changes');
|
||||
});
|
||||
|
||||
test('it recomputes when canViewAll changes', async function (assert) {
|
||||
await render(hbs`{{#if (has-permission)}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No');
|
||||
this.permissions.canViewAll = true;
|
||||
await settled();
|
||||
assert.dom(this.element).hasText('Yes', 'the helper re-computes when canViewAll changes');
|
||||
});
|
||||
|
||||
test('it recomputes when chrootNamespace changes', async function (assert) {
|
||||
await render(hbs`{{#if (has-permission)}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No');
|
||||
this.permissions.chrootNamespace = 'admin';
|
||||
this.permissions.globPaths = { 'test/': { capabilities: ['update'] } };
|
||||
await settled();
|
||||
assert.dom(this.element).hasText('Yes', 'the helper re-computes when chrootNamespace changes');
|
||||
});
|
||||
|
||||
test('it recomputes when namespace.path changes', async function (assert) {
|
||||
await render(hbs`{{#if (has-permission)}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No');
|
||||
this.namespace.path = 'new-ns';
|
||||
this.permissions.globPaths = { 'test/': { capabilities: ['update'] } };
|
||||
await settled();
|
||||
assert.dom(this.element).hasText('Yes', 'the helper re-computes when namespace.path changes');
|
||||
});
|
||||
|
||||
test('it should pass args from helper to service method', async function (assert) {
|
||||
const stub = sinon.stub(this.permissions, 'hasNavPermission').returns(true);
|
||||
this.permissions.set('exactPaths', {
|
||||
'sys/auth': {
|
||||
capabilities: ['read'],
|
||||
},
|
||||
'identity/mfa/method': {
|
||||
capabilities: ['read'],
|
||||
},
|
||||
});
|
||||
|
||||
// seed some ACL so the helper would logically return true
|
||||
this.permissions.exactPaths = {
|
||||
'sys/auth': { capabilities: ['read'] },
|
||||
'identity/mfa/method': { capabilities: ['read'] },
|
||||
};
|
||||
|
||||
// strict-mode: define on context and use `this.` in template
|
||||
this.routeParams = ['methods', 'mfa'];
|
||||
|
||||
await render(hbs`
|
||||
{{#if (has-permission "access" routeParams=this.routeParams requireAll=true)}}
|
||||
Yes
|
||||
{{else}}
|
||||
No
|
||||
{{/if}}
|
||||
`);
|
||||
{{if (has-permission "access" routeParams=this.routeParams requireAll=true) "Yes" "No"}}
|
||||
`);
|
||||
|
||||
// use deep/loose match for the array arg
|
||||
assert.true(
|
||||
stub.withArgs('access', this.routeParams, true).calledOnce,
|
||||
stub.calledWithMatch('access', ['methods', 'mfa'], true),
|
||||
'Args are passed from helper to service'
|
||||
);
|
||||
|
||||
assert.dom(this.element).hasText('Yes', 'Helper returns value from service method');
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('returns false for missing route', async function (assert) {
|
||||
const stub = sinon.stub(this.permissions, 'hasNavPermission').returns(false);
|
||||
await render(hbs`{{#if (has-permission "missing")}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No', 'Helper returns false for missing route');
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('returns false for undefined params', async function (assert) {
|
||||
const stub = sinon.stub(this.permissions, 'hasNavPermission').returns(false);
|
||||
await render(hbs`{{#if (has-permission "access" routeParams=undefined)}}Yes{{else}}No{{/if}}`);
|
||||
assert.dom(this.element).hasText('No', 'Helper returns false for undefined params');
|
||||
stub.restore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { RESULTANT_ACL_PATH } from 'vault/services/permissions';
|
||||
|
||||
module('Unit | Adapter | permissions', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
|
@ -21,7 +22,7 @@ module('Unit | Adapter | permissions', function (hooks) {
|
|||
auth.set('tokens', ['vault-_root_☃1']);
|
||||
auth.setTokenData('vault-_root_☃1', { userRootNamespace: 'admin/bar', backend: { mountPath: 'token' } });
|
||||
|
||||
this.server.get('/sys/internal/ui/resultant-acl', (schema, request) => {
|
||||
this.server.get(`${RESULTANT_ACL_PATH}`, (schema, request) => {
|
||||
assert.strictEqual(
|
||||
request.requestHeaders['X-Vault-Namespace'],
|
||||
'admin/bar',
|
||||
|
|
@ -46,7 +47,7 @@ module('Unit | Adapter | permissions', function (hooks) {
|
|||
auth.set('tokens', ['vault-_root_☃1']);
|
||||
auth.setTokenData('vault-_root_☃1', { userRootNamespace: '', backend: { mountPath: 'token' } });
|
||||
|
||||
this.server.get('/sys/internal/ui/resultant-acl', (schema, request) => {
|
||||
this.server.get(`${RESULTANT_ACL_PATH}`, (schema, request) => {
|
||||
assert.false(
|
||||
Object.keys(request.requestHeaders).includes('X-Vault-Namespace'),
|
||||
'request is called without namespace'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue