Make users-permissions auth strategy use the content API permission engine

This commit is contained in:
Convly 2022-07-29 10:17:06 +02:00
parent 43e360a641
commit 4ebc128ffb
9 changed files with 122 additions and 22 deletions

View File

@ -16,7 +16,7 @@ describe('Admin Auth Strategy', () => {
const ctx = createContext({}, { request, state: {} });
const user = { id: 1, isActive: true };
const findOne = jest.fn(() => user);
const generateUserAbility = jest.fn();
const generateUserAbility = jest.fn(() => 'ability');
global.strapi = {
admin: {
@ -32,7 +32,11 @@ describe('Admin Auth Strategy', () => {
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(findOne).toHaveBeenCalledWith({ where: { id: 1 }, populate: ['roles'] });
expect(response).toStrictEqual({ authenticated: true, credentials: user });
expect(response).toStrictEqual({
authenticated: true,
credentials: user,
ability: 'ability',
});
});
test('Fails to authenticate if the authorization header is missing', async () => {

View File

@ -33,10 +33,16 @@ const authenticate = async ctx => {
const userAbility = await getService('permission').engine.generateUserAbility(user);
// TODO: use the ability from ctx.state.auth instead of
// ctx.state.userAbility, and remove the assign below
ctx.state.userAbility = userAbility;
ctx.state.user = user;
return { authenticated: true, credentials: user };
return {
authenticated: true,
credentials: user,
ability: userAbility,
};
};
/** @type {import('.').AuthStrategy} */

View File

@ -32,6 +32,7 @@ const createAuthentication = () => {
return this;
},
async authenticate(ctx, next) {
const { route } = ctx.state;
@ -47,7 +48,7 @@ const createAuthentication = () => {
for (const strategy of strategiesToUse) {
const result = await strategy.authenticate(ctx);
const { authenticated = false, error = null, credentials } = result || {};
const { authenticated = false, credentials, ability = null, error = null } = result || {};
if (error !== null) {
return ctx.unauthorized(error);
@ -58,6 +59,7 @@ const createAuthentication = () => {
ctx.state.auth = {
strategy,
credentials,
ability,
};
return next();
@ -66,6 +68,7 @@ const createAuthentication = () => {
return ctx.unauthorized('Missing or invalid credentials');
},
async verify(auth, config = {}) {
if (config === false) {
return;

View File

@ -23,6 +23,11 @@ export interface Strapi {
*/
readonly auth: any;
/**
* Getter for the Strapi content API container
*/
readonly contentAPI: any;
/**
* Getter for the Strapi sanitizers container
*/

View File

@ -6,6 +6,7 @@ const user = require('./user');
const role = require('./role');
const usersPermissions = require('./users-permissions');
const providersRegistry = require('./providers-registry');
const permission = require('./permission');
module.exports = {
jwt,
@ -14,4 +15,5 @@ module.exports = {
role,
user,
'users-permissions': usersPermissions,
permission,
};

View File

@ -0,0 +1,55 @@
'use strict';
const PUBLIC_ROLE_FILTER = { role: { type: 'public' } };
module.exports = ({ strapi }) => ({
/**
* Find permissions associated to a specific role ID
*
* @param {number} roleID
*
* @return {object[]}
*/
async findRolePermissions(roleID) {
return strapi.entityService.load(
'plugin::users-permissions.role',
{ id: roleID },
'permissions'
);
},
/**
* Find permissions for the public role
*
* @return {object[]}
*/
async findPublicPermissions() {
return strapi.entityService.findMany('plugin::users-permissions.permission', {
where: PUBLIC_ROLE_FILTER,
});
},
/**
* Transform a Users-Permissions' permission into a content API one
*
* @example
* const upPermission = { action: 'api::foo.foo.find' };
*
* const permission = toContentAPIPermission(upPermission);
* // ^? { action: 'find', subject: 'api::foo.foo' }
*
* @param {object} permission
* @param {string} permission.action
*
* @return {{ action: string, subject: string }}
*/
toContentAPIPermission(permission) {
const { action } = permission;
const actionIndex = action.lastIndexOf('.');
return {
action: action.slice(actionIndex + 1),
subject: action.slice(0, actionIndex),
};
},
});

View File

@ -170,6 +170,13 @@ 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
// 'api::foo.foo.find' => { action: 'find', subject: 'api.foo.foo' } => 'find';
.map(action => getService('permission').toContentAPIPermission({ action }).action)
.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 } });

View File

@ -1,6 +1,6 @@
'use strict';
const { castArray, map } = require('lodash/fp');
const { castArray, map, every, pipe } = require('lodash/fp');
const { ForbiddenError, UnauthorizedError } = require('@strapi/utils').errors;
const { getService } = require('../utils');
@ -16,48 +16,61 @@ const authenticate = async ctx => {
if (token) {
const { id } = token;
// Invalid token
if (id === undefined) {
return { authenticated: false };
}
// fetch authenticated user
const user = await getService('user').fetchAuthenticatedUser(id);
// No user associated to the token
if (!user) {
return { error: 'Invalid credentials' };
}
const advancedSettings = await getAdvancedSettings();
// User not confirmed
if (advancedSettings.email_confirmation && !user.confirmed) {
return { error: 'Invalid credentials' };
}
// User blocked
if (user.blocked) {
return { error: 'Invalid credentials' };
}
// Fetch user's permissions
const permissions = await Promise.resolve(user.role.id)
.then(getService('permission').findRolePermissions)
.then(map(getService('permission').toContentAPIPermission));
// Generate an ability (content API engine) based on the given permissions
const ability = await strapi.contentAPI.permissions.engine.generateAbility(permissions);
ctx.state.user = user;
return {
authenticated: true,
credentials: user,
ability,
};
}
const publicPermissions = await strapi.query('plugin::users-permissions.permission').findMany({
where: {
role: { type: 'public' },
},
});
const publicPermissions = await getService('permission')
.findPublicPermissions()
.then(map(getService('permission').toContentAPIPermission));
if (publicPermissions.length === 0) {
return { authenticated: false };
}
const ability = await strapi.contentAPI.permissions.engine.generateAbility(publicPermissions);
return {
authenticated: true,
credentials: null,
ability,
};
} catch (err) {
return { authenticated: false };
@ -65,7 +78,7 @@ const authenticate = async ctx => {
};
const verify = async (auth, config) => {
const { credentials: user } = auth;
const { credentials: user, ability } = auth;
if (!config.scope) {
if (!user) {
@ -77,18 +90,21 @@ const verify = async (auth, config) => {
}
}
let allowedActions = auth.allowedActions;
if (!allowedActions) {
const permissions = await strapi.query('plugin::users-permissions.permission').findMany({
where: { role: user ? user.role.id : { type: 'public' } },
});
allowedActions = map('action', permissions);
auth.allowedActions = allowedActions;
// If no ability have been generated, then consider auth is missing
if (!ability) {
throw new UnauthorizedError();
}
const isAllowed = castArray(config.scope).every(scope => allowedActions.includes(scope));
const isAllowed = pipe(
// Make sure we're dealing with an array
castArray,
// Transform the scope array into an action array
map(scope => ({ action: scope })),
// Map the users-permissions permissions into content API permissions
map(getService('permission').toContentAPIPermission),
// Check that every required scope is allowed by the ability
every(({ action, subject }) => ability.can(action, subject))
)(config.scope);
if (!isAllowed) {
throw new ForbiddenError();

View File

@ -3,6 +3,7 @@ import * as user from '../services/user';
import * as role from '../services/role';
import * as jwt from '../services/jwt';
import * as providers from '../services/providers';
import * as permission from '../services/permission';
type S = {
['users-permissions']: typeof usersPermissions;
@ -11,6 +12,7 @@ type S = {
jwt: typeof jwt;
providers: typeof providers;
['providers-registry']: typeof providers;
permission: typeof permission;
};
export function getService<T extends keyof S>(name: T): ReturnType<S[T]>;