feat: acl optimization (#1136)

* fix: sort field with table dose not have primary key

* feat: fixed params merger

* chore(plugins/acl): fixed params

* chore(plugins/acl): allowConfigure of collections

* chore(plugins/china-region): disable actions other than list

* chore(plugins/collection-manager): allowConfigure permission

* chore(plugins/file-manager): acl fixed params

* chore: acl fixed params

* chore: rolesResourcesScopes onDelete cascade

* fix: install error

* chore: test

* fix: root user fixed params

* fix: role resource scope onDelete

* chore: test

* chore: test

* fix: acl

* chore: disable index.html cache

* chore: disable index.html cache

* test: destory user role

* test: destory throught table

* fix: test

* fix: test

* chore: add rolesUsers to fixed params

* feat: permission logging

* feat: permission logging

* fix: test

* fix: test

* chore: disable grant target action

* fix: appends with fields

* fix: get action params

* fix: associationActions

* chore: change AssociationField using relation type

* chore: typo

* refactor: allow to skip

* fix: prettier

* chore: attachments association action

* fix: allowConfigure condition

* fix: deprecated allow

* fix: please use skip instead

* feat: table column aclcheck

* chore: test

* feat: throw error when detory no permission record

* chore: test

* chore: acl test

* feat: field acl

* chore: after action middleware

* fix: destory permission check

* chore: middleware use

* fix: test

* feat: filter match

* feat: subform/subtable field acl check

* feat: action permision by scope

* feat: action permision by scope

* feat: list action with allowedActions

* chore: all allowed action

* fix: pk error

* fix: merge error

* fix: create query sql

* fix: skip permission

* fix: scope with association field

* feat: action acl fix

* feat: action acl fix

* fix: update submodule

* Feat: setting center permission (#1214)

* feat: add setting center permissions

* feat: setting center permissions backlist

* feat: setting center permissions BLACKLIST

* feat: setting center permissions blacklist

* feat: setting center permissions blacklist

* feat: setting center permission

* feat: configure plugin tab expand

Co-authored-by: chenos <chenlinxh@gmail.com>

* Feat :field acl (#1211)

Co-authored-by: chenos <chenlinxh@gmail.com>

* fix: build error

* test: acl snippet

* feat: set field

* fix: test

* fix: build error

* fix: utils Dependency cycles

* feat: general permissions

* feat: delete pluginTabBlacklist

* fix: test

* feat: snippetManager allow method

* feat: acl role snippetAllowed method

* feat: array field repository

* feat:  ArrayFieldRepository

* fix: test

* fix: ci

* fix: ci error

* fix: add set parse

* test: array field repository

* chore: addSnippetPatten

* fix: start

* feat: sync role snippets

* feat: snippets check

* feat: snippets check

* chore: acl role snippet api

* fix: test

* fix: test

* refactor: acl role snippets

* chore: registerACLSettingSnippet

* chore: default snippets

* feat: snippets match

* feat: snippets check

* feat: snippets check

* feat: pm permision check

* feat: pm permision check

* feat: snippet pattern match

* feat: pluginManagerToolbar check

* feat: pluginManagerToolbar check

* chore: snippets default value

* feat: set role snippets migration

* chore: snippets

* feat: acl local

* feat: acl local

* feat: bookmask fix

* feat: plugin-manger & ui-editor snippet

* feat: set allowConfigure to false when upgrade to snippets

* feat: destory action acl fix

* feat: destory action acl fix

* fix: association resource params merge

* fix: ui editor snippet

* feat:  action acl fix

* chore: move list meta middleware into plugins/acl

* fix: test

* feat:  action acl fix

* feat: action acl check fix

* feat: plugins toolbar fix

* feat: gitmodules

* fix: subproject

* chore: add avaiableActions to snippet

* chore: change plugin-manager snippet

* feat: configure action acl fix

* feat: plugin tab acl check fix

* chore: roles snippets

* fix: add actions to snippet

* feat: allowconfigure fix

* fix: count with filterBy

* fix: build error

* feat: get action with allowedActions

* feat: acl route check fix

* feat:  aclActionProvider fix

* feat: actionscpe fix

* feat: actionname alias

* feat: setting center fix

* feat: acl provider fix

* fix: role collection

* feat: associate resource  acl

* feat: associate resource  acl

* feat: redirect to 403

* feat: route redirct

* feat:  acl scope check by record

* fix: fields  appends fix

* fix: fields  appends fix

* fix: fields  appends fix

* fix: allowedActions  fix

* fix:  menu items

* fix: rename

* fix: improve code

* fix: improve code

* fix: improve code

* fix: ctx?.data?.data

* fix: styling

* fix: allowAll after ignore scope

* chore: allowConfigure condition

* fix: collections.fields:*

* fix: acl test

* fix: update submodule

* fix: acl test

* fix: acl snippet

* fix: updates

* fix: only load history for logged-in users

* fix: this.app.acl.registerSnippet

* fix: downloadXlsxTemplate

* fix: 404

* feat: allowedAction in association list response

* fix: listData get

* fix: test

* fix: x-collection-field

* fix: update record error

* fix: calendar template

* test: allow manager

* fix: fetch action step

* fix: update submodule

* fix: refresh

* fix: refresh

* fix: rolesResourcesScopes

* test: snippets

* fix: snippets

* fix: test

* fix: omit filter.createdById

* fix: improve code

* fix: collections path

* fix: test error

* fix: upgrade error

* fix: errors

* fix: read allowed actions error

* fix: kanban error

* fix: error

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
ChengLei Shao 2023-01-09 07:35:48 +08:00 committed by GitHub
parent 668f96fb25
commit a614bc7de8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 3056 additions and 635 deletions

View File

@ -13,7 +13,9 @@
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/resourcer": "0.8.1-alpha.4", "@nocobase/resourcer": "0.8.1-alpha.4",
"json-templates": "^4.2.0" "@nocobase/utils": "0.8.1-alpha.4",
"json-templates": "^4.2.0",
"minimatch": "^5.1.1"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -354,32 +354,4 @@ 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

@ -0,0 +1,19 @@
import { ACL } from '..';
import { AllowManager } from '../allow-manager';
describe('allow manager', () => {
let acl: ACL;
beforeEach(() => {
acl = new ACL();
});
it('should allow star resource', async () => {
const allowManager = new AllowManager(acl);
allowManager.allow('*', 'download', 'public');
expect(await allowManager.isAllowed('users', 'download', {})).toBeTruthy();
expect(await allowManager.isAllowed('users', 'fake-method', {})).toBeFalsy();
expect(await allowManager.isAllowed('users', 'other-method', {})).toBeFalsy();
});
});

View File

@ -40,6 +40,9 @@ describe('skip', () => {
resourceName: 'users', resourceName: 'users',
actionName: 'login', actionName: 'login',
}, },
log: {
info() {},
},
app: { app: {
acl, acl,
}, },
@ -49,6 +52,7 @@ describe('skip', () => {
const nextFunc = jest.fn(); const nextFunc = jest.fn();
let skip = false; let skip = false;
acl.allow('users', 'login', (ctx) => { acl.allow('users', 'login', (ctx) => {
return skip; return skip;
}); });
@ -76,6 +80,9 @@ describe('skip', () => {
resourceName: 'users', resourceName: 'users',
actionName: 'login', actionName: 'login',
}, },
log: {
info() {},
},
app: { app: {
acl, acl,
}, },

View File

@ -0,0 +1,82 @@
import { ACL } from '../acl';
import FixedParamsManager from '../fixed-params-manager';
describe('fixed params', () => {
it('should merge params', async () => {
const fixedParamsManager = new FixedParamsManager();
fixedParamsManager.addParams('collections', 'destroy', () => {
return {
filter: {
'name.$ne': 'users',
},
};
});
fixedParamsManager.addParams('collections', 'destroy', () => {
return {
filter: {
'name.$ne': 'roles',
},
};
});
const params = fixedParamsManager.getParams('collections', 'destroy');
expect(params).toEqual({
filter: {
$and: [
{
'name.$ne': 'users',
},
{
'name.$ne': 'roles',
},
],
},
});
});
it('should add fixed params to acl action', async () => {
const acl = new ACL();
const adminRole = acl.define({
role: 'admin',
actions: {
'collections:destroy': {},
},
});
let canResult = acl.can({
role: 'admin',
resource: 'collections',
action: 'destroy',
});
expect(canResult).toEqual({ role: 'admin', resource: 'collections', action: 'destroy' });
acl.addFixedParams('collections', 'destroy', () => {
return {
filter: {
'name.$ne': 'users',
},
};
});
canResult = acl.can({
role: 'admin',
resource: 'collections',
action: 'destroy',
});
expect(canResult).toEqual({
role: 'admin',
resource: 'collections',
action: 'destroy',
params: {
filter: {
'name.$ne': 'users',
},
},
});
});
});

View File

@ -0,0 +1,130 @@
import { ACL } from '..';
import SnippetManager from '../snippet-manager';
describe('acl snippet', () => {
let acl: ACL;
beforeEach(() => {
acl = new ACL();
});
it('should get effective snipptes', () => {
acl.registerSnippet({
name: 'sc.collection-manager.fields',
actions: ['fields:list'],
});
acl.registerSnippet({
name: 'sc.collection-manager.gi',
actions: ['fields:list', 'gi:list'],
});
acl.registerSnippet({
name: 'sc.collection-manager.collections',
actions: ['collections:list'],
});
const adminRole = acl.define({
role: 'admin',
});
adminRole.snippets.add('sc.*');
expect(adminRole.effectiveSnippets().allowed).toEqual([
'sc.collection-manager.fields',
'sc.collection-manager.gi',
'sc.collection-manager.collections',
]);
adminRole.snippets.add('!sc.collection-manager.gi');
expect(adminRole.effectiveSnippets().allowed).toEqual([
'sc.collection-manager.fields',
'sc.collection-manager.collections',
]);
});
it('should register snippet', () => {
acl.registerSnippet({
name: 'sc.collection-manager.fields',
actions: ['fields:list'],
});
acl.registerSnippet({
name: 'sc.collection-manager.gi',
actions: ['fields:list', 'gi:list'],
});
acl.registerSnippet({
name: 'sc.collection-manager.collections',
actions: ['collections:list'],
});
const adminRole = acl.define({
role: 'admin',
});
adminRole.snippets.add('sc.*');
expect(acl.can({ role: 'admin', resource: 'collections', action: 'list' })).not.toBeNull();
expect(adminRole.snippetAllowed('collections:list')).toBe(true);
adminRole.snippets.add('!sc.collection-manager.gi');
expect(acl.can({ role: 'admin', resource: 'gi', action: 'list' })).toBeNull();
expect(adminRole.snippetAllowed('gi:list')).toBe(false);
expect(acl.can({ role: 'admin', resource: 'fields', action: 'list' })).not.toBeNull();
expect(adminRole.snippetAllowed('fields:list')).toBe(true);
expect(adminRole.snippetAllowed('other:list')).toBeNull();
});
});
describe('snippet manager', () => {
describe('allow method', () => {
it('should return true when allowed', () => {
const snippetManager = new SnippetManager();
snippetManager.register({
name: 'sc.collection-manager.fields',
actions: ['collections:list'],
});
expect(snippetManager.allow('collections:list', 'sc.collection-manager.fields')).toBe(true);
});
it('should return true when allowed by wild match', () => {
const snippetManager = new SnippetManager();
snippetManager.register({
name: 'sc.collection-manager.fields',
actions: ['collections:*'],
});
expect(snippetManager.allow('collections:list', 'sc.collection-manager.fields')).toBe(true);
expect(snippetManager.allow('collections:destroy', 'sc.collection-manager.fields')).toBe(true);
});
it('should return false when match negated rule', () => {
const snippetManager = new SnippetManager();
snippetManager.register({
name: 'sc.collection-manager.fields',
actions: ['collections:*'],
});
expect(snippetManager.allow('collections:list', 'sc.collection-manager.fields')).toBe(true);
expect(snippetManager.allow('collections:destroy', '!sc.collection-manager.fields')).toBe(false);
});
it('should return null when not matched', () => {
const snippetManager = new SnippetManager();
snippetManager.register({
name: 'sc.collection-manager.fields',
actions: ['collections:*'],
});
expect(snippetManager.allow('fields:list', 'sc.collection-manager.fields')).toBeNull();
});
});
});

View File

@ -52,7 +52,7 @@ export class ACLAvailableStrategy {
if (this.actionsAsObject?.hasOwnProperty(actionName)) { if (this.actionsAsObject?.hasOwnProperty(actionName)) {
const predicateName = this.actionsAsObject[actionName]; const predicateName = this.actionsAsObject[actionName];
if (predicateName) { if (predicateName) {
return predicate[predicateName]; return lodash.cloneDeep(predicate[predicateName]);
} }
return true; return true;
@ -62,10 +62,6 @@ export class ACLAvailableStrategy {
} }
allow(resourceName: string, actionName: string) { allow(resourceName: string, actionName: string) {
if (this.acl.isConfigResource(resourceName) && this.allowConfigure) {
return true;
}
return this.matchAction(this.acl.resolveActionAlias(actionName)); return this.matchAction(this.acl.resolveActionAlias(actionName));
} }
} }

View File

@ -1,6 +1,8 @@
import { ACL, DefineOptions } from './acl'; import { ACL, DefineOptions } from './acl';
import { AvailableStrategyOptions } from './acl-available-strategy'; import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy';
import { ACLResource } from './acl-resource'; import { ACLResource } from './acl-resource';
import lodash from 'lodash';
import minimatch from 'minimatch';
export interface RoleActionParams { export interface RoleActionParams {
fields?: string[]; fields?: string[];
@ -18,6 +20,7 @@ export interface ResourceActionsOptions {
export class ACLRole { export class ACLRole {
strategy: string | AvailableStrategyOptions; strategy: string | AvailableStrategyOptions;
resources = new Map<string, ACLResource>(); resources = new Map<string, ACLResource>();
snippets: Set<string> = new Set();
constructor(public acl: ACL, public name: string) {} constructor(public acl: ACL, public name: string) {}
@ -29,6 +32,16 @@ export class ACLRole {
this.strategy = value; this.strategy = value;
} }
public getStrategy() {
if (!this.strategy) {
return null;
}
return lodash.isString(this.strategy)
? this.acl.availableStrategy.get(this.strategy)
: new ACLAvailableStrategy(this.acl, this.strategy);
}
public getResourceActionsParams(resourceName: string) { public getResourceActionsParams(resourceName: string) {
const resource = this.getResource(resourceName); const resource = this.getResource(resourceName);
return resource.getActions(); return resource.getActions();
@ -67,6 +80,64 @@ export class ACLRole {
resource.removeAction(actionName); resource.removeAction(actionName);
} }
public effectiveSnippets(): { allowed: Array<string>; rejected: Array<string> } {
const allowedSnippets = new Set<string>();
const rejectedSnippets = new Set<string>();
const availableSnippets = this.acl.snippetManager.snippets;
for (let snippetRule of this.snippets) {
const negated = snippetRule.startsWith('!');
snippetRule = negated ? snippetRule.slice(1) : snippetRule;
for (const [_, availableSnippet] of availableSnippets) {
if (minimatch(availableSnippet.name, snippetRule)) {
if (negated) {
rejectedSnippets.add(availableSnippet.name);
} else {
allowedSnippets.add(availableSnippet.name);
}
}
}
}
// get difference of allowed and rejected snippets
const effectiveSnippets = new Set([...allowedSnippets].filter((x) => !rejectedSnippets.has(x)));
return {
allowed: [...effectiveSnippets],
rejected: [...rejectedSnippets],
};
}
public snippetAllowed(actionPath: string) {
const effectiveSnippets = this.effectiveSnippets();
const getActions = (snippets) => {
return snippets.map((snippetName) => this.acl.snippetManager.snippets.get(snippetName).actions).flat();
};
const allowedActions = getActions(effectiveSnippets.allowed);
const rejectedActions = getActions(effectiveSnippets.rejected);
const actionMatched = (actionPath, actionRule) => {
return minimatch(actionPath, actionRule);
};
for (const action of allowedActions) {
if (actionMatched(actionPath, action)) {
return true;
}
}
for (const action of rejectedActions) {
if (actionMatched(actionPath, action)) {
return false;
}
}
return null;
}
public toJSON(): DefineOptions { public toJSON(): DefineOptions {
const actions = {}; const actions = {};
@ -81,6 +152,7 @@ export class ACLRole {
role: this.name, role: this.name,
strategy: this.strategy, strategy: this.strategy,
actions, actions,
snippets: Array.from(this.snippets),
}; };
} }

View File

@ -1,5 +1,5 @@
import { Action } from '@nocobase/resourcer'; import { Action } from '@nocobase/resourcer';
import { Toposort, ToposortOptions } from '@nocobase/utils'; import { assign, Toposort, ToposortOptions } from '@nocobase/utils';
import EventEmitter from 'events'; import EventEmitter from 'events';
import parse from 'json-templates'; import parse from 'json-templates';
import compose from 'koa-compose'; import compose from 'koa-compose';
@ -8,6 +8,8 @@ import { ACLAvailableAction, AvailableActionOptions } from './acl-available-acti
import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy'; import { ACLAvailableStrategy, AvailableStrategyOptions, predicate } from './acl-available-strategy';
import { ACLRole, ResourceActionsOptions, RoleActionParams } from './acl-role'; import { ACLRole, ResourceActionsOptions, RoleActionParams } from './acl-role';
import { AllowManager, ConditionFunc } from './allow-manager'; import { AllowManager, ConditionFunc } from './allow-manager';
import FixedParamsManager, { Merger } from './fixed-params-manager';
import SnippetManager, { SnippetOptions } from './snippet-manager';
interface CanResult { interface CanResult {
role: string; role: string;
@ -22,6 +24,7 @@ export interface DefineOptions {
strategy?: string | AvailableStrategyOptions; strategy?: string | AvailableStrategyOptions;
actions?: ResourceActionsOptions; actions?: ResourceActionsOptions;
routes?: any; routes?: any;
snippets?: string[];
} }
export interface ListenerContext { export interface ListenerContext {
@ -39,14 +42,18 @@ interface CanArgs {
role: string; role: string;
resource: string; resource: string;
action: string; action: string;
ctx?: any;
} }
export class ACL extends EventEmitter { export class ACL extends EventEmitter {
protected availableActions = new Map<string, ACLAvailableAction>(); protected availableActions = new Map<string, ACLAvailableAction>();
protected availableStrategy = new Map<string, ACLAvailableStrategy>(); public availableStrategy = new Map<string, ACLAvailableStrategy>();
protected fixedParamsManager = new FixedParamsManager();
protected middlewares: Toposort<any>; protected middlewares: Toposort<any>;
public allowManager = new AllowManager(this); public allowManager = new AllowManager(this);
public snippetManager = new SnippetManager();
roles = new Map<string, ACLRole>(); roles = new Map<string, ACLRole>();
@ -86,7 +93,66 @@ export class ACL extends EventEmitter {
} }
}); });
this.middlewares.add(this.allowManager.aclMiddleware()); this.use(this.allowManager.aclMiddleware(), {
tag: 'allow-manager',
before: 'core',
});
this.addCoreMiddleware();
}
protected addCoreMiddleware() {
const acl = this;
const filterParams = (ctx, resourceName, params) => {
if (params?.filter?.createdById) {
const collection = ctx.db.getCollection(resourceName);
if (!collection || !collection.getField('createdById')) {
return lodash.omit(params, 'filter.createdById');
}
}
return params;
};
this.middlewares.add(
async (ctx, next) => {
const resourcerAction: Action = ctx.action;
const { resourceName, actionName } = ctx.action;
const permission = ctx.permission;
ctx.log?.info && ctx.log.info('ctx permission', permission);
if ((!permission.can || typeof permission.can !== 'object') && !permission.skip) {
ctx.throw(403, 'No permissions');
return;
}
const params = permission.can?.params || acl.fixedParamsManager.getParams(resourceName, actionName);
ctx.log?.info && ctx.log.info('acl params', params);
if (params && resourcerAction.mergeParams) {
const filteredParams = filterParams(ctx, resourceName, params);
const parsedParams = acl.parseJsonTemplate(filteredParams, ctx);
ctx.permission.parsedParams = parsedParams;
ctx.log?.info && ctx.log.info('acl parsedParams', parsedParams);
ctx.permission.rawParams = lodash.cloneDeep(resourcerAction.params);
resourcerAction.mergeParams(parsedParams, {
appends: (x, y) => lodash.intersection(x, y),
});
ctx.permission.mergedParams = lodash.cloneDeep(resourcerAction.params);
}
await next();
},
{
tag: 'core',
group: 'core',
},
);
} }
define(options: DefineOptions): ACLRole { define(options: DefineOptions): ACLRole {
@ -164,6 +230,28 @@ export class ACL extends EventEmitter {
return null; return null;
} }
const snippetAllowed = aclRole.snippetAllowed(`${resource}:${action}`);
if (snippetAllowed === false) {
return null;
}
const fixedParams = this.fixedParamsManager.getParams(resource, action);
const mergeParams = (result: CanResult) => {
const params = result['params'] || {};
const mergedParams = assign(params, fixedParams);
if (Object.keys(mergedParams).length) {
result['params'] = mergedParams;
} else {
delete result['params'];
}
return result;
};
const aclResource = aclRole.getResource(resource); const aclResource = aclRole.getResource(resource);
if (aclResource) { if (aclResource) {
@ -171,39 +259,37 @@ export class ACL extends EventEmitter {
if (actionParams) { if (actionParams) {
// handle single action config // handle single action config
return { return mergeParams({
role, role,
resource, resource,
action, action,
params: actionParams, params: actionParams,
}; });
} else { } else {
return null; return null;
} }
} }
if (!aclRole.strategy) { const roleStrategy = aclRole.getStrategy();
if (!roleStrategy && !snippetAllowed) {
return null; return null;
} }
const roleStrategy = lodash.isString(aclRole.strategy) let roleStrategyParams = roleStrategy?.allow(resource, this.resolveActionAlias(action));
? this.availableStrategy.get(aclRole.strategy)
: new ACLAvailableStrategy(this, aclRole.strategy);
if (!roleStrategy) { if (!roleStrategyParams && snippetAllowed) {
return null; roleStrategyParams = {};
} }
const roleStrategyParams = roleStrategy.allow(resource, this.resolveActionAlias(action));
if (roleStrategyParams) { if (roleStrategyParams) {
const result = { role, resource, action }; const result = { role, resource, action, params: {} };
if (lodash.isPlainObject(roleStrategyParams)) { if (lodash.isPlainObject(roleStrategyParams)) {
result['params'] = roleStrategyParams; result['params'] = roleStrategyParams;
} }
return result; return mergeParams(result);
} }
return null; return null;
@ -218,10 +304,17 @@ export class ACL extends EventEmitter {
} }
use(fn: any, options?: ToposortOptions) { use(fn: any, options?: ToposortOptions) {
this.middlewares.add(fn, options); this.middlewares.add(fn, {
group: 'prep',
...options,
});
} }
allow(resourceName: string, actionNames: string[] | string, condition?: string | ConditionFunc) { allow(resourceName: string, actionNames: string[] | string, condition?: string | ConditionFunc) {
return this.skip(resourceName, actionNames, condition);
}
skip(resourceName: string, actionNames: string[] | string, condition?: string | ConditionFunc) {
if (!Array.isArray(actionNames)) { if (!Array.isArray(actionNames)) {
actionNames = [actionNames]; actionNames = [actionNames];
} }
@ -242,23 +335,10 @@ export class ACL extends EventEmitter {
middleware() { middleware() {
const acl = this; const acl = this;
const filterParams = (ctx, resourceName, params) => {
if (params?.filter?.createdById) {
const collection = ctx.db.getCollection(resourceName);
if (collection && !collection.getField('createdById')) {
return lodash.omit(params, 'filter.createdById');
}
}
return params;
};
return async function ACLMiddleware(ctx, next) { return async function ACLMiddleware(ctx, next) {
const roleName = ctx.state.currentRole || 'anonymous'; const roleName = ctx.state.currentRole || 'anonymous';
const { resourceName, actionName } = ctx.action; const { resourceName, actionName } = ctx.action;
const resourcerAction: Action = ctx.action;
ctx.can = (options: Omit<CanArgs, 'role'>) => { ctx.can = (options: Omit<CanArgs, 'role'>) => {
return acl.can({ role: roleName, ...options }); return acl.can({ role: roleName, ...options });
}; };
@ -267,28 +347,30 @@ export class ACL extends EventEmitter {
can: ctx.can({ resource: resourceName, action: actionName }), can: ctx.can({ resource: resourceName, action: actionName }),
}; };
return compose(acl.middlewares.nodes)(ctx, async () => { return compose(acl.middlewares.nodes)(ctx, next);
const permission = ctx.permission;
if (permission.skip) {
return next();
}
if (!permission.can || typeof permission.can !== 'object') {
ctx.throw(403, 'No permissions');
return;
}
const { params } = permission.can;
if (params) {
const filteredParams = filterParams(ctx, resourceName, params);
const parsedParams = acl.parseJsonTemplate(filteredParams, ctx);
resourcerAction.mergeParams(parsedParams);
}
await next();
});
}; };
} }
async getActionParams(ctx) {
const roleName = ctx.state.currentRole || 'anonymous';
const { resourceName, actionName } = ctx.action;
ctx.can = (options: Omit<CanArgs, 'role'>) => {
return this.can({ role: roleName, ...options });
};
ctx.permission = {
can: ctx.can({ resource: resourceName, action: actionName }),
};
await compose(this.middlewares.nodes)(ctx, async () => {});
}
addFixedParams(resource: string, action: string, merger: Merger) {
this.fixedParamsManager.addParams(resource, action, merger);
}
registerSnippet(snippet: SnippetOptions) {
this.snippetManager.register(snippet);
}
} }

View File

@ -12,19 +12,22 @@ export class AllowManager {
return ctx.state.currentUser; return ctx.state.currentUser;
}); });
this.registerAllowCondition('public', (ctx) => {
return true;
});
this.registerAllowCondition('allowConfigure', async (ctx) => { this.registerAllowCondition('allowConfigure', async (ctx) => {
const roleName = ctx.state.currentRole; const roleName = ctx.state.currentRole;
if (!roleName) { if (!roleName) {
return false; return false;
} }
const roleInstance = await ctx.db.getRepository('roles').findOne({ const role = acl.getRole(roleName);
filter: { if (!role) {
name: roleName, return false;
}, }
});
return roleInstance?.get('allowConfigure'); return role.getStrategy()?.allowConfigure;
}); });
} }
@ -43,9 +46,11 @@ export class AllowManager {
for (const fetchActionStep of fetchActionSteps) { for (const fetchActionStep of fetchActionSteps) {
const resource = this.skipActions.get(fetchActionStep); const resource = this.skipActions.get(fetchActionStep);
if (resource) { if (resource) {
const condition = resource.get('*') || resource.get(actionName); for (const fetchActionStep of ['*', actionName]) {
if (condition) { const condition = resource.get(fetchActionStep);
results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition); if (condition) {
results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition);
}
} }
} }
} }
@ -57,34 +62,40 @@ export class AllowManager {
this.registeredCondition.set(name, condition); this.registeredCondition.set(name, condition);
} }
async isAllowed(resourceName: string, actionName: string, ctx: any) {
const skippedConditions = this.getAllowedConditions(resourceName, actionName);
for (const skippedCondition of skippedConditions) {
if (skippedCondition) {
let skipResult = false;
if (typeof skippedCondition === 'function') {
skipResult = await skippedCondition(ctx);
} else if (skippedCondition) {
skipResult = true;
}
if (skipResult) {
return true;
}
}
}
return false;
}
aclMiddleware() { aclMiddleware() {
return async (ctx, next) => { return async (ctx, next) => {
const { resourceName, actionName } = ctx.action; const { resourceName, actionName } = ctx.action;
const skippedConditions = ctx.app.acl.allowManager.getAllowedConditions(resourceName, actionName); let skip = await this.acl.allowManager.isAllowed(resourceName, actionName, ctx);
let skip = false;
for (const skippedCondition of skippedConditions) {
if (skippedCondition) {
let skipResult = false;
if (typeof skippedCondition === 'function') {
skipResult = await skippedCondition(ctx);
} else if (skippedCondition) {
skipResult = true;
}
if (skipResult) {
skip = true;
break;
}
}
}
if (skip) { if (skip) {
ctx.permission = { ctx.permission = {
...(ctx.permission || {}),
skip: true, skip: true,
}; };
} }
await next(); await next();
}; };
} }

View File

@ -0,0 +1,51 @@
import { assign } from '@nocobase/utils';
type Context = any;
export type Merger = () => object;
export type ActionPath = string;
const SPLIT = ':';
export default class FixedParamsManager {
merger = new Map<ActionPath, Array<Merger>>();
addParams(resource: string, action: string, merger: Merger) {
const path = this.getActionPath(resource, action);
this.merger.set(path, [...this.getParamsMerger(resource, action), merger]);
}
getParamsMerger(resource: string, action: string) {
const path = this.getActionPath(resource, action);
return this.merger.get(path) || [];
}
protected getActionPath(resource: string, action: string) {
return `${resource}${SPLIT}${action}`;
}
getParams(resource: string, action: string, extraParams: any = {}) {
const results = {};
for (const merger of this.getParamsMerger(resource, action)) {
FixedParamsManager.mergeParams(results, merger());
}
if (extraParams) {
FixedParamsManager.mergeParams(results, extraParams);
}
return results;
}
static mergeParams(a: any, b: any) {
assign(a, b, {
filter: 'andMerge',
fields: 'intersect',
appends: 'union',
except: 'union',
whitelist: 'intersect',
blacklist: 'intersect',
sort: 'overwrite',
});
}
}

View File

@ -3,4 +3,6 @@ export * from './acl-available-action';
export * from './acl-available-strategy'; export * from './acl-available-strategy';
export * from './acl-resource'; export * from './acl-resource';
export * from './acl-role'; export * from './acl-role';
export * from './no-permission-error';
export * from './skip-middleware'; export * from './skip-middleware';

View File

@ -0,0 +1,7 @@
class NoPermissionError extends Error {
constructor(...args) {
super(...args);
}
}
export { NoPermissionError };

View File

@ -0,0 +1,45 @@
import { ACL } from './acl';
import minimatch from 'minimatch';
export type SnippetOptions = {
name: string;
actions: Array<string>;
};
class Snippet {
constructor(public name: string, public actions: Array<string>) {}
}
export type SnippetGroup = {
name: string;
snippets: SnippetOptions[];
};
class SnippetManager {
public snippets: Map<string, Snippet> = new Map();
register(snippet: SnippetOptions) {
this.snippets.set(snippet.name, snippet);
}
allow(actionPath: string, snippetName: string) {
const negated = snippetName.startsWith('!');
snippetName = negated ? snippetName.slice(1) : snippetName;
const snippet = this.snippets.get(snippetName);
if (!snippet) {
throw new Error(`Snippet ${snippetName} not found`);
}
const matched = snippet.actions.some((action) => minimatch(actionPath, action));
if (matched) {
return negated ? false : true;
}
return null;
}
}
export default SnippetManager;

View File

@ -1,8 +1,8 @@
import { MockServer, mockServer } from './index';
import { registerActions } from '@nocobase/actions'; import { registerActions } from '@nocobase/actions';
import { mockServer } from './index';
describe('destroy action', () => { describe('destroy action', () => {
let app: MockServer; let app;
let Post; let Post;
let Comment; let Comment;
let Tag; let Tag;

View File

@ -1,10 +1,10 @@
import { registerActions } from '@nocobase/actions'; import { registerActions } from '@nocobase/actions';
import { mockServer } from './index'; import { mockServer as actionMockServer } from './index';
describe('list action', () => { describe('list action', () => {
let app; let app;
beforeEach(async () => { beforeEach(async () => {
app = mockServer(); app = actionMockServer();
registerActions(app); registerActions(app);
const Post = app.collection({ const Post = app.collection({

View File

@ -1,9 +1,8 @@
import { Context } from '..';
import { getRepositoryFromParams } from '../utils'; import { getRepositoryFromParams } from '../utils';
import { Context } from '../index';
export async function destroy(ctx: Context, next) { export async function destroy(ctx: Context, next) {
const repository = getRepositoryFromParams(ctx); const repository = getRepositoryFromParams(ctx);
const { filterByTk, filter } = ctx.action.params; const { filterByTk, filter } = ctx.action.params;
const instance = await repository.destroy({ const instance = await repository.destroy({

View File

@ -57,10 +57,13 @@ async function listWithNonPaged(ctx: Context) {
export async function list(ctx: Context, next) { export async function list(ctx: Context, next) {
const { paginate } = ctx.action.params; const { paginate } = ctx.action.params;
if (paginate === false || paginate === 'false') { if (paginate === false || paginate === 'false') {
await listWithNonPaged(ctx); await listWithNonPaged(ctx);
ctx.paginate = false;
} else { } else {
await listWithPagination(ctx); await listWithPagination(ctx);
ctx.paginate = true;
} }
await next(); await next();

View File

@ -1,13 +1,15 @@
import { useFieldSchema } from '@formily/react'; import { Schema, useFieldSchema } from '@formily/react';
import { Spin } from 'antd'; import { Spin } from 'antd';
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { useAPIClient, useRequest } from '../api-client'; import { useAPIClient, useRequest } from '../api-client';
import { useBlockRequestContext } from '../block-provider/BlockProvider';
import { useCollection } from '../collection-manager'; import { useCollection } from '../collection-manager';
import { useRecordIsOwn } from '../record-provider'; import { useResourceActionContext } from '../collection-manager/ResourceActionProvider';
import { useRecord } from '../record-provider';
import { SchemaComponentOptions, useDesignable } from '../schema-component'; import { SchemaComponentOptions, useDesignable } from '../schema-component';
export const ACLContext = createContext(null); export const ACLContext = createContext<any>({});
export const ACLProvider = (props) => { export const ACLProvider = (props) => {
return ( return (
@ -19,7 +21,29 @@ export const ACLProvider = (props) => {
); );
}; };
const getRouteUrl = (props) => {
if (props?.match) {
return props.match;
}
return props && getRouteUrl(props?.children?.props);
};
const getRouteAclCheck = (match, snippets) => {
const { url, params } = match;
if (url === '/admin/pm/list' || params?.pluginName || params?.name?.includes('settings')) {
const pmAclCheck = url === '/admin/pm/list' && snippets.includes('pm');
const pluginTabByName = params?.name.split('/');
pluginTabByName.shift();
const pluginName = params.pluginName || pluginTabByName[0];
const tabName = params.tabName || pluginTabByName[1];
const pluginTabSnippet = pluginName && tabName && `!pm.${pluginName}.${tabName}`;
const pluginTabAclCheck = pluginTabSnippet && !snippets.includes(pluginTabSnippet);
return pmAclCheck || pluginTabAclCheck;
}
return true;
};
export const ACLRolesCheckProvider = (props) => { export const ACLRolesCheckProvider = (props) => {
const route = getRouteUrl(props.children.props);
const { setDesignable } = useDesignable(); const { setDesignable } = useDesignable();
const api = useAPIClient(); const api = useAPIClient();
const result = useRequest( const result = useRequest(
@ -28,7 +52,7 @@ export const ACLRolesCheckProvider = (props) => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
if (!data?.data?.allowConfigure && !data?.data?.allowAll) { if (!data?.data?.snippets.includes('ui.*')) {
setDesignable(false); setDesignable(false);
} }
if (data?.data?.role !== api.auth.role) { if (data?.data?.role !== api.auth.role) {
@ -48,9 +72,9 @@ export const ACLRolesCheckProvider = (props) => {
export const useRoleRecheck = () => { export const useRoleRecheck = () => {
const ctx = useContext(ACLContext); const ctx = useContext(ACLContext);
const { allowAll, allowConfigure } = useACLRoleContext(); const { allowAll } = useACLRoleContext();
return () => { return () => {
if (allowAll || allowConfigure) { if (allowAll) {
return; return;
} }
ctx.refresh(); ctx.refresh();
@ -61,98 +85,191 @@ export const useACLContext = () => {
return useContext(ACLContext); return useContext(ACLContext);
}; };
export const useACLRoleContext = () => { export const ACLActionParamsContext = createContext<any>({});
const ctx = useContext(ACLContext);
const data = ctx.data?.data;
export const useACLRolesCheck = () => {
const ctx = useContext(ACLContext);
const data = ctx?.data?.data;
const getActionAlias = (actionPath: string) => {
const actionName = actionPath.split(':').pop();
return data?.actionAlias?.[actionName] || actionName;
};
return { return {
...data, data,
getActionParams(path: string, { skipOwnCheck, isOwn }) { getActionAlias,
const [resourceName, act] = path.split(':'); inResources: (resourceName: string) => {
const currentAction = data?.actionAlias?.[act] || act; return data?.resources?.includes?.(resourceName);
const hasResource = data?.resources?.includes(resourceName); },
const params = data?.actions?.[`${resourceName}:${currentAction}`]; getResourceActionParams: (actionPath: string) => {
if (hasResource) { const [resourceName] = actionPath.split(':');
if (!skipOwnCheck && params?.own) { const actionAlias = getActionAlias(actionPath);
return isOwn ? params : null; return data?.actions?.[`${resourceName}:${actionAlias}`] || data?.actions?.[actionPath];
} },
return params; getStrategyActionParams: (actionPath: string) => {
} const actionAlias = getActionAlias(actionPath);
const strategyActions = data?.strategy?.actions || []; const strategyAction = data?.strategy?.actions.find((action) => {
const strategyAction = strategyActions?.find((action) => {
const [value] = action.split(':'); const [value] = action.split(':');
return value === currentAction; return value === actionAlias;
}); });
if (!strategyAction) { return strategyAction ? {} : null;
return;
}
if (skipOwnCheck) {
return {};
}
const [, actionScope] = strategyAction.split(':');
if (actionScope === 'own') {
return isOwn;
}
return {};
}, },
}; };
}; };
export const ACLAllowConfigure = (props) => { const getIgnoreScope = (options: any = {}) => {
const { allowAll, allowConfigure } = useACLRoleContext(); const { schema, recordPkValue } = options;
if (allowAll || allowConfigure) { let ignoreScope = false;
return <>{props.children}</>; if (options.ignoreScope) {
ignoreScope = true;
} }
return null; if (schema?.['x-acl-ignore-scope']) {
ignoreScope = true;
}
if (schema?.['x-acl-action-props']?.['skipScopeCheck']) {
ignoreScope = true;
}
if (!recordPkValue) {
ignoreScope = true;
}
return ignoreScope;
}; };
const ACLActionParamsContext = createContext<any>({}); const useAllowedActions = () => {
const result = useBlockRequestContext() || { service: useResourceActionContext() };
return result?.service?.data?.meta?.allowedActions;
};
const useResourceName = () => {
const result = useBlockRequestContext() || { service: useResourceActionContext() };
return result?.props?.resource || result?.service?.defaultRequest?.resource;
};
export function useACLRoleContext() {
const { data, getActionAlias, inResources, getResourceActionParams, getStrategyActionParams } = useACLRolesCheck();
const allowedActions = useAllowedActions();
const verifyScope = (actionName: string, recordPkValue: any) => {
const actionAlias = getActionAlias(actionName);
if (!Array.isArray(allowedActions?.[actionAlias])) {
return null;
}
return allowedActions[actionAlias].includes(recordPkValue);
};
return {
...data,
parseAction: (actionPath: string, options: any = {}) => {
const [resourceName, actionName] = actionPath.split(':');
if (!getIgnoreScope(options)) {
const r = verifyScope(actionName, options.recordPkValue);
if (r !== null) {
return r ? {} : null;
}
}
if (data?.allowAll) {
return {};
}
if (inResources(resourceName)) {
return getResourceActionParams(actionPath);
}
return getStrategyActionParams(actionPath);
},
};
}
export const ACLCollectionProvider = (props) => { export const ACLCollectionProvider = (props) => {
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext(); const { allowAll, parseAction } = useACLRoleContext();
const fieldSchema = useFieldSchema(); const schema = useFieldSchema();
const isOwn = useRecordIsOwn(); if (allowAll) {
if (allowAll || allowConfigure) {
return <>{props.children}</>; return <>{props.children}</>;
} }
const path = fieldSchema['x-acl-action']; const actionPath = schema['x-acl-action'];
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck; if (!actionPath) {
if (!path) {
return <>{props.children}</>; return <>{props.children}</>;
} }
const params = getActionParams(path, { isOwn, skipOwnCheck: skipScopeCheck === false ? false : true }); const params = parseAction(actionPath, { schema });
if (!params) { if (!params) {
return null; return null;
} }
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>; return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}; };
export const useACLActionParamsContext = () => {
return useContext(ACLActionParamsContext);
};
export const useRecordPkValue = () => {
const { getPrimaryKey } = useCollection();
const record = useRecord();
const primaryKey = getPrimaryKey();
return record?.[primaryKey];
};
export const ACLActionProvider = (props) => { export const ACLActionProvider = (props) => {
const { name } = useCollection(); const recordPkValue = useRecordPkValue();
const fieldSchema = useFieldSchema(); const resource = useResourceName();
const isOwn = useRecordIsOwn(); const { parseAction } = useACLRoleContext();
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext(); const schema = useFieldSchema();
if (!name || allowAll || allowConfigure) { let actionPath = schema['x-acl-action'];
if (!actionPath && resource && schema['x-action']) {
actionPath = `${resource}:${schema['x-action']}`;
}
if (!actionPath.includes(':')) {
actionPath = `${resource}:${actionPath}`;
}
if (!actionPath) {
return <>{props.children}</>; return <>{props.children}</>;
} }
const actionName = fieldSchema['x-action']; const params = parseAction(actionPath, { schema, recordPkValue });
const path = fieldSchema['x-acl-action'] || `${name}:${actionName}`;
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck;
const params = getActionParams(path, { skipOwnCheck: skipScopeCheck, isOwn });
if (!params) { if (!params) {
return null; return null;
} }
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>; return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}; };
export const useACLFieldWhitelist = () => {
const params = useContext(ACLActionParamsContext);
const whitelist = []
.concat(params?.whitelist || [])
.concat(params?.fields || [])
.concat(params?.appends || []);
return {
whitelist,
schemaInWhitelist(fieldSchema: Schema) {
if (whitelist.length === 0) {
return true;
}
if (!fieldSchema) {
return true;
}
if (!fieldSchema['x-collection-field']) {
return true;
}
const [, ...keys] = fieldSchema['x-collection-field'].split('.');
return whitelist?.includes(keys.join('.'));
},
};
};
export const ACLCollectionFieldProvider = (props) => { export const ACLCollectionFieldProvider = (props) => {
const fieldSchema = useFieldSchema();
const { allowAll } = useACLRoleContext();
if (allowAll) {
return <>{props.children}</>;
}
if (!fieldSchema['x-collection-field']) {
return <>{props.children}</>;
}
const { whitelist } = useACLFieldWhitelist();
const allowed = whitelist.length > 0 ? whitelist.includes(fieldSchema.name) : true;
if (!allowed) {
return null;
}
return <>{props.children}</>; return <>{props.children}</>;
}; };
export const ACLMenuItemProvider = (props) => { export const ACLMenuItemProvider = (props) => {
const { allowAll, allowConfigure, allowMenuItemIds = [] } = useACLRoleContext(); const { allowAll, allowMenuItemIds = [], snippets } = useACLRoleContext();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
if (allowAll || allowConfigure) { if (allowAll || snippets.includes('ui.*')) {
return <>{props.children}</>; return <>{props.children}</>;
} }
if (!fieldSchema['x-uid']) { if (!fieldSchema['x-uid']) {

View File

@ -0,0 +1,148 @@
import { Checkbox, message, Table } from 'antd';
import React, { createContext, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord } from '../../record-provider';
import { useCompile } from '../../schema-component';
const getParentKeys = (tree, func, path = []) => {
if (!tree) return [];
for (const data of tree) {
path.push(data.key);
if (func(data)) return path;
if (data.children) {
const findChildren = getParentKeys(data.children, func, path);
if (findChildren.length) return findChildren;
}
path.pop();
}
return [];
};
const getChildrenKeys = (data = [], arr = []) => {
for (let item of data) {
arr.push(item.key);
if (item.children && item.children.length) getChildrenKeys(item.children, arr);
}
return arr;
};
const SettingMenuContext = createContext(null);
function useGetContext() {
const [context, setContext] = useState(null);
import('../../pm').then(({ SettingsCenterContext }) => {
setContext(SettingsCenterContext);
});
return context;
}
export const SettingCenterProvider = (props) => {
const context = useGetContext();
const configureItems = context && useContext(context);
return <SettingMenuContext.Provider value={configureItems}>{props.children}</SettingMenuContext.Provider>;
};
const formatPluginTabs = (data) => {
const tabs = [];
for (const key in data) {
const plugin = data?.[key];
for (const tabKey in plugin?.tabs || {}) {
const tab = plugin?.tabs[tabKey];
tabs.push({
pluginTitle: plugin.title,
...tab,
key: `pm.${key}.${tabKey}`,
});
}
}
return tabs;
const arr: any[] = Object.entries(data);
const pluginsTabs = [];
console.log(tabs);
arr.forEach((v) => {
const children = Object.entries(v[1].tabs).map((k: any) => {
return {
key: 'pm.' + v[0] + '.' + k[0],
title: k[1].title,
};
});
pluginsTabs.push({
title: v[1].title,
key: 'pm.' + v[0],
children,
});
});
return pluginsTabs;
};
export const SettingsCenterConfigure = () => {
const record = useRecord();
const api = useAPIClient();
const pluginTags = useContext(SettingMenuContext);
const items: any[] = (pluginTags && formatPluginTabs(pluginTags)) || [];
const { t } = useTranslation();
const compile = useCompile();
const { loading, refresh, data } = useRequest({
resource: 'roles.snippets',
resourceOf: record.name,
action: 'list',
params: {
paginate: false,
},
});
const resource = api.resource('roles.snippets', record.name);
const handleChange = async (checked, record) => {
const childrenKeys = getChildrenKeys(record?.children, []);
const totalKeys = childrenKeys.concat(record.key);
if (!checked) {
await resource.remove({
values: totalKeys.map((v) => '!' + v),
});
refresh();
} else {
await resource.add({
values: totalKeys.map((v) => '!' + v),
});
refresh();
}
message.success(t('Saved successfully'));
};
return (
items?.length && (
<Table
loading={loading}
rowKey={'key'}
pagination={false}
columns={[
{
dataIndex: 'title',
title: t('Plugin tab name'),
render: (value) => {
return compile(value);
},
},
{
dataIndex: 'pluginTitle',
title: t('Plugin name'),
render: (value) => {
return compile(value);
},
},
{
dataIndex: 'accessible',
title: t('Accessible'),
render: (_, record) => {
const checked = !data?.data?.includes('!' + record.key);
return !record.children && <Checkbox checked={checked} onChange={() => handleChange(checked, record)} />;
},
},
]}
dataSource={items}
/>
)
);
};

View File

@ -1,9 +1,9 @@
import { Checkbox, message, Table } from 'antd'; import { Checkbox, message, Table } from 'antd';
import { uniq } from 'lodash';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client'; import { useAPIClient, useRequest } from '../../api-client';
import { useRecord } from '../../record-provider'; import { useRecord } from '../../record-provider';
import { uniq } from 'lodash';
import { useMenuItems } from './MenuItemsProvider'; import { useMenuItems } from './MenuItemsProvider';
const findUids = (items) => { const findUids = (items) => {
@ -117,7 +117,7 @@ export const MenuConfigure = () => {
message.success(t('Saved successfully')); message.success(t('Saved successfully'));
}} }}
/> />
{t('Accessible')} {' '}{t('Accessible')}
</> </>
), ),
render: (_, schema) => { render: (_, schema) => {

View File

@ -0,0 +1,61 @@
import { message } from 'antd';
import React, { createContext, useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../api-client';
import { useRecord } from '../../record-provider';
export const SettingCenterPermissionProvider = (props) => {
const { currentRecord } = useContext(PermissionContext);
if (!currentRecord?.snippets?.includes('pm.*')) {
return null;
}
return <div>{props.children}</div>;
};
export const PermissionContext = createContext<any>(null);
export const PermissionProvider = (props) => {
const api = useAPIClient();
const record = useRecord();
const { t } = useTranslation();
const { snippets } = record;
snippets?.forEach((key) => {
record[key] = true;
});
const [currentRecord, setCurrentRecord] = useState(record);
return (
<PermissionContext.Provider
value={{
currentRecord,
update: async (field, form) => {
const { path, value } = field.getState() as any;
if (['ui.*', 'pm', 'pm.*'].includes(path)) {
const resource = api.resource('roles.snippets', record.name);
if (value) {
await resource.add({
values: [path],
});
} else {
await resource.remove({
values: [path],
});
}
setCurrentRecord({ ...currentRecord, ...form.values, [path]: value });
} else {
await api.resource('roles').update({
filterByTk: record.name,
values: form.values,
});
setCurrentRecord({ ...currentRecord, ...form.values });
}
message.success(t('Saved successfully'));
},
}}
>
{props.children}
</PermissionContext.Provider>
);
};

View File

@ -1,31 +1,81 @@
import { onFieldChange } from '@formily/core'; import { onFieldChange } from '@formily/core';
import { message } from 'antd'; import { connect } from '@formily/react';
import React from 'react'; import { Checkbox } from 'antd';
import uniq from 'lodash/uniq';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client'; import { useAPIClient, useRequest } from '../../api-client';
import { useRecord } from '../../record-provider';
import { SchemaComponent } from '../../schema-component'; import { SchemaComponent } from '../../schema-component';
import { PermissionContext } from './PermisionProvider';
const SnippetCheckboxGroup = connect((props) => {
const { t } = useTranslation();
return (
<Checkbox.Group
style={{
width: '100%',
}}
value={props.value}
onChange={(values) => {
const value = uniq([...(props.value || []), ...values])
.filter((key) => key && !['!ui.*', '!pm', '!pm.*'].includes(key))
.map((key) => {
if (!['ui.*', 'pm', 'pm.*'].includes(key)) {
return key;
}
if (values?.includes(key)) {
return key;
}
return `!${key}`;
});
for (const key of ['ui.*', 'pm', 'pm.*']) {
if (!value.includes(key) && !value.includes(`!${key}`)) {
value.push(`!${key}`);
}
}
props.onChange(value);
}}
>
<div style={{ marginTop: 16 }}>
<Checkbox value="ui.*">{t('Allows to configure interface')}</Checkbox>
</div>
<div style={{ marginTop: 8 }}>
<Checkbox value="pm">{t('Allows to install, activate, disable plugins')}</Checkbox>
</div>
<div style={{ marginTop: 8 }}>
<Checkbox value="pm.*">{t('Allows to configure plugins')}</Checkbox>
</div>
</Checkbox.Group>
);
});
export const RoleConfigure = () => { export const RoleConfigure = () => {
const api = useAPIClient(); const { update, currentRecord } = useContext(PermissionContext);
const record = useRecord();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SchemaComponent <SchemaComponent
components={{ SnippetCheckboxGroup }}
schema={{ schema={{
type: 'void', type: 'void',
name: 'form', name: 'form',
'x-component': 'Form', 'x-component': 'Form',
'x-component-props': { 'x-component-props': {
useValues: (options) => { useValues: (options) => {
const api = useAPIClient();
return useRequest( return useRequest(
{ () =>
resource: 'roles', api
action: 'get', .resource('roles')
params: { .get({
filterByTk: record.name, filterByTk: currentRecord.name,
}, })
}, .then((res) => {
const record = res?.data?.data;
record.snippets?.forEach((key) => {
record[key] = true;
});
return { data: record };
}),
options, options,
); );
}, },
@ -34,24 +84,22 @@ export const RoleConfigure = () => {
if (!form.modified) { if (!form.modified) {
return; return;
} }
await api.resource('roles').update({ await update(field, form);
filterByTk: record.name,
values: form.values,
});
message.success(t('Saved successfully'));
}); });
}, },
}, },
properties: { properties: {
allowConfigure: { snippets: {
title: t('Configure permissions'), title: t('Configure permissions'),
type: 'boolean',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Checkbox', 'x-component': 'SnippetCheckboxGroup',
'x-content': t('Allows configuration of the whole system, including UI, collections, permissions, etc.'),
}, },
'strategy.actions': { 'strategy.actions': {
title: t('Global action permissions'), title: t('Global action permissions'),
description: t('All collections use general action permissions by default; permission configured individually will override the default one.'), description: t(
'All collections use general action permissions by default; permission configured individually will override the default one.',
),
'x-component': 'StrategyActions', 'x-component': 'StrategyActions',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
}, },

View File

@ -2,10 +2,11 @@ import { Spin } from 'antd';
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { useRequest } from '../../api-client'; import { useRequest } from '../../api-client';
import { SchemaComponent } from '../../schema-component'; import { SchemaComponent } from '../../schema-component';
import { roleSchema } from './schemas/roles';
import { MenuItemsProvider } from '../Configuration/MenuItemsProvider'; import { MenuItemsProvider } from '../Configuration/MenuItemsProvider';
import { PermissionProvider, SettingCenterPermissionProvider } from '../Configuration/PermisionProvider';
import { roleSchema } from './schemas/roles';
const AvailableActionsContext = createContext(null); const AvailableActionsContext = createContext([]);
const AvailableActionsProver: React.FC = (props) => { const AvailableActionsProver: React.FC = (props) => {
const { data, loading } = useRequest({ const { data, loading } = useRequest({
@ -26,7 +27,7 @@ export const RoleTable = () => {
return ( return (
<div> <div>
<AvailableActionsProver> <AvailableActionsProver>
<SchemaComponent schema={roleSchema} components={{ MenuItemsProvider }} /> <SchemaComponent schema={roleSchema} components={{ MenuItemsProvider, SettingCenterPermissionProvider,PermissionProvider }} />
</AvailableActionsProver> </AvailableActionsProver>
</div> </div>
); );

View File

@ -37,8 +37,8 @@ export const RolesResourcesActions = connect((props) => {
const roleCollection = useRecord(); const roleCollection = useRecord();
const availableActions = useAvailableActions(); const availableActions = useAvailableActions();
const { getCollection, getCollectionFields } = useCollectionManager(); const { getCollection, getCollectionFields } = useCollectionManager();
const collection = getCollection(roleCollection.name); const collection = getCollection(roleCollection.collectionName);
const collectionFields = getCollectionFields(roleCollection.name); const collectionFields = getCollectionFields(roleCollection.collectionName);
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const field = useField<ArrayField>(); const field = useField<ArrayField>();

View File

@ -3,4 +3,4 @@ export { RoleConfigure } from './RoleConfigure';
export { RolesResourcesActions } from './RolesResourcesActions'; export { RolesResourcesActions } from './RolesResourcesActions';
export { RoleTable } from './RoleTable'; export { RoleTable } from './RoleTable';
export { StrategyActions } from './StrategyActions'; export { StrategyActions } from './StrategyActions';
export { SettingsCenterConfigure,SettingCenterProvider } from './ConfigureCenter';

View File

@ -18,17 +18,30 @@ const collection = {
required: true, required: true,
} as ISchema, } as ISchema,
}, },
// { {
// type: 'string', type: 'string',
// name: 'name', name: 'name',
// interface: 'input', interface: 'input',
// uiSchema: { uiSchema: {
// title: '数据表标识', title: '{{t("Collection name")}}',
// type: 'string', type: 'string',
// 'x-component': 'Input', 'x-component': 'Input',
// description: '使用英文', } as ISchema,
// } as ISchema, },
// }, {
type: 'string',
name: 'type',
interface: 'input',
uiSchema: {
title: '{{t("Resource type")}}',
type: 'string',
'x-component': 'Select',
enum: [
{ label: '{{t("Collection")}}', value: 'collection', color: 'green' },
{ label: '{{t("Association")}}', value: 'association', color: 'blue' },
],
} as ISchema,
},
{ {
type: 'string', type: 'string',
name: 'usingConfig', name: 'usingConfig',
@ -89,7 +102,7 @@ export const roleCollectionsSchema: ISchema = {
useDataSource: '{{ cm.useDataSourceFromRAC }}', useDataSource: '{{ cm.useDataSourceFromRAC }}',
}, },
properties: { properties: {
column1: { column0: {
type: 'void', type: 'void',
'x-decorator': 'Table.Column.Decorator', 'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column', 'x-component': 'Table.Column',
@ -101,6 +114,18 @@ export const roleCollectionsSchema: ISchema = {
}, },
}, },
}, },
// column1: {
// type: 'void',
// 'x-decorator': 'Table.Column.Decorator',
// 'x-component': 'Table.Column',
// properties: {
// type: {
// type: 'string',
// 'x-component': 'CollectionField',
// 'x-read-pretty': true,
// },
// },
// },
column2: { column2: {
type: 'void', type: 'void',
'x-decorator': 'Table.Column.Decorator', 'x-decorator': 'Table.Column.Decorator',

View File

@ -1,6 +1,9 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import pick from 'lodash/pick';
import { useEffect } from 'react';
import { useRequest } from '../../../api-client'; import { useRequest } from '../../../api-client';
import { useRecord } from '../../../record-provider';
import { useActionContext } from '../../../schema-component'; import { useActionContext } from '../../../schema-component';
import { roleCollectionsSchema } from './roleCollections'; import { roleCollectionsSchema } from './roleCollections';
@ -112,6 +115,7 @@ export const roleSchema: ISchema = {
Promise.resolve({ Promise.resolve({
data: { data: {
name: `r_${uid()}`, name: `r_${uid()}`,
snippets: ['!ui.*', '!pm', '!pm.*'],
}, },
}), }),
{ ...options, refreshDeps: [ctx.visible] }, { ...options, refreshDeps: [ctx.visible] },
@ -227,11 +231,14 @@ export const roleSchema: ISchema = {
type: 'void', type: 'void',
title: '{{t("Configure")}}', title: '{{t("Configure")}}',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'roles:update',
'x-component-props': {}, 'x-component-props': {},
properties: { properties: {
drawer: { drawer: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-decorator': 'PermissionProvider',
title: '{{t("Configure permissions")}}', title: '{{t("Configure permissions")}}',
properties: { properties: {
tabs1: { tabs1: {
@ -271,6 +278,19 @@ export const roleSchema: ISchema = {
}, },
}, },
}, },
tab4: {
type: 'void',
title: '{{t("Settings center permissions")}}',
'x-decorator': 'SettingCenterPermissionProvider',
'x-component': 'Tabs.TabPane',
'x-component-props': {},
properties: {
menu: {
'x-decorator': 'SettingCenterProvider',
'x-component': 'SettingsCenterConfigure',
},
},
},
}, },
}, },
}, },
@ -280,6 +300,8 @@ export const roleSchema: ISchema = {
update: { update: {
type: 'void', type: 'void',
title: '{{t("Edit")}}', title: '{{t("Edit")}}',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'roles:update',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-component-props': { 'x-component-props': {
type: 'primary', type: 'primary',
@ -290,7 +312,23 @@ export const roleSchema: ISchema = {
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-decorator': 'Form', 'x-decorator': 'Form',
'x-decorator-props': { 'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}', useValues: (options) => {
const record = useRecord();
const result = useRequest(
() => Promise.resolve({ data: pick(record, ['title', 'name', 'default']) }),
{
...options,
manual: true,
},
);
const ctx = useActionContext();
useEffect(() => {
if (ctx.visible) {
result.run();
}
}, [ctx.visible]);
return result;
},
}, },
title: '{{t("Edit role")}}', title: '{{t("Edit role")}}',
properties: { properties: {
@ -337,6 +375,8 @@ export const roleSchema: ISchema = {
delete: { delete: {
type: 'void', type: 'void',
title: '{{ t("Delete") }}', title: '{{ t("Delete") }}',
'x-acl-action': 'roles:destroy',
'x-decorator': 'ACLActionProvider',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-component-props': { 'x-component-props': {
confirm: { confirm: {

View File

@ -222,6 +222,7 @@ export const scopesSchema: ISchema = {
type: 'void', type: 'void',
title: '{{ t("Edit") }}', title: '{{ t("Edit") }}',
'x-action': 'update', 'x-action': 'update',
'x-decorator': 'ACLActionProvider',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-component-props': { 'x-component-props': {
openMode: 'drawer', openMode: 'drawer',
@ -308,6 +309,7 @@ export const scopesSchema: ISchema = {
destroy: { destroy: {
title: '{{ t("Delete") }}', title: '{{ t("Delete") }}',
'x-action': 'destroy', 'x-action': 'destroy',
'x-decorator': 'ACLActionProvider',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-designer': 'Action.Designer', 'x-designer': 'Action.Designer',
'x-component-props': { 'x-component-props': {

View File

@ -1,3 +1,4 @@
export * from './ACLProvider'; export * from './ACLProvider';
export * from './ACLShortcut'; export * from './ACLShortcut';
import './style.less';

View File

@ -0,0 +1,10 @@
.ant-table-cell {
> .ant-space-horizontal {
.ant-space-item:empty:not(:last-child) + .ant-space-item-split {
display: none;
}
.ant-space-item-split:has(+ .ant-space-item:empty) {
display: none;
}
}
}

View File

@ -22,6 +22,10 @@ export class APIClient extends APIClientSDK {
} }
interceptors() { interceptors() {
this.axios.interceptors.request.use((config) => {
config.headers['X-With-ACL-Meta'] = true;
return config;
});
super.interceptors(); super.interceptors();
this.notification(); this.notification();
} }
@ -37,11 +41,11 @@ export class APIClient extends APIClientSDK {
if (error.response.data.type === 'application/json') { if (error.response.data.type === 'application/json') {
handleErrorMessage(error); handleErrorMessage(error);
} else { } else {
notification.error({ notification.error({
message: error?.response?.data?.errors?.map?.((error: any) => { message: error?.response?.data?.errors?.map?.((error: any) => {
return React.createElement('div', { children: error.message }); return React.createElement('div', { children: error.message });
}), }),
}); });
} }
throw error; throw error;
}, },

View File

@ -120,11 +120,11 @@ export const useKanbanBlockContext = () => {
const useDisableCardDrag = () => { const useDisableCardDrag = () => {
const ctx = useKanbanBlockContext(); const ctx = useKanbanBlockContext();
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext(); const { allowAll, allowConfigure, parseAction } = useACLRoleContext();
if (allowAll || allowConfigure) { if (allowAll || allowConfigure) {
return false; return false;
} }
const result = getActionParams(`${ctx?.props?.resource}:update`, { skipOwnCheck: true }); const result = parseAction(`${ctx?.props?.resource}:update`, { ignoreScope: true });
return !result; return !result;
}; };

View File

@ -21,8 +21,8 @@ export const useCollection = () => {
}, },
[], [],
); );
const totalFields = unionBy(currentFields?.concat(inheritedFields),'name').filter((v)=>{ const totalFields = unionBy(currentFields?.concat(inheritedFields), 'name').filter((v) => {
return !v.isForeignKey return !v.isForeignKey;
}); });
return { return {
...collection, ...collection,
@ -32,7 +32,14 @@ export const useCollection = () => {
return fields?.find((field) => field.name === name); return fields?.find((field) => field.name === name);
}, },
fields: totalFields, fields: totalFields,
getPrimaryKey: () => {
if (collection.targetKey || collection.filterTargetKey) {
return collection.targetKey || collection.filterTargetKey;
}
const field = currentFields.find((field) => field.primaryKey);
return field ? field.name : 'id';
},
currentFields, currentFields,
inheritedFields inheritedFields,
}; };
}; };

View File

@ -5,7 +5,7 @@ import {
recordPickerSelector, recordPickerSelector,
recordPickerViewer, recordPickerViewer,
relationshipType, relationshipType,
reverseFieldProperties, reverseFieldProperties
} from './properties'; } from './properties';
import { IField } from './types'; import { IField } from './types';
@ -59,6 +59,7 @@ export const o2m: IField = {
block: { block: {
type: 'void', type: 'void',
'x-decorator': 'TableFieldProvider', 'x-decorator': 'TableFieldProvider',
'x-acl-action': `${association}:list`,
'x-decorator-props': { 'x-decorator-props': {
collection: field.target, collection: field.target,
association: association, association: association,

View File

@ -28,6 +28,7 @@ export const subTable: IField = {
block: { block: {
type: 'void', type: 'void',
'x-decorator': 'TableFieldProvider', 'x-decorator': 'TableFieldProvider',
'x-acl-action': `${field.target}:list`,
'x-decorator-props': { 'x-decorator-props': {
collection: field.target, collection: field.target,
association: association, association: association,

View File

@ -7,6 +7,11 @@ export const calendar: ICollectionTemplate = {
order: 2, order: 2,
color: 'orange', color: 'orange',
default: { default: {
createdBy: true,
updatedBy: true,
createdAt: true,
updatedAt: true,
sortable: true,
fields: [ fields: [
{ {
name: 'cron', name: 'cron',

View File

@ -419,6 +419,10 @@ export default {
"General permissions": "General permissions", "General permissions": "General permissions",
"Global action permissions": "Global action permissions", "Global action permissions": "Global action permissions",
"General action permissions": "General action permissions", "General action permissions": "General action permissions",
"Settings center permissions":"Settings center permissions",
"Allow to desgin pages":"Allow to desgin pages",
"Allow to manage plugins":"Allow to manage plugins",
"Allow to configure plugins":"Allow to configure plugins",
"Action display name": "Action display name", "Action display name": "Action display name",
"Allow": "Allow", "Allow": "Allow",
"Data scope": "Data scope", "Data scope": "Data scope",

View File

@ -402,6 +402,10 @@ export default {
"General permissions": "一般設定", "General permissions": "一般設定",
"Global action permissions": "グローバル操作権限", "Global action permissions": "グローバル操作権限",
"General action permissions": "一般操作権限", "General action permissions": "一般操作権限",
"Settings center permissions":"中央権限の設定",
'Allow to desgin pages':"インタフェース構成の許可",
"Allow to manage plugins":"管理プラグインの許可",
"Allow to configure plugins":"管理構成センターの許可",
"Action display name": "操作名", "Action display name": "操作名",
"Allow": "許可する", "Allow": "許可する",
"Data scope": "レコードスコープ", "Data scope": "レコードスコープ",

View File

@ -496,6 +496,13 @@ export default {
'General permissions': '通用配置', 'General permissions': '通用配置',
'Global action permissions': '全局操作权限', 'Global action permissions': '全局操作权限',
'General action permissions': '通用操作权限', 'General action permissions': '通用操作权限',
"Settings center permissions":"配置中心权限",
'Allow to desgin pages':"允许界面配置",
"Allow to manage plugins":"允许管理插件",
"Allow to configure plugins":"允许管理配置中心",
'Allows to configure interface': '允许配置界面',
'Allows to install, activate, disable plugins': '允许安装、激活、禁用插件',
'Allows to configure plugins': '允许配置插件',
'Action display name': '操作名称', 'Action display name': '操作名称',
'Allow': '允许', 'Allow': '允许',
'Data scope': '数据范围', 'Data scope': '数据范围',
@ -614,5 +621,7 @@ export default {
"Search and select collection": "搜索并选择数据表", "Search and select collection": "搜索并选择数据表",
'Please fill in the iframe URL': '请填写嵌入的地址', 'Please fill in the iframe URL': '请填写嵌入的地址',
'Fix block': '固定区块' 'Fix block': '固定区块',
'Plugin name': '插件',
'Plugin tab name': '插件标签页',
} }

View File

@ -6,6 +6,7 @@ import { get } from 'lodash';
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useACLRoleContext } from '../acl/ACLProvider';
import { PluginManagerContext } from './context'; import { PluginManagerContext } from './context';
export const usePrefixCls = ( export const usePrefixCls = (
@ -142,19 +143,15 @@ PluginManager.Toolbar.Item = (props) => {
}; };
export const RemotePluginManagerToolbar = () => { export const RemotePluginManagerToolbar = () => {
// const api = useAPIClient(); const { allowAll, snippets } = useACLRoleContext();
// const { data, loading } = useRequest({ const getSnippetsAllow = (aclKey) => {
// resource: 'plugins', return allowAll || snippets?.includes(aclKey);
// action: 'getPinned', };
// });
// if (loading) {
// return <Spin />;
// }
const items = [ const items = [
{ component: 'DesignableSwitch', pin: true }, { component: 'DesignableSwitch', pin: true, isAllow: getSnippetsAllow('ui.*') },
{ component: 'PluginManagerLink', pin: true }, { component: 'PluginManagerLink', pin: true, isAllow: getSnippetsAllow('pm') },
{ component: 'SettingsCenterDropdown', pin: true }, { component: 'SettingsCenterDropdown', pin: true, isAllow: getSnippetsAllow('pm.*') },
// ...data?.data, // ...data?.data,
]; ];
return <PluginManager.Toolbar items={items} />; return <PluginManager.Toolbar items={items.filter((v) => v.isAllow)} />;
}; };

View File

@ -2,27 +2,14 @@ import { AppstoreAddOutlined, SettingOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Dropdown, Menu } from 'antd'; import { Dropdown, Menu } from 'antd';
import React, { useState } from 'react'; import React, { useState, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager'; import { PluginManager } from '../plugin-manager';
import { useCompile } from '../schema-component';
import { ActionContext } from '../schema-component'; import { ActionContext } from '../schema-component';
import { useACLRoleContext } from '../acl/ACLProvider';
const schema: ISchema = { import { getPluginsTabs, SettingsCenterContext } from './index';
type: 'object',
properties: {
[uid()]: {
'x-component': 'Action.Drawer',
type: 'void',
title: '{{t("Collections & Fields")}}',
properties: {
configuration: {
'x-component': 'ConfigurationTable',
},
},
},
},
};
export const PluginManagerLink = () => { export const PluginManagerLink = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@ -41,51 +28,41 @@ export const PluginManagerLink = () => {
); );
}; };
const getBookmarkTabs = (data) => {
const bookmarkTabs = [];
data.forEach((plugin) => {
const tabs = plugin.tabs;
tabs.forEach((tab) => {
tab.isBookmark && tab.isAllow && bookmarkTabs.push({ ...tab, path: `${plugin.key}/${tab.key}` });
});
});
return bookmarkTabs;
};
export const SettingsCenterDropdown = () => { export const SettingsCenterDropdown = () => {
const { snippets = [] } = useACLRoleContext();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const compile = useCompile();
const history = useHistory(); const history = useHistory();
const items = [ const itemData = useContext(SettingsCenterContext);
{ const pluginsTabs = getPluginsTabs(itemData, snippets);
title: t('Collections & Fields'), const bookmarkTabs = getBookmarkTabs(pluginsTabs);
path: 'collection-manager/collections',
},
{
title: t('Roles & Permissions'),
path: 'acl/roles',
},
{
title: t('File storages'),
path: 'file-manager/storages',
},
{
title: t('System settings'),
path: 'system-settings/system-settings',
},
{
title: t('workflow:Workflow'),
path: 'workflow/workflows',
},
// {
// title: t('Graph Collections'),
// path: 'graph/collections',
// },
];
return ( return (
<ActionContext.Provider value={{ visible, setVisible }}> <ActionContext.Provider value={{ visible, setVisible }}>
<Dropdown <Dropdown
placement="bottom"
overlay={ overlay={
<Menu> <Menu>
<Menu.ItemGroup title={t('Bookmark')}> <Menu.ItemGroup title={t('Bookmark')}>
{items.map((item) => { {bookmarkTabs.map((tab) => {
return ( return (
<Menu.Item <Menu.Item
onClick={() => { onClick={() => {
history.push('/admin/settings/' + item.path); history.push('/admin/settings/' + tab.path);
}} }}
key={item.path} key={tab.path}
> >
{item.title} {compile(tab.title)}
</Menu.Item> </Menu.Item>
); );
})} })}

View File

@ -1,10 +1,12 @@
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons'; import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Avatar, Card, Layout, Menu, message, PageHeader, Popconfirm, Spin, Switch, Tabs } from 'antd'; import { Avatar, Card, Layout, Menu, message, PageHeader, Popconfirm, Result, Spin, Switch, Tabs } from 'antd';
import { sortBy } from 'lodash';
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Redirect, useHistory, useRouteMatch } from 'react-router-dom'; import { Redirect, useHistory, useRouteMatch } from 'react-router-dom';
import { ACLPane } from '../acl'; import { ACLPane } from '../acl';
import { useACLRoleContext } from '../acl/ACLProvider';
import { useAPIClient, useRequest } from '../api-client'; import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerPane } from '../collection-manager'; import { CollectionManagerPane } from '../collection-manager';
import { useDocumentTitle } from '../document-title'; import { useDocumentTitle } from '../document-title';
@ -149,7 +151,7 @@ const LocalPlugins = () => {
return ( return (
<> <>
{data?.data?.map((item) => { {data?.data?.map((item) => {
return <PluginCard key={item.id} data={item} />; return <PluginCard data={item} />;
})} })}
</> </>
); );
@ -188,8 +190,9 @@ const PluginList = (props) => {
const { tabName = 'local' } = match.params || {}; const { tabName = 'local' } = match.params || {};
const { setTitle } = useDocumentTitle(); const { setTitle } = useDocumentTitle();
const { t } = useTranslation(); const { t } = useTranslation();
const { snippets = [] } = useACLRoleContext();
return ( return snippets.includes('pm') ? (
<div> <div>
<PageHeader <PageHeader
ghost={false} ghost={false}
@ -217,6 +220,8 @@ const PluginList = (props) => {
)} )}
</div> </div>
</div> </div>
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
); );
}; };
@ -226,16 +231,17 @@ const settings = {
icon: 'LockOutlined', icon: 'LockOutlined',
tabs: { tabs: {
roles: { roles: {
isBookmark: true,
title: '{{t("Roles & Permissions")}}', title: '{{t("Roles & Permissions")}}',
component: ACLPane, component: ACLPane,
}, },
}, },
}, },
'block-templates': { 'ui-schema-storage': {
title: '{{t("Block templates")}}', title: '{{t("Block templates")}}',
icon: 'LayoutOutlined', icon: 'LayoutOutlined',
tabs: { tabs: {
list: { 'block-templates': {
title: '{{t("Block templates")}}', title: '{{t("Block templates")}}',
component: BlockTemplatesPane, component: BlockTemplatesPane,
}, },
@ -246,6 +252,7 @@ const settings = {
title: '{{t("Collection manager")}}', title: '{{t("Collection manager")}}',
tabs: { tabs: {
collections: { collections: {
isBookmark: true,
title: '{{t("Collections & Fields")}}', title: '{{t("Collections & Fields")}}',
component: CollectionManagerPane, component: CollectionManagerPane,
}, },
@ -256,6 +263,7 @@ const settings = {
title: '{{t("System settings")}}', title: '{{t("System settings")}}',
tabs: { tabs: {
'system-settings': { 'system-settings': {
isBookmark: true,
title: '{{t("System settings")}}', title: '{{t("System settings")}}',
component: SystemSettingsPane, component: SystemSettingsPane,
}, },
@ -263,18 +271,44 @@ const settings = {
}, },
}; };
export const getPluginsTabs = (items, snippets) => {
const pluginsTabs = Object.keys(items).map((plugin) => {
const tabsObj = items[plugin].tabs;
const tabs = sortBy(
Object.keys(tabsObj).map((tab) => {
return {
key: tab,
...tabsObj[tab],
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
};
}),
(o) => !o.isAllow,
);
return {
...items[plugin],
key: plugin,
tabs,
isAllow: !tabs.every((v) => !v.isAllow),
};
});
return sortBy(pluginsTabs, (o) => !o.isAllow);
};
const SettingsCenter = (props) => { const SettingsCenter = (props) => {
const { snippets = [] } = useACLRoleContext();
const match = useRouteMatch<any>(); const match = useRouteMatch<any>();
const history = useHistory<any>(); const history = useHistory<any>();
const items = useContext(SettingsCenterContext); const items = useContext(SettingsCenterContext);
const pluginsTabs = getPluginsTabs(items, snippets);
const compile = useCompile(); const compile = useCompile();
const firstUri = useMemo(() => { const firstUri = useMemo(() => {
const keys = Object.keys(items).sort(); const pluginName = pluginsTabs[0].key;
const pluginName = keys.shift(); const tabName = pluginsTabs[0].tabs[0].key;
const tabName = Object.keys(items?.[pluginName]?.tabs || {}).shift();
return `/admin/settings/${pluginName}/${tabName}`; return `/admin/settings/${pluginName}/${tabName}`;
}, [items]); }, [pluginsTabs]);
const { pluginName, tabName } = match.params || {}; const { pluginName, tabName } = match.params || {};
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
if (!pluginName) { if (!pluginName) {
return <Redirect to={firstUri} />; return <Redirect to={firstUri} />;
} }
@ -286,78 +320,86 @@ const SettingsCenter = (props) => {
return <Redirect to={`/admin/settings/${pluginName}/${firstTabName}`} />; return <Redirect to={`/admin/settings/${pluginName}/${firstTabName}`} />;
} }
const component = items[pluginName]?.tabs?.[tabName]?.component; const component = items[pluginName]?.tabs?.[tabName]?.component;
const menuItems: any = Object.keys(items) const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
.sort() const menuItems: any = pluginsTabs
.map((key) => { .filter((plugin) => plugin.isAllow)
const item = items[key]; .map((plugin) => {
const tabKey = Object.keys(item.tabs).shift();
return { return {
label: compile(item.title), label: compile(plugin.title),
key: key, key: plugin.key,
icon: item.icon ? <Icon type={item.icon} /> : null, icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
}; };
}); });
return ( return (
<Layout> <div>
<div <Layout>
style={ <div
{ style={
'--side-menu-width': '200px', {
} as Record<string, string> '--side-menu-width': '200px',
} } as Record<string, string>
className={css`
width: var(--side-menu-width);
overflow: hidden;
flex: 0 0 var(--side-menu-width);
max-width: var(--side-menu-width);
min-width: var(--side-menu-width);
pointer-events: none;
`}
></div>
<Layout.Sider
className={css`
height: 100%;
position: fixed;
padding-top: 46px;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
`}
theme={'light'}
>
<Menu
selectedKeys={[pluginName]}
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
onClick={(e) => {
const item = items[e.key];
const tabKey = Object.keys(item.tabs).shift();
history.push(`/admin/settings/${e.key}/${tabKey}`);
}}
items={menuItems as any}
/>
</Layout.Sider>
<Layout.Content>
<PageHeader
ghost={false}
title={compile(items[pluginName]?.title)}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/settings/${pluginName}/${activeKey}`);
}}
>
{Object.keys(items[pluginName]?.tabs).map((tabKey) => {
const tab = items[pluginName].tabs?.[tabKey];
return <Tabs.TabPane tab={compile(tab?.title)} key={tabKey} />;
})}
</Tabs>
} }
/> className={css`
<div style={{ margin: 24 }}>{component && React.createElement(component)}</div> width: var(--side-menu-width);
</Layout.Content> overflow: hidden;
</Layout> flex: 0 0 var(--side-menu-width);
max-width: var(--side-menu-width);
min-width: var(--side-menu-width);
pointer-events: none;
`}
></div>
<Layout.Sider
className={css`
height: 100%;
position: fixed;
padding-top: 46px;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
`}
theme={'light'}
>
<Menu
selectedKeys={[pluginName]}
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
onClick={(e) => {
const item = items[e.key];
const tabKey = Object.keys(item.tabs).shift();
history.push(`/admin/settings/${e.key}/${tabKey}`);
}}
items={menuItems as any}
/>
</Layout.Sider>
<Layout.Content>
{aclPluginTabCheck && (
<PageHeader
ghost={false}
title={compile(items[pluginName]?.title)}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/settings/${pluginName}/${activeKey}`);
}}
>
{plugin.tabs?.map((tab) => {
return tab.isAllow && <Tabs.TabPane tab={compile(tab?.title)} key={tab.key} />;
})}
</Tabs>
}
/>
)}
<div style={{ margin: 24 }}>
{aclPluginTabCheck ? (
component && React.createElement(component)
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
)}
</div>
</Layout.Content>
</Layout>
</div>
); );
}; };

View File

@ -3,7 +3,6 @@ import { Layout, Spin } from 'antd';
import React, { createContext, useContext, useMemo, useRef, useState } from 'react'; import React, { createContext, useContext, useMemo, useRef, useState } from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom'; import { useHistory, useRouteMatch } from 'react-router-dom';
import { import {
ACLAllowConfigure,
ACLRolesCheckProvider, ACLRolesCheckProvider,
CurrentUser, CurrentUser,
CurrentUserProvider, CurrentUserProvider,
@ -25,8 +24,8 @@ import { PoweredBy } from '../../../powered-by';
import { useMutationObserver } from 'ahooks'; import { useMutationObserver } from 'ahooks';
const filterByACL = (schema, options) => { const filterByACL = (schema, options) => {
const { allowAll, allowConfigure, allowMenuItemIds = [] } = options; const { allowAll, allowMenuItemIds = [] } = options;
if (allowAll || allowConfigure) { if (allowAll) {
return schema; return schema;
} }
const filterSchema = (s) => { const filterSchema = (s) => {
@ -123,7 +122,7 @@ const MenuEditor = (props) => {
); );
}; };
const InternalAdminLayout = (props: any) => { export const InternalAdminLayout = (props: any) => {
const sideMenuRef = useRef<HTMLDivElement>(); const sideMenuRef = useRef<HTMLDivElement>();
const [sideMenuWidth, setSideMenuWidth] = useState(0); const [sideMenuWidth, setSideMenuWidth] = useState(0);
@ -192,9 +191,7 @@ const InternalAdminLayout = (props: any) => {
</div> </div>
</div> </div>
<div style={{ position: 'absolute', height: '100%', zIndex: 10, top: 0, right: 0 }}> <div style={{ position: 'absolute', height: '100%', zIndex: 10, top: 0, right: 0 }}>
<ACLAllowConfigure> <RemotePluginManagerToolbar />
<RemotePluginManagerToolbar />
</ACLAllowConfigure>
<CurrentUser /> <CurrentUser />
</div> </div>
</div> </div>

View File

@ -89,28 +89,31 @@ export const Action: ComposedAction = observer((props: any) => {
const form = useForm(); const form = useForm();
const designerProps = fieldSchema['x-designer-props']; const designerProps = fieldSchema['x-designer-props'];
const openMode = fieldSchema?.['x-component-props']?.['openMode']; const openMode = fieldSchema?.['x-component-props']?.['openMode'];
const disabled = form.disabled || field.disabled;
const openSize = fieldSchema?.['x-component-props']?.['openSize']; const openSize = fieldSchema?.['x-component-props']?.['openSize'];
const renderButton = () => ( const renderButton = () => (
<SortableItem <SortableItem
{...others} {...others}
loading={field?.data?.loading} loading={field?.data?.loading}
icon={<Icon type={icon} />} icon={<Icon type={icon} />}
disabled={form.disabled} disabled={disabled}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.preventDefault(); if (!disabled) {
e.stopPropagation(); e.preventDefault();
const onOk = () => { e.stopPropagation();
onClick?.(e); const onOk = () => {
setVisible(true); onClick?.(e);
run(); setVisible(true);
}; run();
if (confirm) { };
Modal.confirm({ if (confirm) {
...confirm, Modal.confirm({
onOk, ...confirm,
}); onOk,
} else { });
onOk(); } else {
onOk();
}
} }
}} }}
component={component || Button} component={component || Button}

View File

@ -6,10 +6,11 @@ import { uid } from '@formily/shared';
import _ from 'lodash'; import _ from 'lodash';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCompile, useDesignable, useFieldComponentOptions } from '../../hooks'; import { ACLCollectionFieldProvider } from '../../../acl/ACLProvider';
import { useFilterByTk, useFormBlockContext } from '../../../block-provider'; import { useFilterByTk, useFormBlockContext } from '../../../block-provider';
import { useCollection, useCollectionManager } from '../../../collection-manager'; import { useCollection, useCollectionManager } from '../../../collection-manager';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useCompile, useDesignable, useFieldComponentOptions } from '../../hooks';
import { BlockItem } from '../block-item'; import { BlockItem } from '../block-item';
import { HTMLEncode } from '../input/shared'; import { HTMLEncode } from '../input/shared';
@ -26,27 +27,29 @@ const divWrap = (schema: ISchema) => {
export const FormItem: any = (props) => { export const FormItem: any = (props) => {
const field = useField(); const field = useField();
return ( return (
<BlockItem className={'nb-form-item'}> <ACLCollectionFieldProvider>
<Item <BlockItem className={'nb-form-item'}>
className={`${css` <Item
& .ant-space { className={`${css`
flex-wrap: wrap; & .ant-space {
flex-wrap: wrap;
}
`}`}
{...props}
extra={
typeof field.description === 'string' ? (
<div
dangerouslySetInnerHTML={{
__html: HTMLEncode(field.description).split('\n').join('<br/>'),
}}
/>
) : (
field.description
)
} }
`}`} />
{...props} </BlockItem>
extra={ </ACLCollectionFieldProvider>
typeof field.description === 'string' ? (
<div
dangerouslySetInnerHTML={{
__html: HTMLEncode(field.description).split('\n').join('<br/>'),
}}
/>
) : (
field.description
)
}
/>
</BlockItem>
); );
}; };

View File

@ -2,7 +2,7 @@ import { MenuOutlined } from '@ant-design/icons';
import { SortableContext, useSortable } from '@dnd-kit/sortable'; import { SortableContext, useSortable } from '@dnd-kit/sortable';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ArrayField, Field } from '@formily/core'; import { ArrayField, Field } from '@formily/core';
import { ISchema, observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react'; import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { reaction } from '@formily/reactive'; import { reaction } from '@formily/reactive';
import { useEventListener, useMemoizedFn } from 'ahooks'; import { useEventListener, useMemoizedFn } from 'ahooks';
import { Table as AntdTable, TableColumnProps } from 'antd'; import { Table as AntdTable, TableColumnProps } from 'antd';
@ -11,21 +11,23 @@ import React, { RefCallback, useCallback, useEffect, useMemo, useRef, useState }
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DndContext, useDesignable } from '../..'; import { DndContext, useDesignable } from '../..';
import { RecordIndexProvider, RecordProvider, useSchemaInitializer } from '../../../'; import { RecordIndexProvider, RecordProvider, useSchemaInitializer } from '../../../';
import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
import { isCollectionFieldComponent, isColumnComponent } from './utils'; import { isCollectionFieldComponent, isColumnComponent } from './utils';
const useTableColumns = () => { const useTableColumns = () => {
const start = Date.now();
const field = useField<ArrayField>(); const field = useField<ArrayField>();
const schema = useFieldSchema(); const schema = useFieldSchema();
const { schemaInWhitelist } = useACLFieldWhitelist();
const { designable } = useDesignable(); const { designable } = useDesignable();
const { exists, render } = useSchemaInitializer(schema['x-initializer']); const { exists, render } = useSchemaInitializer(schema['x-initializer']);
const columns = schema const columns = schema
.reduceProperties((buf, s) => { .reduceProperties((buf, s) => {
if (isColumnComponent(s)) { if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
return buf.concat([s]); return buf.concat([s]);
} }
return buf;
}, []) }, [])
.map((s: Schema) => { ?.map((s: Schema) => {
const collectionFields = s.reduceProperties((buf, s) => { const collectionFields = s.reduceProperties((buf, s) => {
if (isCollectionFieldComponent(s)) { if (isCollectionFieldComponent(s)) {
return buf.concat([s]); return buf.concat([s]);
@ -42,9 +44,9 @@ const useTableColumns = () => {
render: (v, record) => { render: (v, record) => {
const index = field.value?.indexOf(record); const index = field.value?.indexOf(record);
return ( return (
<RecordIndexProvider index={index}> <RecordIndexProvider index={record.__index || index}>
<RecordProvider record={record}> <RecordProvider record={record}>
<RecursionField schema={s} name={index} onlyRenderProperties /> <RecursionField schema={s} name={record.__index || index} onlyRenderProperties />
</RecordProvider> </RecordProvider>
</RecordIndexProvider> </RecordIndexProvider>
); );

View File

@ -35,7 +35,7 @@ const useTableColumns = () => {
return ( return (
<RecordIndexProvider index={index}> <RecordIndexProvider index={index}>
<RecordProvider record={record}> <RecordProvider record={record}>
<RecursionField schema={s} name={index} onlyRenderProperties /> <RecursionField schema={s} name={record.__index || index} onlyRenderProperties />
</RecordProvider> </RecordProvider>
</RecordIndexProvider> </RecordIndexProvider>
); );

View File

@ -108,6 +108,7 @@ export const ReadPrettyFormActionInitializers = {
'x-component': 'Action', 'x-component': 'Action',
'x-designer': 'Action.Designer', 'x-designer': 'Action.Designer',
'x-action': 'customize:update', 'x-action': 'customize:update',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'update', 'x-acl-action': 'update',
'x-action-settings': { 'x-action-settings': {
assignedValues: {}, assignedValues: {},

View File

@ -173,6 +173,7 @@ export const TableActionColumnInitializers = (props: any) => {
title: '{{ t("Update record") }}', title: '{{ t("Update record") }}',
'x-component': 'Action.Link', 'x-component': 'Action.Link',
'x-action': 'customize:update', 'x-action': 'customize:update',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'update', 'x-acl-action': 'update',
'x-designer': 'Action.Designer', 'x-designer': 'Action.Designer',
'x-action-settings': { 'x-action-settings': {

View File

@ -1,4 +1,4 @@
import { ISchema, Schema } from '@formily/react'; import { Schema } from '@formily/react';
// 表格操作配置 // 表格操作配置
export const TableActionInitializers = { export const TableActionInitializers = {

View File

@ -201,11 +201,9 @@ export const useFormItemInitializerFields = (options?: any) => {
?.filter((field) => field?.interface && !field?.isForeignKey) ?.filter((field) => field?.interface && !field?.isForeignKey)
?.map((field) => { ?.map((field) => {
const interfaceConfig = getInterface(field.interface); const interfaceConfig = getInterface(field.interface);
const schema = { const schema = {
type: 'string', type: 'string',
name: field.name, name: field.name,
// title: field?.uiSchema?.title || field.name,
'x-designer': 'FormItem.Designer', 'x-designer': 'FormItem.Designer',
'x-component': field.interface === 'o2m' && !snapshot ? 'TableField' : 'CollectionField', 'x-component': field.interface === 'o2m' && !snapshot ? 'TableField' : 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
@ -241,7 +239,6 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
const form = useForm(); const form = useForm();
const { readPretty = form.readPretty, block = 'Form' } = options || {}; const { readPretty = form.readPretty, block = 'Form' } = options || {};
const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o']; const interfaces = block === 'Form' ? ['m2o'] : ['o2o', 'oho', 'obo', 'm2o'];
const groups = fields const groups = fields
?.filter((field) => { ?.filter((field) => {
return interfaces.includes(field.interface); return interfaces.includes(field.interface);
@ -255,7 +252,6 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
const schema = { const schema = {
type: 'string', type: 'string',
name: `${field.name}.${subField.name}`, name: `${field.name}.${subField.name}`,
// title: subField?.uiSchema?.title || subField.name,
'x-designer': 'FormItem.Designer', 'x-designer': 'FormItem.Designer',
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-read-pretty': readPretty, 'x-read-pretty': readPretty,
@ -265,7 +261,6 @@ export const useAssociatedFormItemInitializerFields = (options?: any) => {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}.${subField.name}`, 'x-collection-field': `${name}.${field.name}.${subField.name}`,
}; };
// interfaceConfig?.schemaInitialize?.(schema, { field, block: 'Form', readPretty: form.readPretty });
return { return {
type: 'item', type: 'item',
title: subField?.uiSchema?.title || subField.name, title: subField?.uiSchema?.title || subField.name,
@ -630,7 +625,7 @@ export const createDetailsBlockSchema = (options) => {
const resourceName = resource || association || collection; const resourceName = resource || association || collection;
const schema: ISchema = { const schema: ISchema = {
type: 'void', type: 'void',
'x-acl-action': `${resourceName}:get`, 'x-acl-action': `${resourceName}:view`,
'x-decorator': 'DetailsBlockProvider', 'x-decorator': 'DetailsBlockProvider',
'x-decorator-props': { 'x-decorator-props': {
resource: resourceName, resource: resourceName,
@ -807,7 +802,7 @@ export const createReadPrettyFormBlockSchema = (options) => {
}, },
}, },
}; };
console.log(JSON.stringify(schema, null, 2)); // console.log(JSON.stringify(schema, null, 2));
return schema; return schema;
}; };
@ -879,7 +874,7 @@ export const createTableBlockSchema = (options) => {
}, },
}, },
}; };
console.log(JSON.stringify(schema, null, 2)); // console.log(JSON.stringify(schema, null, 2));
return schema; return schema;
}; };

View File

@ -15,7 +15,7 @@ import {
Sequelize, Sequelize,
SyncOptions, SyncOptions,
Transactionable, Transactionable,
Utils, Utils
} from 'sequelize'; } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection'; import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -58,7 +58,7 @@ import {
SyncListener, SyncListener,
UpdateListener, UpdateListener,
UpdateWithAssociationsListener, UpdateWithAssociationsListener,
ValidateListener, ValidateListener
} from './types'; } from './types';
export interface MergeOptions extends merge.Options {} export interface MergeOptions extends merge.Options {}
@ -356,7 +356,19 @@ export class Database extends EventEmitter implements AsyncEmitter {
* @param name * @param name
*/ */
getCollection(name: string): Collection { getCollection(name: string): Collection {
return this.collections.get(name); if (!name) {
return null;
}
const [collectionName, associationName] = name.split('.');
let collection = this.collections.get(collectionName);
if (associationName) {
const target = collection.getField(associationName)?.target;
return target ? this.collections.get(target) : null;
}
return collection;
} }
hasCollection(name: string): boolean { hasCollection(name: string): boolean {
@ -389,11 +401,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
getRepository<R extends ArrayFieldRepository>(name: string, relationId: string | number): R; getRepository<R extends ArrayFieldRepository>(name: string, relationId: string | number): R;
getRepository<R extends RelationRepository>(name: string, relationId?: string | number): Repository | R { getRepository<R extends RelationRepository>(name: string, relationId?: string | number): Repository | R {
if (relationId) { const [collection, relation] = name.split('.');
const [collection, relation] = name.split('.'); if (relation) {
return this.getRepository(collection)?.relation(relation)?.of(relationId) as R; return this.getRepository(collection)?.relation(relation)?.of(relationId) as R;
} }
return this.getCollection(name)?.repository; return this.getCollection(name)?.repository;
} }

View File

@ -11,7 +11,7 @@ import {
Op, Op,
Transactionable, Transactionable,
UpdateOptions as SequelizeUpdateOptions, UpdateOptions as SequelizeUpdateOptions,
WhereOperators, WhereOperators
} from 'sequelize'; } from 'sequelize';
import { Collection } from './collection'; import { Collection } from './collection';
import { Database } from './database'; import { Database } from './database';
@ -193,6 +193,9 @@ class RelationRepositoryBuilder<R extends RelationRepository> {
} }
of(id: string | number): R { of(id: string | number): R {
if (!this.association) {
return;
}
const klass = this.builder()[this.association.associationType]; const klass = this.builder()[this.association.associationType];
return new klass(this.collection, this.associationName, id); return new klass(this.collection, this.associationName, id);
} }

View File

@ -3,7 +3,10 @@ import { availableActions } from './available-action';
const configureResources = [ const configureResources = [
'roles', 'roles',
'users',
'collections', 'collections',
'fields',
'collections.fields',
'roles.collections', 'roles.collections',
'roles.resources', 'roles.resources',
'rolesResourcesScopes', 'rolesResourcesScopes',

View File

@ -45,6 +45,7 @@ export interface ApplicationOptions {
export interface DefaultState extends KoaDefaultState { export interface DefaultState extends KoaDefaultState {
currentUser?: any; currentUser?: any;
[key: string]: any; [key: string]: any;
} }
@ -53,6 +54,7 @@ export interface DefaultContext extends KoaDefaultContext {
cache: Cache; cache: Cache;
resourcer: Resourcer; resourcer: Resourcer;
i18n: any; i18n: any;
[key: string]: any; [key: string]: any;
} }

View File

@ -46,6 +46,10 @@ export function dataWrapping() {
ctx.body = { ctx.body = {
data: ctx.body, data: ctx.body,
}; };
if (ctx.bodyMeta) {
ctx.body.meta = ctx.bodyMeta;
}
} }
} else if (ctx.action) { } else if (ctx.action) {
ctx.body = { ctx.body = {

View File

@ -17,6 +17,7 @@ export function db2resource(ctx: ResourcerContext & { db: Database }, next: () =
if (!params) { if (!params) {
return next(); return next();
} }
const resourceName = getNameByParams(params); const resourceName = getNameByParams(params);
// 如果资源名称未被定义 // 如果资源名称未被定义
if (resourcer.isDefined(resourceName)) { if (resourcer.isDefined(resourceName)) {

View File

@ -43,8 +43,11 @@ export class PluginManager {
this.repository.setPluginManager(this); this.repository.setPluginManager(this);
this.app.resourcer.define(resourceOptions); this.app.resourcer.define(resourceOptions);
this.app.acl.allow('pm', ['enable', 'disable', 'remove'], 'allowConfigure'); this.app.acl.registerSnippet({
this.app.acl.allow('applicationPlugins', 'list', 'allowConfigure'); name: 'pm',
actions: ['pm:*', 'applicationPlugins:list'],
});
this.server = net.createServer((socket) => { this.server = net.createServer((socket) => {
socket.on('data', async (data) => { socket.on('data', async (data) => {
const { method, plugins } = JSON.parse(data.toString()); const { method, plugins } = JSON.parse(data.toString());
@ -253,21 +256,6 @@ export class PluginManager {
}, },
}); });
} }
const file = resolve(
process.cwd(),
'packages',
process.env.APP_PACKAGE_ROOT || 'app',
'client/src/plugins',
`${plugin}.ts`,
);
if (!fs.existsSync(file)) {
try {
require.resolve(`${packageName}/client`);
await fs.promises.writeFile(file, `export { default } from '${packageName}/client';`);
const { run } = require('@nocobase/cli/src/util');
await run('yarn', ['nocobase', 'postinstall']);
} catch (error) {}
}
return instance; return instance;
} }

View File

@ -36,6 +36,10 @@ export abstract class Plugin<O = any> implements PluginInterface {
this.afterAdd(); this.afterAdd();
} }
get name() {
return this.options.name as string;
}
get db() { get db() {
return this.app.db; return this.app.db;
} }

View File

@ -42,6 +42,154 @@ describe('acl', () => {
uiSchemaRepository = db.getRepository('uiSchemas'); uiSchemaRepository = db.getRepository('uiSchemas');
}); });
it('should not have permission to list comments', async () => {
await db.getCollection('collections').repository.create({
values: {
name: 'comments',
fields: [
{
name: 'content',
type: 'string',
},
],
},
context: {},
});
await db.getCollection('collections').repository.create({
values: {
name: 'posts',
fields: [
{
name: 'title',
type: 'string',
},
{
name: 'comments',
type: 'hasMany',
target: 'comments',
interface: 'linkTo',
},
],
},
context: {},
});
await db.getRepository('roles').create({
values: {
name: 'test-role',
},
});
await adminAgent.resource('roles.resources', 'test-role').create({
values: {
name: 'posts',
usingActionsConfig: true,
actions: [
{
name: 'view',
fields: ['comments'],
},
],
},
});
const acl = app.acl;
expect(
acl.can({
role: 'test-role',
resource: 'posts.comments',
action: 'list',
}),
).not.toBeNull();
expect(
acl.can({
role: 'test-role',
resource: 'comments',
action: 'list',
}),
).toBeNull();
});
it('should not destroy default roles when user is root user', async () => {
const rootUser = await db.getRepository('users').findOne({
filter: {
email: process.env.INIT_ROOT_EMAIL,
},
});
const userPlugin = app.getPlugin('users') as UsersPlugin;
const adminAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: rootUser.get('id'),
}),
{ type: 'bearer' },
);
expect(await db.getCollection('roles').repository.count()).toBe(3);
//@ts-ignore
await adminAgent.resource('roles').destroy({
filterByTk: 'root',
});
expect(await db.getCollection('roles').repository.count()).toBe(3);
});
it('should not destroy default roles', async () => {
expect(await db.getCollection('roles').repository.count()).toBe(3);
await adminAgent.resource('roles').destroy({
filterByTk: 'root',
});
expect(await db.getCollection('roles').repository.count()).toBe(3);
});
it('should not destroy all scope', async () => {
let allScope = await adminAgent.resource('rolesResourcesScopes').get({
filter: {
key: 'all',
},
});
expect(allScope.body.data).toBeDefined();
await adminAgent.resource('rolesResourcesScopes').destroy({
filter: {
key: 'all',
},
});
allScope = await adminAgent.resource('rolesResourcesScopes').get({
filter: {
key: 'all',
},
});
expect(allScope.body.data).toBeDefined();
});
it('should not destroy roles collections', async () => {
let rolesCollection = await adminAgent.resource('collections').get({
filterByTk: 'roles',
});
expect(rolesCollection.body.data).toBeDefined();
await adminAgent.resource('collections').destroy({
filterByTk: 'roles',
});
rolesCollection = await adminAgent.resource('collections').get({
filterByTk: 'roles',
});
expect(rolesCollection.body.data).toBeDefined();
});
it('should works with universal actions', async () => { it('should works with universal actions', async () => {
await db.getRepository('roles').create({ await db.getRepository('roles').create({
values: { values: {
@ -474,6 +622,7 @@ describe('acl', () => {
strategy: { strategy: {
actions: ['*'], actions: ['*'],
}, },
snippets: ['pm.*'],
}, },
}); });
const UserRepo = db.getCollection('users').repository; const UserRepo = db.getCollection('users').repository;
@ -545,4 +694,44 @@ describe('acl', () => {
action: 'view', action: 'view',
}); });
}); });
it('should destroy new role when user are root user', async () => {
const roles = await db.getRepository('roles').find();
const users = await db.getRepository('users').find();
const rootUser = await db.getRepository('users').findOne({
filterByTk: 1,
});
const userPlugin = app.getPlugin('users') as UsersPlugin;
const rootAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: rootUser.get('id'),
}),
{ type: 'bearer' },
);
const response = await rootAgent
// @ts-ignore
.resource('roles')
.create({
values: {
name: 'testRole',
},
});
expect(response.statusCode).toEqual(200);
expect(await db.getRepository('roles').findOne({ filterByTk: 'testRole' })).toBeDefined();
const destroyResponse = await rootAgent
// @ts-ignore
.resource('roles')
.destroy({
filterByTk: 'testRole',
});
expect(destroyResponse.statusCode).toEqual(200);
expect(await db.getRepository('roles').findOne({ filterByTk: 'testRole' })).toBeNull();
});
}); });

View File

@ -0,0 +1,141 @@
import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare';
describe('destroy action with acl', () => {
let app: MockServer;
let Post;
beforeEach(async () => {
app = await prepareApp();
Post = app.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{
type: 'bigInt',
name: 'createdById',
},
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
it('should throw error when user has no permission to destroy record', async () => {
const userRole = app.acl.define({
role: 'user',
});
// user can destroy post which created by himself
userRole.grantAction('posts:destroy', {
own: true,
});
const p1 = await Post.repository.create({
values: {
title: 'p1',
createById: 2,
},
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
return next();
},
{
before: 'acl',
},
);
const response = await app
.agent()
.resource('posts')
.destroy({
filterByTk: p1.get('id'),
});
// should throw errors
expect(response.statusCode).toEqual(403);
});
it('should throw error when user has no permissions with array query', async () => {
const userRole = app.acl.define({
role: 'user',
});
userRole.grantAction('posts:destroy', {
filter: {
'title.$in': ['p1', 'p2', 'p3'],
},
});
await Post.repository.create({
values: [
{
title: 'p1',
},
{
title: 'p2',
},
{
title: 'p3',
},
{
title: 'p4',
},
{
title: 'p5',
},
{
title: 'p6',
},
],
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
return next();
},
{
before: 'acl',
},
);
const response = await app
.agent()
.resource('posts')
.destroy({
filter: {
'title.$in': ['p4', 'p5', 'p6'],
},
});
// should throw error
expect(response.statusCode).toEqual(403);
const response2 = await app
.agent()
.resource('posts')
.destroy({
filter: {
'title.$in': ['p1'],
},
});
// should throw error
expect(response2.statusCode).toEqual(200);
});
});

View File

@ -4,6 +4,108 @@ import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
describe('association test', () => {
let app: MockServer;
let db: Database;
let acl: ACL;
let user;
let userAgent;
let admin;
let adminAgent;
afterEach(async () => {
await app.destroy();
});
beforeEach(async () => {
app = await prepareApp();
db = app.db;
acl = app.acl;
});
it('should set association actions', async () => {
await db.getRepository('collections').create({
values: {
name: 'posts',
fields: [
{ name: 'title', type: 'string' },
{ name: 'userComments', type: 'hasMany', target: 'comments', interface: 'linkTo' },
],
},
context: {},
});
await db.getRepository('collections').create({
values: {
name: 'comments',
fields: [{ name: 'content', type: 'string' }],
},
context: {},
});
await db.getRepository('roles').create({
values: {
name: 'test-role',
},
context: {},
});
await db.getRepository('roles.resources', 'test-role').create({
values: {
name: 'posts',
usingActionsConfig: true,
actions: [
{
name: 'view',
fields: ['userComments'],
},
],
},
context: {},
});
const role = acl.getRole('test-role');
expect(
acl.can({
role: 'test-role',
action: 'list',
resource: 'posts.userComments',
}),
).not.toBeNull();
const post = await db.getRepository('posts').create({
values: {
title: 'hello world',
userComments: [{ content: 'comment 1' }],
},
});
const UserRepo = db.getCollection('users').repository;
const user = await UserRepo.create({
values: {
roles: ['test-role'],
},
});
const userPlugin = app.getPlugin('users') as UsersPlugin;
const userAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
//@ts-ignore
const response = await userAgent.resource('posts').list({});
expect(response.statusCode).toEqual(200);
const post1 = response.body.data[0];
expect(post1.userComments).not.toBeDefined();
});
});
describe('association field acl', () => { describe('association field acl', () => {
let app: MockServer; let app: MockServer;
let db: Database; let db: Database;
@ -26,22 +128,24 @@ describe('association field acl', () => {
await db.getRepository('roles').create({ await db.getRepository('roles').create({
values: { values: {
name: 'new', name: 'new',
allowConfigure: true,
}, },
}); });
await db.getRepository('roles').create({ await db.getRepository('roles').create({
values: { values: {
name: 'testAdmin', name: 'testAdmin',
allowConfigure: true, snippets: ['pm.*'],
}, },
}); });
const UserRepo = db.getCollection('users').repository; const UserRepo = db.getCollection('users').repository;
user = await UserRepo.create({ user = await UserRepo.create({
values: { values: {
roles: ['new'], roles: ['new'],
}, },
}); });
admin = await UserRepo.create({ admin = await UserRepo.create({
values: { values: {
roles: ['testAdmin'], roles: ['testAdmin'],
@ -55,6 +159,7 @@ describe('association field acl', () => {
}), }),
{ type: 'bearer' }, { type: 'bearer' },
); );
adminAgent = app.agent().auth( adminAgent = app.agent().auth(
userPlugin.jwtService.sign({ userPlugin.jwtService.sign({
userId: admin.get('id'), userId: admin.get('id'),
@ -119,9 +224,22 @@ describe('association field acl', () => {
], ],
}, },
}); });
await adminAgent.resource('roles.resources', 'new').create({
values: {
name: 'orders',
usingActionsConfig: true,
actions: [
{
name: 'view',
},
],
},
});
}); });
it('should revoke target action on association action revoke', async () => { // skip because of disable grant associations target action
it.skip('should revoke target action on association action revoke', async () => {
expect( expect(
acl.can({ acl.can({
role: 'new', role: 'new',
@ -173,6 +291,7 @@ describe('association field acl', () => {
const actionId = viewAction.get('id') as number; const actionId = viewAction.get('id') as number;
const response = await adminAgent.resource('roles.resources', 'new').update({ const response = await adminAgent.resource('roles.resources', 'new').update({
filterByTk: 'users',
values: { values: {
name: 'users', name: 'users',
usingActionsConfig: true, usingActionsConfig: true,
@ -197,6 +316,7 @@ describe('association field acl', () => {
it('should revoke association action on field deleted', async () => { it('should revoke association action on field deleted', async () => {
await adminAgent.resource('roles.resources', 'new').update({ await adminAgent.resource('roles.resources', 'new').update({
filterByTk: 'users',
values: { values: {
name: 'users', name: 'users',
usingActionsConfig: true, usingActionsConfig: true,
@ -208,6 +328,7 @@ describe('association field acl', () => {
], ],
}, },
}); });
expect( expect(
acl.can({ acl.can({
role: 'new', role: 'new',
@ -222,6 +343,7 @@ describe('association field acl', () => {
whitelist: ['age', 'name'], whitelist: ['age', 'name'],
}, },
}); });
const roleResource = await db.getRepository('rolesResources').findOne({ const roleResource = await db.getRepository('rolesResources').findOne({
filter: { filter: {
name: 'users', name: 'users',

View File

@ -23,7 +23,7 @@ describe('configuration', () => {
await db.getRepository('roles').create({ await db.getRepository('roles').create({
values: { values: {
name: 'test1', name: 'test1',
allowConfigure: true, snippets: ['pm.*'],
}, },
}); });
@ -36,23 +36,29 @@ describe('configuration', () => {
const UserRepo = db.getCollection('users').repository; const UserRepo = db.getCollection('users').repository;
admin = await UserRepo.create({ admin = await UserRepo.create({
values: { values: {
roles: ['test1'] roles: ['test1'],
} },
}); });
user = await UserRepo.create({ user = await UserRepo.create({
values: { values: {
roles: ['test2'] roles: ['test2'],
} },
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth(userPlugin.jwtService.sign({ adminAgent = app.agent().auth(
userId: admin.get('id'), userPlugin.jwtService.sign({
}), { type: 'bearer' }); userId: admin.get('id'),
}),
{ type: 'bearer' },
);
userAgent = app.agent().auth(userPlugin.jwtService.sign({ userAgent = app.agent().auth(
userId: user.get('id'), userPlugin.jwtService.sign({
}), { type: 'bearer' }); userId: user.get('id'),
}),
{ type: 'bearer' },
);
guestAgent = app.agent(); guestAgent = app.agent();
}); });

View File

@ -0,0 +1,273 @@
import { Database } from '@nocobase/database';
import { prepareApp } from './prepare';
describe('list action with acl', () => {
let app;
let Post;
beforeEach(async () => {
app = await prepareApp();
Post = app.db.collection({
name: 'posts',
fields: [
{ type: 'string', name: 'title' },
{
type: 'bigInt',
name: 'createdById',
},
],
});
await app.db.sync();
});
afterEach(async () => {
await app.destroy();
});
it('should list with meta permission that has difference primary key', async () => {
const userRole = app.acl.define({
role: 'user',
});
userRole.grantAction('tests:view', {});
userRole.grantAction('tests:update', {
own: true,
});
const Test = app.db.collection({
name: 'tests',
fields: [
{ type: 'string', name: 'name', primaryKey: true },
{
type: 'bigInt',
name: 'createdById',
},
],
autoGenId: false,
filterTargetKey: 'name',
});
await app.db.sync();
await Test.repository.create({
values: [
{ name: 't1', createdById: 1 },
{ name: 't2', createdById: 1 },
{ name: 't3', createdById: 2 },
],
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
return next();
},
{
before: 'acl',
},
);
const response = await app.agent().set('X-With-ACL-Meta', true).resource('tests').list({});
const data = response.body;
expect(data.meta.allowedActions.view).toEqual(['t1', 't2', 't3']);
expect(data.meta.allowedActions.update).toEqual(['t1', 't2']);
expect(data.meta.allowedActions.destroy).toEqual([]);
});
it('should list items with meta permission', async () => {
const userRole = app.acl.define({
role: 'user',
});
userRole.grantAction('posts:view', {});
userRole.grantAction('posts:update', {
own: true,
});
await Post.repository.create({
values: [
{ title: 'p1', createdById: 1 },
{ title: 'p2', createdById: 1 },
{ title: 'p3', createdById: 2 },
],
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
return next();
},
{
before: 'acl',
},
);
const response = await app.agent().set('X-With-ACL-Meta', true).resource('posts').list({});
const data = response.body;
expect(data.meta.allowedActions.view).toEqual([1, 2, 3]);
expect(data.meta.allowedActions.update).toEqual([1, 2]);
expect(data.meta.allowedActions.destroy).toEqual([]);
});
it('should response item permission when request get action', async () => {
const userRole = app.acl.define({
role: 'user',
});
userRole.grantAction('posts:view', {});
userRole.grantAction('posts:update', {
own: true,
});
await Post.repository.create({
values: [
{ title: 'p1', createdById: 1 },
{ title: 'p2', createdById: 1 },
{ title: 'p3', createdById: 2 },
],
});
app.resourcer.use(
(ctx, next) => {
ctx.state.currentRole = 'user';
ctx.state.currentUser = {
id: 1,
};
return next();
},
{
before: 'acl',
},
);
const getResponse = await app.agent().set('X-With-ACL-Meta', true).resource('posts').get({
filterByTk: 1,
});
const getBody = getResponse.body;
expect(getBody.meta.allowedActions).toBeDefined();
});
});
describe('list association action with acl', () => {
let app;
let db: Database;
afterEach(async () => {
await app.destroy();
});
beforeEach(async () => {
app = await prepareApp();
db = app.db;
app.db.collection({
name: 'posts',
fields: [
{
type: 'string',
name: 'title',
},
{
type: 'hasMany',
name: 'comments',
},
],
});
app.db.collection({
name: 'comments',
fields: [
{
type: 'string',
name: 'content',
},
{
type: 'belongsTo',
name: 'post',
},
],
});
await app.db.sync();
});
it('should list allowedActions', async () => {
await db.getRepository('roles').create({
values: {
name: 'newRole',
},
});
const user = await db.getRepository('users').create({
values: {
roles: ['newRole'],
},
});
await db.getRepository('roles.resources', 'newRole').create({
values: {
name: 'posts',
usingActionConfig: true,
actions: [
{
name: 'view',
fields: ['title', 'comments'],
},
{
name: 'create',
fields: ['title', 'comments'],
},
],
},
});
const userPlugin = app.getPlugin('users');
const userAgent = app.agent().set('X-With-ACL-Meta', true).auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
await userAgent.resource('posts').create({
values: {
title: 'post1',
comments: [{ content: 'comment1' }, { content: 'comment2' }],
},
});
const response = await userAgent.resource('posts').list({});
expect(response.statusCode).toEqual(200);
const commentsResponse = await userAgent.resource('posts.comments', 1).list({});
const data = commentsResponse.body;
/**
* allowedActions.view == [1]
* allowedActions.update = []
* allowedActions.destroy = []
*/
expect(data['meta']['allowedActions']).toBeDefined();
expect(data['meta']['allowedActions'].view).toContain(1);
expect(data['meta']['allowedActions'].view).toContain(2);
});
});

View File

@ -1,4 +1,4 @@
import { Database, Model } from '@nocobase/database'; import { ArrayFieldRepository, Database, Model } from '@nocobase/database';
import UsersPlugin from '@nocobase/plugin-users'; import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
@ -96,4 +96,29 @@ describe('role api', () => {
expect(defaultRole.length).toEqual(1); expect(defaultRole.length).toEqual(1);
expect(defaultRole[0].get('name')).toEqual('role2'); expect(defaultRole[0].get('name')).toEqual('role2');
}); });
it('should sync snippet patterns', async () => {
app.acl.registerSnippet({
name: 'collections',
actions: ['collection:*'],
});
await db.getRepository('roles').create({
values: {
name: 'role1',
},
});
await db.getRepository<ArrayFieldRepository>('roles.snippets', 'role1').set({
values: ['collections'],
});
const role1Instance = await db.getRepository('roles').findOne({
filterByTk: 'role1',
});
const role1 = app.acl.getRole('role1');
expect(role1.toJSON()['snippets']).toEqual(['collections']);
});
}); });

View File

@ -0,0 +1,41 @@
import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare';
describe('snippet', () => {
let app: MockServer;
beforeEach(async () => {
app = await prepareApp();
});
afterEach(async () => {
await app.destroy();
});
it('should not allow to create collections when global allow create', async () => {
await app.db.getRepository('roles').create({
values: {
name: 'testRole',
strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] },
snippets: ['!ui.*', '!pm', '!pm.*'],
},
});
const user = await app.db.getRepository('users').create({
values: {
roles: ['testRole'],
},
});
const userPlugin: any = app.getPlugin('users');
const userAgent: any = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
const createCollectionResponse = await userAgent.resource('collections').create({});
expect(createCollectionResponse.statusCode).toEqual(403);
});
});

View File

@ -21,9 +21,9 @@ describe('actions', () => {
pluginUser = app.getPlugin('users'); pluginUser = app.getPlugin('users');
adminUser = await db.getRepository('users').findOne({ adminUser = await db.getRepository('users').findOne({
filter: { filter: {
email: process.env.INIT_ROOT_EMAIL email: process.env.INIT_ROOT_EMAIL,
}, },
appends: ['roles'] appends: ['roles'],
}); });
agent = app.agent(); agent = app.agent();
@ -44,9 +44,98 @@ describe('actions', () => {
filterByTk: adminUser.id, filterByTk: adminUser.id,
values: { values: {
nickname: 'a', nickname: 'a',
roles: adminUser.roles roles: adminUser.roles,
} },
}); });
expect(res2.status).toBe(200); expect(res2.status).toBe(200);
}); });
it('can destroy users role', async () => {
const role2 = await db.getRepository('roles').create({
values: {
name: 'test',
},
});
const users2 = await db.getRepository('users').create({
values: {
email: 'test2@nocobase.com',
name: 'test2',
password: '123456',
roles: [
{
name: 'test',
},
],
},
});
let response = await agent.post('/users:signin').send({
email: 'test2@nocobase.com',
password: '123456',
});
expect(response.statusCode).toEqual(200);
const token = response.body.data.token;
const loggedAgent = app.agent().auth(token, { type: 'bearer' });
const rolesCheckResponse = (await loggedAgent.set('Accept', 'application/json').get('/roles:check')) as any;
expect(rolesCheckResponse.statusCode).toEqual(200);
await db.getRepository('roles').destroy({
filterByTk: 'test',
});
response = await agent.post('/users:signin').send({
email: 'test2@nocobase.com',
password: '123456',
});
expect(response.statusCode).toEqual(200);
const rolesCheckResponse2 = (await loggedAgent.set('Accept', 'application/json').get('/roles:check')) as any;
expect(rolesCheckResponse2.status).toEqual(500);
expect(rolesCheckResponse2.body.errors[0].message).toEqual('Role not found');
});
it('should destroy through table record when destroy role', async () => {
await db.getRepository('roles').create({
values: {
name: 'test',
},
});
const users2 = await db.getRepository('users').create({
values: {
email: 'test2@nocobase.com',
name: 'test2',
password: '123456',
roles: [
{
name: 'test',
},
],
},
});
expect(await users2.countRoles()).toEqual(1);
await db.getRepository('roles').destroy({
filterByTk: 'test',
});
expect(await users2.countRoles()).toEqual(0);
await db.getRepository('roles').create({
values: {
name: 'test',
},
});
expect(await users2.countRoles()).toEqual(0);
});
}); });

View File

@ -1,10 +1,10 @@
const map2obj = (map: Map<string, string>) => { const map2obj = (map: Map<string, string>) => {
const obj = {}; const obj = {};
for(let [key, value] of map){ for (let [key, value] of map) {
obj[key] = value; obj[key] = value;
} }
return obj; return obj;
} };
export async function checkAction(ctx, next) { export async function checkAction(ctx, next) {
const currentRole = ctx.state.currentRole; const currentRole = ctx.state.currentRole;
@ -23,9 +23,11 @@ export async function checkAction(ctx, next) {
}); });
const role = ctx.app.acl.getRole(currentRole); const role = ctx.app.acl.getRole(currentRole);
const availableActions = ctx.app.acl.getAvailableActions();
ctx.body = { ctx.body = {
...role.toJSON(), ...role.toJSON(),
availableActions: [...availableActions.keys()],
resources: [...role.resources.keys()], resources: [...role.resources.keys()],
actionAlias: map2obj(ctx.app.acl.actionAlias), actionAlias: map2obj(ctx.app.acl.actionAlias),
allowAll: currentRole === 'root', allowAll: currentRole === 'root',

View File

@ -15,6 +15,7 @@ const roleCollectionsResource = {
const db: Database = ctx.db; const db: Database = ctx.db;
const collectionRepository = db.getRepository('collections'); const collectionRepository = db.getRepository('collections');
const fieldRepository = db.getRepository('fields');
// all collections // all collections
const [collections, count] = await collectionRepository.findAndCount({ const [collections, count] = await collectionRepository.findAndCount({
@ -35,23 +36,52 @@ const roleCollectionsResource = {
.filter((roleResources) => roleResources.get('usingActionsConfig')) .filter((roleResources) => roleResources.get('usingActionsConfig'))
.map((roleResources) => roleResources.get('name')); .map((roleResources) => roleResources.get('name'));
const items = collections.map((collection, i) => {
const exists = roleResourcesNames.includes(collection.get('name'));
const usingConfig: UsingConfigType = roleResourceActionResourceNames.includes(collection.get('name'))
? 'resourceAction'
: 'strategy';
const c = db.getCollection(collection.get('name'));
// const children = [...c.fields.values()]
// .filter(
// (f) => f.options.interface && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f.options.type),
// )
// .map((f, j) => {
// const name = `${collection.get('name')}.${f.options.name}`;
// const usingConfig: UsingConfigType = roleResourceActionResourceNames.includes(name)
// ? 'resourceAction'
// : 'strategy';
// const exists = roleResourcesNames.includes(name);
// return {
// type: 'association',
// __index: `${i}.children.${j}`,
// name,
// collectionName: f.options.target,
// title: f.options?.uiSchema?.title,
// roleName: role,
// usingConfig,
// exists,
// };
// });
return {
type: 'collection',
name: collection.get('name') as string,
collectionName: collection.get('name'),
title: collection.get('title') as string,
roleName: role,
usingConfig,
exists,
// children: children.length > 0 ? children : null,
};
});
ctx.body = { ctx.body = {
count, count,
rows: collections.map((collection) => { rows: items,
const exists = roleResourcesNames.includes(collection.get('name'));
const usingConfig: UsingConfigType = roleResourceActionResourceNames.includes(collection.get('name'))
? 'resourceAction'
: 'strategy';
return {
name: collection.get('name') as string,
title: collection.get('title') as string,
roleName: role,
usingConfig,
exists,
};
}),
page: Number(page), page: Number(page),
pageSize: Number(pageSize), pageSize: Number(pageSize),
totalPage: totalPage(count, pageSize), totalPage: totalPage(count, pageSize),

View File

@ -75,5 +75,10 @@ export default {
sourceKey: 'name', sourceKey: 'name',
targetKey: 'name', targetKey: 'name',
}, },
{
type: 'set',
name: 'snippets',
defaultValue: ['!ui.*', '!pm', '!pm.*'],
},
], ],
} as CollectionOptions; } as CollectionOptions;

View File

@ -23,6 +23,7 @@ export default {
type: 'belongsTo', type: 'belongsTo',
name: 'scope', name: 'scope',
target: 'rolesResourcesScopes', target: 'rolesResourcesScopes',
onDelete: 'RESTRICT',
}, },
], ],
} as CollectionOptions; } as CollectionOptions;

View File

@ -10,6 +10,7 @@ export default extend({
target: 'roles', target: 'roles',
foreignKey: 'userId', foreignKey: 'userId',
otherKey: 'roleName', otherKey: 'roleName',
onDelete: 'CASCADE',
sourceKey: 'id', sourceKey: 'id',
targetKey: 'name', targetKey: 'name',
through: 'rolesUsers', through: 'rolesUsers',
@ -25,6 +26,6 @@ export default extend({
}, },
}, },
}, },
} },
], ],
}); });

View File

@ -0,0 +1,17 @@
import { Migration } from '@nocobase/server';
export default class extends Migration {
async up() {
await this.app.db.getRepository('roles').update({
filter: {
$or: [{ allowConfigure: true }, { name: 'root' }],
},
values: {
snippets: ['ui.*', 'pm', 'pm.*'],
allowConfigure: false,
},
});
}
async down() {}
}

View File

@ -17,5 +17,7 @@ export class RoleModel extends Model {
...((this.get('strategy') as object) || {}), ...((this.get('strategy') as object) || {}),
allowConfigure: this.get('allowConfigure') as boolean, allowConfigure: this.get('allowConfigure') as boolean,
}); });
role.snippets = new Set(this.get('snippets'));
} }
} }

View File

@ -50,7 +50,7 @@ export class RoleResourceActionModel extends Model {
continue; continue;
} }
const fieldType = collectionField.get('interface') as string; const fieldType = collectionField.get('type') as string;
const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction]; const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction];
@ -59,8 +59,9 @@ export class RoleResourceActionModel extends Model {
if (fieldActions) { if (fieldActions) {
// grant association actions to role // grant association actions to role
const associationActions = fieldActions.associationActions || []; const associationActions = fieldActions.associationActions || [];
associationActions.forEach((associationAction) => { associationActions.forEach((associationAction) => {
const actionName = `${resourceName}.${fieldTarget}:${associationAction}`; const actionName = `${resourceName}.${collectionField.get('name')}:${associationAction}`;
role.grantAction(actionName); role.grantAction(actionName);
}); });
@ -69,6 +70,12 @@ export class RoleResourceActionModel extends Model {
targetActions.forEach((targetAction) => { targetActions.forEach((targetAction) => {
const targetActionPath = `${fieldTarget}:${targetAction}`; const targetActionPath = `${fieldTarget}:${targetAction}`;
const existsAction = role.getActionParams(targetActionPath);
if (existsAction) {
return;
}
// set resource target action with current resourceName // set resource target action with current resourceName
grantHelper.resourceTargetActionMap.set(`${role.name}.${resourceName}`, [ grantHelper.resourceTargetActionMap.set(`${role.name}.${resourceName}`, [
...(grantHelper.resourceTargetActionMap.get(resourceName) || []), ...(grantHelper.resourceTargetActionMap.get(resourceName) || []),

View File

@ -1,6 +1,8 @@
import { Context } from '@nocobase/actions'; import { NoPermissionError } from '@nocobase/acl';
import { Collection } from '@nocobase/database'; import { Context, utils as actionUtils } from '@nocobase/actions';
import { Collection, RelationField } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import lodash from 'lodash';
import { resolve } from 'path'; import { resolve } from 'path';
import { availableActionResource } from './actions/available-actions'; import { availableActionResource } from './actions/available-actions';
import { checkAction } from './actions/role-check'; import { checkAction } from './actions/role-check';
@ -49,41 +51,52 @@ export class PluginACL extends Plugin {
registerAssociationFieldsActions() { registerAssociationFieldsActions() {
// if grant create action to role, it should // if grant create action to role, it should
// also grant add action and association target's view action // also grant add action and association target's view action
this.registerAssociationFieldAction('linkTo', {
this.registerAssociationFieldAction('hasOne', {
view: { view: {
associationActions: ['list', 'get'], associationActions: ['list', 'get', 'view'],
}, },
create: { create: {
associationActions: ['add'], associationActions: ['create', 'set'],
targetActions: ['view'],
}, },
update: { update: {
associationActions: ['add', 'remove', 'toggle'], associationActions: ['update', 'remove', 'set'],
targetActions: ['view'],
}, },
}); });
this.registerAssociationFieldAction('attachments', { this.registerAssociationFieldAction('hasMany', {
view: { view: {
associationActions: ['list', 'get'], associationActions: ['list', 'get', 'view'],
}, },
add: { create: {
associationActions: ['upload', 'add'], associationActions: ['create', 'set', 'add'],
}, },
update: { update: {
associationActions: ['update', 'add', 'remove', 'toggle'], associationActions: ['update', 'remove', 'set'],
}, },
}); });
this.registerAssociationFieldAction('subTable', { this.registerAssociationFieldAction('belongsTo', {
view: { view: {
associationActions: ['list', 'get'], associationActions: ['list', 'get', 'view'],
}, },
create: { create: {
associationActions: ['create'], associationActions: ['create', 'set'],
}, },
update: { update: {
associationActions: ['update', 'destroy'], associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('belongsToMany', {
view: {
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create', 'set', 'add'],
},
update: {
associationActions: ['update', 'remove', 'set', 'toggle'],
}, },
}); });
} }
@ -123,12 +136,62 @@ export class PluginACL extends Plugin {
} }
async beforeLoad() { async beforeLoad() {
this.db.addMigrations({
namespace: this.name,
directory: resolve(__dirname, './migrations'),
context: {
plugin: this,
},
});
this.app.db.registerModels({ this.app.db.registerModels({
RoleResourceActionModel, RoleResourceActionModel,
RoleResourceModel, RoleResourceModel,
RoleModel, RoleModel,
}); });
this.app.acl.registerSnippet({
name: `pm.${this.name}.roles`,
actions: [
'roles:*',
'roles.snippets:*',
'availableActions:list',
'roles.collections:list',
'roles.resources:*',
'uiSchemas:getProperties',
'roles.menuUiSchemas:*',
],
});
// change resource fields to association fields
this.app.acl.beforeGrantAction((ctx) => {
const actionName = this.app.acl.resolveActionAlias(ctx.actionName);
const collection = this.app.db.getCollection(ctx.resourceName);
if (!collection) {
return;
}
const fieldsParams = ctx.params.fields;
if (!fieldsParams) {
return;
}
if (actionName == 'view' || actionName == 'export') {
const associationsFields = fieldsParams.filter((fieldName) => {
const field = collection.getField(fieldName);
return field instanceof RelationField;
});
ctx.params = {
...ctx.params,
fields: lodash.difference(fieldsParams, associationsFields),
appends: associationsFields,
};
}
});
this.registerAssociationFieldsActions(); this.registerAssociationFieldsActions();
this.app.resourcer.define(availableActionResource); this.app.resourcer.define(availableActionResource);
@ -147,6 +210,7 @@ export class PluginACL extends Plugin {
}, },
transaction, transaction,
}); });
if (defaultRole && (await model.countRoles({ transaction })) == 0) { if (defaultRole && (await model.countRoles({ transaction })) == 0) {
await model.addRoles(defaultRole, { transaction }); await model.addRoles(defaultRole, { transaction });
} }
@ -269,7 +333,7 @@ export class PluginACL extends Plugin {
// sync database role data to acl // sync database role data to acl
this.app.on('afterLoad', async (app, options) => { this.app.on('afterLoad', async (app, options) => {
if (options?.method === 'install') { if (options?.method === 'install' || options?.method === 'upgrade') {
return; return;
} }
const exists = await this.app.db.collectionExistsInDb('roles'); const exists = await this.app.db.collectionExistsInDb('roles');
@ -320,6 +384,7 @@ export class PluginACL extends Plugin {
name: 'root', name: 'root',
title: '{{t("Root")}}', title: '{{t("Root")}}',
hidden: true, hidden: true,
snippets: ['ui.*', 'pm', 'pm.*'],
}, },
{ {
name: 'admin', name: 'admin',
@ -327,6 +392,7 @@ export class PluginACL extends Plugin {
allowConfigure: true, allowConfigure: true,
allowNewMenu: true, allowNewMenu: true,
strategy: { actions: ['create', 'view', 'update', 'destroy'] }, strategy: { actions: ['create', 'view', 'update', 'destroy'] },
snippets: ['ui.*', 'pm', 'pm.*'],
}, },
{ {
name: 'member', name: 'member',
@ -334,6 +400,7 @@ export class PluginACL extends Plugin {
allowNewMenu: true, allowNewMenu: true,
strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] }, strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] },
default: true, default: true,
snippets: ['!ui.*', '!pm', '!pm.*'],
}, },
], ],
}); });
@ -359,16 +426,44 @@ export class PluginACL extends Plugin {
this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'parseToken' }); this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'parseToken' });
this.app.acl.allow('users', 'setDefaultRole', 'loggedIn'); this.app.acl.allow('users', 'setDefaultRole', 'loggedIn');
this.app.acl.allow('roles', 'check', 'loggedIn'); this.app.acl.allow('roles', 'check', 'loggedIn');
this.app.acl.allow('roles', ['create', 'update', 'destroy'], 'allowConfigure');
this.app.acl.allow('roles.menuUiSchemas', ['set', 'toggle', 'list'], 'allowConfigure');
this.app.acl.allow('*', '*', (ctx) => { this.app.acl.allow('*', '*', (ctx) => {
return ctx.state.currentRole === 'root'; return ctx.state.currentRole === 'root';
}); });
this.app.acl.addFixedParams('collections', 'destroy', () => {
return {
filter: {
$and: [{ 'name.$ne': 'roles' }, { 'name.$ne': 'rolesUsers' }],
},
};
});
this.app.acl.addFixedParams('rolesResourcesScopes', 'destroy', () => {
return {
filter: {
$and: [{ 'key.$ne': 'all' }, { 'key.$ne': 'own' }],
},
};
});
this.app.acl.addFixedParams('rolesResourcesScopes', 'update', () => {
return {
filter: {
$and: [{ 'key.$ne': 'all' }, { 'key.$ne': 'own' }],
},
};
});
this.app.acl.addFixedParams('roles', 'destroy', () => {
return {
filter: {
$and: [{ 'name.$ne': 'root' }, { 'name.$ne': 'admin' }, { 'name.$ne': 'member' }],
},
};
});
this.app.resourcer.use(async (ctx, next) => { this.app.resourcer.use(async (ctx, next) => {
const { actionName, resourceName, params } = ctx.action; const { actionName, resourceName, params } = ctx.action;
const { showAnonymous } = params || {}; const { showAnonymous } = params || {};
@ -381,11 +476,13 @@ export class PluginACL extends Plugin {
}); });
} }
} }
if (actionName === 'update' && resourceName === 'roles.resources') { if (actionName === 'update' && resourceName === 'roles.resources') {
ctx.action.mergeParams({ ctx.action.mergeParams({
updateAssociationValues: ['actions'], updateAssociationValues: ['actions'],
}); });
} }
await next(); await next();
}); });
@ -405,6 +502,7 @@ export class PluginACL extends Plugin {
} else { } else {
collection = ctx.db.getCollection(resourceName); collection = ctx.db.getCollection(resourceName);
} }
if (collection && collection.hasField('createdById')) { if (collection && collection.hasField('createdById')) {
ctx.permission.can.params.fields.push('createdById'); ctx.permission.can.params.fields.push('createdById');
} }
@ -413,37 +511,252 @@ export class PluginACL extends Plugin {
}); });
const parseJsonTemplate = this.app.acl.parseJsonTemplate; const parseJsonTemplate = this.app.acl.parseJsonTemplate;
this.app.acl.use(async (ctx: Context, next) => {
const { actionName, resourceName, resourceOf } = ctx.action; this.app.acl.use(
if (resourceName.includes('.') && resourceOf) { async (ctx: Context, next) => {
if (!ctx?.permission?.can?.params) { const { actionName, resourceName, resourceOf } = ctx.action;
return next(); // is association request
} if (resourceName.includes('.') && resourceOf) {
// 关联数据去掉 filter if (!ctx?.permission?.can?.params) {
delete ctx.permission.can.params.filter; return next();
// 关联数据能不能处理取决于 source 是否有权限 }
const [collectionName] = resourceName.split('.'); // 关联数据去掉 filter
const action = ctx.can({ resource: collectionName, action: actionName }); delete ctx.permission.can.params.filter;
const availableAction = this.app.acl.getAvailableAction(actionName); // 关联数据能不能处理取决于 source 是否有权限
if (availableAction?.options?.onNewRecord) { const [collectionName] = resourceName.split('.');
if (action) { const action = ctx.can({ resource: collectionName, action: actionName });
ctx.permission.skip = true;
const availableAction = this.app.acl.getAvailableAction(actionName);
if (availableAction?.options?.onNewRecord) {
if (action) {
ctx.permission.skip = true;
} else {
ctx.permission.can = false;
}
} else { } else {
ctx.permission.can = false; const filter = parseJsonTemplate(action?.params?.filter || {}, ctx);
} const sourceInstance = await ctx.db.getRepository(collectionName).findOne({
} else { filterByTk: resourceOf,
const filter = parseJsonTemplate(action?.params?.filter || {}, ctx); filter,
const sourceInstance = await ctx.db.getRepository(collectionName).findOne({ });
filterByTk: resourceOf, if (!sourceInstance) {
filter, ctx.permission.can = false;
}); }
if (!sourceInstance) {
ctx.permission.can = false;
} }
} }
} await next();
},
{
before: 'core',
},
);
// throw error when user has no fixed params permissions
this.app.acl.use(
async (ctx: any, next) => {
const action = ctx.permission?.can?.action;
if (action == 'destroy' && !ctx.action.resourceName.includes('.')) {
const repository = actionUtils.getRepositoryFromParams(ctx);
// params after merge with fixed params
const filteredCount = await repository.count(ctx.permission.mergedParams);
// params user requested
const queryCount = await repository.count(ctx.permission.rawParams);
if (queryCount > filteredCount) {
ctx.throw(403, 'No permissions');
return;
}
}
await next();
},
{
after: 'core',
group: 'after',
},
);
const withACLMeta = async (ctx: any, next) => {
await next(); await next();
});
if (!ctx.action) {
return;
}
const { resourceName, actionName } = ctx.action;
if (!ctx.get('X-With-ACL-Meta')) {
return;
}
const collection = ctx.db.getCollection(resourceName);
if (!collection) {
return;
}
if (ctx.status !== 200) {
return;
}
if (!['list', 'get'].includes(actionName)) {
return;
}
const Model = collection.model;
const primaryKeyField = Model.primaryKeyField || Model.primaryKeyAttribute;
const dataPath = ctx.body?.rows ? 'body.rows' : 'body';
let listData = lodash.get(ctx, dataPath);
if (actionName == 'get') {
listData = lodash.castArray(listData);
}
const actions = ['view', 'update', 'destroy'];
const actionsParams = [];
for (const action of actions) {
const actionCtx: any = {
db: ctx.db,
action: {
actionName: action,
name: action,
params: {},
resourceName: ctx.action.resourceName,
resourceOf: ctx.action.resourceOf,
mergeParams() {},
},
state: {
currentRole: ctx.state.currentRole,
currentUser: (() => {
if (!ctx.state.currentUser) {
return null;
}
if (ctx.state.currentUser.toJSON) {
return ctx.state.currentUser?.toJSON();
}
return ctx.state.currentUser;
})(),
},
permission: {},
throw(...args) {
throw new NoPermissionError(...args);
},
};
try {
await this.app.acl.getActionParams(actionCtx);
} catch (e) {
if (e instanceof NoPermissionError) {
continue;
}
throw e;
}
actionsParams.push([
action,
actionCtx.permission?.can === null && !actionCtx.permission.skip
? null
: actionCtx.permission?.parsedParams || {},
]);
}
const ids = listData.map((item) => item[primaryKeyField]);
const conditions = [];
const allAllowed = [];
for (const [action, params] of actionsParams) {
if (!params) {
continue;
}
if (lodash.isEmpty(params) || lodash.isEmpty(params.filter)) {
allAllowed.push(action);
continue;
}
const queryParams = collection.repository.buildQueryOptions(params);
const actionSql = ctx.db.sequelize.queryInterface.queryGenerator.selectQuery(
Model.getTableName(),
{
// ...queryParams,
where: queryParams.where,
attributes: [primaryKeyField],
includeIgnoreAttributes: false,
// include: queryParams.include,
},
Model,
);
const whereCase = actionSql.match(/WHERE (.*?);/)[1];
conditions.push({
whereCase,
action,
include: queryParams.include,
});
}
const results = await collection.model.findAll({
where: {
[primaryKeyField]: ids,
},
attributes: [
primaryKeyField,
...conditions.map((condition) => {
return [ctx.db.sequelize.literal(`CASE WHEN ${condition.whereCase} THEN 1 ELSE 0 END`), condition.action];
}),
],
include: conditions.map((condition) => condition.include).flat(),
});
const allowedActions = actions
.map((action) => {
if (allAllowed.includes(action)) {
return [action, ids];
}
return [action, results.filter((item) => Boolean(item.get(action))).map((item) => item.get(primaryKeyField))];
})
.reduce((acc, [action, ids]) => {
acc[action] = ids;
return acc;
}, {});
if (actionName == 'get') {
ctx.bodyMeta = {
...(ctx.bodyMeta || {}),
allowedActions: allowedActions,
};
}
if (actionName == 'list') {
ctx.body.allowedActions = allowedActions;
}
};
// append allowedActions to list & get response
this.app.use(
async (ctx, next) => {
try {
await withACLMeta(ctx, next);
} catch (error) {
ctx.logger.error(error);
}
},
{ after: 'restApi', group: 'after' },
);
} }
async install() { async install() {

View File

@ -0,0 +1,35 @@
import { Database } from '@nocobase/database';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '../index';
describe('actions test', () => {
let app: MockServer;
let db: Database;
beforeEach(async () => {
app = mockServer({
registerActions: true,
});
await app.cleanDb();
app.plugin(Plugin);
await app.load();
await app.db.sync();
db = app.db;
});
afterEach(async () => {
await app.destroy();
});
it('should only call list action on chinaRegions resource', async () => {
const listResponse = await app.agent().resource('chinaRegions').list();
expect(listResponse.statusCode).toEqual(200);
const createResponse = await app.agent().resource('chinaRegions').create();
expect(createResponse.statusCode).toEqual(404);
});
});

View File

@ -11,7 +11,18 @@ export class PluginChinaRegion extends Plugin {
await this.db.import({ await this.db.import({
directory: resolve(__dirname, 'collections'), directory: resolve(__dirname, 'collections'),
}); });
this.app.acl.allow('chinaRegions', 'list', 'loggedIn'); this.app.acl.allow('chinaRegions', 'list', 'loggedIn');
this.app.resourcer.use(async (ctx, next) => {
const { resourceName, actionName } = ctx.action.params;
if (resourceName == 'chinaRegions' && actionName !== 'list') {
ctx.throw(404, 'Not Found');
} else {
await next();
}
});
} }
async importData() { async importData() {
@ -63,7 +74,6 @@ export class PluginChinaRegion extends Plugin {
const count = await ChinaRegion.count(); const count = await ChinaRegion.count();
// console.log(`${count} rows of region data imported in ${(Date.now() - timer) / 1000}s`); // console.log(`${count} rows of region data imported in ${(Date.now() - timer) / 1000}s`);
} }
} }
export default PluginChinaRegion; export default PluginChinaRegion;

View File

@ -38,6 +38,15 @@ export class CollectionManagerPlugin extends Plugin {
CollectionRepository, CollectionRepository,
}); });
this.app.acl.registerSnippet({
name: `pm.${this.name}.collections`,
actions: [
'collections:*',
// 'fields:*',
'collections.fields:*',
],
});
this.app.db.on('fields.beforeUpdate', async (model, options) => { this.app.db.on('fields.beforeUpdate', async (model, options) => {
const newValue = options.values; const newValue = options.values;
if ( if (
@ -213,7 +222,6 @@ export class CollectionManagerPlugin extends Plugin {
}); });
this.app.acl.allow('collections', 'list', 'loggedIn'); this.app.acl.allow('collections', 'list', 'loggedIn');
this.app.acl.allow('collections', ['create', 'update', 'destroy'], 'allowConfigure');
} }
async load() { async load() {

View File

@ -26,6 +26,13 @@ export default class PluginFileManager extends Plugin {
async load() { async load() {
await this.importCollections(resolve(__dirname, './collections')); await this.importCollections(resolve(__dirname, './collections'));
this.app.acl.registerSnippet({
name: `pm.${this.name}.storages`,
actions: ['storages:*'],
});
this.app.acl.allow('attachments', 'upload', 'loggedIn');
// 暂时中间件只能通过 use 加进来 // 暂时中间件只能通过 use 加进来
this.app.resourcer.use(uploadMiddleware); this.app.resourcer.use(uploadMiddleware);
this.app.resourcer.registerActionHandler('upload', uploadAction); this.app.resourcer.registerActionHandler('upload', uploadAction);
@ -34,6 +41,24 @@ export default class PluginFileManager extends Plugin {
await getStorageConfig(STORAGE_TYPE_LOCAL).middleware(this.app); await getStorageConfig(STORAGE_TYPE_LOCAL).middleware(this.app);
} }
this.app.acl.allow('attachments', 'upload', 'loggedIn'); const defaultStorageName = getStorageConfig(this.storageType()).defaults().name;
this.app.acl.addFixedParams('storages', 'destroy', () => {
return {
filter: { 'name.$ne': defaultStorageName },
};
});
const ownMerger = () => {
return {
filter: {
createdById: '{{ctx.state.currentUser.id}}',
},
};
};
this.app.acl.addFixedParams('attachments', 'update', ownMerger);
this.app.acl.addFixedParams('attachments', 'create', ownMerger);
this.app.acl.addFixedParams('attachments', 'destroy', ownMerger);
} }
} }

View File

@ -11,11 +11,17 @@ export class IframeBlockPlugin extends Plugin {
await this.db.import({ await this.db.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),
}); });
this.app.acl.allow('iframeHtml', ['get', 'create', 'update', 'destroy'], 'allowConfigure');
this.app.acl.allow('iframeHtml', 'getHtml', 'loggedIn');
this.app.actions({ this.app.actions({
'iframeHtml:getHtml': getHtml, 'iframeHtml:getHtml': getHtml,
}); });
this.app.acl.allow('iframeHtml', 'getHtml', 'loggedIn');
this.app.acl.registerSnippet({
name: 'ui.iframeHtml',
actions: [
'iframeHtml:*',
],
});
} }
async install(options?: InstallOptions) {} async install(options?: InstallOptions) {}

View File

@ -42,14 +42,14 @@ const initImportSettings = (fields) => {
export const ImportActionInitializer = (props) => { export const ImportActionInitializer = (props) => {
const { item, insert } = props; const { item, insert } = props;
const { exists, remove } = useCurrentSchema('import', 'x-action', item.find, item.remove); const { exists, remove } = useCurrentSchema('importXlsx', 'x-action', item.find, item.remove);
const { name } = useCollection(); const { name } = useCollection();
const fields = useFields(name); const fields = useFields(name);
const schema: ISchema = { const schema: ISchema = {
type: 'void', type: 'void',
title: '{{ t("Import") }}', title: '{{ t("Import") }}',
'x-action': 'import', 'x-action': 'importXlsx',
'x-action-settings': { 'x-action-settings': {
importSettings: { importColumns: [], explain: '' }, importSettings: { importColumns: [], explain: '' },
}, },

View File

@ -18,7 +18,7 @@ import { ImportStatus } from './ImportModal';
const useImportSchema = (s: Schema) => { const useImportSchema = (s: Schema) => {
let schema = s; let schema = s;
while (schema && schema['x-action'] !== 'import') { while (schema && schema['x-action'] !== 'importXlsx') {
schema = schema.parent; schema = schema.parent;
} }
return { schema }; return { schema };

View File

@ -11,31 +11,18 @@ export class ImportPlugin extends Plugin {
} }
async load() { async load() {
// Visit: http://localhost:13000/api/import:importXlsx
this.app.resourcer.use(importMiddleware); this.app.resourcer.use(importMiddleware);
this.app.resourcer.registerActionHandler('downloadXlsxTemplate', downloadXlsxTemplate); this.app.resourcer.registerActionHandler('downloadXlsxTemplate', downloadXlsxTemplate);
this.app.resourcer.registerActionHandler('importXlsx', importXlsx); this.app.resourcer.registerActionHandler('importXlsx', importXlsx);
// this.app.resource({
// name: 'import',
// actions: {
// importXlsx,
// },
// });
this.app.acl.setAvailableAction('importXlsx', { this.app.acl.setAvailableAction('importXlsx', {
displayName: '{{t("Import")}}', displayName: '{{t("Import")}}',
allowConfigureFields: true, allowConfigureFields: true,
type: 'new-data', type: 'new-data',
onNewRecord: true, onNewRecord: true,
}); });
this.app.acl.use(async (ctx, next) => {
const { actionName } = ctx.action; this.app.acl.allow('*', 'downloadXlsxTemplate', 'loggedIn');
if (['downloadXlsxTemplate', 'importXlsx'].includes(actionName)) {
ctx.permission = {
skip: true,
};
}
await next();
});
} }
async install(options: InstallOptions) { async install(options: InstallOptions) {

View File

@ -1,46 +1,49 @@
import { InstallOptions, Plugin } from '@nocobase/server'; import { InstallOptions, Plugin } from '@nocobase/server';
import { CircleField, LineStringField, PointField, PolygonField } from './fields';
import { resolve } from 'path'; import { resolve } from 'path';
import { getConfiguration, setConfiguration } from './actions'; import { getConfiguration, setConfiguration } from './actions';
import { CircleField, LineStringField, PointField, PolygonField } from './fields';
export class MapPlugin extends Plugin { export class MapPlugin extends Plugin {
afterAdd() { } afterAdd() {}
beforeLoad() { beforeLoad() {
const fields = { const fields = {
point: PointField, point: PointField,
polygon: PolygonField, polygon: PolygonField,
lineString: LineStringField, lineString: LineStringField,
circle: CircleField circle: CircleField,
}; };
this.db.registerFieldTypes(fields); this.db.registerFieldTypes(fields);
} }
async load() { async load() {
await this.db.import({ await this.db.import({
directory: resolve(__dirname, 'collections'), directory: resolve(__dirname, 'collections'),
}); });
this.app.resource(({ this.app.resource({
name: 'map-configuration', name: 'map-configuration',
actions: { actions: {
get: getConfiguration, get: getConfiguration,
set: setConfiguration set: setConfiguration,
}, },
only: ['get', 'set'] only: ['get', 'set'],
})) });
this.app.acl.registerSnippet({
name: `pm.${this.name}.configuration`,
actions: ['map-configuration:*'],
});
} }
async install(options?: InstallOptions) { } async install(options?: InstallOptions) {}
async afterEnable() { } async afterEnable() {}
async afterDisable() { } async afterDisable() {}
async remove() { } async remove() {}
} }
export default MapPlugin; export default MapPlugin;

View File

@ -3,7 +3,6 @@ import { resolve } from 'path';
import { ApplicationModel } from './models/application'; import { ApplicationModel } from './models/application';
export class PluginMultiAppManager extends Plugin { export class PluginMultiAppManager extends Plugin {
async install(options?: InstallOptions) { async install(options?: InstallOptions) {
const repo = this.db.getRepository<any>('collections'); const repo = this.db.getRepository<any>('collections');
if (repo) { if (repo) {
@ -46,5 +45,12 @@ export class PluginMultiAppManager extends Plugin {
} }
}, },
); );
this.app.acl.registerSnippet({
name: `pm.${this.name}.applications`,
actions: [
'applications:*',
],
});
} }
} }

View File

@ -15,7 +15,7 @@ export default function (props) {
title: t('OIDC manager'), title: t('OIDC manager'),
icon: 'FileOutlined', icon: 'FileOutlined',
tabs: { tabs: {
storages: { providers: {
title: t('OIDC Providers'), title: t('OIDC Providers'),
component: OIDCPanel, component: OIDCPanel,
}, },

View File

@ -44,9 +44,12 @@ export class OidcPlugin extends Plugin {
await next(); await next();
}); });
// 开放访问权限 this.app.acl.allow('oidc', '*', 'public');
this.app.acl.allow('oidcProviders', '*', 'allowConfigure');
this.app.acl.allow('oidc', '*'); this.app.acl.registerSnippet({
name: `pm.${this.name}.providers`,
actions: ['oidcProviders:*'],
});
} }
async install(options?: InstallOptions) {} async install(options?: InstallOptions) {}

View File

@ -1,6 +1,5 @@
import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { PluginManagerContext, SettingsCenterProvider } from '@nocobase/client';
import { SigninPageExtensionProvider } from '@nocobase/client';
import { useSamlTranslation } from './locale'; import { useSamlTranslation } from './locale';
import { SAMLList } from './SAMLList'; import { SAMLList } from './SAMLList';
import { SAMLPanel } from './SAMLPanel'; import { SAMLPanel } from './SAMLPanel';
@ -17,7 +16,7 @@ export default function (props) {
title: t('SAML manager'), title: t('SAML manager'),
icon: 'FileOutlined', icon: 'FileOutlined',
tabs: { tabs: {
storages: { providers: {
title: t('SAML Providers'), title: t('SAML Providers'),
component: SAMLPanel, component: SAMLPanel,
}, },

View File

@ -33,8 +33,12 @@ export class SAMLPlugin extends Plugin {
}); });
// 开放访问权限 // 开放访问权限
this.app.acl.allow('samlProviders', '*', 'allowConfigure'); this.app.acl.allow('saml', '*', 'public');
this.app.acl.allow('saml', '*');
this.app.acl.registerSnippet({
name: `pm.${this.name}.providers`,
actions: ['samlProviders:*'],
});
} }
async install(options?: InstallOptions) {} async install(options?: InstallOptions) {}

View File

@ -1,4 +1,3 @@
import { skip } from '@nocobase/acl';
import { InstallOptions, Plugin } from '@nocobase/server'; import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path'; import { resolve } from 'path';
@ -29,17 +28,25 @@ export class SystemSettingsPlugin extends Plugin {
if (cmd) { if (cmd) {
cmd.option('-l, --lang [lang]'); cmd.option('-l, --lang [lang]');
} }
this.app.acl.registerSnippet({
name: `pm.${this.name}.system-settings`,
actions: ['systemSettings:update'],
});
} }
async load() { async load() {
await this.importCollections(resolve(__dirname, './collections')); await this.app.db.import({
directory: resolve(__dirname, 'collections'),
});
this.app.acl.use( this.app.acl.addFixedParams('systemSettings', 'destroy', () => {
skip({ return {
resourceName: 'systemSettings', 'id.$ne': 1,
actionName: 'get', };
}), });
);
this.app.acl.allow('systemSettings', 'get', 'public');
} }
} }

View File

@ -1,4 +1,3 @@
import { skip } from '@nocobase/acl';
import { MagicAttributeModel } from '@nocobase/database'; import { MagicAttributeModel } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { resolve } from 'path'; import { resolve } from 'path';
@ -87,12 +86,7 @@ export class UiRoutesStoragePlugin extends Plugin {
await this.importCollections(resolve(__dirname, './collections')); await this.importCollections(resolve(__dirname, './collections'));
this.app.acl.use( this.app.acl.allow('uiRoutes', 'getAccessible');
skip({
resourceName: 'uiRoutes',
actionName: 'getAccessible',
}),
);
} }
} }

View File

@ -26,6 +26,29 @@ export class UiSchemaStoragePlugin extends Plugin {
this.registerRepository(); this.registerRepository();
this.app.acl.registerSnippet({
name: `pm.${this.name}.block-templates`,
actions: ['uiSchemaTemplates:*'],
});
this.app.acl.registerSnippet({
name: 'ui.uiSchemas',
actions: [
'uiSchemas:insert',
'uiSchemas:insertNewSchema',
'uiSchemas:remove',
'uiSchemas:patch',
'uiSchemas:batchPatch',
'uiSchemas:clearAncestor',
'uiSchemas:insertBeforeBegin',
'uiSchemas:insertAfterBegin',
'uiSchemas:insertBeforeEnd',
'uiSchemas:insertAfterEnd',
'uiSchemas:insertAdjacent',
'uiSchemas:saveAsTemplate',
],
});
db.on('uiSchemas.beforeCreate', function setUid(model) { db.on('uiSchemas.beforeCreate', function setUid(model) {
if (!model.get('name')) { if (!model.get('name')) {
model.set('name', uid()); model.set('name', uid());
@ -61,8 +84,8 @@ export class UiSchemaStoragePlugin extends Plugin {
actions: uiSchemaActions, actions: uiSchemaActions,
}); });
this.app.acl.allow('uiSchemas', '*', 'loggedIn'); this.app.acl.allow('uiSchemas', ['getProperties', 'getJsonSchema'], 'loggedIn');
this.app.acl.allow('uiSchemaTemplates', '*', 'loggedIn'); this.app.acl.allow('uiSchemaTemplates', ['get', 'list'], 'loggedIn');
} }
async load() { async load() {

View File

@ -25,8 +25,8 @@ describe('actions', () => {
pluginUser = app.getPlugin('users'); pluginUser = app.getPlugin('users');
adminUser = await db.getRepository('users').findOne({ adminUser = await db.getRepository('users').findOne({
filter: { filter: {
email: process.env.INIT_ROOT_EMAIL email: process.env.INIT_ROOT_EMAIL,
} },
}); });
agent = app.agent(); agent = app.agent();
@ -67,16 +67,16 @@ describe('actions', () => {
const res1 = await agent.resource('users').updateProfile({ const res1 = await agent.resource('users').updateProfile({
filterByTk: adminUser.id, filterByTk: adminUser.id,
values: { values: {
nickname: 'a' nickname: 'a',
} },
}); });
expect(res1.status).toBe(401); expect(res1.status).toBe(401);
const res2 = await adminAgent.resource('users').updateProfile({ const res2 = await adminAgent.resource('users').updateProfile({
filterByTk: adminUser.id, filterByTk: adminUser.id,
values: { values: {
nickname: 'a' nickname: 'a',
} },
}); });
expect(res2.status).toBe(200); expect(res2.status).toBe(200);
}); });

View File

@ -92,6 +92,22 @@ export default class UsersPlugin extends Plugin<UserPluginConfig> {
this.app.resourcer.use(parseToken, { tag: 'parseToken' }); this.app.resourcer.use(parseToken, { tag: 'parseToken' });
this.app.acl.addFixedParams('users', 'destroy', () => {
return {
filter: {
'id.$ne': 1,
},
};
});
this.app.acl.addFixedParams('collections', 'destroy', () => {
return {
filter: {
'name.$ne': 'users',
},
};
});
const publicActions = ['check', 'signin', 'signup', 'lostpassword', 'resetpassword', 'getUserByResetToken']; const publicActions = ['check', 'signin', 'signup', 'lostpassword', 'resetpassword', 'getUserByResetToken'];
const loggedInActions = ['signout', 'updateProfile', 'changePassword']; const loggedInActions = ['signout', 'updateProfile', 'changePassword'];

View File

@ -137,8 +137,11 @@ export default class VerificationPlugin extends Plugin {
return this.intercept(context, next); return this.intercept(context, next);
}); });
app.acl.allow('verifications', 'create'); app.acl.allow('verifications', 'create', 'public');
app.acl.allow('verifications_providers', '*', 'allowConfigure'); this.app.acl.registerSnippet({
name: `pm.${this.name}.providers`,
actions: ['verifications_providers:*'],
});
} }
async getDefault() { async getDefault() {

View File

@ -37,15 +37,18 @@ function WorkflowPane() {
export const WorkflowProvider = (props) => { export const WorkflowProvider = (props) => {
const ctx = useContext(PluginManagerContext); const ctx = useContext(PluginManagerContext);
const { routes, components, ...others } = useContext(RouteSwitchContext); const { routes, components, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift({ routes[1].routes.unshift(
type: 'route', {
path: '/admin/settings/workflow/workflows/:id', type: 'route',
component: 'WorkflowPage', path: '/admin/settings/workflow/workflows/:id',
}, { component: 'WorkflowPage',
type: 'route', },
path: '/admin/settings/workflow/executions/:id', {
component: 'ExecutionPage', type: 'route',
}); path: '/admin/settings/workflow/executions/:id',
component: 'ExecutionPage',
},
);
return ( return (
<SettingsCenterProvider <SettingsCenterProvider
settings={{ settings={{
@ -55,6 +58,7 @@ export const WorkflowProvider = (props) => {
title: lang('Workflow'), title: lang('Workflow'),
tabs: { tabs: {
workflows: { workflows: {
isBookmark: true,
title: lang('Workflow'), title: lang('Workflow'),
component: WorkflowPane, component: WorkflowPane,
}, },
@ -70,10 +74,10 @@ export const WorkflowProvider = (props) => {
}, },
}} }}
> >
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}> <RouteSwitchContext.Provider
<WorkflowContext.Provider value={{ triggers, instructions }}> value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}
{props.children} >
</WorkflowContext.Provider> <WorkflowContext.Provider value={{ triggers, instructions }}>{props.children}</WorkflowContext.Provider>
</RouteSwitchContext.Provider> </RouteSwitchContext.Provider>
</PluginManagerContext.Provider> </PluginManagerContext.Provider>
</SettingsCenterProvider> </SettingsCenterProvider>

View File

@ -1,6 +1,6 @@
import path from 'path'; import path from 'path';
import { Op, Transactionable } from '@nocobase/database'; import { Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
@ -10,10 +10,10 @@ import { EXECUTION_STATUS } from './constants';
import extensions from './extensions'; import extensions from './extensions';
import initInstructions, { Instruction } from './instructions'; import initInstructions, { Instruction } from './instructions';
import ExecutionModel from './models/Execution'; import ExecutionModel from './models/Execution';
import JobModel from './models/Job';
import WorkflowModel from './models/Workflow'; import WorkflowModel from './models/Workflow';
import Processor from './Processor'; import Processor from './Processor';
import initTriggers, { Trigger } from './triggers'; import initTriggers, { Trigger } from './triggers';
import JobModel from './models/Job';
type Pending = [ExecutionModel, JobModel?]; type Pending = [ExecutionModel, JobModel?];
@ -72,6 +72,20 @@ export default class WorkflowPlugin extends Plugin {
async load() { async load() {
const { db, options } = this; const { db, options } = this;
this.app.acl.registerSnippet({
name: `pm.${this.name}.workflows`,
actions: [
'workflows:*',
'workflows.nodes:*',
'executions:list',
'executions:get',
'flow_nodes:update',
'flow_nodes:destroy',
],
});
this.app.acl.allow('users_jobs', ['list', 'get', 'submit'], 'loggedIn');
await db.import({ await db.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),
}); });

View File

@ -34,7 +34,7 @@ export class PresetNocoBase extends Plugin {
return localPlugins; return localPlugins;
} }
async addBuiltInPlugins() { async addBuiltInPlugins(options?: any) {
const builtInPlugins = this.getBuiltInPlugins(); const builtInPlugins = this.getBuiltInPlugins();
await this.app.pm.add(builtInPlugins, { await this.app.pm.add(builtInPlugins, {
enabled: true, enabled: true,
@ -43,7 +43,7 @@ export class PresetNocoBase extends Plugin {
}); });
const localPlugins = this.getLocalPlugins(); const localPlugins = this.getLocalPlugins();
await this.app.pm.add(localPlugins, {}); await this.app.pm.add(localPlugins, {});
await this.app.reload(); await this.app.reload({ method: options.method });
} }
afterAdd() { afterAdd() {
@ -59,15 +59,15 @@ export class PresetNocoBase extends Plugin {
if (r) { if (r) {
console.log(`Clear the installed application plugins`); console.log(`Clear the installed application plugins`);
await this.db.getRepository('applicationPlugins').destroy({ truncate: true }); await this.db.getRepository('applicationPlugins').destroy({ truncate: true });
await this.app.reload(); await this.app.reload({ method: options.method });
} }
} }
}); });
this.app.on('beforeUpgrade', async () => { this.app.on('beforeUpgrade', async (options) => {
const result = await this.app.version.satisfies('<0.8.0-alpha.1'); const result = await this.app.version.satisfies('<0.8.0-alpha.1');
if (result) { if (result) {
console.log(`Initialize all built-in plugins`); console.log(`Initialize all built-in plugins`);
await this.addBuiltInPlugins(); await this.addBuiltInPlugins({ method: 'upgrade' });
} }
const builtInPlugins = this.getBuiltInPlugins(); const builtInPlugins = this.getBuiltInPlugins();
const plugins = await this.app.db.getRepository('applicationPlugins').find(); const plugins = await this.app.db.getRepository('applicationPlugins').find();
@ -85,14 +85,15 @@ export class PresetNocoBase extends Plugin {
localPlugins.filter((plugin) => !pluginNames.includes(plugin)), localPlugins.filter((plugin) => !pluginNames.includes(plugin)),
{}, {},
); );
await this.app.reload(); await this.app.reload({ method: 'upgrade' });
await this.app.db.sync(); await this.app.db.sync();
}); });
this.app.on('beforeInstall', async () => { this.app.on('beforeInstall', async (options) => {
console.log(`Initialize all built-in plugins`); console.log(`Initialize all built-in plugins`);
await this.addBuiltInPlugins(); await this.addBuiltInPlugins({ method: 'install' });
}); });
} }
beforeLoad() { beforeLoad() {
this.db.addMigrations({ this.db.addMigrations({
namespace: this.getName(), namespace: this.getName(),

Some files were not shown because too many files have changed in this diff Show More