UNPKG

am-i-allowed

Version:

A generic, very powerful yet very friendly, 0 dependencies, agnostic, permissions/access-control/authorization library

281 lines (228 loc) 9.91 kB
import {IActor, IPermissionStore, IPrivilegeManaged, Operation, PermissionsMetaData} from "./types"; import {standardPermissionChecker} from "./permission-checker"; import {DefaultOperationsTaxonomy} from "./operations-taxonomy"; /** * This is the main class. Normally you'd need just one PrivilegeManager for the whole application. * Use it to check permissions. * * * Special terms: * * The relation between ac actor to a given entity may be one of the following: * - Visitor: a not logged-in user * - User: a logged-in user, with an account * - Group Member: a user that shares the group of the entity * - a role owner: there's a role explicitly assigned to the use on the entity * */ export class PrivilegeManager { readonly operationTree: OperationTree private entityMetaDataLookup: EntityMetaDataLookup; /** * Builds a privilege manager instance. * @param store the persistence backend for the permission storage * @param operationsTransformer an optional operation tree transformer, in case you wish to alter the default one, add more operations, etc */ constructor(public store: IPermissionStore, operationsTransformer = (operationTree) => operationTree) { this.operationTree = new OperationTree(operationsTransformer(DefaultOperationsTaxonomy)) this.entityMetaDataLookup = new EntityMetaDataLookup(this) } /** * Check for if the actor is allowed to do something and throws an exception if he isn't * @param actor * @param operation * @param entity * @param specialContext for custom logic * @throws NoPrivilegeException if actor is not allowed */ async test(actor: IActor, operation: Operation, entity: IPrivilegeManaged, specialContext?: any): Promise<void> { // @ts-ignore const isAllowed = await this.isAllowed(...arguments) if (!isAllowed) { // @ts-ignore throw new NoPrivilegeException(...arguments) } } /** * Check if the actor is allowed to do * @param actor * @param operation * @param entity * @param specialContext * @return <promise> of true or false */ isAllowed(actor: IActor, operation: Operation, entity: IPrivilegeManaged, specialContext?: any): Promise<boolean> { if (!this.operationTree.find(operation)) throw new Error(`Operation ${operation.toString()} is not defined. Consider adding it to the operations tree.`) // @ts-ignore const customPermissionChecker = entity.customPermissionChecker || entity.constructor?.customPermissionChecker; if (customPermissionChecker) return customPermissionChecker(this, ...arguments) // @ts-ignore return standardPermissionChecker(this, ...arguments) } /** * @return all the actors that have explicit roles assigned on that entity * @param entity the entity */ getRoleOwners(entity: IPrivilegeManaged): Promise<{ [p: string]: string[] }> { return this.store.getRoleOwners(entity) } /** * @return all the roles explicitly assigned to the actor on any entity * @param actorId * @param skip pagination support * @param limit pagination support */ getActorRoles(actorId, skip = 0, limit = 1000): Promise<{ [entityId: string]: string[] }> { return this.store.getActorRoles(actorId, skip, limit) } /** * assign a role to use in entity * @param entity the entity * @param actor either IActor or an id * @param role the role */ assignRole(entity: IPrivilegeManaged, actor: IActor, role: Role): Promise<void> { return this.store.assignRole(entity, actor, role.roleName) } /** * @Return the roles the actor have on an entity * @param actor * @param entity */ async getRolesForActor(actor: IActor, entity: IPrivilegeManaged): Promise<Role[]> { return this.store.getRolesForUser(actor, entity, await this.findMetaData(entity)) } // noinspection JSIgnoredPromiseFromCall /** * Define a new role. Also add itself to the corresponding entityType. Y * @param roleName name of role * @param entityType The entity types this role is applicable to * @param operations the operation the role holder may do on the entities of the aforementioned types * @return the new role object */ addRole(roleName: string, operations: Operation[], entityType: (string | Function)): Role { const role = new Role(this, roleName, operations, entityType) const metaData = this.getOrAddMetaData(entityType); metaData.roles[roleName] = role this.store.saveRole(metaData.name, role).then(()=>{}) return role } /** * Define a new role. Also add itself to the corresponding entityType. You can add multiple roles, each for * different entity type if you provide more than one entity type. * @param roleName name of role * @param entityTypes The entity types this role is applicable to * @param operations the operation the role holder may do on the entities of the aforementioned types * @return the new roles objects */ addRoles(roleName: string, operations: Operation[], ...entityTypes: (string | Function)[]): Role[] { return entityTypes.map(entityType => this.addRole( roleName, operations, entityType )) } deleteRole(roleName: string, entityTypeName: string): Promise<void> { return this.store.deleteRole(roleName, entityTypeName) } async saveRole(entityTypeName: string, role: Role) { await this.store.saveRole(entityTypeName, role) } getOrAddMetaData(type: string | Function) { return this.entityMetaDataLookup.getOrAddMetaData(type) } async findMetaData(entity: IPrivilegeManaged) { return this.entityMetaDataLookup.findMetaData(entity) } } export class NoPrivilegeException extends Error { constructor(actor: IActor, operation: Operation, entity: IPrivilegeManaged, specialContext?: any) { super(`${actor.id} attempted unprivileged operation ${operation.toString()} on ${entity.id} with ${JSON.stringify(specialContext || '')}`) } message: string; name: string; } //////////////////////////////////////////// /** * This class's purpose is to holds the operation definition tree and provide the expandOperation method */ class OperationTree { private parentsMap = new Map<Operation, Operation[]>() constructor(private tree: object) { this.processTree(tree) } private processTree(tree: object) { const self = this populate(tree) function populate(node, parents: string[] = [],) { for (let [name, children] of Object.entries(node)) { const entryParents = self.parentsMap.get(name) || [] self.parentsMap.set(name, entryParents.concat(parents)) children && Object.keys(children).length && populate(children, [name, ...parents]) } } } /** * expand to include the super-operations * @param operation */ expandOperation(operation: Operation): Operation[] { if (!operation) return [] const parents = this.parentsMap.get(operation) if (parents.length) return [operation, ...parents] return [operation, ...parents, ...parents.reduce((a, c) => { a.push(...this.expandOperation(c)) return a }, [])] } find(operation: Operation): boolean { return this.parentsMap.has(operation); } } class EntityMetaDataLookup { metaDataMap = new Map<string, PermissionsMetaData>() constructor(private privilegeManager: PrivilegeManager) { } getOrAddMetaData(entityType: string | Function): PermissionsMetaData { const name = typeof entityType == 'string' ? entityType : entityType.name const clazz = typeof entityType == 'string' ? null : entityType let metadata = this.metaDataMap.get(name) if (!metadata) { // @ts-ignore metadata = clazz?.permissionsMetaData || new PermissionsMetaData(name, {}) this.metaDataMap.set(name, metadata) } return metadata } async findMetaData(entity: IPrivilegeManaged):Promise<PermissionsMetaData> { // first, we check if there's meta data on the entity itself // @ts-ignore let metaData = entity.permissionsMetaData || entity.constructor?.permissionsMetaData if (!metaData) { const entityName = entity.constructor === Object ? entity.___name : entity.constructor.name; return this.getOrAddMetaData(entityName) } // if it is defined as function - execute the function metaData = typeof metaData === 'function' ? await metaData() : metaData // validate the metadata if it wasn't validated before if (!metaData._validated) metaData._validated = this.validateMetaData(metaData) return metaData } private validateMetaData(md: PermissionsMetaData) { [...md.defaultGroupMemberPermissions, ...md.defaultUserPermissions, ...md.defaultVisitorPermissions].forEach( o => { if (!this.privilegeManager.operationTree.find(o)) throw new Error(`Operation "${o}" is not in the taxonomy`) }) return true; } } /** * Role defines the set of permitted operations. Each role is applicable to a provided entity types */ export class Role { readonly operations: Set<Operation> constructor(pm: PrivilegeManager, readonly roleName: string, operations: string[], readonly entityType: (string | Function)) { this.operations = new Set<Operation>(operations); } }