Plugin acl (#166)
* 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 * chore: test * feat: plugin-acl * feat: acl with predicate * grant universal action test * grant action test * update resource action test * revoke resource action * usingActionsConfig switch * plugin-ui-schema-storage * remove global acl instance * fix: collection manager with sqlite * add own action listener * add acl middleware * add acl allowConfigure strategy option * add plugin-acl allowConfigure * change acl resourceName * add acl middleware merge params * bugfix * append fields on acl action params * acl middleware parse template * fix: collection-manager migrate Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
a2f3e1904e
commit
bd285e0ba9
@ -7,6 +7,67 @@ describe('acl', () => {
|
||||
acl = new ACL();
|
||||
});
|
||||
|
||||
it('should grant action with own params', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
|
||||
acl.setAvailableAction('create', {
|
||||
type: 'new-data',
|
||||
});
|
||||
|
||||
acl.define({
|
||||
role: 'admin',
|
||||
actions: {
|
||||
'posts:edit': {
|
||||
own: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const canResult = acl.can({ role: 'admin', resource: 'posts', action: 'edit' });
|
||||
|
||||
expect(canResult).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'edit',
|
||||
params: {
|
||||
filter: {
|
||||
createdById: '{{ ctx.state.currentUser.id }}',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should define role with predicate', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
|
||||
acl.setAvailableAction('create', {
|
||||
type: 'new-data',
|
||||
});
|
||||
|
||||
acl.define({
|
||||
role: 'admin',
|
||||
strategy: {
|
||||
actions: ['edit:own', 'create'],
|
||||
},
|
||||
});
|
||||
|
||||
const canResult = acl.can({ role: 'admin', resource: 'posts', action: 'edit' });
|
||||
|
||||
expect(canResult).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'edit',
|
||||
params: {
|
||||
filter: {
|
||||
createdById: '{{ ctx.state.currentUser.id }}',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow all', () => {
|
||||
acl.setAvailableAction('create', {
|
||||
type: 'new-data',
|
||||
@ -248,16 +309,16 @@ describe('acl', () => {
|
||||
type: 'old-data',
|
||||
});
|
||||
|
||||
acl.beforeGrantAction('posts:create', (ctx) => {
|
||||
acl.beforeGrantAction((ctx) => {
|
||||
if (ctx.path === 'posts:create') {
|
||||
ctx.params = {
|
||||
filter: {
|
||||
status: 'publish',
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
expect(acl.listenerCount('posts:create.beforeGrantAction')).toEqual(1);
|
||||
|
||||
acl.define({
|
||||
role: 'admin',
|
||||
actions: {
|
||||
@ -310,4 +371,32 @@ describe('acl', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow system config', () => {
|
||||
acl.setAvailableAction('create', {
|
||||
displayName: 'create',
|
||||
type: 'new-data',
|
||||
});
|
||||
|
||||
acl.registerConfigResources(['roles']);
|
||||
|
||||
const role = acl.define({
|
||||
role: 'admin',
|
||||
strategy: {
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'roles',
|
||||
action: 'create',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'roles',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3,9 +3,9 @@ import { ACL } from './acl';
|
||||
type StrategyValue = false | '*' | string | string[];
|
||||
|
||||
export interface AvailableStrategyOptions {
|
||||
acl: ACL;
|
||||
displayName?: string;
|
||||
actions: false | string | string[];
|
||||
actions?: false | string | string[];
|
||||
allowConfigure?: boolean;
|
||||
resource?: '*';
|
||||
}
|
||||
|
||||
@ -25,18 +25,63 @@ export function strategyValueMatched(strategy: StrategyValue, value: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export class ACLAvailableStrategy {
|
||||
options: AvailableStrategyOptions;
|
||||
export const predicate = {
|
||||
own: {
|
||||
filter: {
|
||||
createdById: '{{ ctx.state.currentUser.id }}',
|
||||
},
|
||||
},
|
||||
all: {},
|
||||
};
|
||||
|
||||
constructor(options: AvailableStrategyOptions) {
|
||||
export class ACLAvailableStrategy {
|
||||
acl: ACL;
|
||||
options: AvailableStrategyOptions;
|
||||
actionsAsObject: { [key: string]: string };
|
||||
|
||||
allowConfigure: boolean;
|
||||
|
||||
constructor(acl: ACL, options: AvailableStrategyOptions) {
|
||||
this.acl = acl;
|
||||
this.options = options;
|
||||
this.allowConfigure = options.allowConfigure;
|
||||
|
||||
let actions = this.options.actions;
|
||||
if (lodash.isString(actions) && actions != '*') {
|
||||
actions = [actions];
|
||||
}
|
||||
|
||||
if (lodash.isArray(actions)) {
|
||||
this.actionsAsObject = actions.reduce((carry, action) => {
|
||||
const [actionName, predicate] = action.split(':');
|
||||
carry[actionName] = predicate;
|
||||
return carry;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
matchAction(actionName: string) {
|
||||
return strategyValueMatched(this.options.actions, actionName);
|
||||
if (this.options.actions == '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.actionsAsObject?.hasOwnProperty(actionName)) {
|
||||
const predicateName = this.actionsAsObject[actionName];
|
||||
if (predicateName) {
|
||||
return predicate[predicateName];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
allow(resourceName: string, actionName: string) {
|
||||
return this.matchAction(this.options.acl.resolveActionAlias(actionName));
|
||||
if (this.acl.isConfigResource(resourceName) && this.allowConfigure) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.matchAction(this.acl.resolveActionAlias(actionName));
|
||||
}
|
||||
}
|
||||
|
@ -43,9 +43,12 @@ export class ACLResource {
|
||||
role: this.role,
|
||||
acl: this.role.acl,
|
||||
params: params || {},
|
||||
path: `${this.name}:${name}`,
|
||||
resourceName: this.name,
|
||||
actionName: name,
|
||||
};
|
||||
|
||||
this.acl.emit(`${this.name}:${name}.beforeGrantAction`, context);
|
||||
this.acl.emit('beforeGrantAction', context);
|
||||
|
||||
this.actions.set(name, context.params);
|
||||
}
|
||||
|
@ -1,13 +1,10 @@
|
||||
import lodash from 'lodash';
|
||||
import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy';
|
||||
import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } 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;
|
||||
}
|
||||
import { Action } from '@nocobase/resourcer';
|
||||
const parse = require('json-templates');
|
||||
|
||||
interface CanResult {
|
||||
role: string;
|
||||
@ -18,7 +15,8 @@ interface CanResult {
|
||||
|
||||
export interface DefineOptions {
|
||||
role: string;
|
||||
strategy?: string | AvailableStrategyOptions;
|
||||
allowConfigure?: boolean;
|
||||
strategy?: string | Omit<AvailableStrategyOptions, 'acl'>;
|
||||
actions?: {
|
||||
[key: string]: RoleActionParams;
|
||||
};
|
||||
@ -28,11 +26,20 @@ export interface DefineOptions {
|
||||
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<string, AclAvailableAction>();
|
||||
protected availableStrategy = new Map<string, ACLAvailableStrategy>();
|
||||
@ -41,6 +48,39 @@ export class ACL extends EventEmitter {
|
||||
|
||||
actionAlias = new Map<string, string>();
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
define(options: DefineOptions): ACLRole {
|
||||
const roleName = options.role;
|
||||
const role = new ACLRole(this, roleName);
|
||||
@ -64,6 +104,22 @@ export class ACL extends EventEmitter {
|
||||
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));
|
||||
|
||||
@ -75,21 +131,19 @@ export class ACL extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableActions() {
|
||||
return this.availableActions;
|
||||
}
|
||||
|
||||
setAvailableStrategy(name: string, options: Omit<AvailableStrategyOptions, 'acl'>) {
|
||||
this.availableStrategy.set(
|
||||
name,
|
||||
new ACLAvailableStrategy({
|
||||
...options,
|
||||
acl: this,
|
||||
}),
|
||||
);
|
||||
this.availableStrategy.set(name, new ACLAvailableStrategy(this, options));
|
||||
}
|
||||
|
||||
beforeGrantAction(path: string, listener?: Listener) {
|
||||
this.addListener(`${path}.beforeGrantAction`, listener);
|
||||
beforeGrantAction(listener?: Listener) {
|
||||
this.addListener('beforeGrantAction', listener);
|
||||
}
|
||||
|
||||
can({ role, resource, action }: { role: string; resource: string; action: string }): CanResult | null {
|
||||
can({ role, resource, action }: CanArgs): CanResult | null {
|
||||
if (!this.isAvailableAction(action)) {
|
||||
return null;
|
||||
}
|
||||
@ -111,16 +165,28 @@ export class ACL extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (!aclRole.strategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roleStrategy = lodash.isString(aclRole.strategy)
|
||||
? this.availableStrategy.get(aclRole.strategy)
|
||||
: new ACLAvailableStrategy(aclRole.strategy);
|
||||
: new ACLAvailableStrategy(this, aclRole.strategy);
|
||||
|
||||
if (!roleStrategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (roleStrategy.allow(resource, this.resolveActionAlias(action))) {
|
||||
return { role, resource, action };
|
||||
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;
|
||||
@ -133,4 +199,34 @@ export class ACL extends EventEmitter {
|
||||
public resolveActionAlias(action: string) {
|
||||
return this.actionAlias.get(action) ? this.actionAlias.get(action) : action;
|
||||
}
|
||||
|
||||
middleware() {
|
||||
const aclInstance = this;
|
||||
|
||||
return async function ACLMiddleware(ctx, next) {
|
||||
const roleName = ctx.state.currentRole;
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
|
||||
const resourcerAction: Action = ctx.action;
|
||||
|
||||
ctx.can = (options: Omit<CanArgs, 'role'>) => {
|
||||
return aclInstance.can({ role: roleName, ...options });
|
||||
};
|
||||
|
||||
const canResult = ctx.can({ resource: resourceName, action: actionName });
|
||||
|
||||
if (!canResult) {
|
||||
ctx.throw(403, 'no permission');
|
||||
return;
|
||||
}
|
||||
|
||||
if (lodash.get(canResult, 'params')) {
|
||||
const template = parse(canResult.params);
|
||||
|
||||
resourcerAction.mergeParams(template({ ctx }));
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -304,6 +304,10 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (options.hooks !== false) {
|
||||
await this.database.emitAsync(`${this.collection.name}.afterCreateWithAssociations`, instance, options);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
@ -44,6 +44,7 @@ export function transactionWrapperBuilder(transactionGenerator) {
|
||||
return results;
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error({ err });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
|
21
packages/plugin-acl/package.json
Normal file
21
packages/plugin-acl/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-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": {
|
||||
"json-templates": "^4.2.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nocobase/nocobase.git",
|
||||
"directory": "packages/plugin-acl"
|
||||
}
|
||||
}
|
317
packages/plugin-acl/src/__tests__/acl.test.ts
Normal file
317
packages/plugin-acl/src/__tests__/acl.test.ts
Normal file
@ -0,0 +1,317 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { prepareApp } from './prepare';
|
||||
import { Database } from '@nocobase/database';
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import PluginACL from '../server';
|
||||
|
||||
describe('acl', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let acl: ACL;
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
const aclPlugin = app.getPlugin<PluginACL>('PluginACL');
|
||||
|
||||
acl = aclPlugin.getACL();
|
||||
});
|
||||
|
||||
it('should works with universal actions', async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
const role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
// grant universal action
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles')
|
||||
.update({
|
||||
resourceIndex: 'admin',
|
||||
values: {
|
||||
strategy: {
|
||||
actions: ['create'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('should works with resources actions', async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
const role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'c1',
|
||||
title: 'table1',
|
||||
},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'c2',
|
||||
title: 'table2',
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('rolesResourcesScopes')
|
||||
.create({
|
||||
values: {
|
||||
resourceName: 'c1',
|
||||
name: 'published',
|
||||
scope: {
|
||||
published: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const publishedScope = await db.getRepository('rolesResourcesScopes').findOne();
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'c1',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
scope: publishedScope.get('id'),
|
||||
},
|
||||
{
|
||||
name: 'view',
|
||||
fields: ['title', 'age'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'c1',
|
||||
action: 'create',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'c1',
|
||||
action: 'create',
|
||||
params: {
|
||||
filter: { published: true },
|
||||
fields: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'c1',
|
||||
action: 'view',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'c1',
|
||||
action: 'view',
|
||||
params: {
|
||||
fields: ['title', 'age'],
|
||||
},
|
||||
});
|
||||
|
||||
// revoke action
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.list({
|
||||
associatedIndex: role.get('name') as string,
|
||||
appends: ['actions'],
|
||||
});
|
||||
|
||||
const actions = response.body.data[0].actions;
|
||||
const resourceId = response.body.data[0].id;
|
||||
|
||||
const viewActionId = actions.find((action) => action.name === 'view').id;
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.update({
|
||||
associatedIndex: role.get('name') as string,
|
||||
resourceIndex: resourceId,
|
||||
values: {
|
||||
name: 'c1',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
id: viewActionId,
|
||||
name: 'view',
|
||||
fields: ['title', 'age'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'c1',
|
||||
action: 'create',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should revoke actions when not using actions config', async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
const role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
title: 'posts',
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'posts',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.update({
|
||||
associatedIndex: role.get('name') as string,
|
||||
resourceIndex: (
|
||||
await db.getRepository('rolesResources').findOne({
|
||||
filter: {
|
||||
name: 'posts',
|
||||
roleName: 'admin',
|
||||
},
|
||||
})
|
||||
).get('id') as string,
|
||||
values: {
|
||||
usingActionsConfig: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.update({
|
||||
associatedIndex: role.get('name') as string,
|
||||
resourceIndex: (
|
||||
await db.getRepository('rolesResources').findOne({
|
||||
filter: {
|
||||
name: 'posts',
|
||||
roleName: 'admin',
|
||||
},
|
||||
})
|
||||
).get('id') as string,
|
||||
values: {
|
||||
usingActionsConfig: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
}),
|
||||
).toMatchObject({
|
||||
role: 'admin',
|
||||
resource: 'posts',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
});
|
239
packages/plugin-acl/src/__tests__/middleware.test.ts
Normal file
239
packages/plugin-acl/src/__tests__/middleware.test.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { changeMockUser, prepareApp } from './prepare';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import PluginACL from '@nocobase/plugin-acl';
|
||||
|
||||
describe('middleware', () => {
|
||||
let app: MockServer;
|
||||
let role: Model;
|
||||
let db: Database;
|
||||
let acl: ACL;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
acl = app.getPlugin<PluginACL>('PluginACL').getACL();
|
||||
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections.fields', 'posts').create({
|
||||
values: {
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections.fields', 'posts').create({
|
||||
values: {
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('collections.fields', 'posts').create({
|
||||
values: {
|
||||
name: 'createdById',
|
||||
type: 'integer',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should throw 403 when no permission', async () => {
|
||||
const response = await app.agent().resource('posts').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(403);
|
||||
});
|
||||
|
||||
it('should return 200 when role has permission', async () => {
|
||||
await db.getRepository('roles').update({
|
||||
filterByTk: 'admin',
|
||||
values: {
|
||||
strategy: {
|
||||
actions: ['create:all'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.agent().resource('posts').create({
|
||||
values: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
it('should limit fields on view actions', async () => {
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'posts',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
fields: ['title', 'description'],
|
||||
},
|
||||
{
|
||||
name: 'view',
|
||||
fields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('posts')
|
||||
.create({
|
||||
values: {
|
||||
title: 'post-title',
|
||||
description: 'post-description',
|
||||
},
|
||||
});
|
||||
|
||||
const post = await db.getRepository('posts').findOne();
|
||||
expect(post.get('title')).toEqual('post-title');
|
||||
expect(post.get('description')).toEqual('post-description');
|
||||
|
||||
const response = await app.agent().resource('posts').list({});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
const data = response.body.data[0];
|
||||
|
||||
expect(data['id']).not.toBeUndefined();
|
||||
expect(data['title']).toEqual('post-title');
|
||||
expect(data['description']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse template value on action params', async () => {
|
||||
changeMockUser({
|
||||
id: 2,
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('rolesResourcesScopes')
|
||||
.create({
|
||||
values: {
|
||||
name: 'own',
|
||||
scope: {
|
||||
createdById: '{{ ctx.state.currentUser.id }}',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const scope = await db.getRepository('rolesResourcesScopes').findOne();
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'posts',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
fields: ['title', 'description', 'createdById'],
|
||||
},
|
||||
{
|
||||
name: 'view',
|
||||
fields: ['title'],
|
||||
scope: scope.get('id'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('posts')
|
||||
.create({
|
||||
values: {
|
||||
title: 't1',
|
||||
description: 'd1',
|
||||
createdById: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('posts')
|
||||
.create({
|
||||
values: {
|
||||
title: 't2',
|
||||
description: 'p2',
|
||||
createdById: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.agent().resource('posts').list();
|
||||
const data = response.body.data;
|
||||
expect(data.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should change fields params to whitelist in create action', async () => {
|
||||
await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'posts',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
fields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await app
|
||||
.agent()
|
||||
.resource('posts')
|
||||
.create({
|
||||
values: {
|
||||
title: 'post-title',
|
||||
description: 'post-description',
|
||||
},
|
||||
});
|
||||
|
||||
const post = await db.getRepository('posts').findOne();
|
||||
expect(post.get('title')).toEqual('post-title');
|
||||
expect(post.get('description')).toBeNull();
|
||||
});
|
||||
});
|
36
packages/plugin-acl/src/__tests__/prepare.ts
Normal file
36
packages/plugin-acl/src/__tests__/prepare.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { mockServer } from '@nocobase/test';
|
||||
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
|
||||
import PluginCollectionManager from '@nocobase/plugin-collection-manager';
|
||||
import PluginACL from '../server';
|
||||
|
||||
let mockRole: string = 'admin';
|
||||
let mockUser = {};
|
||||
|
||||
export function changeMockRole(role: string) {
|
||||
mockRole = role;
|
||||
}
|
||||
|
||||
export function changeMockUser(user: any) {
|
||||
mockUser = user;
|
||||
}
|
||||
|
||||
export async function prepareApp() {
|
||||
const app = mockServer({
|
||||
registerActions: true,
|
||||
});
|
||||
|
||||
await app.cleanDb();
|
||||
app.plugin(PluginUiSchema);
|
||||
app.plugin(PluginCollectionManager);
|
||||
|
||||
app.resourcer.use(async (ctx, next) => {
|
||||
ctx.state.currentRole = mockRole;
|
||||
ctx.state.currentUser = mockUser;
|
||||
await next();
|
||||
});
|
||||
|
||||
app.plugin(PluginACL);
|
||||
await app.loadAndSync();
|
||||
|
||||
return app;
|
||||
}
|
142
packages/plugin-acl/src/__tests__/role-resource.test.ts
Normal file
142
packages/plugin-acl/src/__tests__/role-resource.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
import { prepareApp } from './prepare';
|
||||
import { CollectionRepository } from '@nocobase/plugin-collection-manager';
|
||||
|
||||
describe('role resource api', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let role: Model;
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should grant resource action', async () => {
|
||||
const collectionManager = db.getRepository('collections') as CollectionRepository;
|
||||
|
||||
await collectionManager.create({
|
||||
values: {
|
||||
name: 'c1',
|
||||
title: 'table1',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await collectionManager.create({
|
||||
values: {
|
||||
name: 'c2',
|
||||
title: 'table2',
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
// get collections list
|
||||
let response = await app
|
||||
.agent()
|
||||
.resource('roles.collections')
|
||||
.list({
|
||||
associatedIndex: role.get('name') as string,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
expect(response.body.data).toMatchObject([
|
||||
{
|
||||
name: 'c1',
|
||||
title: 'table1',
|
||||
usingConfig: 'strategy',
|
||||
},
|
||||
{
|
||||
name: 'c2',
|
||||
title: 'table2',
|
||||
usingConfig: 'strategy',
|
||||
},
|
||||
]);
|
||||
|
||||
// set resource actions
|
||||
response = await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.create({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'c1',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'create',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
// get collections list
|
||||
response = await app
|
||||
.agent()
|
||||
.resource('roles.collections')
|
||||
.list({
|
||||
associatedIndex: role.get('name') as string,
|
||||
filter: {
|
||||
name: 'c1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.body.data[0]['usingConfig']).toEqual('resourceAction');
|
||||
|
||||
response = await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.list({
|
||||
associatedIndex: role.get('name') as string,
|
||||
appends: 'actions',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
const resources = response.body.data;
|
||||
const resourceAction = resources[0]['actions'][0];
|
||||
|
||||
expect(resourceAction['name']).toEqual('create');
|
||||
|
||||
// update resource actions
|
||||
response = await app
|
||||
.agent()
|
||||
.resource('roles.resources')
|
||||
.update({
|
||||
associatedIndex: role.get('name') as string,
|
||||
values: {
|
||||
name: 'c1',
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'view',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data[0]['actions'].length).toEqual(1);
|
||||
expect(response.body.data[0]['actions'][0]['name']).toEqual('view');
|
||||
});
|
||||
});
|
66
packages/plugin-acl/src/__tests__/role.test.ts
Normal file
66
packages/plugin-acl/src/__tests__/role.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { CollectionRepository } from '@nocobase/plugin-collection-manager';
|
||||
import { Database, Model } from '@nocobase/database';
|
||||
|
||||
import { prepareApp } from './prepare';
|
||||
|
||||
describe('role api', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
});
|
||||
|
||||
describe('grant', () => {
|
||||
let role: Model;
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
|
||||
role = await db.getRepository('roles').findOne({
|
||||
filter: {
|
||||
name: 'admin',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should list actions', async () => {
|
||||
const response = await app.agent().resource('availableActions').list();
|
||||
expect(response.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
it('should grant universal role actions', async () => {
|
||||
// grant role actions
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('roles')
|
||||
.update({
|
||||
values: {
|
||||
strategy: {
|
||||
actions: ['create:all', 'view:own'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
await role.reload();
|
||||
|
||||
expect(role.get('strategy')).toMatchObject({
|
||||
actions: ['create:all', 'view:own'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
51
packages/plugin-acl/src/__tests__/scope.test.ts
Normal file
51
packages/plugin-acl/src/__tests__/scope.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { prepareApp } from './prepare';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { Database } from '@nocobase/database';
|
||||
|
||||
describe('scope api', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'admin',
|
||||
title: 'Admin User',
|
||||
allowConfigure: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create scope of resource', async () => {
|
||||
const response = await app
|
||||
.agent()
|
||||
.resource('rolesResourcesScopes')
|
||||
.create({
|
||||
values: {
|
||||
resourceName: 'posts',
|
||||
name: 'published posts',
|
||||
scope: {
|
||||
published: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
const scope = await db.getRepository('rolesResourcesScopes').findOne({
|
||||
filter: {
|
||||
name: 'published posts',
|
||||
},
|
||||
});
|
||||
|
||||
expect(scope.get('scope')).toMatchObject({
|
||||
published: true,
|
||||
});
|
||||
});
|
||||
});
|
29
packages/plugin-acl/src/acl/available-action.ts
Normal file
29
packages/plugin-acl/src/acl/available-action.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { AvailableActionOptions } from '@nocobase/acl';
|
||||
|
||||
const availableActions: {
|
||||
[key: string]: AvailableActionOptions;
|
||||
} = {
|
||||
create: {
|
||||
displayName: 't("create")',
|
||||
type: 'new-data',
|
||||
},
|
||||
import: {
|
||||
displayName: 't("import")',
|
||||
type: 'new-data',
|
||||
},
|
||||
view: {
|
||||
displayName: 't("view")',
|
||||
type: 'old-data',
|
||||
aliases: ['get', 'list'],
|
||||
},
|
||||
update: {
|
||||
displayName: 't("edit")',
|
||||
type: 'old-data',
|
||||
},
|
||||
destroy: {
|
||||
displayName: 't("destroy")',
|
||||
type: 'old-data',
|
||||
},
|
||||
};
|
||||
|
||||
export { availableActions };
|
23
packages/plugin-acl/src/acl/index.ts
Normal file
23
packages/plugin-acl/src/acl/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import { availableActions } from './available-action';
|
||||
|
||||
const configureResources = [
|
||||
'roles',
|
||||
'collections',
|
||||
'roles.collections',
|
||||
'roles.resources',
|
||||
'rolesResourcesScopes',
|
||||
'availableActions',
|
||||
];
|
||||
|
||||
export function createACL() {
|
||||
const acl = new ACL();
|
||||
|
||||
for (const [actionName, actionParams] of Object.entries(availableActions)) {
|
||||
acl.setAvailableAction(actionName, actionParams);
|
||||
}
|
||||
|
||||
acl.registerConfigResources(configureResources);
|
||||
|
||||
return acl;
|
||||
}
|
15
packages/plugin-acl/src/actions/available-actions.ts
Normal file
15
packages/plugin-acl/src/actions/available-actions.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import PluginACL from '@nocobase/plugin-acl';
|
||||
|
||||
const availableActionResource = {
|
||||
name: 'availableActions',
|
||||
actions: {
|
||||
list(ctx, next) {
|
||||
const aclPlugin: PluginACL = ctx.app.getPlugin('PluginACL');
|
||||
const acl = aclPlugin.getACL();
|
||||
const availableActions = acl.getAvailableActions();
|
||||
ctx.body = Array.from(availableActions.entries()).map((item) => item[1]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { availableActionResource };
|
39
packages/plugin-acl/src/actions/role-collections.ts
Normal file
39
packages/plugin-acl/src/actions/role-collections.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Database } from '@nocobase/database';
|
||||
|
||||
type UsingConfigType = 'strategy' | 'resourceAction';
|
||||
|
||||
const roleCollectionsResource = {
|
||||
name: 'roles.collections',
|
||||
actions: {
|
||||
async list(ctx, next) {
|
||||
const role = ctx.action.params.associatedIndex;
|
||||
|
||||
const db: Database = ctx.db;
|
||||
const collectionRepository = db.getRepository('collections');
|
||||
const collections = await collectionRepository.find();
|
||||
|
||||
const roleResources = await db.getRepository('rolesResources').find({
|
||||
filter: {
|
||||
roleName: role,
|
||||
usingActionsConfig: true,
|
||||
},
|
||||
});
|
||||
|
||||
const roleResourcesNames = roleResources.map((roleResource) => roleResource.get('name'));
|
||||
|
||||
ctx.body = collections.map((collection) => {
|
||||
const usingConfig: UsingConfigType = roleResourcesNames.includes(collection.get('name'))
|
||||
? 'resourceAction'
|
||||
: 'strategy';
|
||||
|
||||
return {
|
||||
name: collection.get('name'),
|
||||
title: collection.get('title'),
|
||||
usingConfig,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { roleCollectionsResource };
|
19
packages/plugin-acl/src/collections/action-scopes.ts
Normal file
19
packages/plugin-acl/src/collections/action-scopes.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'rolesResourcesScopes',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'resourceName',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'scope',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
24
packages/plugin-acl/src/collections/resouces.ts
Normal file
24
packages/plugin-acl/src/collections/resouces.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'rolesResources',
|
||||
fields: [
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'role',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'usingActionsConfig',
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'actions',
|
||||
target: 'rolesResourcesActions',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
27
packages/plugin-acl/src/collections/resource-actions.ts
Normal file
27
packages/plugin-acl/src/collections/resource-actions.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'rolesResourcesActions',
|
||||
fields: [
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'resource',
|
||||
foreignKey: 'rolesResourceId',
|
||||
target: 'rolesResources',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'fields',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'scope',
|
||||
target: 'rolesResourcesScopes',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
49
packages/plugin-acl/src/collections/roles.ts
Normal file
49
packages/plugin-acl/src/collections/roles.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { CollectionOptions } from '@nocobase/database';
|
||||
|
||||
export default {
|
||||
name: 'roles',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
name: 'name',
|
||||
primaryKey: true,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'default',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'strategy',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'allowConfigure',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'allowNewMenu',
|
||||
},
|
||||
{
|
||||
type: 'belongsToMany',
|
||||
name: 'menuUiSchemas',
|
||||
target: 'ui_schemas',
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'resources',
|
||||
target: 'rolesResources',
|
||||
sourceKey: 'name',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
1
packages/plugin-acl/src/index.ts
Normal file
1
packages/plugin-acl/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as default } from './server';
|
112
packages/plugin-acl/src/server.ts
Normal file
112
packages/plugin-acl/src/server.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import path from 'path';
|
||||
import { availableActionResource } from './actions/available-actions';
|
||||
import { roleCollectionsResource } from './actions/role-collections';
|
||||
import { createACL } from './acl';
|
||||
|
||||
async function actionModelToParams(actionModel, resourceName) {
|
||||
const fields = actionModel.get('fields');
|
||||
const actionPath = `${resourceName}:${actionModel.get('name')}`;
|
||||
|
||||
const actionParams = {
|
||||
fields,
|
||||
};
|
||||
|
||||
const scope = await actionModel.getScope();
|
||||
|
||||
if (scope) {
|
||||
actionParams['filter'] = scope.get('scope');
|
||||
}
|
||||
|
||||
return {
|
||||
actionPath,
|
||||
actionParams,
|
||||
};
|
||||
}
|
||||
|
||||
export default class PluginACL extends Plugin {
|
||||
acl: ACL;
|
||||
|
||||
getACL() {
|
||||
return this.acl;
|
||||
}
|
||||
|
||||
async load() {
|
||||
const acl = createACL();
|
||||
this.acl = acl;
|
||||
|
||||
await this.app.db.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
});
|
||||
|
||||
this.app.resourcer.define(availableActionResource);
|
||||
this.app.resourcer.define(roleCollectionsResource);
|
||||
|
||||
this.app.resourcer.use(this.acl.middleware());
|
||||
|
||||
this.app.db.on('roles.afterSave', (model) => {
|
||||
const roleName = model.get('name');
|
||||
let role = acl.getRole(roleName);
|
||||
|
||||
if (!role) {
|
||||
role = acl.define({
|
||||
role: model.get('name'),
|
||||
});
|
||||
}
|
||||
|
||||
role.setStrategy({
|
||||
...(model.get('strategy') || {}),
|
||||
allowConfigure: model.get('allowConfigure'),
|
||||
});
|
||||
});
|
||||
|
||||
this.app.db.on('roles.afterDestroy', (model) => {
|
||||
const roleName = model.get('name');
|
||||
acl.removeRole(roleName);
|
||||
});
|
||||
|
||||
this.app.db.on('rolesResources.afterSave', async (model, options) => {
|
||||
const roleName = model.get('roleName');
|
||||
const role = acl.getRole(roleName);
|
||||
|
||||
if (model.usingActionsConfig === true && model._previousDataValues.usingActionsConfig === false) {
|
||||
const actions = await model.getActions();
|
||||
for (const action of actions) {
|
||||
const { actionPath, actionParams } = await actionModelToParams(action, model.get('name'));
|
||||
role.grantAction(actionPath, actionParams);
|
||||
}
|
||||
}
|
||||
|
||||
if (model._previousDataValues.usingActionsConfig === true && model.usingActionsConfig === false) {
|
||||
role.revokeResource(model.get('name'));
|
||||
}
|
||||
});
|
||||
|
||||
this.app.db.on('rolesResourcesActions.beforeBulkUpdate', async (options) => {
|
||||
options.individualHooks = true;
|
||||
});
|
||||
|
||||
this.app.db.on('rolesResourcesActions.afterSave', async (model) => {
|
||||
const resource = await model.getResource();
|
||||
if (!resource) {
|
||||
const previousResource = await this.app.db.getRepository('rolesResources').findOne({
|
||||
filter: {
|
||||
id: model._previousDataValues.rolesResourceId,
|
||||
},
|
||||
});
|
||||
|
||||
const roleName = previousResource.get('roleName') as string;
|
||||
const role = acl.getRole(roleName);
|
||||
role.revokeAction(`${previousResource.get('name')}:${model.get('name')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const roleName = resource.get('roleName');
|
||||
const role = acl.getRole(roleName);
|
||||
|
||||
const { actionPath, actionParams } = await actionModelToParams(model, resource.get('name'));
|
||||
role.grantAction(actionPath, actionParams);
|
||||
});
|
||||
}
|
||||
}
|
9
packages/plugin-acl/tsconfig.build.json
Normal file
9
packages/plugin-acl/tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.build.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
|
||||
}
|
5
packages/plugin-acl/tsconfig.json
Normal file
5
packages/plugin-acl/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./esm/*", "./lib/*"]
|
||||
}
|
@ -2,21 +2,24 @@ import path from 'path';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { CollectionModel } from './models/collection';
|
||||
import { FieldModel } from './models/field';
|
||||
import { uid } from '@nocobase/utils';
|
||||
import beforeInitOptions from './hooks/beforeInitOptions';
|
||||
import { beforeCreateForChildrenCollection } from './hooks/beforeCreateForChildrenCollection';
|
||||
import { beforeCreateForReverseField } from './hooks/beforeCreateForReverseField';
|
||||
import { afterCreateForReverseField } from './hooks/afterCreateForReverseField';
|
||||
|
||||
export * from './repositories/collection-repository';
|
||||
|
||||
export default class CollectionManagerPlugin extends Plugin {
|
||||
async load() {
|
||||
this.app.db.registerModels({
|
||||
CollectionModel,
|
||||
FieldModel,
|
||||
});
|
||||
|
||||
await this.app.db.import({
|
||||
directory: path.resolve(__dirname, './collections'),
|
||||
});
|
||||
|
||||
// 要在 beforeInitOptions 之前处理
|
||||
this.app.db.on('fields.beforeCreate', beforeCreateForReverseField(this.app.db));
|
||||
this.app.db.on('fields.beforeCreate', beforeCreateForChildrenCollection(this.app.db));
|
||||
@ -31,13 +34,15 @@ export default class CollectionManagerPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
this.app.db.on('fields.afterCreate', afterCreateForReverseField(this.app.db));
|
||||
this.app.db.on('collections.afterCreate', async (model, options) => {
|
||||
|
||||
this.app.db.on('collections.afterCreateWithAssociations', async (model, options) => {
|
||||
if (options.context) {
|
||||
process.nextTick(async () => {
|
||||
await model.migrate();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.app.db.on('fields.afterCreate', async (model, options) => {
|
||||
if (options.context) {
|
||||
process.nextTick(async () => {
|
||||
|
@ -16,7 +16,9 @@ export class CollectionModel extends MagicAttributeModel {
|
||||
async load(loadOptions: LoadOptions = {}) {
|
||||
const { skipExist, skipField } = loadOptions;
|
||||
const name = this.get('name');
|
||||
|
||||
let collection: Collection;
|
||||
|
||||
if (this.db.hasCollection(name)) {
|
||||
collection = this.db.getCollection(name);
|
||||
if (skipExist) {
|
||||
@ -26,6 +28,7 @@ export class CollectionModel extends MagicAttributeModel {
|
||||
} else {
|
||||
collection = this.db.collection(this.get());
|
||||
}
|
||||
|
||||
if (!skipField) {
|
||||
await this.loadFields();
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ export class FieldModel extends MagicAttributeModel {
|
||||
}
|
||||
|
||||
async load(loadOptions?: LoadOptions) {
|
||||
const { skipExist } = loadOptions;
|
||||
const { skipExist = false } = loadOptions || {};
|
||||
const collectionName = this.get('collectionName');
|
||||
if (!this.db.hasCollection(collectionName)) {
|
||||
throw new Error(`${collectionName} collection does not exist.`);
|
||||
|
@ -151,6 +151,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
await this.emitAsync('plugins.afterLoad');
|
||||
}
|
||||
|
||||
getPlugin<P extends Plugin>(name: string) {
|
||||
return this.plugins.get(name) as P;
|
||||
}
|
||||
|
||||
async emitAsync(event: string | symbol, ...args: any[]): Promise<boolean> {
|
||||
// @ts-ignore
|
||||
const events = this._events;
|
||||
|
@ -89,7 +89,7 @@ export class MockServer extends Application {
|
||||
if (params.resourceIndex) {
|
||||
filterByTk = params.resourceIndex;
|
||||
}
|
||||
let url = prefix;
|
||||
let url = prefix || '';
|
||||
if (keys.length > 1) {
|
||||
url += `/${keys[0]}/${resourceOf}/${keys[1]}`;
|
||||
} else {
|
||||
@ -135,4 +135,6 @@ export function mockServer(options?: ApplicationOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createMockServer() {}
|
||||
|
||||
export default mockServer;
|
||||
|
38
yarn.lock
38
yarn.lock
@ -2844,7 +2844,14 @@
|
||||
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
|
||||
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
|
||||
|
||||
"@types/react-dom@^16.9.8", "@types/react-dom@^17.0.0":
|
||||
"@types/react-dom@^16.9.8":
|
||||
version "16.9.14"
|
||||
resolved "https://registry.npmmirror.com/@types/react-dom/download/@types/react-dom-16.9.14.tgz#674b8f116645fe5266b40b525777fc6bb8eb3bcd"
|
||||
integrity sha512-FIX2AVmPTGP30OUJ+0vadeIFJJ07Mh1m+U0rxfgyW34p3rTlXI+nlenvAxNn4BP36YyI9IJ/+UJ7Wu22N1pI7A==
|
||||
dependencies:
|
||||
"@types/react" "^16"
|
||||
|
||||
"@types/react-dom@^17.0.0":
|
||||
version "17.0.11"
|
||||
resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466"
|
||||
integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==
|
||||
@ -2894,7 +2901,7 @@
|
||||
"@types/history" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.9.43", "@types/react@^17.0.0":
|
||||
"@types/react@*", "@types/react@^17.0.0":
|
||||
version "17.0.34"
|
||||
resolved "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
|
||||
integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==
|
||||
@ -2903,6 +2910,15 @@
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/react@^16", "@types/react@^16.9.43":
|
||||
version "16.14.21"
|
||||
resolved "https://registry.npmmirror.com/@types/react/download/@types/react-16.14.21.tgz#35199b21a278355ec7a3c40003bd6a334bd4ae4a"
|
||||
integrity sha512-rY4DzPKK/4aohyWiDRHS2fotN5rhBSK6/rz1X37KzNna9HJyqtaGAbq9fVttrEPWF5ywpfIP1ITL8Xi2QZn6Eg==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/sax@^1.2.1":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@types/sax/-/sax-1.2.3.tgz#b630ac1403ebd7812e0bf9a10de9bf5077afb348"
|
||||
@ -5369,6 +5385,11 @@ dedent@^0.7.0:
|
||||
resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
|
||||
integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
|
||||
|
||||
dedupe@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmmirror.com/dedupe/download/dedupe-3.0.2.tgz#c7c9d5534167b69dc07bd21d093882abbe88b0a9"
|
||||
integrity sha1-x8nVU0Fntp3Ae9IdCTiCq76IsKk=
|
||||
|
||||
deep-equal@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
|
||||
@ -8805,6 +8826,14 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
|
||||
resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
|
||||
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
|
||||
|
||||
json-templates@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmmirror.com/json-templates/download/json-templates-4.2.0.tgz#34fdd6fedbe6955e934d86812a89d30d1df87415"
|
||||
integrity sha1-NP3W/tvmlV6TTYaBKonTDR34dBU=
|
||||
dependencies:
|
||||
dedupe "^3.0.2"
|
||||
object-path "^0.11.8"
|
||||
|
||||
json2mq@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a"
|
||||
@ -10519,6 +10548,11 @@ object-keys@^1.0.12, object-keys@^1.1.1:
|
||||
resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
|
||||
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
|
||||
|
||||
object-path@^0.11.8:
|
||||
version "0.11.8"
|
||||
resolved "https://registry.nlark.com/object-path/download/object-path-0.11.8.tgz?cache=0&sync_timestamp=1631790608134&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fobject-path%2Fdownload%2Fobject-path-0.11.8.tgz#ed002c02bbdd0070b78a27455e8ae01fc14d4742"
|
||||
integrity sha1-7QAsArvdAHC3iidFXorgH8FNR0I=
|
||||
|
||||
object-visit@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
|
||||
|
Loading…
Reference in New Issue
Block a user