mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52:20 +08:00
feat: support user role union (#6301)
* feat: support merge role function * feat: test * fix: snippets merge bug * feat: support with-acl-meta allowedActions * feat: support mobileRoutes role union * feat: support union role of data source manager plugin * fix: merge action fields * fix: merge action * chore: code clean * fix: perform a deep clone of the object in the toJSON method * refactor: mergeRole code migration * fix: desktopRoutes test * fix: build * refactor: optimze acl role code and add test * fix: bug * fix: skip test * fix: acl role action whitelist invalid * fix: actions bug * chore: merge develop * chore: desktop routes code * fix: test * feat: support set system role mode * fix: test * fix: snippets bug * chore: update text * fix: test * fix: test * fix: text * fix: text * refactor: optimze code * refactor: optimze code * refactor: optimze code * refactor: optimze code --------- Co-authored-by: 霍世杰 <huoshijie@huoshijiedeMacBook-Pro.local> Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
62f10d9c97
commit
d7821bf6d7
551
packages/core/acl/src/__tests__/acl-role.test.ts
Normal file
551
packages/core/acl/src/__tests__/acl-role.test.ts
Normal file
@ -0,0 +1,551 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { ACL } from '..';
|
||||
describe('multiple roles merge', () => {
|
||||
let acl: ACL;
|
||||
beforeEach(() => {
|
||||
acl = new ACL();
|
||||
});
|
||||
describe('filter merge', () => {
|
||||
test('should allow all(params:{}) when filter1 = undefined, filter2 is not exists', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params:{}) when filter1 = undefined, filter2 = {}', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params={}) when filter1 = {}, filter2 = {}', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should union filter(params.filter={$or:[{id:1}, {id:2}]}) when filter1 = {id: 1}, filter2 = {id: 2}', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { id: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { id: 2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {
|
||||
filter: {
|
||||
$or: expect.arrayContaining([{ id: 1 }, { id: 2 }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should union filter(filter={$or:[{id:1}, {name: zhangsan}]}) when filter1 = {id: 1}, filter2 = {name: zhangsan}', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { id: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { name: 'zhangsan' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {
|
||||
filter: {
|
||||
$or: expect.arrayContaining([{ id: 1 }, { name: 'zhangsan' }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should union filter(filter={$or:[{id:1}, {name: zhangsan}]}) when filter1 = {id: 1}, filter2 = { $or: [{name: zhangsan}]', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { id: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { name: 'zhangsan' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {
|
||||
filter: {
|
||||
$or: expect.arrayContaining([{ id: 1 }, { name: 'zhangsan' }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('feilds merge', () => {
|
||||
test('should allow all(params={}) when fields1 = undefined, fields2 is not exists', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params={}) when fields1 = undefined, fields2 is not exists', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params={}) when fields1 = [], fields2 =[]', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should union fields(params={ fields: [a,b]}) when fields1 = [a], fields2 =[b]', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {
|
||||
fields: expect.arrayContaining(['a', 'b']),
|
||||
},
|
||||
});
|
||||
});
|
||||
test('should union no repeat fields(params={ fields: [a,b,c]}) when fields1 = [a,b], fields2 =[b,c]', () => {
|
||||
acl.setAvailableAction('edit', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['b', 'c'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {
|
||||
fields: expect.arrayContaining(['a', 'b', 'c']),
|
||||
},
|
||||
});
|
||||
expect(canResult.params.fields.length).toStrictEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitelist', () => {
|
||||
test('should union whitelist(params={ fields: [a,b,c]}) when fields1 = [a,b], fields2 =[c]', () => {
|
||||
acl.setAvailableAction('update');
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:update': {
|
||||
whitelist: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:update': {
|
||||
whitelist: ['c'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ resource: 'posts', action: 'update', roles: ['role1', 'role2'] });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'update',
|
||||
params: {
|
||||
whitelist: expect.arrayContaining(['a', 'b', 'c']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('appends', () => {
|
||||
test('should union appends(params={ appends: [a,b,c]}) when appends = [a,b], appends =[c]', () => {
|
||||
acl.setAvailableAction('update');
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:update': {
|
||||
appends: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:update': {
|
||||
appends: ['c'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ resource: 'posts', action: 'update', roles: ['role1', 'role2'] });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'update',
|
||||
params: {
|
||||
appends: expect.arrayContaining(['a', 'b', 'c']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter & fields merge', () => {
|
||||
test('should allow all(params={}) when actions1 = {filter: {}}, actions2 = {fields: []}', () => {
|
||||
acl.setAvailableAction('view', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params={}) when actions1 = {filter: {}}, actions2 = {fields: [a]}', () => {
|
||||
acl.setAvailableAction('view', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
test('should allow all(params={}) when actions1 = {filter: {a:1}}, actions2 = {fields: []}', () => {
|
||||
acl.setAvailableAction('view', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { a: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow all(params={}) when actions1 = {filter: {a:1}}, actions2 = {fields: [a]}', () => {
|
||||
acl.setAvailableAction('view', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { a: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
fields: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('should union filter&fields(params={ filter:{ $or:[{a:1},{a:2}]}, fields:[a,b]}) when actions1={filter:{a:1}, fields:[a]}, actions2={filter: {a:1}},fields:[b]}', () => {
|
||||
acl.setAvailableAction('view', {
|
||||
type: 'old-data',
|
||||
});
|
||||
acl.define({
|
||||
role: 'role1',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { a: 1 },
|
||||
fields: ['a'],
|
||||
},
|
||||
},
|
||||
});
|
||||
acl.define({
|
||||
role: 'role2',
|
||||
actions: {
|
||||
'posts:view': {
|
||||
filter: { a: 2 },
|
||||
fields: ['b'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' });
|
||||
expect(canResult).toStrictEqual({
|
||||
role: 'role1',
|
||||
resource: 'posts',
|
||||
action: 'view',
|
||||
params: expect.objectContaining({
|
||||
filter: { $or: expect.arrayContaining([{ a: 1 }, { a: 2 }]) },
|
||||
fields: expect.arrayContaining(['a', 'b']),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -7,11 +7,11 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { default as _, default as lodash } from 'lodash';
|
||||
import minimatch from 'minimatch';
|
||||
import { ACL, DefineOptions } from './acl';
|
||||
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[];
|
||||
@ -185,12 +185,12 @@ export class ACLRole {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return _.cloneDeep({
|
||||
role: this.name,
|
||||
strategy: this.strategy,
|
||||
actions,
|
||||
snippets: Array.from(this.snippets),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected getResourceActionFromPath(path: string) {
|
||||
|
@ -19,6 +19,7 @@ import { AllowManager, ConditionFunc } from './allow-manager';
|
||||
import FixedParamsManager, { Merger } from './fixed-params-manager';
|
||||
import SnippetManager, { SnippetOptions } from './snippet-manager';
|
||||
import { NoPermissionError } from './errors/no-permission-error';
|
||||
import { mergeAclActionParams, removeEmptyParams } from './utils';
|
||||
|
||||
interface CanResult {
|
||||
role: string;
|
||||
@ -54,11 +55,12 @@ export interface ListenerContext {
|
||||
type Listener = (ctx: ListenerContext) => void;
|
||||
|
||||
interface CanArgs {
|
||||
role: string;
|
||||
role?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
rawResourceName?: string;
|
||||
ctx?: any;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export class ACL extends EventEmitter {
|
||||
@ -169,6 +171,10 @@ export class ACL extends EventEmitter {
|
||||
return this.roles.get(name);
|
||||
}
|
||||
|
||||
getRoles(names: string[]): ACLRole[] {
|
||||
return names.map((name) => this.getRole(name)).filter((x) => Boolean(x));
|
||||
}
|
||||
|
||||
removeRole(name: string) {
|
||||
return this.roles.delete(name);
|
||||
}
|
||||
@ -202,6 +208,36 @@ export class ACL extends EventEmitter {
|
||||
}
|
||||
|
||||
can(options: CanArgs): CanResult | null {
|
||||
if (options.role) {
|
||||
return lodash.cloneDeep(this.getCanByRole(options));
|
||||
}
|
||||
if (options.roles?.length) {
|
||||
return lodash.cloneDeep(this.getCanByRoles(options));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getCanByRoles(options: CanArgs) {
|
||||
let canResult: CanResult | null = null;
|
||||
|
||||
for (const role of options.roles) {
|
||||
const result = this.getCanByRole({
|
||||
role,
|
||||
...options,
|
||||
});
|
||||
if (!canResult) {
|
||||
canResult = result;
|
||||
canResult && removeEmptyParams(canResult.params);
|
||||
} else if (canResult && result) {
|
||||
canResult.params = mergeAclActionParams(canResult.params, result.params);
|
||||
}
|
||||
}
|
||||
|
||||
return canResult;
|
||||
}
|
||||
|
||||
private getCanByRole(options: CanArgs) {
|
||||
const { role, resource, action, rawResourceName } = options;
|
||||
const aclRole = this.roles.get(role);
|
||||
|
||||
@ -351,9 +387,12 @@ export class ACL extends EventEmitter {
|
||||
}
|
||||
|
||||
ctx.can = (options: Omit<CanArgs, 'role'>) => {
|
||||
const canResult = acl.can({ role: roleName, ...options });
|
||||
|
||||
return canResult;
|
||||
const roles = ctx.state.currentRoles || [roleName];
|
||||
const can = acl.can({ roles, ...options });
|
||||
if (!can) {
|
||||
return null;
|
||||
}
|
||||
return can;
|
||||
};
|
||||
|
||||
ctx.permission = {
|
||||
@ -370,7 +409,7 @@ export class ACL extends EventEmitter {
|
||||
* @internal
|
||||
*/
|
||||
async getActionParams(ctx) {
|
||||
const roleName = ctx.state.currentRole || 'anonymous';
|
||||
const roleNames = ctx.state.currentRoles?.length ? ctx.state.currentRoles : 'anonymous';
|
||||
const { resourceName: rawResourceName, actionName } = ctx.action;
|
||||
|
||||
let resourceName = rawResourceName;
|
||||
@ -386,11 +425,11 @@ export class ACL extends EventEmitter {
|
||||
}
|
||||
|
||||
ctx.can = (options: Omit<CanArgs, 'role'>) => {
|
||||
const can = this.can({ role: roleName, ...options });
|
||||
if (!can) {
|
||||
return null;
|
||||
const can = this.can({ roles: roleNames, ...options });
|
||||
if (can) {
|
||||
return lodash.cloneDeep(can);
|
||||
}
|
||||
return lodash.cloneDeep(can);
|
||||
return null;
|
||||
};
|
||||
|
||||
ctx.permission = {
|
||||
|
@ -14,3 +14,4 @@ export * from './acl-resource';
|
||||
export * from './acl-role';
|
||||
export * from './skip-middleware';
|
||||
export * from './errors';
|
||||
export * from './utils';
|
||||
|
213
packages/core/acl/src/utils/acl-role.ts
Normal file
213
packages/core/acl/src/utils/acl-role.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { assign } from '@nocobase/utils';
|
||||
import _ from 'lodash';
|
||||
import { ACLRole } from '../acl-role';
|
||||
|
||||
export function mergeRole(roles: ACLRole[]) {
|
||||
const result: Record<string, any> = {
|
||||
roles: [],
|
||||
strategy: {},
|
||||
actions: null,
|
||||
snippets: [],
|
||||
resources: null,
|
||||
};
|
||||
const allSnippets: string[][] = [];
|
||||
for (const role of roles) {
|
||||
const jsonRole = role.toJSON();
|
||||
result.roles = mergeRoleNames(result.roles, jsonRole.role);
|
||||
result.strategy = mergeRoleStrategy(result.strategy, jsonRole.strategy);
|
||||
result.actions = mergeRoleActions(result.actions, jsonRole.actions);
|
||||
result.resources = mergeRoleResources(result.resources, [...role.resources.keys()]);
|
||||
if (_.isArray(jsonRole.snippets)) {
|
||||
allSnippets.push(jsonRole.snippets);
|
||||
}
|
||||
}
|
||||
result.snippets = mergeRoleSnippets(allSnippets);
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeRoleNames(sourceRoleNames, newRoleName) {
|
||||
return newRoleName ? sourceRoleNames.concat(newRoleName) : sourceRoleNames;
|
||||
}
|
||||
|
||||
function mergeRoleStrategy(sourceStrategy, newStrategy) {
|
||||
if (!newStrategy) {
|
||||
return sourceStrategy;
|
||||
}
|
||||
if (_.isArray(newStrategy.actions)) {
|
||||
if (!sourceStrategy.actions) {
|
||||
sourceStrategy.actions = newStrategy.actions;
|
||||
} else {
|
||||
const actions = sourceStrategy.actions.concat(newStrategy.actions);
|
||||
return {
|
||||
...sourceStrategy,
|
||||
actions: [...new Set(actions)],
|
||||
};
|
||||
}
|
||||
}
|
||||
return sourceStrategy;
|
||||
}
|
||||
|
||||
function mergeRoleActions(sourceActions, newActions) {
|
||||
if (_.isEmpty(sourceActions)) return newActions;
|
||||
if (_.isEmpty(newActions)) return sourceActions;
|
||||
|
||||
const result = {};
|
||||
[...new Set(Reflect.ownKeys(sourceActions).concat(Reflect.ownKeys(newActions)))].forEach((key) => {
|
||||
if (_.has(sourceActions, key) && _.has(newActions, key)) {
|
||||
result[key] = mergeAclActionParams(sourceActions[key], newActions[key]);
|
||||
return;
|
||||
}
|
||||
result[key] = _.has(sourceActions, key) ? sourceActions[key] : newActions[key];
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeRoleSnippets(allRoleSnippets: string[][]): string[] {
|
||||
if (!allRoleSnippets.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allSnippets = allRoleSnippets.flat();
|
||||
const isExclusion = (value) => value.startsWith('!');
|
||||
const includes = new Set(allSnippets.filter((x) => !isExclusion(x)));
|
||||
const excludes = new Set(allSnippets.filter(isExclusion));
|
||||
|
||||
// 统计 xxx.* 在多少个角色中存在
|
||||
const domainRoleMap = new Map<string, Set<number>>();
|
||||
allRoleSnippets.forEach((roleSnippets, i) => {
|
||||
roleSnippets
|
||||
.filter((x) => x.endsWith('.*') && !isExclusion(x))
|
||||
.forEach((include) => {
|
||||
const domain = include.slice(0, -1);
|
||||
if (!domainRoleMap.has(domain)) {
|
||||
domainRoleMap.set(domain, new Set());
|
||||
}
|
||||
domainRoleMap.get(domain).add(i);
|
||||
});
|
||||
});
|
||||
|
||||
// 处理黑名单交集(只有所有角色都有 `!xxx` 才保留)
|
||||
const excludesSet = new Set<string>();
|
||||
for (const snippet of excludes) {
|
||||
if (allRoleSnippets.every((x) => x.includes(snippet))) {
|
||||
excludesSet.add(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [domain, indexes] of domainRoleMap.entries()) {
|
||||
const fullDomain = `${domain}.*`;
|
||||
|
||||
// xxx.* 存在时,覆盖 !xxx.*
|
||||
if (includes.has(fullDomain)) {
|
||||
excludesSet.delete(`!${fullDomain}`);
|
||||
}
|
||||
|
||||
// 计算 !xxx.yyy,当所有 xxx.* 角色都包含 !xxx.yyy 时才保留
|
||||
for (const roleIndex of indexes) {
|
||||
for (const exclude of allRoleSnippets[roleIndex]) {
|
||||
if (exclude.startsWith(`!${domain}`) && exclude !== `!${fullDomain}`) {
|
||||
if ([...indexes].every((i) => allRoleSnippets[i].includes(exclude))) {
|
||||
excludesSet.add(exclude);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 !xxx.yyy 只有在 xxx.* 存在时才有效,同时解决 [xxx] 和 [!xxx] 冲突
|
||||
if (includes.size > 0) {
|
||||
for (const x of [...excludesSet]) {
|
||||
const exactMatch = x.slice(1);
|
||||
const segments = exactMatch.split('.');
|
||||
if (segments.length > 1 && segments[1] !== '*') {
|
||||
const parentDomain = segments[0] + '.*';
|
||||
if (!includes.has(parentDomain)) {
|
||||
excludesSet.delete(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...includes, ...excludesSet];
|
||||
}
|
||||
|
||||
function mergeRoleResources(sourceResources, newResources) {
|
||||
if (sourceResources === null) {
|
||||
return newResources;
|
||||
}
|
||||
|
||||
return [...new Set(sourceResources.concat(newResources))];
|
||||
}
|
||||
|
||||
export function mergeAclActionParams(sourceParams, targetParams) {
|
||||
if (_.isEmpty(sourceParams) || _.isEmpty(targetParams)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// source 和 target 其中之一没有 fields 字段时, 最终希望没有此字段
|
||||
removeUnmatchedParams(sourceParams, targetParams, ['fields', 'whitelist', 'appends']);
|
||||
|
||||
const andMerge = (x, y) => {
|
||||
if (_.isEmpty(x) || _.isEmpty(y)) {
|
||||
return [];
|
||||
}
|
||||
return _.uniq(x.concat(y)).filter(Boolean);
|
||||
};
|
||||
|
||||
const mergedParams = assign(targetParams, sourceParams, {
|
||||
own: (x, y) => x || y,
|
||||
filter: (x, y) => {
|
||||
if (_.isEmpty(x) || _.isEmpty(y)) {
|
||||
return {};
|
||||
}
|
||||
const xHasOr = _.has(x, '$or'),
|
||||
yHasOr = _.has(y, '$or');
|
||||
let $or = [x, y];
|
||||
if (xHasOr && !yHasOr) {
|
||||
$or = [...x.$or, y];
|
||||
} else if (!xHasOr && yHasOr) {
|
||||
$or = [x, ...y.$or];
|
||||
} else if (xHasOr && yHasOr) {
|
||||
$or = [...x.$or, ...y.$or];
|
||||
}
|
||||
|
||||
return { $or: _.uniqWith($or, _.isEqual) };
|
||||
},
|
||||
fields: andMerge,
|
||||
whitelist: andMerge,
|
||||
appends: andMerge,
|
||||
});
|
||||
removeEmptyParams(mergedParams);
|
||||
return mergedParams;
|
||||
}
|
||||
|
||||
export function removeEmptyParams(params) {
|
||||
if (!_.isObject(params)) {
|
||||
return;
|
||||
}
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (_.isEmpty(params[key])) {
|
||||
delete params[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeUnmatchedParams(source, target, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
if (_.has(source, key) && !_.has(target, key)) {
|
||||
delete source[key];
|
||||
}
|
||||
if (!_.has(source, key) && _.has(target, key)) {
|
||||
delete target[key];
|
||||
}
|
||||
}
|
||||
}
|
10
packages/core/acl/src/utils/index.ts
Normal file
10
packages/core/acl/src/utils/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './acl-role';
|
@ -22,7 +22,7 @@ describe('middleware', () => {
|
||||
app = await createMockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager', 'error-handler'],
|
||||
plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager', 'error-handler', 'system-settings'],
|
||||
});
|
||||
|
||||
// app.plugin(ApiKeysPlugin);
|
||||
|
@ -12,3 +12,4 @@ export * from './useAppSpin';
|
||||
export * from './usePlugin';
|
||||
export * from './useRouter';
|
||||
export * from './useGlobalVariable';
|
||||
export * from './useAclSnippets';
|
||||
|
@ -887,5 +887,6 @@
|
||||
"Are you sure you want to hide this tab?": "Are you sure you want to hide this tab?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.",
|
||||
"Deprecated": "Deprecated",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "The following old template features have been deprecated and will be removed in next version."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "The following old template features have been deprecated and will be removed in next version.",
|
||||
"Full permissions": "Full permissions"
|
||||
}
|
||||
|
@ -804,5 +804,6 @@
|
||||
"Are you sure you want to hide this tab?": "¿Estás seguro de que quieres ocultar esta pestaña?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Después de ocultar, esta pestaña ya no aparecerá en la barra de pestañas. Para mostrarla de nuevo, deberás ir a la página de gestión de rutas para configurarla.",
|
||||
"Deprecated": "Obsoleto",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Las siguientes características de plantilla antigua han quedado obsoletas y se eliminarán en la próxima versión."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Las siguientes características de plantilla antigua han quedado obsoletas y se eliminarán en la próxima versión.",
|
||||
"Full permissions": "Todos los derechos"
|
||||
}
|
||||
|
@ -824,5 +824,6 @@
|
||||
"Are you sure you want to hide this tab?": "Êtes-vous sûr de vouloir masquer cet onglet ?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Après avoir masqué, cette tab ne sera plus affichée dans la barre de tab. Pour la montrer à nouveau, vous devez vous rendre sur la page de gestion des routes pour la configurer.",
|
||||
"Deprecated": "Déprécié",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Les fonctionnalités des anciens modèles ont été dépréciées et seront supprimées dans la prochaine version."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Les fonctionnalités des anciens modèles ont été dépréciées et seront supprimées dans la prochaine version.",
|
||||
"Full permissions": "Tous les droits"
|
||||
}
|
||||
|
@ -1042,5 +1042,6 @@
|
||||
"Are you sure you want to hide this tab?": "このタブを非表示にしますか?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "非表示にすると、このタブはタブバーに表示されなくなります。再表示するには、ルート管理ページで設定する必要があります。",
|
||||
"Deprecated": "非推奨",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "次の古いテンプレート機能は非推奨になり、次のバージョンで削除されます。"
|
||||
"The following old template features have been deprecated and will be removed in next version.": "次の古いテンプレート機能は非推奨になり、次のバージョンで削除されます。",
|
||||
"Full permissions": "すべての権限"
|
||||
}
|
||||
|
@ -915,5 +915,6 @@
|
||||
"Are you sure you want to hide this tab?": "이 탭을 숨기시겠습니까?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "숨기면 이 탭은 탭 바에 더 이상 표시되지 않습니다. 다시 표시하려면 라우트 관리 페이지에서 설정해야 합니다.",
|
||||
"Deprecated": "사용 중단됨",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "다음 오래된 템플릿 기능은 사용 중단되었으며 다음 버전에서 제거될 것입니다."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "다음 오래된 템플릿 기능은 사용 중단되었으며 다음 버전에서 제거될 것입니다.",
|
||||
"Full permissions": "모든 권한"
|
||||
}
|
||||
|
@ -781,5 +781,6 @@
|
||||
"Are you sure you want to hide this tab?": "Tem certeza de que deseja ocultar esta guia?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Depois de ocultar, esta guia não aparecerá mais na barra de guias. Para mostrá-la novamente, você precisa ir à página de gerenciamento de rotas para configurá-la.",
|
||||
"Deprecated": "Descontinuado",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "As seguintes funcionalidades de modelo antigo foram descontinuadas e serão removidas na próxima versão."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "As seguintes funcionalidades de modelo antigo foram descontinuadas e serão removidas na próxima versão.",
|
||||
"Full permissions": "Todas as permissões"
|
||||
}
|
||||
|
@ -610,5 +610,6 @@
|
||||
"Are you sure you want to hide this tab?": "Вы уверены, что хотите скрыть эту вкладку?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "После скрытия этой вкладки она больше не будет отображаться во вкладке. Чтобы снова отобразить ее, вам нужно будет перейти на страницу управления маршрутами, чтобы установить ее.",
|
||||
"Deprecated": "Устаревший",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Следующие старые функции шаблонов устарели и будут удалены в следующей версии."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Следующие старые функции шаблонов устарели и будут удалены в следующей версии.",
|
||||
"Full permissions": "Полные права"
|
||||
}
|
||||
|
@ -608,5 +608,6 @@
|
||||
"Are you sure you want to hide this tab?": "Bu sekmeyi gizlemek istediğinizden emin misiniz?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Gizlendikten sonra, bu sekme artık sekme çubuğunda görünmeyecek. Onu tekrar göstermek için, rotayı yönetim sayfasına gidip ayarlamanız gerekiyor.",
|
||||
"Deprecated": "Kullanımdan kaldırıldı",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Aşağıdaki eski şablon özellikleri kullanımdan kaldırıldı ve gelecek sürümlerde kaldırılacaktır."
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Aşağıdaki eski şablon özellikleri kullanımdan kaldırıldı ve gelecek sürümlerde kaldırılacaktır.",
|
||||
"Full permissions": "Tüm izinler"
|
||||
}
|
||||
|
@ -824,5 +824,6 @@
|
||||
"Are you sure you want to hide this tab?": "Ви впевнені, що хочете приховати цю вкладку?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Після приховування цієї вкладки вона більше не з'явиться в панелі вкладок. Щоб знову показати її, вам потрібно перейти на сторінку керування маршрутами, щоб налаштувати її.",
|
||||
"Deprecated": "Застаріло",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Наступні старі функції шаблонів були застарілі і будуть видалені в наступній версії."
|
||||
}
|
||||
"The following old template features have been deprecated and will be removed in next version.": "Наступні старі функції шаблонів були застарілі і будуть видалені в наступній версії.",
|
||||
"Full permissions": "Повні права"
|
||||
}
|
@ -1083,5 +1083,6 @@
|
||||
"Are you sure you want to hide this tab?": "你确定要隐藏该标签页吗?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "隐藏后,该标签将不再显示在标签栏中。要想再次显示它,你需要到路由管理页面进行设置。",
|
||||
"Deprecated": "已弃用",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "以下旧的模板功能已弃用,将在下个版本移除。"
|
||||
}
|
||||
"The following old template features have been deprecated and will be removed in next version.": "以下旧的模板功能已弃用,将在下个版本移除。",
|
||||
"Full permissions": "全部权限"
|
||||
}
|
@ -915,6 +915,6 @@
|
||||
"Are you sure you want to hide this tab?": "你確定要隱藏這個標籤嗎?",
|
||||
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "隱藏後,這個標籤將不再出現在標籤欄中。要再次顯示它,你需要到路由管理頁面進行設置。",
|
||||
"Deprecated": "已棄用",
|
||||
"The following old template features have been deprecated and will be removed in next version.": "以下舊的模板功能已棄用,將在下個版本移除。"
|
||||
}
|
||||
|
||||
"The following old template features have been deprecated and will be removed in next version.": "以下舊的模板功能已棄用,將在下個版本移除。",
|
||||
"Full permissions": "完全權限"
|
||||
}
|
@ -12,8 +12,9 @@ import { useACLRoleContext } from '../acl';
|
||||
import { ReturnTypeOfUseRequest, useAPIClient, useRequest } from '../api-client';
|
||||
import { useAppSpin } from '../application';
|
||||
import { useCompile } from '../schema-component';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
|
||||
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest & { roleMode?: { data: { roleMode } } }>(null);
|
||||
CurrentUserContext.displayName = 'CurrentUserContext';
|
||||
|
||||
export const useCurrentUserContext = () => {
|
||||
@ -33,10 +34,15 @@ export const useCurrentRoles = () => {
|
||||
});
|
||||
}
|
||||
return roles;
|
||||
}, [allowAnonymous, data?.data?.roles]);
|
||||
}, [allowAnonymous, data?.data?.roles, compile]);
|
||||
return options;
|
||||
};
|
||||
|
||||
export const useCurrentRoleMode = () => {
|
||||
const { roleMode } = useCurrentUserContext();
|
||||
return roleMode?.data;
|
||||
};
|
||||
|
||||
export const CurrentUserProvider = (props) => {
|
||||
const api = useAPIClient();
|
||||
const result = useRequest<any>(() =>
|
||||
@ -48,11 +54,17 @@ export const CurrentUserProvider = (props) => {
|
||||
})
|
||||
.then((res) => res?.data),
|
||||
);
|
||||
|
||||
const { loading: roleModeLoading, data } = useRequest(() => api.resource('roles').getSystemRoleMode(), {
|
||||
onSuccess: (res) => {
|
||||
return res.data.data.roleMode;
|
||||
},
|
||||
});
|
||||
const { render } = useAppSpin();
|
||||
|
||||
if (result.loading) {
|
||||
if (result.loading || roleModeLoading) {
|
||||
return render();
|
||||
}
|
||||
|
||||
result['roleMode'] = data?.['data'];
|
||||
return <CurrentUserContext.Provider value={result}>{props.children}</CurrentUserContext.Provider>;
|
||||
};
|
||||
|
@ -73,7 +73,7 @@ interface Resource {
|
||||
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
}
|
||||
|
||||
interface ExtendedAgent extends SuperAgentTest {
|
||||
export interface ExtendedAgent extends SuperAgentTest {
|
||||
login: (user: any, roleName?: string) => Promise<ExtendedAgent>;
|
||||
loginUsingId: (userId: number, roleName?: string) => Promise<ExtendedAgent>;
|
||||
resource: (name: string, resourceOf?: any) => Resource;
|
||||
|
@ -28,7 +28,6 @@ export const NewRole: React.FC = () => {
|
||||
type: 'text',
|
||||
icon: 'PlusOutlined',
|
||||
style: {
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
|
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Flex, message, Select, Space, theme, Tooltip } from 'antd';
|
||||
import { useACLTranslation } from './locale';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useAPIClient, useCurrentRoleMode } from '@nocobase/client';
|
||||
import React, { useState } from 'react';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
export const RoleModeSelect = () => {
|
||||
const { t } = useACLTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const api = useAPIClient();
|
||||
const roleModeData = useCurrentRoleMode();
|
||||
const initialRoleMode = roleModeData?.roleMode || 'default';
|
||||
const [roleMode, setRoleMode] = useState(initialRoleMode);
|
||||
|
||||
const { run: updateRoleMode } = useRequest(
|
||||
(roleMode) => api.resource('roles').setSystemRoleMode({ values: { roleMode } }),
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: (_, params) => {
|
||||
setRoleMode(params[0]);
|
||||
message.success(t('Saved successfully'));
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
value={roleMode}
|
||||
onChange={(value) => updateRoleMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'default',
|
||||
label: t('Independent roles'),
|
||||
desc: t('Do not use role union. Users need to switch between their roles individually.'),
|
||||
},
|
||||
{
|
||||
value: 'allow-use-union',
|
||||
label: t('Allow roles union'),
|
||||
desc: t(
|
||||
'Allow users to use role union, which means they can use permissions from all their roles simultaneously, or switch between individual roles.',
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'only-use-union',
|
||||
label: t('Roles union only'),
|
||||
desc: t('Force users to use only role union. They cannot switch between individual roles.'),
|
||||
},
|
||||
]}
|
||||
optionRender={(option) => (
|
||||
<Tooltip placement="right" title={<div>{option.data.desc}</div>}>
|
||||
<Flex justify="space-between">
|
||||
<span style={{ display: 'inline-flex', paddingRight: 8 }}>{option.data.label}</span>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -23,6 +23,7 @@ import { RolesManagerContext } from './RolesManagerProvider';
|
||||
import { RolesMenu } from './RolesMenu';
|
||||
import { useACLTranslation } from './locale';
|
||||
import { Permissions } from './permissions/Permissions';
|
||||
import { RoleModeSelect } from './RoleModeSelect';
|
||||
|
||||
const collection = {
|
||||
name: 'roles',
|
||||
@ -81,7 +82,7 @@ export const RolesManagement: React.FC = () => {
|
||||
<RolesManagerContext.Provider value={{ role, setRole }}>
|
||||
<Card>
|
||||
<Row gutter={24} style={{ flexWrap: 'nowrap' }}>
|
||||
<Col flex="280px" style={{ borderRight: '1px solid #eee', minWidth: '250px' }}>
|
||||
<Col flex="280px" style={{ borderRight: '1px solid #eee', minWidth: '350px' }}>
|
||||
<ResourceActionProvider
|
||||
collection={collection}
|
||||
request={{
|
||||
@ -98,8 +99,9 @@ export const RolesManagement: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<CollectionProvider_deprecated collection={collection}>
|
||||
<Row>
|
||||
<Row justify="space-between" align="middle" style={{ width: '100%' }}>
|
||||
<NewRole />
|
||||
<RoleModeSelect />
|
||||
</Row>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<RolesMenu />
|
||||
|
@ -9,13 +9,23 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentRoles, useAPIClient, SchemaSettingsItem, SelectWithTitle } from '@nocobase/client';
|
||||
import {
|
||||
useCurrentRoles,
|
||||
useAPIClient,
|
||||
SchemaSettingsItem,
|
||||
SelectWithTitle,
|
||||
useCurrentRoleMode,
|
||||
} from '@nocobase/client';
|
||||
|
||||
export const SwitchRole = () => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPIClient();
|
||||
const roles = useCurrentRoles();
|
||||
if (roles.length <= 1) {
|
||||
const roleModeData = useCurrentRoleMode();
|
||||
const currentRole = roles.find((role) => role.name === api.auth.role)?.name;
|
||||
|
||||
// 当角色数量小于等于1 或者 是仅使用合并角色模式时,不显示切换角色选项
|
||||
if (roles.length <= 1 || roleModeData?.roleMode === 'only-use-union') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -27,7 +37,7 @@ export const SwitchRole = () => {
|
||||
value: 'name',
|
||||
}}
|
||||
options={roles}
|
||||
defaultValue={api.auth.role}
|
||||
defaultValue={currentRole || roles[0].name}
|
||||
onChange={async (roleName) => {
|
||||
api.auth.setRole(roleName);
|
||||
await api.resource('users').setDefaultRole({ values: { roleName } });
|
||||
|
@ -3,5 +3,16 @@
|
||||
"The user role does not exist. Please try signing in again": "The user role does not exist. Please try signing in again",
|
||||
"New role": "New role",
|
||||
"Permissions": "Permissions",
|
||||
"Desktop menu": "Desktop menu"
|
||||
"Desktop menu": "Desktop menu",
|
||||
"Independent roles": "Independent roles",
|
||||
"Allow roles union": "Allow roles union",
|
||||
"Roles union only": "Roles union only",
|
||||
"Role mode": "Role mode",
|
||||
"Saved successfully": "Saved successfully",
|
||||
"Please select role mode": "Please select role mode",
|
||||
"Full permissions": "Full permissions",
|
||||
"Role mode doc": "https://docs.nocobase.com/handbook/acl/manual",
|
||||
"Do not use role union. Users need to switch between their roles individually.": "Do not use role union. Users need to switch between their roles individually.",
|
||||
"Allow users to use role union, which means they can use permissions from all their roles simultaneously, or switch between individual roles.": "Allow users to use role union, which means they can use permissions from all their roles simultaneously, or switch between individual roles.",
|
||||
"Force users to use only role union. They cannot switch between individual roles.": "Force users to use only role union. They cannot switch between individual roles."
|
||||
}
|
||||
|
@ -7,5 +7,16 @@
|
||||
"General": "通用",
|
||||
"Desktop menu": "桌面端菜单",
|
||||
"Plugin settings": "插件设置",
|
||||
"Data sources": "数据源"
|
||||
}
|
||||
"Data sources": "数据源",
|
||||
"Independent roles": "独立角色",
|
||||
"Allow roles union": "允许角色并集",
|
||||
"Roles union only": "仅角色并集",
|
||||
"Role mode": "角色模式",
|
||||
"Saved successfully": "保存成功",
|
||||
"Please select role mode": "请选择角色模式",
|
||||
"Full permissions": "全部权限",
|
||||
"Role mode doc": "https://docs-cn.nocobase.com/handbook/acl/manual",
|
||||
"Do not use role union. Users need to switch between their roles individually.": "不使用角色并集,用户需要逐个切换自己拥有的角色。",
|
||||
"Allow users to use role union, which means they can use permissions from all their roles simultaneously, or switch between individual roles.": "允许用户使用角色并集,即可以同时使用自己拥有的所有角色的权限,也允许用户逐个切换自己的角色。",
|
||||
"Force users to use only role union. They cannot switch between individual roles.": "强制用户仅能使用角色并集,不能逐个切换角色。"
|
||||
}
|
@ -23,6 +23,7 @@ export async function prepareApp(): Promise<MockServer> {
|
||||
'auth',
|
||||
'data-source-manager',
|
||||
'collection-tree',
|
||||
'system-settings',
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -20,7 +20,7 @@ describe('role', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
api = await createMockServer({
|
||||
plugins: ['field-sort', 'users', 'acl', 'auth', 'data-source-manager'],
|
||||
plugins: ['field-sort', 'users', 'acl', 'auth', 'data-source-manager', 'system-settings'],
|
||||
});
|
||||
db = api.db;
|
||||
usersPlugin = api.getPlugin('users');
|
||||
|
@ -0,0 +1,428 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import Database from '@nocobase/database';
|
||||
import { ExtendedAgent, MockServer } from '@nocobase/test';
|
||||
import { prepareApp } from './prepare';
|
||||
import { UNION_ROLE_KEY } from '../constants';
|
||||
import { SystemRoleMode } from '../enum';
|
||||
|
||||
describe('union role: full permissions', async () => {
|
||||
let agent: ExtendedAgent, rootUser, user, role1, role2;
|
||||
let app: MockServer, db: Database;
|
||||
function generateRandomString() {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await prepareApp();
|
||||
db = app.db;
|
||||
rootUser = await db.getRepository('users').findOne({
|
||||
filter: {
|
||||
email: process.env.INIT_ROOT_EMAIL,
|
||||
},
|
||||
});
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const role1Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r1',
|
||||
},
|
||||
});
|
||||
role1 = role1Response.body.data;
|
||||
const role2Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r2',
|
||||
},
|
||||
});
|
||||
role2 = role2Response.body.data;
|
||||
user = await db.getRepository('users').create({
|
||||
values: {
|
||||
name: 'u1',
|
||||
roles: [role1.name, role2.name],
|
||||
},
|
||||
});
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
it('should roles check successful when login role is union', async () => {
|
||||
const rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
const data = rolesResponse.body.data;
|
||||
expect(data.roles.length).toBe(2);
|
||||
expect(data.roles).include(role1.name);
|
||||
expect(data.roles).include(role2.name);
|
||||
});
|
||||
|
||||
it('System -> Allows to configure interface: update snippets ui.*, expect: include ui.*', async () => {
|
||||
let rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
let data = rolesResponse.body.data;
|
||||
expect(data.snippets).not.include('ui.*');
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const updateResponse = await rootAgent.resource('roles').update({
|
||||
filterByTk: role1.name,
|
||||
values: {
|
||||
snippets: ['ui.*', '!pm', '!pm.*', '!app'],
|
||||
},
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
data = rolesResponse.body.data;
|
||||
expect(data.snippets).include('ui.*');
|
||||
});
|
||||
|
||||
it('System -> Allows to install, activate, disable plugins: role1 snippets [!pm.logger], role2 snippets [!pm.workflow.workflows], expect: snippets []', async () => {
|
||||
let rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
let data = rolesResponse.body.data;
|
||||
expect(data.snippets).not.include('ui.*');
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
let updateResponse = await rootAgent.resource('roles').update({
|
||||
filterByTk: role1.name,
|
||||
values: {
|
||||
snippets: ['!ui.*', '!pm', 'pm.*', '!app'],
|
||||
},
|
||||
});
|
||||
updateResponse = await rootAgent.resource('roles').update({
|
||||
filterByTk: role2.name,
|
||||
values: {
|
||||
snippets: ['!ui.*', '!pm', 'pm.*', '!app'],
|
||||
},
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
|
||||
await rootAgent.post(`/roles/${role1.name}/snippets:add`).send(['!pm.logger']);
|
||||
await rootAgent.post(`/roles/${role2.name}/snippets:add`).send(['!pm.workflow.workflows']);
|
||||
agent = await app.agent().login(user, role1.name);
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
data = rolesResponse.body.data;
|
||||
expect(data.snippets).include('pm.*');
|
||||
expect(data.snippets).include('!pm.logger');
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
data = rolesResponse.body.data;
|
||||
expect(data.snippets).include('pm.*');
|
||||
expect(data.snippets).not.include('!pm.logger');
|
||||
expect(data.snippets).not.include('!pm.workflow.workflows');
|
||||
});
|
||||
|
||||
it('System -> Allows to install, activate, disable plugins: role1 snippets [pm.*, !pm.authenticators], role2 snippets [!pm.*], expect: snippets [pm.*, !pm.authenticators]', async () => {
|
||||
let rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
let data = rolesResponse.body.data;
|
||||
expect(data.snippets).not.include('pm.*');
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const updateResponse = await rootAgent.resource('roles').update({
|
||||
filterByTk: role1.name,
|
||||
values: {
|
||||
snippets: ['!ui.*', '!pm', 'pm.*', '!app'],
|
||||
},
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
|
||||
await rootAgent.post(`/roles/${role1.name}/snippets:add`).send(['!pm.auth.authenticators']);
|
||||
agent = await app.agent().login(user, role1.name);
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
data = rolesResponse.body.data;
|
||||
expect(data.snippets).include('pm.*');
|
||||
expect(data.snippets).include('!pm.auth.authenticators');
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
data = rolesResponse.body.data;
|
||||
expect(data.snippets).include('pm.*');
|
||||
expect(data.snippets).include('!pm.auth.authenticators');
|
||||
});
|
||||
|
||||
it('Data sources -> General action permissions, strategy.actions: role1->[view], role2->[create], expect: strategy.actions=[view,create]', async () => {
|
||||
let getRolesResponse = await agent.resource('roles').list({ pageSize: 30 });
|
||||
expect(getRolesResponse.statusCode).toBe(403);
|
||||
|
||||
// set strategy actions: [view]
|
||||
const dataSourceRoleRepo = db.getRepository('dataSourcesRoles');
|
||||
let dataSourceRole = await dataSourceRoleRepo.findOne({
|
||||
where: { dataSourceKey: 'main', roleName: role1.name },
|
||||
});
|
||||
await dataSourceRoleRepo.update({
|
||||
filter: { id: dataSourceRole.id },
|
||||
values: {
|
||||
strategy: { actions: ['view'] },
|
||||
},
|
||||
});
|
||||
|
||||
getRolesResponse = await agent.resource('roles').list({ pageSize: 30 });
|
||||
expect(getRolesResponse.statusCode).toBe(200);
|
||||
expect(getRolesResponse.body.data.length).gt(0);
|
||||
|
||||
let createRoleResponse = await agent.resource('roles').create({ name: 'r3', title: '角色3' });
|
||||
expect(createRoleResponse.statusCode).toBe(403);
|
||||
|
||||
dataSourceRole = await dataSourceRoleRepo.findOne({
|
||||
where: { dataSourceKey: 'main', roleName: role2.name },
|
||||
});
|
||||
await dataSourceRoleRepo.update({
|
||||
filter: { id: dataSourceRole.id },
|
||||
values: {
|
||||
strategy: { actions: ['create'] },
|
||||
},
|
||||
});
|
||||
|
||||
createRoleResponse = await agent.resource('roles').create({ name: 'r3', title: '角色3' });
|
||||
expect(createRoleResponse.statusCode).toBe(200);
|
||||
|
||||
// verfiy strategy actions: role1 + role2 = [view, create]
|
||||
const rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
const data = rolesResponse.body.data;
|
||||
expect(data.roles.length).toBe(2);
|
||||
expect(data.strategy.actions).include('view');
|
||||
expect(data.strategy.actions).include('create');
|
||||
});
|
||||
|
||||
it('Data sources -> Action permissions, actions', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const tbResponse = await rootAgent.resource('collections').create({
|
||||
values: {
|
||||
logging: true,
|
||||
name: 'test_tb_1',
|
||||
template: 'tree',
|
||||
view: false,
|
||||
tree: 'adjacencyList',
|
||||
fields: [
|
||||
{
|
||||
interface: 'integer',
|
||||
name: 'parentId',
|
||||
type: 'bigInt',
|
||||
isForeignKey: true,
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
title: '{{t("Parent ID")}}',
|
||||
'x-component': 'InputNumber',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'm2o',
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
foreignKey: 'parentId',
|
||||
treeParent: true,
|
||||
onDelete: 'CASCADE',
|
||||
uiSchema: {
|
||||
title: '{{t("Parent")}}',
|
||||
'x-component': 'AssociationField',
|
||||
'x-component-props': {
|
||||
multiple: false,
|
||||
fieldNames: {
|
||||
label: 'id',
|
||||
value: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
target: 'test_tb_1',
|
||||
},
|
||||
{
|
||||
interface: 'o2m',
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
foreignKey: 'parentId',
|
||||
treeChildren: true,
|
||||
onDelete: 'CASCADE',
|
||||
uiSchema: {
|
||||
title: '{{t("Children")}}',
|
||||
'x-component': 'AssociationField',
|
||||
'x-component-props': {
|
||||
multiple: true,
|
||||
fieldNames: {
|
||||
label: 'id',
|
||||
value: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
target: 'test_tb_1',
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
uiSchema: {
|
||||
type: 'number',
|
||||
title: '{{t("ID")}}',
|
||||
'x-component': 'InputNumber',
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
interface: 'integer',
|
||||
},
|
||||
{
|
||||
name: 'title_id',
|
||||
interface: 'input',
|
||||
type: 'string',
|
||||
uiSchema: {
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
title: 'title',
|
||||
},
|
||||
defaultValue: null,
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
interface: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
foreignKey: 'createdById',
|
||||
uiSchema: {
|
||||
type: 'object',
|
||||
title: '{{t("Created by")}}',
|
||||
'x-component': 'AssociationField',
|
||||
'x-component-props': {
|
||||
fieldNames: {
|
||||
value: 'id',
|
||||
label: 'nickname',
|
||||
},
|
||||
},
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
] as any,
|
||||
autoGenId: false,
|
||||
title: '测试表1',
|
||||
},
|
||||
});
|
||||
expect(tbResponse.statusCode).toBe(200);
|
||||
const testTbName = tbResponse.body.data.name;
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
let getTbResponse = await agent.resource(testTbName).list({ pageSize: 30 });
|
||||
expect(getTbResponse.statusCode).toBe(403);
|
||||
|
||||
const ownDataSourceScopeRole = await db.getRepository('dataSourcesRolesResourcesScopes').findOne({
|
||||
where: {
|
||||
key: 'own',
|
||||
dataSourceKey: 'main',
|
||||
},
|
||||
});
|
||||
const scopeFields = ['id', 'createdBy', 'createdById'];
|
||||
const dataSourceResourcesResponse = await rootAgent
|
||||
.post(`/roles/${role1.name}/dataSourceResources:create`)
|
||||
.query({
|
||||
filterByTk: testTbName,
|
||||
filter: {
|
||||
dataSourceKey: 'main',
|
||||
name: testTbName,
|
||||
},
|
||||
})
|
||||
.send({
|
||||
usingActionsConfig: true,
|
||||
actions: [
|
||||
{
|
||||
name: 'view',
|
||||
fields: scopeFields,
|
||||
scope: {
|
||||
id: ownDataSourceScopeRole.id,
|
||||
createdAt: '2025-02-19T08:57:17.385Z',
|
||||
updatedAt: '2025-02-19T08:57:17.385Z',
|
||||
key: 'own',
|
||||
dataSourceKey: 'main',
|
||||
name: '{{t("Own records")}}',
|
||||
resourceName: null,
|
||||
scope: {
|
||||
createdById: '{{ ctx.state.currentUser.id }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
name: testTbName,
|
||||
dataSourceKey: 'main',
|
||||
});
|
||||
expect(dataSourceResourcesResponse.statusCode).toBe(200);
|
||||
const rootUserCreatedName = generateRandomString();
|
||||
await db.getRepository(testTbName).create({
|
||||
values: {
|
||||
createdBy: rootUser.id,
|
||||
title_id: rootUserCreatedName,
|
||||
},
|
||||
});
|
||||
const userCreatedName = generateRandomString();
|
||||
await db.getRepository(testTbName).create({
|
||||
values: {
|
||||
createdBy: user.id,
|
||||
title_id: userCreatedName,
|
||||
},
|
||||
});
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
getTbResponse = await agent.resource(testTbName).list({ pageSize: 30 });
|
||||
expect(getTbResponse.statusCode).toBe(200);
|
||||
expect(getTbResponse.body.data.length).gt(0);
|
||||
expect(getTbResponse.body.data.some((x) => x.createdById === user.id)).toBe(true);
|
||||
expect(getTbResponse.body.data.some((x) => x.createdById === rootUser.id)).toBe(false);
|
||||
// no title_id, only id and createdById
|
||||
expect(getTbResponse.body.data.some((x) => Boolean(x.title_id))).toBe(false);
|
||||
});
|
||||
|
||||
it('should list allowedActions include update of all data when set general actions: { edit: all records, delete: all records }', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const updateRoleActions = await rootAgent
|
||||
.post(`/dataSources/main/roles:update`)
|
||||
.query({
|
||||
filterByTk: role1.name,
|
||||
})
|
||||
.send({
|
||||
roleName: role1.name,
|
||||
strategy: {
|
||||
actions: ['create', 'view', 'destroy', 'update'],
|
||||
},
|
||||
dataSourceKey: 'main',
|
||||
});
|
||||
expect(updateRoleActions.statusCode).toBe(200);
|
||||
const createUserResponse1 = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: generateRandomString(),
|
||||
},
|
||||
});
|
||||
agent = (await (await app.agent().login(user, UNION_ROLE_KEY)).set({ 'X-With-ACL-Meta': true })) as any;
|
||||
const rolesResponse = await agent.resource('roles').list({ pageSize: 30 });
|
||||
expect(rolesResponse.statusCode).toBe(200);
|
||||
const meta = rolesResponse.body.meta;
|
||||
const data = rolesResponse.body.data;
|
||||
expect(data.length).gt(0);
|
||||
expect(meta.allowedActions.update).exist;
|
||||
expect(meta.allowedActions.destroy).exist;
|
||||
expect(meta.allowedActions.update).include(createUserResponse1.body.data.name);
|
||||
expect(meta.allowedActions.destroy).include(createUserResponse1.body.data.name);
|
||||
});
|
||||
|
||||
it('should update user role mode successfully', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
let getSystemSettingsResponse = await rootAgent.resource('roles').getSystemRoleMode();
|
||||
expect(getSystemSettingsResponse.status).toBe(200);
|
||||
expect(getSystemSettingsResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.default);
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
},
|
||||
});
|
||||
getSystemSettingsResponse = await rootAgent.resource('roles').getSystemRoleMode();
|
||||
expect(getSystemSettingsResponse.status).toBe(200);
|
||||
expect(getSystemSettingsResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.allowUseUnion);
|
||||
});
|
||||
});
|
@ -6,6 +6,7 @@
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { mergeRole } from '@nocobase/acl';
|
||||
|
||||
const map2obj = (map: Map<string, string>) => {
|
||||
const obj = {};
|
||||
@ -16,17 +17,17 @@ const map2obj = (map: Map<string, string>) => {
|
||||
};
|
||||
|
||||
export async function checkAction(ctx, next) {
|
||||
const currentRole = ctx.state.currentRole;
|
||||
const currentRoles = ctx.state.currentRoles;
|
||||
|
||||
const roleInstance = await ctx.db.getRepository('roles').findOne({
|
||||
const roleInstances = await ctx.db.getRepository('roles').find({
|
||||
filter: {
|
||||
name: currentRole,
|
||||
name: currentRoles,
|
||||
},
|
||||
appends: ['menuUiSchemas'],
|
||||
});
|
||||
|
||||
if (!roleInstance) {
|
||||
throw new Error(`Role ${currentRole} not exists`);
|
||||
if (!roleInstances.length) {
|
||||
throw new Error(`Role ${currentRoles} not exists`);
|
||||
}
|
||||
|
||||
const anonymous = await ctx.db.getRepository('roles').findOne({
|
||||
@ -35,15 +36,20 @@ export async function checkAction(ctx, next) {
|
||||
},
|
||||
});
|
||||
|
||||
let role = ctx.app.acl.getRole(currentRole);
|
||||
let roles = ctx.app.acl.getRoles(currentRoles);
|
||||
|
||||
if (!role) {
|
||||
await ctx.app.emitAsync('acl:writeRoleToACL', roleInstance);
|
||||
role = ctx.app.acl.getRole(currentRole);
|
||||
if (!roles.length) {
|
||||
await Promise.all(roleInstances.map((x) => ctx.app.emitAsync('acl:writeRoleToACL', x)));
|
||||
roles = ctx.app.acl.getRoles(currentRoles);
|
||||
}
|
||||
|
||||
const availableActions = ctx.app.acl.getAvailableActions();
|
||||
const role = mergeRole(roles);
|
||||
const allowMenuItemIds = roleInstances.flatMap((roleInstance) =>
|
||||
roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),
|
||||
);
|
||||
let uiButtonSchemasBlacklist = [];
|
||||
const currentRole = ctx.state.currentRole;
|
||||
if (currentRole !== 'root') {
|
||||
const eqCurrentRoleList = await ctx.db
|
||||
.getRepository('uiButtonSchemasRoles')
|
||||
@ -62,13 +68,13 @@ export async function checkAction(ctx, next) {
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
...role.toJSON(),
|
||||
...role,
|
||||
role: currentRole,
|
||||
availableActions: [...availableActions.keys()],
|
||||
resources: [...role.resources.keys()],
|
||||
actionAlias: map2obj(ctx.app.acl.actionAlias),
|
||||
allowAll: currentRole === 'root',
|
||||
allowConfigure: roleInstance.get('allowConfigure'),
|
||||
allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),
|
||||
allowAll: !!currentRoles.includes('root'),
|
||||
allowConfigure: !!roleInstances.find((x) => x.get('allowConfigure')),
|
||||
allowMenuItemIds: [...new Set(allowMenuItemIds)],
|
||||
allowAnonymous: !!anonymous,
|
||||
uiButtonSchemasBlacklist,
|
||||
};
|
||||
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SystemRoleMode } from '../enum';
|
||||
|
||||
export const getSystemRoleMode = async (ctx, next) => {
|
||||
const systemSettings = await ctx.db.getRepository('systemSettings').findOne();
|
||||
const roleMode = systemSettings?.get('roleMode') || SystemRoleMode.default;
|
||||
ctx.body = { roleMode };
|
||||
await next();
|
||||
};
|
||||
|
||||
export const setSystemRoleMode = async (ctx, next) => {
|
||||
const roleMode = ctx.action.params.values?.roleMode;
|
||||
if (!SystemRoleMode.validate(roleMode)) {
|
||||
throw new Error('Invalid role mode');
|
||||
}
|
||||
await ctx.db.getRepository('systemSettings').update({
|
||||
filterByTk: 1,
|
||||
values: { roleMode },
|
||||
});
|
||||
await next();
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import { extendCollection } from '@nocobase/database';
|
||||
export default extendCollection({
|
||||
name: 'systemSettings',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'roleMode',
|
||||
defaultValue: 'default',
|
||||
},
|
||||
],
|
||||
});
|
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const UNION_ROLE_KEY = '__union__';
|
17
packages/plugins/@nocobase/plugin-acl/src/server/enum.ts
Normal file
17
packages/plugins/@nocobase/plugin-acl/src/server/enum.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const SystemRoleMode = {
|
||||
default: 'default',
|
||||
allowUseUnion: 'allow-use-union',
|
||||
onlyUseUnion: 'only-use-union',
|
||||
validate(value: string) {
|
||||
return Object.values(this).includes(value);
|
||||
},
|
||||
} as const;
|
@ -11,5 +11,5 @@ export * from './middlewares/setCurrentRole';
|
||||
export * from './middlewares/with-acl-meta';
|
||||
export { RoleResourceActionModel } from './model/RoleResourceActionModel';
|
||||
export { RoleResourceModel } from './model/RoleResourceModel';
|
||||
|
||||
export { UNION_ROLE_KEY } from './constants';
|
||||
export { default } from './server';
|
||||
|
@ -10,6 +10,8 @@
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { Cache } from '@nocobase/cache';
|
||||
import { Model, Repository } from '@nocobase/database';
|
||||
import { UNION_ROLE_KEY } from '../constants';
|
||||
import { SystemRoleMode } from '../enum';
|
||||
|
||||
export async function setCurrentRole(ctx: Context, next) {
|
||||
const currentRole = ctx.get('X-Role');
|
||||
@ -45,6 +47,28 @@ export async function setCurrentRole(ctx: Context, next) {
|
||||
roles.forEach((role: any) => rolesMap.set(role.name, role));
|
||||
const userRoles = Array.from(rolesMap.values());
|
||||
ctx.state.currentUser.roles = userRoles;
|
||||
const systemSettings = await ctx.db.getRepository('systemSettings').findOne();
|
||||
const roleMode = systemSettings?.get('roleMode') || SystemRoleMode.default;
|
||||
if (ctx.state.currentRole === UNION_ROLE_KEY && roleMode === SystemRoleMode.default) {
|
||||
ctx.state.currentRole = userRoles[0].name;
|
||||
ctx.headers['x-role'] = userRoles[0].name;
|
||||
} else if (roleMode === SystemRoleMode.onlyUseUnion) {
|
||||
ctx.state.currentRole = UNION_ROLE_KEY;
|
||||
ctx.headers['x-role'] = UNION_ROLE_KEY;
|
||||
ctx.state.currentRoles = userRoles.map((role) => role.name);
|
||||
return next();
|
||||
} else if (roleMode === SystemRoleMode.allowUseUnion) {
|
||||
ctx.state.currentUser.roles = userRoles.concat({
|
||||
name: UNION_ROLE_KEY,
|
||||
title: ctx.t('Full permissions', { ns: 'acl' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (currentRole === UNION_ROLE_KEY) {
|
||||
ctx.state.currentRole = UNION_ROLE_KEY;
|
||||
ctx.state.currentRoles = userRoles.map((role) => role.name);
|
||||
return next();
|
||||
}
|
||||
|
||||
let role: string | undefined;
|
||||
// 1. If the X-Role is set, use the specified role
|
||||
@ -63,7 +87,8 @@ export async function setCurrentRole(ctx: Context, next) {
|
||||
role = (defaultRole || userRoles[0])?.name;
|
||||
}
|
||||
ctx.state.currentRole = role;
|
||||
if (!ctx.state.currentRole) {
|
||||
ctx.state.currentRoles = [role];
|
||||
if (!ctx.state.currentRoles.length) {
|
||||
return ctx.throw(401, {
|
||||
code: 'ROLE_NOT_FOUND_ERR',
|
||||
message: ctx.t('The user role does not exist. Please try signing in again', { ns: 'acl' }),
|
||||
|
@ -95,6 +95,7 @@ function createWithACLMetaMiddleware() {
|
||||
},
|
||||
state: {
|
||||
currentRole: ctx.state.currentRole,
|
||||
currentRoles: ctx.state.currentRoles,
|
||||
currentUser: (() => {
|
||||
if (!ctx.state.currentUser) {
|
||||
return null;
|
||||
|
@ -22,6 +22,7 @@ import { createWithACLMetaMiddleware } from './middlewares/with-acl-meta';
|
||||
import { RoleModel } from './model/RoleModel';
|
||||
import { RoleResourceActionModel } from './model/RoleResourceActionModel';
|
||||
import { RoleResourceModel } from './model/RoleResourceModel';
|
||||
import { getSystemRoleMode, setSystemRoleMode } from './actions/union-role';
|
||||
|
||||
export class PluginACLServer extends Plugin {
|
||||
get acl() {
|
||||
@ -163,6 +164,9 @@ export class PluginACLServer extends Plugin {
|
||||
this.app.resourcer.define(availableActionResource);
|
||||
this.app.resourcer.define(roleCollectionsResource);
|
||||
|
||||
this.app.resourcer.registerActionHandler('roles:getSystemRoleMode', getSystemRoleMode);
|
||||
this.app.resourcer.registerActionHandler('roles:setSystemRoleMode', setSystemRoleMode);
|
||||
|
||||
this.app.resourcer.registerActionHandler('roles:check', checkAction);
|
||||
|
||||
this.app.resourcer.registerActionHandler(`users:setDefaultRole`, setDefaultRole);
|
||||
@ -443,6 +447,7 @@ export class PluginACLServer extends Plugin {
|
||||
|
||||
this.app.acl.allow('users', 'setDefaultRole', 'loggedIn');
|
||||
this.app.acl.allow('roles', 'check', 'loggedIn');
|
||||
this.app.acl.allow('roles', 'getSystemRoleMode', 'loggedIn');
|
||||
|
||||
this.app.acl.allow('*', '*', (ctx) => {
|
||||
return ctx.state.currentRole === 'root';
|
||||
|
@ -22,7 +22,15 @@ describe('actions', () => {
|
||||
app = await createMockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['field-sort', 'users', 'auth', 'acl', 'action-custom-request', 'data-source-manager'],
|
||||
plugins: [
|
||||
'field-sort',
|
||||
'users',
|
||||
'auth',
|
||||
'acl',
|
||||
'action-custom-request',
|
||||
'data-source-manager',
|
||||
'system-settings',
|
||||
],
|
||||
});
|
||||
db = app.db;
|
||||
repo = db.getRepository('customRequests');
|
||||
|
@ -33,7 +33,7 @@ describe('actions', () => {
|
||||
app = await createMockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['field-sort', 'users', 'auth', 'api-keys', 'acl', 'data-source-manager'],
|
||||
plugins: ['field-sort', 'users', 'auth', 'api-keys', 'acl', 'data-source-manager', 'system-settings'],
|
||||
});
|
||||
|
||||
db = app.db;
|
||||
|
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { UNION_ROLE_KEY } from '@nocobase/plugin-acl';
|
||||
import { MockServer, createMockServer, ExtendedAgent } from '@nocobase/test';
|
||||
|
||||
describe('Web client desktopRoutes', async () => {
|
||||
let app: MockServer, db;
|
||||
let agent: ExtendedAgent, rootUser, user, role1, role2;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: [
|
||||
'acl',
|
||||
'client',
|
||||
'users',
|
||||
'ui-schema-storage',
|
||||
'system-settings',
|
||||
'field-sort',
|
||||
'data-source-main',
|
||||
'auth',
|
||||
'data-source-manager',
|
||||
'error-handler',
|
||||
'collection-tree',
|
||||
],
|
||||
});
|
||||
db = app.db;
|
||||
rootUser = await db.getRepository('users').findOne({
|
||||
filter: {
|
||||
email: process.env.INIT_ROOT_EMAIL,
|
||||
},
|
||||
});
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const role1Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r1',
|
||||
},
|
||||
});
|
||||
role1 = role1Response.body.data;
|
||||
const role2Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r2',
|
||||
},
|
||||
});
|
||||
role2 = role2Response.body.data;
|
||||
user = await db.getRepository('users').create({
|
||||
values: {
|
||||
name: 'u1',
|
||||
roles: [role1.name, role2.name],
|
||||
},
|
||||
});
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
};
|
||||
|
||||
const createUiMenu = async (loginAgent: ExtendedAgent, data?: { title?: string }) => {
|
||||
const response = await loginAgent.resource(`desktopRoutes`).create({
|
||||
values: {
|
||||
type: 'page',
|
||||
title: data?.title || generateRandomString(),
|
||||
schemaUid: generateRandomString(),
|
||||
menuSchemaUid: generateRandomString(),
|
||||
enableTabs: false,
|
||||
children: [
|
||||
{
|
||||
type: 'tabs',
|
||||
schemaUid: generateRandomString(),
|
||||
tabSchemaName: generateRandomString(),
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).exist;
|
||||
if (data?.title) {
|
||||
expect(response.body.data.title).toBe(data.title);
|
||||
}
|
||||
const menu = response.body.data;
|
||||
const uiSchemaResponse = await loginAgent
|
||||
.post(`/uiSchemas:insertAdjacent/nocobase-admin-menu`)
|
||||
.query({ position: 'beforeEnd' })
|
||||
.send({
|
||||
schema: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: menu.title,
|
||||
'x-component': 'Menu.Item',
|
||||
'x-decorator': 'ACLMenuItemProvider',
|
||||
'x-component-props': {},
|
||||
properties: {
|
||||
page: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Page',
|
||||
'x-async': true,
|
||||
properties: {
|
||||
[menu.children[0].tabSchemaName]: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'page:addBlock',
|
||||
'x-uid': menu.children[0].schemaUid,
|
||||
name: menu.children[0].tabSchemaName,
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
},
|
||||
'x-uid': menu.schemaUid,
|
||||
name: 'page',
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
},
|
||||
'x-uid': menu.menuSchemaUid,
|
||||
__route__: {
|
||||
createdAt: '2025-02-27T03:34:19.689Z',
|
||||
updatedAt: '2025-02-27T03:34:19.689Z',
|
||||
id: menu.id,
|
||||
type: menu.type,
|
||||
title: menu.title,
|
||||
schemaUid: menu.schemaUid,
|
||||
menuSchemaUid: menu.menuSchemaUid,
|
||||
enableTabs: false,
|
||||
sort: menu.sort,
|
||||
createdById: menu.createdById,
|
||||
updatedById: menu.updatedById,
|
||||
parentId: null,
|
||||
icon: null,
|
||||
tabSchemaName: null,
|
||||
options: null,
|
||||
hideInMenu: null,
|
||||
enableHeader: null,
|
||||
displayTitle: null,
|
||||
hidden: null,
|
||||
children: [
|
||||
{
|
||||
createdAt: '2025-02-27T03:34:19.746Z',
|
||||
updatedAt: '2025-02-27T03:34:19.746Z',
|
||||
id: menu.children[0].id,
|
||||
type: 'tabs',
|
||||
schemaUid: menu.children[0].schemaUid,
|
||||
tabSchemaName: menu.children[0].tabSchemaName,
|
||||
hidden: true,
|
||||
parentId: menu.children[0].parentId,
|
||||
sort: menu.children[0].sort,
|
||||
createdById: menu.children[0].createdById,
|
||||
updatedById: menu.children[0].updatedById,
|
||||
title: null,
|
||||
icon: null,
|
||||
menuSchemaUid: null,
|
||||
options: null,
|
||||
hideInMenu: null,
|
||||
enableTabs: null,
|
||||
enableHeader: null,
|
||||
displayTitle: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: generateRandomString(),
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
wrap: null,
|
||||
});
|
||||
expect(uiSchemaResponse.statusCode).toBe(200);
|
||||
return response.body.data;
|
||||
};
|
||||
|
||||
const getAccessibleMenus = async (loginAgent: ExtendedAgent) => {
|
||||
const menuResponse = await loginAgent
|
||||
.get(`/desktopRoutes:listAccessible`)
|
||||
.query({ tree: true, sort: 'sort' })
|
||||
.send();
|
||||
expect(menuResponse.statusCode).toBe(200);
|
||||
return menuResponse.body.data;
|
||||
};
|
||||
|
||||
it('Desktop menu, add menu Accessible menu1 to role1, add menu Accessible menu2 to role2, expect role1 visible menu1, role2 visible menu2', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const page1 = await createUiMenu(rootAgent, { title: 'page1' });
|
||||
const page2 = await createUiMenu(rootAgent, { title: 'page2' });
|
||||
|
||||
// add accessible menu1 to role1
|
||||
let addMenuResponse = await rootAgent.post(`/roles/${role1.name}/desktopRoutes:add`).send([page1.id]);
|
||||
|
||||
// add accessible menu2 to role2
|
||||
addMenuResponse = await rootAgent.post(`/roles/${role2.name}/desktopRoutes:add`).send([page2.id]);
|
||||
|
||||
agent = await app.agent().login(user, role1.name);
|
||||
let accessibleMenus = await getAccessibleMenus(agent);
|
||||
let menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page1.title);
|
||||
expect(menuProps).not.include(page2.title);
|
||||
|
||||
agent = await app.agent().login(user, role2.name);
|
||||
accessibleMenus = await getAccessibleMenus(agent);
|
||||
menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page2.title);
|
||||
expect(menuProps).not.include(page1.title);
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
accessibleMenus = await getAccessibleMenus(agent);
|
||||
menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page1.title);
|
||||
expect(menuProps).include(page2.title);
|
||||
});
|
||||
|
||||
it('Desktop menu, allowNewMenu = true, expect display new menu', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const role1Response = await rootAgent.resource('roles').get({
|
||||
filterByTk: role1.name,
|
||||
});
|
||||
expect(role1Response.statusCode).toBe(200);
|
||||
const updateRole1Response = await rootAgent.resource('roles').update({
|
||||
filterByTk: role1.name,
|
||||
values: {
|
||||
...role1Response.body.data,
|
||||
allowNewMenu: true,
|
||||
},
|
||||
});
|
||||
expect(updateRole1Response.statusCode).toBe(200);
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
const page1 = await createUiMenu(rootAgent, { title: 'page1' });
|
||||
|
||||
// auto can see new menu
|
||||
const accessibleMenus = await getAccessibleMenus(agent);
|
||||
expect(accessibleMenus.length).toBe(1);
|
||||
expect(accessibleMenus[0].title).toBe(page1.title);
|
||||
});
|
||||
});
|
@ -18,7 +18,7 @@ describe('desktopRoutes:listAccessible', () => {
|
||||
app = await createMockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['nocobase'],
|
||||
plugins: ['nocobase', 'collection-tree'],
|
||||
});
|
||||
db = app.db;
|
||||
|
||||
@ -153,7 +153,7 @@ describe('desktopRoutes:listAccessible', () => {
|
||||
});
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
await rootAgent.resource('roles.desktopRoutes', 'member').remove({
|
||||
values: [1, 2, 3, 4, 5, 6, 8, 9], // 只保留 page4 的访问权限
|
||||
values: [1, 2, 3, 4, 5, 6],
|
||||
});
|
||||
|
||||
// 验证返回结果包含子路由
|
||||
|
@ -260,26 +260,12 @@ export class PluginClientServer extends Plugin {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const role = await rolesRepository.findOne({
|
||||
filterByTk: ctx.state.currentRole,
|
||||
const roles = await rolesRepository.find({
|
||||
filterByTk: ctx.state.currentRoles,
|
||||
appends: ['desktopRoutes'],
|
||||
});
|
||||
|
||||
// 1. 如果 page 的 children 为空,那么需要把 page 的 children 全部找出来,然后返回。否则前端会因为缺少 tab 路由的数据而导致页面空白
|
||||
// 2. 如果 page 的 children 不为空,不需要做特殊处理
|
||||
const desktopRoutesId = role.get('desktopRoutes').map(async (item, index, items) => {
|
||||
if (item.type === 'page' && !items.some((tab) => tab.parentId === item.id)) {
|
||||
const children = await desktopRoutesRepository.find({
|
||||
filter: {
|
||||
parentId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
return [item.id, ...(children || []).map((child) => child.id)];
|
||||
}
|
||||
|
||||
return item.id;
|
||||
});
|
||||
const desktopRoutesId = roles.flatMap((x) => x.get('desktopRoutes')).map((item) => item.id);
|
||||
|
||||
if (desktopRoutesId) {
|
||||
const ids = (await Promise.all(desktopRoutesId)).flat();
|
||||
|
@ -9,6 +9,7 @@
|
||||
|
||||
import { CollectionManager, DataSource, IRepository } from '@nocobase/data-source-manager';
|
||||
import { ICollectionManager, IModel } from '@nocobase/data-source-manager/src/types';
|
||||
import { UNION_ROLE_KEY } from '@nocobase/plugin-acl';
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
import os from 'os';
|
||||
import { SuperAgentTest } from 'supertest';
|
||||
@ -408,7 +409,6 @@ describe('data source with acl', () => {
|
||||
const checkData = checkRep.body;
|
||||
|
||||
expect(checkData.meta.dataSources.mockInstance1).toBeDefined();
|
||||
console.log(JSON.stringify(checkData, null, 2));
|
||||
});
|
||||
|
||||
it('should update roles strategy', async () => {
|
||||
@ -452,4 +452,70 @@ describe('data source with acl', () => {
|
||||
|
||||
expect(adminRoleResp2.body.data.strategy.actions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(`should list response meta include new data sources`, async () => {
|
||||
const adminUser = await app.db.getRepository('users').create({
|
||||
values: {
|
||||
roles: ['root'],
|
||||
},
|
||||
});
|
||||
|
||||
await app.db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'testRole',
|
||||
title: '测试角色',
|
||||
},
|
||||
});
|
||||
|
||||
const testUser = await app.db.getRepository('users').create({
|
||||
values: {
|
||||
roles: ['testRole'],
|
||||
},
|
||||
});
|
||||
|
||||
const adminAgent: any = await app.agent().login(adminUser);
|
||||
|
||||
// create user resource permission
|
||||
const createConnectionResourceResp = await adminAgent.resource('roles.dataSourceResources', 'testRole').create({
|
||||
values: {
|
||||
dataSourceKey: 'mockInstance1',
|
||||
usingActionsConfig: true,
|
||||
strategy: {
|
||||
actions: ['view'],
|
||||
},
|
||||
name: 'posts',
|
||||
},
|
||||
});
|
||||
|
||||
expect(createConnectionResourceResp.status).toBe(200);
|
||||
|
||||
const createResourceResp = await adminAgent.resource('dataSources.roles', 'mockInstance1').update({
|
||||
filterByTk: 'testRole',
|
||||
values: {
|
||||
strategy: {
|
||||
actions: ['view'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResourceResp.status).toBe(200);
|
||||
|
||||
// call roles check
|
||||
let checkRep = await (await app.agent().login(testUser)).resource('roles').check({});
|
||||
expect(checkRep.status).toBe(200);
|
||||
|
||||
let checkData = checkRep.body;
|
||||
|
||||
expect(checkData.meta.dataSources.mockInstance1).exist;
|
||||
expect(checkData.meta.dataSources.mockInstance1.strategy).toEqual({ actions: ['view'] });
|
||||
|
||||
const testUserAgent = await app.agent().login(testUser, UNION_ROLE_KEY);
|
||||
checkRep = await testUserAgent.resource('roles').check({});
|
||||
expect(checkRep.status).toBe(200);
|
||||
|
||||
checkData = checkRep.body;
|
||||
|
||||
expect(checkData.meta.dataSources.mockInstance1).exist;
|
||||
expect(checkData.meta.dataSources.mockInstance1.strategy).toEqual({ actions: ['view'] });
|
||||
});
|
||||
});
|
||||
|
@ -23,6 +23,7 @@ import { DataSourcesRolesResourcesModel } from './models/connections-roles-resou
|
||||
import { DataSourcesRolesResourcesActionModel } from './models/connections-roles-resources-action';
|
||||
import { DataSourceModel } from './models/data-source';
|
||||
import { DataSourcesRolesModel } from './models/data-sources-roles-model';
|
||||
import { mergeRole } from '@nocobase/acl';
|
||||
|
||||
type DataSourceState = 'loading' | 'loaded' | 'loading-failed' | 'reloading' | 'reloading-failed';
|
||||
|
||||
@ -700,7 +701,7 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
await next();
|
||||
const { resourceName, actionName } = action.params;
|
||||
if (resourceName === 'roles' && actionName == 'check') {
|
||||
const roleName = ctx.state.currentRole;
|
||||
const roleNames = ctx.state.currentRoles;
|
||||
const dataSources = await ctx.db.getRepository('dataSources').find();
|
||||
|
||||
ctx.bodyMeta = {
|
||||
@ -716,20 +717,8 @@ export class PluginDataSourceManagerServer extends Plugin {
|
||||
}
|
||||
|
||||
const aclInstance = dataSource.acl;
|
||||
const roleInstance = aclInstance.getRole(roleName);
|
||||
|
||||
const dataObj = {
|
||||
strategy: {},
|
||||
resources: roleInstance ? [...roleInstance.resources.keys()] : [],
|
||||
actions: {},
|
||||
};
|
||||
|
||||
if (roleInstance) {
|
||||
const data = roleInstance.toJSON();
|
||||
dataObj['name'] = data['name'];
|
||||
dataObj['strategy'] = data['strategy'];
|
||||
dataObj['actions'] = data['actions'];
|
||||
}
|
||||
const roleInstances = aclInstance.getRoles(roleNames);
|
||||
const dataObj = mergeRole(roleInstances);
|
||||
|
||||
carry[dataSourceModel.get('key')] = dataObj;
|
||||
|
||||
|
@ -6,6 +6,7 @@
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
import _ from 'lodash';
|
||||
|
||||
export function mergeOptions(fieldOptions, modelOptions) {
|
||||
const newOptions = {
|
||||
|
@ -19,7 +19,7 @@ describe('external data source', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.INIT_ROOT_USERNAME = 'test';
|
||||
app = await createMockServer({
|
||||
plugins: ['field-sort', 'data-source-manager', 'users', 'acl', 'auth'],
|
||||
plugins: ['field-sort', 'data-source-manager', 'users', 'acl', 'auth', 'system-settings'],
|
||||
});
|
||||
db = app.db;
|
||||
ctx = {
|
||||
|
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { UNION_ROLE_KEY } from '@nocobase/plugin-acl';
|
||||
import { MockServer, createMockServer, ExtendedAgent } from '@nocobase/test';
|
||||
|
||||
describe('union role mobileRoutes', async () => {
|
||||
let app: MockServer, db;
|
||||
let agent: ExtendedAgent, rootUser, user, role1, role2;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: [
|
||||
'acl',
|
||||
'mobile',
|
||||
'users',
|
||||
'ui-schema-storage',
|
||||
'system-settings',
|
||||
'field-sort',
|
||||
'data-source-main',
|
||||
'auth',
|
||||
'data-source-manager',
|
||||
'error-handler',
|
||||
],
|
||||
});
|
||||
db = app.db;
|
||||
rootUser = await db.getRepository('users').findOne({
|
||||
filter: {
|
||||
email: process.env.INIT_ROOT_EMAIL,
|
||||
},
|
||||
});
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const role1Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r1',
|
||||
},
|
||||
});
|
||||
role1 = role1Response.body.data;
|
||||
const role2Response = await rootAgent.resource('roles').create({
|
||||
values: {
|
||||
name: 'r2',
|
||||
},
|
||||
});
|
||||
role2 = role2Response.body.data;
|
||||
user = await db.getRepository('users').create({
|
||||
values: {
|
||||
name: 'u1',
|
||||
roles: [role1.name, role2.name],
|
||||
},
|
||||
});
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
};
|
||||
|
||||
const createUiMenu = async (loginAgent: ExtendedAgent, data?: { title?: string }) => {
|
||||
const response = await loginAgent.resource(`mobileRoutes`).create({
|
||||
values: {
|
||||
type: 'page',
|
||||
title: data?.title || generateRandomString(),
|
||||
schemaUid: generateRandomString(),
|
||||
menuSchemaUid: generateRandomString(),
|
||||
enableTabs: false,
|
||||
children: [
|
||||
{
|
||||
type: 'tabs',
|
||||
schemaUid: generateRandomString(),
|
||||
tabSchemaName: generateRandomString(),
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data).exist;
|
||||
if (data?.title) {
|
||||
expect(response.body.data.title).toBe(data.title);
|
||||
}
|
||||
const menu = response.body.data;
|
||||
const uiSchemaResponse = await loginAgent
|
||||
.post(`/uiSchemas:insertAdjacent/nocobase-admin-menu`)
|
||||
.query({ position: 'beforeEnd' })
|
||||
.send({
|
||||
schema: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: menu.title,
|
||||
'x-component': 'Menu.Item',
|
||||
'x-decorator': 'ACLMenuItemProvider',
|
||||
'x-component-props': {},
|
||||
properties: {
|
||||
page: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Page',
|
||||
'x-async': true,
|
||||
properties: {
|
||||
[menu.children[0].tabSchemaName]: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'page:addBlock',
|
||||
'x-uid': menu.children[0].schemaUid,
|
||||
name: menu.children[0].tabSchemaName,
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
},
|
||||
'x-uid': menu.schemaUid,
|
||||
name: 'page',
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
},
|
||||
'x-uid': menu.menuSchemaUid,
|
||||
__route__: {
|
||||
createdAt: '2025-02-27T03:34:19.689Z',
|
||||
updatedAt: '2025-02-27T03:34:19.689Z',
|
||||
id: menu.id,
|
||||
type: menu.type,
|
||||
title: menu.title,
|
||||
schemaUid: menu.schemaUid,
|
||||
menuSchemaUid: menu.menuSchemaUid,
|
||||
enableTabs: false,
|
||||
sort: menu.sort,
|
||||
createdById: menu.createdById,
|
||||
updatedById: menu.updatedById,
|
||||
parentId: null,
|
||||
icon: null,
|
||||
tabSchemaName: null,
|
||||
options: null,
|
||||
hideInMenu: null,
|
||||
enableHeader: null,
|
||||
displayTitle: null,
|
||||
hidden: null,
|
||||
children: [
|
||||
{
|
||||
createdAt: '2025-02-27T03:34:19.746Z',
|
||||
updatedAt: '2025-02-27T03:34:19.746Z',
|
||||
id: menu.children[0].id,
|
||||
type: 'tabs',
|
||||
schemaUid: menu.children[0].schemaUid,
|
||||
tabSchemaName: menu.children[0].tabSchemaName,
|
||||
hidden: true,
|
||||
parentId: menu.children[0].parentId,
|
||||
sort: menu.children[0].sort,
|
||||
createdById: menu.children[0].createdById,
|
||||
updatedById: menu.children[0].updatedById,
|
||||
title: null,
|
||||
icon: null,
|
||||
menuSchemaUid: null,
|
||||
options: null,
|
||||
hideInMenu: null,
|
||||
enableTabs: null,
|
||||
enableHeader: null,
|
||||
displayTitle: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
name: generateRandomString(),
|
||||
'x-app-version': '1.6.0-alpha.28',
|
||||
},
|
||||
wrap: null,
|
||||
});
|
||||
expect(uiSchemaResponse.statusCode).toBe(200);
|
||||
return response.body.data;
|
||||
};
|
||||
|
||||
const getAccessibleMenus = async (loginAgent: ExtendedAgent) => {
|
||||
const menuResponse = await loginAgent
|
||||
.get(`/mobileRoutes:listAccessible`)
|
||||
.query({ tree: true, sort: 'sort' })
|
||||
.send();
|
||||
expect(menuResponse.statusCode).toBe(200);
|
||||
return menuResponse.body.data;
|
||||
};
|
||||
|
||||
it('should fetch successful when login role is union', async () => {
|
||||
const listAccessibleResponse = await agent.resource('mobileRoutes').listAccessible({
|
||||
filter: {
|
||||
tree: true,
|
||||
sort: 'sort',
|
||||
pagination: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(listAccessibleResponse.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('Mobile menu, add menu Accessible menu1 to role1, add menu Accessible menu2 to role2, expect role1 visible menu1, role2 visible menu2', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const page1 = await createUiMenu(rootAgent, { title: 'page1' });
|
||||
const page2 = await createUiMenu(rootAgent, { title: 'page2' });
|
||||
|
||||
// add accessible menu1 to role1
|
||||
let addMenuResponse = await rootAgent.post(`/roles/${role1.name}/mobileRoutes:add`).send([page1.id]);
|
||||
|
||||
// add accessible menu2 to role2
|
||||
addMenuResponse = await rootAgent.post(`/roles/${role2.name}/mobileRoutes:add`).send([page2.id]);
|
||||
|
||||
agent = await app.agent().login(user, role1.name);
|
||||
let accessibleMenus = await getAccessibleMenus(agent);
|
||||
let menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page1.title);
|
||||
expect(menuProps).not.include(page2.title);
|
||||
|
||||
agent = await app.agent().login(user, role2.name);
|
||||
accessibleMenus = await getAccessibleMenus(agent);
|
||||
menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page2.title);
|
||||
expect(menuProps).not.include(page1.title);
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
accessibleMenus = await getAccessibleMenus(agent);
|
||||
menuProps = accessibleMenus.map((x) => x.title);
|
||||
expect(menuProps).include(page1.title);
|
||||
expect(menuProps).include(page2.title);
|
||||
});
|
||||
|
||||
it('Mobile menu, allowNewMenu = true, expect display new menu', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
const role1Response = await rootAgent.resource('roles').get({
|
||||
filterByTk: role1.name,
|
||||
});
|
||||
expect(role1Response.statusCode).toBe(200);
|
||||
const updateRole1Response = await rootAgent.resource('roles').update({
|
||||
filterByTk: role1.name,
|
||||
values: {
|
||||
...role1Response.body.data,
|
||||
allowNewMobileMenu: true,
|
||||
},
|
||||
});
|
||||
expect(updateRole1Response.statusCode).toBe(200);
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
const page1 = await createUiMenu(rootAgent, { title: 'page1' });
|
||||
|
||||
// auto can see new menu
|
||||
const accessibleMenus = await getAccessibleMenus(agent);
|
||||
expect(accessibleMenus[0].title).toBe(page1.title);
|
||||
});
|
||||
});
|
@ -133,12 +133,12 @@ export class PluginMobileServer extends Plugin {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const role = await rolesRepository.findOne({
|
||||
filterByTk: ctx.state.currentRole,
|
||||
const roles = await rolesRepository.find({
|
||||
filterByTk: ctx.state.currentRoles,
|
||||
appends: ['mobileRoutes'],
|
||||
});
|
||||
|
||||
const mobileRoutesId = role.get('mobileRoutes').map((item) => item.id);
|
||||
const mobileRoutesId = roles.flatMap((x) => x.get('mobileRoutes').map((x) => x.id));
|
||||
|
||||
ctx.body = await mobileRoutesRepository.find({
|
||||
tree: true,
|
||||
|
@ -24,7 +24,7 @@ describe('workflow > actions > executions', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp({
|
||||
plugins: ['users', 'acl', 'auth', 'data-source-manager'],
|
||||
plugins: ['users', 'acl', 'auth', 'data-source-manager', 'system-settings'],
|
||||
acl: true,
|
||||
});
|
||||
agent = await app.agent().loginUsingId(1);
|
||||
|
Loading…
x
Reference in New Issue
Block a user