Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Add capabilities service #28168

Merged
merged 13 commits into from
Aug 23, 2024
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),
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved
}).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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could also call this @permissions if we're worried about confusing it with the @lazyCapabilities method

@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
Loading