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",
"dependencies": {
"@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": {
"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',
actionName: 'login',
},
log: {
info() {},
},
app: {
acl,
},
@ -49,6 +52,7 @@ describe('skip', () => {
const nextFunc = jest.fn();
let skip = false;
acl.allow('users', 'login', (ctx) => {
return skip;
});
@ -76,6 +80,9 @@ describe('skip', () => {
resourceName: 'users',
actionName: 'login',
},
log: {
info() {},
},
app: {
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)) {
const predicateName = this.actionsAsObject[actionName];
if (predicateName) {
return predicate[predicateName];
return lodash.cloneDeep(predicate[predicateName]);
}
return true;
@ -62,10 +62,6 @@ export class ACLAvailableStrategy {
}
allow(resourceName: string, actionName: string) {
if (this.acl.isConfigResource(resourceName) && this.allowConfigure) {
return true;
}
return this.matchAction(this.acl.resolveActionAlias(actionName));
}
}

View File

@ -1,6 +1,8 @@
import { ACL, DefineOptions } from './acl';
import { AvailableStrategyOptions } from './acl-available-strategy';
import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy';
import { ACLResource } from './acl-resource';
import lodash from 'lodash';
import minimatch from 'minimatch';
export interface RoleActionParams {
fields?: string[];
@ -18,6 +20,7 @@ export interface ResourceActionsOptions {
export class ACLRole {
strategy: string | AvailableStrategyOptions;
resources = new Map<string, ACLResource>();
snippets: Set<string> = new Set();
constructor(public acl: ACL, public name: string) {}
@ -29,6 +32,16 @@ export class ACLRole {
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) {
const resource = this.getResource(resourceName);
return resource.getActions();
@ -67,6 +80,64 @@ export class ACLRole {
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 {
const actions = {};
@ -81,6 +152,7 @@ export class ACLRole {
role: this.name,
strategy: this.strategy,
actions,
snippets: Array.from(this.snippets),
};
}

View File

@ -1,5 +1,5 @@
import { Action } from '@nocobase/resourcer';
import { Toposort, ToposortOptions } from '@nocobase/utils';
import { assign, Toposort, ToposortOptions } from '@nocobase/utils';
import EventEmitter from 'events';
import parse from 'json-templates';
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 { ACLRole, ResourceActionsOptions, RoleActionParams } from './acl-role';
import { AllowManager, ConditionFunc } from './allow-manager';
import FixedParamsManager, { Merger } from './fixed-params-manager';
import SnippetManager, { SnippetOptions } from './snippet-manager';
interface CanResult {
role: string;
@ -22,6 +24,7 @@ export interface DefineOptions {
strategy?: string | AvailableStrategyOptions;
actions?: ResourceActionsOptions;
routes?: any;
snippets?: string[];
}
export interface ListenerContext {
@ -39,14 +42,18 @@ interface CanArgs {
role: string;
resource: string;
action: string;
ctx?: any;
}
export class ACL extends EventEmitter {
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>;
public allowManager = new AllowManager(this);
public snippetManager = new SnippetManager();
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 {
@ -164,6 +230,28 @@ export class ACL extends EventEmitter {
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);
if (aclResource) {
@ -171,39 +259,37 @@ export class ACL extends EventEmitter {
if (actionParams) {
// handle single action config
return {
return mergeParams({
role,
resource,
action,
params: actionParams,
};
});
} else {
return null;
}
}
if (!aclRole.strategy) {
const roleStrategy = aclRole.getStrategy();
if (!roleStrategy && !snippetAllowed) {
return null;
}
const roleStrategy = lodash.isString(aclRole.strategy)
? this.availableStrategy.get(aclRole.strategy)
: new ACLAvailableStrategy(this, aclRole.strategy);
let roleStrategyParams = roleStrategy?.allow(resource, this.resolveActionAlias(action));
if (!roleStrategy) {
return null;
if (!roleStrategyParams && snippetAllowed) {
roleStrategyParams = {};
}
const roleStrategyParams = roleStrategy.allow(resource, this.resolveActionAlias(action));
if (roleStrategyParams) {
const result = { role, resource, action };
const result = { role, resource, action, params: {} };
if (lodash.isPlainObject(roleStrategyParams)) {
result['params'] = roleStrategyParams;
}
return result;
return mergeParams(result);
}
return null;
@ -218,10 +304,17 @@ export class ACL extends EventEmitter {
}
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) {
return this.skip(resourceName, actionNames, condition);
}
skip(resourceName: string, actionNames: string[] | string, condition?: string | ConditionFunc) {
if (!Array.isArray(actionNames)) {
actionNames = [actionNames];
}
@ -242,23 +335,10 @@ export class ACL extends EventEmitter {
middleware() {
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) {
const roleName = ctx.state.currentRole || 'anonymous';
const { resourceName, actionName } = ctx.action;
const resourcerAction: Action = ctx.action;
ctx.can = (options: Omit<CanArgs, 'role'>) => {
return acl.can({ role: roleName, ...options });
};
@ -267,28 +347,30 @@ export class ACL extends EventEmitter {
can: ctx.can({ resource: resourceName, action: actionName }),
};
return compose(acl.middlewares.nodes)(ctx, async () => {
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();
});
return compose(acl.middlewares.nodes)(ctx, 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;
});
this.registerAllowCondition('public', (ctx) => {
return true;
});
this.registerAllowCondition('allowConfigure', async (ctx) => {
const roleName = ctx.state.currentRole;
if (!roleName) {
return false;
}
const roleInstance = await ctx.db.getRepository('roles').findOne({
filter: {
name: roleName,
},
});
const role = acl.getRole(roleName);
if (!role) {
return false;
}
return roleInstance?.get('allowConfigure');
return role.getStrategy()?.allowConfigure;
});
}
@ -43,9 +46,11 @@ export class AllowManager {
for (const fetchActionStep of fetchActionSteps) {
const resource = this.skipActions.get(fetchActionStep);
if (resource) {
const condition = resource.get('*') || resource.get(actionName);
if (condition) {
results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition);
for (const fetchActionStep of ['*', actionName]) {
const condition = resource.get(fetchActionStep);
if (condition) {
results.push(typeof condition === 'string' ? this.registeredCondition.get(condition) : condition);
}
}
}
}
@ -57,34 +62,40 @@ export class AllowManager {
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() {
return async (ctx, next) => {
const { resourceName, actionName } = ctx.action;
const skippedConditions = ctx.app.acl.allowManager.getAllowedConditions(resourceName, actionName);
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;
}
}
}
let skip = await this.acl.allowManager.isAllowed(resourceName, actionName, ctx);
if (skip) {
ctx.permission = {
...(ctx.permission || {}),
skip: true,
};
}
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-resource';
export * from './acl-role';
export * from './no-permission-error';
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 { mockServer } from './index';
describe('destroy action', () => {
let app: MockServer;
let app;
let Post;
let Comment;
let Tag;

View File

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

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import { useFieldSchema } from '@formily/react';
import { Schema, useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import React, { createContext, useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { useAPIClient, useRequest } from '../api-client';
import { useBlockRequestContext } from '../block-provider/BlockProvider';
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';
export const ACLContext = createContext(null);
export const ACLContext = createContext<any>({});
export const ACLProvider = (props) => {
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) => {
const route = getRouteUrl(props.children.props);
const { setDesignable } = useDesignable();
const api = useAPIClient();
const result = useRequest(
@ -28,7 +52,7 @@ export const ACLRolesCheckProvider = (props) => {
},
{
onSuccess(data) {
if (!data?.data?.allowConfigure && !data?.data?.allowAll) {
if (!data?.data?.snippets.includes('ui.*')) {
setDesignable(false);
}
if (data?.data?.role !== api.auth.role) {
@ -48,9 +72,9 @@ export const ACLRolesCheckProvider = (props) => {
export const useRoleRecheck = () => {
const ctx = useContext(ACLContext);
const { allowAll, allowConfigure } = useACLRoleContext();
const { allowAll } = useACLRoleContext();
return () => {
if (allowAll || allowConfigure) {
if (allowAll) {
return;
}
ctx.refresh();
@ -61,98 +85,191 @@ export const useACLContext = () => {
return useContext(ACLContext);
};
export const useACLRoleContext = () => {
const ctx = useContext(ACLContext);
const data = ctx.data?.data;
export const ACLActionParamsContext = createContext<any>({});
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 {
...data,
getActionParams(path: string, { skipOwnCheck, isOwn }) {
const [resourceName, act] = path.split(':');
const currentAction = data?.actionAlias?.[act] || act;
const hasResource = data?.resources?.includes(resourceName);
const params = data?.actions?.[`${resourceName}:${currentAction}`];
if (hasResource) {
if (!skipOwnCheck && params?.own) {
return isOwn ? params : null;
}
return params;
}
const strategyActions = data?.strategy?.actions || [];
const strategyAction = strategyActions?.find((action) => {
data,
getActionAlias,
inResources: (resourceName: string) => {
return data?.resources?.includes?.(resourceName);
},
getResourceActionParams: (actionPath: string) => {
const [resourceName] = actionPath.split(':');
const actionAlias = getActionAlias(actionPath);
return data?.actions?.[`${resourceName}:${actionAlias}`] || data?.actions?.[actionPath];
},
getStrategyActionParams: (actionPath: string) => {
const actionAlias = getActionAlias(actionPath);
const strategyAction = data?.strategy?.actions.find((action) => {
const [value] = action.split(':');
return value === currentAction;
return value === actionAlias;
});
if (!strategyAction) {
return;
}
if (skipOwnCheck) {
return {};
}
const [, actionScope] = strategyAction.split(':');
if (actionScope === 'own') {
return isOwn;
}
return {};
return strategyAction ? {} : null;
},
};
};
export const ACLAllowConfigure = (props) => {
const { allowAll, allowConfigure } = useACLRoleContext();
if (allowAll || allowConfigure) {
return <>{props.children}</>;
const getIgnoreScope = (options: any = {}) => {
const { schema, recordPkValue } = options;
let ignoreScope = false;
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) => {
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext();
const fieldSchema = useFieldSchema();
const isOwn = useRecordIsOwn();
if (allowAll || allowConfigure) {
const { allowAll, parseAction } = useACLRoleContext();
const schema = useFieldSchema();
if (allowAll) {
return <>{props.children}</>;
}
const path = fieldSchema['x-acl-action'];
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck;
if (!path) {
const actionPath = schema['x-acl-action'];
if (!actionPath) {
return <>{props.children}</>;
}
const params = getActionParams(path, { isOwn, skipOwnCheck: skipScopeCheck === false ? false : true });
const params = parseAction(actionPath, { schema });
if (!params) {
return null;
}
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) => {
const { name } = useCollection();
const fieldSchema = useFieldSchema();
const isOwn = useRecordIsOwn();
const { allowAll, allowConfigure, getActionParams } = useACLRoleContext();
if (!name || allowAll || allowConfigure) {
const recordPkValue = useRecordPkValue();
const resource = useResourceName();
const { parseAction } = useACLRoleContext();
const schema = useFieldSchema();
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}</>;
}
const actionName = fieldSchema['x-action'];
const path = fieldSchema['x-acl-action'] || `${name}:${actionName}`;
const skipScopeCheck = fieldSchema['x-acl-action-props']?.skipScopeCheck;
const params = getActionParams(path, { skipOwnCheck: skipScopeCheck, isOwn });
const params = parseAction(actionPath, { schema, recordPkValue });
if (!params) {
return null;
}
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) => {
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}</>;
};
export const ACLMenuItemProvider = (props) => {
const { allowAll, allowConfigure, allowMenuItemIds = [] } = useACLRoleContext();
const { allowAll, allowMenuItemIds = [], snippets } = useACLRoleContext();
const fieldSchema = useFieldSchema();
if (allowAll || allowConfigure) {
if (allowAll || snippets.includes('ui.*')) {
return <>{props.children}</>;
}
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 { uniq } from 'lodash';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord } from '../../record-provider';
import { uniq } from 'lodash';
import { useMenuItems } from './MenuItemsProvider';
const findUids = (items) => {
@ -117,7 +117,7 @@ export const MenuConfigure = () => {
message.success(t('Saved successfully'));
}}
/>
{t('Accessible')}
{' '}{t('Accessible')}
</>
),
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 { message } from 'antd';
import React from 'react';
import { connect } from '@formily/react';
import { Checkbox } from 'antd';
import uniq from 'lodash/uniq';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client';
import { useRecord } from '../../record-provider';
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 = () => {
const api = useAPIClient();
const record = useRecord();
const { update, currentRecord } = useContext(PermissionContext);
const { t } = useTranslation();
return (
<SchemaComponent
components={{ SnippetCheckboxGroup }}
schema={{
type: 'void',
name: 'form',
'x-component': 'Form',
'x-component-props': {
useValues: (options) => {
const api = useAPIClient();
return useRequest(
{
resource: 'roles',
action: 'get',
params: {
filterByTk: record.name,
},
},
() =>
api
.resource('roles')
.get({
filterByTk: currentRecord.name,
})
.then((res) => {
const record = res?.data?.data;
record.snippets?.forEach((key) => {
record[key] = true;
});
return { data: record };
}),
options,
);
},
@ -34,24 +84,22 @@ export const RoleConfigure = () => {
if (!form.modified) {
return;
}
await api.resource('roles').update({
filterByTk: record.name,
values: form.values,
});
message.success(t('Saved successfully'));
await update(field, form);
});
},
},
properties: {
allowConfigure: {
snippets: {
title: t('Configure permissions'),
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': t('Allows configuration of the whole system, including UI, collections, permissions, etc.'),
'x-component': 'SnippetCheckboxGroup',
},
'strategy.actions': {
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-decorator': 'FormItem',
},

View File

@ -2,10 +2,11 @@ import { Spin } from 'antd';
import React, { createContext, useContext } from 'react';
import { useRequest } from '../../api-client';
import { SchemaComponent } from '../../schema-component';
import { roleSchema } from './schemas/roles';
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 { data, loading } = useRequest({
@ -26,7 +27,7 @@ export const RoleTable = () => {
return (
<div>
<AvailableActionsProver>
<SchemaComponent schema={roleSchema} components={{ MenuItemsProvider }} />
<SchemaComponent schema={roleSchema} components={{ MenuItemsProvider, SettingCenterPermissionProvider,PermissionProvider }} />
</AvailableActionsProver>
</div>
);

View File

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

View File

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

View File

@ -18,17 +18,30 @@ const collection = {
required: true,
} as ISchema,
},
// {
// type: 'string',
// name: 'name',
// interface: 'input',
// uiSchema: {
// title: '数据表标识',
// type: 'string',
// 'x-component': 'Input',
// description: '使用英文',
// } as ISchema,
// },
{
type: 'string',
name: 'name',
interface: 'input',
uiSchema: {
title: '{{t("Collection name")}}',
type: 'string',
'x-component': 'Input',
} 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',
name: 'usingConfig',
@ -89,7 +102,7 @@ export const roleCollectionsSchema: ISchema = {
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
column1: {
column0: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'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: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',

View File

@ -1,6 +1,9 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import pick from 'lodash/pick';
import { useEffect } from 'react';
import { useRequest } from '../../../api-client';
import { useRecord } from '../../../record-provider';
import { useActionContext } from '../../../schema-component';
import { roleCollectionsSchema } from './roleCollections';
@ -112,6 +115,7 @@ export const roleSchema: ISchema = {
Promise.resolve({
data: {
name: `r_${uid()}`,
snippets: ['!ui.*', '!pm', '!pm.*'],
},
}),
{ ...options, refreshDeps: [ctx.visible] },
@ -227,11 +231,14 @@ export const roleSchema: ISchema = {
type: 'void',
title: '{{t("Configure")}}',
'x-component': 'Action.Link',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'roles:update',
'x-component-props': {},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'PermissionProvider',
title: '{{t("Configure permissions")}}',
properties: {
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: {
type: 'void',
title: '{{t("Edit")}}',
'x-decorator': 'ACLActionProvider',
'x-acl-action': 'roles:update',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
@ -290,7 +312,23 @@ export const roleSchema: ISchema = {
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'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")}}',
properties: {
@ -337,6 +375,8 @@ export const roleSchema: ISchema = {
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-acl-action': 'roles:destroy',
'x-decorator': 'ACLActionProvider',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {

View File

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

View File

@ -1,3 +1,4 @@
export * from './ACLProvider';
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() {
this.axios.interceptors.request.use((config) => {
config.headers['X-With-ACL-Meta'] = true;
return config;
});
super.interceptors();
this.notification();
}
@ -37,11 +41,11 @@ export class APIClient extends APIClientSDK {
if (error.response.data.type === 'application/json') {
handleErrorMessage(error);
} else {
notification.error({
message: error?.response?.data?.errors?.map?.((error: any) => {
return React.createElement('div', { children: error.message });
}),
});
notification.error({
message: error?.response?.data?.errors?.map?.((error: any) => {
return React.createElement('div', { children: error.message });
}),
});
}
throw error;
},

View File

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

View File

@ -21,8 +21,8 @@ export const useCollection = () => {
},
[],
);
const totalFields = unionBy(currentFields?.concat(inheritedFields),'name').filter((v)=>{
return !v.isForeignKey
const totalFields = unionBy(currentFields?.concat(inheritedFields), 'name').filter((v) => {
return !v.isForeignKey;
});
return {
...collection,
@ -32,7 +32,14 @@ export const useCollection = () => {
return fields?.find((field) => field.name === name);
},
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,
inheritedFields
inheritedFields,
};
};

View File

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

View File

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

View File

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

View File

@ -419,6 +419,10 @@ export default {
"General permissions": "General permissions",
"Global action permissions": "Global 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",
"Allow": "Allow",
"Data scope": "Data scope",

View File

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

View File

@ -496,6 +496,13 @@ export default {
'General permissions': '通用配置',
'Global 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': '操作名称',
'Allow': '允许',
'Data scope': '数据范围',
@ -614,5 +621,7 @@ export default {
"Search and select collection": "搜索并选择数据表",
'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 { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useACLRoleContext } from '../acl/ACLProvider';
import { PluginManagerContext } from './context';
export const usePrefixCls = (
@ -142,19 +143,15 @@ PluginManager.Toolbar.Item = (props) => {
};
export const RemotePluginManagerToolbar = () => {
// const api = useAPIClient();
// const { data, loading } = useRequest({
// resource: 'plugins',
// action: 'getPinned',
// });
// if (loading) {
// return <Spin />;
// }
const { allowAll, snippets } = useACLRoleContext();
const getSnippetsAllow = (aclKey) => {
return allowAll || snippets?.includes(aclKey);
};
const items = [
{ component: 'DesignableSwitch', pin: true },
{ component: 'PluginManagerLink', pin: true },
{ component: 'SettingsCenterDropdown', pin: true },
{ component: 'DesignableSwitch', pin: true, isAllow: getSnippetsAllow('ui.*') },
{ component: 'PluginManagerLink', pin: true, isAllow: getSnippetsAllow('pm') },
{ component: 'SettingsCenterDropdown', pin: true, isAllow: getSnippetsAllow('pm.*') },
// ...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 { uid } from '@formily/shared';
import { Dropdown, Menu } from 'antd';
import React, { useState } from 'react';
import React, { useState, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager';
import { useCompile } from '../schema-component';
import { ActionContext } from '../schema-component';
const schema: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-component': 'Action.Drawer',
type: 'void',
title: '{{t("Collections & Fields")}}',
properties: {
configuration: {
'x-component': 'ConfigurationTable',
},
},
},
},
};
import { useACLRoleContext } from '../acl/ACLProvider';
import { getPluginsTabs, SettingsCenterContext } from './index';
export const PluginManagerLink = () => {
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 = () => {
const { snippets = [] } = useACLRoleContext();
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const compile = useCompile();
const history = useHistory();
const items = [
{
title: t('Collections & Fields'),
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',
// },
];
const itemData = useContext(SettingsCenterContext);
const pluginsTabs = getPluginsTabs(itemData, snippets);
const bookmarkTabs = getBookmarkTabs(pluginsTabs);
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<Dropdown
placement="bottom"
overlay={
<Menu>
<Menu.ItemGroup title={t('Bookmark')}>
{items.map((item) => {
{bookmarkTabs.map((tab) => {
return (
<Menu.Item
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>
);
})}

View File

@ -1,10 +1,12 @@
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
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 { useTranslation } from 'react-i18next';
import { Redirect, useHistory, useRouteMatch } from 'react-router-dom';
import { ACLPane } from '../acl';
import { useACLRoleContext } from '../acl/ACLProvider';
import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerPane } from '../collection-manager';
import { useDocumentTitle } from '../document-title';
@ -149,7 +151,7 @@ const LocalPlugins = () => {
return (
<>
{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 { setTitle } = useDocumentTitle();
const { t } = useTranslation();
const { snippets = [] } = useACLRoleContext();
return (
return snippets.includes('pm') ? (
<div>
<PageHeader
ghost={false}
@ -217,6 +220,8 @@ const PluginList = (props) => {
)}
</div>
</div>
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
);
};
@ -226,16 +231,17 @@ const settings = {
icon: 'LockOutlined',
tabs: {
roles: {
isBookmark: true,
title: '{{t("Roles & Permissions")}}',
component: ACLPane,
},
},
},
'block-templates': {
'ui-schema-storage': {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
tabs: {
list: {
'block-templates': {
title: '{{t("Block templates")}}',
component: BlockTemplatesPane,
},
@ -246,6 +252,7 @@ const settings = {
title: '{{t("Collection manager")}}',
tabs: {
collections: {
isBookmark: true,
title: '{{t("Collections & Fields")}}',
component: CollectionManagerPane,
},
@ -256,6 +263,7 @@ const settings = {
title: '{{t("System settings")}}',
tabs: {
'system-settings': {
isBookmark: true,
title: '{{t("System settings")}}',
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 { snippets = [] } = useACLRoleContext();
const match = useRouteMatch<any>();
const history = useHistory<any>();
const items = useContext(SettingsCenterContext);
const pluginsTabs = getPluginsTabs(items, snippets);
const compile = useCompile();
const firstUri = useMemo(() => {
const keys = Object.keys(items).sort();
const pluginName = keys.shift();
const tabName = Object.keys(items?.[pluginName]?.tabs || {}).shift();
const pluginName = pluginsTabs[0].key;
const tabName = pluginsTabs[0].tabs[0].key;
return `/admin/settings/${pluginName}/${tabName}`;
}, [items]);
}, [pluginsTabs]);
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) {
return <Redirect to={firstUri} />;
}
@ -286,78 +320,86 @@ const SettingsCenter = (props) => {
return <Redirect to={`/admin/settings/${pluginName}/${firstTabName}`} />;
}
const component = items[pluginName]?.tabs?.[tabName]?.component;
const menuItems: any = Object.keys(items)
.sort()
.map((key) => {
const item = items[key];
const tabKey = Object.keys(item.tabs).shift();
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
const menuItems: any = pluginsTabs
.filter((plugin) => plugin.isAllow)
.map((plugin) => {
return {
label: compile(item.title),
key: key,
icon: item.icon ? <Icon type={item.icon} /> : null,
label: compile(plugin.title),
key: plugin.key,
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
};
});
return (
<Layout>
<div
style={
{
'--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>
<div>
<Layout>
<div
style={
{
'--side-menu-width': '200px',
} as Record<string, string>
}
/>
<div style={{ margin: 24 }}>{component && React.createElement(component)}</div>
</Layout.Content>
</Layout>
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>
{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 { useHistory, useRouteMatch } from 'react-router-dom';
import {
ACLAllowConfigure,
ACLRolesCheckProvider,
CurrentUser,
CurrentUserProvider,
@ -25,8 +24,8 @@ import { PoweredBy } from '../../../powered-by';
import { useMutationObserver } from 'ahooks';
const filterByACL = (schema, options) => {
const { allowAll, allowConfigure, allowMenuItemIds = [] } = options;
if (allowAll || allowConfigure) {
const { allowAll, allowMenuItemIds = [] } = options;
if (allowAll) {
return schema;
}
const filterSchema = (s) => {
@ -123,7 +122,7 @@ const MenuEditor = (props) => {
);
};
const InternalAdminLayout = (props: any) => {
export const InternalAdminLayout = (props: any) => {
const sideMenuRef = useRef<HTMLDivElement>();
const [sideMenuWidth, setSideMenuWidth] = useState(0);
@ -192,9 +191,7 @@ const InternalAdminLayout = (props: any) => {
</div>
</div>
<div style={{ position: 'absolute', height: '100%', zIndex: 10, top: 0, right: 0 }}>
<ACLAllowConfigure>
<RemotePluginManagerToolbar />
</ACLAllowConfigure>
<RemotePluginManagerToolbar />
<CurrentUser />
</div>
</div>

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { MenuOutlined } from '@ant-design/icons';
import { SortableContext, useSortable } from '@dnd-kit/sortable';
import { css } from '@emotion/css';
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 { useEventListener, useMemoizedFn } from 'ahooks';
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 { DndContext, useDesignable } from '../..';
import { RecordIndexProvider, RecordProvider, useSchemaInitializer } from '../../../';
import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
import { isCollectionFieldComponent, isColumnComponent } from './utils';
const useTableColumns = () => {
const start = Date.now();
const field = useField<ArrayField>();
const schema = useFieldSchema();
const { schemaInWhitelist } = useACLFieldWhitelist();
const { designable } = useDesignable();
const { exists, render } = useSchemaInitializer(schema['x-initializer']);
const columns = schema
.reduceProperties((buf, s) => {
if (isColumnComponent(s)) {
if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
return buf.concat([s]);
}
return buf;
}, [])
.map((s: Schema) => {
?.map((s: Schema) => {
const collectionFields = s.reduceProperties((buf, s) => {
if (isCollectionFieldComponent(s)) {
return buf.concat([s]);
@ -42,9 +44,9 @@ const useTableColumns = () => {
render: (v, record) => {
const index = field.value?.indexOf(record);
return (
<RecordIndexProvider index={index}>
<RecordIndexProvider index={record.__index || index}>
<RecordProvider record={record}>
<RecursionField schema={s} name={index} onlyRenderProperties />
<RecursionField schema={s} name={record.__index || index} onlyRenderProperties />
</RecordProvider>
</RecordIndexProvider>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import {
Sequelize,
SyncOptions,
Transactionable,
Utils,
Utils
} from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug';
import { Collection, CollectionOptions, RepositoryType } from './collection';
@ -58,7 +58,7 @@ import {
SyncListener,
UpdateListener,
UpdateWithAssociationsListener,
ValidateListener,
ValidateListener
} from './types';
export interface MergeOptions extends merge.Options {}
@ -356,7 +356,19 @@ export class Database extends EventEmitter implements AsyncEmitter {
* @param name
*/
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 {
@ -389,11 +401,10 @@ export class Database extends EventEmitter implements AsyncEmitter {
getRepository<R extends ArrayFieldRepository>(name: string, relationId: string | number): 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.getCollection(name)?.repository;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,8 +43,11 @@ export class PluginManager {
this.repository.setPluginManager(this);
this.app.resourcer.define(resourceOptions);
this.app.acl.allow('pm', ['enable', 'disable', 'remove'], 'allowConfigure');
this.app.acl.allow('applicationPlugins', 'list', 'allowConfigure');
this.app.acl.registerSnippet({
name: 'pm',
actions: ['pm:*', 'applicationPlugins:list'],
});
this.server = net.createServer((socket) => {
socket.on('data', async (data) => {
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;
}

View File

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

View File

@ -42,6 +42,154 @@ describe('acl', () => {
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 () => {
await db.getRepository('roles').create({
values: {
@ -474,6 +622,7 @@ describe('acl', () => {
strategy: {
actions: ['*'],
},
snippets: ['pm.*'],
},
});
const UserRepo = db.getCollection('users').repository;
@ -545,4 +694,44 @@ describe('acl', () => {
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 { 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', () => {
let app: MockServer;
let db: Database;
@ -26,22 +128,24 @@ describe('association field acl', () => {
await db.getRepository('roles').create({
values: {
name: 'new',
allowConfigure: true,
},
});
await db.getRepository('roles').create({
values: {
name: 'testAdmin',
allowConfigure: true,
snippets: ['pm.*'],
},
});
const UserRepo = db.getCollection('users').repository;
user = await UserRepo.create({
values: {
roles: ['new'],
},
});
admin = await UserRepo.create({
values: {
roles: ['testAdmin'],
@ -55,6 +159,7 @@ describe('association field acl', () => {
}),
{ type: 'bearer' },
);
adminAgent = app.agent().auth(
userPlugin.jwtService.sign({
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(
acl.can({
role: 'new',
@ -173,6 +291,7 @@ describe('association field acl', () => {
const actionId = viewAction.get('id') as number;
const response = await adminAgent.resource('roles.resources', 'new').update({
filterByTk: 'users',
values: {
name: 'users',
usingActionsConfig: true,
@ -197,6 +316,7 @@ describe('association field acl', () => {
it('should revoke association action on field deleted', async () => {
await adminAgent.resource('roles.resources', 'new').update({
filterByTk: 'users',
values: {
name: 'users',
usingActionsConfig: true,
@ -208,6 +328,7 @@ describe('association field acl', () => {
],
},
});
expect(
acl.can({
role: 'new',
@ -222,6 +343,7 @@ describe('association field acl', () => {
whitelist: ['age', 'name'],
},
});
const roleResource = await db.getRepository('rolesResources').findOne({
filter: {
name: 'users',

View File

@ -23,7 +23,7 @@ describe('configuration', () => {
await db.getRepository('roles').create({
values: {
name: 'test1',
allowConfigure: true,
snippets: ['pm.*'],
},
});
@ -36,23 +36,29 @@ describe('configuration', () => {
const UserRepo = db.getCollection('users').repository;
admin = await UserRepo.create({
values: {
roles: ['test1']
}
roles: ['test1'],
},
});
user = await UserRepo.create({
values: {
roles: ['test2']
}
roles: ['test2'],
},
});
const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth(userPlugin.jwtService.sign({
userId: admin.get('id'),
}), { type: 'bearer' });
adminAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
userAgent = app.agent().auth(userPlugin.jwtService.sign({
userId: user.get('id'),
}), { type: 'bearer' });
userAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
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 { MockServer } from '@nocobase/test';
@ -96,4 +96,29 @@ describe('role api', () => {
expect(defaultRole.length).toEqual(1);
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');
adminUser = await db.getRepository('users').findOne({
filter: {
email: process.env.INIT_ROOT_EMAIL
email: process.env.INIT_ROOT_EMAIL,
},
appends: ['roles']
appends: ['roles'],
});
agent = app.agent();
@ -44,9 +44,98 @@ describe('actions', () => {
filterByTk: adminUser.id,
values: {
nickname: 'a',
roles: adminUser.roles
}
roles: adminUser.roles,
},
});
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 obj = {};
for(let [key, value] of map){
for (let [key, value] of map) {
obj[key] = value;
}
return obj;
}
return obj;
};
export async function checkAction(ctx, next) {
const currentRole = ctx.state.currentRole;
@ -23,9 +23,11 @@ export async function checkAction(ctx, next) {
});
const role = ctx.app.acl.getRole(currentRole);
const availableActions = ctx.app.acl.getAvailableActions();
ctx.body = {
...role.toJSON(),
availableActions: [...availableActions.keys()],
resources: [...role.resources.keys()],
actionAlias: map2obj(ctx.app.acl.actionAlias),
allowAll: currentRole === 'root',

View File

@ -15,6 +15,7 @@ const roleCollectionsResource = {
const db: Database = ctx.db;
const collectionRepository = db.getRepository('collections');
const fieldRepository = db.getRepository('fields');
// all collections
const [collections, count] = await collectionRepository.findAndCount({
@ -35,23 +36,52 @@ const roleCollectionsResource = {
.filter((roleResources) => roleResources.get('usingActionsConfig'))
.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 = {
count,
rows: collections.map((collection) => {
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,
};
}),
rows: items,
page: Number(page),
pageSize: Number(pageSize),
totalPage: totalPage(count, pageSize),

View File

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

View File

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

View File

@ -10,6 +10,7 @@ export default extend({
target: 'roles',
foreignKey: 'userId',
otherKey: 'roleName',
onDelete: 'CASCADE',
sourceKey: 'id',
targetKey: 'name',
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) || {}),
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;
}
const fieldType = collectionField.get('interface') as string;
const fieldType = collectionField.get('type') as string;
const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction];
@ -59,8 +59,9 @@ export class RoleResourceActionModel extends Model {
if (fieldActions) {
// grant association actions to role
const associationActions = fieldActions.associationActions || [];
associationActions.forEach((associationAction) => {
const actionName = `${resourceName}.${fieldTarget}:${associationAction}`;
const actionName = `${resourceName}.${collectionField.get('name')}:${associationAction}`;
role.grantAction(actionName);
});
@ -69,6 +70,12 @@ export class RoleResourceActionModel extends Model {
targetActions.forEach((targetAction) => {
const targetActionPath = `${fieldTarget}:${targetAction}`;
const existsAction = role.getActionParams(targetActionPath);
if (existsAction) {
return;
}
// set resource target action with current resourceName
grantHelper.resourceTargetActionMap.set(`${role.name}.${resourceName}`, [
...(grantHelper.resourceTargetActionMap.get(resourceName) || []),

View File

@ -1,6 +1,8 @@
import { Context } from '@nocobase/actions';
import { Collection } from '@nocobase/database';
import { NoPermissionError } from '@nocobase/acl';
import { Context, utils as actionUtils } from '@nocobase/actions';
import { Collection, RelationField } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import lodash from 'lodash';
import { resolve } from 'path';
import { availableActionResource } from './actions/available-actions';
import { checkAction } from './actions/role-check';
@ -49,41 +51,52 @@ export class PluginACL extends Plugin {
registerAssociationFieldsActions() {
// if grant create action to role, it should
// also grant add action and association target's view action
this.registerAssociationFieldAction('linkTo', {
this.registerAssociationFieldAction('hasOne', {
view: {
associationActions: ['list', 'get'],
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['add'],
targetActions: ['view'],
associationActions: ['create', 'set'],
},
update: {
associationActions: ['add', 'remove', 'toggle'],
targetActions: ['view'],
associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('attachments', {
this.registerAssociationFieldAction('hasMany', {
view: {
associationActions: ['list', 'get'],
associationActions: ['list', 'get', 'view'],
},
add: {
associationActions: ['upload', 'add'],
create: {
associationActions: ['create', 'set', 'add'],
},
update: {
associationActions: ['update', 'add', 'remove', 'toggle'],
associationActions: ['update', 'remove', 'set'],
},
});
this.registerAssociationFieldAction('subTable', {
this.registerAssociationFieldAction('belongsTo', {
view: {
associationActions: ['list', 'get'],
associationActions: ['list', 'get', 'view'],
},
create: {
associationActions: ['create'],
associationActions: ['create', 'set'],
},
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() {
this.db.addMigrations({
namespace: this.name,
directory: resolve(__dirname, './migrations'),
context: {
plugin: this,
},
});
this.app.db.registerModels({
RoleResourceActionModel,
RoleResourceModel,
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.app.resourcer.define(availableActionResource);
@ -147,6 +210,7 @@ export class PluginACL extends Plugin {
},
transaction,
});
if (defaultRole && (await model.countRoles({ transaction })) == 0) {
await model.addRoles(defaultRole, { transaction });
}
@ -269,7 +333,7 @@ export class PluginACL extends Plugin {
// sync database role data to acl
this.app.on('afterLoad', async (app, options) => {
if (options?.method === 'install') {
if (options?.method === 'install' || options?.method === 'upgrade') {
return;
}
const exists = await this.app.db.collectionExistsInDb('roles');
@ -320,6 +384,7 @@ export class PluginACL extends Plugin {
name: 'root',
title: '{{t("Root")}}',
hidden: true,
snippets: ['ui.*', 'pm', 'pm.*'],
},
{
name: 'admin',
@ -327,6 +392,7 @@ export class PluginACL extends Plugin {
allowConfigure: true,
allowNewMenu: true,
strategy: { actions: ['create', 'view', 'update', 'destroy'] },
snippets: ['ui.*', 'pm', 'pm.*'],
},
{
name: 'member',
@ -334,6 +400,7 @@ export class PluginACL extends Plugin {
allowNewMenu: true,
strategy: { actions: ['view', 'update:own', 'destroy:own', 'create'] },
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.acl.allow('users', 'setDefaultRole', '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) => {
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) => {
const { actionName, resourceName, params } = ctx.action;
const { showAnonymous } = params || {};
@ -381,11 +476,13 @@ export class PluginACL extends Plugin {
});
}
}
if (actionName === 'update' && resourceName === 'roles.resources') {
ctx.action.mergeParams({
updateAssociationValues: ['actions'],
});
}
await next();
});
@ -405,6 +502,7 @@ export class PluginACL extends Plugin {
} else {
collection = ctx.db.getCollection(resourceName);
}
if (collection && collection.hasField('createdById')) {
ctx.permission.can.params.fields.push('createdById');
}
@ -413,37 +511,252 @@ export class PluginACL extends Plugin {
});
const parseJsonTemplate = this.app.acl.parseJsonTemplate;
this.app.acl.use(async (ctx: Context, next) => {
const { actionName, resourceName, resourceOf } = ctx.action;
if (resourceName.includes('.') && resourceOf) {
if (!ctx?.permission?.can?.params) {
return next();
}
// 关联数据去掉 filter
delete ctx.permission.can.params.filter;
// 关联数据能不能处理取决于 source 是否有权限
const [collectionName] = resourceName.split('.');
const action = ctx.can({ resource: collectionName, action: actionName });
const availableAction = this.app.acl.getAvailableAction(actionName);
if (availableAction?.options?.onNewRecord) {
if (action) {
ctx.permission.skip = true;
this.app.acl.use(
async (ctx: Context, next) => {
const { actionName, resourceName, resourceOf } = ctx.action;
// is association request
if (resourceName.includes('.') && resourceOf) {
if (!ctx?.permission?.can?.params) {
return next();
}
// 关联数据去掉 filter
delete ctx.permission.can.params.filter;
// 关联数据能不能处理取决于 source 是否有权限
const [collectionName] = resourceName.split('.');
const action = ctx.can({ resource: collectionName, action: actionName });
const availableAction = this.app.acl.getAvailableAction(actionName);
if (availableAction?.options?.onNewRecord) {
if (action) {
ctx.permission.skip = true;
} else {
ctx.permission.can = false;
}
} else {
ctx.permission.can = false;
}
} else {
const filter = parseJsonTemplate(action?.params?.filter || {}, ctx);
const sourceInstance = await ctx.db.getRepository(collectionName).findOne({
filterByTk: resourceOf,
filter,
});
if (!sourceInstance) {
ctx.permission.can = false;
const filter = parseJsonTemplate(action?.params?.filter || {}, ctx);
const sourceInstance = await ctx.db.getRepository(collectionName).findOne({
filterByTk: resourceOf,
filter,
});
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();
});
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() {

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({
directory: resolve(__dirname, 'collections'),
});
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() {
@ -63,7 +74,6 @@ export class PluginChinaRegion extends Plugin {
const count = await ChinaRegion.count();
// console.log(`${count} rows of region data imported in ${(Date.now() - timer) / 1000}s`);
}
}
export default PluginChinaRegion;

View File

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

View File

@ -26,6 +26,13 @@ export default class PluginFileManager extends Plugin {
async load() {
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 加进来
this.app.resourcer.use(uploadMiddleware);
this.app.resourcer.registerActionHandler('upload', uploadAction);
@ -34,6 +41,24 @@ export default class PluginFileManager extends Plugin {
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({
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({
'iframeHtml:getHtml': getHtml,
});
this.app.acl.allow('iframeHtml', 'getHtml', 'loggedIn');
this.app.acl.registerSnippet({
name: 'ui.iframeHtml',
actions: [
'iframeHtml:*',
],
});
}
async install(options?: InstallOptions) {}

View File

@ -42,14 +42,14 @@ const initImportSettings = (fields) => {
export const ImportActionInitializer = (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 fields = useFields(name);
const schema: ISchema = {
type: 'void',
title: '{{ t("Import") }}',
'x-action': 'import',
'x-action': 'importXlsx',
'x-action-settings': {
importSettings: { importColumns: [], explain: '' },
},

View File

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

View File

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

View File

@ -1,46 +1,49 @@
import { InstallOptions, Plugin } from '@nocobase/server';
import { CircleField, LineStringField, PointField, PolygonField } from './fields';
import { resolve } from 'path';
import { getConfiguration, setConfiguration } from './actions';
import { CircleField, LineStringField, PointField, PolygonField } from './fields';
export class MapPlugin extends Plugin {
afterAdd() { }
afterAdd() {}
beforeLoad() {
const fields = {
point: PointField,
polygon: PolygonField,
lineString: LineStringField,
circle: CircleField
circle: CircleField,
};
this.db.registerFieldTypes(fields);
}
async load() {
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
this.app.resource(({
this.app.resource({
name: 'map-configuration',
actions: {
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;

View File

@ -3,7 +3,6 @@ import { resolve } from 'path';
import { ApplicationModel } from './models/application';
export class PluginMultiAppManager extends Plugin {
async install(options?: InstallOptions) {
const repo = this.db.getRepository<any>('collections');
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'),
icon: 'FileOutlined',
tabs: {
storages: {
providers: {
title: t('OIDC Providers'),
component: OIDCPanel,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,6 +26,29 @@ export class UiSchemaStoragePlugin extends Plugin {
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) {
if (!model.get('name')) {
model.set('name', uid());
@ -61,8 +84,8 @@ export class UiSchemaStoragePlugin extends Plugin {
actions: uiSchemaActions,
});
this.app.acl.allow('uiSchemas', '*', 'loggedIn');
this.app.acl.allow('uiSchemaTemplates', '*', 'loggedIn');
this.app.acl.allow('uiSchemas', ['getProperties', 'getJsonSchema'], 'loggedIn');
this.app.acl.allow('uiSchemaTemplates', ['get', 'list'], 'loggedIn');
}
async load() {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import path from 'path';
import { Op, Transactionable } from '@nocobase/database';
import { Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
@ -10,10 +10,10 @@ import { EXECUTION_STATUS } from './constants';
import extensions from './extensions';
import initInstructions, { Instruction } from './instructions';
import ExecutionModel from './models/Execution';
import JobModel from './models/Job';
import WorkflowModel from './models/Workflow';
import Processor from './Processor';
import initTriggers, { Trigger } from './triggers';
import JobModel from './models/Job';
type Pending = [ExecutionModel, JobModel?];
@ -72,6 +72,20 @@ export default class WorkflowPlugin extends Plugin {
async load() {
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({
directory: path.resolve(__dirname, 'collections'),
});

View File

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

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