fix: correct role union calculation logic (#6605)

* refactor: enhance role merging logic: prioritize strategy actions over existing actions

* refactor: union role acl

* refactor: union role acl

* refactor: union role acl

* fix: assign getKeys

* fix: assign

* refactor: switch role set default role record

* refactor: switch role set default role record

* refactor: switch role set default role record

* refactor: switch role set default role record

* refactor: add setCurrentRole cache

* fix: e2e test

* fix: setCurrentRole

* refactor: switch role

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ajie 2025-04-09 19:06:05 +08:00 committed by GitHub
parent 2d9545a719
commit 41961fb923
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 403 additions and 31 deletions

View File

@ -30,9 +30,65 @@ export function mergeRole(roles: ACLRole[]) {
}
}
result.snippets = mergeRoleSnippets(allSnippets);
adjustActionByStrategy(roles, result);
return result;
}
/**
* When merging permissions from multiple roles, if strategy.actions allows certain actions, then those actions have higher priority.
* For example, [
* {
* actions: {
* 'users:view': {...},
* 'users:create': {...}
* },
* strategy: {
* actions: ['view']
* }
* }]
* finally result: [{
* actions: {
* 'users:create': {...},
* 'users:view': {} // all view
* },
* {
* strategy: {
* actions: ['view']
* }]
**/
function adjustActionByStrategy(
roles,
result: {
actions?: Record<string, object>;
strategy?: { actions?: string[] };
resources?: string[];
},
) {
const { actions, strategy } = result;
const actionSet = getAdjustActions(roles);
if (!_.isEmpty(actions) && !_.isEmpty(strategy?.actions) && !_.isEmpty(result.resources)) {
for (const resource of result.resources) {
for (const action of strategy.actions) {
if (actionSet.has(action)) {
actions[`${resource}:${action}`] = {};
}
}
}
}
}
function getAdjustActions(roles: ACLRole[]) {
const actionSet = new Set<string>();
for (const role of roles) {
const jsonRole = role.toJSON();
// Within the same role, actions have higher priority than strategy.actions.
if (!_.isEmpty(jsonRole.strategy?.['actions']) && _.isEmpty(jsonRole.actions)) {
jsonRole.strategy['actions'].forEach((x) => !x.includes('own') && actionSet.add(x));
}
}
return actionSet;
}
function mergeRoleNames(sourceRoleNames, newRoleName) {
return newRoleName ? sourceRoleNames.concat(newRoleName) : sourceRoleNames;
}

View File

@ -315,6 +315,34 @@ describe('merge strategy', () => {
key1: 'val1 + val2',
});
});
it('case 2', () => {
const obj = assign(
{
filter: { a: 'a2' },
},
{},
{
filter: () => '123',
},
);
expect(obj).toMatchObject({
filter: '123',
});
});
it('case 3', () => {
const obj = assign(
{},
{
filter: { a: 'a2' },
},
{
filter: () => '123',
},
);
expect(obj).toMatchObject({
filter: '123',
});
});
});
describe('merge', () => {
@ -416,4 +444,32 @@ describe('merge strategy', () => {
});
});
});
describe('source is empty', () => {
it('case 1', () => {
const obj = assign(
{
resourceName: 'uiSchemas',
resourceIndex: 'n0jylid5rqa',
actionName: 'getJsonSchema',
values: {},
},
{},
{
filter: 'andMerge',
fields: 'intersect',
except: 'union',
whitelist: 'intersect',
blacklist: 'intersect',
sort: 'overwrite',
},
);
expect(obj).toMatchObject({
resourceName: 'uiSchemas',
resourceIndex: 'n0jylid5rqa',
actionName: 'getJsonSchema',
values: {},
});
});
});
});

View File

@ -8,7 +8,7 @@
*/
import deepmerge from 'deepmerge';
import lodash from 'lodash';
import _ from 'lodash';
import { isPlainObject } from './common';
type MergeStrategyType = 'merge' | 'deepMerge' | 'overwrite' | 'andMerge' | 'orMerge' | 'intersect' | 'union';
@ -88,7 +88,7 @@ mergeStrategies.set('union', (x, y) => {
if (typeof y === 'string') {
y = y.split(',');
}
return lodash.uniq((x || []).concat(y || [])).filter(Boolean);
return _.uniq((x || []).concat(y || [])).filter(Boolean);
});
mergeStrategies.set('intersect', (x, y) =>
@ -110,15 +110,22 @@ mergeStrategies.set('intersect', (x, y) =>
);
export function assign(target: any, source: any, strategies: MergeStrategies = {}) {
getKeys(source).forEach((sourceKey) => {
const sourceKeys = getKeys(source);
const targetKeys = getKeys(target);
_.uniq([...sourceKeys, ...targetKeys]).forEach((sourceKey) => {
const strategy = strategies[sourceKey];
let func = mergeStrategies.get('deepMerge');
let func: any;
if (typeof strategy === 'function') {
func = strategy;
} else if (typeof strategy === 'string' && mergeStrategies.has(strategy as any)) {
func = mergeStrategies.get(strategy as any);
}
target[sourceKey] = func(target[sourceKey], source[sourceKey]);
if (func) {
target[sourceKey] = func(target[sourceKey], source[sourceKey]);
} else if (sourceKeys.includes(sourceKey)) {
const func = mergeStrategies.get('deepMerge');
target[sourceKey] = func(target[sourceKey], source[sourceKey]);
}
});
return target;
}

View File

@ -17,14 +17,20 @@ import {
useCurrentRoleMode,
} from '@nocobase/client';
import { Divider } from 'antd';
import _ from 'lodash';
export const SwitchRole = () => {
const { t } = useTranslation();
const api = useAPIClient();
const roles = useCurrentRoles();
const roles = _.cloneDeep(useCurrentRoles());
const roleMode = useCurrentRoleMode();
const currentRole = roles.find((role) => role.name === api.auth.role)?.name;
if (roleMode === 'allow-use-union') {
roles.unshift({
name: '__union__',
title: t('Full permissions', { ns: 'acl' }),
});
}
// 当角色数量小于等于1 或者 是仅使用合并角色模式时,不显示切换角色选项
if (roles.length <= 1 || roleMode === 'only-use-union') {
return null;

View File

@ -11,6 +11,8 @@ import Database, { BelongsToManyRepository } from '@nocobase/database';
import UsersPlugin from '@nocobase/plugin-users';
import { createMockServer, MockServer } from '@nocobase/test';
import jwt from 'jsonwebtoken';
import { SystemRoleMode } from '../enum';
import { UNION_ROLE_KEY } from '../constants';
describe('role', () => {
let api: MockServer;
@ -20,7 +22,7 @@ describe('role', () => {
beforeEach(async () => {
api = await createMockServer({
plugins: ['field-sort', 'users', 'acl', 'auth', 'data-source-manager', 'system-settings'],
plugins: ['field-sort', 'users', 'acl', 'auth', 'data-source-manager', 'system-settings', 'ui-schema-storage'],
});
db = api.db;
usersPlugin = api.getPlugin('users');
@ -163,4 +165,77 @@ describe('role', () => {
expect(defaultRole['name']).toEqual('test2');
});
it('should set users default role is __union__', async () => {
const role = await db.getRepository('roles').create({
values: {
name: 'test',
title: 'test user',
allowConfigure: true,
},
});
const user = await db.getRepository('users').create({
values: {
token: '123',
roles: [role.name],
},
});
const repo = db.getRepository('systemSettings');
await repo.update({
filter: { id: 1 },
values: {
roleMode: SystemRoleMode.allowUseUnion,
},
});
const userToken = jwt.sign({ userId: user.get('id') }, 'test-key');
const response = await api
.agent()
.post('/users:setDefaultRole')
.send({
roleName: UNION_ROLE_KEY,
})
.set({
Authorization: `Bearer ${userToken}`,
'X-Authenticator': 'basic',
});
expect(response.statusCode).toEqual(200);
let userRole = await db.getRepository('rolesUsers').findOne({ where: { userId: user.get('id'), default: true } });
expect(userRole.roleName).toEqual(UNION_ROLE_KEY);
// switch
const response1 = await api
.agent()
.post('/users:setDefaultRole')
.send({
roleName: role.name,
})
.set({
Authorization: `Bearer ${userToken}`,
'X-Authenticator': 'basic',
});
expect(response1.statusCode).toEqual(200);
userRole = await db.getRepository('rolesUsers').findOne({ where: { userId: user.get('id'), default: true } });
expect(userRole.roleName).toEqual(role.name);
const response2 = await api
.agent()
.post('/users:setDefaultRole')
.send({
roleName: UNION_ROLE_KEY,
})
.set({
Authorization: `Bearer ${userToken}`,
'X-Authenticator': 'basic',
});
expect(response2.statusCode).toEqual(200);
userRole = await db.getRepository('rolesUsers').findOne({ where: { userId: user.get('id'), default: true } });
expect(userRole.roleName).toEqual(UNION_ROLE_KEY);
const agent = await api.agent().login(user);
const response3 = await agent.resource('roles').check();
expect(response3.statusCode).toEqual(200);
});
});

View File

@ -530,4 +530,150 @@ describe('union role: full permissions', async () => {
expect(createRoleResponse.statusCode).toBe(200);
expect(createRoleResponse.body.data.role).not.toBe(UNION_ROLE_KEY);
});
it('should general action permissions override specific resource permissions when using union role #1924', async () => {
const rootAgent = await app.agent().login(rootUser);
await rootAgent
.post(`/dataSources/main/roles:update`)
.query({
filterByTk: role1.name,
})
.send({
roleName: role1.name,
strategy: {
actions: ['view'],
},
dataSourceKey: 'main',
});
const ownDataSourceScopeRole = await db.getRepository('dataSourcesRolesResourcesScopes').findOne({
where: {
key: 'own',
dataSourceKey: 'main',
},
});
const scopeFields = ['id', 'createdBy', 'createdById'];
const dataSourceResourcesResponse = await rootAgent
.post(`/roles/${role2.name}/dataSourceResources:create`)
.query({
filterByTk: 'users',
filter: {
dataSourceKey: 'main',
name: 'users',
},
})
.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: 'users',
dataSourceKey: 'main',
});
expect(dataSourceResourcesResponse.statusCode).toBe(200);
agent = await app.agent().login(user, UNION_ROLE_KEY);
const rolesResponse = await agent.resource('roles').check();
expect(rolesResponse.status).toBe(200);
expect(rolesResponse.body.data.actions['users:view']).toStrictEqual({});
});
it('should verify actions configuration for union role with specific scopes', async () => {
const rootAgent = await app.agent().login(rootUser);
await rootAgent
.post(`/dataSources/main/roles:update`)
.query({
filterByTk: role1.name,
})
.send({
roleName: role1.name,
strategy: {
actions: ['view', 'create:own', 'update'],
},
dataSourceKey: 'main',
});
const ownDataSourceScopeRole = await db.getRepository('dataSourcesRolesResourcesScopes').findOne({
where: {
key: 'own',
dataSourceKey: 'main',
},
});
const scopeFields = ['id', 'createdBy', 'createdById'];
const dataSourceResourcesResponse = await rootAgent
.post(`/roles/${role2.name}/dataSourceResources:create`)
.query({
filterByTk: 'users',
filter: {
dataSourceKey: 'main',
name: 'users',
},
})
.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: 'create',
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: 'users',
dataSourceKey: 'main',
});
expect(dataSourceResourcesResponse.statusCode).toBe(200);
agent = await app.agent().login(user, UNION_ROLE_KEY);
const rolesResponse = await agent.resource('roles').check();
expect(rolesResponse.status).toBe(200);
expect(rolesResponse.body.data.actions).toHaveProperty('users:create');
expect(rolesResponse.body.data.actions).toHaveProperty('users:view');
expect(rolesResponse.body.data.actions['users:view']).toStrictEqual({});
expect(rolesResponse.body.data.actions).not.toHaveProperty('users:create:own');
expect(rolesResponse.body.data.actions['users:create']).toHaveProperty('filter');
expect(rolesResponse.body.data.actions['users:create']).toHaveProperty('whitelist');
expect(rolesResponse.body.data.actions['users:update']).toStrictEqual({});
});
});

View File

@ -29,25 +29,47 @@ export async function setDefaultRole(ctx: Context, next: Next) {
const repository = db.getRepository('rolesUsers');
await db.sequelize.transaction(async (transaction) => {
await repository.update({
const currentUserDefaultRole = await repository.findOne({
filter: {
userId: currentUser.id,
},
values: {
default: false,
},
transaction,
});
await repository.update({
filter: {
userId: currentUser.id,
roleName,
},
values: {
default: true,
},
transaction,
});
if (currentUserDefaultRole?.roleName === roleName) {
return;
}
if (currentUserDefaultRole) {
await repository.model.update(
{ default: false },
{ where: { userId: currentUser.id, roleName: currentUserDefaultRole.roleName }, transaction },
);
}
const targetUserRole = await repository.findOne({
filter: {
userId: currentUser.id,
roleName,
},
transaction,
});
let model;
if (targetUserRole) {
await repository.model.update({ default: true }, { where: { userId: currentUser.id, roleName }, transaction });
model = targetUserRole.set('default', true);
} else {
model = await repository.create({
values: {
userId: currentUser.id,
roleName,
default: true,
},
transaction,
});
}
db.emitAsync('rolesUsers.afterSave', model);
});
ctx.body = 'ok';

View File

@ -47,7 +47,9 @@ 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 systemSettings = (await cache.wrap(`app:systemSettings`, () =>
ctx.db.getRepository('systemSettings').findOne(),
)) as Model;
const roleMode = systemSettings?.get('roleMode') || SystemRoleMode.default;
if ([currentRole, ctx.state.currentRole].includes(UNION_ROLE_KEY) && roleMode === SystemRoleMode.default) {
currentRole = userRoles[0].name;
@ -58,12 +60,6 @@ export async function setCurrentRole(ctx: Context, next) {
ctx.headers['x-role'] = UNION_ROLE_KEY;
ctx.state.currentRoles = userRoles.map((role) => role.name);
return next();
} else if (roleMode === SystemRoleMode.allowUseUnion) {
userRoles.unshift({
name: UNION_ROLE_KEY,
title: ctx.t('Full permissions', { ns: 'acl' }),
});
ctx.state.currentUser.roles = userRoles;
}
if (currentRole === UNION_ROLE_KEY) {
@ -85,11 +81,13 @@ export async function setCurrentRole(ctx: Context, next) {
}
// 2. If the X-Role is not set, or the X-Role does not belong to the user, use the default role
if (!role) {
const defaultRole = userRoles.find((role) => role?.rolesUsers?.default);
role = (defaultRole || userRoles.find((x) => x.name !== UNION_ROLE_KEY))?.name;
const defaultRoleModel = await cache.wrap(`roles:${ctx.state.currentUser.id}:defaultRole`, () =>
ctx.db.getRepository('rolesUsers').findOne({ where: { userId: ctx.state.currentUser.id, default: true } }),
);
role = defaultRoleModel?.roleName || userRoles[0]?.name;
}
ctx.state.currentRole = role;
ctx.state.currentRoles = [role];
ctx.state.currentRoles = role === UNION_ROLE_KEY ? [userRoles[0]?.name] : [role];
if (!ctx.state.currentRoles.length) {
return ctx.throw(401, {
code: 'ROLE_NOT_FOUND_ERR',

View File

@ -341,10 +341,16 @@ export class PluginACLServer extends Plugin {
this.app.db.on('rolesUsers.afterSave', async (model) => {
const cache = this.app.cache as Cache;
await cache.del(`roles:${model.get('userId')}`);
await cache.del(`roles:${model.get('userId')}:defaultRole`);
});
this.app.db.on('systemSettings.afterSave', async (model) => {
const cache = this.app.cache as Cache;
await cache.del(`app:systemSettings`);
});
this.app.db.on('rolesUsers.afterDestroy', async (model) => {
const cache = this.app.cache as Cache;
await cache.del(`roles:${model.get('userId')}`);
await cache.del(`roles:${model.get('userId')}:defaultRole`);
});
const writeRolesToACL = async (app, options) => {