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 { assert } from '@ember/debug';
import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path';
import type AdapterError from '@ember-data/adapter/error';
import type CapabilitiesModel from 'vault/vault/models/capabilities';
import type Store from '@ember-data/store';
import type ApiService from 'vault/services/api';
import type NamespaceService from 'vault/services/namespace';
interface Capabilities {
canCreate: boolean;
@ -24,36 +23,91 @@ interface MultipleCapabilities {
[key: string]: Capabilities;
}
export default class CapabilitiesService extends Service {
@service declare readonly store: Store;
type CapabilityTypes = 'root' | 'sudo' | 'deny' | 'create' | 'read' | 'update' | 'delete' | 'list' | 'patch';
interface CapabilitiesData {
[key: string]: CapabilityTypes[];
}
async request(query: { paths?: string[]; path?: string }) {
if (query?.paths) {
const { paths } = query;
return this.store.query('capabilities', { paths });
}
if (query?.path) {
const { path } = query;
const storeData = await this.store.peekRecord('capabilities', path);
return storeData ? storeData : this.store.findRecord('capabilities', path);
}
return assert('query object must contain "paths" or "path" key', false);
export default class CapabilitiesService extends Service {
@service declare readonly api: ApiService;
@service declare readonly namespace: NamespaceService;
SUDO_PATHS = [
'sys/seal',
'sys/replication/performance/primary/secondary-token',
'sys/replication/dr/primary/secondary-token',
'sys/replication/reindex',
'sys/leases/lookup/',
];
SUDO_PATH_PREFIXES = ['sys/leases/revoke-prefix', 'sys/leases/revoke-force'];
/*
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> {
// if the request to capabilities-self fails, silently catch
// all of path capabilities default to "true"
const resp: CapabilitiesModel[] = await this.request({ paths }).catch(() => []);
return paths.reduce((obj: MultipleCapabilities, apiPath: string) => {
// path is the model's primaryKey (id)
const model = resp.find((m) => m.path === apiPath);
if (model) {
const { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate } = model;
obj[apiPath] = { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate };
} else {
// default to true if there is a problem fetching the model
// since we can rely on the API to gate as a fallback
obj[apiPath] = {
// map capabilities to friendly names like canRead, canUpdate, etc.
mapCapabilities(relativeNamespacePaths: string[], capabilitiesData: CapabilitiesData) {
const { SUDO_PATHS, SUDO_PATH_PREFIXES } = this;
const { relativeNamespace } = this.namespace;
// request may not return capabilities for all provided paths
// loop provided paths and map capabilities, defaulting to true for missing paths
return relativeNamespacePaths.reduce((mappedCapabilities: MultipleCapabilities, path) => {
const capabilities = capabilitiesData[path];
const getCapability = (capability: CapabilityTypes) => {
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
// we can rely on the API to gate as a fallback
return paths.reduce((obj: MultipleCapabilities, path: string) => {
obj[path] = {
canCreate: true,
canDelete: true,
canList: true,
@ -62,59 +116,37 @@ export default class CapabilitiesService extends Service {
canSudo: true,
canUpdate: true,
};
}
return obj;
}, {});
return obj;
}, {});
}
}
/*
this method returns all of the capabilities for a singular path
*/
fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError {
try {
return this.request({ path });
} catch (error) {
return error as AdapterError;
}
async fetchPathCapabilities(path: string) {
const capabilities = await this.fetch([path]);
return capabilities[path];
}
/*
internal method for specific capability checks below
checks the capability model for the passed capability, ie "canRead"
*/
async _fetchSpecificCapability(
path: string,
capability: string
): Promise<CapabilitiesModel | AdapterError> {
try {
const capabilities = await this.request({ path });
return capabilities[capability];
} catch (e) {
return e as AdapterError;
}
async _fetchSpecificCapability(path: string, capability: keyof Capabilities) {
const capabilities = await this.fetchPathCapabilities(path);
return capabilities ? capabilities[capability] : true;
}
canRead(path: string) {
try {
return this._fetchSpecificCapability(path, 'canRead');
} catch (e) {
return e;
}
return this._fetchSpecificCapability(path, 'canRead');
}
canUpdate(path: string) {
try {
return this._fetchSpecificCapability(path, 'canUpdate');
} catch (e) {
return e;
}
return this._fetchSpecificCapability(path, 'canUpdate');
}
canPatch(path: string) {
try {
return this._fetchSpecificCapability(path, 'canPatch');
} catch (e) {
return e;
}
return this._fetchSpecificCapability(path, 'canPatch');
}
}

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**
- **[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
@ -218,14 +218,14 @@ async getExportCapabilities(ns = '') {
```
**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
async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${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
return {
metadata: perms[metadataPath],

View File

@ -54,7 +54,7 @@ export default class KvSecretRoute extends Route {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${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 {
metadata: perms[metadataPath],
data: perms[dataPath],

View File

@ -17,7 +17,7 @@ export default Route.extend(ClusterRoute, {
async fetchCapabilities() {
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('performance', 'secondary'),

View File

@ -13,7 +13,6 @@ module('Unit | Service | capabilities', function (hooks) {
hooks.beforeEach(function () {
this.capabilities = this.owner.lookup('service:capabilities');
this.store = this.owner.lookup('service:store');
this.generateResponse = ({ path, paths, capabilities }) => {
if (path) {
// "capabilities" is an array
@ -39,50 +38,10 @@ module('Unit | Service | capabilities', function (hooks) {
};
});
module('general methods', function () {
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 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) {
test('fetch: it makes request to capabilities-self', async function (assert) {
const paths = ['/my/api/path', 'another/api/path'];
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');
@ -92,7 +51,8 @@ module('Unit | Service | capabilities', function (hooks) {
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 = {
'/my/api/path': {
canCreate: false,
@ -116,10 +76,10 @@ module('Unit | Service | capabilities', function (hooks) {
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
const paths = ['/my/api/path', 'another/api/path'];
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = {
'/my/api/path': {
canCreate: true,
@ -143,9 +103,10 @@ module('Unit | Service | capabilities', function (hooks) {
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 expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
@ -155,7 +116,8 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: { '/my/api/path': ['read', 'list'] },
});
});
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = {
'/my/api/path': {
canCreate: false,
@ -179,6 +141,30 @@ module('Unit | Service | capabilities', function (hooks) {
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 () {
const path = '/my/api/path';
[
@ -246,15 +232,12 @@ module('Unit | Service | capabilities', function (hooks) {
hooks.beforeEach(function () {
this.nsSvc = this.owner.lookup('service:namespace');
this.nsSvc.path = 'ns1';
this.store.unloadAll('capabilities');
});
test('fetchPathCapabilities works as expected', async function (assert) {
const ns = this.nsSvc.path;
const path = '/my/api/path';
const expectedAttrs = {
// capabilities has ID at non-namespaced path
id: path,
canCreate: false,
canDelete: false,
canList: true,
@ -263,6 +246,7 @@ module('Unit | Service | capabilities', function (hooks) {
canSudo: false,
canUpdate: false,
};
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
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'],
});
});
const actual = await this.capabilities.fetchPathCapabilities(path);
assert.strictEqual(this.store.peekAll('capabilities').length, 1, 'adds 1 record');
Object.keys(expectedAttrs).forEach(function (key) {
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 paths = ['/my/api/path', '/another/api/path'];
const expectedPayload = paths.map((p) => `${ns}${p}`);
@ -306,7 +290,8 @@ module('Unit | Service | capabilities', function (hooks) {
});
return resp;
});
const actual = await this.capabilities.fetchMultiplePaths(paths);
const actual = await this.capabilities.fetch(paths);
const expected = {
'/my/api/path': {
canCreate: false,
@ -327,19 +312,7 @@ module('Unit | Service | capabilities', function (hooks) {
canUpdate: true,
},
};
assert.deepEqual(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]}`
);
});
});
assert.propEqual(actual, expected, 'method returns expected response');
});
});
});