diff --git a/packages/acl/.npmignore b/packages/acl/.npmignore new file mode 100644 index 000000000..d44041bb5 --- /dev/null +++ b/packages/acl/.npmignore @@ -0,0 +1,8 @@ +node_modules +*.log +docs +__tests__ +jest.config.js +tsconfig.json +src +.fatherrc.ts \ No newline at end of file diff --git a/packages/acl/package.json b/packages/acl/package.json new file mode 100644 index 000000000..3ac97960d --- /dev/null +++ b/packages/acl/package.json @@ -0,0 +1,20 @@ +{ + "name": "@nocobase/acl", + "version": "0.6.0-alpha.0", + "description": "", + "license": "MIT", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm", + "build:cjs": "tsc --project tsconfig.build.json", + "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" + }, + "dependencies": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nocobase/nocobase.git", + "directory": "packages/acl" + } +} diff --git a/packages/acl/src/__tests__/acl.test.ts b/packages/acl/src/__tests__/acl.test.ts new file mode 100644 index 000000000..a74f6c9e0 --- /dev/null +++ b/packages/acl/src/__tests__/acl.test.ts @@ -0,0 +1,287 @@ +import { ACL } from '..'; + +describe('acl', () => { + let acl: ACL; + beforeEach(() => { + acl = new ACL(); + }); + + it('should allow all', () => { + acl.setAvailableAction('create', { + type: 'new-data', + }); + + acl.setAvailableAction('edit', { + type: 'old-data', + }); + + acl.setAvailableStrategy('s1', { + displayName: 's1', + actions: '*', + }); + + acl.define({ + role: 'admin', + strategy: 's1', + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).not.toBeNull(); + }); + + it('should deny all', () => { + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + acl.define({ + role: 'admin', + strategy: 's1', + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).toBeNull(); + }); + + it('should deny when action is not available action', () => { + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).toBeNull(); + + role.grantAction('posts:create', {}); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).toBeNull(); + }); + + it('should grant action when define role', () => { + acl.setAvailableAction('create', { + displayName: 'create', + type: 'new-data', + }); + + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + actions: { + 'posts:create': {}, + }, + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).not.toBeNull(); + }); + it('should grant action', function () { + acl.setAvailableAction('create', { + displayName: 'create', + type: 'new-data', + }); + + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).toBeNull(); + + role.grantAction('posts:create', {}); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).not.toBeNull(); + }); + + it('should works with alias action', () => { + acl.setAvailableAction('view', { + displayName: 'view', + type: 'new-data', + aliases: ['get', 'list'], + }); + + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: ['view'], + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + }); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'get' })).not.toBeNull(); + expect(acl.can({ role: 'admin', resource: 'posts', action: 'list' })).not.toBeNull(); + }); + + it('should return action params when check permission', () => { + acl.setAvailableStrategy('s2', { + displayName: 'view create update', + actions: ['view', 'create', 'update'], + }); + + acl.setAvailableAction('view', { type: 'new-data' }); + acl.setAvailableAction('create', { type: 'new-data' }); + acl.setAvailableAction('update', { type: 'new-data' }); + + acl.define({ + role: 'admin', + strategy: 's2', + actions: { + 'posts:view': { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }, + }); + + const canResult = acl.can({ role: 'admin', resource: 'posts', action: 'view' }); + + expect(canResult).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'view', + params: { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }); + }); + + it('should getActionParams', () => { + acl.setAvailableStrategy('s2', { + displayName: 'view create update', + actions: ['view', 'create', 'update'], + }); + + acl.setAvailableAction('view', { type: 'new-data' }); + acl.setAvailableAction('create', { type: 'new-data' }); + acl.setAvailableAction('update', { type: 'new-data' }); + + const role = acl.define({ + role: 'admin', + strategy: 's2', + actions: { + 'posts:view': { + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }, + }, + }); + + const params = role.getActionParams('posts:view'); + + expect(params).toMatchObject({ + filter: { + createdById: '{{ ctx.state.currentUser.id }}', + }, + }); + }); + + it('should revoke action', () => { + acl.setAvailableAction('create', { + displayName: 'create', + type: 'new-data', + }); + + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + }); + + role.grantAction('posts:create', {}); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).not.toBeNull(); + + role.revokeAction('posts:create'); + + expect(acl.can({ role: 'admin', resource: 'posts', action: 'create' })).toBeNull(); + }); + + it('should call beforeGrantAction', () => { + acl.setAvailableAction('create', { + type: 'old-data', + }); + + acl.beforeGrantAction('posts:create', (ctx) => { + ctx.params = { + filter: { + status: 'publish', + }, + }; + }); + + expect(acl.listenerCount('posts:create.beforeGrantAction')).toEqual(1); + + acl.define({ + role: 'admin', + actions: { + 'posts:create': {}, + }, + }); + + const results = acl.can({ role: 'admin', resource: 'posts', action: 'create' }); + + expect(results).toMatchObject({ + role: 'admin', + resource: 'posts', + action: 'create', + params: { + filter: { + status: 'publish', + }, + }, + }); + }); + + it('should to JSON', () => { + acl.setAvailableAction('create', { + displayName: 'create', + type: 'new-data', + }); + + acl.setAvailableStrategy('s1', { + displayName: 'test', + actions: false, + }); + + const role = acl.define({ + role: 'admin', + strategy: 's1', + actions: { + 'posts:create': { + filter: { a: 'b' }, + }, + }, + }); + + const roleJSON = role.toJSON(); + + expect(roleJSON).toMatchObject({ + role: 'admin', + strategy: 's1', + actions: { + 'posts:create': {}, + }, + }); + }); +}); diff --git a/packages/acl/src/acl-available-action.ts b/packages/acl/src/acl-available-action.ts new file mode 100644 index 000000000..2bcd0b52f --- /dev/null +++ b/packages/acl/src/acl-available-action.ts @@ -0,0 +1,10 @@ +export interface AvailableActionOptions { + aliases?: string[] | string; + type: 'new-data' | 'old-data'; + displayName?: string; + resource?: string; +} + +export class AclAvailableAction { + constructor(private name: string, private options: AvailableActionOptions) {} +} diff --git a/packages/acl/src/acl-available-strategy.ts b/packages/acl/src/acl-available-strategy.ts new file mode 100644 index 000000000..5098e406a --- /dev/null +++ b/packages/acl/src/acl-available-strategy.ts @@ -0,0 +1,40 @@ +import lodash from 'lodash'; +type StrategyValue = false | '*' | string | string[]; + +export interface AvailableStrategyOptions { + displayName?: string; + actions: false | string | string[]; + resource?: '*'; +} + +export function strategyValueMatched(strategy: StrategyValue, value: string) { + if (strategy === '*') { + return true; + } + + if (lodash.isString(strategy) && strategy === value) { + return true; + } + + if (lodash.isArray(strategy) && strategy.includes(value)) { + return true; + } + + return false; +} + +export class ACLAvailableStrategy { + options: AvailableStrategyOptions; + + constructor(options: AvailableStrategyOptions) { + this.options = options; + } + + matchAction(actionName: string) { + return strategyValueMatched(this.options.actions, actionName); + } + + allow(resourceName: string, actionName: string) { + return this.matchAction(actionName); + } +} diff --git a/packages/acl/src/acl-resource.ts b/packages/acl/src/acl-resource.ts new file mode 100644 index 000000000..60c99195e --- /dev/null +++ b/packages/acl/src/acl-resource.ts @@ -0,0 +1,62 @@ +import { ACLRole, RoleActionParams } from './acl-role'; +import { ACL, ListenerContext } from './acl'; + +export type ResourceActions = { [key: string]: RoleActionParams }; + +interface AclResourceOptions { + name: string; + role: ACLRole; + actions?: ResourceActions; +} + +export class ACLResource { + actions = new Map(); + acl: ACL; + role: ACLRole; + name: string; + + constructor(options: AclResourceOptions) { + this.acl = options.role.acl; + + this.role = options.role; + this.name = options.name; + + const actionsOption: ResourceActions = options.actions || {}; + for (const actionName of Object.keys(actionsOption)) { + this.actions.set(actionName, actionsOption[actionName]); + } + } + + getActions() { + return Array.from(this.actions.keys()).reduce((carry, key) => { + carry[key] = this.actions.get(key); + return carry; + }, {}); + } + + getAction(name: string) { + return this.actions.get(name); + } + + setAction(name: string, params: RoleActionParams) { + const context: ListenerContext = { + role: this.role, + acl: this.role.acl, + params: params || {}, + }; + + this.acl.emit(`${this.name}:${name}.beforeGrantAction`, context); + + this.actions.set(name, context.params); + } + + setActions(actions: { [key: string]: RoleActionParams }) { + for (const actionName of Object.keys(actions)) { + this.setAction(actionName, actions[actionName]); + } + } + + removeAction(name: string) { + this.actions.delete(name); + } +} diff --git a/packages/acl/src/acl-role.ts b/packages/acl/src/acl-role.ts new file mode 100644 index 000000000..2edca2fe4 --- /dev/null +++ b/packages/acl/src/acl-role.ts @@ -0,0 +1,116 @@ +import { ACLResource } from './acl-resource'; +import { AvailableStrategyOptions } from './acl-available-strategy'; +import { ACL, DefineOptions } from './acl'; + +export interface RoleActionParams { + fields?: string[]; + filter?: any; + own?: boolean; + whitelist?: string[]; + blacklist?: string[]; + [key: string]: any; +} + +interface ResourceActionsOptions { + [actionName: string]: RoleActionParams; +} + +export class ACLRole { + strategy: string | AvailableStrategyOptions; + resources = new Map(); + + constructor(public acl: ACL, public name: string) {} + + getResource(name: string): ACLResource | undefined { + return this.resources.get(name); + } + + setResource(name: string, resource: ACLResource) { + this.resources.set(name, resource); + } + + public setStrategy(value: string | AvailableStrategyOptions) { + this.strategy = value; + } + + public grantResource(resourceName: string, options: ResourceActionsOptions) { + const resource = new ACLResource({ + role: this, + name: resourceName, + }); + + for (const [actionName, actionParams] of Object.entries(options)) { + resource.setAction(actionName, actionParams); + } + + this.resources.set(resourceName, resource); + } + + public getResourceActionsParams(resourceName: string) { + const resource = this.getResource(resourceName); + return resource.getActions(); + } + + public revokeResource(resourceName) { + this.resources.delete(resourceName); + } + + public grantAction(path: string, options?: RoleActionParams) { + let { resource, resourceName, actionName } = this.getResourceActionFromPath(path); + + if (!resource) { + resource = new ACLResource({ + role: this, + name: resourceName, + }); + + this.resources.set(resourceName, resource); + } + + resource.setAction(actionName, options); + } + + public getActionParams(path: string): RoleActionParams { + const { action } = this.getResourceActionFromPath(path); + return action; + } + + public revokeAction(path: string) { + const { resource, actionName } = this.getResourceActionFromPath(path); + resource.removeAction(actionName); + } + + public toJSON(): DefineOptions { + const actions = {}; + + for (const resourceName of this.resources.keys()) { + const resourceActions = this.getResourceActionsParams(resourceName); + for (const actionName of Object.keys(resourceActions)) { + actions[`${resourceName}:${actionName}`] = resourceActions[actionName]; + } + } + + return { + role: this.name, + strategy: this.strategy, + actions, + }; + } + + protected getResourceActionFromPath(path: string) { + const [resourceName, actionName] = path.split(':'); + + const resource = this.resources.get(resourceName); + let action = null; + if (resource) { + action = resource.getAction(actionName); + } + + return { + resourceName, + actionName, + resource, + action, + }; + } +} diff --git a/packages/acl/src/acl.ts b/packages/acl/src/acl.ts new file mode 100644 index 000000000..fcd75b875 --- /dev/null +++ b/packages/acl/src/acl.ts @@ -0,0 +1,132 @@ +import lodash from 'lodash'; +import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy'; +import { ACLRole, RoleActionParams } from './acl-role'; +import { AclAvailableAction, AvailableActionOptions } from './acl-available-action'; +import EventEmitter from 'events'; + +interface StrategyOptions { + role: string; + strategy: string; +} + +interface CanResult { + role: string; + resource: string; + action: string; + params?: any; +} + +export interface DefineOptions { + role: string; + strategy?: string | AvailableStrategyOptions; + actions?: { + [key: string]: RoleActionParams; + }; + routes?: any; +} + +export interface ListenerContext { + acl: ACL; + role: ACLRole; + params: RoleActionParams; +} + +type Listener = (ctx: ListenerContext) => void; + +export class ACL extends EventEmitter { + protected availableActions = new Map(); + protected availableStrategy = new Map(); + + roles = new Map(); + + actionAlias = new Map(); + + 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); + } + + 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); + } + } + } + + setAvailableStrategy(name: string, options: AvailableStrategyOptions) { + this.availableStrategy.set(name, new ACLAvailableStrategy(options)); + } + + beforeGrantAction(path: string, listener?: Listener) { + this.addListener(`${path}.beforeGrantAction`, listener); + } + + can({ role, resource, action }: { role: string; resource: string; action: string }): CanResult | null { + action = this.resolveActionAlias(action); + + if (!this.isAvailableAction(action)) { + return null; + } + + const aclRole = this.roles.get(role); + const aclResource = aclRole.getResource(resource); + + if (aclResource) { + const aclActionConfig = aclResource.actions.get(this.resolveActionAlias(action)); + + if (aclActionConfig) { + // handle single action config + return { + role, + resource, + action, + params: aclActionConfig, + }; + } + } + + const roleStrategy = lodash.isString(aclRole.strategy) + ? this.availableStrategy.get(aclRole.strategy) + : new ACLAvailableStrategy(aclRole.strategy); + + if (!roleStrategy) { + return null; + } + + if (roleStrategy.allow(resource, action)) { + return { role, resource, action }; + } + + return null; + } + + protected isAvailableAction(actionName: string) { + return this.availableActions.has(actionName); + } + + protected resolveActionAlias(action: string) { + return this.actionAlias.get(action) ? this.actionAlias.get(action) : action; + } +} diff --git a/packages/acl/src/index.ts b/packages/acl/src/index.ts new file mode 100644 index 000000000..401c1b504 --- /dev/null +++ b/packages/acl/src/index.ts @@ -0,0 +1,5 @@ +export * from './acl-resource'; +export * from './acl-role'; +export * from './acl-available-strategy'; +export * from './acl-available-action'; +export * from './acl'; diff --git a/packages/acl/tsconfig.build.json b/packages/acl/tsconfig.build.json new file mode 100644 index 000000000..8d3db734b --- /dev/null +++ b/packages/acl/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/packages/acl/tsconfig.json b/packages/acl/tsconfig.json new file mode 100644 index 000000000..e10a5305a --- /dev/null +++ b/packages/acl/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} \ No newline at end of file diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 036e5cb6e..22aa2c3d3 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -19,4 +19,4 @@ export function registerActions(api: any) { ); } -export default {}; +export default actions; diff --git a/packages/database/src/__tests__/database.test.ts b/packages/database/src/__tests__/database.test.ts index 02fa0d23f..28473393b 100644 --- a/packages/database/src/__tests__/database.test.ts +++ b/packages/database/src/__tests__/database.test.ts @@ -3,6 +3,23 @@ import path from 'path'; import { Model } from '..'; describe('database', () => { + test('get repository', async () => { + const db = mockDatabase(); + db.collection({ + name: 'tests', + fields: [{ type: 'hasMany', name: 'relations' }], + }); + + db.collection({ + name: 'relations', + }); + + expect(db.getRepository('tests')).toEqual(db.getCollection('tests').repository); + expect(db.getRepository('tests.relations', '1')).toEqual( + db.getCollection('tests').repository.relation('relations').of('1'), + ); + }); + test('import', async () => { const db = mockDatabase(); await db.import({ @@ -191,5 +208,4 @@ describe('database', () => { test.customMethod(); expect(test.get('abc')).toBe('abc'); }); - }); diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts index 99dc84e3b..43035e480 100644 --- a/packages/database/src/database.ts +++ b/packages/database/src/database.ts @@ -11,6 +11,8 @@ import { ModelHook } from './model-hook'; import { ImporterReader, ImportFileExtension } from './collection-importer'; import extendOperators from './operators'; +import { Repository } from './repository'; +import { RelationRepository } from './relation-repository/relation-repository'; export interface MergeOptions extends merge.Options {} @@ -126,6 +128,18 @@ export class Database extends EventEmitter implements AsyncEmitter { } } + getRepository(name: string): Repository; + getRepository(name: string, relationId: string | number): R; + + getRepository(name: string, relationId?: string | number): Repository | R { + if (relationId) { + const [collection, relation] = name.split('.'); + return this.getRepository(collection).relation(relation).of(relationId) as R; + } + + return this.getCollection(name).repository; + } + addPendingField(field: RelationField) { const associating = this.pendingFields; const items = this.pendingFields.get(field.target) || [];