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:
ChengLei Shao 2022-01-24 14:10:35 +08:00 committed by GitHub
parent a2f3e1904e
commit bd285e0ba9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1552 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@ export function transactionWrapperBuilder(transactionGenerator) {
return results;
} catch (err) {
await transaction.rollback();
console.error({ err });
throw err;
}
} else {

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

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

View 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();
});
});

View 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;
}

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

View 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'],
});
});
});
});

View 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,
});
});
});

View 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 };

View 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;
}

View 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 };

View 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 };

View 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;

View 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;

View 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;

View 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;

View File

@ -0,0 +1 @@
export { default as default } from './server';

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

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

@ -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 () => {

View File

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

View File

@ -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.`);

View File

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

View File

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

View File

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