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');
+ });
});