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:
ajie 2025-03-12 09:03:33 +08:00 committed by GitHub
parent 62f10d9c97
commit d7821bf6d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2134 additions and 102 deletions

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

View File

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

View File

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

View File

@ -14,3 +14,4 @@ export * from './acl-resource';
export * from './acl-role';
export * from './skip-middleware';
export * from './errors';
export * from './utils';

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

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

View File

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

View File

@ -12,3 +12,4 @@ export * from './useAppSpin';
export * from './usePlugin';
export * from './useRouter';
export * from './useGlobalVariable';
export * from './useAclSnippets';

View File

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

View File

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

View File

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

View File

@ -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": "すべての権限"
}

View File

@ -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": "모든 권한"
}

View File

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

View File

@ -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": "Полные права"
}

View File

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

View File

@ -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": "Повні права"
}

View File

@ -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": "全部权限"
}

View File

@ -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": "完全權限"
}

View File

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

View File

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

View File

@ -28,7 +28,6 @@ export const NewRole: React.FC = () => {
type: 'text',
icon: 'PlusOutlined',
style: {
width: '100%',
textAlign: 'left',
},
},

View File

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

View File

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

View File

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

View File

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

View File

@ -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.": "强制用户仅能使用角色并集,不能逐个切换角色。"
}

View File

@ -23,6 +23,7 @@ export async function prepareApp(): Promise<MockServer> {
'auth',
'data-source-manager',
'collection-tree',
'system-settings',
],
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 const UNION_ROLE_KEY = '__union__';

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

View File

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

View File

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

View File

@ -95,6 +95,7 @@ function createWithACLMetaMiddleware() {
},
state: {
currentRole: ctx.state.currentRole,
currentRoles: ctx.state.currentRoles,
currentUser: (() => {
if (!ctx.state.currentUser) {
return null;

View File

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

View File

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

View File

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

View File

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

View File

@ -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],
});
// 验证返回结果包含子路由

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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