mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 06:15:11 +00:00
feat: strategy with resources list (#4312)
* chore: strategy with resources list * chore: append strategy resource when collection loaded * chore: test * chore: no permission error * chore: test * fix: update strategy resources after update collection * fix: test * fix: snippet name * chore: error class import
This commit is contained in:
parent
819ac79f1a
commit
5f5d3f3d90
@ -415,4 +415,28 @@ describe('acl', () => {
|
||||
ctx1.permission.can.params.fields.push('createdById');
|
||||
expect(ctx2.permission.can.params.fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not allow when strategyResources is set', async () => {
|
||||
acl.setAvailableAction('create', {
|
||||
displayName: 'create',
|
||||
type: 'new-data',
|
||||
});
|
||||
|
||||
const role = acl.define({
|
||||
role: 'admin',
|
||||
strategy: {
|
||||
actions: ['create'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(acl.can({ role: 'admin', resource: 'users', action: 'create' })).toBeTruthy();
|
||||
|
||||
acl.setStrategyResources(['posts']);
|
||||
|
||||
expect(acl.can({ role: 'admin', resource: 'users', action: 'create' })).toBeNull();
|
||||
|
||||
acl.setStrategyResources(['posts', 'users']);
|
||||
|
||||
expect(acl.can({ role: 'admin', resource: 'users', action: 'create' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
@ -7,8 +7,56 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MockServer, createMockServer } from '@nocobase/test';
|
||||
import { ACL } from '..';
|
||||
import SnippetManager from '../snippet-manager';
|
||||
describe('nocobase snippet', () => {
|
||||
let app: MockServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await createMockServer({
|
||||
plugins: ['nocobase'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
test('snippet allowed', async () => {
|
||||
const testRole = app.acl.define({
|
||||
role: 'test',
|
||||
});
|
||||
|
||||
testRole.snippets.add('!pm.users');
|
||||
testRole.snippets.add('pm.*');
|
||||
|
||||
expect(
|
||||
app.acl.can({
|
||||
role: 'test',
|
||||
resource: 'users',
|
||||
action: 'list',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow all snippets', async () => {
|
||||
const testRole = app.acl.define({
|
||||
role: 'test',
|
||||
});
|
||||
|
||||
testRole.snippets.add('!pm.acl.roles');
|
||||
testRole.snippets.add('pm.*');
|
||||
|
||||
expect(
|
||||
app.acl.can({
|
||||
role: 'test',
|
||||
resource: 'users',
|
||||
action: 'list',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acl snippet', () => {
|
||||
let acl: ACL;
|
||||
@ -86,6 +134,34 @@ describe('acl snippet', () => {
|
||||
|
||||
expect(adminRole.snippetAllowed('other:list')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return true when last rule allowd', () => {
|
||||
acl.registerSnippet({
|
||||
name: 'sc.collection-manager.fields',
|
||||
actions: ['fields:list'],
|
||||
});
|
||||
|
||||
acl.registerSnippet({
|
||||
name: 'sc.collection-manager.gi',
|
||||
actions: ['fields:list'],
|
||||
});
|
||||
|
||||
acl.registerSnippet({
|
||||
name: 'sc.users',
|
||||
actions: ['users:*'],
|
||||
});
|
||||
|
||||
const adminRole = acl.define({
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
adminRole.snippets.add('!sc.collection-manager.gi');
|
||||
adminRole.snippets.add('!sc.users');
|
||||
adminRole.snippets.add('sc.*');
|
||||
|
||||
expect(acl.can({ role: 'admin', resource: 'fields', action: 'list' })).toBeTruthy();
|
||||
expect(acl.can({ role: 'admin', resource: 'users', action: 'list' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippet manager', () => {
|
||||
@ -135,5 +211,22 @@ describe('snippet manager', () => {
|
||||
|
||||
expect(snippetManager.allow('fields:list', 'sc.collection-manager.fields')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not register snippet named with *', async () => {
|
||||
const snippetManager = new SnippetManager();
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
snippetManager.register({
|
||||
name: 'sc.collection-manager.*',
|
||||
actions: ['collections:*'],
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -107,6 +107,7 @@ export class ACLRole {
|
||||
|
||||
public effectiveSnippets(): { allowed: Array<string>; rejected: Array<string> } {
|
||||
const currentParams = this._serializeSet(this.snippets);
|
||||
|
||||
if (this._snippetCache.params === currentParams) {
|
||||
return this._snippetCache.result;
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import { ACLRole, ResourceActionsOptions, RoleActionParams } from './acl-role';
|
||||
import { AllowManager, ConditionFunc } from './allow-manager';
|
||||
import FixedParamsManager, { Merger } from './fixed-params-manager';
|
||||
import SnippetManager, { SnippetOptions } from './snippet-manager';
|
||||
import { NoPermissionError } from './errors/no-permission-error';
|
||||
|
||||
interface CanResult {
|
||||
role: string;
|
||||
@ -92,6 +93,8 @@ export class ACL extends EventEmitter {
|
||||
|
||||
protected middlewares: Toposort<any>;
|
||||
|
||||
protected strategyResources: Set<string> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@ -124,6 +127,25 @@ export class ACL extends EventEmitter {
|
||||
this.addCoreMiddleware();
|
||||
}
|
||||
|
||||
setStrategyResources(resources: Array<string> | null) {
|
||||
this.strategyResources = new Set(resources);
|
||||
}
|
||||
|
||||
getStrategyResources() {
|
||||
return this.strategyResources ? [...this.strategyResources] : null;
|
||||
}
|
||||
|
||||
appendStrategyResource(resource: string) {
|
||||
if (!this.strategyResources) {
|
||||
this.strategyResources = new Set();
|
||||
}
|
||||
this.strategyResources.add(resource);
|
||||
}
|
||||
|
||||
removeStrategyResource(resource: string) {
|
||||
this.strategyResources.delete(resource);
|
||||
}
|
||||
|
||||
define(options: DefineOptions): ACLRole {
|
||||
const roleName = options.role;
|
||||
const role = new ACLRole(this, roleName);
|
||||
@ -230,7 +252,11 @@ export class ACL extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
let roleStrategyParams = roleStrategy?.allow(resource, this.resolveActionAlias(action));
|
||||
let roleStrategyParams;
|
||||
|
||||
if (this.strategyResources === null || this.strategyResources.has(resource)) {
|
||||
roleStrategyParams = roleStrategy?.allow(resource, this.resolveActionAlias(action));
|
||||
}
|
||||
|
||||
if (!roleStrategyParams && snippetAllowed) {
|
||||
roleStrategyParams = {};
|
||||
@ -391,7 +417,7 @@ export class ACL extends EventEmitter {
|
||||
if (params?.filter?.createdById) {
|
||||
const collection = ctx.db.getCollection(resourceName);
|
||||
if (!collection || !collection.getField('createdById')) {
|
||||
return lodash.omit(params, 'filter.createdById');
|
||||
throw new NoPermissionError('createdById field not found');
|
||||
}
|
||||
}
|
||||
|
||||
@ -419,25 +445,34 @@ export class ACL extends EventEmitter {
|
||||
|
||||
ctx.log?.debug && ctx.log.debug('acl params', params);
|
||||
|
||||
if (params && resourcerAction.mergeParams) {
|
||||
const filteredParams = acl.filterParams(ctx, resourceName, params);
|
||||
const parsedParams = await acl.parseJsonTemplate(filteredParams, ctx);
|
||||
try {
|
||||
if (params && resourcerAction.mergeParams) {
|
||||
const filteredParams = acl.filterParams(ctx, resourceName, params);
|
||||
const parsedParams = await acl.parseJsonTemplate(filteredParams, ctx);
|
||||
|
||||
ctx.permission.parsedParams = parsedParams;
|
||||
ctx.log?.debug && ctx.log.debug('acl parsedParams', parsedParams);
|
||||
ctx.permission.rawParams = lodash.cloneDeep(resourcerAction.params);
|
||||
resourcerAction.mergeParams(parsedParams, {
|
||||
appends: (x, y) => {
|
||||
if (!x) {
|
||||
return [];
|
||||
}
|
||||
if (!y) {
|
||||
return x;
|
||||
}
|
||||
return (x as any[]).filter((i) => y.includes(i.split('.').shift()));
|
||||
},
|
||||
});
|
||||
ctx.permission.mergedParams = lodash.cloneDeep(resourcerAction.params);
|
||||
ctx.permission.parsedParams = parsedParams;
|
||||
ctx.log?.debug && ctx.log.debug('acl parsedParams', parsedParams);
|
||||
ctx.permission.rawParams = lodash.cloneDeep(resourcerAction.params);
|
||||
resourcerAction.mergeParams(parsedParams, {
|
||||
appends: (x, y) => {
|
||||
if (!x) {
|
||||
return [];
|
||||
}
|
||||
if (!y) {
|
||||
return x;
|
||||
}
|
||||
return (x as any[]).filter((i) => y.includes(i.split('.').shift()));
|
||||
},
|
||||
});
|
||||
ctx.permission.mergedParams = lodash.cloneDeep(resourcerAction.params);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof NoPermissionError) {
|
||||
ctx.throw(403, 'No permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
await next();
|
||||
|
10
packages/core/acl/src/errors/index.ts
Normal file
10
packages/core/acl/src/errors/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './no-permission-error';
|
10
packages/core/acl/src/errors/no-permission-error.ts
Normal file
10
packages/core/acl/src/errors/no-permission-error.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export class NoPermissionError extends Error {}
|
@ -13,3 +13,4 @@ export * from './acl-available-strategy';
|
||||
export * from './acl-resource';
|
||||
export * from './acl-role';
|
||||
export * from './skip-middleware';
|
||||
export * from './errors';
|
||||
|
@ -30,6 +30,12 @@ class SnippetManager {
|
||||
public snippets: Map<string, Snippet> = new Map();
|
||||
|
||||
register(snippet: SnippetOptions) {
|
||||
const name = snippet.name;
|
||||
// throw error if name include * or end with dot
|
||||
if (name.includes('*') || name.endsWith('.')) {
|
||||
throw new Error(`Invalid snippet name: ${name}, name should not include * or end with dot.`);
|
||||
}
|
||||
|
||||
this.snippets.set(snippet.name, snippet);
|
||||
}
|
||||
|
||||
|
@ -337,6 +337,7 @@ describe('acl', () => {
|
||||
forceUpdate: true,
|
||||
});
|
||||
|
||||
app.acl.appendStrategyResource('posts');
|
||||
expect(
|
||||
acl.can({
|
||||
role: 'new',
|
||||
@ -887,4 +888,27 @@ describe('acl', () => {
|
||||
expect(destroyResponse.statusCode).toEqual(200);
|
||||
expect(await db.getRepository('roles').findOne({ filterByTk: 'testRole' })).toBeNull();
|
||||
});
|
||||
|
||||
it('should set acl strategy resources', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(app.acl.getStrategyResources()).toContain('posts');
|
||||
|
||||
await db.getRepository('collections').destroy({
|
||||
filterByTk: 'posts',
|
||||
});
|
||||
|
||||
expect(app.acl.getStrategyResources()).not.toContain('posts');
|
||||
});
|
||||
});
|
||||
|
@ -78,6 +78,46 @@ describe('middleware', () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
it('should no permission when createdById field not exists in collection', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'foos',
|
||||
autoGenId: false,
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
primaryKey: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
});
|
||||
|
||||
await db.getRepository('roles').update({
|
||||
filterByTk: 'admin',
|
||||
values: {
|
||||
strategy: {
|
||||
actions: ['create', 'update:own'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await adminAgent.resource('foos').create({
|
||||
values: {
|
||||
name: 'foo-name',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
const updateRes = await adminAgent.resource('foos').update({
|
||||
filterByTk: response.body.data.name,
|
||||
});
|
||||
|
||||
expect(updateRes.statusCode).toEqual(403);
|
||||
});
|
||||
|
||||
it('should throw 403 when no permission', async () => {
|
||||
const response = await app.agent().resource('posts').create({
|
||||
values: {},
|
||||
|
@ -99,6 +99,7 @@ describe('own test', () => {
|
||||
})
|
||||
.set({ Authorization: 'Bearer ' + adminToken });
|
||||
|
||||
acl.appendStrategyResource('tests');
|
||||
const response = await userAgent.get('/tests:list');
|
||||
expect(response.statusCode).toEqual(200);
|
||||
});
|
||||
@ -113,6 +114,7 @@ describe('own test', () => {
|
||||
},
|
||||
});
|
||||
|
||||
acl.appendStrategyResource('posts');
|
||||
let response = await userAgent.resource('posts').create({
|
||||
values: {
|
||||
title: 't1',
|
||||
|
@ -15,5 +15,6 @@ export async function prepareApp(): Promise<MockServer> {
|
||||
acl: true,
|
||||
plugins: ['acl', 'error-handler', 'users', 'ui-schema-storage', 'data-source-main', 'auth', 'data-source-manager'],
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
@ -48,6 +48,28 @@ describe('role api', () => {
|
||||
adminAgent = app.agent().login(admin);
|
||||
});
|
||||
|
||||
it('should have permission to users collection with strategy', async () => {
|
||||
await db.getRepository('roles').create({
|
||||
values: {
|
||||
name: 'tests',
|
||||
strategy: {
|
||||
actions: ['view'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user1 = await db.getRepository('users').create({
|
||||
values: {
|
||||
roles: ['tests'],
|
||||
},
|
||||
});
|
||||
|
||||
const userAgent = app.agent().login(user1);
|
||||
|
||||
const response = await userAgent.resource('users').list();
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('should list actions', async () => {
|
||||
const response = await adminAgent.resource('availableActions').list();
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
@ -9,8 +9,7 @@
|
||||
|
||||
import lodash from 'lodash';
|
||||
import { snakeCase } from '@nocobase/database';
|
||||
|
||||
class NoPermissionError extends Error {}
|
||||
import { NoPermissionError } from '@nocobase/acl';
|
||||
|
||||
function createWithACLMetaMiddleware() {
|
||||
return async (ctx: any, next) => {
|
||||
|
@ -106,6 +106,8 @@ export class PluginACLServer extends Plugin {
|
||||
'dataSources:list',
|
||||
'roles.dataSourcesCollections:*',
|
||||
'roles.dataSourceResources:*',
|
||||
'dataSourcesRolesResourcesScopes:*',
|
||||
'rolesResourcesScopes:*',
|
||||
],
|
||||
});
|
||||
|
||||
@ -576,6 +578,22 @@ export class PluginACLServer extends Plugin {
|
||||
},
|
||||
{ after: 'dataSource', group: 'with-acl-meta' },
|
||||
);
|
||||
|
||||
this.db.on('afterUpdateCollection', async (collection) => {
|
||||
if (collection.options.loadedFromCollectionManager) {
|
||||
this.app.acl.appendStrategyResource(collection.name);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.on('afterDefineCollection', async (collection) => {
|
||||
if (collection.options.loadedFromCollectionManager) {
|
||||
this.app.acl.appendStrategyResource(collection.name);
|
||||
}
|
||||
});
|
||||
|
||||
this.db.on('afterRemoveCollection', (collection) => {
|
||||
this.app.acl.removeStrategyResource(collection.name);
|
||||
});
|
||||
}
|
||||
|
||||
async install() {
|
||||
|
@ -173,6 +173,7 @@ export class CollectionRepository extends Repository {
|
||||
|
||||
const options = collection.options;
|
||||
const fields = [];
|
||||
|
||||
for (const [name, field] of collection.fields) {
|
||||
fields.push({
|
||||
name,
|
||||
|
@ -148,7 +148,7 @@ export default class PluginUsersServer extends Plugin {
|
||||
loggedInActions.forEach((action) => this.app.acl.allow('users', action, 'loggedIn'));
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}.*`,
|
||||
name: `pm.${this.name}`,
|
||||
actions: ['users:*'],
|
||||
});
|
||||
}
|
||||
@ -200,6 +200,7 @@ export default class PluginUsersServer extends Plugin {
|
||||
async install(options) {
|
||||
const { rootNickname, rootPassword, rootEmail, rootUsername } = this.getInstallingData(options);
|
||||
const User = this.db.getCollection('users');
|
||||
|
||||
if (await User.repository.findOne({ filter: { email: rootEmail } })) {
|
||||
return;
|
||||
}
|
||||
@ -214,6 +215,7 @@ export default class PluginUsersServer extends Plugin {
|
||||
});
|
||||
|
||||
const repo = this.db.getRepository<any>('collections');
|
||||
|
||||
if (repo) {
|
||||
await repo.db2cm('users');
|
||||
}
|
||||
|
@ -229,7 +229,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
});
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
name: 'ui.*',
|
||||
name: 'ui.workflows',
|
||||
actions: ['workflows:list'],
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user