mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-06 06:37:02 +02:00
update capabilities service to use api service (#30222)
This commit is contained in:
parent
4f661c67c1
commit
b959cab17b
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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],
|
||||
|
@ -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],
|
||||
|
@ -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'),
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user