mirror of
https://github.com/strapi/strapi.git
synced 2025-09-25 16:29:34 +00:00
Merge pull request #17362 from strapi/chore/ts-permissions
Tested on 0.0.0-experimental.4b25b175f94cc15a4346702e9d9a729b9e5db70f
This commit is contained in:
commit
f05ad73601
@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
.eslintrc.js
|
||||
dist/
|
||||
jest.config.js
|
||||
|
@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom/back'],
|
||||
extends: ['custom/typescript'],
|
||||
};
|
||||
|
54
packages/core/permissions/index.d.ts
vendored
54
packages/core/permissions/index.d.ts
vendored
@ -1,54 +0,0 @@
|
||||
import { hooks, providerFactory } from '@strapi/utils';
|
||||
|
||||
interface Permission {
|
||||
action: string;
|
||||
subject?: string | object | null;
|
||||
properties?: object;
|
||||
conditions?: string[];
|
||||
}
|
||||
|
||||
type Provider = ReturnType<typeof providerFactory>;
|
||||
|
||||
interface BaseAction {
|
||||
actionId: string;
|
||||
}
|
||||
|
||||
interface BaseCondition {
|
||||
name: string;
|
||||
handler(...params: unknown[]): boolean | object;
|
||||
}
|
||||
|
||||
interface ActionProvider<T extends Action = Action> extends Provider {}
|
||||
interface ConditionProvider<T extends Condition = Condition> extends Provider {}
|
||||
|
||||
interface PermissionEngineHooks {
|
||||
'before-format::validate.permission': ReturnType<typeof hooks.createAsyncBailHook>;
|
||||
'format.permission': ReturnType<typeof hooks.createAsyncSeriesWaterfallHook>;
|
||||
'after-format::validate.permission': ReturnType<typeof hooks.createAsyncBailHook>;
|
||||
'before-evaluate.permission': ReturnType<typeof hooks.createAsyncSeriesHook>;
|
||||
'before-register.permission': ReturnType<typeof hooks.createAsyncSeriesHook>;
|
||||
}
|
||||
|
||||
type PermissionEngineHookName = keyof PermissionEngineHooks;
|
||||
|
||||
interface PermissionEngine {
|
||||
hooks: object;
|
||||
|
||||
on(hook: PermissionEngineHookName, handler: Function): PermissionEngine;
|
||||
generateAbility(permissions: Permission[], options?: object): Ability;
|
||||
createRegisterFunction(can: Function, options: object): Function;
|
||||
}
|
||||
|
||||
interface BaseAbility {
|
||||
can: Function;
|
||||
}
|
||||
|
||||
interface AbilityBuilder {
|
||||
can(permission: Permission): void | Promise<void>;
|
||||
build(): BaseAbility | Promise<BaseAbility>;
|
||||
}
|
||||
|
||||
interface PermissionEngineParams {
|
||||
providers: { action: ActionProvider; condition: ConditionProvider };
|
||||
abilityBuilderFactory(): AbilityBuilder;
|
||||
}
|
@ -2,5 +2,6 @@
|
||||
|
||||
module.exports = {
|
||||
preset: '../../../jest-preset.unit.js',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
displayName: 'Core permissions',
|
||||
};
|
||||
|
@ -1,7 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const permission = require('./permission');
|
||||
|
||||
module.exports = {
|
||||
permission,
|
||||
};
|
@ -1,68 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash/fp');
|
||||
|
||||
const PERMISSION_FIELDS = ['action', 'subject', 'properties', 'conditions'];
|
||||
|
||||
const sanitizePermissionFields = _.pick(PERMISSION_FIELDS);
|
||||
|
||||
/**
|
||||
* @typedef {import("../../..").Permission} Permission
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a permission with default values for optional properties
|
||||
*
|
||||
* @return {Pick<Permission, 'conditions' | 'properties' | 'subject'>}
|
||||
*/
|
||||
const getDefaultPermission = () => ({
|
||||
conditions: [],
|
||||
properties: {},
|
||||
subject: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new permission based on given attributes
|
||||
*
|
||||
* @param {object} attributes
|
||||
*
|
||||
* @return {Permission}
|
||||
*/
|
||||
const create = _.pipe(_.pick(PERMISSION_FIELDS), _.merge(getDefaultPermission()));
|
||||
|
||||
/**
|
||||
* Add a condition to a permission
|
||||
*
|
||||
* @param {string} condition The condition to add
|
||||
* @param {Permission} permission The permission on which we want to add the condition
|
||||
*
|
||||
* @return {Permission}
|
||||
*/
|
||||
const addCondition = _.curry((condition, permission) => {
|
||||
const { conditions } = permission;
|
||||
|
||||
const newConditions = Array.isArray(conditions)
|
||||
? _.uniq(conditions.concat(condition))
|
||||
: [condition];
|
||||
|
||||
return _.set('conditions', newConditions, permission);
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets a property or a part of a property from a permission.
|
||||
*
|
||||
* @function
|
||||
*
|
||||
* @param {string} property - The property to get
|
||||
* @param {Permission} permission - The permission on which we want to access the property
|
||||
*
|
||||
* @return {Permission}
|
||||
*/
|
||||
const getProperty = _.curry((property, permission) => _.get(`properties.${property}`, permission));
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
sanitizePermissionFields,
|
||||
addCondition,
|
||||
getProperty,
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { caslAbilityBuilder } = require('./casl-ability');
|
||||
|
||||
module.exports = {
|
||||
caslAbilityBuilder,
|
||||
};
|
@ -1,97 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { cloneDeep, has } = require('lodash/fp');
|
||||
const { hooks } = require('@strapi/utils');
|
||||
|
||||
const domain = require('../domain');
|
||||
|
||||
/**
|
||||
* Create a hook map used by the permission Engine
|
||||
*
|
||||
* @return {import('../..').PermissionEngineHooks}
|
||||
*/
|
||||
const createEngineHooks = () => ({
|
||||
'before-format::validate.permission': hooks.createAsyncBailHook(),
|
||||
'format.permission': hooks.createAsyncSeriesWaterfallHook(),
|
||||
'after-format::validate.permission': hooks.createAsyncBailHook(),
|
||||
'before-evaluate.permission': hooks.createAsyncSeriesHook(),
|
||||
'before-register.permission': hooks.createAsyncSeriesHook(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a context from a domain {@link Permission} used by the validate hooks
|
||||
* @param {Permission} permission
|
||||
* @return {{ readonly permission: Permission }}
|
||||
*/
|
||||
const createValidateContext = (permission) => ({
|
||||
get permission() {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a context from a domain {@link Permission} used by the before valuate hook
|
||||
* @param {Permission} permission
|
||||
* @return {{readonly permission: Permission, addCondition(string): this}}
|
||||
*/
|
||||
const createBeforeEvaluateContext = (permission) => ({
|
||||
get permission() {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
|
||||
addCondition(condition) {
|
||||
Object.assign(permission, domain.permission.addCondition(condition, permission));
|
||||
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a context from a casl Permission & some options
|
||||
* @param caslPermission
|
||||
* @param {object} options
|
||||
* @param {Permission} options.permission
|
||||
* @param {object} options.user
|
||||
*/
|
||||
const createWillRegisterContext = ({ permission, options }) => ({
|
||||
...options,
|
||||
|
||||
get permission() {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
|
||||
condition: {
|
||||
and(rawConditionObject) {
|
||||
if (!permission.condition) {
|
||||
Object.assign(permission, { condition: { $and: [] } });
|
||||
}
|
||||
|
||||
permission.condition.$and.push(rawConditionObject);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
or(rawConditionObject) {
|
||||
if (!permission.condition) {
|
||||
Object.assign(permission, { condition: { $and: [] } });
|
||||
}
|
||||
|
||||
const orClause = permission.condition.$and.find(has('$or'));
|
||||
|
||||
if (orClause) {
|
||||
orClause.$or.push(rawConditionObject);
|
||||
} else {
|
||||
permission.condition.$and.push({ $or: [rawConditionObject] });
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createEngineHooks,
|
||||
createValidateContext,
|
||||
createBeforeEvaluateContext,
|
||||
createWillRegisterContext,
|
||||
};
|
@ -1,209 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash/fp');
|
||||
|
||||
const abilities = require('./abilities');
|
||||
|
||||
const {
|
||||
createEngineHooks,
|
||||
createWillRegisterContext,
|
||||
createBeforeEvaluateContext,
|
||||
createValidateContext,
|
||||
} = require('./hooks');
|
||||
|
||||
/**
|
||||
* @typedef {import("../..").PermissionEngine} PermissionEngine
|
||||
* @typedef {import("../..").ActionProvider} ActionProvider
|
||||
* @typedef {import("../..").ConditionProvider} ConditionProvider
|
||||
* @typedef {import("../..").PermissionEngineParams} PermissionEngineParams
|
||||
* @typedef {import("../..").Permission} Permission
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a default state object for the engine
|
||||
*/
|
||||
const createEngineState = () => {
|
||||
const hooks = createEngineHooks();
|
||||
|
||||
return { hooks };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
abilities,
|
||||
|
||||
/**
|
||||
* Create a new instance of a permission engine
|
||||
*
|
||||
* @param {PermissionEngineParams} params
|
||||
*
|
||||
* @return {PermissionEngine}
|
||||
*/
|
||||
new(params) {
|
||||
const { providers, abilityBuilderFactory = abilities.caslAbilityBuilder } = params;
|
||||
|
||||
const state = createEngineState();
|
||||
|
||||
const runValidationHook = async (hook, context) => state.hooks[hook].call(context);
|
||||
|
||||
/**
|
||||
* Evaluate a permission using local and registered behaviors (using hooks).
|
||||
* Validate, format (add condition, etc...), evaluate (evaluate conditions) and register a permission
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.options
|
||||
* @param {Function} params.register
|
||||
* @param {Permission} params.permission
|
||||
*/
|
||||
const evaluate = async (params) => {
|
||||
const { options, register } = params;
|
||||
|
||||
const preFormatValidation = await runValidationHook(
|
||||
'before-format::validate.permission',
|
||||
createBeforeEvaluateContext(params.permission)
|
||||
);
|
||||
|
||||
if (preFormatValidation === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = await state.hooks['format.permission'].call(params.permission);
|
||||
|
||||
const afterFormatValidation = await runValidationHook(
|
||||
'after-format::validate.permission',
|
||||
createValidateContext(permission)
|
||||
);
|
||||
|
||||
if (afterFormatValidation === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission));
|
||||
|
||||
const { action, subject, properties, conditions = [] } = permission;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
const resolveConditions = _.map(providers.condition.get);
|
||||
|
||||
const removeInvalidConditions = _.filter((condition) => _.isFunction(condition.handler));
|
||||
|
||||
const evaluateConditions = (conditions) => {
|
||||
return Promise.all(
|
||||
conditions.map(async (condition) => ({
|
||||
condition,
|
||||
result: await condition.handler(
|
||||
_.merge(options, { permission: _.cloneDeep(permission) })
|
||||
),
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const removeInvalidResults = _.filter(
|
||||
({ result }) => _.isBoolean(result) || _.isObject(result)
|
||||
);
|
||||
|
||||
const evaluatedConditions = await Promise.resolve(conditions)
|
||||
.then(resolveConditions)
|
||||
.then(removeInvalidConditions)
|
||||
.then(evaluateConditions)
|
||||
.then(removeInvalidResults);
|
||||
|
||||
const resultPropEq = _.propEq('result');
|
||||
const pickResults = _.map(_.prop('result'));
|
||||
|
||||
if (evaluatedConditions.every(resultPropEq(false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_.isEmpty(evaluatedConditions) || evaluatedConditions.some(resultPropEq(true))) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
const results = pickResults(evaluatedConditions).filter(_.isObject);
|
||||
|
||||
if (_.isEmpty(results)) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
return register({
|
||||
action,
|
||||
subject,
|
||||
properties,
|
||||
condition: { $and: [{ $or: results }] },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
get hooks() {
|
||||
return state.hooks;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a register function that wraps a `can` function
|
||||
* used to register a permission in the ability builder
|
||||
*
|
||||
* @param {Function} can
|
||||
* @param {object} options
|
||||
*
|
||||
* @return {Function}
|
||||
*/
|
||||
createRegisterFunction(can, options) {
|
||||
return async (permission) => {
|
||||
const hookContext = createWillRegisterContext({ options, permission });
|
||||
|
||||
await state.hooks['before-register.permission'].call(hookContext);
|
||||
|
||||
return can(permission);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a new handler for a given hook
|
||||
*
|
||||
* @param {string} hook
|
||||
* @param {Function} handler
|
||||
*
|
||||
* @return {this}
|
||||
*/
|
||||
on(hook, handler) {
|
||||
const validHooks = Object.keys(state.hooks);
|
||||
const isValidHook = validHooks.includes(hook);
|
||||
|
||||
if (!isValidHook) {
|
||||
throw new Error(
|
||||
`Invalid hook supplied when trying to register an handler to the permission engine. Got "${hook}" but expected one of ${validHooks.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
state.hooks[hook].register(handler);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an ability based on the instance's
|
||||
* ability builder and the given permissions
|
||||
*
|
||||
* @param {Permission[]} permissions
|
||||
* @param {object} [options]
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
async generateAbility(permissions, options = {}) {
|
||||
const { can, build } = abilityBuilderFactory();
|
||||
|
||||
for (const permission of permissions) {
|
||||
const register = this.createRegisterFunction(can, options);
|
||||
|
||||
await evaluate({ permission, options, register });
|
||||
}
|
||||
|
||||
return build();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const domain = require('./domain');
|
||||
const engine = require('./engine');
|
||||
|
||||
module.exports = {
|
||||
domain,
|
||||
engine,
|
||||
};
|
@ -19,8 +19,17 @@
|
||||
"url": "https://strapi.io"
|
||||
}
|
||||
],
|
||||
"main": "./lib/index.js",
|
||||
"files": [
|
||||
"./dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "run -T tsc",
|
||||
"build:ts": "run build",
|
||||
"watch": "run -T tsc -w --preserveWatchOutput",
|
||||
"clean": "run -T rimraf ./dist",
|
||||
"prepublishOnly": "yarn clean && yarn build",
|
||||
"test:unit": "run -T jest",
|
||||
"test:unit:watch": "run -T jest --watch",
|
||||
"lint": "run -T eslint ."
|
||||
@ -31,6 +40,10 @@
|
||||
"lodash": "4.17.21",
|
||||
"sift": "16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-custom": "4.11.7",
|
||||
"tsconfig": "4.11.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <=20.x.x",
|
||||
"npm": ">=6.0.0"
|
||||
|
@ -1,8 +1,14 @@
|
||||
'use strict';
|
||||
import _ from 'lodash';
|
||||
import { subject } from '@casl/ability';
|
||||
import { providerFactory } from '@strapi/utils';
|
||||
import permissions from '..';
|
||||
import type { HookName } from '../engine/hooks';
|
||||
import type { Permission } from '../domain/permission';
|
||||
|
||||
const _ = require('lodash');
|
||||
const { subject } = require('@casl/ability');
|
||||
const permissions = require('..');
|
||||
interface EngineHook {
|
||||
name: HookName;
|
||||
fn: (permission: Permission) => unknown;
|
||||
}
|
||||
|
||||
describe('Permissions Engine', () => {
|
||||
const allowedCondition = 'plugin::test.isAuthor';
|
||||
@ -35,27 +41,27 @@ describe('Permissions Engine', () => {
|
||||
];
|
||||
|
||||
const providers = {
|
||||
condition: {
|
||||
get(condition) {
|
||||
const c = conditions.find((c) => c.name === condition);
|
||||
if (c) return c;
|
||||
return {
|
||||
async handler() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
action: providerFactory(),
|
||||
condition: providerFactory(),
|
||||
};
|
||||
|
||||
Object.assign(providers.condition, {
|
||||
get(condition) {
|
||||
const c = conditions.find((c) => c.name === condition);
|
||||
if (c) return c;
|
||||
return {
|
||||
async handler() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an engine hook function that rejects a specific action
|
||||
*
|
||||
* @param {string} action
|
||||
*
|
||||
* @return {(params) => boolean | undefined)}
|
||||
*/
|
||||
const generateInvalidateActionHook = (action) => {
|
||||
const generateInvalidateActionHook = (action: string) => {
|
||||
return (params) => {
|
||||
if (params.permission.action === action) {
|
||||
return false;
|
||||
@ -65,13 +71,8 @@ describe('Permissions Engine', () => {
|
||||
|
||||
/**
|
||||
* build an engine and add all given hooks
|
||||
*
|
||||
* @param {PermissionEngineParams} params
|
||||
* @param {string} action
|
||||
*
|
||||
* @return {PermissionEngine}
|
||||
*/
|
||||
const buildEngineWithHooks = (params = { providers }, engineHooks = []) => {
|
||||
const buildEngineWithHooks = (params = { providers }, engineHooks: EngineHook[] = []) => {
|
||||
const engine = permissions.engine.new(params);
|
||||
engineHooks.forEach(({ name, fn }) => {
|
||||
engine.on(name, fn);
|
||||
@ -81,24 +82,19 @@ describe('Permissions Engine', () => {
|
||||
|
||||
/**
|
||||
* build an engine, add all given hooks, and generate an ability
|
||||
*
|
||||
* @param {PermissionEngineParams} params
|
||||
* @param {string} action
|
||||
*
|
||||
* @return {{
|
||||
* engine: PermissionEngine,
|
||||
* ability: Ability,
|
||||
* createRegisterFunction: jest.Mock<jest.Mock<any, any[]>, [can?: any, options?: any]>,
|
||||
* registerFunction: [jest.Mock<Function, [import('../../').Permission]>]
|
||||
* }}
|
||||
*/
|
||||
const buildEngineWithAbility = async ({
|
||||
permissions,
|
||||
engineProviders = providers,
|
||||
engineHooks,
|
||||
abilityOptions,
|
||||
engineHooks = [],
|
||||
abilityOptions = {},
|
||||
}: {
|
||||
permissions: Permission[];
|
||||
engineHooks?: EngineHook[];
|
||||
engineProviders?: { action: any; condition: any };
|
||||
abilityOptions?: Record<string, unknown>;
|
||||
}) => {
|
||||
const registerFunctions = [];
|
||||
const registerFunctions: jest.Mock[] = [];
|
||||
const engine = buildEngineWithHooks({ providers: engineProviders }, engineHooks);
|
||||
const engineCrf = engine.createRegisterFunction;
|
||||
const createRegisterFunction = jest
|
||||
@ -156,8 +152,8 @@ describe('Permissions Engine', () => {
|
||||
permissions,
|
||||
});
|
||||
|
||||
expect(ability.can('read')).toBeTruthy();
|
||||
expect(ability.can('i_dont_exist')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeTruthy();
|
||||
expect(ability.can('i_dont_exist', 'all')).toBeFalsy();
|
||||
|
||||
expect(createRegisterFunction).toBeCalledTimes(2);
|
||||
expect(registerFunctions[0]).toBeCalledWith(permissions[0]);
|
||||
@ -173,7 +169,7 @@ describe('Permissions Engine', () => {
|
||||
permissions,
|
||||
});
|
||||
|
||||
expect(ability.can('read')).toBeTruthy();
|
||||
expect(ability.can('read', 'all')).toBeTruthy();
|
||||
|
||||
expect(createRegisterFunction).toBeCalledTimes(2);
|
||||
expect(registerFunctions[0]).toBeCalledWith(permissions[0]);
|
||||
@ -229,7 +225,7 @@ describe('Permissions Engine', () => {
|
||||
it('registers action with subject and properties', async () => {
|
||||
const permissions = [{ action: 'read', subject: 'article', properties: { fields: ['title'] } }];
|
||||
const { ability, registerFunctions } = await buildEngineWithAbility({ permissions });
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('read', 'user')).toBeFalsy();
|
||||
expect(ability.can('read', 'article')).toBeTruthy();
|
||||
expect(ability.can('read', 'article', 'title')).toBeTruthy();
|
||||
@ -256,7 +252,7 @@ describe('Permissions Engine', () => {
|
||||
|
||||
expect(ability.rules).toMatchObject(expectedAbilityRules(permissions));
|
||||
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('read', 'user')).toBeFalsy();
|
||||
expect(ability.can('read', 'article')).toBeTruthy();
|
||||
expect(ability.can('read', 'article', 'title')).toBeTruthy();
|
||||
@ -290,7 +286,7 @@ describe('Permissions Engine', () => {
|
||||
|
||||
expect(ability.rules).toMatchObject(expectedAbilityRules(expectedPermissions));
|
||||
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('read', 'user')).toBeFalsy();
|
||||
expect(ability.can('read', 'article', 'name')).toBeFalsy();
|
||||
|
||||
@ -316,7 +312,7 @@ describe('Permissions Engine', () => {
|
||||
|
||||
expect(ability.rules).toMatchObject(expectedAbilityRules(permissions));
|
||||
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('read', 'user')).toBeFalsy();
|
||||
expect(ability.can('read', 'article', 'name')).toBeFalsy();
|
||||
|
||||
@ -347,8 +343,8 @@ describe('Permissions Engine', () => {
|
||||
|
||||
expect(ability.rules).toMatchObject(expectedAbilityRules(newPermissions));
|
||||
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('read', 'all')).toBeFalsy();
|
||||
expect(ability.can('view', 'article')).toBeTruthy();
|
||||
expect(registerFunctions[0]).toBeCalledWith(newPermissions[0]);
|
||||
});
|
||||
@ -460,11 +456,11 @@ describe('Permissions Engine', () => {
|
||||
|
||||
expect(ability.rules).toMatchObject(expectedAbilityRules(newPermissions));
|
||||
|
||||
expect(ability.can('update')).toBeFalsy();
|
||||
expect(ability.can('modify')).toBeTruthy();
|
||||
expect(ability.can('delete')).toBeFalsy();
|
||||
expect(ability.can('remove')).toBeTruthy();
|
||||
expect(ability.can('view')).toBeFalsy();
|
||||
expect(ability.can('update', 'all')).toBeFalsy();
|
||||
expect(ability.can('modify', 'all')).toBeTruthy();
|
||||
expect(ability.can('delete', 'all')).toBeFalsy();
|
||||
expect(ability.can('remove', 'all')).toBeTruthy();
|
||||
expect(ability.can('view', 'all')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
3
packages/core/permissions/src/domain/index.ts
Normal file
3
packages/core/permissions/src/domain/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import * as permission from './permission';
|
||||
|
||||
export { permission };
|
53
packages/core/permissions/src/domain/permission/index.ts
Normal file
53
packages/core/permissions/src/domain/permission/index.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import _ from 'lodash/fp';
|
||||
|
||||
const PERMISSION_FIELDS = ['action', 'subject', 'properties', 'conditions'] as const;
|
||||
|
||||
const sanitizePermissionFields = _.pick(PERMISSION_FIELDS);
|
||||
|
||||
export interface Permission {
|
||||
action: string;
|
||||
subject?: string | object | null;
|
||||
properties?: object;
|
||||
conditions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a permission with default values for optional properties
|
||||
*/
|
||||
const getDefaultPermission = (): Pick<Permission, 'conditions' | 'properties' | 'subject'> => ({
|
||||
conditions: [],
|
||||
properties: {},
|
||||
subject: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new permission based on given attributes
|
||||
*
|
||||
* @param {object} attributes
|
||||
*/
|
||||
const create = _.pipe(_.pick(PERMISSION_FIELDS), _.merge(getDefaultPermission()));
|
||||
|
||||
/**
|
||||
* Add a condition to a permission
|
||||
*/
|
||||
const addCondition = _.curry((condition: string, permission: Permission): Permission => {
|
||||
const { conditions } = permission;
|
||||
|
||||
const newConditions = Array.isArray(conditions)
|
||||
? _.uniq(conditions.concat(condition))
|
||||
: [condition];
|
||||
|
||||
return _.set('conditions', newConditions, permission);
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets a property or a part of a property from a permission.
|
||||
*/
|
||||
const getProperty = _.curry(
|
||||
<T extends keyof Permission['properties']>(
|
||||
property: T,
|
||||
permission: Permission
|
||||
): Permission['properties'][T] => _.get(`properties.${property}`, permission)
|
||||
);
|
||||
|
||||
export { create, sanitizePermissionFields, addCondition, getProperty };
|
@ -1,8 +1,20 @@
|
||||
'use strict';
|
||||
import * as sift from 'sift';
|
||||
import { AbilityBuilder, Ability, Subject } from '@casl/ability';
|
||||
import { pick, isNil, isObject } from 'lodash/fp';
|
||||
|
||||
const sift = require('sift');
|
||||
const { AbilityBuilder, Ability } = require('@casl/ability');
|
||||
const { pick, isNil, isObject } = require('lodash/fp');
|
||||
export interface PermissionRule {
|
||||
action: string;
|
||||
subject?: Subject | null;
|
||||
properties?: {
|
||||
fields?: string[];
|
||||
};
|
||||
condition?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CustomAbilityBuilder {
|
||||
can(permission: PermissionRule): ReturnType<AbilityBuilder<Ability>['can']>;
|
||||
build(): Ability;
|
||||
}
|
||||
|
||||
const allowedOperations = [
|
||||
'$or',
|
||||
@ -17,22 +29,22 @@ const allowedOperations = [
|
||||
'$gte',
|
||||
'$exists',
|
||||
'$elemMatch',
|
||||
];
|
||||
] as const;
|
||||
|
||||
const operations = pick(allowedOperations, sift);
|
||||
|
||||
const conditionsMatcher = (conditions) => {
|
||||
const conditionsMatcher = (conditions: unknown) => {
|
||||
return sift.createQueryTester(conditions, { operations });
|
||||
};
|
||||
|
||||
/**
|
||||
* Casl Ability Builder.
|
||||
*/
|
||||
const caslAbilityBuilder = () => {
|
||||
export const caslAbilityBuilder = (): CustomAbilityBuilder => {
|
||||
const { can, build, ...rest } = new AbilityBuilder(Ability);
|
||||
|
||||
return {
|
||||
can(permission) {
|
||||
can(permission: PermissionRule) {
|
||||
const { action, subject, properties = {}, condition } = permission;
|
||||
const { fields } = properties;
|
||||
|
||||
@ -51,7 +63,3 @@ const caslAbilityBuilder = () => {
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
caslAbilityBuilder,
|
||||
};
|
1
packages/core/permissions/src/engine/abilities/index.ts
Normal file
1
packages/core/permissions/src/engine/abilities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './casl-ability';
|
107
packages/core/permissions/src/engine/hooks.ts
Normal file
107
packages/core/permissions/src/engine/hooks.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { cloneDeep, has, isArray } from 'lodash/fp';
|
||||
import { hooks } from '@strapi/utils';
|
||||
|
||||
import * as domain from '../domain';
|
||||
import type { Permission } from '../domain/permission';
|
||||
import type { PermissionRule } from './abilities';
|
||||
|
||||
export interface PermissionEngineHooks {
|
||||
'before-format::validate.permission': ReturnType<typeof hooks.createAsyncBailHook>;
|
||||
'format.permission': ReturnType<typeof hooks.createAsyncSeriesWaterfallHook>;
|
||||
'after-format::validate.permission': ReturnType<typeof hooks.createAsyncBailHook>;
|
||||
'before-evaluate.permission': ReturnType<typeof hooks.createAsyncSeriesHook>;
|
||||
'before-register.permission': ReturnType<typeof hooks.createAsyncSeriesHook>;
|
||||
}
|
||||
|
||||
export type HookName = keyof PermissionEngineHooks;
|
||||
|
||||
/**
|
||||
* Create a hook map used by the permission Engine
|
||||
*/
|
||||
const createEngineHooks = (): PermissionEngineHooks => ({
|
||||
'before-format::validate.permission': hooks.createAsyncBailHook(),
|
||||
'format.permission': hooks.createAsyncSeriesWaterfallHook(),
|
||||
'after-format::validate.permission': hooks.createAsyncBailHook(),
|
||||
'before-evaluate.permission': hooks.createAsyncSeriesHook(),
|
||||
'before-register.permission': hooks.createAsyncSeriesHook(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a context from a domain {@link Permission} used by the validate hooks
|
||||
*/
|
||||
const createValidateContext = (permission: Permission) => ({
|
||||
get permission(): Readonly<Permission> {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a context from a domain {@link Permission} used by the before valuate hook
|
||||
*/
|
||||
const createBeforeEvaluateContext = (permission: Permission) => ({
|
||||
get permission(): Readonly<Permission> {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
|
||||
addCondition(condition: string) {
|
||||
Object.assign(permission, domain.permission.addCondition(condition, permission));
|
||||
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
interface WillRegisterContextParams {
|
||||
permission: PermissionRule;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a context from a casl Permission & some options
|
||||
* @param caslPermission
|
||||
*/
|
||||
const createWillRegisterContext = ({ permission, options }: WillRegisterContextParams) => ({
|
||||
...options,
|
||||
|
||||
get permission() {
|
||||
return cloneDeep(permission);
|
||||
},
|
||||
|
||||
condition: {
|
||||
and(rawConditionObject: unknown) {
|
||||
if (!permission.condition) {
|
||||
permission.condition = { $and: [] };
|
||||
}
|
||||
|
||||
if (isArray(permission.condition.$and)) {
|
||||
permission.condition.$and.push(rawConditionObject);
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
or(rawConditionObject: unknown) {
|
||||
if (!permission.condition) {
|
||||
permission.condition = { $and: [] };
|
||||
}
|
||||
|
||||
if (isArray(permission.condition.$and)) {
|
||||
const orClause = permission.condition.$and.find(has('$or'));
|
||||
|
||||
if (orClause) {
|
||||
orClause.$or.push(rawConditionObject);
|
||||
} else {
|
||||
permission.condition.$and.push({ $or: [rawConditionObject] });
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export {
|
||||
createEngineHooks,
|
||||
createValidateContext,
|
||||
createBeforeEvaluateContext,
|
||||
createWillRegisterContext,
|
||||
};
|
211
packages/core/permissions/src/engine/index.ts
Normal file
211
packages/core/permissions/src/engine/index.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import _ from 'lodash/fp';
|
||||
import { Ability } from '@casl/ability';
|
||||
import { providerFactory } from '@strapi/utils';
|
||||
|
||||
import {
|
||||
createEngineHooks,
|
||||
createWillRegisterContext,
|
||||
createBeforeEvaluateContext,
|
||||
createValidateContext,
|
||||
} from './hooks';
|
||||
import type { PermissionEngineHooks, HookName } from './hooks';
|
||||
|
||||
import * as abilities from './abilities';
|
||||
import { Permission } from '../domain/permission';
|
||||
|
||||
export { abilities };
|
||||
|
||||
type Provider = ReturnType<typeof providerFactory>;
|
||||
type ActionProvider = Provider;
|
||||
type ConditionProvider = Provider;
|
||||
|
||||
export interface Engine {
|
||||
hooks: PermissionEngineHooks;
|
||||
on(hook: HookName, handler: (...args: unknown[]) => unknown): Engine;
|
||||
generateAbility(permissions: Permission[], options?: object): Promise<Ability>;
|
||||
createRegisterFunction(
|
||||
can: (permission: abilities.PermissionRule) => unknown,
|
||||
options: Record<string, unknown>
|
||||
): (permission: abilities.PermissionRule) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface EngineParams {
|
||||
providers: { action: ActionProvider; condition: ConditionProvider };
|
||||
abilityBuilderFactory?(): abilities.CustomAbilityBuilder;
|
||||
}
|
||||
|
||||
interface EvaluateParams {
|
||||
options: Record<string, unknown>;
|
||||
register: (permission: abilities.PermissionRule) => Promise<unknown>;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
name: string;
|
||||
handler(...params: unknown[]): boolean | object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default state object for the engine
|
||||
*/
|
||||
const createEngineState = () => {
|
||||
const hooks = createEngineHooks();
|
||||
|
||||
return { hooks };
|
||||
};
|
||||
|
||||
const newEngine = (params: EngineParams): Engine => {
|
||||
const { providers, abilityBuilderFactory = abilities.caslAbilityBuilder } = params;
|
||||
|
||||
const state = createEngineState();
|
||||
|
||||
const runValidationHook = async (hook: HookName, context: unknown) =>
|
||||
state.hooks[hook].call(context);
|
||||
|
||||
/**
|
||||
* Evaluate a permission using local and registered behaviors (using hooks).
|
||||
* Validate, format (add condition, etc...), evaluate (evaluate conditions) and register a permission
|
||||
*/
|
||||
const evaluate = async (params: EvaluateParams) => {
|
||||
const { options, register } = params;
|
||||
|
||||
const preFormatValidation = await runValidationHook(
|
||||
'before-format::validate.permission',
|
||||
createBeforeEvaluateContext(params.permission)
|
||||
);
|
||||
|
||||
if (preFormatValidation === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = (await state.hooks['format.permission'].call(
|
||||
params.permission
|
||||
)) as Permission;
|
||||
|
||||
const afterFormatValidation = await runValidationHook(
|
||||
'after-format::validate.permission',
|
||||
createValidateContext(permission)
|
||||
);
|
||||
|
||||
if (afterFormatValidation === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await state.hooks['before-evaluate.permission'].call(createBeforeEvaluateContext(permission));
|
||||
|
||||
const { action, subject, properties, conditions = [] } = permission;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
const resolveConditions = _.map(providers.condition.get);
|
||||
|
||||
const removeInvalidConditions = _.filter((condition: Condition) =>
|
||||
_.isFunction(condition.handler)
|
||||
);
|
||||
|
||||
const evaluateConditions = (conditions: Condition[]) => {
|
||||
return Promise.all(
|
||||
conditions.map(async (condition) => ({
|
||||
condition,
|
||||
result: await condition.handler(
|
||||
_.merge(options, { permission: _.cloneDeep(permission) })
|
||||
),
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const removeInvalidResults = _.filter(
|
||||
({ result }) => _.isBoolean(result) || _.isObject(result)
|
||||
);
|
||||
|
||||
const evaluatedConditions = await Promise.resolve(conditions)
|
||||
.then(resolveConditions)
|
||||
.then(removeInvalidConditions)
|
||||
.then(evaluateConditions)
|
||||
.then(removeInvalidResults);
|
||||
|
||||
const resultPropEq = _.propEq('result');
|
||||
const pickResults = _.map(_.prop('result'));
|
||||
|
||||
if (evaluatedConditions.every(resultPropEq(false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_.isEmpty(evaluatedConditions) || evaluatedConditions.some(resultPropEq(true))) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
const results = pickResults(evaluatedConditions).filter(_.isObject);
|
||||
|
||||
if (_.isEmpty(results)) {
|
||||
return register({ action, subject, properties });
|
||||
}
|
||||
|
||||
return register({
|
||||
action,
|
||||
subject,
|
||||
properties,
|
||||
condition: { $and: [{ $or: results }] },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
get hooks() {
|
||||
return state.hooks;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a register function that wraps a `can` function
|
||||
* used to register a permission in the ability builder
|
||||
*/
|
||||
createRegisterFunction(can, options: Record<string, unknown>) {
|
||||
return async (permission: abilities.PermissionRule) => {
|
||||
const hookContext = createWillRegisterContext({ options, permission });
|
||||
|
||||
await state.hooks['before-register.permission'].call(hookContext);
|
||||
|
||||
return can(permission);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a new handler for a given hook
|
||||
*/
|
||||
on(hook, handler) {
|
||||
const validHooks = Object.keys(state.hooks);
|
||||
const isValidHook = validHooks.includes(hook);
|
||||
|
||||
if (!isValidHook) {
|
||||
throw new Error(
|
||||
`Invalid hook supplied when trying to register an handler to the permission engine. Got "${hook}" but expected one of ${validHooks.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
state.hooks[hook].register(handler);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an ability based on the instance's
|
||||
* ability builder and the given permissions
|
||||
*/
|
||||
async generateAbility(permissions, options: Record<string, unknown> = {}) {
|
||||
const { can, build } = abilityBuilderFactory();
|
||||
|
||||
for (const permission of permissions) {
|
||||
const register = this.createRegisterFunction(can, options);
|
||||
|
||||
await evaluate({ permission, options, register });
|
||||
}
|
||||
|
||||
return build();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { newEngine as new };
|
1
packages/core/permissions/src/global.d.ts
vendored
Normal file
1
packages/core/permissions/src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare const strapi: any;
|
7
packages/core/permissions/src/index.ts
Normal file
7
packages/core/permissions/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import * as domain from './domain';
|
||||
import * as engine from './engine';
|
||||
|
||||
export = {
|
||||
domain,
|
||||
engine,
|
||||
};
|
8
packages/core/permissions/tsconfig.eslint.json
Normal file
8
packages/core/permissions/tsconfig.eslint.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
8
packages/core/permissions/tsconfig.json
Normal file
8
packages/core/permissions/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "**/__tests__/**"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user