update capabilities service to use api service (#30222)

This commit is contained in:
Jordan Reimer 2025-04-15 09:35:05 -06:00 committed by GitHub
parent 4f661c67c1
commit b959cab17b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 142 additions and 137 deletions

View File

@ -4,11 +4,10 @@
*/ */
import Service, { service } from '@ember/service'; import Service, { service } from '@ember/service';
import { assert } from '@ember/debug'; import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path';
import type AdapterError from '@ember-data/adapter/error'; import type ApiService from 'vault/services/api';
import type CapabilitiesModel from 'vault/vault/models/capabilities'; import type NamespaceService from 'vault/services/namespace';
import type Store from '@ember-data/store';
interface Capabilities { interface Capabilities {
canCreate: boolean; canCreate: boolean;
@ -24,36 +23,91 @@ interface MultipleCapabilities {
[key: string]: Capabilities; [key: string]: Capabilities;
} }
type CapabilityTypes = 'root' | 'sudo' | 'deny' | 'create' | 'read' | 'update' | 'delete' | 'list' | 'patch';
interface CapabilitiesData {
[key: string]: CapabilityTypes[];
}
export default class CapabilitiesService extends Service { export default class CapabilitiesService extends Service {
@service declare readonly store: Store; @service declare readonly api: ApiService;
@service declare readonly namespace: NamespaceService;
async request(query: { paths?: string[]; path?: string }) { SUDO_PATHS = [
if (query?.paths) { 'sys/seal',
const { paths } = query; 'sys/replication/performance/primary/secondary-token',
return this.store.query('capabilities', { paths }); 'sys/replication/dr/primary/secondary-token',
} 'sys/replication/reindex',
if (query?.path) { 'sys/leases/lookup/',
const { path } = query; ];
const storeData = await this.store.peekRecord('capabilities', path); SUDO_PATH_PREFIXES = ['sys/leases/revoke-prefix', 'sys/leases/revoke-force'];
return storeData ? storeData : this.store.findRecord('capabilities', path);
} /*
return assert('query object must contain "paths" or "path" key', false); 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.
The "relativeNamespace" refers to the namespace the user is currently in and attempting to access capabilities for.
Prepending "relativeNamespace" to the path while making the request to the "userRootNamespace"
ensures we are querying capabilities-self where the user is most likely to have their policy/permissions.
*/
relativeNamespacePaths(paths: string[]) {
const { relativeNamespace } = this.namespace;
// sanitizeStart ensures original path doesn't have leading slash
return paths.map((path) => (relativeNamespace ? `${relativeNamespace}/${sanitizeStart(path)}` : path));
} }
async fetchMultiplePaths(paths: string[]): Promise<MultipleCapabilities> { // map capabilities to friendly names like canRead, canUpdate, etc.
// if the request to capabilities-self fails, silently catch mapCapabilities(relativeNamespacePaths: string[], capabilitiesData: CapabilitiesData) {
// all of path capabilities default to "true" const { SUDO_PATHS, SUDO_PATH_PREFIXES } = this;
const resp: CapabilitiesModel[] = await this.request({ paths }).catch(() => []); const { relativeNamespace } = this.namespace;
return paths.reduce((obj: MultipleCapabilities, apiPath: string) => { // request may not return capabilities for all provided paths
// path is the model's primaryKey (id) // loop provided paths and map capabilities, defaulting to true for missing paths
const model = resp.find((m) => m.path === apiPath); return relativeNamespacePaths.reduce((mappedCapabilities: MultipleCapabilities, path) => {
if (model) { const capabilities = capabilitiesData[path];
const { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate } = model;
obj[apiPath] = { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate }; const getCapability = (capability: CapabilityTypes) => {
} else { if (!(path in capabilitiesData)) {
return true;
}
if (!capabilities?.length || capabilities.includes('deny')) {
return false;
}
if (capabilities.includes('root')) {
return true;
}
// if the path is sudo protected, they'll need sudo + the appropriate capability
if (SUDO_PATHS.includes(path) || SUDO_PATH_PREFIXES.find((item) => path.startsWith(item))) {
return capabilities.includes('sudo') && capabilities.includes(capability);
}
return capabilities.includes(capability);
};
// remove relativeNamespace from the path that was added for the request
const key = path.replace(relativeNamespace, '');
mappedCapabilities[key] = {
canCreate: getCapability('create'),
canDelete: getCapability('delete'),
canList: getCapability('list'),
canPatch: getCapability('patch'),
canRead: getCapability('read'),
canSudo: getCapability('sudo'),
canUpdate: getCapability('update'),
};
return mappedCapabilities;
}, {});
}
async fetch(paths: string[]): Promise<MultipleCapabilities> {
const payload = {
paths: this.relativeNamespacePaths(paths),
namespace: sanitizePath(this.namespace.userRootNamespace),
};
try {
const { data } = await this.api.sys.queryTokenSelfCapabilities(payload);
return this.mapCapabilities(payload.paths, data as CapabilitiesData);
} catch (e) {
// default to true if there is a problem fetching the model // default to true if there is a problem fetching the model
// since we can rely on the API to gate as a fallback // we can rely on the API to gate as a fallback
obj[apiPath] = { return paths.reduce((obj: MultipleCapabilities, path: string) => {
obj[path] = {
canCreate: true, canCreate: true,
canDelete: true, canDelete: true,
canList: true, canList: true,
@ -62,59 +116,37 @@ export default class CapabilitiesService extends Service {
canSudo: true, canSudo: true,
canUpdate: true, canUpdate: true,
}; };
}
return obj; return obj;
}, {}); }, {});
} }
}
/* /*
this method returns all of the capabilities for a singular path this method returns all of the capabilities for a singular path
*/ */
fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError { async fetchPathCapabilities(path: string) {
try { const capabilities = await this.fetch([path]);
return this.request({ path }); return capabilities[path];
} catch (error) {
return error as AdapterError;
}
} }
/* /*
internal method for specific capability checks below internal method for specific capability checks below
checks the capability model for the passed capability, ie "canRead" checks the capability model for the passed capability, ie "canRead"
*/ */
async _fetchSpecificCapability( async _fetchSpecificCapability(path: string, capability: keyof Capabilities) {
path: string, const capabilities = await this.fetchPathCapabilities(path);
capability: string return capabilities ? capabilities[capability] : true;
): Promise<CapabilitiesModel | AdapterError> {
try {
const capabilities = await this.request({ path });
return capabilities[capability];
} catch (e) {
return e as AdapterError;
}
} }
canRead(path: string) { canRead(path: string) {
try {
return this._fetchSpecificCapability(path, 'canRead'); return this._fetchSpecificCapability(path, 'canRead');
} catch (e) {
return e;
}
} }
canUpdate(path: string) { canUpdate(path: string) {
try {
return this._fetchSpecificCapability(path, 'canUpdate'); return this._fetchSpecificCapability(path, 'canUpdate');
} catch (e) {
return e;
}
} }
canPatch(path: string) { canPatch(path: string) {
try {
return this._fetchSpecificCapability(path, 'canPatch'); return this._fetchSpecificCapability(path, 'canPatch');
} catch (e) {
return e;
}
} }
} }

View File

@ -35,7 +35,7 @@ Other patterns and where they belong in relation to the Model:
- **Validations** - While an argument can go either way about this one, we are going to continue defining these on the Model using our handy [withModelValidation decorator](#withmodelvalidations). The state of validation is directly correlated to a given Record which is a strong reason to keep it on the Model. **TL;DR: Lives on Model** - **Validations** - While an argument can go either way about this one, we are going to continue defining these on the Model using our handy [withModelValidation decorator](#withmodelvalidations). The state of validation is directly correlated to a given Record which is a strong reason to keep it on the Model. **TL;DR: Lives on Model**
- **[Capabilities](#capabilities)** - Capabilities are calculated by fetching permissions by path -- often multiple paths, based on the same information we need to fetch the Record data (eg. backend, ID). When using `lazyCapabilities` on the model we kick off one API request for each of the paths we need, while using the capabilities service `fetchMultiplePaths` method we can make one request with all the required paths included. Our best practice is to fetch capabilities outside of the Model (perhaps as part of a route model, or on a user action such as dropdown click open). A downside to this approach is that the API may get re-requested per page we check the capability (eg. list view dropdown and then detail view) -- but we can optimize this in the future by first checking the store for `capabilities` of matching path/ID before sending the API request. **TL;DR: Lives in route or component where they are used** - **[Capabilities](#capabilities)** - Capabilities are calculated by fetching permissions by path -- often multiple paths, based on the same information we need to fetch the Record data (eg. backend, ID). When using `lazyCapabilities` on the model we kick off one API request for each of the paths we need, while using the capabilities service `fetch` method we can make one request with all the required paths included. Our best practice is to fetch capabilities outside of the Model (perhaps as part of a route model, or on a user action such as dropdown click open). A downside to this approach is that the API may get re-requested per page we check the capability (eg. list view dropdown and then detail view) -- but we can optimize this in the future by first checking the store for `capabilities` of matching path/ID before sending the API request. **TL;DR: Lives in route or component where they are used**
## Patterns ## Patterns
@ -218,14 +218,14 @@ async getExportCapabilities(ns = '') {
``` ```
**Multiple capabilities checked at once** **Multiple capabilities checked at once**
When there are multiple capabilities paths to check, the recommended approach is to use the [capabilities service's](../app/services/capabilities.ts) `fetchMultiplePaths` method. It will pass all the paths in a single API request instead of making a capabilities-self call for each path as the other techniques do. In [this example](../lib/kv/addon/routes/secret.js), we get the capabilities as part of the route's model hook and then return the relevant `can*` values: When there are multiple capabilities paths to check, the recommended approach is to use the [capabilities service's](../app/services/capabilities.ts) `fetch` method. It will pass all the paths in a single API request instead of making a capabilities-self call for each path as the other techniques do. In [this example](../lib/kv/addon/routes/secret.js), we get the capabilities as part of the route's model hook and then return the relevant `can*` values:
```js ```js
async fetchCapabilities(backend, path) { async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`; const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`; const dataPath = `${backend}/data/${path}`;
const subkeysPath = `${backend}/subkeys/${path}`; const subkeysPath = `${backend}/subkeys/${path}`;
const perms = await this.capabilities.fetchMultiplePaths([metadataPath, dataPath, subkeysPath]); const perms = await this.capabilities.fetch([metadataPath, dataPath, subkeysPath]);
// returns values keyed at the path // returns values keyed at the path
return { return {
metadata: perms[metadataPath], metadata: perms[metadataPath],

View File

@ -54,7 +54,7 @@ export default class KvSecretRoute extends Route {
const metadataPath = `${backend}/metadata/${path}`; const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`; const dataPath = `${backend}/data/${path}`;
const subkeysPath = `${backend}/subkeys/${path}`; const subkeysPath = `${backend}/subkeys/${path}`;
const perms = await this.capabilities.fetchMultiplePaths([metadataPath, dataPath, subkeysPath]); const perms = await this.capabilities.fetch([metadataPath, dataPath, subkeysPath]);
return { return {
metadata: perms[metadataPath], metadata: perms[metadataPath],
data: perms[dataPath], data: perms[dataPath],

View File

@ -17,7 +17,7 @@ export default Route.extend(ClusterRoute, {
async fetchCapabilities() { async fetchCapabilities() {
const enablePath = (type, cluster) => `sys/replication/${type}/${cluster}/enable`; const enablePath = (type, cluster) => `sys/replication/${type}/${cluster}/enable`;
const perms = await this.capabilities.fetchMultiplePaths([ const perms = await this.capabilities.fetch([
enablePath('dr', 'primary'), enablePath('dr', 'primary'),
enablePath('dr', 'primary'), enablePath('dr', 'primary'),
enablePath('performance', 'secondary'), enablePath('performance', 'secondary'),

View File

@ -13,7 +13,6 @@ module('Unit | Service | capabilities', function (hooks) {
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.capabilities = this.owner.lookup('service:capabilities'); this.capabilities = this.owner.lookup('service:capabilities');
this.store = this.owner.lookup('service:store');
this.generateResponse = ({ path, paths, capabilities }) => { this.generateResponse = ({ path, paths, capabilities }) => {
if (path) { if (path) {
// "capabilities" is an array // "capabilities" is an array
@ -39,50 +38,10 @@ module('Unit | Service | capabilities', function (hooks) {
}; };
}); });
module('general methods', function () { test('fetch: it makes request to capabilities-self', async function (assert) {
test('request: it makes request to capabilities-self with path param', function (assert) {
const path = '/my/api/path';
const expectedPayload = { paths: [path] };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({ path, capabilities: ['read'] });
});
this.capabilities.request({ path });
});
test('request: it makes request to capabilities-self with paths param', function (assert) {
const paths = ['/my/api/path', 'another/api/path']; const paths = ['/my/api/path', 'another/api/path'];
const expectedPayload = { paths }; const expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({
paths,
capabilities: { '/my/api/path': ['read'], 'another/api/path': ['read'] },
});
});
this.capabilities.request({ paths });
});
});
test('fetchPathCapabilities: it makes request to capabilities-self with path param', function (assert) {
const path = '/my/api/path';
const expectedPayload = { paths: [path] };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({ path, capabilities: ['read'] });
});
this.capabilities.fetchPathCapabilities(path);
});
test('fetchMultiplePaths: it makes request to capabilities-self with paths param', async function (assert) {
const paths = ['/my/api/path', 'another/api/path'];
const expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => { this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody); const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self'); assert.true(true, 'request made to capabilities-self');
@ -92,7 +51,8 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: { '/my/api/path': ['read', 'list'], 'another/api/path': ['read', 'delete'] }, capabilities: { '/my/api/path': ['read', 'list'], 'another/api/path': ['read', 'delete'] },
}); });
}); });
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = { const expected = {
'/my/api/path': { '/my/api/path': {
canCreate: false, canCreate: false,
@ -116,10 +76,10 @@ module('Unit | Service | capabilities', function (hooks) {
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
}); });
test('fetchMultiplePaths: it defaults to true if the capabilities request fails', async function (assert) { test('fetch: it defaults to true if the capabilities request fails', async function (assert) {
// don't stub endpoint which causes request to fail // don't stub endpoint which causes request to fail
const paths = ['/my/api/path', 'another/api/path']; const paths = ['/my/api/path', 'another/api/path'];
const actual = await this.capabilities.fetchMultiplePaths(paths); const actual = await this.capabilities.fetch(paths);
const expected = { const expected = {
'/my/api/path': { '/my/api/path': {
canCreate: true, canCreate: true,
@ -143,9 +103,10 @@ module('Unit | Service | capabilities', function (hooks) {
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
}); });
test('fetchMultiplePaths: it defaults to true if no model is found', async function (assert) { test('fetch: it defaults to true if no model is found', async function (assert) {
const paths = ['/my/api/path', 'another/api/path']; const paths = ['/my/api/path', 'another/api/path'];
const expectedPayload = { paths }; const expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => { this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody); const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self'); assert.true(true, 'request made to capabilities-self');
@ -155,7 +116,8 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: { '/my/api/path': ['read', 'list'] }, capabilities: { '/my/api/path': ['read', 'list'] },
}); });
}); });
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = { const expected = {
'/my/api/path': { '/my/api/path': {
canCreate: false, canCreate: false,
@ -179,6 +141,30 @@ module('Unit | Service | capabilities', function (hooks) {
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
}); });
test('fetchPathCapabilities: it makes request to capabilities-self and returns capabilities for single path', async function (assert) {
const path = '/my/api/path';
const expectedPayload = { paths: [path] };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({ path, capabilities: ['read'] });
});
const actual = await this.capabilities.fetchPathCapabilities(path);
const expected = {
canCreate: false,
canDelete: false,
canList: false,
canPatch: false,
canRead: true,
canSudo: false,
canUpdate: false,
};
assert.propEqual(actual, expected, 'returns capabilities for provided path');
});
module('specific methods', function () { module('specific methods', function () {
const path = '/my/api/path'; const path = '/my/api/path';
[ [
@ -246,15 +232,12 @@ module('Unit | Service | capabilities', function (hooks) {
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.nsSvc = this.owner.lookup('service:namespace'); this.nsSvc = this.owner.lookup('service:namespace');
this.nsSvc.path = 'ns1'; this.nsSvc.path = 'ns1';
this.store.unloadAll('capabilities');
}); });
test('fetchPathCapabilities works as expected', async function (assert) { test('fetchPathCapabilities works as expected', async function (assert) {
const ns = this.nsSvc.path; const ns = this.nsSvc.path;
const path = '/my/api/path'; const path = '/my/api/path';
const expectedAttrs = { const expectedAttrs = {
// capabilities has ID at non-namespaced path
id: path,
canCreate: false, canCreate: false,
canDelete: false, canDelete: false,
canList: true, canList: true,
@ -263,6 +246,7 @@ module('Unit | Service | capabilities', function (hooks) {
canSudo: false, canSudo: false,
canUpdate: false, canUpdate: false,
}; };
this.server.post('/sys/capabilities-self', (schema, req) => { this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody); const actual = JSON.parse(req.requestBody);
assert.strictEqual(req.url, '/v1/sys/capabilities-self', 'request made to capabilities-self'); assert.strictEqual(req.url, '/v1/sys/capabilities-self', 'request made to capabilities-self');
@ -276,8 +260,8 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: ['read', 'list'], capabilities: ['read', 'list'],
}); });
}); });
const actual = await this.capabilities.fetchPathCapabilities(path); const actual = await this.capabilities.fetchPathCapabilities(path);
assert.strictEqual(this.store.peekAll('capabilities').length, 1, 'adds 1 record');
Object.keys(expectedAttrs).forEach(function (key) { Object.keys(expectedAttrs).forEach(function (key) {
assert.strictEqual( assert.strictEqual(
@ -288,7 +272,7 @@ module('Unit | Service | capabilities', function (hooks) {
}); });
}); });
test('fetchMultiplePaths works as expected', async function (assert) { test('fetch works as expected', async function (assert) {
const ns = this.nsSvc.path; const ns = this.nsSvc.path;
const paths = ['/my/api/path', '/another/api/path']; const paths = ['/my/api/path', '/another/api/path'];
const expectedPayload = paths.map((p) => `${ns}${p}`); const expectedPayload = paths.map((p) => `${ns}${p}`);
@ -306,7 +290,8 @@ module('Unit | Service | capabilities', function (hooks) {
}); });
return resp; return resp;
}); });
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = { const expected = {
'/my/api/path': { '/my/api/path': {
canCreate: false, canCreate: false,
@ -327,19 +312,7 @@ module('Unit | Service | capabilities', function (hooks) {
canUpdate: true, canUpdate: true,
}, },
}; };
assert.deepEqual(actual, expected, 'method returns expected response'); assert.propEqual(actual, expected, 'method returns expected response');
assert.strictEqual(this.store.peekAll('capabilities').length, 2, 'adds 2 records');
Object.keys(expected).forEach((path) => {
const record = this.store.peekRecord('capabilities', path);
assert.strictEqual(record.id, path, `record exists with id: ${record.id}`);
Object.keys(expected[path]).forEach((attr) => {
assert.strictEqual(
record[attr],
expected[path][attr],
`record has correct value for ${attr}: ${record[attr]}`
);
});
});
}); });
}); });
}); });