diff --git a/packages/core/admin/server/content-types/api-token.js b/packages/core/admin/server/content-types/api-token.js index d78222e115..0d808cedd7 100644 --- a/packages/core/admin/server/content-types/api-token.js +++ b/packages/core/admin/server/content-types/api-token.js @@ -47,5 +47,12 @@ module.exports = { configurable: false, required: true, }, + permissions: { + type: 'relation', + target: 'admin::token-permission', + relation: 'oneToMany', + mappedBy: 'token', + configurable: false, + }, }, }; diff --git a/packages/core/admin/server/content-types/index.js b/packages/core/admin/server/content-types/index.js index b665381c49..a4a126c818 100644 --- a/packages/core/admin/server/content-types/index.js +++ b/packages/core/admin/server/content-types/index.js @@ -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') }, }; diff --git a/packages/core/admin/server/content-types/token-permission.js b/packages/core/admin/server/content-types/token-permission.js new file mode 100644 index 0000000000..39f755da6a --- /dev/null +++ b/packages/core/admin/server/content-types/token-permission.js @@ -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', + }, + }, +}; diff --git a/packages/core/admin/server/services/api-token.js b/packages/core/admin/server/services/api-token.js index 47b344872b..8dcdb1e8da 100644 --- a/packages/core/admin/server/services/api-token.js +++ b/packages/core/admin/server/services/api-token.js @@ -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} */ const SELECT_FIELDS = ['id', 'name', 'description', 'type', 'createdAt']; +/** @constant {Array} */ +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} @@ -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>} */ 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>} */ 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 = { diff --git a/packages/core/admin/server/services/constants.js b/packages/core/admin/server/services/constants.js index e294c4a77a..f9de980700 100644 --- a/packages/core/admin/server/services/constants.js +++ b/packages/core/admin/server/services/constants.js @@ -13,5 +13,6 @@ module.exports = { API_TOKEN_TYPE: { READ_ONLY: 'read-only', FULL_ACCESS: 'full-access', + CUSTOM: 'custom', }, }; diff --git a/packages/core/admin/server/strategies/api-token.js b/packages/core/admin/server/strategies/api-token.js index 4c3f537743..eae039632c 100644 --- a/packages/core/admin/server/strategies/api-token.js +++ b/packages/core/admin/server/strategies/api-token.js @@ -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();