Skip to content

Commit

Permalink
add capabilities service (from #28168)
Browse files Browse the repository at this point in the history
  • Loading branch information
hashishaw committed Sep 12, 2024
1 parent a0339d9 commit 3fdafc7
Show file tree
Hide file tree
Showing 6 changed files with 656 additions and 66 deletions.
57 changes: 46 additions & 11 deletions ui/app/adapters/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,31 @@
import AdapterError from '@ember-data/adapter/error';
import { set } from '@ember/object';
import ApplicationAdapter from './application';
import { sanitizePath } from 'core/utils/sanitize-path';
import { sanitizePath, sanitizeStart } 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 in the current namespace,
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}/${sanitizeStart(path)}`;
}

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,33 @@ export default ApplicationAdapter.extend({
resp.path = id;
return resp;
});
},
});
}

query(store, type, query) {
const pathMap = query?.paths.reduce((mapping, path) => {
const withNs = this._formatPath(path);
if (withNs) {
mapping[withNs] = path;
}
return mapping;
}, {});

return this.ajax(this.buildURL(type), 'POST', {
data: { paths: Object.keys(pathMap) },
namespace: sanitizePath(this.namespaceService.userRootNamespace),
})
.then((queryResult) => {
if (queryResult) {
// send the pathMap with the response so the serializer can normalize the paths to be relative to the namespace
queryResult.pathMap = pathMap;
}
return queryResult;
})
.catch((e) => {
if (e instanceof AdapterError) {
set(e, 'policyPath', 'sys/capabilities-self');
}
throw e;
});
}
}
21 changes: 14 additions & 7 deletions ui/app/serializers/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ 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((fullPath) => {
// we use pathMap to normalize a namespace-prefixed path back to the relative path
// this is okay because we clear capabilities when moving between namespaces
const path = payload.pathMap ? payload.pathMap[fullPath] : fullPath;
return { capabilities: payload.data[fullPath], 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
120 changes: 120 additions & 0 deletions ui/app/services/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* 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 Capabilities {
canCreate: boolean;
canDelete: boolean;
canList: boolean;
canPatch: boolean;
canRead: boolean;
canSudo: boolean;
canUpdate: boolean;
}

interface MultipleCapabilities {
[key: string]: Capabilities;
}

export default class CapabilitiesService extends Service {
@service declare readonly store: StoreService;

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);
}

async fetchMultiplePaths(paths: string[]): Promise<MultipleCapabilities> {
// 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] = {
canCreate: true,
canDelete: true,
canList: true,
canPatch: true,
canRead: true,
canSudo: true,
canUpdate: true,
};
}
return obj;
}, {});
}

/*
this method returns all of the capabilities for a singular path
*/
fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError {
try {
return 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;
}
}

canRead(path: string) {
try {
return this._fetchSpecificCapability(path, 'canRead');
} catch (e) {
return e;
}
}

canUpdate(path: string) {
try {
return this._fetchSpecificCapability(path, 'canUpdate');
} catch (e) {
return e;
}
}

canPatch(path: string) {
try {
return this._fetchSpecificCapability(path, 'canPatch');
} catch (e) {
return e;
}
}
}
Loading

0 comments on commit 3fdafc7

Please sign in to comment.