feat: add acl plugin (#169)
* feat: getRepository * getRepository return type * export action * add: acl * feat: setResourceAction * feat: action alias * chore: code struct * feat: removeResourceAction * chore: file name * ignorecase * remove ACL * feat: ACL * feat: role toJSON * using emit * chore: test * feat: plugin-acl * feat: acl with predicate * grant universal action test * grant action test * update resource action test * revoke resource action * usingActionsConfig switch * plugin-ui-schema-storage * remove global acl instance * fix: collection manager with sqlite * add own action listener * add acl middleware * add acl allowConfigure strategy option * add plugin-acl allowConfigure * change acl resourceName * add acl middleware merge params * bugfix * append fields on acl action params * acl middleware parse template * fix: collection-manager migrate * add acl association field test * feat(plugin-acl): grant association field actions * chore(plugin-acl): type name * feat(plugin-acl): regrant actions on resource action update * feat(plugin-acl): regrant action on field destroy * fix(plugin-acl): test * fix(plugin-acl): test run * feat(plugin-acl): set default role * feat(plugin-users): set user default role * test(plugin-users): create user with role * feat(plugin-users): create user with role Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
		
							parent
							
								
									8e1543269f
								
							
						
					
					
						commit
						7a7ab2ef41
					
				| @ -35,7 +35,7 @@ export class ACLResource { | ||||
|   } | ||||
| 
 | ||||
|   getAction(name: string) { | ||||
|     return this.actions.get(this.acl.resolveActionAlias(name)); | ||||
|     return this.actions.get(name) || this.actions.get(this.acl.resolveActionAlias(name)); | ||||
|   } | ||||
| 
 | ||||
|   setAction(name: string, params: RoleActionParams) { | ||||
|  | ||||
| @ -52,7 +52,11 @@ export class ACLRole { | ||||
|   } | ||||
| 
 | ||||
|   public revokeResource(resourceName) { | ||||
|     this.resources.delete(resourceName); | ||||
|     for (const key of [...this.resources.keys()]) { | ||||
|       if (key === resourceName || key.includes(`${resourceName}.`)) { | ||||
|         this.resources.delete(key); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public grantAction(path: string, options?: RoleActionParams) { | ||||
| @ -101,6 +105,7 @@ export class ACLRole { | ||||
|     const [resourceName, actionName] = path.split(':'); | ||||
| 
 | ||||
|     const resource = this.resources.get(resourceName); | ||||
| 
 | ||||
|     let action = null; | ||||
|     if (resource) { | ||||
|       action = resource.getAction(actionName); | ||||
|  | ||||
| @ -144,10 +144,6 @@ export class ACL extends EventEmitter { | ||||
|   } | ||||
| 
 | ||||
|   can({ role, resource, action }: CanArgs): CanResult | null { | ||||
|     if (!this.isAvailableAction(action)) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const aclRole = this.roles.get(role); | ||||
|     const aclResource = aclRole.getResource(resource); | ||||
| 
 | ||||
|  | ||||
| @ -70,6 +70,7 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa | ||||
|       where: { | ||||
|         [Op.and]: where, | ||||
|       }, | ||||
|       individualHooks: true, | ||||
|       transaction, | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -158,6 +158,13 @@ export abstract class MultipleRelationRepository extends RelationRepository { | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     for (const instance of instances) { | ||||
|       if (options.hooks !== false) { | ||||
|         await this.db.emitAsync(`${this.targetCollection.name}.afterUpdateWithAssociations`, instance, options); | ||||
|         await this.db.emitAsync(`${this.targetCollection.name}.afterSaveWithAssociations`, instance, options); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return instances; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { updateAssociations } from '../update-associations'; | ||||
| import lodash from 'lodash'; | ||||
| import { transactionWrapperBuilder } from '../transaction-decorator'; | ||||
| import { RelationField } from '../fields/relation-field'; | ||||
| import Database from '../database'; | ||||
| 
 | ||||
| export const transaction = transactionWrapperBuilder(function () { | ||||
|   return this.sourceCollection.model.sequelize.transaction(); | ||||
| @ -22,8 +23,11 @@ export abstract class RelationRepository { | ||||
|   associationField: RelationField; | ||||
|   sourceKeyValue: string | number; | ||||
|   sourceInstance: Model; | ||||
|   db: Database; | ||||
| 
 | ||||
|   constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) { | ||||
|     this.db = sourceCollection.context.database; | ||||
| 
 | ||||
|     this.sourceCollection = sourceCollection; | ||||
|     this.sourceKeyValue = sourceKeyValue; | ||||
|     this.associationName = association; | ||||
| @ -55,6 +59,11 @@ export abstract class RelationRepository { | ||||
| 
 | ||||
|     await updateAssociations(instance, values, options); | ||||
| 
 | ||||
|     if (options.hooks !== false) { | ||||
|       const eventName = `${this.targetCollection.name}.afterSaveWithAssociations`; | ||||
|       await this.db.emitAsync(eventName, instance, options); | ||||
|     } | ||||
| 
 | ||||
|     return instance; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -306,6 +306,7 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e | ||||
| 
 | ||||
|     if (options.hooks !== false) { | ||||
|       await this.database.emitAsync(`${this.collection.name}.afterCreateWithAssociations`, instance, options); | ||||
|       await this.database.emitAsync(`${this.collection.name}.afterSaveWithAssociations`, instance, options); | ||||
|     } | ||||
| 
 | ||||
|     return instance; | ||||
| @ -361,6 +362,13 @@ export class Repository<TModelAttributes extends {} = any, TCreationAttributes e | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     if (options.hooks !== false) { | ||||
|       for (const instance of instances) { | ||||
|         await this.database.emitAsync(`${this.collection.name}.afterUpdateWithAssociations`, instance, options); | ||||
|         await this.database.emitAsync(`${this.collection.name}.afterSaveWithAssociations`, instance, options); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return instances; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -43,8 +43,8 @@ export function transactionWrapperBuilder(transactionGenerator) { | ||||
| 
 | ||||
|             return results; | ||||
|           } catch (err) { | ||||
|             await transaction.rollback(); | ||||
|             console.error({ err }); | ||||
|             await transaction.rollback(); | ||||
|             throw err; | ||||
|           } | ||||
|         } else { | ||||
|  | ||||
| @ -7,7 +7,7 @@ import { | ||||
|   Hookable, | ||||
|   Model, | ||||
|   ModelCtor, | ||||
|   Transactionable | ||||
|   Transactionable, | ||||
| } from 'sequelize'; | ||||
| import { TransactionAble } from './repository'; | ||||
| import { UpdateGuard } from './update-guard'; | ||||
| @ -179,9 +179,6 @@ export async function updateAssociation( | ||||
| ) { | ||||
|   const association = modelAssociationByKey(instance, key); | ||||
| 
 | ||||
|   // @ts-ignore
 | ||||
|   console.log(key, options.context); | ||||
| 
 | ||||
|   if (!association) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
| @ -147,7 +147,6 @@ describe('acl', () => { | ||||
|       action: 'create', | ||||
|       params: { | ||||
|         filter: { published: true }, | ||||
|         fields: [], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
| @ -162,7 +161,7 @@ describe('acl', () => { | ||||
|       resource: 'c1', | ||||
|       action: 'view', | ||||
|       params: { | ||||
|         fields: ['title', 'age'], | ||||
|         fields: ['age', 'title', 'id', 'createdAt', 'updatedAt'], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										300
									
								
								packages/plugin-acl/src/__tests__/association-field.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										300
									
								
								packages/plugin-acl/src/__tests__/association-field.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,300 @@ | ||||
| import { MockServer } from '@nocobase/test'; | ||||
| import { Database, HasManyRepository, Model } from '@nocobase/database'; | ||||
| import { ACL } from '@nocobase/acl'; | ||||
| import { prepareApp } from './prepare'; | ||||
| import PluginACL from '@nocobase/plugin-acl'; | ||||
| 
 | ||||
| describe('association field acl', () => { | ||||
|   let app: MockServer; | ||||
|   let db: Database; | ||||
|   let acl: ACL; | ||||
| 
 | ||||
|   let role: Model; | ||||
| 
 | ||||
|   afterEach(async () => { | ||||
|     await app.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     app = await prepareApp(); | ||||
|     db = app.db; | ||||
|     const aclPlugin = app.getPlugin<PluginACL>('PluginACL'); | ||||
| 
 | ||||
|     acl = aclPlugin.getACL(); | ||||
| 
 | ||||
|     role = await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'admin', | ||||
|         title: 'Admin User', | ||||
|         allowConfigure: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections').create({ | ||||
|       values: { | ||||
|         name: 'users', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections').create({ | ||||
|       values: { | ||||
|         name: 'orders', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections.fields', 'users').create({ | ||||
|       values: { | ||||
|         name: 'name', | ||||
|         type: 'string', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections.fields', 'users').create({ | ||||
|       values: { | ||||
|         name: 'age', | ||||
|         type: 'integer', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections.fields', 'users').create({ | ||||
|       values: { | ||||
|         interface: 'linkTo', | ||||
|         name: 'orders', | ||||
|         type: 'hasMany', | ||||
|         target: 'orders', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('collections.fields', 'orders').create({ | ||||
|       values: { | ||||
|         name: 'content', | ||||
|         type: 'string', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     await app | ||||
|       .agent() | ||||
|       .resource('roles.resources') | ||||
|       .create({ | ||||
|         associatedIndex: 'admin', | ||||
|         values: { | ||||
|           name: 'users', | ||||
|           usingActionsConfig: true, | ||||
|           actions: [ | ||||
|             { | ||||
|               name: 'create', | ||||
|               fields: ['orders'], | ||||
|             }, | ||||
|             { | ||||
|               name: 'view', | ||||
|               fields: ['orders'], | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should revoke target action on association action revoke', async () => { | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'orders', | ||||
|         action: 'list', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'orders', | ||||
|       action: 'list', | ||||
|     }); | ||||
| 
 | ||||
|     await app | ||||
|       .agent() | ||||
|       .resource('roles.resources') | ||||
|       .update({ | ||||
|         associatedIndex: 'admin', | ||||
|         values: { | ||||
|           name: 'users', | ||||
|           usingActionsConfig: true, | ||||
|           actions: [], | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'orders', | ||||
|         action: 'list', | ||||
|       }), | ||||
|     ).toBeNull(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should revoke association action on action revoke', async () => { | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'users.orders', | ||||
|         action: 'add', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'users.orders', | ||||
|       action: 'add', | ||||
|     }); | ||||
| 
 | ||||
|     const viewAction = await db.getRepository('rolesResourcesActions').findOne({ | ||||
|       filter: { | ||||
|         name: 'view', | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const actionId = viewAction.get('id') as number; | ||||
| 
 | ||||
|     const response = await app | ||||
|       .agent() | ||||
|       .resource('roles.resources') | ||||
|       .update({ | ||||
|         associatedIndex: 'admin', | ||||
|         values: { | ||||
|           name: 'users', | ||||
|           usingActionsConfig: true, | ||||
|           actions: [ | ||||
|             { | ||||
|               id: actionId, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|     expect(response.statusCode).toEqual(200); | ||||
| 
 | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'users.orders', | ||||
|         action: 'add', | ||||
|       }), | ||||
|     ).toBeNull(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should revoke association action on field deleted', async () => { | ||||
|     await app | ||||
|       .agent() | ||||
|       .resource('roles.resources') | ||||
|       .update({ | ||||
|         associatedIndex: 'admin', | ||||
|         values: { | ||||
|           name: 'users', | ||||
|           usingActionsConfig: true, | ||||
|           actions: [ | ||||
|             { | ||||
|               name: 'create', | ||||
|               fields: ['name', 'age'], | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }); | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'users', | ||||
|         action: 'create', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'users', | ||||
|       action: 'create', | ||||
|       params: { | ||||
|         whitelist: ['age', 'name'], | ||||
|       }, | ||||
|     }); | ||||
|     const roleResource = await db.getRepository('rolesResources').findOne({ | ||||
|       filter: { | ||||
|         name: 'users', | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const action = await db | ||||
|       .getRepository<HasManyRepository>('rolesResources.actions', roleResource.get('id') as string) | ||||
|       .findOne({ | ||||
|         filter: { | ||||
|           name: 'create', | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|     expect(action.get('fields').includes('name')).toBeTruthy(); | ||||
| 
 | ||||
|     // remove field
 | ||||
|     await db.getRepository<HasManyRepository>('collections.fields', 'users').destroy({ | ||||
|       filter: { | ||||
|         name: 'name', | ||||
|       }, | ||||
|       context: {}, | ||||
|     }); | ||||
| 
 | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'users', | ||||
|         action: 'create', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'users', | ||||
|       action: 'create', | ||||
|       params: { | ||||
|         whitelist: ['age'], | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should allow association fields access', async () => { | ||||
|     const createResponse = await app | ||||
|       .agent() | ||||
|       .resource('users') | ||||
|       .create({ | ||||
|         values: { | ||||
|           orders: [ | ||||
|             { | ||||
|               content: 'apple', | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|     expect(createResponse.statusCode).toEqual(200); | ||||
| 
 | ||||
|     const user = await db.getRepository('users').findOne(); | ||||
|     // @ts-ignore
 | ||||
|     expect(await user.countOrders()).toEqual(1); | ||||
| 
 | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'users.orders', | ||||
|         action: 'list', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'users.orders', | ||||
|       action: 'list', | ||||
|     }); | ||||
| 
 | ||||
|     expect( | ||||
|       acl.can({ | ||||
|         role: 'admin', | ||||
|         resource: 'orders', | ||||
|         action: 'list', | ||||
|       }), | ||||
|     ).toMatchObject({ | ||||
|       role: 'admin', | ||||
|       resource: 'orders', | ||||
|       action: 'list', | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -61,7 +61,7 @@ describe('middleware', () => { | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   afterAll(async () => { | ||||
|   afterEach(async () => { | ||||
|     await app.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|  | ||||
| @ -63,4 +63,30 @@ describe('role api', () => { | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should works with default option', async () => { | ||||
|     await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'role1', | ||||
|         title: 'admin 1', | ||||
|         default: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'role2', | ||||
|         default: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const defaultRole = await db.getRepository('roles').find({ | ||||
|       filter: { | ||||
|         default: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     expect(defaultRole.length).toEqual(1); | ||||
|     expect(defaultRole[0].get('name')).toEqual('role2'); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { CollectionOptions } from '@nocobase/database'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'rolesResources', | ||||
|   model: 'RoleResourceModel', | ||||
|   fields: [ | ||||
|     { | ||||
|       type: 'belongsTo', | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { CollectionOptions } from '@nocobase/database'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'rolesResourcesActions', | ||||
|   model: 'RoleResourceActionModel', | ||||
|   fields: [ | ||||
|     { | ||||
|       type: 'belongsTo', | ||||
| @ -14,7 +15,7 @@ export default { | ||||
|       name: 'name', | ||||
|     }, | ||||
|     { | ||||
|       type: 'json', | ||||
|       type: 'array', | ||||
|       name: 'fields', | ||||
|       defaultValue: [], | ||||
|     }, | ||||
|  | ||||
| @ -26,6 +26,11 @@ export default { | ||||
|       type: 'json', | ||||
|       name: 'strategy', | ||||
|     }, | ||||
|     { | ||||
|       type: 'boolean', | ||||
|       name: 'default', | ||||
|       defaultValue: false, | ||||
|     }, | ||||
|     { | ||||
|       type: 'boolean', | ||||
|       name: 'allowConfigure', | ||||
|  | ||||
							
								
								
									
										75
									
								
								packages/plugin-acl/src/model/RoleResourceActionModel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								packages/plugin-acl/src/model/RoleResourceActionModel.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | ||||
| import { Database, Model } from '@nocobase/database'; | ||||
| import { ACL, ACLRole } from '@nocobase/acl'; | ||||
| import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server'; | ||||
| 
 | ||||
| export class RoleResourceActionModel extends Model { | ||||
|   async writeToACL(options: { | ||||
|     acl: ACL; | ||||
|     role: ACLRole; | ||||
|     resourceName: string; | ||||
|     associationFieldsActions: AssociationFieldsActions; | ||||
|     grantHelper: GrantHelper; | ||||
|   }) { | ||||
|     // @ts-ignore
 | ||||
|     const db: Database = this.constructor.database; | ||||
| 
 | ||||
|     const { resourceName, role, acl, associationFieldsActions, grantHelper } = options; | ||||
|     const actionName = this.get('name') as string; | ||||
| 
 | ||||
|     const fields = this.get('fields') as any; | ||||
|     const actionPath = `${resourceName}:${actionName}`; | ||||
|     const actionParams = { | ||||
|       fields, | ||||
|     }; | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const scope = await this.getScope(); | ||||
| 
 | ||||
|     if (scope) { | ||||
|       actionParams['filter'] = scope.get('scope'); | ||||
|     } | ||||
| 
 | ||||
|     role.grantAction(actionPath, actionParams); | ||||
| 
 | ||||
|     const collection = db.getCollection(resourceName); | ||||
| 
 | ||||
|     if (!collection) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const availableAction = acl.resolveActionAlias(actionName); | ||||
| 
 | ||||
|     for (const field of fields) { | ||||
|       const collectionField = collection.getField(field); | ||||
|       const fieldType = collectionField.get('interface') as string; | ||||
| 
 | ||||
|       const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction]; | ||||
| 
 | ||||
|       if (fieldActions) { | ||||
|         const associationActions = fieldActions.associationActions || []; | ||||
|         associationActions.forEach((associationAction) => { | ||||
|           const actionName = `${resourceName}.${field}:${associationAction}`; | ||||
|           role.grantAction(actionName); | ||||
|         }); | ||||
| 
 | ||||
|         const targetActions = fieldActions.targetActions || []; | ||||
| 
 | ||||
|         targetActions.forEach((targetAction) => { | ||||
|           const targetActionPath = `${field}:${targetAction}`; | ||||
| 
 | ||||
|           grantHelper.resourceTargetActionMap.set(resourceName, [ | ||||
|             ...(grantHelper.resourceTargetActionMap.get(resourceName) || []), | ||||
|             targetActionPath, | ||||
|           ]); | ||||
| 
 | ||||
|           grantHelper.targetActionResourceMap.set(targetActionPath, [ | ||||
|             ...(grantHelper.targetActionResourceMap.get(targetActionPath) || []), | ||||
|             resourceName, | ||||
|           ]); | ||||
| 
 | ||||
|           role.grantAction(targetActionPath); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										60
									
								
								packages/plugin-acl/src/model/RoleResourceModel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								packages/plugin-acl/src/model/RoleResourceModel.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | ||||
| import { Database, Model } from '@nocobase/database'; | ||||
| import { ACL, ACLRole } from '@nocobase/acl'; | ||||
| import { RoleResourceActionModel } from './RoleResourceActionModel'; | ||||
| import { AssociationFieldsActions, GrantHelper } from '../server'; | ||||
| 
 | ||||
| export class RoleResourceModel extends Model { | ||||
|   async revoke(options: { role: ACLRole; resourceName: string; grantHelper: GrantHelper }) { | ||||
|     const { role, resourceName, grantHelper } = options; | ||||
|     role.revokeResource(resourceName); | ||||
| 
 | ||||
|     const targetActions = grantHelper.resourceTargetActionMap.get(resourceName) || []; | ||||
| 
 | ||||
|     for (const targetAction of targetActions) { | ||||
|       const targetActionResource = (grantHelper.targetActionResourceMap.get(targetAction) || []).filter( | ||||
|         (item) => resourceName !== item, | ||||
|       ); | ||||
| 
 | ||||
|       grantHelper.targetActionResourceMap.set(targetAction, targetActionResource); | ||||
|       if (targetActionResource.length == 0) { | ||||
|         role.revokeAction(targetAction); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     grantHelper.resourceTargetActionMap.set(resourceName, []); | ||||
|   } | ||||
| 
 | ||||
|   async writeToACL(options: { | ||||
|     acl: ACL; | ||||
|     associationFieldsActions: AssociationFieldsActions; | ||||
|     grantHelper: GrantHelper; | ||||
|     transaction: any; | ||||
|   }) { | ||||
|     const { acl, associationFieldsActions, grantHelper } = options; | ||||
|     const resourceName = this.get('name') as string; | ||||
|     const roleName = this.get('roleName') as string; | ||||
|     const role = acl.getRole(roleName); | ||||
| 
 | ||||
|     await this.revoke({ role, resourceName, grantHelper }); | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     if (this.usingActionsConfig === false) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const actions: RoleResourceActionModel[] = await this.getActions({ | ||||
|       transaction: options.transaction, | ||||
|     }); | ||||
| 
 | ||||
|     for (const action of actions) { | ||||
|       await action.writeToACL({ | ||||
|         acl, | ||||
|         role, | ||||
|         resourceName, | ||||
|         associationFieldsActions, | ||||
|         grantHelper: options.grantHelper, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -4,35 +4,90 @@ import path from 'path'; | ||||
| import { availableActionResource } from './actions/available-actions'; | ||||
| import { roleCollectionsResource } from './actions/role-collections'; | ||||
| import { createACL } from './acl'; | ||||
| import { RoleResourceActionModel } from './model/RoleResourceActionModel'; | ||||
| import { RoleResourceModel } from './model/RoleResourceModel'; | ||||
| 
 | ||||
| async function actionModelToParams(actionModel, resourceName) { | ||||
|   const fields = actionModel.get('fields'); | ||||
|   const actionPath = `${resourceName}:${actionModel.get('name')}`; | ||||
| export interface AssociationFieldAction { | ||||
|   associationActions: string[]; | ||||
|   targetActions?: string[]; | ||||
| } | ||||
| 
 | ||||
|   const actionParams = { | ||||
|     fields, | ||||
|   }; | ||||
| interface AssociationFieldActions { | ||||
|   [availableActionName: string]: AssociationFieldAction; | ||||
| } | ||||
| 
 | ||||
|   const scope = await actionModel.getScope(); | ||||
| export interface AssociationFieldsActions { | ||||
|   [associationType: string]: AssociationFieldActions; | ||||
| } | ||||
| 
 | ||||
|   if (scope) { | ||||
|     actionParams['filter'] = scope.get('scope'); | ||||
|   } | ||||
| export class GrantHelper { | ||||
|   resourceTargetActionMap = new Map<string, string[]>(); | ||||
|   targetActionResourceMap = new Map<string, string[]>(); | ||||
| 
 | ||||
|   return { | ||||
|     actionPath, | ||||
|     actionParams, | ||||
|   }; | ||||
|   constructor() {} | ||||
| } | ||||
| 
 | ||||
| export default class PluginACL extends Plugin { | ||||
|   acl: ACL; | ||||
| 
 | ||||
|   associationFieldsActions: AssociationFieldsActions = {}; | ||||
| 
 | ||||
|   grantHelper = new GrantHelper(); | ||||
| 
 | ||||
|   registerAssociationFieldAction(associationType: string, value: AssociationFieldActions) { | ||||
|     this.associationFieldsActions[associationType] = value; | ||||
|   } | ||||
| 
 | ||||
|   getACL() { | ||||
|     return this.acl; | ||||
|   } | ||||
| 
 | ||||
|   registerAssociationFieldsActions() { | ||||
|     this.registerAssociationFieldAction('linkTo', { | ||||
|       view: { | ||||
|         associationActions: ['list', 'get'], | ||||
|       }, | ||||
|       create: { | ||||
|         associationActions: ['add'], | ||||
|         targetActions: ['view'], | ||||
|       }, | ||||
|       update: { | ||||
|         associationActions: ['add', 'remove', 'toggle'], | ||||
|         targetActions: ['view'], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     this.registerAssociationFieldAction('attachments', { | ||||
|       view: { | ||||
|         associationActions: ['list', 'get'], | ||||
|       }, | ||||
|       add: { | ||||
|         associationActions: ['upload', 'add'], | ||||
|       }, | ||||
|       update: { | ||||
|         associationActions: ['update', 'add', 'remove', 'toggle'], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     this.registerAssociationFieldAction('subTable', { | ||||
|       view: { | ||||
|         associationActions: ['list', 'get'], | ||||
|       }, | ||||
|       create: { | ||||
|         associationActions: ['create'], | ||||
|       }, | ||||
|       update: { | ||||
|         associationActions: ['update', 'destroy'], | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async load() { | ||||
|     this.app.db.registerModels({ | ||||
|       RoleResourceActionModel, | ||||
|       RoleResourceModel, | ||||
|     }); | ||||
| 
 | ||||
|     const acl = createACL(); | ||||
|     this.acl = acl; | ||||
| 
 | ||||
| @ -40,12 +95,15 @@ export default class PluginACL extends Plugin { | ||||
|       directory: path.resolve(__dirname, 'collections'), | ||||
|     }); | ||||
| 
 | ||||
|     this.registerAssociationFieldsActions(); | ||||
| 
 | ||||
|     this.app.resourcer.define(availableActionResource); | ||||
|     this.app.resourcer.define(roleCollectionsResource); | ||||
| 
 | ||||
|     this.app.resourcer.use(this.acl.middleware()); | ||||
| 
 | ||||
|     this.app.db.on('roles.afterSave', (model) => { | ||||
|     this.app.db.on('roles.afterSave', async (model, options) => { | ||||
|       const { transaction } = options; | ||||
|       const roleName = model.get('name'); | ||||
|       let role = acl.getRole(roleName); | ||||
| 
 | ||||
| @ -59,6 +117,20 @@ export default class PluginACL extends Plugin { | ||||
|         ...(model.get('strategy') || {}), | ||||
|         allowConfigure: model.get('allowConfigure'), | ||||
|       }); | ||||
| 
 | ||||
|       // model is default
 | ||||
|       if (model.get('default')) { | ||||
|         await this.app.db.getRepository('roles').update({ | ||||
|           values: { | ||||
|             default: false, | ||||
|           }, | ||||
|           filter: { | ||||
|             'name.$ne': model.get('name'), | ||||
|           }, | ||||
|           hooks: false, | ||||
|           transaction, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.app.db.on('roles.afterDestroy', (model) => { | ||||
| @ -66,47 +138,53 @@ export default class PluginACL extends Plugin { | ||||
|       acl.removeRole(roleName); | ||||
|     }); | ||||
| 
 | ||||
|     this.app.db.on('rolesResources.afterSave', async (model, options) => { | ||||
|       const roleName = model.get('roleName'); | ||||
|       const role = acl.getRole(roleName); | ||||
| 
 | ||||
|       if (model.usingActionsConfig === true && model._previousDataValues.usingActionsConfig === false) { | ||||
|         const actions = await model.getActions(); | ||||
|         for (const action of actions) { | ||||
|           const { actionPath, actionParams } = await actionModelToParams(action, model.get('name')); | ||||
|           role.grantAction(actionPath, actionParams); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (model._previousDataValues.usingActionsConfig === true && model.usingActionsConfig === false) { | ||||
|         role.revokeResource(model.get('name')); | ||||
|       } | ||||
|     this.app.db.on('rolesResources.afterSaveWithAssociations', async (model: RoleResourceModel, options) => { | ||||
|       await model.writeToACL({ | ||||
|         acl: this.acl, | ||||
|         associationFieldsActions: this.associationFieldsActions, | ||||
|         transaction: options.transaction, | ||||
|         grantHelper: this.grantHelper, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     this.app.db.on('rolesResourcesActions.beforeBulkUpdate', async (options) => { | ||||
|       options.individualHooks = true; | ||||
|     this.app.db.on('rolesResourcesActions.afterUpdateWithAssociations', async (model, options) => { | ||||
|       const { transaction } = options; | ||||
|       const resource = await model.getResource({ | ||||
|         transaction, | ||||
|       }); | ||||
| 
 | ||||
|       await resource.writeToACL({ | ||||
|         acl: this.acl, | ||||
|         associationFieldsActions: this.associationFieldsActions, | ||||
|         transaction: options.transaction, | ||||
|         grantHelper: this.grantHelper, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     this.app.db.on('rolesResourcesActions.afterSave', async (model) => { | ||||
|       const resource = await model.getResource(); | ||||
|       if (!resource) { | ||||
|         const previousResource = await this.app.db.getRepository('rolesResources').findOne({ | ||||
|           filter: { | ||||
|             id: model._previousDataValues.rolesResourceId, | ||||
|     this.app.db.on('fields.afterDestroy', async (model, options) => { | ||||
|       const collectionName = model.get('collectionName'); | ||||
|       const fieldName = model.get('name'); | ||||
| 
 | ||||
|       const resourceActions = await this.app.db.getRepository('rolesResourcesActions').find({ | ||||
|         filter: { | ||||
|           'resource.name': collectionName, | ||||
|           'fields.$anyOf': [fieldName], | ||||
|         }, | ||||
|         transaction: options.transaction, | ||||
|       }); | ||||
| 
 | ||||
|       for (const resourceAction of resourceActions) { | ||||
|         const fields = resourceAction.get('fields') as string[]; | ||||
|         const newFields = fields.filter((field) => field != fieldName); | ||||
| 
 | ||||
|         await this.app.db.getRepository('rolesResourcesActions').update({ | ||||
|           filterByTk: resourceAction.get('id') as number, | ||||
|           values: { | ||||
|             fields: newFields, | ||||
|           }, | ||||
|           transaction: options.transaction, | ||||
|         }); | ||||
| 
 | ||||
|         const roleName = previousResource.get('roleName') as string; | ||||
|         const role = acl.getRole(roleName); | ||||
|         role.revokeAction(`${previousResource.get('name')}:${model.get('name')}`); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const roleName = resource.get('roleName'); | ||||
|       const role = acl.getRole(roleName); | ||||
| 
 | ||||
|       const { actionPath, actionParams } = await actionModelToParams(model, resource.get('name')); | ||||
|       role.grantAction(actionPath, actionParams); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										72
									
								
								packages/plugin-users/src/__tests__/role.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								packages/plugin-users/src/__tests__/role.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | ||||
| import { MockServer, mockServer } from '@nocobase/test'; | ||||
| import Database from '@nocobase/database'; | ||||
| import PluginACL from '@nocobase/plugin-acl'; | ||||
| 
 | ||||
| describe('role', () => { | ||||
|   let api: MockServer; | ||||
|   let db: Database; | ||||
| 
 | ||||
|   beforeEach(async () => { | ||||
|     api = mockServer(); | ||||
|     await api.cleanDb(); | ||||
|     api.plugin(require('../server').default); | ||||
|     api.plugin(PluginACL); | ||||
|     await api.loadAndSync(); | ||||
| 
 | ||||
|     db = api.db; | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(async () => { | ||||
|     await api.destroy(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should set default role', async () => { | ||||
|     await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'test1', | ||||
|         title: 'Admin User', | ||||
|         allowConfigure: true, | ||||
|         default: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const user = await db.getRepository('users').create({}); | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const roles = await user.getRoles(); | ||||
| 
 | ||||
|     expect(roles.length).toEqual(1); | ||||
|     expect(roles[0].get('name')).toEqual('test1'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not add role when user has role', async () => { | ||||
|     await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'test1', | ||||
|         default: true, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     await db.getRepository('roles').create({ | ||||
|       values: { | ||||
|         name: 'test2', | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const user = await db.getRepository('users').create({ | ||||
|       values: { | ||||
|         roles: [ | ||||
|           { | ||||
|             name: 'test2', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const roles = await user.getRoles(); | ||||
| 
 | ||||
|     expect(roles.length).toEqual(1); | ||||
|     expect(roles[0].get('name')).toEqual('test2'); | ||||
|   }); | ||||
| }); | ||||
| @ -50,8 +50,8 @@ export default { | ||||
|       type: 'belongsToMany', | ||||
|       name: 'roles', | ||||
|       target: 'roles', | ||||
|       foreignKey: 'user_id', | ||||
|       otherKey: 'role_name', | ||||
|       foreignKey: 'userId', | ||||
|       otherKey: 'roleName', | ||||
|       sourceKey: 'id', | ||||
|       targetKey: 'name', | ||||
|       uiSchema: { | ||||
|  | ||||
| @ -19,6 +19,21 @@ export default { | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     database.on('users.afterCreateWithAssociations', async (model, options) => { | ||||
|       const { transaction } = options; | ||||
| 
 | ||||
|       const defaultRole = await this.app.db.getRepository('roles').findOne({ | ||||
|         filter: { | ||||
|           default: true, | ||||
|         }, | ||||
|         transaction, | ||||
|       }); | ||||
| 
 | ||||
|       if (defaultRole && (await model.countRoles({ transaction })) == 0) { | ||||
|         await model.addRoles(defaultRole, { transaction }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     database.on('afterDefineCollection', (collection: Collection) => { | ||||
|       let { createdBy, updatedBy } = collection.options; | ||||
|       if (createdBy === true) { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user