diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts index 4dfbc165d0..de4d0520ff 100644 --- a/ui/app/services/capabilities.ts +++ b/ui/app/services/capabilities.ts @@ -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 { - // 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 { + 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 | 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 { - 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'); } } diff --git a/ui/docs/models.md b/ui/docs/models.md index e6c94f74be..1c5b75fca5 100644 --- a/ui/docs/models.md +++ b/ui/docs/models.md @@ -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], diff --git a/ui/lib/kv/addon/routes/secret.js b/ui/lib/kv/addon/routes/secret.js index 8b745820c8..43ff63a01f 100644 --- a/ui/lib/kv/addon/routes/secret.js +++ b/ui/lib/kv/addon/routes/secret.js @@ -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], diff --git a/ui/lib/replication/addon/routes/application.js b/ui/lib/replication/addon/routes/application.js index 58569ad3b2..3ca635b98b 100644 --- a/ui/lib/replication/addon/routes/application.js +++ b/ui/lib/replication/addon/routes/application.js @@ -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'), diff --git a/ui/tests/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js index 0964db1d43..ac3f93fd2c 100644 --- a/ui/tests/unit/services/capabilities-test.js +++ b/ui/tests/unit/services/capabilities-test.js @@ -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'); }); }); });