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:
ChengLei Shao 2024-05-11 23:08:50 +08:00 committed by GitHub
parent 819ac79f1a
commit 5f5d3f3d90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 313 additions and 24 deletions

View File

@ -415,4 +415,28 @@ describe('acl', () => {
ctx1.permission.can.params.fields.push('createdById'); ctx1.permission.can.params.fields.push('createdById');
expect(ctx2.permission.can.params.fields).toEqual([]); 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();
});
}); });

View File

@ -7,8 +7,56 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { MockServer, createMockServer } from '@nocobase/test';
import { ACL } from '..'; import { ACL } from '..';
import SnippetManager from '../snippet-manager'; 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', () => { describe('acl snippet', () => {
let acl: ACL; let acl: ACL;
@ -86,6 +134,34 @@ describe('acl snippet', () => {
expect(adminRole.snippetAllowed('other:list')).toBeNull(); 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', () => { describe('snippet manager', () => {
@ -135,5 +211,22 @@ describe('snippet manager', () => {
expect(snippetManager.allow('fields:list', 'sc.collection-manager.fields')).toBeNull(); 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();
});
}); });
}); });

View File

@ -107,6 +107,7 @@ export class ACLRole {
public effectiveSnippets(): { allowed: Array<string>; rejected: Array<string> } { public effectiveSnippets(): { allowed: Array<string>; rejected: Array<string> } {
const currentParams = this._serializeSet(this.snippets); const currentParams = this._serializeSet(this.snippets);
if (this._snippetCache.params === currentParams) { if (this._snippetCache.params === currentParams) {
return this._snippetCache.result; return this._snippetCache.result;
} }

View File

@ -18,6 +18,7 @@ import { ACLRole, ResourceActionsOptions, RoleActionParams } from './acl-role';
import { AllowManager, ConditionFunc } from './allow-manager'; import { AllowManager, ConditionFunc } from './allow-manager';
import FixedParamsManager, { Merger } from './fixed-params-manager'; import FixedParamsManager, { Merger } from './fixed-params-manager';
import SnippetManager, { SnippetOptions } from './snippet-manager'; import SnippetManager, { SnippetOptions } from './snippet-manager';
import { NoPermissionError } from './errors/no-permission-error';
interface CanResult { interface CanResult {
role: string; role: string;
@ -92,6 +93,8 @@ export class ACL extends EventEmitter {
protected middlewares: Toposort<any>; protected middlewares: Toposort<any>;
protected strategyResources: Set<string> | null = null;
constructor() { constructor() {
super(); super();
@ -124,6 +127,25 @@ export class ACL extends EventEmitter {
this.addCoreMiddleware(); 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 { define(options: DefineOptions): ACLRole {
const roleName = options.role; const roleName = options.role;
const role = new ACLRole(this, roleName); const role = new ACLRole(this, roleName);
@ -230,7 +252,11 @@ export class ACL extends EventEmitter {
return null; 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) { if (!roleStrategyParams && snippetAllowed) {
roleStrategyParams = {}; roleStrategyParams = {};
@ -391,7 +417,7 @@ export class ACL extends EventEmitter {
if (params?.filter?.createdById) { if (params?.filter?.createdById) {
const collection = ctx.db.getCollection(resourceName); const collection = ctx.db.getCollection(resourceName);
if (!collection || !collection.getField('createdById')) { 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); ctx.log?.debug && ctx.log.debug('acl params', params);
if (params && resourcerAction.mergeParams) { try {
const filteredParams = acl.filterParams(ctx, resourceName, params); if (params && resourcerAction.mergeParams) {
const parsedParams = await acl.parseJsonTemplate(filteredParams, ctx); const filteredParams = acl.filterParams(ctx, resourceName, params);
const parsedParams = await acl.parseJsonTemplate(filteredParams, ctx);
ctx.permission.parsedParams = parsedParams; ctx.permission.parsedParams = parsedParams;
ctx.log?.debug && ctx.log.debug('acl parsedParams', parsedParams); ctx.log?.debug && ctx.log.debug('acl parsedParams', parsedParams);
ctx.permission.rawParams = lodash.cloneDeep(resourcerAction.params); ctx.permission.rawParams = lodash.cloneDeep(resourcerAction.params);
resourcerAction.mergeParams(parsedParams, { resourcerAction.mergeParams(parsedParams, {
appends: (x, y) => { appends: (x, y) => {
if (!x) { if (!x) {
return []; return [];
} }
if (!y) { if (!y) {
return x; return x;
} }
return (x as any[]).filter((i) => y.includes(i.split('.').shift())); return (x as any[]).filter((i) => y.includes(i.split('.').shift()));
}, },
}); });
ctx.permission.mergedParams = lodash.cloneDeep(resourcerAction.params); ctx.permission.mergedParams = lodash.cloneDeep(resourcerAction.params);
}
} catch (e) {
if (e instanceof NoPermissionError) {
ctx.throw(403, 'No permissions');
return;
}
throw e;
} }
await next(); await next();

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 './no-permission-error';

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 class NoPermissionError extends Error {}

View File

@ -13,3 +13,4 @@ export * from './acl-available-strategy';
export * from './acl-resource'; export * from './acl-resource';
export * from './acl-role'; export * from './acl-role';
export * from './skip-middleware'; export * from './skip-middleware';
export * from './errors';

View File

@ -30,6 +30,12 @@ class SnippetManager {
public snippets: Map<string, Snippet> = new Map(); public snippets: Map<string, Snippet> = new Map();
register(snippet: SnippetOptions) { 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); this.snippets.set(snippet.name, snippet);
} }

View File

@ -337,6 +337,7 @@ describe('acl', () => {
forceUpdate: true, forceUpdate: true,
}); });
app.acl.appendStrategyResource('posts');
expect( expect(
acl.can({ acl.can({
role: 'new', role: 'new',
@ -887,4 +888,27 @@ describe('acl', () => {
expect(destroyResponse.statusCode).toEqual(200); expect(destroyResponse.statusCode).toEqual(200);
expect(await db.getRepository('roles').findOne({ filterByTk: 'testRole' })).toBeNull(); 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');
});
}); });

View File

@ -78,6 +78,46 @@ describe('middleware', () => {
await app.destroy(); 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 () => { it('should throw 403 when no permission', async () => {
const response = await app.agent().resource('posts').create({ const response = await app.agent().resource('posts').create({
values: {}, values: {},

View File

@ -99,6 +99,7 @@ describe('own test', () => {
}) })
.set({ Authorization: 'Bearer ' + adminToken }); .set({ Authorization: 'Bearer ' + adminToken });
acl.appendStrategyResource('tests');
const response = await userAgent.get('/tests:list'); const response = await userAgent.get('/tests:list');
expect(response.statusCode).toEqual(200); expect(response.statusCode).toEqual(200);
}); });
@ -113,6 +114,7 @@ describe('own test', () => {
}, },
}); });
acl.appendStrategyResource('posts');
let response = await userAgent.resource('posts').create({ let response = await userAgent.resource('posts').create({
values: { values: {
title: 't1', title: 't1',

View File

@ -15,5 +15,6 @@ export async function prepareApp(): Promise<MockServer> {
acl: true, acl: true,
plugins: ['acl', 'error-handler', 'users', 'ui-schema-storage', 'data-source-main', 'auth', 'data-source-manager'], plugins: ['acl', 'error-handler', 'users', 'ui-schema-storage', 'data-source-main', 'auth', 'data-source-manager'],
}); });
return app; return app;
} }

View File

@ -48,6 +48,28 @@ describe('role api', () => {
adminAgent = app.agent().login(admin); 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 () => { it('should list actions', async () => {
const response = await adminAgent.resource('availableActions').list(); const response = await adminAgent.resource('availableActions').list();
expect(response.statusCode).toEqual(200); expect(response.statusCode).toEqual(200);

View File

@ -9,8 +9,7 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { snakeCase } from '@nocobase/database'; import { snakeCase } from '@nocobase/database';
import { NoPermissionError } from '@nocobase/acl';
class NoPermissionError extends Error {}
function createWithACLMetaMiddleware() { function createWithACLMetaMiddleware() {
return async (ctx: any, next) => { return async (ctx: any, next) => {

View File

@ -106,6 +106,8 @@ export class PluginACLServer extends Plugin {
'dataSources:list', 'dataSources:list',
'roles.dataSourcesCollections:*', 'roles.dataSourcesCollections:*',
'roles.dataSourceResources:*', 'roles.dataSourceResources:*',
'dataSourcesRolesResourcesScopes:*',
'rolesResourcesScopes:*',
], ],
}); });
@ -576,6 +578,22 @@ export class PluginACLServer extends Plugin {
}, },
{ after: 'dataSource', group: 'with-acl-meta' }, { 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() { async install() {

View File

@ -173,6 +173,7 @@ export class CollectionRepository extends Repository {
const options = collection.options; const options = collection.options;
const fields = []; const fields = [];
for (const [name, field] of collection.fields) { for (const [name, field] of collection.fields) {
fields.push({ fields.push({
name, name,

View File

@ -148,7 +148,7 @@ export default class PluginUsersServer extends Plugin {
loggedInActions.forEach((action) => this.app.acl.allow('users', action, 'loggedIn')); loggedInActions.forEach((action) => this.app.acl.allow('users', action, 'loggedIn'));
this.app.acl.registerSnippet({ this.app.acl.registerSnippet({
name: `pm.${this.name}.*`, name: `pm.${this.name}`,
actions: ['users:*'], actions: ['users:*'],
}); });
} }
@ -200,6 +200,7 @@ export default class PluginUsersServer extends Plugin {
async install(options) { async install(options) {
const { rootNickname, rootPassword, rootEmail, rootUsername } = this.getInstallingData(options); const { rootNickname, rootPassword, rootEmail, rootUsername } = this.getInstallingData(options);
const User = this.db.getCollection('users'); const User = this.db.getCollection('users');
if (await User.repository.findOne({ filter: { email: rootEmail } })) { if (await User.repository.findOne({ filter: { email: rootEmail } })) {
return; return;
} }
@ -214,6 +215,7 @@ export default class PluginUsersServer extends Plugin {
}); });
const repo = this.db.getRepository<any>('collections'); const repo = this.db.getRepository<any>('collections');
if (repo) { if (repo) {
await repo.db2cm('users'); await repo.db2cm('users');
} }

View File

@ -229,7 +229,7 @@ export default class PluginWorkflowServer extends Plugin {
}); });
this.app.acl.registerSnippet({ this.app.acl.registerSnippet({
name: 'ui.*', name: 'ui.workflows',
actions: ['workflows:list'], actions: ['workflows:list'],
}); });