diff --git a/packages/core/acl/package.json b/packages/core/acl/package.json index c89abef3d..777c9fcf8 100644 --- a/packages/core/acl/package.json +++ b/packages/core/acl/package.json @@ -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", diff --git a/packages/core/acl/src/__tests__/acl.test.ts b/packages/core/acl/src/__tests__/acl.test.ts index 579e60919..2c71a9307 100644 --- a/packages/core/acl/src/__tests__/acl.test.ts +++ b/packages/core/acl/src/__tests__/acl.test.ts @@ -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', - }); - }); }); diff --git a/packages/core/acl/src/__tests__/allow-manager.test.ts b/packages/core/acl/src/__tests__/allow-manager.test.ts new file mode 100644 index 000000000..e26b0c85b --- /dev/null +++ b/packages/core/acl/src/__tests__/allow-manager.test.ts @@ -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(); + }); +}); diff --git a/packages/core/acl/src/__tests__/allow.test.ts b/packages/core/acl/src/__tests__/allow.test.ts index d1895b7ec..f020d9031 100644 --- a/packages/core/acl/src/__tests__/allow.test.ts +++ b/packages/core/acl/src/__tests__/allow.test.ts @@ -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, }, diff --git a/packages/core/acl/src/__tests__/fixed-params.test.ts b/packages/core/acl/src/__tests__/fixed-params.test.ts new file mode 100644 index 000000000..2576b3a93 --- /dev/null +++ b/packages/core/acl/src/__tests__/fixed-params.test.ts @@ -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', + }, + }, + }); + }); +}); diff --git a/packages/core/acl/src/__tests__/snippet.test.ts b/packages/core/acl/src/__tests__/snippet.test.ts new file mode 100644 index 000000000..8f91c55eb --- /dev/null +++ b/packages/core/acl/src/__tests__/snippet.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/acl/src/acl-available-strategy.ts b/packages/core/acl/src/acl-available-strategy.ts index 5e664d9ad..f70f8020f 100644 --- a/packages/core/acl/src/acl-available-strategy.ts +++ b/packages/core/acl/src/acl-available-strategy.ts @@ -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)); } } diff --git a/packages/core/acl/src/acl-role.ts b/packages/core/acl/src/acl-role.ts index 0b1ec76de..75908e715 100644 --- a/packages/core/acl/src/acl-role.ts +++ b/packages/core/acl/src/acl-role.ts @@ -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(); + snippets: Set = 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; rejected: Array } { + const allowedSnippets = new Set(); + const rejectedSnippets = new Set(); + + 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), }; } diff --git a/packages/core/acl/src/acl.ts b/packages/core/acl/src/acl.ts index 6df2316fc..284a863c8 100644 --- a/packages/core/acl/src/acl.ts +++ b/packages/core/acl/src/acl.ts @@ -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(); - protected availableStrategy = new Map(); + public availableStrategy = new Map(); + protected fixedParamsManager = new FixedParamsManager(); + protected middlewares: Toposort; public allowManager = new AllowManager(this); + public snippetManager = new SnippetManager(); roles = new Map(); @@ -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) => { 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) => { + 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); + } } diff --git a/packages/core/acl/src/allow-manager.ts b/packages/core/acl/src/allow-manager.ts index 9d5998e47..ddf514133 100644 --- a/packages/core/acl/src/allow-manager.ts +++ b/packages/core/acl/src/allow-manager.ts @@ -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(); }; } diff --git a/packages/core/acl/src/fixed-params-manager.ts b/packages/core/acl/src/fixed-params-manager.ts new file mode 100644 index 000000000..169363e3b --- /dev/null +++ b/packages/core/acl/src/fixed-params-manager.ts @@ -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>(); + + 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', + }); + } +} diff --git a/packages/core/acl/src/index.ts b/packages/core/acl/src/index.ts index 391b93064..5ae743acd 100644 --- a/packages/core/acl/src/index.ts +++ b/packages/core/acl/src/index.ts @@ -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'; + diff --git a/packages/core/acl/src/no-permission-error.ts b/packages/core/acl/src/no-permission-error.ts new file mode 100644 index 000000000..74e1a188d --- /dev/null +++ b/packages/core/acl/src/no-permission-error.ts @@ -0,0 +1,7 @@ +class NoPermissionError extends Error { + constructor(...args) { + super(...args); + } +} + +export { NoPermissionError }; diff --git a/packages/core/acl/src/snippet-manager.ts b/packages/core/acl/src/snippet-manager.ts new file mode 100644 index 000000000..d69168773 --- /dev/null +++ b/packages/core/acl/src/snippet-manager.ts @@ -0,0 +1,45 @@ +import { ACL } from './acl'; +import minimatch from 'minimatch'; + +export type SnippetOptions = { + name: string; + actions: Array; +}; + +class Snippet { + constructor(public name: string, public actions: Array) {} +} + +export type SnippetGroup = { + name: string; + snippets: SnippetOptions[]; +}; + +class SnippetManager { + public snippets: Map = 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; diff --git a/packages/core/actions/src/__tests__/destroy-action.test.ts b/packages/core/actions/src/__tests__/destroy-action.test.ts index 2042bdc97..3a910817f 100644 --- a/packages/core/actions/src/__tests__/destroy-action.test.ts +++ b/packages/core/actions/src/__tests__/destroy-action.test.ts @@ -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; diff --git a/packages/core/actions/src/__tests__/list-action.test.ts b/packages/core/actions/src/__tests__/list-action.test.ts index 0f0932cf2..10a3e1171 100644 --- a/packages/core/actions/src/__tests__/list-action.test.ts +++ b/packages/core/actions/src/__tests__/list-action.test.ts @@ -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({ diff --git a/packages/core/actions/src/actions/destroy.ts b/packages/core/actions/src/actions/destroy.ts index 1abf86ced..cbeae5c1f 100644 --- a/packages/core/actions/src/actions/destroy.ts +++ b/packages/core/actions/src/actions/destroy.ts @@ -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({ diff --git a/packages/core/actions/src/actions/list.ts b/packages/core/actions/src/actions/list.ts index f9e76b477..0ee7e3b19 100644 --- a/packages/core/actions/src/actions/list.ts +++ b/packages/core/actions/src/actions/list.ts @@ -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(); diff --git a/packages/core/client/src/acl/ACLProvider.tsx b/packages/core/client/src/acl/ACLProvider.tsx index f7d822623..20c71c6ff 100644 --- a/packages/core/client/src/acl/ACLProvider.tsx +++ b/packages/core/client/src/acl/ACLProvider.tsx @@ -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({}); 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({}); +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({}); +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 {props.children}; }; +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 {props.children}; }; +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']) { diff --git a/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx new file mode 100644 index 000000000..a27c49029 --- /dev/null +++ b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx @@ -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 {props.children}; +}; + +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 && ( + { + 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 && handleChange(checked, record)} />; + }, + }, + ]} + dataSource={items} + /> + ) + ); +}; diff --git a/packages/core/client/src/acl/Configuration/MenuConfigure.tsx b/packages/core/client/src/acl/Configuration/MenuConfigure.tsx index 903759964..6ef8fbf89 100644 --- a/packages/core/client/src/acl/Configuration/MenuConfigure.tsx +++ b/packages/core/client/src/acl/Configuration/MenuConfigure.tsx @@ -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) => { diff --git a/packages/core/client/src/acl/Configuration/PermisionProvider.tsx b/packages/core/client/src/acl/Configuration/PermisionProvider.tsx new file mode 100644 index 000000000..60769f254 --- /dev/null +++ b/packages/core/client/src/acl/Configuration/PermisionProvider.tsx @@ -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
{props.children}
; +}; + +export const PermissionContext = createContext(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 ( + { + 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} + + ); +}; diff --git a/packages/core/client/src/acl/Configuration/RoleConfigure.tsx b/packages/core/client/src/acl/Configuration/RoleConfigure.tsx index 548daaf77..bbb7117b4 100644 --- a/packages/core/client/src/acl/Configuration/RoleConfigure.tsx +++ b/packages/core/client/src/acl/Configuration/RoleConfigure.tsx @@ -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 ( + { + 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); + }} + > +
+ {t('Allows to configure interface')} +
+
+ {t('Allows to install, activate, disable plugins')} +
+
+ {t('Allows to configure plugins')} +
+
+ ); +}); export const RoleConfigure = () => { - const api = useAPIClient(); - const record = useRecord(); + const { update, currentRecord } = useContext(PermissionContext); const { t } = useTranslation(); return ( { + 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', }, diff --git a/packages/core/client/src/acl/Configuration/RoleTable.tsx b/packages/core/client/src/acl/Configuration/RoleTable.tsx index 81457d772..037c5366b 100644 --- a/packages/core/client/src/acl/Configuration/RoleTable.tsx +++ b/packages/core/client/src/acl/Configuration/RoleTable.tsx @@ -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 (
- +
); diff --git a/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx b/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx index 7e36f9ff5..053de33bd 100644 --- a/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx +++ b/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx @@ -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(); diff --git a/packages/core/client/src/acl/Configuration/index.tsx b/packages/core/client/src/acl/Configuration/index.tsx index d9be8947d..e036b370f 100644 --- a/packages/core/client/src/acl/Configuration/index.tsx +++ b/packages/core/client/src/acl/Configuration/index.tsx @@ -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'; diff --git a/packages/core/client/src/acl/Configuration/schemas/roleCollections.ts b/packages/core/client/src/acl/Configuration/schemas/roleCollections.ts index 5c6484dcf..ddf4ca098 100644 --- a/packages/core/client/src/acl/Configuration/schemas/roleCollections.ts +++ b/packages/core/client/src/acl/Configuration/schemas/roleCollections.ts @@ -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', diff --git a/packages/core/client/src/acl/Configuration/schemas/roles.ts b/packages/core/client/src/acl/Configuration/schemas/roles.ts index ffd6eca0a..16d84baec 100644 --- a/packages/core/client/src/acl/Configuration/schemas/roles.ts +++ b/packages/core/client/src/acl/Configuration/schemas/roles.ts @@ -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: { diff --git a/packages/core/client/src/acl/Configuration/schemas/scopes.ts b/packages/core/client/src/acl/Configuration/schemas/scopes.ts index f8fbcfc26..95a7852dd 100644 --- a/packages/core/client/src/acl/Configuration/schemas/scopes.ts +++ b/packages/core/client/src/acl/Configuration/schemas/scopes.ts @@ -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': { diff --git a/packages/core/client/src/acl/index.tsx b/packages/core/client/src/acl/index.tsx index 7a540754a..2f457af1d 100644 --- a/packages/core/client/src/acl/index.tsx +++ b/packages/core/client/src/acl/index.tsx @@ -1,3 +1,4 @@ export * from './ACLProvider'; export * from './ACLShortcut'; +import './style.less'; diff --git a/packages/core/client/src/acl/style.less b/packages/core/client/src/acl/style.less new file mode 100644 index 000000000..bf8d58f1a --- /dev/null +++ b/packages/core/client/src/acl/style.less @@ -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; + } + } +} diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index 5423f500b..19a5a7b91 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -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; }, diff --git a/packages/core/client/src/block-provider/KanbanBlockProvider.tsx b/packages/core/client/src/block-provider/KanbanBlockProvider.tsx index 4234560d5..74d738f36 100644 --- a/packages/core/client/src/block-provider/KanbanBlockProvider.tsx +++ b/packages/core/client/src/block-provider/KanbanBlockProvider.tsx @@ -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; }; diff --git a/packages/core/client/src/collection-manager/hooks/useCollection.ts b/packages/core/client/src/collection-manager/hooks/useCollection.ts index bc0c7a328..ae4532c7c 100644 --- a/packages/core/client/src/collection-manager/hooks/useCollection.ts +++ b/packages/core/client/src/collection-manager/hooks/useCollection.ts @@ -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, }; }; diff --git a/packages/core/client/src/collection-manager/interfaces/o2m.tsx b/packages/core/client/src/collection-manager/interfaces/o2m.tsx index 32edb18ac..5d2a0ef9b 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2m.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2m.tsx @@ -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, diff --git a/packages/core/client/src/collection-manager/interfaces/subTable.ts b/packages/core/client/src/collection-manager/interfaces/subTable.ts index da3bfe95b..502bb1450 100644 --- a/packages/core/client/src/collection-manager/interfaces/subTable.ts +++ b/packages/core/client/src/collection-manager/interfaces/subTable.ts @@ -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, diff --git a/packages/core/client/src/collection-manager/templates/calendar.tsx b/packages/core/client/src/collection-manager/templates/calendar.tsx index 8ae68baa4..92bf0977e 100644 --- a/packages/core/client/src/collection-manager/templates/calendar.tsx +++ b/packages/core/client/src/collection-manager/templates/calendar.tsx @@ -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', diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 7913d294a..e0a90ed0e 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -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", diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts index 510d27144..ec9da84a1 100644 --- a/packages/core/client/src/locale/ja_JP.ts +++ b/packages/core/client/src/locale/ja_JP.ts @@ -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": "レコードスコープ", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 7c6671bb7..8d85b7120 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -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': '插件标签页', } diff --git a/packages/core/client/src/plugin-manager/PluginManager.tsx b/packages/core/client/src/plugin-manager/PluginManager.tsx index 041543ba2..c149c4cd4 100644 --- a/packages/core/client/src/plugin-manager/PluginManager.tsx +++ b/packages/core/client/src/plugin-manager/PluginManager.tsx @@ -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 ; - // } + 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 ; + return v.isAllow)} />; }; diff --git a/packages/core/client/src/pm/PluginManagerLink.tsx b/packages/core/client/src/pm/PluginManagerLink.tsx index 1f3f214f0..60b0e25c5 100644 --- a/packages/core/client/src/pm/PluginManagerLink.tsx +++ b/packages/core/client/src/pm/PluginManagerLink.tsx @@ -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 ( - {items.map((item) => { + {bookmarkTabs.map((tab) => { return ( { - history.push('/admin/settings/' + item.path); + history.push('/admin/settings/' + tab.path); }} - key={item.path} + key={tab.path} > - {item.title} + {compile(tab.title)} ); })} diff --git a/packages/core/client/src/pm/index.tsx b/packages/core/client/src/pm/index.tsx index 41f6d186c..fe7057028 100644 --- a/packages/core/client/src/pm/index.tsx +++ b/packages/core/client/src/pm/index.tsx @@ -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 ; + return ; })} ); @@ -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') ? (
{ )}
+ ) : ( + ); }; @@ -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(); const history = useHistory(); 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 ; } @@ -286,78 +320,86 @@ const SettingsCenter = (props) => { return ; } 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 ? : null, + label: compile(plugin.title), + key: plugin.key, + icon: plugin.icon ? : null, }; }); return ( - -
- } - 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; - `} - >
- - { - const item = items[e.key]; - const tabKey = Object.keys(item.tabs).shift(); - history.push(`/admin/settings/${e.key}/${tabKey}`); - }} - items={menuItems as any} - /> - - - { - history.push(`/admin/settings/${pluginName}/${activeKey}`); - }} - > - {Object.keys(items[pluginName]?.tabs).map((tabKey) => { - const tab = items[pluginName].tabs?.[tabKey]; - return ; - })} - +
+ +
} - /> -
{component && React.createElement(component)}
- - + 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; + `} + >
+ + { + const item = items[e.key]; + const tabKey = Object.keys(item.tabs).shift(); + history.push(`/admin/settings/${e.key}/${tabKey}`); + }} + items={menuItems as any} + /> + + + {aclPluginTabCheck && ( + { + history.push(`/admin/settings/${pluginName}/${activeKey}`); + }} + > + {plugin.tabs?.map((tab) => { + return tab.isAllow && ; + })} + + } + /> + )} +
+ {aclPluginTabCheck ? ( + component && React.createElement(component) + ) : ( + + )} +
+
+ +
); }; diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index 91607fdf1..e03f681bd 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -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(); const [sideMenuWidth, setSideMenuWidth] = useState(0); @@ -192,9 +191,7 @@ const InternalAdminLayout = (props: any) => {
- - - +
diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 6283f668b..691370db6 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -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 = () => ( } - 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} diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx index e6132b5f5..8c412c06a 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx @@ -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 ( - - + + '), + }} + /> + ) : ( + field.description + ) } - `}`} - {...props} - extra={ - typeof field.description === 'string' ? ( -
'), - }} - /> - ) : ( - field.description - ) - } - /> - + /> + + ); }; diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx index 41a8e13ee..c2cbc9675 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx @@ -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(); 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 ( - + - + ); diff --git a/packages/core/client/src/schema-component/antd/table/Table.Array.tsx b/packages/core/client/src/schema-component/antd/table/Table.Array.tsx index 8dc86ceca..0d22ea16d 100644 --- a/packages/core/client/src/schema-component/antd/table/Table.Array.tsx +++ b/packages/core/client/src/schema-component/antd/table/Table.Array.tsx @@ -35,7 +35,7 @@ const useTableColumns = () => { return ( - + ); diff --git a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx index eb0cda14a..18b686467 100644 --- a/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/ReadPrettyFormActionInitializers.tsx @@ -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: {}, diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx index 548a0f669..9acde7233 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionColumnInitializers.tsx @@ -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': { diff --git a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx index 4d500bc7d..feef2aaa6 100644 --- a/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx +++ b/packages/core/client/src/schema-initializer/buttons/TableActionInitializers.tsx @@ -1,4 +1,4 @@ -import { ISchema, Schema } from '@formily/react'; +import { Schema } from '@formily/react'; // 表格操作配置 export const TableActionInitializers = { diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 28e2831a1..4c6ca59d1 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -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; }; diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index 7217ec5db..b8525c07b 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -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(name: string, relationId: string | number): R; getRepository(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; } diff --git a/packages/core/database/src/repository.ts b/packages/core/database/src/repository.ts index eb187bd9f..5582652e5 100644 --- a/packages/core/database/src/repository.ts +++ b/packages/core/database/src/repository.ts @@ -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 { } of(id: string | number): R { + if (!this.association) { + return; + } const klass = this.builder()[this.association.associationType]; return new klass(this.collection, this.associationName, id); } diff --git a/packages/core/server/src/acl/index.ts b/packages/core/server/src/acl/index.ts index 5e48bd5ef..a97cc2805 100644 --- a/packages/core/server/src/acl/index.ts +++ b/packages/core/server/src/acl/index.ts @@ -3,7 +3,10 @@ import { availableActions } from './available-action'; const configureResources = [ 'roles', + 'users', 'collections', + 'fields', + 'collections.fields', 'roles.collections', 'roles.resources', 'rolesResourcesScopes', diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index e1d525da0..f962b6623 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -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; } diff --git a/packages/core/server/src/middlewares/data-wrapping.ts b/packages/core/server/src/middlewares/data-wrapping.ts index 8d956344e..a7df9cab9 100644 --- a/packages/core/server/src/middlewares/data-wrapping.ts +++ b/packages/core/server/src/middlewares/data-wrapping.ts @@ -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 = { diff --git a/packages/core/server/src/middlewares/db2resource.ts b/packages/core/server/src/middlewares/db2resource.ts index f5d7d6d4e..6bd725b38 100644 --- a/packages/core/server/src/middlewares/db2resource.ts +++ b/packages/core/server/src/middlewares/db2resource.ts @@ -17,6 +17,7 @@ export function db2resource(ctx: ResourcerContext & { db: Database }, next: () = if (!params) { return next(); } + const resourceName = getNameByParams(params); // 如果资源名称未被定义 if (resourcer.isDefined(resourceName)) { diff --git a/packages/core/server/src/plugin-manager/PluginManager.ts b/packages/core/server/src/plugin-manager/PluginManager.ts index dfa12585b..559a2ba59 100644 --- a/packages/core/server/src/plugin-manager/PluginManager.ts +++ b/packages/core/server/src/plugin-manager/PluginManager.ts @@ -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; } diff --git a/packages/core/server/src/plugin.ts b/packages/core/server/src/plugin.ts index 83e0e71f6..1f74d5de9 100644 --- a/packages/core/server/src/plugin.ts +++ b/packages/core/server/src/plugin.ts @@ -36,6 +36,10 @@ export abstract class Plugin implements PluginInterface { this.afterAdd(); } + get name() { + return this.options.name as string; + } + get db() { return this.app.db; } diff --git a/packages/plugins/acl/src/__tests__/acl.test.ts b/packages/plugins/acl/src/__tests__/acl.test.ts index 0f28b693a..cb8de2908 100644 --- a/packages/plugins/acl/src/__tests__/acl.test.ts +++ b/packages/plugins/acl/src/__tests__/acl.test.ts @@ -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(); + }); }); diff --git a/packages/plugins/acl/src/__tests__/actions.test.ts b/packages/plugins/acl/src/__tests__/actions.test.ts new file mode 100644 index 000000000..ebff31329 --- /dev/null +++ b/packages/plugins/acl/src/__tests__/actions.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/acl/src/__tests__/association-field.test.ts b/packages/plugins/acl/src/__tests__/association-field.test.ts index 4cb35277f..95c579c81 100644 --- a/packages/plugins/acl/src/__tests__/association-field.test.ts +++ b/packages/plugins/acl/src/__tests__/association-field.test.ts @@ -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', diff --git a/packages/plugins/acl/src/__tests__/configuration.test.ts b/packages/plugins/acl/src/__tests__/configuration.test.ts index 0555164be..eea160357 100644 --- a/packages/plugins/acl/src/__tests__/configuration.test.ts +++ b/packages/plugins/acl/src/__tests__/configuration.test.ts @@ -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(); }); diff --git a/packages/plugins/acl/src/__tests__/list-action.test.ts b/packages/plugins/acl/src/__tests__/list-action.test.ts new file mode 100644 index 000000000..cd65c4dcf --- /dev/null +++ b/packages/plugins/acl/src/__tests__/list-action.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/acl/src/__tests__/role.test.ts b/packages/plugins/acl/src/__tests__/role.test.ts index 2d549c3b8..c62df40e4 100644 --- a/packages/plugins/acl/src/__tests__/role.test.ts +++ b/packages/plugins/acl/src/__tests__/role.test.ts @@ -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('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']); + }); }); diff --git a/packages/plugins/acl/src/__tests__/snippets.test.ts b/packages/plugins/acl/src/__tests__/snippets.test.ts new file mode 100644 index 000000000..e92f4af8e --- /dev/null +++ b/packages/plugins/acl/src/__tests__/snippets.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/acl/src/__tests__/users.test.ts b/packages/plugins/acl/src/__tests__/users.test.ts index d5db0d5c3..c09f10276 100644 --- a/packages/plugins/acl/src/__tests__/users.test.ts +++ b/packages/plugins/acl/src/__tests__/users.test.ts @@ -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); + }); }); diff --git a/packages/plugins/acl/src/actions/role-check.ts b/packages/plugins/acl/src/actions/role-check.ts index bbbc05712..ea06dd688 100644 --- a/packages/plugins/acl/src/actions/role-check.ts +++ b/packages/plugins/acl/src/actions/role-check.ts @@ -1,10 +1,10 @@ const map2obj = (map: Map) => { 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', diff --git a/packages/plugins/acl/src/actions/role-collections.ts b/packages/plugins/acl/src/actions/role-collections.ts index e1d9ca2b4..a6c4356bb 100644 --- a/packages/plugins/acl/src/actions/role-collections.ts +++ b/packages/plugins/acl/src/actions/role-collections.ts @@ -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), diff --git a/packages/plugins/acl/src/collections/roles.ts b/packages/plugins/acl/src/collections/roles.ts index 289e7fb1b..8a58a3086 100644 --- a/packages/plugins/acl/src/collections/roles.ts +++ b/packages/plugins/acl/src/collections/roles.ts @@ -75,5 +75,10 @@ export default { sourceKey: 'name', targetKey: 'name', }, + { + type: 'set', + name: 'snippets', + defaultValue: ['!ui.*', '!pm', '!pm.*'], + }, ], } as CollectionOptions; diff --git a/packages/plugins/acl/src/collections/rolesResourcesActions.ts b/packages/plugins/acl/src/collections/rolesResourcesActions.ts index 8f854dada..18dcd8631 100644 --- a/packages/plugins/acl/src/collections/rolesResourcesActions.ts +++ b/packages/plugins/acl/src/collections/rolesResourcesActions.ts @@ -23,6 +23,7 @@ export default { type: 'belongsTo', name: 'scope', target: 'rolesResourcesScopes', + onDelete: 'RESTRICT', }, ], } as CollectionOptions; diff --git a/packages/plugins/acl/src/collections/users.ts b/packages/plugins/acl/src/collections/users.ts index b85f574dd..5d201d0ec 100644 --- a/packages/plugins/acl/src/collections/users.ts +++ b/packages/plugins/acl/src/collections/users.ts @@ -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({ }, }, }, - } + }, ], }); diff --git a/packages/plugins/acl/src/migrations/20221214072638-set-role-snippets.ts b/packages/plugins/acl/src/migrations/20221214072638-set-role-snippets.ts new file mode 100644 index 000000000..de82169e9 --- /dev/null +++ b/packages/plugins/acl/src/migrations/20221214072638-set-role-snippets.ts @@ -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() {} +} diff --git a/packages/plugins/acl/src/model/RoleModel.ts b/packages/plugins/acl/src/model/RoleModel.ts index 4d3ed358e..bdd7d53b6 100644 --- a/packages/plugins/acl/src/model/RoleModel.ts +++ b/packages/plugins/acl/src/model/RoleModel.ts @@ -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')); } } diff --git a/packages/plugins/acl/src/model/RoleResourceActionModel.ts b/packages/plugins/acl/src/model/RoleResourceActionModel.ts index 1eec88930..c7925a35f 100644 --- a/packages/plugins/acl/src/model/RoleResourceActionModel.ts +++ b/packages/plugins/acl/src/model/RoleResourceActionModel.ts @@ -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) || []), diff --git a/packages/plugins/acl/src/server.ts b/packages/plugins/acl/src/server.ts index 1af5633e2..c083cb064 100644 --- a/packages/plugins/acl/src/server.ts +++ b/packages/plugins/acl/src/server.ts @@ -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() { diff --git a/packages/plugins/china-region/src/server/__tests__/action.test.ts b/packages/plugins/china-region/src/server/__tests__/action.test.ts new file mode 100644 index 000000000..2b64bf109 --- /dev/null +++ b/packages/plugins/china-region/src/server/__tests__/action.test.ts @@ -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); + }); +}); diff --git a/packages/plugins/china-region/src/server/index.ts b/packages/plugins/china-region/src/server/index.ts index 2972934eb..c62bff1f9 100644 --- a/packages/plugins/china-region/src/server/index.ts +++ b/packages/plugins/china-region/src/server/index.ts @@ -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; diff --git a/packages/plugins/collection-manager/src/server.ts b/packages/plugins/collection-manager/src/server.ts index fd57dacc3..fd0796918 100644 --- a/packages/plugins/collection-manager/src/server.ts +++ b/packages/plugins/collection-manager/src/server.ts @@ -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() { diff --git a/packages/plugins/file-manager/src/server/server.ts b/packages/plugins/file-manager/src/server/server.ts index 30d53bf2a..c070f52be 100644 --- a/packages/plugins/file-manager/src/server/server.ts +++ b/packages/plugins/file-manager/src/server/server.ts @@ -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); } } diff --git a/packages/plugins/iframe-block/src/server/plugin.ts b/packages/plugins/iframe-block/src/server/plugin.ts index 866ec25d2..f1b7c0ea1 100644 --- a/packages/plugins/iframe-block/src/server/plugin.ts +++ b/packages/plugins/iframe-block/src/server/plugin.ts @@ -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) {} diff --git a/packages/plugins/import/src/client/ImportActionInitializer.tsx b/packages/plugins/import/src/client/ImportActionInitializer.tsx index 0653776f6..fc5cce98c 100644 --- a/packages/plugins/import/src/client/ImportActionInitializer.tsx +++ b/packages/plugins/import/src/client/ImportActionInitializer.tsx @@ -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: '' }, }, diff --git a/packages/plugins/import/src/client/useImportAction.ts b/packages/plugins/import/src/client/useImportAction.ts index 92f3f9b35..95894073b 100644 --- a/packages/plugins/import/src/client/useImportAction.ts +++ b/packages/plugins/import/src/client/useImportAction.ts @@ -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 }; diff --git a/packages/plugins/import/src/server/index.ts b/packages/plugins/import/src/server/index.ts index 2f49415de..0f455be11 100644 --- a/packages/plugins/import/src/server/index.ts +++ b/packages/plugins/import/src/server/index.ts @@ -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) { diff --git a/packages/plugins/map/src/server/plugin.ts b/packages/plugins/map/src/server/plugin.ts index 9b3eeb1dd..d0956b327 100644 --- a/packages/plugins/map/src/server/plugin.ts +++ b/packages/plugins/map/src/server/plugin.ts @@ -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; diff --git a/packages/plugins/multi-app-manager/src/server.ts b/packages/plugins/multi-app-manager/src/server.ts index 7d6cf9a8e..7017bb14f 100644 --- a/packages/plugins/multi-app-manager/src/server.ts +++ b/packages/plugins/multi-app-manager/src/server.ts @@ -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('collections'); if (repo) { @@ -46,5 +45,12 @@ export class PluginMultiAppManager extends Plugin { } }, ); + + this.app.acl.registerSnippet({ + name: `pm.${this.name}.applications`, + actions: [ + 'applications:*', + ], + }); } } diff --git a/packages/plugins/oidc/src/client/index.tsx b/packages/plugins/oidc/src/client/index.tsx index e87e488a1..9972f795b 100644 --- a/packages/plugins/oidc/src/client/index.tsx +++ b/packages/plugins/oidc/src/client/index.tsx @@ -15,7 +15,7 @@ export default function (props) { title: t('OIDC manager'), icon: 'FileOutlined', tabs: { - storages: { + providers: { title: t('OIDC Providers'), component: OIDCPanel, }, diff --git a/packages/plugins/oidc/src/server/plugin.ts b/packages/plugins/oidc/src/server/plugin.ts index f508ae828..21384b5c4 100644 --- a/packages/plugins/oidc/src/server/plugin.ts +++ b/packages/plugins/oidc/src/server/plugin.ts @@ -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) {} diff --git a/packages/plugins/saml/src/client/index.tsx b/packages/plugins/saml/src/client/index.tsx index 4fcb19fee..cb26ebfc4 100644 --- a/packages/plugins/saml/src/client/index.tsx +++ b/packages/plugins/saml/src/client/index.tsx @@ -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, }, diff --git a/packages/plugins/saml/src/server/plugin.ts b/packages/plugins/saml/src/server/plugin.ts index 685b64e4c..fa6860a57 100644 --- a/packages/plugins/saml/src/server/plugin.ts +++ b/packages/plugins/saml/src/server/plugin.ts @@ -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) {} diff --git a/packages/plugins/system-settings/src/server.ts b/packages/plugins/system-settings/src/server.ts index a473c1696..a03e3f1cf 100644 --- a/packages/plugins/system-settings/src/server.ts +++ b/packages/plugins/system-settings/src/server.ts @@ -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'); } } diff --git a/packages/plugins/ui-routes-storage/src/server.ts b/packages/plugins/ui-routes-storage/src/server.ts index c83e065e8..348a6e7a0 100644 --- a/packages/plugins/ui-routes-storage/src/server.ts +++ b/packages/plugins/ui-routes-storage/src/server.ts @@ -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'); } } diff --git a/packages/plugins/ui-schema-storage/src/server.ts b/packages/plugins/ui-schema-storage/src/server.ts index 4c9c0665e..617bb8a08 100644 --- a/packages/plugins/ui-schema-storage/src/server.ts +++ b/packages/plugins/ui-schema-storage/src/server.ts @@ -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() { diff --git a/packages/plugins/users/src/__tests__/actions.test.ts b/packages/plugins/users/src/__tests__/actions.test.ts index 594871621..8fc60773b 100644 --- a/packages/plugins/users/src/__tests__/actions.test.ts +++ b/packages/plugins/users/src/__tests__/actions.test.ts @@ -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); }); diff --git a/packages/plugins/users/src/server.ts b/packages/plugins/users/src/server.ts index 08abb8c41..008c57f65 100644 --- a/packages/plugins/users/src/server.ts +++ b/packages/plugins/users/src/server.ts @@ -92,6 +92,22 @@ export default class UsersPlugin extends Plugin { 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']; diff --git a/packages/plugins/verification/src/server/Plugin.ts b/packages/plugins/verification/src/server/Plugin.ts index d8164b8de..f5baac1df 100644 --- a/packages/plugins/verification/src/server/Plugin.ts +++ b/packages/plugins/verification/src/server/Plugin.ts @@ -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() { diff --git a/packages/plugins/workflow/src/client/WorkflowProvider.tsx b/packages/plugins/workflow/src/client/WorkflowProvider.tsx index 8e36205fc..c5e680db5 100644 --- a/packages/plugins/workflow/src/client/WorkflowProvider.tsx +++ b/packages/plugins/workflow/src/client/WorkflowProvider.tsx @@ -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 ( { title: lang('Workflow'), tabs: { workflows: { + isBookmark: true, title: lang('Workflow'), component: WorkflowPane, }, @@ -70,10 +74,10 @@ export const WorkflowProvider = (props) => { }, }} > - - - {props.children} - + + {props.children} diff --git a/packages/plugins/workflow/src/server/Plugin.ts b/packages/plugins/workflow/src/server/Plugin.ts index bd48ba707..0364126cc 100644 --- a/packages/plugins/workflow/src/server/Plugin.ts +++ b/packages/plugins/workflow/src/server/Plugin.ts @@ -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'), }); diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index f76d86b89..64f55f2cd 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -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(), diff --git a/yarn.lock b/yarn.lock index c27c0ad66..d9571a30f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16106,6 +16106,13 @@ minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" +minimatch@^5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.2.tgz#0939d7d6f0898acbd1508abe534d1929368a8fff" + integrity sha512-bNH9mmM9qsJ2X4r2Nat1B//1dJVcn3+iBLa3IgqJ7EbGaDNepL9QSHOxN4ng33s52VMMhhIfgCYDk3C4ZmlDAg== + dependencies: + brace-expansion "^2.0.1" + minimist-options@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"