diff --git a/changelog/_9522.txt b/changelog/_9522.txt new file mode 100644 index 0000000000..6a75d786c9 --- /dev/null +++ b/changelog/_9522.txt @@ -0,0 +1,2 @@ +```release-note:bug +ui: Fixes permissions for hiding and showing sidebar navigation items for policies that include special characters: `+`, `*` \ No newline at end of file diff --git a/ui/app/components/secret-engine/list.hbs b/ui/app/components/secret-engine/list.hbs index accb408798..06cce7506a 100644 --- a/ui/app/components/secret-engine/list.hbs +++ b/ui/app/components/secret-engine/list.hbs @@ -167,6 +167,8 @@ +{{else}} + {{/each}} {{#if this.engineToDisable}} diff --git a/ui/app/controllers/vault/cluster.js b/ui/app/controllers/vault/cluster.js index 4733e4ae0e..33605bbfdd 100644 --- a/ui/app/controllers/vault/cluster.js +++ b/ui/app/controllers/vault/cluster.js @@ -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; + } +} diff --git a/ui/app/controllers/vault/cluster/access/methods.js b/ui/app/controllers/vault/cluster/access/methods.js index 43cc99e378..596da0cb83 100644 --- a/ui/app/controllers/vault/cluster/access/methods.js +++ b/ui/app/controllers/vault/cluster/access/methods.js @@ -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'); + }; } diff --git a/ui/app/helpers/has-permission.js b/ui/app/helpers/has-permission.js index 2a64329bac..ac9d77b2d2 100644 --- a/ui/app/helpers/has-permission.js +++ b/ui/app/helpers/has-permission.js @@ -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); }, }); diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 95efca5269..616ee7a8fb 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -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: + * // (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 / 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'); } diff --git a/ui/app/templates/vault/cluster.hbs b/ui/app/templates/vault/cluster.hbs index 3a67880e98..f4200f4fd7 100644 --- a/ui/app/templates/vault/cluster.hbs +++ b/ui/app/templates/vault/cluster.hbs @@ -81,9 +81,12 @@ {{#if this.auth.isActiveSession}} - {{#if this.permissionBanner}} + {{#if this.permissions.permissionsBanner}}
- +
{{/if}} {{outlet}} diff --git a/ui/app/templates/vault/cluster/access/methods.hbs b/ui/app/templates/vault/cluster/access/methods.hbs index 3c2f1a37e9..ef3bbcf3a1 100644 --- a/ui/app/templates/vault/cluster/access/methods.hbs +++ b/ui/app/templates/vault/cluster/access/methods.hbs @@ -115,6 +115,8 @@ +{{else}} + {{/each}} {{#if this.methodToDisable}} diff --git a/ui/tests/acceptance/cluster-test.js b/ui/tests/acceptance/cluster-test.js index 6c56abd1cd..776d0227c8 100644 --- a/ui/tests/acceptance/cluster-test.js +++ b/ui/tests/acceptance/cluster-test.js @@ -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'); }); }); diff --git a/ui/tests/integration/helpers/has-permission-test.js b/ui/tests/integration/helpers/has-permission-test.js index 6fef454191..8f1047f351 100644 --- a/ui/tests/integration/helpers/has-permission-test.js +++ b/ui/tests/integration/helpers/has-permission-test.js @@ -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(); + }); }); diff --git a/ui/tests/unit/adapters/permissions-test.js b/ui/tests/unit/adapters/permissions-test.js index 56702f8f9a..77b0060b0a 100644 --- a/ui/tests/unit/adapters/permissions-test.js +++ b/ui/tests/unit/adapters/permissions-test.js @@ -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' diff --git a/ui/tests/unit/services/permissions-test.js b/ui/tests/unit/services/permissions-test.js index a3eab058cb..91f1280626 100644 --- a/ui/tests/unit/services/permissions-test.js +++ b/ui/tests/unit/services/permissions-test.js @@ -8,237 +8,276 @@ 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'; +import { PERMISSIONS_BANNER_STATES, RESULTANT_ACL_PATH } from 'vault/services/permissions'; const PERMISSIONS_RESPONSE = { data: { exact_paths: { - foo: { - capabilities: ['read'], - }, - 'bar/bee': { - capabilities: ['create', 'list'], - }, - boo: { - capabilities: ['deny'], - }, + foo: { capabilities: ['read'] }, + 'bar/bee': { capabilities: ['create', 'list'] }, + boo: { capabilities: ['deny'] }, }, glob_paths: { - 'baz/biz': { - capabilities: ['read'], - }, - 'ends/in/slash/': { - capabilities: ['list'], - }, + 'baz/biz': { capabilities: ['read'] }, + 'ends/in/slash/': { capabilities: ['list'] }, }, }, }; +// Small helper to DRY namespace registration across modules +function registerNs(owner, path) { + const Ns = Service.extend({ path }); + owner.register('service:namespace', Ns); +} + +// Note: Policy matching follows Vault’s priority rules +// (see: https://developer.hashicorp.com/vault/docs/concepts/policies#priority-matching). +// In short: +// - '+' = exactly one segment, '*' = wildcard, '/' = include base, '/*' = children-only. +// - Most-specific (longest) match wins; deny beats allow. +// - Exact and glob matches both apply; any deny blocks access. +// - Special: '' key means allow-all (`path "*" { ... }`), but can still be overridden by a more specific deny. +// - Root tokens short-circuit to allow; banners use canary-path heuristics. + module('Unit | Service | permissions', function (hooks) { setupTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - this.server.get('/sys/internal/ui/resultant-acl', () => { - return PERMISSIONS_RESPONSE; - }); + this.server.get(`/${RESULTANT_ACL_PATH}`, () => PERMISSIONS_RESPONSE); this.service = this.owner.lookup('service:permissions'); }); + // ─────────────────────────────────────────────────────────────────────────── + // Basics + // ─────────────────────────────────────────────────────────────────────────── test('sets paths properly', async function (assert) { await this.service.getPaths.perform(); - assert.deepEqual(this.service.get('exactPaths'), PERMISSIONS_RESPONSE.data.exact_paths); - assert.deepEqual(this.service.get('globPaths'), PERMISSIONS_RESPONSE.data.glob_paths); + assert.deepEqual(this.service.exactPaths, PERMISSIONS_RESPONSE.data.exact_paths); + assert.deepEqual(this.service.globPaths, PERMISSIONS_RESPONSE.data.glob_paths); }); test('sets the root token', function (assert) { this.service.setPaths({ data: { root: true } }); - assert.true(this.service.canViewAll); + assert.true(this.service.isRoot, 'isRoot is true with root token'); }); test('defaults to show all items when policy cannot be found', async function (assert) { - this.server.get('/sys/internal/ui/resultant-acl', () => overrideResponse(403)); + this.server.get(`${RESULTANT_ACL_PATH}`, () => overrideResponse(403)); await this.service.getPaths.perform(); - assert.true(this.service.canViewAll); + assert.true(this.service.hasFallbackAccess, 'hasFallbackAccess is true when policy cannot be found'); }); + // ─────────────────────────────────────────────────────────────────────────── + // navPathParams + // ─────────────────────────────────────────────────────────────────────────── test('returns the first allowed nav route for policies', function (assert) { const policyPaths = { - 'sys/policies/acl': { - capabilities: ['deny'], - }, - 'sys/policies/rgp': { - capabilities: ['read'], - }, + 'sys/policies/acl': { capabilities: ['deny'] }, + 'sys/policies/rgp': { capabilities: ['read'] }, }; - this.service.set('exactPaths', policyPaths); - assert.strictEqual(this.service.navPathParams('policies').models[0], 'rgp'); + this.service.setProperties({ exactPaths: policyPaths }); + assert.strictEqual( + this.service.navPathParams('policies').models[0], + 'rgp', + 'first allowed route is returned' + ); }); test('returns the first allowed nav route for access', function (assert) { const accessPaths = { - 'sys/auth': { - capabilities: ['deny'], - }, - 'identity/entity/id': { - capabilities: ['read'], - }, + 'sys/auth': { capabilities: ['deny'] }, + 'identity/entity/id': { capabilities: ['read'] }, }; const expected = { route: 'vault.cluster.access.identity', models: ['entities'] }; - this.service.set('exactPaths', accessPaths); + this.service.setProperties({ exactPaths: accessPaths }); assert.deepEqual(this.service.navPathParams('access'), expected); }); + test('navPathParams uses canonical first route when a glob allows all', function (assert) { + this.service.setProperties({ globPaths: { 'sys/policies/*': { capabilities: ['read'] } } }); + const result = this.service.navPathParams('policies'); + assert.deepEqual( + result, + { models: ['acl'] }, + 'picks first in canonical order (acl → rgp → egp) when all allowed' + ); + }); + + test('most-specific exact key wins (length-descending)', function (assert) { + this.service.setProperties({ + exactPaths: { + 'sys/auth': { capabilities: ['deny'] }, + 'sys/auth/methods': { capabilities: ['read'] }, + }, + }); + // Querying base "sys/auth" should still be allowed if a deeper exact key is intended to satisfy children, + // BUT our matcher selects the most specific only when the base is equal or a parent. For "sys/auth/methods" + // we expect the deeper allow to win; for the base itself we respect the base entry. + assert.true(this.service.hasPermission('sys/auth/methods'), 'deeper exact allow applies'); + assert.false(this.service.hasPermission('sys/auth'), 'base respects base deny'); + }); + 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')); + this.service.setProperties({ exactPaths: PERMISSIONS_RESPONSE.data.exact_paths }); + assert.true(this.service.hasPermission('foo'), 'policy includes access to foo exact path'); }); - 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("returns true if a path's base is included in the policy exact paths", function (assert) { + this.service.setProperties({ exactPaths: PERMISSIONS_RESPONSE.data.exact_paths }); + assert.true(this.service.hasPermission('bar'), 'base "bar" satisfied by "bar/bee" key'); }); - 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')); + // An empty string key here represents a policy of `path "*" { ... }` + // (wildcard for all paths). While it can be argued this doesn't guarantee meaningful access to canary endpoints, + // we are interpreting it to mean "allow all." + // This is consistent with the behavior of the root token, which also suppresses the banner. + test('empty-root glob key ("") (e.g. sys "*") interrupted as allow all', function (assert) { + this.service.setProperties({ globPaths: { '': { capabilities: ['read'] } } }); + assert.true(this.service.hasPermission('sys/auth'), 'empty-root allows all'); }); - 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('most-specific deny overrides empty-root allow', function (assert) { + // "" = allow-all, but a longer, more specific deny should win + this.service.setProperties({ + globPaths: { + '': { capabilities: ['read'] }, + 'sys/auth/*': { capabilities: ['deny'] }, + }, + }); + assert.false( + this.service.hasPermission('sys/auth/methods'), + 'specific deny on sys/auth/* wins over allow-all' + ); + assert.true( + this.service.hasPermission('sys/policies/acl'), + 'non-matching path remains allowed via empty-root allow' + ); }); - 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('returns false if the matched exact path includes deny capability', function (assert) { + this.service.setProperties({ exactPaths: { boo: { capabilities: ['deny'] } } }); + assert.false(this.service.hasPermission('boo'), 'deny capability blocks access'); }); - 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); + test('matches whether or not path ends with slash when glob ends with slash', function (assert) { + this.service.setProperties({ globPaths: { 'ends/in/slash/': { capabilities: ['list'] } } }); 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) { + test('returns false if a policy does not include 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); + this.service.setProperties({ isRoot: 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); + + test('returns true if policy has all requested capabilities on a path', function (assert) { + this.service.setProperties({ + exactPaths: PERMISSIONS_RESPONSE.data.exact_paths, + 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('returns false if policy lacks any requested capability on a path', function (assert) { + this.service.setProperties({ + exactPaths: PERMISSIONS_RESPONSE.data.exact_paths, + globPaths: PERMISSIONS_RESPONSE.data.glob_paths, + }); + assert.false(this.service.hasPermission('bar/bee', ['create', 'delete']), 'delete missing'); + assert.false(this.service.hasPermission('foo', ['create']), 'create missing'); + }); + + test('returns false when an exact path matches but the requested capability is missing', function (assert) { + this.service.setProperties({ exactPaths: { 'bar/bee': { capabilities: ['list'] } } }); + assert.false(this.service.hasPermission('bar/bee', ['update']), 'update not granted'); + assert.false( + this.service.hasPermission('bar/bee', ['list', 'create']), + 'require-all semantics: create not granted' + ); }); }); 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'], - }, + 'sys/auth': { capabilities: ['deny'] }, + 'identity/group/id': { capabilities: ['list', 'read'] }, }; - this.service.set('exactPaths', accessPaths); + this.service.setProperties({ exactPaths: accessPaths }); assert.true(this.service.hasNavPermission('access', 'groups')); }); - test('returns false if a policy does not include the required capabilities for at least one path', function (assert) { + test('returns false if a policy lacks the required capabilities for the path', function (assert) { const accessPaths = { - 'sys/auth': { - capabilities: ['deny'], - }, - 'identity/group/id': { - capabilities: ['read'], - }, + 'sys/auth': { capabilities: ['deny'] }, + 'identity/group/id': { capabilities: ['read'] }, }; - this.service.set('exactPaths', accessPaths); + this.service.setProperties({ exactPaths: accessPaths }); assert.false(this.service.hasNavPermission('access', 'groups')); }); - test('should handle routeParams as array', function (assert) { + test('handles routeParams as array with requireAll semantics', function (assert) { const getPaths = (override) => ({ - 'sys/auth': { - capabilities: [override || 'read'], - }, - 'identity/mfa/method': { - capabilities: [override || 'read'], - }, - 'identity/oidc/client': { - capabilities: [override || 'deny'], - }, + 'sys/auth': { capabilities: [override || 'read'] }, + 'identity/mfa/method': { capabilities: [override || 'read'] }, + 'identity/oidc/client': { capabilities: [override || 'deny'] }, }); - this.service.set('exactPaths', getPaths()); + this.service.setProperties({ exactPaths: getPaths() }); assert.true( this.service.hasNavPermission('access', ['methods', 'mfa', 'oidc']), - 'hasNavPermission returns true for array of route params when any route is permitted' + 'true 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' + 'false when any route is not permitted and requireAll is passed' ); - this.service.set('exactPaths', getPaths('read')); + this.service.setProperties({ 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' + 'true when all routes are permitted and requireAll is passed' ); - this.service.set('exactPaths', getPaths('deny')); + this.service.setProperties({ 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' + 'false 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' + 'false when no routes are permitted and requireAll is passed' ); }); }); - module('pathWithNamespace', function () { + module('pathNameWithNamespace', 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); + registerNs(this.owner, 'marketing'); 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/'); + registerNs(this.owner, 'marketing'); + this.service.setProperties({ 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'); + this.service.setProperties({ 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/'); + registerNs(this.owner, '/marketing'); + this.service.setProperties({ chrootNamespace: '/admin/' }); assert.strictEqual(this.service.pathNameWithNamespace('/sys/auth'), 'admin/marketing/sys/auth'); assert.strictEqual( this.service.pathNameWithNamespace('/sys/policies/'), @@ -247,243 +286,420 @@ module('Unit | Service | permissions', function (hooks) { }); }); - module('permissions banner calculates correctly', function () { + // ─────────────────────────────────────────────────────────────────────────── + // Glob matching semantics (+ and *) + // ─────────────────────────────────────────────────────────────────────────── + module('glob matching semantics', function () { + test('+ matches exactly one segment; trailing /* means any child (≥1)', function (assert) { + this.service.setProperties({ globPaths: { 'secret/+/*': { capabilities: ['read'] } } }); + + assert.true( + this.service.hasPermission('secret/team-a/foo'), + 'one segment after secret, then a child → match' + ); + assert.true(this.service.hasPermission('secret/team-a/foo/bar'), 'multiple children via /* → match'); + + // children-only: base MUST have at least one child + assert.false( + this.service.hasPermission('secret/foo'), + 'zero segments after + (needs child due to /*) → no match' + ); + assert.false(this.service.hasPermission('secret/team-a'), 'base-only (no child) with /* → no match'); + + // IMPORTANT CHANGE: with + matching "team-a", the /* tail matches any depth of children + assert.true( + this.service.hasPermission('secret/team-a/eng/foo/bar/baz'), + '/* tail matches one-or-more segments; deep children → match' + ); + }); + + test('* greedy tail works', function (assert) { + this.service.set('globPaths', { 'sys/*': { capabilities: ['list'] } }); + assert.true(this.service.hasPermission('sys/policies/acl')); + assert.true(this.service.hasPermission('sys/auth/methods')); + }); + + test('include-base semantics for keys ending with "/" (base OR children)', function (assert) { + this.service.set('globPaths', { 'sys/': { capabilities: ['list'] } }); + assert.true(this.service.hasPermission('sys'), 'base matches'); + assert.true(this.service.hasPermission('sys/'), 'normalized base matches'); + assert.true(this.service.hasPermission('sys/auth'), 'child matches'); + }); + + test('most-specific wins across multiple matching globs', function (assert) { + this.service.set('globPaths', { + 'sys/*': { capabilities: ['deny'] }, // broad deny + 'sys/auth/*': { capabilities: ['read'] }, // specific allow + }); + assert.true(this.service.hasPermission('sys/auth/methods'), 'specific allow overrides broader deny'); + assert.false(this.service.hasPermission('sys/policies/acl'), 'non-auth still denied by broad rule'); + }); + + test('most-specific deny takes precedence when the most specific entry is deny', function (assert) { + this.service.set('globPaths', { + 'sys/*': { capabilities: ['list'] }, // broad allow + 'sys/auth/*': { capabilities: ['deny'] }, // specific deny + }); + assert.false(this.service.hasPermission('sys/auth/methods'), 'specific deny blocks'); + assert.true(this.service.hasPermission('sys/policies/acl'), 'non-auth remains allowed by broad rule'); + }); + + test('capability filtering applies to glob matches', function (assert) { + this.service.set('globPaths', { 'sys/tools/*': { capabilities: ['update'] } }); + assert.true(this.service.hasPermission('sys/tools/hash', ['update'])); + assert.false(this.service.hasPermission('sys/tools/hash', ['read'])); + }); + + test('deny precedence: exact deny beats glob allow', function (assert) { + // /ns/sys/auth matches BOTH exact (deny) and glob (allow) → expect false + this.service.setProperties({ + exactPaths: { 'sys/auth': { capabilities: ['deny'] } }, + globPaths: { 'sys/*': { capabilities: ['read'] } }, + }); + assert.false(this.service.hasPermission('sys/auth'), 'exact deny wins'); + }); + + test('deny precedence: glob deny beats exact allow', function (assert) { + this.service.setProperties({ + exactPaths: { 'sys/auth': { capabilities: ['read'] } }, + globPaths: { 'sys/auth/*': { capabilities: ['deny'] } }, + }); + assert.false(this.service.hasPermission('sys/auth/methods'), 'glob deny wins'); + }); + }); + + module('permissionsBanner - fullCurrentNamespace (e.g. ns/)', function () { [ // First set: no chroot or user root { - scenario: 'when root wildcard in root namespace', + scenario: 'no chroot or user root → full ns is currentNs', chroot: null, userRoot: '', currentNs: 'foo/bar', - globs: { - '': { capabilities: ['read'] }, - }, - 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', - }, + expectedFullNs: 'foo/bar', }, // Second set: chroot and user root (currentNs excludes chroot) { - scenario: 'when namespace wildcard in child ns & chroot', + scenario: 'chroot + user root → both prefixed', chroot: 'foo/', userRoot: 'bar', currentNs: 'bar/baz', - globs: { - 'foo/bar/': { capabilities: ['read'] }, - }, - expected: { - wildcard: true, - banner: null, - fullNs: 'foo/bar/baz', - }, + expectedFullNs: 'foo/bar/baz', }, + // Third set: chroot only { - 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: 'chroot only → chroot prefixed', chroot: 'admin/', userRoot: '', currentNs: 'child', - globs: { - 'admin/': { capabilities: ['read'] }, - }, - expected: { - wildcard: true, - banner: null, - fullNs: 'admin/child', - }, - expectedAccess: true, + expectedFullNs: 'admin/child', }, + // Fourth set: user root only { - 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', + scenario: 'user root only → user root prefixed', 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, - userRoot: 'bar', - currentNs: 'bar/baz', - globs: { - 'bar/': { capabilities: ['read'] }, - }, - expected: { - wildcard: true, - banner: null, - fullNs: 'bar/baz', - }, - }, - { - 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 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: { - 'foo/bing/kv/data/': { capabilities: ['read'] }, - }, - expected: { - wildcard: false, - banner: null, - fullNs: 'foo/bing', - }, + expectedFullNs: 'foo/bing', }, ].forEach((testCase) => { - test(`${testCase.scenario}`, async function (assert) { - const namespaceService = Service.extend({ - userRootNamespace: testCase.userRoot, - path: testCase.currentNs, - }); - this.owner.register('service:namespace', namespaceService); + test(testCase.scenario, async function (assert) { + const Ns = Service.extend({ userRootNamespace: testCase.userRoot, path: testCase.currentNs }); + this.owner.register('service:namespace', Ns); + this.service.setPaths({ - data: { - glob_paths: testCase.globs, - exact_paths: {}, - chroot_namespace: testCase.chroot, - }, + data: { glob_paths: {}, 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); + assert.strictEqual(fullNamespace, testCase.expectedFullNs, 'full ns computed correctly'); }); }); }); + + module('permissionsBanner — namespace + globPaths coverage (e.g. +/)', function () { + test('ACL not loaded → no banner (prevents flicker)', function (assert) { + registerNs(this.owner, 'ns1/child'); + // Fresh service: exact/glob/canViewAll all null + assert.strictEqual(this.service.permissionsBanner, null, 'null while loading'); + }); + + test('ACL load failed → read-failed banner', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + _aclLoadFailed: true, + exactPaths: null, + globPaths: null, + canViewAll: false, + }); + assert.strictEqual(this.service.permissionsBanner, PERMISSIONS_BANNER_STATES.readFailed); + }); + + // If you want root to *always* suppress banners, flip getter order and update this. + test('root token → no banner regardless of namespace', function (assert) { + registerNs(this.owner, 'ns1/child'); + this.service.setProperties({ + isRoot: true, + exactPaths: {}, + globPaths: {}, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + + test('no canaries allowed in ns → no-ns-access banner', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + exactPaths: { 'some/noncanary': { capabilities: ['read'] } }, // non-canary + globPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, PERMISSIONS_BANNER_STATES.noAccess); + }); + + // +/sys/auth (one ns segment) vs current ns depth 1 + test('+/sys/auth (base-only) grants canary in one-segment ns (base only)', function (assert) { + registerNs(this.owner, 'ns1'); // full: ns1/sys/auth + this.service.setProperties({ + globPaths: { '+/sys/auth': { capabilities: ['list'] } }, // no trailing slash → base only + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null, 'base canary suppresses banner'); + }); + + test('+/sys/auth/ (include-base) grants canary for base and children in one-segment ns', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + globPaths: { '+/sys/auth/': { capabilities: ['list'] } }, // include-base + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + + // +/+/sys/auth for two-segment namespace + test('+/+/sys/auth (base-only) grants canary in two-segment ns', function (assert) { + registerNs(this.owner, 'ns1/child'); // full: ns1/child/sys/auth + this.service.setProperties({ + globPaths: { '+/+/sys/auth': { capabilities: ['read'] } }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + + test('single + does NOT match two-segment ns → no-ns-access', function (assert) { + registerNs(this.owner, 'ns1/child'); + this.service.setProperties({ + globPaths: { '+/sys/auth': { capabilities: ['read'] } }, // only one '+' + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, PERMISSIONS_BANNER_STATES.noAccess); + }); + + test('+/+/sys/auth/ include-base still suppresses banner in two-segment ns', function (assert) { + registerNs(this.owner, 'ns1/child'); + this.service.setProperties({ + globPaths: { '+/+/sys/auth/': { capabilities: ['read'] } }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + + test('chroot + one-segment ns matches +/+ canary', function (assert) { + registerNs(this.owner, 'marketing'); // full: admin/marketing/sys/auth + this.service.setProperties({ + chrootNamespace: 'admin', + globPaths: { '+/+/sys/auth': { capabilities: ['list'] } }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + + test('most-specific allow (+/+/sys/auth/) overrides broader deny (+/+/sys/*)', function (assert) { + registerNs(this.owner, 'ns1/child'); // full path prefix: ns1/child + + this.service.setProperties({ + globPaths: { + '+/+/sys/*': { capabilities: ['deny'] }, // children-only deny under sys + '+/+/sys/auth/': { capabilities: ['read'] }, // include-base allow for sys/auth + }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + + assert.strictEqual( + this.service.permissionsBanner, + null, + 'specific include-base allow suppresses banner even with broader children-only deny' + ); + }); + + test('deny on the canary path blocks banner suppression when no other canaries are allowed', function (assert) { + registerNs(this.owner, 'ns1/child'); + + this.service.setProperties({ + globPaths: { + '+/+/sys/auth/': { capabilities: ['deny'] }, // deny the canary + // no other canary allows present + }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + + assert.strictEqual( + this.service.permissionsBanner, + PERMISSIONS_BANNER_STATES.noAccess, + 'without other allowed canaries, deny on sys/auth shows the banner' + ); + }); + + test('empty-root glob "" (aka `path "*"`) suppresses banner in namespaced paths', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + globPaths: { '': { capabilities: ['read'] } }, // allow-all + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual( + this.service.permissionsBanner, + null, + 'allow-all policy suppresses the no-access banner even in a namespace' + ); + }); + + test('canary denied explicitly → no-ns-access', function (assert) { + registerNs(this.owner, 'ns1/child'); + this.service.setProperties({ + globPaths: { '+/+/sys/auth': { capabilities: ['deny'] } }, + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, PERMISSIONS_BANNER_STATES.noAccess); + }); + + test('canary allowed with any non-deny capability suppresses banner', function (assert) { + registerNs(this.owner, 'ns1/child'); + this.service.setProperties({ + globPaths: { '+/+/sys/auth': { capabilities: ['list'] } }, // any non-deny is enough + exactPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null); + }); + }); + + module('permissionsBanner — canary checks (aka “standard mgmt endpoints”)', function () { + /** + * Canary endpoints are a curated set of safe, management-style API paths that the UI probes + * inside the *current* namespace to decide whether to suppress the “no access” banner. + * Heuristic: any non-deny capability on at least one canary → suppress banner. + * Examples (not exhaustive): 'sys/auth', 'identity/*', 'sys/leases/lookup', 'sys/policies/*', + * 'sys/tools/hash', 'sys/replication', 'sys/license', etc. + */ + + test('no banner when any canary path is allowed via exact path (e.g., sys/tools/hash)', function (assert) { + registerNs(this.owner, ''); + this.service.setProperties({ + exactPaths: { 'sys/tools/hash': { capabilities: ['update'] } }, // canary + globPaths: {}, + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null, 'canary access suppresses banner'); + }); + + test('edge case: namespace-only engine access (+/kv/data/*) still shows banner', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + exactPaths: {}, + globPaths: { '+/kv/data/*': { capabilities: ['read'] } }, // non-canary + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual( + this.service.permissionsBanner, + PERMISSIONS_BANNER_STATES.noAccess, + 'engine-only glob (+/kv/data/*) is not a canary → banner shows' + ); + }); + + test('canary via glob (+/sys/auth/) suppresses banner in a one-segment namespace', function (assert) { + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + exactPaths: {}, + globPaths: { '+/sys/auth/': { capabilities: ['list'] } }, // include-base glob canary + canViewAll: false, + _aclLoadFailed: false, + }); + assert.strictEqual(this.service.permissionsBanner, null, 'glob canary suppresses banner'); + }); + }); + + module('permissionsBanner — resultant-acl direct check', function () { + test('allow on resultant-acl (ns via + glob) suppresses banner', function (assert) { + // current namespace depth = 1 → use single '+' + registerNs(this.owner, 'ns1'); + this.service.setProperties({ + exactPaths: {}, + globPaths: { [`+/${RESULTANT_ACL_PATH}`]: { capabilities: ['read'] } }, + _aclLoadFailed: false, + }); + assert.strictEqual( + this.service.permissionsBanner, + null, + 'read capability to resultant-acl suppresses banner' + ); + }); + + test('deny on resultant-acl (two-segment ns) with no other canaries → no-ns-access', function (assert) { + registerNs(this.owner, 'ns1/child'); // full path checked: ns1/child/sys/internal/ui/resultant-acl + this.service.setProperties({ + exactPaths: {}, // no exact allows + globPaths: { + [`+/+/${RESULTANT_ACL_PATH}`]: { capabilities: ['deny'] }, // explicit deny + }, + _aclLoadFailed: false, + }); + assert.strictEqual( + this.service.permissionsBanner, + PERMISSIONS_BANNER_STATES.noAccess, + 'deny to resultant-acl with no other canaries shows no-access banner' + ); + }); + }); + // ─────────────────────────────────────────────────────────────────────────── + // precedence test (document current getter order) + // ─────────────────────────────────────────────────────────────────────────── + test('root overrides read-failed: no banner for canViewAll', function (assert) { + // namespace doesn’t matter; root short-circuits + const Ns = Service.extend({ path: '' }); + this.owner.register('service:namespace', Ns); + + this.service.setProperties({ + _aclLoadFailed: true, // simulate fetch failure + isRoot: true, + canViewAll: true, + exactPaths: null, + globPaths: null, + }); + + assert.strictEqual(this.service.permissionsBanner, null, 'root sees no banner even if ACL fetch failed'); + }); });