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
33 changes: 23 additions & 10 deletions ui/app/adapters/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ 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) {
_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 +33,7 @@ export default ApplicationAdapter.extend({
}
throw e;
});
},
}

queryRecord(store, type, query) {
const { id } = query;
Expand All @@ -44,5 +44,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
91 changes: 91 additions & 0 deletions ui/app/services/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* 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 ArrayProxy from '@ember/array/proxy';
import type CapabilitiesModel from 'vault/vault/models/capabilities';
import type StoreService from 'vault/services/store';

interface Query {
paths?: string[];
path?: string;
}

interface ComputedCapabilities {
canSudo: string;
canRead: string;
canCreate: string;
canUpdate: string;
canDelete: string;
canList: 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;

request = async (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 all of the capabilities for a particular path or paths
*/
async fetchAll(query: Query): Promise<CapabilitiesModel | ArrayProxy<CapabilitiesModel>> | AdapterError {
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved
try {
return await this.request(query);
} catch (error) {
return error;
}
}

/*
internal method for specific capability checks below
checks each capability model (one for each path, if multiple)
for the passed capability, ie "canRead"
*/
async _fetchSpecific(
query: Query,
capability: string
): Promise<CapabilitiesModel | ArrayProxy<CapabilitiesModel>> | AdapterError {
try {
const capabilities = await this.request(query);
if (query?.path) {
return capabilities[capability];
}
if (query?.paths) {
return capabilities.every((c: CapabilitiesModel) => c[capability as keyof ComputedCapabilities]);
}
} catch (e) {
return e;
}
}
hellobontempo marked this conversation as resolved.
Show resolved Hide resolved

async canRead(query: Query) {
try {
return await this._fetchSpecific(query, 'canRead');
} catch (e) {
return e;
}
}

async canUpdate(query: Query) {
try {
return await this._fetchSpecific(query, '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
Copy link
Contributor Author

@hellobontempo hellobontempo Aug 22, 2024

Choose a reason for hiding this comment

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

move this to addon so in templates we can do things like

{{#if (await (this.capabilities.canRead "secret/data/my-secret"))}}
   Show something...
{{/if}}

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';
79 changes: 79 additions & 0 deletions ui/tests/unit/services/capabilities-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 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, capabilities }) => {
if (path) {
return {
request_id: '6cc7a484-921a-a730-179c-eaf6c6fbe97e',
data: {
capabilities: capabilities,
[path]: capabilities,
},
};
}
};
});

test('it makes request to capabilities-self', 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 });
});

const SINGULAR_PATH = [
{
capabilities: ['read'],
canRead: true,
canUpdate: false,
},
{
capabilities: ['update'],
canRead: false,
canUpdate: true,
},
{
capabilities: ['deny'],
canRead: false,
canUpdate: false,
},
{
capabilities: ['read', 'update'],
canRead: true,
canUpdate: true,
},
];
SINGULAR_PATH.forEach(({ capabilities, canRead, canUpdate }) => {
const path = '/my/api/path';
test(`singular path returns expected boolean for "${capabilities.join(', ')}"`, async function (assert) {
this.server.post('/sys/capabilities-self', () => {
return this.generateResponse({ path, capabilities });
});

const canReadResponse = await this.capabilities.canRead({ path });
const canUpdateResponse = await this.capabilities.canUpdate({ path });
assert[canRead](canReadResponse, `canRead returns ${canRead}`);
assert[canUpdate](canUpdateResponse, `canUpdate returns ${canRead}`);
});
});
});
7 changes: 5 additions & 2 deletions ui/types/vault/services/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecordArray>;

clearDataset(modelName: string);
findRecord(modelName: string, path: string);
peekRecord(modelName: string, path: string);
query(modelName: string, query: object);
}
Loading