From 09c92b89cc94acf47afc6d1419cab266472ddf39 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:17:19 -0700 Subject: [PATCH] UI: Add capabilities service (#28168) * add capabilities service * remove from kv engine for now * add canRead * move await helper to addon * add test * update capabilities service to accommodate multiple paths * address comments, make methods more explicit * remove namespace key * fix typo in test * add namespace back! * round out tests for other methods * add test * add comment --- ui/app/adapters/capabilities.js | 40 +++-- ui/app/serializers/capabilities.js | 16 +- ui/app/services/capabilities.ts | 87 ++++++++++ ui/app/services/flags.ts | 2 +- ui/{app => lib/core/addon}/helpers/await.js | 0 ui/lib/core/app/helpers/await.js | 6 + ui/tests/unit/adapters/capabilities-test.js | 172 ++++++++++++++------ ui/tests/unit/services/capabilities-test.js | 140 ++++++++++++++++ ui/types/vault/services/store.d.ts | 7 +- 9 files changed, 404 insertions(+), 66 deletions(-) create mode 100644 ui/app/services/capabilities.ts rename ui/{app => lib/core/addon}/helpers/await.js (100%) create mode 100644 ui/lib/core/app/helpers/await.js create mode 100644 ui/tests/unit/services/capabilities-test.js diff --git a/ui/app/adapters/capabilities.js b/ui/app/adapters/capabilities.js index 2d8a7c2e478a..b9c56ad5f4b0 100644 --- a/ui/app/adapters/capabilities.js +++ b/ui/app/adapters/capabilities.js @@ -8,22 +8,29 @@ import { set } from '@ember/object'; import ApplicationAdapter from './application'; import { sanitizePath } from 'core/utils/sanitize-path'; -export default ApplicationAdapter.extend({ +export default class CapabilitiesAdapter extends ApplicationAdapter { pathForType() { return 'capabilities-self'; - }, + } - formatPaths(path) { + /* + users don't always have access to the capabilities-self endpoint, + this can happen when logging in to a namespace and then navigating to a child namespace. + adding "relativeNamespace" to the path and/or "this.namespaceService.userRootNamespace" + to the request header ensures we are querying capabilities-self in the user's root namespace, + which is where they are most likely to have their policy/permissions. + */ + _formatPath(path) { const { relativeNamespace } = this.namespaceService; if (!relativeNamespace) { - return [path]; + return path; } // ensure original path doesn't have leading slash - return [`${relativeNamespace}/${path.replace(/^\//, '')}`]; - }, + return `${relativeNamespace}/${path.replace(/^\//, '')}`; + } async findRecord(store, type, id) { - const paths = this.formatPaths(id); + const paths = [this._formatPath(id)]; return this.ajax(this.buildURL(type), 'POST', { data: { paths }, namespace: sanitizePath(this.namespaceService.userRootNamespace), @@ -33,7 +40,7 @@ export default ApplicationAdapter.extend({ } throw e; }); - }, + } queryRecord(store, type, query) { const { id } = query; @@ -44,5 +51,18 @@ export default ApplicationAdapter.extend({ resp.path = id; return resp; }); - }, -}); + } + + query(store, type, query) { + const paths = query?.paths.map((p) => this._formatPath(p)); + return this.ajax(this.buildURL(type), 'POST', { + data: { paths }, + namespace: sanitizePath(this.namespaceService.userRootNamespace), + }).catch((e) => { + if (e instanceof AdapterError) { + set(e, 'policyPath', 'sys/capabilities-self'); + } + throw e; + }); + } +} diff --git a/ui/app/serializers/capabilities.js b/ui/app/serializers/capabilities.js index 18b2820cb63e..dd53d9c0a21b 100644 --- a/ui/app/serializers/capabilities.js +++ b/ui/app/serializers/capabilities.js @@ -9,14 +9,16 @@ export default ApplicationSerializer.extend({ primaryKey: 'path', normalizeResponse(store, primaryModelClass, payload, id, requestType) { - // queryRecord will already have set this, and we won't have an id here - if (id) { - payload.path = id; + let response; + // queryRecord will already have set path, and we won't have an id here + if (id) payload.path = id; + + if (requestType === 'query') { + // each key on the response is a path with an array of capabilities as its value + response = Object.keys(payload.data).map((path) => ({ capabilities: payload.data[path], path })); + } else { + response = { ...payload.data, path: payload.path }; } - const response = { - ...payload.data, - path: payload.path, - }; return this._super(store, primaryModelClass, response, id, requestType); }, diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts new file mode 100644 index 000000000000..1fb3def51298 --- /dev/null +++ b/ui/app/services/capabilities.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service, { service } from '@ember/service'; +import { assert } from '@ember/debug'; + +import type AdapterError from '@ember-data/adapter/error'; +import type CapabilitiesModel from 'vault/vault/models/capabilities'; +import type StoreService from 'vault/services/store'; + +interface Query { + paths?: string[]; + path?: string; +} + +export default class CapabilitiesService extends Service { + @service declare readonly store: StoreService; + + async request(query: Query) { + 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); + } + + /* + this method returns a capabilities model for each path in the array of paths + */ + async fetchMultiplePaths(paths: string[]): Promise> | AdapterError { + try { + return await this.request({ paths }); + } catch (e) { + return e; + } + } + + /* + this method returns all of the capabilities for a singular path + */ + async fetchPathCapabilities(path: string): Promise | AdapterError { + try { + return await this.request({ path }); + } catch (error) { + return error; + } + } + + /* + internal method for specific capability checks below + checks the capability model for the passed capability, ie "canRead" + */ + async _fetchSpecificCapability( + path: string, + capability: string + ): Promise | AdapterError { + try { + const capabilities = await this.request({ path }); + return capabilities[capability]; + } catch (e) { + return e; + } + } + + async canRead(path: string) { + try { + return await this._fetchSpecificCapability(path, 'canRead'); + } catch (e) { + return e; + } + } + + async canUpdate(path: string) { + try { + return await this._fetchSpecificCapability(path, 'canUpdate'); + } catch (e) { + return e; + } + } +} diff --git a/ui/app/services/flags.ts b/ui/app/services/flags.ts index ccacb50d9ed3..124815eae22d 100644 --- a/ui/app/services/flags.ts +++ b/ui/app/services/flags.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Service, { inject as service } from '@ember/service'; +import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { keepLatestTask } from 'ember-concurrency'; import { DEBUG } from '@glimmer/env'; diff --git a/ui/app/helpers/await.js b/ui/lib/core/addon/helpers/await.js similarity index 100% rename from ui/app/helpers/await.js rename to ui/lib/core/addon/helpers/await.js diff --git a/ui/lib/core/app/helpers/await.js b/ui/lib/core/app/helpers/await.js new file mode 100644 index 000000000000..160809893e7e --- /dev/null +++ b/ui/lib/core/app/helpers/await.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/helpers/await'; diff --git a/ui/tests/unit/adapters/capabilities-test.js b/ui/tests/unit/adapters/capabilities-test.js index 0cd83a000796..86bb1feed7c2 100644 --- a/ui/tests/unit/adapters/capabilities-test.js +++ b/ui/tests/unit/adapters/capabilities-test.js @@ -10,61 +10,141 @@ import { setupTest } from 'ember-qunit'; module('Unit | Adapter | capabilities', function (hooks) { setupTest(hooks); - test('calls the correct url', function (assert) { - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + module('findRecord', function () { + test('it calls the correct url', function (assert) { + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['foo'] }, 'data params OK'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['foo'] }, options.data, 'data params OK'); - assert.strictEqual(method, 'POST', 'method OK'); - }); + test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.setNamespace('admin'); - test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { - const namespaceSvc = this.owner.lookup('service:namespace'); - namespaceSvc.setNamespace('admin'); + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['admin/foo'] }, 'data params prefix paths with namespace'); + assert.strictEqual(options.namespace, '', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['admin/foo'] }, options.data, 'data params prefix paths with namespace'); - assert.strictEqual(options.namespace, '', 'sent with root namespace'); - assert.strictEqual(method, 'POST', 'method OK'); + test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + const auth = this.owner.lookup('service:auth'); + namespaceSvc.setNamespace('admin/bar/baz'); + // Set user root namespace + auth.setCluster('1'); + auth.set('tokens', ['vault-_root_☃1']); + auth.setTokenData('vault-_root_☃1', { + userRootNamespace: 'admin/bar', + backend: { mountPath: 'token' }, + }); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['baz/foo'] }, + 'data params prefix path with relative namespace' + ); + assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); }); - test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { - const namespaceSvc = this.owner.lookup('service:namespace'); - const auth = this.owner.lookup('service:auth'); - namespaceSvc.setNamespace('admin/bar/baz'); - // Set user root namespace - auth.setCluster('1'); - auth.set('tokens', ['vault-_root_☃1']); - auth.setTokenData('vault-_root_☃1', { userRootNamespace: 'admin/bar', backend: { mountPath: 'token' } }); - - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + module('query', function () { + test('it calls the correct url', function (assert) { + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['foo', 'my/path'] }, 'data params OK'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['baz/foo'] }, options.data, 'data params prefix path with relative namespace'); - assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); - assert.strictEqual(method, 'POST', 'method OK'); + test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.setNamespace('admin'); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['admin/foo', 'admin/my/path'] }, + 'data params prefix paths with namespace' + ); + assert.strictEqual(options.namespace, '', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); + + test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + const auth = this.owner.lookup('service:auth'); + namespaceSvc.setNamespace('admin/bar/baz'); + // Set user root namespace + auth.setCluster('1'); + auth.set('tokens', ['vault-_root_☃1']); + auth.setTokenData('vault-_root_☃1', { + userRootNamespace: 'admin/bar', + backend: { mountPath: 'token' }, + }); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['baz/foo', 'baz/my/path'] }, + 'data params prefix path with relative namespace' + ); + assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); }); }); diff --git a/ui/tests/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js new file mode 100644 index 000000000000..7225d232b254 --- /dev/null +++ b/ui/tests/unit/services/capabilities-test.js @@ -0,0 +1,140 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Unit | Service | capabilities', function (hooks) { + setupTest(hooks); + setupMirage(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 + return { + request_id: '6cc7a484-921a-a730-179c-eaf6c6fbe97e', + data: { + capabilities, + [path]: capabilities, + }, + }; + } + if (paths) { + // "capabilities" is an object, paths are keys and values are array of capabilities + const data = paths.reduce((obj, path) => { + obj[path] = capabilities[path]; + return obj; + }, {}); + return { + request_id: '6cc7a484-921a-a730-179c-eaf6c6fbe97e', + data, + }; + } + }; + }); + + 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', 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.fetchMultiplePaths(paths); + }); + + module('specific methods', function () { + const path = '/my/api/path'; + [ + { + capabilities: ['read'], + expectedRead: true, // expected computed properties based on response + expectedUpdate: false, + }, + { + capabilities: ['update'], + expectedRead: false, + expectedUpdate: true, + }, + { + capabilities: ['deny'], + expectedRead: false, + expectedUpdate: false, + }, + { + capabilities: ['read', 'update'], + expectedRead: true, + expectedUpdate: true, + }, + ].forEach(({ capabilities, expectedRead, expectedUpdate }) => { + test(`canRead returns expected value for "${capabilities.join(', ')}"`, async function (assert) { + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ path, capabilities }); + }); + + const response = await this.capabilities.canRead(path); + assert[expectedRead](response, `canRead returns ${expectedRead}`); + }); + + test(`canUpdate returns expected value for "${capabilities.join(', ')}"`, async function (assert) { + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ path, capabilities }); + }); + const response = await this.capabilities.canUpdate(path); + assert[expectedUpdate](response, `canUpdate returns ${expectedUpdate}`); + }); + }); + }); +}); diff --git a/ui/types/vault/services/store.d.ts b/ui/types/vault/services/store.d.ts index af201ccb5547..9abeb08ee75e 100644 --- a/ui/types/vault/services/store.d.ts +++ b/ui/types/vault/services/store.d.ts @@ -8,9 +8,12 @@ import Store, { RecordArray } from '@ember-data/store'; export default class StoreService extends Store { lazyPaginatedQuery( modelName: string, - query: Object, - options?: { adapterOptions: Object } + query: object, + options?: { adapterOptions: object } ): Promise; clearDataset(modelName: string); + findRecord(modelName: string, path: string); + peekRecord(modelName: string, path: string); + query(modelName: string, query: object); }