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 ctx = createContext({}, { request, state: {} });
const user = { id: 1, isActive: true }; const user = { id: 1, isActive: true };
const findOne = jest.fn(() => user); const findOne = jest.fn(() => user);
const generateUserAbility = jest.fn(); const generateUserAbility = jest.fn(() => 'ability');
global.strapi = { global.strapi = {
admin: { admin: {
@ -32,7 +32,11 @@ describe('Admin Auth Strategy', () => {
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token'); expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(findOne).toHaveBeenCalledWith({ where: { id: 1 }, populate: ['roles'] }); 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 () => { 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); 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.userAbility = userAbility;
ctx.state.user = user; ctx.state.user = user;
return { authenticated: true, credentials: user }; return {
authenticated: true,
credentials: user,
ability: userAbility,
};
}; };
/** @type {import('.').AuthStrategy} */ /** @type {import('.').AuthStrategy} */

View File

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

View File

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

View File

@ -6,6 +6,7 @@ const user = require('./user');
const role = require('./role'); const role = require('./role');
const usersPermissions = require('./users-permissions'); const usersPermissions = require('./users-permissions');
const providersRegistry = require('./providers-registry'); const providersRegistry = require('./providers-registry');
const permission = require('./permission');
module.exports = { module.exports = {
jwt, jwt,
@ -14,4 +15,5 @@ module.exports = {
role, role,
user, user,
'users-permissions': usersPermissions, '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); 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( await Promise.all(
toDelete.map(action => { toDelete.map(action => {
return strapi.query('plugin::users-permissions.permission').delete({ where: { action } }); return strapi.query('plugin::users-permissions.permission').delete({ where: { action } });

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { castArray, map } = require('lodash/fp'); const { castArray, map, every, pipe } = require('lodash/fp');
const { ForbiddenError, UnauthorizedError } = require('@strapi/utils').errors; const { ForbiddenError, UnauthorizedError } = require('@strapi/utils').errors;
const { getService } = require('../utils'); const { getService } = require('../utils');
@ -16,48 +16,61 @@ const authenticate = async ctx => {
if (token) { if (token) {
const { id } = token; const { id } = token;
// Invalid token
if (id === undefined) { if (id === undefined) {
return { authenticated: false }; return { authenticated: false };
} }
// fetch authenticated user
const user = await getService('user').fetchAuthenticatedUser(id); const user = await getService('user').fetchAuthenticatedUser(id);
// No user associated to the token
if (!user) { if (!user) {
return { error: 'Invalid credentials' }; return { error: 'Invalid credentials' };
} }
const advancedSettings = await getAdvancedSettings(); const advancedSettings = await getAdvancedSettings();
// User not confirmed
if (advancedSettings.email_confirmation && !user.confirmed) { if (advancedSettings.email_confirmation && !user.confirmed) {
return { error: 'Invalid credentials' }; return { error: 'Invalid credentials' };
} }
// User blocked
if (user.blocked) { if (user.blocked) {
return { error: 'Invalid credentials' }; 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; ctx.state.user = user;
return { return {
authenticated: true, authenticated: true,
credentials: user, credentials: user,
ability,
}; };
} }
const publicPermissions = await strapi.query('plugin::users-permissions.permission').findMany({ const publicPermissions = await getService('permission')
where: { .findPublicPermissions()
role: { type: 'public' }, .then(map(getService('permission').toContentAPIPermission));
},
});
if (publicPermissions.length === 0) { if (publicPermissions.length === 0) {
return { authenticated: false }; return { authenticated: false };
} }
const ability = await strapi.contentAPI.permissions.engine.generateAbility(publicPermissions);
return { return {
authenticated: true, authenticated: true,
credentials: null, credentials: null,
ability,
}; };
} catch (err) { } catch (err) {
return { authenticated: false }; return { authenticated: false };
@ -65,7 +78,7 @@ const authenticate = async ctx => {
}; };
const verify = async (auth, config) => { const verify = async (auth, config) => {
const { credentials: user } = auth; const { credentials: user, ability } = auth;
if (!config.scope) { if (!config.scope) {
if (!user) { if (!user) {
@ -77,18 +90,21 @@ const verify = async (auth, config) => {
} }
} }
let allowedActions = auth.allowedActions; // If no ability have been generated, then consider auth is missing
if (!ability) {
if (!allowedActions) { throw new UnauthorizedError();
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;
} }
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) { if (!isAllowed) {
throw new ForbiddenError(); throw new ForbiddenError();

View File

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