import { Action } from '@nocobase/resourcer'; import EventEmitter from 'events'; import compose from 'koa-compose'; import lodash from 'lodash'; import { AclAvailableAction, AvailableActionOptions } from './acl-available-action'; import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy'; import { ACLRole, RoleActionParams } from './acl-role'; import { SkipManager } from './skip-manager'; const parse = require('json-templates'); interface CanResult { role: string; resource: string; action: string; params?: any; } export interface DefineOptions { role: string; allowConfigure?: boolean; strategy?: string | Omit; actions?: { [key: string]: RoleActionParams; }; routes?: any; } export interface ListenerContext { acl: ACL; role: ACLRole; path: string; actionName: string; resourceName: string; params: RoleActionParams; } type Listener = (ctx: ListenerContext) => void; interface CanArgs { role: string; resource: string; action: string; } export class ACL extends EventEmitter { protected availableActions = new Map(); protected availableStrategy = new Map(); protected middlewares = []; public skipManager = new SkipManager(this); roles = new Map(); actionAlias = new Map(); configResources: string[] = []; constructor() { super(); this.beforeGrantAction((ctx) => { if (lodash.isPlainObject(ctx.params) && ctx.params.own) { ctx.params = lodash.merge(ctx.params, predicate.own); } }); this.beforeGrantAction((ctx) => { const actionName = this.resolveActionAlias(ctx.actionName); if (lodash.isPlainObject(ctx.params)) { if ((actionName === 'create' || actionName === 'update') && ctx.params.fields) { ctx.params = { ...lodash.omit(ctx.params, 'fields'), whitelist: ctx.params.fields, }; } if (actionName === 'view' && ctx.params.fields) { const appendFields = ['id', 'createdAt', 'updatedAt']; ctx.params = { ...lodash.omit(ctx.params, 'fields'), fields: [...ctx.params.fields, ...appendFields], }; } } }); this.middlewares.push(this.skipManager.aclMiddleware()); } define(options: DefineOptions): ACLRole { const roleName = options.role; const role = new ACLRole(this, roleName); if (options.strategy) { role.strategy = options.strategy; } const actions = options.actions || {}; for (const [actionName, actionParams] of Object.entries(actions)) { role.grantAction(actionName, actionParams); } this.roles.set(roleName, role); return role; } getRole(name: string): ACLRole { return this.roles.get(name); } removeRole(name: string) { return this.roles.delete(name); } registerConfigResources(names: string[]) { names.forEach((name) => this.registerConfigResource(name)); } registerConfigResource(name: string) { this.configResources.push(name); } isConfigResource(name: string) { return this.configResources.includes(name); } setAvailableAction(name: string, options: AvailableActionOptions) { this.availableActions.set(name, new AclAvailableAction(name, options)); if (options.aliases) { const aliases = lodash.isArray(options.aliases) ? options.aliases : [options.aliases]; for (const alias of aliases) { this.actionAlias.set(alias, name); } } } getAvailableActions() { return this.availableActions; } setAvailableStrategy(name: string, options: Omit) { this.availableStrategy.set(name, new ACLAvailableStrategy(this, options)); } beforeGrantAction(listener?: Listener) { this.addListener('beforeGrantAction', listener); } can({ role, resource, action }: CanArgs): CanResult | null { const aclRole = this.roles.get(role); if (!aclRole) { return null; } const aclResource = aclRole.getResource(resource); if (aclResource) { const actionParams = aclResource.getAction(action); if (actionParams) { // handle single action config return { role, resource, action, params: actionParams, }; } } if (!aclRole.strategy) { return null; } const roleStrategy = lodash.isString(aclRole.strategy) ? this.availableStrategy.get(aclRole.strategy) : new ACLAvailableStrategy(this, aclRole.strategy); if (!roleStrategy) { return null; } const roleStrategyParams = roleStrategy.allow(resource, this.resolveActionAlias(action)); if (roleStrategyParams) { const result = { role, resource, action }; if (lodash.isPlainObject(roleStrategyParams)) { result['params'] = roleStrategyParams; } return result; } return null; } protected isAvailableAction(actionName: string) { return this.availableActions.has(this.resolveActionAlias(actionName)); } public resolveActionAlias(action: string) { return this.actionAlias.get(action) ? this.actionAlias.get(action) : action; } use(fn: any) { this.middlewares.push(fn); } skip(resourceName: string, actionName: string, condition?: any) { this.skipManager.skip(resourceName, actionName, condition); } middleware() { const acl = this; const filterParams = (ctx, resourceName, params) => { if (params?.filter?.createdById) { const collection = ctx.db.getCollection(resourceName); if (!collection.getField('createdById')) { return lodash.omit(params, 'filter.createdById'); } } return params; }; const ctxToObject = (ctx) => { return { state: JSON.parse(JSON.stringify(ctx.state)), }; }; return async function ACLMiddleware(ctx, next) { const roleName = ctx.state.currentRole || 'anonymous'; const { resourceName, actionName } = ctx.action; const resourcerAction: Action = ctx.action; ctx.can = (options: Omit) => { return acl.can({ role: roleName, ...options }); }; ctx.permission = { can: ctx.can({ resource: resourceName, action: actionName }), }; return compose(acl.middlewares)(ctx, async () => { const permission = ctx.permission; if (permission.skip) { return next(); } if (!permission.can || typeof permission.can !== 'object') { ctx.throw(403, 'No permissions'); return; } const { params } = permission.can; if (params) { const filteredParams = filterParams(ctx, resourceName, params); const parsedParams = parse(filteredParams)({ ctx: ctxToObject(ctx) }); resourcerAction.mergeParams(parsedParams); } await next(); }); }; } }