Skip to content

Commit

Permalink
UI: Add capabilities service (#28168)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hellobontempo authored Aug 23, 2024
1 parent 111d6a8 commit 09c92b8
Show file tree
Hide file tree
Showing 9 changed files with 404 additions and 66 deletions.
40 changes: 30 additions & 10 deletions ui/app/adapters/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -33,7 +40,7 @@ export default ApplicationAdapter.extend({
}
throw e;
});
},
}

queryRecord(store, type, query) {
const { id } = query;
Expand All @@ -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;
});
}
}
16 changes: 9 additions & 7 deletions ui/app/serializers/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},

Expand Down
87 changes: 87 additions & 0 deletions ui/app/services/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<Array<CapabilitiesModel>> | 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<CapabilitiesModel> | 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<CapabilitiesModel> | 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;
}
}
}
2 changes: 1 addition & 1 deletion ui/app/services/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
File renamed without changes.
6 changes: 6 additions & 0 deletions ui/lib/core/app/helpers/await.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

export { default } from 'core/helpers/await';
172 changes: 126 additions & 46 deletions ui/tests/unit/adapters/capabilities-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading

0 comments on commit 09c92b8

Please sign in to comment.