Merge branch 'features/api-token-v2' into api-token-v2/change-primary-button-size-on-api-token-list

This commit is contained in:
Simone Taeggi 2022-08-24 09:36:45 +02:00
commit 1cb18d767e
10 changed files with 547 additions and 80 deletions

View File

@ -38,6 +38,21 @@ module.exports = {
ctx.created({ data: apiToken });
},
async regenerate(ctx) {
const { id } = ctx.params;
const apiTokenService = getService('api-token');
const apiTokenExists = await apiTokenService.getById(id);
if (!apiTokenExists) {
ctx.notFound('API Token not found');
return;
}
const accessToken = await apiTokenService.regenerate(id);
ctx.created({ data: accessToken });
},
async list(ctx) {
const apiTokenService = getService('api-token');
const apiTokens = await apiTokenService.list();
@ -60,7 +75,6 @@ module.exports = {
if (!apiToken) {
ctx.notFound('API Token not found');
return;
}

View File

@ -56,4 +56,15 @@ module.exports = [
],
},
},
{
method: 'POST',
path: '/api-tokens/:id/regenerate',
handler: 'api-token.regenerate',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{ name: 'admin::hasPermissions', config: { actions: ['admin::api-tokens.update'] } },
],
},
},
];

View File

@ -1,5 +1,6 @@
'use strict';
const { NotFoundError } = require('@strapi/utils/lib/errors');
const crypto = require('crypto');
const { omit } = require('lodash/fp');
const apiTokenService = require('../api-token');
@ -250,6 +251,59 @@ describe('API Token', () => {
});
});
describe('regenerate', () => {
test('It regenerates the accessKey', async () => {
const update = jest.fn(({ data }) => Promise.resolve(data));
global.strapi = {
query() {
return { update };
},
config: {
get: jest.fn(() => ''),
},
};
const id = 1;
const res = await apiTokenService.regenerate(id);
expect(update).toHaveBeenCalledWith({
where: { id },
select: ['id', 'accessKey'],
data: {
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
},
});
expect(res).toEqual({ accessKey: mockedApiToken.hexedString });
});
test('It throws a NotFound if the id is not found', async () => {
const update = jest.fn(() => Promise.resolve(null));
global.strapi = {
query() {
return { update };
},
config: {
get: jest.fn(() => ''),
},
};
const id = 1;
await expect(async () => {
await apiTokenService.regenerate(id);
}).rejects.toThrowError(NotFoundError);
expect(update).toHaveBeenCalledWith({
where: { id },
select: ['id', 'accessKey'],
data: {
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
},
});
});
});
describe('update', () => {
test('Updates a non-custom token', async () => {
const token = {

View File

@ -181,6 +181,32 @@ const create = async (attributes) => {
return result;
};
/**
* @param {string|number} id
*
* @returns {Promise<ApiToken>}
*/
const regenerate = async (id) => {
const accessKey = crypto.randomBytes(128).toString('hex');
const apiToken = await strapi.query('admin::api-token').update({
select: ['id', 'accessKey'],
where: { id },
data: {
accessKey: hash(accessKey),
},
});
if (!apiToken) {
throw new NotFoundError('The provided token id does not exist');
}
return {
...apiToken,
accessKey,
};
};
/**
* @returns {void}
*/
@ -371,6 +397,7 @@ const update = async (id, attributes) => {
module.exports = {
create,
regenerate,
exists,
checkSaltIsDefined,
hash,

View File

@ -1,6 +1,6 @@
'use strict';
const { omit, map, orderBy } = require('lodash');
const { omit } = require('lodash');
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createAuthRequest } = require('../../../../../test/helpers/request');
@ -36,6 +36,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
const createValidToken = async (token = {}) => {
const body = {
type: 'read-only',
// eslint-disable-next-line no-plusplus
name: `token_${String(currentTokens++)}`,
description: 'generic description',
...token,
@ -130,7 +131,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
name: body.name,
permissions: [],
@ -189,7 +190,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
name: body.name,
permissions: [],
@ -217,7 +218,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
name: body.name,
permissions: body.permissions,
@ -269,7 +270,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
name: body.name,
permissions: [],
@ -296,7 +297,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
name: 'api-token_tests-spaces-at-the-end',
permissions: [],
@ -331,9 +332,16 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
expect(res.statusCode).toBe(200);
expect(res.body.data.length).toBe(4);
expect(orderBy(res.body.data, ['id'])).toStrictEqual(
map(orderBy(tokens, ['id']), (t) => omit(t, ['accessKey']))
);
// check that each token exists in data
tokens.forEach((token) => {
const t = res.body.data.find((t) => t.id === token.id);
if (t.permissions) {
t.permissions = t.permissions.sort();
// eslint-disable-next-line no-param-reassign
token.permissions = token.permissions.sort();
}
expect(t).toMatchObject(omit(token, ['accessKey']));
});
});
test('Deletes a token (successfully)', async () => {
@ -345,7 +353,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
name: token.name,
permissions: token.permissions,
description: token.description,
@ -378,7 +386,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
name: token.name,
permissions: token.permissions,
description: token.description,
@ -402,7 +410,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(res.statusCode).toBe(200);
expect(res.body.data).toStrictEqual({
expect(res.body.data).toMatchObject({
name: token.name,
permissions: token.permissions,
description: token.description,
@ -460,7 +468,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
});
expect(updatedRes.statusCode).toBe(200);
expect(updatedRes.body.data).toStrictEqual({
expect(updatedRes.body.data).toMatchObject({
name: updatedBody.name,
permissions: [],
description: updatedBody.description,
@ -608,4 +616,36 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
updatedAt: expect.any(String),
});
});
test('Regenerates an api token access key', async () => {
const token = await createValidToken();
const res = await rq({
url: `/admin/api-tokens/${token.id}/regenerate`,
method: 'POST',
});
expect(res.statusCode).toBe(201);
expect(res.body.data).toMatchObject({
accessKey: expect.any(String),
});
expect(res.body.data.accessKey).not.toEqual(token.accessKey);
});
test('Regenerate throws a NotFound if provided an invalid id', async () => {
const res = await rq({
url: `/admin/api-tokens/999999/regenerate`,
method: 'POST',
});
expect(res.statusCode).toBe(404);
expect(res.body.error).toMatchObject({
name: 'NotFoundError',
status: 404,
});
});
test.todo('Regenerated access key works');
test.todo('Tokens access content for which they are authorized');
test.todo('Tokens fail to access content for which they are not authorized');
});

View File

@ -454,7 +454,7 @@ class Strapi {
await this.runLifecyclesFunctions(LIFECYCLES.BOOTSTRAP);
// TODO: is this the best place for this?
await this.contentAPI.permissions.syncActions();
await this.contentAPI.permissions.registerActions();
this.cron.start();

View File

@ -0,0 +1,291 @@
'use strict';
const createContentAPI = require('../content-api');
describe('Content API - Permissions', () => {
const bindToContentAPI = (action) => {
Object.assign(action, { [Symbol.for('__type__')]: ['content-api'] });
return action;
};
describe('Get Actions Map', () => {
test('When no API are defined, it should return an empty object', () => {
global.strapi = {};
const contentAPI = createContentAPI(global.strapi);
const actionsMap = contentAPI.permissions.getActionsMap();
expect(actionsMap).toEqual({});
});
test('When no controller are defined for an API, it should ignore the API', () => {
global.strapi = {
api: {
foo: {},
bar: {},
},
};
const contentAPI = createContentAPI(global.strapi);
const actionsMap = contentAPI.permissions.getActionsMap();
expect(actionsMap).toEqual({});
});
test(`Do not register controller if they're not bound to the content API`, () => {
const actionC = () => {};
Object.assign(actionC, { [Symbol.for('__type__')]: ['admin-api'] });
global.strapi = {
api: {
foo: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB() {},
actionC,
},
},
},
},
};
const contentAPI = createContentAPI(global.strapi);
const actionsMap = contentAPI.permissions.getActionsMap();
expect(actionsMap).toEqual({
'api::foo': { controllers: { controllerA: ['actionA'] } },
});
});
test('Creates and populate a map of actions from APIs and plugins', () => {
global.strapi = {
api: {
foo: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
},
},
bar: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
},
},
foobar: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
controllerB: {
actionC: bindToContentAPI(() => {}),
actionD: bindToContentAPI(() => {}),
},
},
},
},
plugins: {
foo: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
},
},
bar: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
},
},
},
};
const contentAPI = createContentAPI(global.strapi);
const actionsMap = contentAPI.permissions.getActionsMap();
expect(actionsMap).toEqual({
'api::foo': { controllers: { controllerA: ['actionA', 'actionB'] } },
'api::bar': { controllers: { controllerA: ['actionA', 'actionB'] } },
'api::foobar': {
controllers: {
controllerA: ['actionA', 'actionB'],
controllerB: ['actionC', 'actionD'],
},
},
'plugin::foo': { controllers: { controllerA: ['actionA', 'actionB'] } },
'plugin::bar': { controllers: { controllerA: ['actionA', 'actionB'] } },
});
});
});
describe('Register Actions', () => {
beforeEach(() => {
global.strapi = {
api: {
foo: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
actionB: bindToContentAPI(() => {}),
},
controllerB: {
actionC: bindToContentAPI(() => {}),
actionD: bindToContentAPI(() => {}),
},
},
},
},
plugins: {
foo: {
controllers: {
controllerA: {
actionA: bindToContentAPI(() => {}),
},
},
},
},
};
});
test('The action provider should holds every action from APIs and plugins', async () => {
const contentAPI = createContentAPI(global.strapi);
await contentAPI.permissions.registerActions();
const values = contentAPI.permissions.providers.action.values();
expect(values).toEqual([
{
uid: 'api::foo.controllerA.actionA',
api: 'api::foo',
controller: 'controllerA',
action: 'actionA',
},
{
uid: 'api::foo.controllerA.actionB',
api: 'api::foo',
controller: 'controllerA',
action: 'actionB',
},
{
uid: 'api::foo.controllerB.actionC',
api: 'api::foo',
controller: 'controllerB',
action: 'actionC',
},
{
uid: 'api::foo.controllerB.actionD',
api: 'api::foo',
controller: 'controllerB',
action: 'actionD',
},
{
uid: 'plugin::foo.controllerA.actionA',
api: 'plugin::foo',
controller: 'controllerA',
action: 'actionA',
},
]);
});
test('Call registerActions twice should throw a duplicate error', async () => {
const contentAPI = createContentAPI(global.strapi);
await contentAPI.permissions.registerActions();
expect(() => contentAPI.permissions.registerActions()).rejects.toThrowError(
'Duplicated item key: api::foo.controllerA.actionA'
);
});
});
describe('Providers', () => {
test('You should not be able to register action once strapi is loaded', () => {
global.strapi.isLoaded = true;
const contentAPI = createContentAPI(global.strapi);
// Actions
expect(() =>
contentAPI.permissions.providers.action.register('foo', {})
).rejects.toThrowError(`You can't register new actions outside the bootstrap function.`);
// Conditions
expect(() =>
contentAPI.permissions.providers.condition.register({ name: 'myCondition' })
).rejects.toThrowError(`You can't register new conditions outside the bootstrap function.`);
// Register Actions
expect(() => contentAPI.permissions.registerActions()).rejects.toThrowError(
`You can't register new actions outside the bootstrap function.`
);
});
});
describe('Engine', () => {
test('Engine warns when registering an unknown action', async () => {
global.strapi = {
log: {
debug: jest.fn(),
},
};
const contentAPI = createContentAPI();
const ability = await contentAPI.permissions.engine.generateAbility([{ action: 'foo' }]);
expect(ability.rules).toHaveLength(0);
expect(global.strapi.log.debug).toHaveBeenCalledWith(
`Unknown action "foo" supplied when registering a new permission`
);
});
test('Engine filter out invalid action when generating an ability', async () => {
global.strapi = {
log: {
debug: jest.fn(),
},
api: {
foo: {
controllers: {
bar: { foobar: bindToContentAPI(() => {}) },
},
},
},
};
const contentAPI = createContentAPI(global.strapi);
await contentAPI.permissions.registerActions();
const ability = await contentAPI.permissions.engine.generateAbility([
{ action: 'foo' },
{ action: 'api::foo.bar.foobar' },
]);
expect(ability.rules).toHaveLength(1);
expect(ability.rules).toEqual([
{
action: 'api::foo.bar.foobar',
subject: 'all',
},
]);
expect(global.strapi.log.debug).toHaveBeenCalledTimes(1);
expect(global.strapi.log.debug).toHaveBeenCalledWith(
`Unknown action "foo" supplied when registering a new permission`
);
});
});
});

View File

@ -1,65 +1,7 @@
'use strict';
const { uniq } = require('lodash');
const _ = require('lodash');
const permissions = require('./permissions');
/**
* Create a content API container that holds logic, tools and utils. (eg: permissions, ...)
*/
const createContentAPI = (/* strapi */) => {
const syncActions = async () => {
/**
* NOTE: For some reason, this doesn't seem to be necessary because all the routes exist
* createActionProvider uses a providerFactory, which seems to already include everything, and when we try
* to register our actions we get an error that the keys already exist
* Could providerFactory not be providing a new provider, and instead sharing the registry with everything that uses it?
*
* If this isn't an issue to fix and is expected, we don't need the route registration code below and it should be removed
* */
// Start of route registration
const apiRoutesName = Object.values(strapi.api)
.map((api) => api.routes)
.reduce((acc, routesMap) => {
const routes = Object.values(routesMap)
// Only content api routes
.filter((p) => p.type === 'content-api')
// Resolve every handler name for each route
.reduce((a, p) => a.concat(p.routes.map((i) => i.handler)), []);
return acc.concat(routes);
}, []);
const pluginsRoutesname = Object.values(strapi.plugins)
.map((plugin) => plugin.routes['content-api'] || {})
.map((p) => (p.routes || []).map((i) => i.handler))
.flat();
const actions = apiRoutesName.concat(pluginsRoutesname);
Promise.all(
uniq(actions).map((action) =>
providers.action.register(action).catch(() => {
// console.log('Key already exists', action);
})
)
);
};
// End of route registration
// Add providers
const providers = {
action: permissions.providers.createActionProvider(),
condition: permissions.providers.createConditionProvider(),
};
// create permission engine
const engine = permissions
.createPermissionEngine({ providers })
.on('before-format::validate.permission', createValidatePermissionHandler(providers.action));
return {
permissions: {
engine,
providers,
syncActions,
},
};
};
/**
* Creates an handler which check that the permission's action exists in the action registry
@ -78,4 +20,96 @@ const createValidatePermissionHandler =
}
};
/**
* Create a content API container that holds logic, tools and utils. (eg: permissions, ...)
*/
const createContentAPI = (strapi) => {
// Add providers
const providers = {
action: permissions.providers.createActionProvider(),
condition: permissions.providers.createConditionProvider(),
};
const getActionsMap = () => {
const actionMap = {};
const isContentApi = (action) => {
if (!_.has(action, Symbol.for('__type__'))) {
return false;
}
return action[Symbol.for('__type__')].includes('content-api');
};
const registerAPIsActions = (apis, source) => {
_.forEach(apis, (api, apiName) => {
const controllers = _.reduce(
api.controllers,
(acc, controller, controllerName) => {
const contentApiActions = _.pickBy(controller, isContentApi);
if (_.isEmpty(contentApiActions)) {
return acc;
}
acc[controllerName] = Object.keys(contentApiActions);
return acc;
},
{}
);
if (!_.isEmpty(controllers)) {
actionMap[`${source}::${apiName}`] = { controllers };
}
});
};
registerAPIsActions(strapi.api, 'api');
registerAPIsActions(strapi.plugins, 'plugin');
return actionMap;
};
const registerActions = async () => {
const actionsMap = getActionsMap();
// For each API
for (const [api, value] of Object.entries(actionsMap)) {
const { controllers } = value;
// Register controllers methods as actions
for (const [controller, actions] of Object.entries(controllers)) {
// Register each action individually
await Promise.all(
actions.map((action) => {
const actionUID = `${api}.${controller}.${action}`;
return providers.action.register(actionUID, {
api,
controller,
action,
uid: actionUID,
});
})
);
}
}
};
// create permission engine
const engine = permissions
.createPermissionEngine({ providers })
.on('before-format::validate.permission', createValidatePermissionHandler(providers.action));
return {
permissions: {
engine,
providers,
registerActions,
getActionsMap,
},
};
};
module.exports = createContentAPI;

View File

@ -8,12 +8,12 @@ module.exports = (options = {}) => {
return {
...provider,
async register(action) {
async register(action, payload) {
if (strapi.isLoaded) {
throw new Error(`You can't register new actions outside the bootstrap function.`);
}
return provider.register(action, { name: action });
return provider.register(action, payload);
},
};
};

View File

@ -171,10 +171,6 @@ module.exports = ({ strapi }) => ({
const toDelete = _.difference(permissionsFoundInDB, allActions);
// Register actions into the content API action provider
// TODO: do this in the content API bootstrap phase instead
allActions.forEach((action) => strapi.contentAPI.permissions.providers.action.register(action));
await Promise.all(
toDelete.map((action) => {
return strapi.query('plugin::users-permissions.permission').delete({ where: { action } });