Merge pull request #17362 from strapi/chore/ts-permissions

Tested on 0.0.0-experimental.4b25b175f94cc15a4346702e9d9a729b9e5db70f
This commit is contained in:
Alexandre BODIN 2023-07-24 18:47:03 +02:00 committed by GitHub
commit f05ad73601
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 487 additions and 517 deletions

View File

@ -1,2 +1,4 @@
node_modules/
.eslintrc.js
dist/
jest.config.js

View File

@ -1,4 +1,4 @@
module.exports = {
root: true,
extends: ['custom/back'],
extends: ['custom/typescript'],
};

View File

@ -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;
}

View File

@ -2,5 +2,6 @@
module.exports = {
preset: '../../../jest-preset.unit.js',
testMatch: ['**/__tests__/**/*.test.ts'],
displayName: 'Core permissions',
};

View File

@ -1,7 +0,0 @@
'use strict';
const permission = require('./permission');
module.exports = {
permission,
};

View File

@ -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,
};

View File

@ -1,7 +0,0 @@
'use strict';
const { caslAbilityBuilder } = require('./casl-ability');
module.exports = {
caslAbilityBuilder,
};

View File

@ -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,
};

View File

@ -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();
},
};
},
};

View File

@ -1,9 +0,0 @@
'use strict';
const domain = require('./domain');
const engine = require('./engine');
module.exports = {
domain,
engine,
};

View File

@ -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"

View File

@ -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();
});
});
});

View File

@ -0,0 +1,3 @@
import * as permission from './permission';
export { permission };

View 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 };

View File

@ -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,
};

View File

@ -0,0 +1 @@
export * from './casl-ability';

View 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,
};

View 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 };

View File

@ -0,0 +1 @@
declare const strapi: any;

View File

@ -0,0 +1,7 @@
import * as domain from './domain';
import * as engine from './engine';
export = {
domain,
engine,
};

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,8 @@
{
"extends": "tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/**"]
}

View File

@ -7851,8 +7851,10 @@ __metadata:
dependencies:
"@casl/ability": 5.4.4
"@strapi/utils": 4.11.7
eslint-config-custom: 4.11.7
lodash: 4.17.21
sift: 16.0.1
tsconfig: 4.11.7
languageName: unknown
linkType: soft