Backport UI Fix: UI permissions banner and side bar nav gating respect Vault glob semantics (+, deny precedence) into ce/main (#9800)

* UI Fix: UI permissions banner and side bar nav gating respect Vault glob semantics (+, deny precedence) (#9522)

* add in empty states when no permissions error but no list values found.

* wip

* wip cont.

* a lot closer... I think

* looking good, now to smoke test (Again)

* welp revert fix to adapter that borked it.

* add changelog

* test coverage—a lot

* fix some issues with root vs fallback show sidebar nav

* address pr comments and clean up comments and left over duplicate methods in permission service

* Apply suggestion from @hellobontempo

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

* add resultant-acl in canary paths

* remove from canary and use capability check instead inside permissionsBanner

* clean up

* fix merge things

---------

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

* add conditional for enterprise vs ce

---------

Co-authored-by: Angel Garbarino <Monkeychip@users.noreply.github.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Co-authored-by: Angel Garbarino <argarbarino@gmail.com>
This commit is contained in:
Vault Automation 2025-10-14 12:30:41 -04:00 committed by GitHub
parent 5d05b1d023
commit 79bab3edd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1028 additions and 549 deletions

2
changelog/_9522.txt Normal file
View file

@ -0,0 +1,2 @@
```release-note:bug
ui: Fixes permissions for hiding and showing sidebar navigation items for policies that include special characters: `+`, `*`

View file

@ -167,6 +167,8 @@
</Hds::Dropdown>
</div>
</LinkedBlock>
{{else}}
<EmptyState @title="No Secrets engines found" />
{{/each}}
{{#if this.engineToDisable}}

View file

@ -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;
}
}

View file

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

View file

@ -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);
},
});

View file

@ -8,10 +8,66 @@ import { tracked } from '@glimmer/tracking';
import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path';
import { task } from 'ember-concurrency';
/**
* PermissionsService
* ---------------------------------------------------------------------------
* What it does
* - Gates sidebar visibility and the Resultant ACL banner.
* - Consumes one resultant-acl payload: { exact_paths, glob_paths, root, chroot_namespace }.
* - Evaluates permissions against the fully-qualified path:
* <chroot>/<currentNamespace>/<apiPath> (omitting empty parts).
*
* Policy semantics (brief)
* - '+' = exactly one segment; '*' = wildcard; '/' = include base; '/*' = children-only.
* - Globpaths return policy paths with * if the path includes a wildcard. Meaning,
* sys/admin/* returns as sys/admin/, but +/sys/admin/* returns as +/sys/admin/*
* the implication is that a plus sign make the * a non-greedy match (e.g. children only), but without a plus
* a policy path is greedy (e.g. base + children)
* - Most-specific (longest) match wins; any explicit 'deny' beats allow.
* Docs: https://developer.hashicorp.com/vault/docs/concepts/policies#priority-matching
*
* Sidebar flow
* - Templates has-permissions hasNavPermission() hasPermission() on resolved API paths.
*
* Banner flow
* - permissionsBanner getter computes effective namespace and returns:
* null if root, read-failed if ACL fetch failed, or if any canary path is allowed here
* no-ns-access otherwise
* - Canary paths are a small set of UI-centric endpoints we probe in the current namespace.
* - They build off API_PATHS, which also drives sidebar nav items.
* - If you ever wanted to add to a canary probe list, either expand API_PATHS
* or add to CANARY_PATHS directly.
*
* Chroot note
* - When chroot_namespace is present, backend keys are already prefixed.
* We mirror this by composing fullCurrentNamespace for all checks.
*
* Example resultant-acl payload:
* {
* "exact_paths": {
* "sys/policies/acl": { "capabilities": ["read", "list"] },
* "sys/policies/acl/my-policy": { "capabilities": ["read"] },
* "sys/auth": { "capabilities": ["deny"] }
* },
* "glob_paths": {
* "secret/data/finance/+/payroll": { "capabilities": ["create", "update", "read", "list"] },
* "secret/data/engineering/*": { "capabilities": ["read", "list"] },
* "secret/data/hr/": { "capabilities": ["read", "list"] },
* "+/auth/*": { "capabilities": ["deny"] },
* "": { "capabilities": ["read", "list"] } // baseline allow-all
* },
* "root": false,
* "chroot_namespace": "ns1/child"
* }
*/
export const PERMISSIONS_BANNER_STATES = {
readFailed: 'read-failed',
noAccess: 'no-ns-access',
};
export const RESULTANT_ACL_PATH = 'sys/internal/ui/resultant-acl'; // export for tests
const API_PATHS = {
access: {
methods: 'sys/auth',
@ -71,43 +127,51 @@ const API_PATHS_TO_ROUTE_PARAMS = {
'identity/oidc/client': { route: 'vault.cluster.access.oidc', models: [] },
};
/*
The Permissions service is used to gate top navigation and sidebar items.
It fetches a users' policy from the resultant-acl endpoint and stores their
allowed exact and glob paths as state. It also has methods for checking whether
a user has permission for a given path.
The data from the resultant-acl endpoint has the following shape:
{
exact_paths: {
[key: string]: {
capabilities: string[];
};
};
glob_paths: {
[key: string]: {
capabilities: string[];
};
};
root: boolean;
chroot_namespace?: string;
};
There are a couple nuances to be aware of about this response. When a
chroot_namespace is set, all of the paths in the response will be prefixed
with that namespace. Additionally, this endpoint is only added to the default
policy in the user's root namespace, so we make the call to the user's root
namespace (the namespace where the user's auth method is mounted) no matter
what the current namespace is.
*/
// Canary endpoints: quick check for “meaningful UI access” in the *current* namespace.
// If the token has any non-deny capability on any canary here, we suppress the banner.
// This does not try to cover all possible paths (e.g. secrets engines only such as `+/kv/data/*`),
// by design—probing everything is infeasible. Keep the list small and UI-centric.
//
// IMPORTANT NOTE: A user scoped only to say a secrets engine path
// (e.g. `+/kv/data/*`) would still trigger the banner, since iterating over all
// possible paths is not feasible.
//
// This may be the situation for Namespace-tenancy setups where users are
// confined to secrets engines in a child namespace and given no management of
// endpoints there.
const CANARY_PATHS = [
...Object.values(API_PATHS.access),
...Object.values(API_PATHS.policies),
...Object.values(API_PATHS.tools),
...Object.values(API_PATHS.status),
...Object.values(API_PATHS.clients),
...Object.values(API_PATHS.settings),
...Object.values(API_PATHS.sync),
...Object.values(API_PATHS.monitoring),
];
export default class PermissionsService extends Service {
@tracked exactPaths = null;
@tracked globPaths = null;
@tracked canViewAll = null;
@tracked permissionsBanner = null;
@tracked hasFallbackAccess = false;
@tracked isRoot = false;
@tracked _aclLoadFailed = false;
@tracked chrootNamespace = null;
@service store;
@service namespace;
// isAclLoaded:
// - True if we know the caller is actual root (resp.data.root === true),
// or if weve 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
// didnt confirm) and set hasFallbackAccess=true instead. This way the
// sidebar remains usable but the banner shows "read-failed".
get isAclLoaded() {
return this.isRoot || this.exactPaths !== null || this.globPaths !== null;
}
get fullCurrentNamespace() {
const currentNs = this.namespace.path;
return this.chrootNamespace
@ -115,171 +179,305 @@ export default class PermissionsService extends Service {
: sanitizePath(currentNs);
}
@task *getPaths() {
if (this.paths) {
return;
// Banner logic: only show when we *know* the user has no meaningful UI access
// in the current namespace. Precedence:
// 1) Root token → never show a banner.
// 2) ACL fetch failed (_aclLoadFailed) → show "read-failed" (sidebar may still
// render via hasFallbackAccess; the banner explains missing ACL data).
// 3) ACLs not loaded yet (!isAclLoaded) → suppress banner (avoid flicker while loading).
// 4) Explicit UI check: if the caller can READ "sys/internal/ui/resultant-acl" in the
// effective namespace, treat that as minimal UI access → suppress banner.
// History: This was the original intent (CE PR #23503). We temporarily removed reliance
// on this signal (CE PR #25256) because our old banner logic used string prefix checks
// and the resultant-acl payload did not preserve namespace-segment wildcards (`+`), so
// policies like `+/sys/...` or `+/+/sys/...` in child namespaces could be missed.
// Today the backend returns `+` in `glob_paths` and this service matches against the
// fully-qualified path using proper glob semantics, so this explicit check is reliable again.
// 5) Heuristic canaries: if any curated UI-centric path is non-deny, suppress the banner.
// 6) Otherwise → "no-ns-access".
get permissionsBanner() {
// 1) Real root never sees a banner
if (this.isRoot) return null;
// 2) Fetch failed → explain
if (this._aclLoadFailed) return PERMISSIONS_BANNER_STATES.readFailed;
// 3) Still loading → stay quiet
if (!this.isAclLoaded) return null;
// 4) Explicit resultant-acl read → minimal UI access confirmed
if (this.hasPermission(RESULTANT_ACL_PATH, ['read'])) {
return null;
}
// 5) Heuristic: any canary with any non-deny capability
const anyAllowed = CANARY_PATHS.some((p) => this.hasPermission(p));
// 6) Final decision
return anyAllowed ? null : PERMISSIONS_BANNER_STATES.noAccess;
}
// Load resultant-ACL used by sidebar + banner.
// Success:
// • Clear _aclLoadFailed, then hydrate via setPaths(resp).
// Failure (network/403/etc):
// • Mark _aclLoadFailed so the banner explains missing ACL data.
// • Enable hasFallbackAccess so the sidebar remains usable (legacy behavior).
// • Clear exact/glob to avoid using stale ACL from a prior success.
@task *getPaths() {
try {
const resp = yield this.store.adapterFor('permissions').query();
this._aclLoadFailed = false;
this.setPaths(resp);
return;
} catch (err) {
// If no policy can be found, default to showing all nav items.
this.canViewAll = true;
this.permissionsBanner = PERMISSIONS_BANNER_STATES.readFailed;
this._aclLoadFailed = true;
this.isRoot = false;
this.hasFallbackAccess = true; // fallback so nav stays visible
// Avoid stale ACL from a previous successful load
this.exactPaths = null;
this.globPaths = null;
}
}
/**
* hasWildcardNsAccess checks if the user has a wildcard access to target namespace
* via full glob path or any ancestors of the target namespace
* @param {string} targetNs is the current/target namespace that we are checking access for
* @param {object} globPaths key is path, value is object with capabilities
* @returns {boolean} whether the user's policy includes wildcard access to NS
*/
hasWildcardNsAccess(targetNs, globPaths = {}) {
const nsParts = sanitizePath(targetNs).split('/');
let matchKey = null;
// For each section of the namespace, check if there is a matching wildcard path
while (nsParts.length > 0) {
// glob paths always end in a slash
const test = `${nsParts.join('/')}/`;
if (Object.keys(globPaths).includes(test)) {
matchKey = test;
break;
}
nsParts.pop();
}
// Finally, check if user has wildcard access to the root namespace
// which is represented by an empty string
if (!matchKey && Object.keys(globPaths).includes('')) {
matchKey = '';
}
if (null === matchKey) {
return false;
}
// if there is a match make sure the capabilities do not include deny
return !this.isDenied(globPaths[matchKey]);
}
// This method is called to recalculate whether to show the permissionsBanner when the namespace changes
calcNsAccess() {
if (this.canViewAll) {
this.permissionsBanner = null;
return;
}
const namespace = this.fullCurrentNamespace;
const allowed =
// check if the user has wildcard access to the relative root namespace
this.hasWildcardNsAccess(namespace, this.globPaths) ||
// or if any of their glob paths start with the namespace
Object.keys(this.globPaths).any((k) => k.startsWith(namespace) && !this.isDenied(this.globPaths[k])) ||
// or if any of their exact paths start with the namespace
Object.keys(this.exactPaths).any((k) => k.startsWith(namespace) && !this.isDenied(this.exactPaths[k]));
this.permissionsBanner = allowed ? null : PERMISSIONS_BANNER_STATES.noAccess;
}
// Populate tracked state from a successful resultant-ACL response.
// • exact_paths / glob_paths → used for all permission checks
// • root (boolean) → authoritative “real root” from backend
// • hasFallbackAccess → always false on success (only true if ACL fetch fails)
// • chroot_namespace → prefix applied to all checks
setPaths(resp) {
this.exactPaths = resp.data.exact_paths;
this.globPaths = resp.data.glob_paths;
this.canViewAll = resp.data.root;
this.isRoot = resp.data.root; // true root per backend
this.hasFallbackAccess = false; // nav gating shortcut (true root behaves like “show all”). Only set true if ACL fail.
this.chrootNamespace = resp.data.chroot_namespace;
this.calcNsAccess();
}
reset() {
this.exactPaths = null;
this.globPaths = null;
this.canViewAll = null;
this.hasFallbackAccess = false;
this.chrootNamespace = null;
this.permissionsBanner = null;
this._aclLoadFailed = false;
}
// ===== Matching helpers (STRICT) ==========================================
// _globKeyToRegex
// Converts a Vault glob-style policy key into a regex:
// • Trailing "/" → include base + any children
// • Trailing "/*" → children-only (≥1 child; base does NOT match)
// • "+" → exactly one segment
// • "*" → greedy segment matcher
// • "" (empty key) → baseline allow-all, equivalent to `path "*" { … }`
//
// PERF NOTE: Consider caching compiled regexes if ACLs are large or called frequently.
_globKeyToRegex(globKey) {
// Empty key → allow-all
if (globKey === '') {
return /^.*$/;
}
const endsWithChildrenOnly = /\/\*$/.test(globKey); // "/*" → children-only
const endsWithIncludeBase = !endsWithChildrenOnly && /\/$/.test(globKey); // "/" → include base
const trimmed = globKey
.replace(/\/\*$/, '') // strip "/*"
.replace(/\/$/, ''); // strip trailing "/"
const parts = trimmed.split('/').map((seg) => {
if (seg === '+') return '[^/]+'; // exactly one segment
if (seg === '*') return '.*'; // greedy tail matcher
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape literal
});
if (endsWithChildrenOnly) {
// require ≥1 child segment
return new RegExp('^' + parts.join('/') + '/.+' + '$');
}
if (endsWithIncludeBase) {
// match base OR any children
return new RegExp('^' + parts.join('/') + '(?:/.*)?' + '$');
}
// exact match only
return new RegExp('^' + parts.join('/') + '$');
}
// _matchExact
// Example: needle = "sys/auth"
// • "sys/auth" → matches directly
// • "sys/auth/methods" → chosen over "sys/auth" (more specific child)
//
// Resolves an "exact_paths" match with Vault policy semantics:
// • Normalize trailing "/" for both needle and keys
// • Exact equality takes precedence
// • Otherwise, choose the longest child key (most specific) under the base
_matchExact(fullPath) {
const exact = this.exactPaths;
if (!exact) return null;
const needle = fullPath.replace(/\/$/, '');
// Build [orig, norm] pairs so we can match on norm but return orig
const tuples = Object.keys(exact).map((k) => [k, k.replace(/\/$/, '')]);
// Exact equality wins outright
const eq = tuples.find(([, norm]) => norm === needle);
if (eq) {
const [orig] = eq;
return { key: orig, entry: exact[orig] };
}
// Otherwise, pick the longest child (most specific) under the base
const child = tuples
.filter(([, norm]) => norm.startsWith(needle + '/'))
.sort((a, b) => b[1].length - a[1].length)[0];
return child ? { key: child[0], entry: exact[child[0]] } : null;
}
// _matchGlob
// Example: fullPath = "sys/auth/methods"
// • glob "*" → matches (baseline allow-all)
// • glob "+/sys/*" → matches, less specific
// • glob "+/sys/auth/*" → matches, more specific → chosen
// • glob "sys/auth/methods" → exact equality handled in _matchExact
//
// Glob match precedence:
// • Empty key "" is a special baseline = allow all (overridable by denies)
// • Among regex matches, the longest key (most specific) wins
// • Final allow/deny resolution is deferred to _decide()
_matchGlob(fullPath) {
const globPaths = this.globPaths;
if (!globPaths) return null;
let matchKey = null;
// Treat empty-root ('') as baseline "allow all" if present
if (Object.prototype.hasOwnProperty.call(globPaths, '')) {
matchKey = '';
}
for (const k of Object.keys(globPaths)) {
if (k === '') continue; // already accounted for
const re = this._globKeyToRegex(k);
if (re.test(fullPath)) {
if (matchKey === null || k.length > matchKey.length) {
matchKey = k; // longest wins
}
}
}
return matchKey !== null ? { key: matchKey, entry: globPaths[matchKey] } : null;
}
// _decide
// Resolution rule across matchers:
// - If any matched entry is 'deny' → false (authoritative).
// - Otherwise, ALL requested capabilities must be satisfied by at least one matched non-deny entry.
// • The 'capabilities' arg comes from the caller (via hasPermission):
// - ["list"] for identity pages
// - ["read", "update"] if a UI flow requires those
// - [null] means "any non-deny capability is enough" (common for sidebar/nav checks).
_decide(fullPath, capabilities = [null]) {
// Collect matches from BOTH exact and glob, then apply precedence:
// 1. If ANY matching entry is an explicit deny → false.
// 2. If no deny, allow if every requested capability is satisfied by ≥1 non-deny match.
// 3. Otherwise → false.
const exactMatch = this._matchExact(fullPath);
const globMatch = this._matchGlob(fullPath);
// If any deny match exists (exact or glob), short-circuit to false
if ((exactMatch && this.isDenied(exactMatch.entry)) || (globMatch && this.isDenied(globMatch.entry))) {
return false;
}
// Now check capability satisfaction across the best exact/glob
const candidates = [exactMatch?.entry, globMatch?.entry].filter(Boolean);
return capabilities.every((cap) => {
// Null means “any non-deny capability”
if (cap === null) return candidates.some((e) => !this.isDenied(e));
return candidates.some((e) => !this.isDenied(e) && this.hasCapability(e, cap));
});
}
// ===== Public checks =======================================================
// Entry point used by the has-permissions helper (and tests).
// If `routeParams` is an array and `requireAll` is true, *all* params must
// be permitted; otherwise any one is enough.
// For identity entities/groups pages we require 'list'; other pages only need
// “not denied”, so we pass `[null]` to mean “any non-deny capability suffices”.
// Determine if a nav item should be shown.
// - Resolves one or more API paths from API_PATHS[navItem] (optionally using routeParams).
// - If routeParams is an array:
// • requireAll=true → every param must be permitted (Array.every)
// • requireAll=false → at least one must be permitted (Array.some)
// - Capability policy:
// • 'entities' / 'groups' require ["list"]
// • everything else uses [null] → “any non-deny capability is enough”
// - Delegates to hasPermission(path, capabilities) per resolved path.
hasNavPermission(navItem, routeParams, requireAll) {
if (routeParams) {
// check that the user has permission to access all (requireAll = true) or any of the routes when array is passed
// useful for hiding nav headings when user does not have access to any of the links
const params = Array.isArray(routeParams) ? routeParams : [routeParams];
const evalMethod = !Array.isArray(routeParams) || requireAll ? 'every' : 'some';
return params[evalMethod]((param) => {
// viewing the entity and groups pages require the list capability, while the others require the default, which is anything other than deny
const capability = param === 'entities' || param === 'groups' ? ['list'] : [null];
return this.hasPermission(API_PATHS[navItem][param], capability);
});
}
// No params → show if any canonical path for the nav item is permitted.
return Object.values(API_PATHS[navItem]).some((path) => this.hasPermission(path));
}
// Compute route params (models) for the *first* accessible path of a nav item.
// - Uses hasPermission(...) to pick the first allowed path in API_PATHS[navItem].
// - For 'policies' and 'tools', the model is the last path segment (e.g., 'acl', 'hash').
// - Otherwise, returns the pre-mapped route + models from API_PATHS_TO_ROUTE_PARAMS.
navPathParams(navItem) {
const path = Object.values(API_PATHS[navItem]).find((path) => this.hasPermission(path));
if (['policies', 'tools'].includes(navItem)) {
return { models: [path.split('/').lastObject] };
const last = path.split('/').pop();
return { models: [last] };
}
return API_PATHS_TO_ROUTE_PARAMS[path];
}
// Build a fully-qualified API path scoped to the effective namespace.
// - Prefixes <chroot>/<currentNamespace> when present.
// - sanitizePath/sanitizeStart prevent accidental double slashes and leading '/' issues.
// Examples:
// ns="team", chroot=null, path="sys/auth" → "team/sys/auth"
// ns="team/child", chroot="admin", path="/sys/auth/" → "admin/team/child/sys/auth/"
pathNameWithNamespace(pathName) {
const namespace = this.fullCurrentNamespace;
if (namespace) {
return `${sanitizePath(namespace)}/${sanitizeStart(pathName)}`;
} else {
return pathName;
}
const ns = this.fullCurrentNamespace;
return ns ? `${sanitizePath(ns)}/${sanitizeStart(pathName)}` : pathName;
}
// Core permission check used by both sidebar gating and banner canary probes.
// - Root short-circuit: real root (isRoot) → true.
// - Otherwise, compose the fully-qualified path and delegate to _decide(full, capabilities).
// - 'capabilities' is the callers 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 its 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');
}

View file

@ -81,9 +81,12 @@
{{#if this.auth.isActiveSession}}
<TokenExpireWarning @expirationDate={{this.auth.tokenExpirationDate}} @allowingExpiration={{this.auth.allowExpiration}}>
{{#if this.permissionBanner}}
{{#if this.permissions.permissionsBanner}}
<div class="has-top-margin-m">
<ResultantAclBanner @isEnterprise={{this.activeCluster.version.isEnterprise}} @failType={{this.permissionBanner}} />
<ResultantAclBanner
@isEnterprise={{this.activeCluster.version.isEnterprise}}
@failType={{this.permissions.permissionsBanner}}
/>
</div>
{{/if}}
{{outlet}}

View file

@ -115,6 +115,8 @@
</div>
</div>
</LinkedBlock>
{{else}}
<EmptyState @title="No Authentication Methods found" />
{{/each}}
{{#if this.methodToDisable}}

View file

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

View file

@ -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();
});
});

View file

@ -6,6 +6,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { RESULTANT_ACL_PATH } from 'vault/services/permissions';
module('Unit | Adapter | permissions', function (hooks) {
setupTest(hooks);
@ -21,7 +22,7 @@ module('Unit | Adapter | permissions', function (hooks) {
auth.set('tokens', ['vault-_root_☃1']);
auth.setTokenData('vault-_root_☃1', { userRootNamespace: 'admin/bar', backend: { mountPath: 'token' } });
this.server.get('/sys/internal/ui/resultant-acl', (schema, request) => {
this.server.get(`${RESULTANT_ACL_PATH}`, (schema, request) => {
assert.strictEqual(
request.requestHeaders['X-Vault-Namespace'],
'admin/bar',
@ -46,7 +47,7 @@ module('Unit | Adapter | permissions', function (hooks) {
auth.set('tokens', ['vault-_root_☃1']);
auth.setTokenData('vault-_root_☃1', { userRootNamespace: '', backend: { mountPath: 'token' } });
this.server.get('/sys/internal/ui/resultant-acl', (schema, request) => {
this.server.get(`${RESULTANT_ACL_PATH}`, (schema, request) => {
assert.false(
Object.keys(request.requestHeaders).includes('X-Vault-Namespace'),
'request is called without namespace'

File diff suppressed because it is too large Load diff