mirror of
https://github.com/strapi/strapi.git
synced 2025-11-03 11:25:17 +00:00
Attach permission model to tokens, update api token' strategy & services
This commit is contained in:
parent
701aa19911
commit
3a9709bdbf
@ -47,5 +47,12 @@ module.exports = {
|
|||||||
configurable: false,
|
configurable: false,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
permissions: {
|
||||||
|
type: 'relation',
|
||||||
|
target: 'admin::token-permission',
|
||||||
|
relation: 'oneToMany',
|
||||||
|
mappedBy: 'token',
|
||||||
|
configurable: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,4 +5,5 @@ module.exports = {
|
|||||||
user: { schema: require('./User') },
|
user: { schema: require('./User') },
|
||||||
role: { schema: require('./Role') },
|
role: { schema: require('./Role') },
|
||||||
'api-token': { schema: require('./api-token') },
|
'api-token': { schema: require('./api-token') },
|
||||||
|
'token-permission': { schema: require('./token-permission') },
|
||||||
};
|
};
|
||||||
|
|||||||
36
packages/core/admin/server/content-types/token-permission.js
Normal file
36
packages/core/admin/server/content-types/token-permission.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,9 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
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} [description]
|
||||||
* @property {string} accessKey
|
* @property {string} accessKey
|
||||||
* @property {TokenType} type
|
* @property {TokenType} type
|
||||||
|
* @property {(number|ApiTokenPermission)[]} [permissions]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ApiTokenPermission
|
||||||
|
*
|
||||||
|
* @property {number|string} id
|
||||||
|
* @property {string} action
|
||||||
|
* @property {ApiToken|number} [token]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @constant {Array<string>} */
|
/** @constant {Array<string>} */
|
||||||
const SELECT_FIELDS = ['id', 'name', 'description', 'type', 'createdAt'];
|
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 {Object} whereParams
|
||||||
* @param {string|number} [whereParams.id]
|
* @param {string|number} [whereParams.id]
|
||||||
@ -50,6 +75,7 @@ const hash = accessKey => {
|
|||||||
* @param {Object} attributes
|
* @param {Object} attributes
|
||||||
* @param {TokenType} attributes.type
|
* @param {TokenType} attributes.type
|
||||||
* @param {string} attributes.name
|
* @param {string} attributes.name
|
||||||
|
* @param {string[]} [attributes.permissions]
|
||||||
* @param {string} [attributes.description]
|
* @param {string} [attributes.description]
|
||||||
*
|
*
|
||||||
* @returns {Promise<ApiToken>}
|
* @returns {Promise<ApiToken>}
|
||||||
@ -57,18 +83,29 @@ const hash = accessKey => {
|
|||||||
const create = async attributes => {
|
const create = async attributes => {
|
||||||
const accessKey = crypto.randomBytes(128).toString('hex');
|
const accessKey = crypto.randomBytes(128).toString('hex');
|
||||||
|
|
||||||
|
assertCustomTokenPermissionsValidity(attributes);
|
||||||
|
|
||||||
|
// Create the token
|
||||||
const apiToken = await strapi.query('admin::api-token').create({
|
const apiToken = await strapi.query('admin::api-token').create({
|
||||||
select: SELECT_FIELDS,
|
select: SELECT_FIELDS,
|
||||||
data: {
|
data: {
|
||||||
...attributes,
|
...omit('permissions', attributes),
|
||||||
accessKey: hash(accessKey),
|
accessKey: hash(accessKey),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const result = { ...apiToken, accessKey };
|
||||||
...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 () => {
|
const list = async () => {
|
||||||
return strapi.query('admin::api-token').findMany({
|
return strapi.query('admin::api-token').findMany({
|
||||||
select: SELECT_FIELDS,
|
select: SELECT_FIELDS,
|
||||||
|
populate: POPULATE_FIELDS,
|
||||||
orderBy: { name: 'ASC' },
|
orderBy: { name: 'ASC' },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -107,7 +145,9 @@ const list = async () => {
|
|||||||
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
|
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
|
||||||
*/
|
*/
|
||||||
const revoke = async id => {
|
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'>>}
|
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
|
||||||
*/
|
*/
|
||||||
const update = async (id, attributes) => {
|
const update = async (id, attributes) => {
|
||||||
return strapi
|
assertCustomTokenPermissionsValidity(attributes);
|
||||||
.query('admin::api-token')
|
|
||||||
.update({ where: { id }, data: attributes, select: SELECT_FIELDS });
|
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 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 = {
|
module.exports = {
|
||||||
|
|||||||
@ -13,5 +13,6 @@ module.exports = {
|
|||||||
API_TOKEN_TYPE: {
|
API_TOKEN_TYPE: {
|
||||||
READ_ONLY: 'read-only',
|
READ_ONLY: 'read-only',
|
||||||
FULL_ACCESS: 'full-access',
|
FULL_ACCESS: 'full-access',
|
||||||
|
CUSTOM: 'custom',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const { castArray } = require('lodash/fp');
|
||||||
const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
|
const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
|
||||||
const constants = require('../services/constants');
|
const constants = require('../services/constants');
|
||||||
const { getService } = require('../utils');
|
const { getService } = require('../utils');
|
||||||
@ -37,29 +38,52 @@ const authenticate = async ctx => {
|
|||||||
return { authenticated: false };
|
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 };
|
return { authenticated: true, credentials: apiToken };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {import('.').VerifyFunction} */
|
/** @type {import('.').VerifyFunction} */
|
||||||
const verify = (auth, config) => {
|
const verify = (auth, config) => {
|
||||||
const { credentials: apiToken } = auth;
|
const { credentials: apiToken, ability } = auth;
|
||||||
|
|
||||||
if (!apiToken) {
|
if (!apiToken) {
|
||||||
throw new UnauthorizedError();
|
throw new UnauthorizedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full access
|
||||||
if (apiToken.type === constants.API_TOKEN_TYPE.FULL_ACCESS) {
|
if (apiToken.type === constants.API_TOKEN_TYPE.FULL_ACCESS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Read only
|
||||||
* If you don't have `full-access` you can only access `find` and `findOne`
|
else if (apiToken.type === constants.API_TOKEN_TYPE.READ_ONLY) {
|
||||||
* scopes. If the route has no scope, then you can't get access to it.
|
/**
|
||||||
*/
|
* 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)) {
|
||||||
if (config.scope && scopes.every(isReadScope)) {
|
return;
|
||||||
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();
|
throw new ForbiddenError();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user