mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-18 18:38:08 -05:00
Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run
This commit is contained in:
commit
c46306dfe4
8 changed files with 349 additions and 106 deletions
|
|
@ -12,11 +12,44 @@ import type ApiService from 'vault/services/api';
|
|||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type { Capabilities, CapabilitiesMap, CapabilitiesData, CapabilityTypes } from 'vault/app-types';
|
||||
|
||||
type CapabilityFetchOptions = {
|
||||
routeForCache?: string;
|
||||
};
|
||||
|
||||
export default class CapabilitiesService extends Service {
|
||||
@service declare readonly api: ApiService;
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
|
||||
@tracked requestedPaths = new Set<string>([]);
|
||||
/*
|
||||
* API path caching for <CodeGeneratorPolicyFlyout />
|
||||
* Cache API paths when capabilities are fetched during route model hooks
|
||||
* so the flyout can prefill with relevant policy paths.
|
||||
*/
|
||||
@tracked routePathCache = new Map<string, Set<string>>();
|
||||
|
||||
// Cache API paths requested for a particular route
|
||||
cacheRoutePaths(route: string, apiPaths: string[]) {
|
||||
this.routePathCache.set(route, new Set(apiPaths));
|
||||
}
|
||||
|
||||
// Lookup the paths for a route. Returns exact match first then falls back to longest matching route prefix.
|
||||
lookupRoutePaths(route: string) {
|
||||
const exactMatch = this.routePathCache.get(route);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const routes = Array.from(this.routePathCache.keys());
|
||||
const matchingRoutes = routes.filter((r) => route.includes(r));
|
||||
if (matchingRoutes.length) {
|
||||
// Return longest matching cached route which is the most specific parent
|
||||
const bestMatch = matchingRoutes.reduce((a, b) => (a.length > b.length ? a : b), '');
|
||||
return this.routePathCache.get(bestMatch);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/* End logic for <CodeGeneratorPolicyFlyout /> API path caching */
|
||||
|
||||
/*
|
||||
Add API paths to the PATH_MAP constant using a friendly key, e.g. 'syncDestinations'.
|
||||
|
|
@ -33,6 +66,10 @@ export default class CapabilitiesService extends Service {
|
|||
return path(params || {});
|
||||
}
|
||||
|
||||
pathsForList(paths: (keyof typeof PATH_MAP)[], params: object) {
|
||||
return paths.map((path) => this.pathFor(path, params));
|
||||
}
|
||||
|
||||
/*
|
||||
Users don't always have access to the capabilities-self endpoint in the current namespace.
|
||||
This can happen when logging in to a namespace and then navigating to a child namespace.
|
||||
|
|
@ -85,8 +122,11 @@ export default class CapabilitiesService extends Service {
|
|||
}, {});
|
||||
}
|
||||
|
||||
async fetch(paths: string[]): Promise<CapabilitiesMap> {
|
||||
this.requestedPaths = new Set(paths);
|
||||
async fetch(paths: string[], fetchOptions: CapabilityFetchOptions = {}): Promise<CapabilitiesMap> {
|
||||
// Cache API paths if route name provided
|
||||
if (fetchOptions?.routeForCache) {
|
||||
this.cacheRoutePaths(fetchOptions.routeForCache, paths);
|
||||
}
|
||||
|
||||
const payload = { paths: paths.map((path) => this.relativeNamespacePath(path)) };
|
||||
|
||||
|
|
@ -116,16 +156,16 @@ export default class CapabilitiesService extends Service {
|
|||
|
||||
// convenience method for fetching capabilities for a singular path without needing to use pathFor
|
||||
// ex: capabilities.for('syncDestinations', { type: 'github', name: 'org-sync' });
|
||||
async for<T>(key: keyof typeof PATH_MAP, params?: T) {
|
||||
async for<T>(key: keyof typeof PATH_MAP, params?: T, fetchOptions: CapabilityFetchOptions = {}) {
|
||||
const path = this.pathFor(key, params);
|
||||
return this.fetchPathCapabilities(path);
|
||||
return this.fetchPathCapabilities(path, fetchOptions);
|
||||
}
|
||||
|
||||
/*
|
||||
this method returns all of the capabilities for a singular path
|
||||
*/
|
||||
async fetchPathCapabilities(path: string) {
|
||||
const capabilities = await this.fetch([path]);
|
||||
async fetchPathCapabilities(path: string, fetchOptions: CapabilityFetchOptions = {}) {
|
||||
const capabilities = await this.fetch([path], fetchOptions);
|
||||
return capabilities[path] as Capabilities;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { formatStanzas, policySnippetArgs, PolicyStanza } from 'core/utils/code-
|
|||
import { validate } from 'vault/utils/forms/validate';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import routerLookup from 'core/utils/router-lookup';
|
||||
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import type { PolicyData } from './builder';
|
||||
|
|
@ -18,9 +19,11 @@ import type ApiService from 'vault/services/api';
|
|||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import type FlashMessageService from 'ember-cli-flash/services/flash-messages';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
interface Args {
|
||||
onClose?: CallableFunction;
|
||||
policyPaths: string[];
|
||||
}
|
||||
|
||||
export default class CodeGeneratorPolicyFlyout extends Component<Args> {
|
||||
|
|
@ -42,6 +45,10 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
|
|||
@tracked stanzas: PolicyStanza[] = this.defaultStanzas;
|
||||
@tracked validationErrors: ValidationMap | null = null;
|
||||
|
||||
get router(): RouterService {
|
||||
return routerLookup(this);
|
||||
}
|
||||
|
||||
validationError = (param: string) => {
|
||||
const { isValid, errors } = this.validationErrors?.[param] ?? {};
|
||||
return !isValid && errors ? errors.join(' ') : '';
|
||||
|
|
@ -97,16 +104,14 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
|
|||
@action
|
||||
openFlyout() {
|
||||
this.showFlyout = true;
|
||||
const presetStanzas = Array.from(this.capabilities.requestedPaths).map(
|
||||
(path) => new PolicyStanza({ path })
|
||||
);
|
||||
|
||||
const defaultState = formatStanzas(this.defaultStanzas);
|
||||
const currentState = formatStanzas(this.stanzas);
|
||||
const noChanges = currentState === defaultState;
|
||||
// Only preset stanzas if no changes have been made to the flyout
|
||||
if (presetStanzas.length && noChanges) {
|
||||
this.stanzas = presetStanzas;
|
||||
if (noChanges) {
|
||||
const presetStanzas = this.computePolicyPaths();
|
||||
this.stanzas = presetStanzas ? presetStanzas : this.stanzas;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,4 +132,24 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
|
|||
this.policyContent = '';
|
||||
this.stanzas = [new PolicyStanza()];
|
||||
}
|
||||
|
||||
computePolicyPaths() {
|
||||
// Explicit policy paths take precedence
|
||||
if (this.args.policyPaths) {
|
||||
return this.presetStanzas(this.args.policyPaths);
|
||||
}
|
||||
|
||||
const currentRouteName = this.router.currentRouteName;
|
||||
if (!currentRouteName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routePolicyPaths = this.capabilities.lookupRoutePaths(currentRouteName);
|
||||
return routePolicyPaths ? this.presetStanzas(Array.from(routePolicyPaths)) : null;
|
||||
}
|
||||
|
||||
presetStanzas(paths: string[]) {
|
||||
const presetStanzas = paths.map((path) => new PolicyStanza({ path }));
|
||||
return presetStanzas.length ? presetStanzas : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { getOwner } from '@ember/owner';
|
||||
import { action } from '@ember/object';
|
||||
import { encodePath } from 'vault/utils/path-encoding-helpers';
|
||||
import routerLookup from 'core/utils/router-lookup';
|
||||
|
||||
/**
|
||||
* @module LinkedBlock
|
||||
|
|
@ -26,13 +26,8 @@ import { encodePath } from 'vault/utils/path-encoding-helpers';
|
|||
*/
|
||||
|
||||
export default class LinkedBlockComponent extends Component {
|
||||
// We don't import the router service here because Ember Engine's use the alias 'app-router'
|
||||
// Since this component is shared across engines, we look up the router dynamically using getOwner instead.
|
||||
// This way we avoid throwing an error by looking up a service that doesn't exist.
|
||||
// https://guides.emberjs.com/release/services/#toc_accessing-services
|
||||
get router() {
|
||||
const owner = getOwner(this);
|
||||
return owner.lookup('service:router') || owner.lookup('service:app-router');
|
||||
return routerLookup(this);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
|||
20
ui/lib/core/addon/utils/router-lookup.ts
Normal file
20
ui/lib/core/addon/utils/router-lookup.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { getOwner } from '@ember/owner';
|
||||
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
|
||||
// In components shared across engines, we have to look up the router dynamically
|
||||
// and use getOwner because Ember Engine's use the alias 'app-router'.
|
||||
// This way we avoid throwing an error by looking up a service that doesn't exist.
|
||||
// https://guides.emberjs.com/release/services/#toc_accessing-services
|
||||
export default function routerLookup(context: object) {
|
||||
const owner = getOwner(context);
|
||||
return (
|
||||
(owner?.lookup('service:router') as RouterService) ||
|
||||
(owner?.lookup('service:app-router') as RouterService)
|
||||
);
|
||||
}
|
||||
|
|
@ -57,7 +57,11 @@ export default class KvSecretsListRoute extends Route {
|
|||
const backend = this.secretMountPath.currentPath;
|
||||
const filterValue = pathToSecret ? (pageFilter ? pathToSecret + pageFilter : pathToSecret) : pageFilter;
|
||||
const secrets = await this.fetchMetadata(backend, pathToSecret, params);
|
||||
const capabilities = await this.capabilities.for('kvMetadata', { backend, path: path_to_secret });
|
||||
const capabilities = await this.capabilities.for(
|
||||
'kvMetadata',
|
||||
{ backend, path: path_to_secret },
|
||||
{ routeForCache: 'vault.cluster.secrets.backend.kv' }
|
||||
);
|
||||
const backendModel = this.modelFor('application');
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -65,14 +65,10 @@ export default class KvSecretRoute extends Route {
|
|||
const undeletePath = `${backend}/undelete/${path}`;
|
||||
const destroyPath = `${backend}/destroy/${path}`;
|
||||
|
||||
const perms = await this.capabilities.fetch([
|
||||
metadataPath,
|
||||
dataPath,
|
||||
subkeysPath,
|
||||
deletePath,
|
||||
undeletePath,
|
||||
destroyPath,
|
||||
]);
|
||||
const apiPaths = [metadataPath, dataPath, subkeysPath, deletePath, undeletePath, destroyPath];
|
||||
const perms = await this.capabilities.fetch(apiPaths, {
|
||||
routeForCache: 'vault.cluster.secrets.backend.kv.secret',
|
||||
});
|
||||
|
||||
return {
|
||||
canReadData: perms[dataPath].canRead,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks
|
|||
this.version = this.owner.lookup('service:version');
|
||||
this.version.type = 'enterprise'; // the flyout is only available for enterprise versions
|
||||
this.onClose = undefined;
|
||||
this.policyPaths = undefined;
|
||||
this.assertSaveRequest = (assert, expectedPolicy, msg = 'policy content is correct') => {
|
||||
this.server.post('/sys/policies/acl/:name', (_, req) => {
|
||||
const { policy } = JSON.parse(req.requestBody);
|
||||
|
|
@ -35,7 +36,9 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks
|
|||
});
|
||||
};
|
||||
this.renderComponent = async ({ open = true } = {}) => {
|
||||
await render(hbs`<CodeGenerator::Policy::Flyout @onClose={{this.onClose}} />`);
|
||||
await render(
|
||||
hbs`<CodeGenerator::Policy::Flyout @onClose={{this.onClose}} @policyPaths={{this.policyPaths}} />`
|
||||
);
|
||||
if (open) {
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
}
|
||||
|
|
@ -72,6 +75,20 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks
|
|||
assert.dom(GENERAL.flyout).doesNotExist('flyout closes after clicking cancel');
|
||||
});
|
||||
|
||||
test('it presets with paths from @policyPaths array', async function (assert) {
|
||||
this.policyPaths = ['some/preset/path'];
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('some/preset/path');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it handles empty @policyPaths array', async function (assert) {
|
||||
this.policyPaths = [];
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('', 'does not prepopulate with empty array');
|
||||
});
|
||||
|
||||
test('it yields custom trigger component', async function (assert) {
|
||||
await render(hbs`<Hds::Dropdown as |D|>
|
||||
<D.ToggleButton @text="Toolbox" data-test-dropdown="Toolbox" />
|
||||
|
|
@ -346,83 +363,138 @@ EOT`;
|
|||
assert.dom(GENERAL.validationErrorByAttr('name')).doesNotExist('validation error is cleared');
|
||||
});
|
||||
|
||||
module('capabilities', function (hooks) {
|
||||
module('capabilities service prepopulating', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.capabilities = this.owner.lookup('service:capabilities');
|
||||
const router = this.owner.lookup('service:router');
|
||||
this.currentRouteNameStub = Sinon.stub(router, 'currentRouteName');
|
||||
this.cacheCapabilityPaths = (route, paths) => {
|
||||
this.capabilities.cacheRoutePaths(route, paths);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders when no capabilities have been requested', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set([]);
|
||||
hooks.afterEach(function () {
|
||||
this.currentRouteNameStub.restore();
|
||||
});
|
||||
|
||||
test('it handles null currentRouteName gracefully', async function (assert) {
|
||||
this.currentRouteNameStub.value(null);
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('', 'does not prepopulate when route name is null');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it does not prepopulate when no paths have been cached', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('');
|
||||
});
|
||||
|
||||
test('it prepopulates with a single capability path', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set(['super-secret/data']);
|
||||
test('it does not prepopulate paths when cached capabilities route is unrelated to the current route', async function (assert) {
|
||||
this.currentRouteNameStub.value('vault.cluster.secrets.secret');
|
||||
this.cacheCapabilityPaths('vault.cluster.settings', ['some/settings']);
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('');
|
||||
});
|
||||
|
||||
test('it prepopulates paths when cached capabilities route equals current route', async function (assert) {
|
||||
this.currentRouteNameStub.value('vault.cluster.secrets.secret');
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['super-secret/data']);
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it prepopulates with a multiple capability paths', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set(['path/one', 'path/two']);
|
||||
test('it prepopulates paths from longest matching parent route', async function (assert) {
|
||||
// Cache paths for parent route
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.backend.kv.secret', [
|
||||
'kv/data/my-secret',
|
||||
'kv/metadata/my-secret',
|
||||
]);
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.backend.kv', ['should/not/cache']);
|
||||
// Current route is a child (e.g., secret.details)
|
||||
this.currentRouteNameStub.value('vault.cluster.secrets.backend.kv.secret.details');
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('path/one');
|
||||
assert.dom(SELECTORS.pathByContainer(1)).hasValue('path/two');
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('kv/data/my-secret', 'uses parent paths');
|
||||
assert.dom(SELECTORS.pathByContainer(1)).hasValue('kv/metadata/my-secret', 'includes all parent paths');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 2 });
|
||||
});
|
||||
|
||||
test('it does not override user changes to a preset path on reopen', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set(['super-secret/data']);
|
||||
await this.renderComponent();
|
||||
// All of these tests run with the current route stubbed and cached paths
|
||||
module('when the flyout is prepopulated', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['super-secret/data']);
|
||||
this.currentRouteNameStub.value('vault.cluster.secrets.secret');
|
||||
});
|
||||
|
||||
// User updates path
|
||||
await typeIn(SELECTORS.pathByContainer(0), '/*');
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert
|
||||
.dom(SELECTORS.pathByContainer(0))
|
||||
.hasValue('super-secret/data/*', 'user path changes are preserved');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
test('paths from arg take precedence over capabilities service', async function (assert) {
|
||||
this.policyPaths = ['super-explicit/path'];
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-explicit/path');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it does not override user capabilities selection for a preset path on reopen', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set(['super-secret/data']);
|
||||
await this.renderComponent();
|
||||
test('it prepopulates with a single capability path', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
// User updates path
|
||||
await click(SELECTORS.checkboxByContainer(0, 'read'));
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert
|
||||
.dom(SELECTORS.checkboxByContainer(0, 'read'))
|
||||
.isChecked('user capabilities changes are preserved');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
test('it prepopulates with multiple capability paths', async function (assert) {
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['path/one', 'path/two']);
|
||||
await this.renderComponent();
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('path/one');
|
||||
assert.dom(SELECTORS.pathByContainer(1)).hasValue('path/two');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 2 });
|
||||
});
|
||||
|
||||
test('it does not override user added stanza on reopen', async function (assert) {
|
||||
this.capabilities.requestedPaths = new Set(['super-secret/data']);
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(SELECTORS.pathByContainer(1), 'new/path/*');
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 2 }, 'it renders two stanzas after reopening');
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data', 'preset path still exists');
|
||||
assert.dom(SELECTORS.pathByContainer(1)).hasValue('new/path/*', 'user added path still exists');
|
||||
});
|
||||
test('it does not override user changes to a preset path on reopen', async function (assert) {
|
||||
await this.renderComponent();
|
||||
// User updates path
|
||||
await typeIn(SELECTORS.pathByContainer(0), '/*');
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert
|
||||
.dom(SELECTORS.pathByContainer(0))
|
||||
.hasValue('super-secret/data/*', 'user path changes are preserved');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it does not save prepopulated paths as policy content', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.capabilities.requestedPaths = new Set(['path/one', 'path/two']);
|
||||
await this.renderComponent();
|
||||
// Fill in name and save to make sure policyContent is empty
|
||||
this.assertSaveRequest(assert, '', 'policy content is empty despite pre-filled paths');
|
||||
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
|
||||
await click(GENERAL.submitButton);
|
||||
test('it does not override user capabilities selection for a preset path on reopen', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
// User updates path
|
||||
await click(SELECTORS.checkboxByContainer(0, 'read'));
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert
|
||||
.dom(SELECTORS.checkboxByContainer(0, 'read'))
|
||||
.isChecked('user capabilities changes are preserved');
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
});
|
||||
|
||||
test('it does not override user added stanza on reopen', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(SELECTORS.pathByContainer(1), 'new/path/*');
|
||||
// Close and reopen
|
||||
await click(GENERAL.cancelButton);
|
||||
await click(GENERAL.button('Generate policy'));
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 2 }, 'it renders two stanzas after reopening');
|
||||
assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data', 'preset path still exists');
|
||||
assert.dom(SELECTORS.pathByContainer(1)).hasValue('new/path/*', 'user added path still exists');
|
||||
});
|
||||
|
||||
test('it does not save prepopulated paths as policy content', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['path/one', 'path/two']);
|
||||
await this.renderComponent();
|
||||
// Fill in name and save to make sure policyContent is empty
|
||||
this.assertSaveRequest(assert, '', 'policy content is empty despite pre-filled paths');
|
||||
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
|
||||
await click(GENERAL.submitButton);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ module('Unit | Service | capabilities', function (hooks) {
|
|||
|
||||
hooks.beforeEach(function () {
|
||||
this.capabilities = this.owner.lookup('service:capabilities');
|
||||
this.checkCachedPaths = (route) => this.capabilities.routePathCache.get(route);
|
||||
this.generateResponse = ({ path, paths, capabilities }) => {
|
||||
if (path) {
|
||||
// "capabilities" is an array
|
||||
|
|
@ -142,8 +143,9 @@ module('Unit | Service | capabilities', function (hooks) {
|
|||
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
|
||||
});
|
||||
|
||||
test('fetch: it tracks the requested paths', async function (assert) {
|
||||
test('fetch: it caches paths when routeForCache is provided', async function (assert) {
|
||||
const paths = ['/my/api/path', 'another/api/path'];
|
||||
const route = 'vault.cluster.some-route';
|
||||
|
||||
this.server.post('/sys/capabilities-self', () => {
|
||||
return this.generateResponse({
|
||||
|
|
@ -152,33 +154,110 @@ module('Unit | Service | capabilities', function (hooks) {
|
|||
});
|
||||
});
|
||||
|
||||
assert.strictEqual(this.capabilities.requestedPaths.size, 0, 'requestedPaths is empty before fetch');
|
||||
|
||||
await this.capabilities.fetch(paths);
|
||||
|
||||
assert.strictEqual(this.capabilities.requestedPaths.size, 2, 'requestedPaths contains 2 items');
|
||||
assert.true(this.capabilities.requestedPaths.has('/my/api/path'), 'contains first path');
|
||||
assert.true(this.capabilities.requestedPaths.has('another/api/path'), 'contains second path');
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 0, 'cache is empty before fetch');
|
||||
await this.capabilities.fetch(paths, { routeForCache: route });
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 1, 'cache contains 1 route');
|
||||
const cachedPaths = this.checkCachedPaths(route);
|
||||
assert.deepEqual(Array.from(cachedPaths), paths, 'cached paths match fetched paths');
|
||||
});
|
||||
|
||||
test('fetch: it replaces requestedPaths on each call', async function (assert) {
|
||||
const firstPaths = ['/path/one', '/path/two'];
|
||||
const secondPaths = ['/path/three'];
|
||||
|
||||
test('fetch: it does not cache when routeForCache is not provided', async function (assert) {
|
||||
const paths = ['/my/api/path'];
|
||||
this.server.post('/sys/capabilities-self', () => {
|
||||
return this.generateResponse({
|
||||
paths: firstPaths,
|
||||
capabilities: { '/path/one': ['read'], '/path/two': ['read'], '/path/three': ['read'] },
|
||||
});
|
||||
return this.generateResponse({ paths, capabilities: { '/my/api/path': ['read'] } });
|
||||
});
|
||||
await this.capabilities.fetch(paths);
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 0, 'cache remains empty');
|
||||
});
|
||||
|
||||
await this.capabilities.fetch(firstPaths);
|
||||
assert.strictEqual(this.capabilities.requestedPaths.size, 2, 'initially has 2 paths');
|
||||
test('cacheRoutePaths: it caches paths', async function (assert) {
|
||||
const route = 'vault.cluster.some-route';
|
||||
const apiPaths = ['/my/api/path', 'another/api/path'];
|
||||
|
||||
await this.capabilities.fetch(secondPaths);
|
||||
assert.strictEqual(this.capabilities.requestedPaths.size, 1, 'updated to have 1 path');
|
||||
assert.true(this.capabilities.requestedPaths.has('/path/three'), 'contains new path');
|
||||
assert.false(this.capabilities.requestedPaths.has('/path/one'), 'no longer contains old path');
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 0, 'routePathCache is empty before fetch');
|
||||
this.capabilities.cacheRoutePaths(route, apiPaths);
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 1, 'routePathCache contains 1 item');
|
||||
const cachedPaths = this.checkCachedPaths(route);
|
||||
assert.strictEqual(cachedPaths.size, 2, 'it stores 2 paths for the route');
|
||||
assert.true(cachedPaths.has('/my/api/path'), 'contains first path');
|
||||
assert.true(cachedPaths.has('another/api/path'), 'contains second path');
|
||||
});
|
||||
|
||||
test('cacheRoutePaths: it caches the paths for multiple routes', async function (assert) {
|
||||
const routeA = 'vault.cluster.route-A';
|
||||
const pathsA = ['route/A/path'];
|
||||
const routeB = 'vault.cluster.route-B';
|
||||
const pathsB = ['route/B/path'];
|
||||
|
||||
this.capabilities.cacheRoutePaths(routeA, pathsA);
|
||||
this.capabilities.cacheRoutePaths(routeB, pathsB);
|
||||
assert.strictEqual(this.capabilities.routePathCache.size, 2, 'contains two items');
|
||||
assert.true(this.checkCachedPaths(routeA).has(pathsA[0]), 'contains routeA path');
|
||||
assert.true(this.checkCachedPaths(routeB).has(pathsB[0]), 'contains routeB path');
|
||||
});
|
||||
|
||||
test('cacheRoutePaths: it replaces cached paths if called with the same route', async function (assert) {
|
||||
const route = 'vault.cluster.some-route';
|
||||
const firstCall = ['first/path'];
|
||||
|
||||
this.capabilities.cacheRoutePaths(route, firstCall);
|
||||
const initialCache = this.checkCachedPaths(route);
|
||||
assert.strictEqual(initialCache.size, 1, 'cached paths contains 1 path');
|
||||
assert.true(initialCache.has(firstCall[0]), 'contains initial path');
|
||||
|
||||
const secondCall = ['second/path'];
|
||||
this.capabilities.cacheRoutePaths(route, secondCall);
|
||||
const secondCache = this.checkCachedPaths(route);
|
||||
assert.strictEqual(secondCache.size, 1, 'second cache still contains 1 path');
|
||||
assert.true(secondCache.has(secondCall[0]), 'path is from second call');
|
||||
});
|
||||
|
||||
test('cacheRoutePaths: it deduplicates paths', async function (assert) {
|
||||
const route = 'vault.cluster.some-route';
|
||||
const paths = ['same/path', 'same/path', 'almost/same/path'];
|
||||
|
||||
this.capabilities.cacheRoutePaths(route, paths);
|
||||
const cachedPaths = this.checkCachedPaths(route);
|
||||
assert.strictEqual(cachedPaths.size, 2, 'routePathCache contains 2 items');
|
||||
assert.true(cachedPaths.has('same/path'), 'contains first path');
|
||||
assert.true(cachedPaths.has('almost/same/path'), 'contains second path');
|
||||
});
|
||||
|
||||
test('lookupRoutePaths: it returns exact route match', async function (assert) {
|
||||
const route = 'vault.cluster.some-route';
|
||||
const pathSet = new Set(['apps/super-secret']);
|
||||
this.capabilities.routePathCache.set(route, pathSet);
|
||||
|
||||
const returnedPaths = await this.capabilities.lookupRoutePaths(route);
|
||||
assert.deepEqual(returnedPaths, pathSet, 'returned paths equal original set');
|
||||
});
|
||||
|
||||
test('lookupRoutePaths: it returns longest matching route if multiple exist', async function (assert) {
|
||||
const ancestor1 = 'vault.cluster.secrets';
|
||||
const ancestor2 = 'vault.cluster.secrets.backend.kv';
|
||||
const ancestor3 = 'vault.cluster.secrets.backend.kv.secret';
|
||||
const pathSet1 = new Set(['apps/']);
|
||||
const pathSet2 = new Set(['apps/github-creds/']);
|
||||
const pathSet3 = new Set(['apps/github-creds/api-tokens']);
|
||||
this.capabilities.routePathCache.set(ancestor1, pathSet1);
|
||||
this.capabilities.routePathCache.set(ancestor2, pathSet2);
|
||||
this.capabilities.routePathCache.set(ancestor3, pathSet3);
|
||||
|
||||
const returnedPaths = await this.capabilities.lookupRoutePaths(
|
||||
'vault.cluster.secrets.backend.kv.secret.details'
|
||||
);
|
||||
assert.deepEqual(returnedPaths, pathSet3, 'it returns the paths that match the longest route');
|
||||
});
|
||||
|
||||
test('lookupRoutePaths: it returns null when no routes are cached', async function (assert) {
|
||||
const returnedPaths = await this.capabilities.lookupRoutePaths('vault.cluster.some-route');
|
||||
assert.strictEqual(returnedPaths, null, 'returns null when cache is empty');
|
||||
});
|
||||
|
||||
test('lookupRoutePaths: it returns null when no matching routes exist', async function (assert) {
|
||||
this.capabilities.routePathCache.set('vault.cluster.secrets', new Set(['secret/']));
|
||||
const returnedPaths = await this.capabilities.lookupRoutePaths('vault.cluster.settings.configure');
|
||||
assert.strictEqual(returnedPaths, null, 'returns null when no routes match');
|
||||
});
|
||||
|
||||
test('fetchPathCapabilities: it makes request to capabilities-self and returns capabilities for single path', async function (assert) {
|
||||
|
|
@ -221,6 +300,18 @@ module('Unit | Service | capabilities', function (hooks) {
|
|||
);
|
||||
});
|
||||
|
||||
test('pathsForList: it returns multiple PATH_MAP keys', async function (assert) {
|
||||
const keys = ['kubernetesRole', 'kubernetesCreds'];
|
||||
const params = { backend: 'k8', name: 'my-role' };
|
||||
const paths = this.capabilities.pathsForList(keys, params);
|
||||
assert.deepEqual(paths, ['k8/role/my-role', 'k8/creds/my-role'], 'computes all paths');
|
||||
});
|
||||
|
||||
test('pathsForList: it handles an empty array', async function (assert) {
|
||||
const paths = this.capabilities.pathsForList([], {});
|
||||
assert.deepEqual(paths, [], 'returns empty array');
|
||||
});
|
||||
|
||||
test('for: it should fetch capabilities for single path using pathFor and fetchPathCapabilities methods', async function (assert) {
|
||||
const pathForStub = sinon.spy(this.capabilities, 'pathFor');
|
||||
const fetchPathCapabilitiesStub = sinon.stub(this.capabilities, 'fetchPathCapabilities').resolves();
|
||||
|
|
|
|||
Loading…
Reference in a new issue