Attach permission model to tokens, update api token' strategy & services

This commit is contained in:
Convly 2022-08-05 12:01:36 +02:00
parent 701aa19911
commit 3a9709bdbf
6 changed files with 135 additions and 19 deletions

View File

@ -47,5 +47,12 @@ module.exports = {
configurable: false,
required: true,
},
permissions: {
type: 'relation',
target: 'admin::token-permission',
relation: 'oneToMany',
mappedBy: 'token',
configurable: false,
},
},
};

View File

@ -5,4 +5,5 @@ module.exports = {
user: { schema: require('./User') },
role: { schema: require('./Role') },
'api-token': { schema: require('./api-token') },
'token-permission': { schema: require('./token-permission') },
};

View File

@ -0,0 +1,36 @@
'use strict';
module.exports = {
collectionName: 'strapi_token_permissions',
info: {
name: 'API Token Permission',
description: '',
singularName: 'token-permission',
pluralName: 'token-permissions',
displayName: 'API Token Permission',
},
options: {},
pluginOptions: {
'content-manager': {
visible: false,
},
'content-type-builder': {
visible: false,
},
},
attributes: {
action: {
type: 'string',
minLength: 1,
configurable: false,
required: true,
},
token: {
configurable: false,
type: 'relation',
relation: 'manyToOne',
inversedBy: 'permissions',
target: 'admin::api-token',
},
},
};

View File

@ -1,9 +1,10 @@
'use strict';
const crypto = require('crypto');
const { omit } = require('lodash/fp');
/**
* @typedef {'read-only'|'full-access'} TokenType
* @typedef {'read-only'|'full-access'|'custom'} TokenType
*/
/**
@ -14,11 +15,35 @@ const crypto = require('crypto');
* @property {string} [description]
* @property {string} accessKey
* @property {TokenType} type
* @property {(number|ApiTokenPermission)[]} [permissions]
*/
/**
* @typedef ApiTokenPermission
*
* @property {number|string} id
* @property {string} action
* @property {ApiToken|number} [token]
*/
/** @constant {Array<string>} */
const SELECT_FIELDS = ['id', 'name', 'description', 'type', 'createdAt'];
/** @constant {Array<string>} */
const POPULATE_FIELDS = ['permissions'];
const assertCustomTokenPermissionsValidity = attributes => {
// Ensure non-custom tokens doesn't have permissions
if (attributes.type !== 'custom' && attributes.permissions) {
throw new Error('Non-custom tokens should not references permissions');
}
// Custom type tokens should always have permissions attached to them
if (attributes.type === 'custom' && !attributes.permissions) {
throw new Error('Missing permissions attributes for custom token');
}
};
/**
* @param {Object} whereParams
* @param {string|number} [whereParams.id]
@ -50,6 +75,7 @@ const hash = accessKey => {
* @param {Object} attributes
* @param {TokenType} attributes.type
* @param {string} attributes.name
* @param {string[]} [attributes.permissions]
* @param {string} [attributes.description]
*
* @returns {Promise<ApiToken>}
@ -57,18 +83,29 @@ const hash = accessKey => {
const create = async attributes => {
const accessKey = crypto.randomBytes(128).toString('hex');
assertCustomTokenPermissionsValidity(attributes);
// Create the token
const apiToken = await strapi.query('admin::api-token').create({
select: SELECT_FIELDS,
data: {
...attributes,
...omit('permissions', attributes),
accessKey: hash(accessKey),
},
});
return {
...apiToken,
accessKey,
};
const result = { ...apiToken, accessKey };
// If this is a custom type token, create and link the associated permissions
if (attributes.type === 'custom') {
const permissions = await strapi
.query('admin::token-permissions')
.createMany({ data: permissions.map(action => ({ action, token: apiToken.id })) });
Object.assign(result, { permissions });
}
return result;
};
/**
@ -97,6 +134,7 @@ For security reasons, prefer storing the secret in an environment variable and r
const list = async () => {
return strapi.query('admin::api-token').findMany({
select: SELECT_FIELDS,
populate: POPULATE_FIELDS,
orderBy: { name: 'ASC' },
});
};
@ -107,7 +145,9 @@ const list = async () => {
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const revoke = async id => {
return strapi.query('admin::api-token').delete({ select: SELECT_FIELDS, where: { id } });
return strapi
.query('admin::api-token')
.delete({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: { id } });
};
/**
@ -138,9 +178,14 @@ const getByName = async name => {
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const update = async (id, attributes) => {
return strapi
.query('admin::api-token')
.update({ where: { id }, data: attributes, select: SELECT_FIELDS });
assertCustomTokenPermissionsValidity(attributes);
return strapi.query('admin::api-token').update({
select: SELECT_FIELDS,
populate: POPULATE_FIELDS,
where: { id },
data: omit('permissions', attributes),
});
};
/**
@ -157,7 +202,9 @@ const getBy = async (whereParams = {}) => {
return null;
}
return strapi.query('admin::api-token').findOne({ select: SELECT_FIELDS, where: whereParams });
return strapi
.query('admin::api-token')
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams });
};
module.exports = {

View File

@ -13,5 +13,6 @@ module.exports = {
API_TOKEN_TYPE: {
READ_ONLY: 'read-only',
FULL_ACCESS: 'full-access',
CUSTOM: 'custom',
},
};

View File

@ -1,5 +1,6 @@
'use strict';
const { castArray } = require('lodash/fp');
const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
const constants = require('../services/constants');
const { getService } = require('../utils');
@ -37,29 +38,52 @@ const authenticate = async ctx => {
return { authenticated: false };
}
if (apiToken.type === constants.API_TOKEN_TYPE.CUSTOM) {
const ability = await strapi.contentAPI.permissions.engine.generateAbility(
apiToken.permissions.map(({ action }) => ({ action }))
);
return { authenticated: true, ability, credentials: apiToken };
}
return { authenticated: true, credentials: apiToken };
};
/** @type {import('.').VerifyFunction} */
const verify = (auth, config) => {
const { credentials: apiToken } = auth;
const { credentials: apiToken, ability } = auth;
if (!apiToken) {
throw new UnauthorizedError();
}
// Full access
if (apiToken.type === constants.API_TOKEN_TYPE.FULL_ACCESS) {
return;
}
/**
* If you don't have `full-access` you can only access `find` and `findOne`
* scopes. If the route has no scope, then you can't get access to it.
*/
// Read only
else if (apiToken.type === constants.API_TOKEN_TYPE.READ_ONLY) {
/**
* If you don't have `full-access` you can only access `find` and `findOne`
* scopes. If the route has no scope, then you can't get access to it.
*/
const scopes = castArray(config.scope);
const scopes = Array.isArray(config.scope) ? config.scope : [config.scope];
if (config.scope && scopes.every(isReadScope)) {
return;
if (config.scope && scopes.every(isReadScope)) {
return;
}
}
// Custom
else if (apiToken.type === constants.API_TOKEN_TYPE.CUSTOM && ability) {
const scopes = castArray(config.scope);
const isAllowed = scopes.every(scope => ability.can(scope));
if (isAllowed) {
return;
}
}
throw new ForbiddenError();