diff --git a/packages/core/permissions/.eslintignore b/packages/core/permissions/.eslintignore index 1723d82cf5..1178bea959 100644 --- a/packages/core/permissions/.eslintignore +++ b/packages/core/permissions/.eslintignore @@ -1,2 +1,4 @@ node_modules/ .eslintrc.js +dist/ +jest.config.js diff --git a/packages/core/permissions/.eslintrc.js b/packages/core/permissions/.eslintrc.js index 7d02bb5dfe..a1dca8d1af 100644 --- a/packages/core/permissions/.eslintrc.js +++ b/packages/core/permissions/.eslintrc.js @@ -1,4 +1,4 @@ module.exports = { root: true, - extends: ['custom/back'], + extends: ['custom/typescript'], }; diff --git a/packages/core/permissions/index.d.ts b/packages/core/permissions/index.d.ts deleted file mode 100644 index 821ccca5a3..0000000000 --- a/packages/core/permissions/index.d.ts +++ /dev/null @@ -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; - -interface BaseAction { - actionId: string; -} - -interface BaseCondition { - name: string; - handler(...params: unknown[]): boolean | object; -} - -interface ActionProvider extends Provider {} -interface ConditionProvider extends Provider {} - -interface PermissionEngineHooks { - 'before-format::validate.permission': ReturnType; - 'format.permission': ReturnType; - 'after-format::validate.permission': ReturnType; - 'before-evaluate.permission': ReturnType; - 'before-register.permission': ReturnType; -} - -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; - build(): BaseAbility | Promise; -} - -interface PermissionEngineParams { - providers: { action: ActionProvider; condition: ConditionProvider }; - abilityBuilderFactory(): AbilityBuilder; -} diff --git a/packages/core/permissions/jest.config.js b/packages/core/permissions/jest.config.js index 65395f7593..fda832edc8 100644 --- a/packages/core/permissions/jest.config.js +++ b/packages/core/permissions/jest.config.js @@ -2,5 +2,6 @@ module.exports = { preset: '../../../jest-preset.unit.js', + testMatch: ['**/__tests__/**/*.test.ts'], displayName: 'Core permissions', }; diff --git a/packages/core/permissions/lib/domain/index.js b/packages/core/permissions/lib/domain/index.js deleted file mode 100644 index e525bcd84f..0000000000 --- a/packages/core/permissions/lib/domain/index.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -const permission = require('./permission'); - -module.exports = { - permission, -}; diff --git a/packages/core/permissions/lib/domain/permission/index.js b/packages/core/permissions/lib/domain/permission/index.js deleted file mode 100644 index d2b67b03fd..0000000000 --- a/packages/core/permissions/lib/domain/permission/index.js +++ /dev/null @@ -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} - */ -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, -}; diff --git a/packages/core/permissions/lib/engine/abilities/index.js b/packages/core/permissions/lib/engine/abilities/index.js deleted file mode 100644 index c1e5c9ca85..0000000000 --- a/packages/core/permissions/lib/engine/abilities/index.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -const { caslAbilityBuilder } = require('./casl-ability'); - -module.exports = { - caslAbilityBuilder, -}; diff --git a/packages/core/permissions/lib/engine/hooks.js b/packages/core/permissions/lib/engine/hooks.js deleted file mode 100644 index b94661082b..0000000000 --- a/packages/core/permissions/lib/engine/hooks.js +++ /dev/null @@ -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, -}; diff --git a/packages/core/permissions/lib/engine/index.js b/packages/core/permissions/lib/engine/index.js deleted file mode 100644 index 1b5fc73d56..0000000000 --- a/packages/core/permissions/lib/engine/index.js +++ /dev/null @@ -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(); - }, - }; - }, -}; diff --git a/packages/core/permissions/lib/index.js b/packages/core/permissions/lib/index.js deleted file mode 100644 index 17986350be..0000000000 --- a/packages/core/permissions/lib/index.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -const domain = require('./domain'); -const engine = require('./engine'); - -module.exports = { - domain, - engine, -}; diff --git a/packages/core/permissions/package.json b/packages/core/permissions/package.json index 8fb3175503..cd97dba418 100644 --- a/packages/core/permissions/package.json +++ b/packages/core/permissions/package.json @@ -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" diff --git a/packages/core/permissions/lib/__tests__/permissions.engine.test.js b/packages/core/permissions/src/__tests__/permissions.engine.test.ts similarity index 89% rename from packages/core/permissions/lib/__tests__/permissions.engine.test.js rename to packages/core/permissions/src/__tests__/permissions.engine.test.ts index 5b4fdcd9d5..16f4ef3255 100644 --- a/packages/core/permissions/lib/__tests__/permissions.engine.test.js +++ b/packages/core/permissions/src/__tests__/permissions.engine.test.ts @@ -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, [can?: any, options?: any]>, - * registerFunction: [jest.Mock] - * }} */ const buildEngineWithAbility = async ({ permissions, engineProviders = providers, - engineHooks, - abilityOptions, + engineHooks = [], + abilityOptions = {}, + }: { + permissions: Permission[]; + engineHooks?: EngineHook[]; + engineProviders?: { action: any; condition: any }; + abilityOptions?: Record; }) => { - 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(); }); }); }); diff --git a/packages/core/permissions/src/domain/index.ts b/packages/core/permissions/src/domain/index.ts new file mode 100644 index 0000000000..e603c25886 --- /dev/null +++ b/packages/core/permissions/src/domain/index.ts @@ -0,0 +1,3 @@ +import * as permission from './permission'; + +export { permission }; diff --git a/packages/core/permissions/src/domain/permission/index.ts b/packages/core/permissions/src/domain/permission/index.ts new file mode 100644 index 0000000000..3e95d249eb --- /dev/null +++ b/packages/core/permissions/src/domain/permission/index.ts @@ -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 => ({ + 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( + ( + property: T, + permission: Permission + ): Permission['properties'][T] => _.get(`properties.${property}`, permission) +); + +export { create, sanitizePermissionFields, addCondition, getProperty }; diff --git a/packages/core/permissions/lib/engine/abilities/casl-ability.js b/packages/core/permissions/src/engine/abilities/casl-ability.ts similarity index 54% rename from packages/core/permissions/lib/engine/abilities/casl-ability.js rename to packages/core/permissions/src/engine/abilities/casl-ability.ts index aaabe4b9ad..f4fa9a3d7e 100644 --- a/packages/core/permissions/lib/engine/abilities/casl-ability.js +++ b/packages/core/permissions/src/engine/abilities/casl-ability.ts @@ -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; +} + +export interface CustomAbilityBuilder { + can(permission: PermissionRule): ReturnType['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, -}; diff --git a/packages/core/permissions/src/engine/abilities/index.ts b/packages/core/permissions/src/engine/abilities/index.ts new file mode 100644 index 0000000000..75b03c863d --- /dev/null +++ b/packages/core/permissions/src/engine/abilities/index.ts @@ -0,0 +1 @@ +export * from './casl-ability'; diff --git a/packages/core/permissions/src/engine/hooks.ts b/packages/core/permissions/src/engine/hooks.ts new file mode 100644 index 0000000000..24e6e0cf43 --- /dev/null +++ b/packages/core/permissions/src/engine/hooks.ts @@ -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; + 'format.permission': ReturnType; + 'after-format::validate.permission': ReturnType; + 'before-evaluate.permission': ReturnType; + 'before-register.permission': ReturnType; +} + +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 { + return cloneDeep(permission); + }, +}); + +/** + * Create a context from a domain {@link Permission} used by the before valuate hook + */ +const createBeforeEvaluateContext = (permission: Permission) => ({ + get permission(): Readonly { + return cloneDeep(permission); + }, + + addCondition(condition: string) { + Object.assign(permission, domain.permission.addCondition(condition, permission)); + + return this; + }, +}); + +interface WillRegisterContextParams { + permission: PermissionRule; + options: Record; +} + +/** + * 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, +}; diff --git a/packages/core/permissions/src/engine/index.ts b/packages/core/permissions/src/engine/index.ts new file mode 100644 index 0000000000..34e034cd73 --- /dev/null +++ b/packages/core/permissions/src/engine/index.ts @@ -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; +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; + createRegisterFunction( + can: (permission: abilities.PermissionRule) => unknown, + options: Record + ): (permission: abilities.PermissionRule) => Promise; +} + +export interface EngineParams { + providers: { action: ActionProvider; condition: ConditionProvider }; + abilityBuilderFactory?(): abilities.CustomAbilityBuilder; +} + +interface EvaluateParams { + options: Record; + register: (permission: abilities.PermissionRule) => Promise; + 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) { + 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 = {}) { + 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 }; diff --git a/packages/core/permissions/src/global.d.ts b/packages/core/permissions/src/global.d.ts new file mode 100644 index 0000000000..71780f90e0 --- /dev/null +++ b/packages/core/permissions/src/global.d.ts @@ -0,0 +1 @@ +declare const strapi: any; diff --git a/packages/core/permissions/src/index.ts b/packages/core/permissions/src/index.ts new file mode 100644 index 0000000000..8d6c12e778 --- /dev/null +++ b/packages/core/permissions/src/index.ts @@ -0,0 +1,7 @@ +import * as domain from './domain'; +import * as engine from './engine'; + +export = { + domain, + engine, +}; diff --git a/packages/core/permissions/tsconfig.eslint.json b/packages/core/permissions/tsconfig.eslint.json new file mode 100644 index 0000000000..b531808514 --- /dev/null +++ b/packages/core/permissions/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/core/permissions/tsconfig.json b/packages/core/permissions/tsconfig.json new file mode 100644 index 0000000000..f6e9b92e7e --- /dev/null +++ b/packages/core/permissions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/**"] +} diff --git a/yarn.lock b/yarn.lock index 09963feb62..4d53c52f92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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