feat: acl (#153)

* feat: getRepository

* getRepository return type

* export action

* add: acl

* feat: setResourceAction

* feat: action alias

* chore: code struct

* feat: removeResourceAction

* chore: file name

* ignorecase

* remove ACL

* feat: ACL

* feat: role toJSON

* using emit

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-01-18 16:38:03 +08:00 committed by GitHub
parent c9742c1710
commit fd32705954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 726 additions and 2 deletions

8
packages/acl/.npmignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
*.log
docs
__tests__
jest.config.js
tsconfig.json
src
.fatherrc.ts

20
packages/acl/package.json Normal file
View File

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

View File

@ -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': {},
},
});
});
});

View File

@ -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) {}
}

View File

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

View File

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

View File

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

132
packages/acl/src/acl.ts Normal file
View File

@ -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<string, AclAvailableAction>();
protected availableStrategy = new Map<string, ACLAvailableStrategy>();
roles = new Map<string, ACLRole>();
actionAlias = new Map<string, string>();
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;
}
}

View File

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

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./lib",
"declaration": true
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./esm/*", "./lib/*"]
}

View File

@ -19,4 +19,4 @@ export function registerActions(api: any) {
);
}
export default {};
export default actions;

View File

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

View File

@ -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<R extends RelationRepository>(name: string, relationId: string | number): R;
getRepository<R extends RelationRepository>(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) || [];