UNPKG

@superawesome/permissions

Version:

Fine grained permissions / access control with ownerships & attribute picking, done right.

300 lines (289 loc) 17 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Permissions = void 0; // tslint:disable:prefer-const // 3rd party const _ = require("lodash"); const _f = require("lodash/fp"); const json_diff_1 = require("json-diff"); const types_1 = require("./types"); const utils_1 = require("./utils"); const consolidations_1 = require("./consolidations"); const Permit_class_1 = require("./Permit.class"); const logger_1 = require("./logger"); /** The main class - see [Basic Usage](/additional-documentation/basic-usage.html) */ class Permissions { constructor({ permissionDefinitions, permissionDefinitionDefaults, limitOwnReduce, } = {}) { this._permissionDefinitionsInternal = []; this._rolesNotFound = {}; this._isBuilt = false; /** * * @param pdi a PermissionDefinitionInternal * @param strict true means we dont care if redefining action is _.equal. Duplicating is bad enough! */ this.filterPDsWithDuplicateGrantActions = (pdi, strict = false) => _.filter(this._permissionDefinitionsInternal, (originalIpd) => _.isEqual(originalIpd.resource, pdi.resource) && _.some(pdi.roles, (ipdToAddRole) => _.includes(originalIpd.roles, ipdToAddRole)) && _.some(pdi.grant, (attributes, action) => !!originalIpd.grant[action] && (strict || !_.isEqual(originalIpd.grant[action], pdi.grant[action])))); this._limitOwnReduce = limitOwnReduce; this.addDefinitions(permissionDefinitions || [], permissionDefinitionDefaults); } addDefinitions(permissionDefinitions, permissionDefinitionDefaults = {}) { this.ensureHasNotBuild(); if (!permissionDefinitions) throw new Error(`SA-Permissions: in addDefinitions(), invalid permissionDefinitions: ${utils_1.stringify(permissionDefinitions)}`); if (!_.isArray(permissionDefinitions)) permissionDefinitions = [permissionDefinitions]; const ipdsToAdd = permissionDefinitions.map(utils_1.projectPDWithDefaultsToInternal(permissionDefinitionDefaults)); // sanity checks before adding ipds _.each(ipdsToAdd, (ipdToAdd, ipdToAddIdx) => { // if we are trying to redefine a role+resource+action:possession // with DIFFERENT attributes (i.e non-strict) throw as its very dangerous! const nonStrictDuplicatePds = this.filterPDsWithDuplicateGrantActions(ipdToAdd); if (!_.isEmpty(nonStrictDuplicatePds)) { const firstConflictingAction = _.findKey(ipdToAdd.grant, (attributes, action) => !!nonStrictDuplicatePds[0].grant[action]); throw new Error(`SA-Permissions: InvalidPermissionDefinitionError: addDefinitions() redefining action error. Action: "${firstConflictingAction}" Action Attributes: ${utils_1.stringify(ipdToAdd.grant[firstConflictingAction])} While adding PD: ${utils_1.stringify(ipdToAdd)} Conflicted with PD: ${utils_1.stringify(nonStrictDuplicatePds[0])}`); } // if we are trying to redefine a role+resource+action:possession // even with SAME different attributes (i.e very strict) warn as obsolete! const strictDuplicatePds = this.filterPDsWithDuplicateGrantActions(ipdToAdd, true); if (!_.isEmpty(strictDuplicatePds)) { const firstConflictingAction = _.findKey(ipdToAdd.grant, (attributes, action) => !!strictDuplicatePds[0].grant[action]); logger_1.getLogger().warn(`addDefinitions() redefining action in a PD with same attributes is obsolete:`, { action: firstConflictingAction, attributes: ipdToAdd.grant[firstConflictingAction], permissionDefinition: ipdToAdd, }); } if (utils_1.hasSomeOwnGrant(ipdToAdd)) { const isOwnerFound = !!ipdToAdd.isOwner; let listOwnedFound = !!ipdToAdd.listOwned; let limitOwnedFound = !!ipdToAdd.limitOwned; // on this PD if (listOwnedFound && limitOwnedFound) throw new Error(`SA-Permissions: in addDefinitions() found BOTH "listOwned" & "limitOwned" callbacks in the added PermissionDefinition. Use one or the other, but not both. PermissionDefinition = ${JSON.stringify(permissionDefinitions[ipdToAddIdx], null, 2)}`); // It has some OWN Grant, but no owner hooks found, throw if (!isOwnerFound || (!listOwnedFound && !limitOwnedFound)) { throw new Error(`SA-Permissions: in addDefinitions() PermissionDefinition has 'own' action but no ${!isOwnerFound ? '"isOwner"' : '"listOwned" nor "limitOwned"'} callbacks are there. PermissionDefinition = ${utils_1.stringify(ipdToAdd)} `); } // check all for same resource as ipdToAdd let conflictedPD; for (const opd of this._permissionDefinitionsInternal) { if (ipdToAdd.resource === opd.resource) { listOwnedFound = listOwnedFound || !!opd.listOwned; limitOwnedFound = limitOwnedFound || !!opd.limitOwned; } if (listOwnedFound && limitOwnedFound) { conflictedPD = opd; break; } } if (listOwnedFound && limitOwnedFound) throw new Error(`SA-Permissions: in addDefinitions() found BOTH "listOwned" & "limitOwned" callbacks in some PermissionDefinition for resource "${ipdToAdd.resource}". Use one or the other, but not both. Adding PD: ${utils_1.stringify(permissionDefinitions[ipdToAddIdx])} Conflicted with PD: ${utils_1.stringify(conflictedPD)}`); } this._permissionDefinitionsInternal.push(ipdToAdd); // all ok, add it! }); } /** * Check is this Permissions instance has been built (so no more .addDefinitions() allowed) */ get isBuilt() { return this._isBuilt; } build() { this._isBuilt = true; if (this._acre) return this; [this._accessControl, this._acre] = utils_1.buildAccessControl(this._permissionDefinitionsInternal); this.roles = this.getRoles(); return this; } /** The `grantPermit()` is the way to *query* the Permissions instance for granting permissions to a User. The method responds with an instance of [Permit](/classes/Permit.html) that holds all known information about the queried **user**, **resource** and **action**. In short, the question is "can some of `user.roles` perform `action` either a) on **any** `resource` or b) on an **own** `resource` (AND the specific `resourceId` if passed)? We are checking all roles for both **any** & **own**, while collecting all `isOwner` & `listOwned` and feed all known information into a **Permit** object. @return Promise<Permit> a Promise of a [Permit](/classes/Permit.html) instance. */ async grantPermit({ // <TUserId extends Tid = number, TResourceId extends Tid = number> user, action, resource, resourceId, }) { this.ensureHasBuild(); if (!types_1.isValidIUser(user)) throw new Error('SA-Permissions: at grantPermit(), user is not a valid `interface IUser {id: TId; roles: string[];}`'); if (!this.getResources().includes(resource)) throw new Error(`SA-Permissions: at grantPermit(), Invalid resource: "${resource}"`); if (action.split(':').length > 1) throw new Error(`SA-Permissions: at grantPermit(), Invalid action structure: "${action}". The colon ":" in the action is not allowed on grantPermit() and you must NOT specify ":own" or ":any" after the action at it. SA-Permissions always returns a Permit that checks for both any & own.`); let acPermission; // = { granted: false } as any; let anyAcPermission; let ownAcPermission; // The `Permit` values const isOwners = []; const listOwneds = []; const limitOwneds = []; // 2 passes: check all EPossession against all roles. // if any permissions.granted is true, granted is true // but continue to gather all permissions.attributes, isOwner & listOwned for (const queryPossession of [types_1.EPossession.any, types_1.EPossession.own]) { logger_1.getLogger().debug('grantPermit: possession', { possession: queryPossession }); const unknownRoles = _.difference(user.roles, this.roles); const roles = _.without(user.roles, ...unknownRoles); _.each(unknownRoles, (rl) => { if (!this._rolesNotFound[rl]) { this._rolesNotFound[rl] = true; logger_1.getLogger().warn(`SA-Permissions(): at grantPermit(), role not found: ${rl} (will not warn again about this role)`); } }); const queryInfo = { role: roles, action: `${action}:${queryPossession}`, resource, }; try { acPermission = this._acre.permission(queryInfo); } catch (error) { // @todo: handle throw error; } logger_1.getLogger().debug('grantPermit: this._accessControl.permission(queryInfo)', { queryInfo, 'permission.granted': acPermission.granted, 'permission.attributes': acPermission.attributes, }); switch (queryPossession) { case types_1.EPossession.any: { anyAcPermission = acPermission; break; } case types_1.EPossession.own: { ownAcPermission = acPermission; if (ownAcPermission.granted) { const matchingCpds = _.filter(this._permissionDefinitionsInternal, (pd) => { return (_.some(pd.roles, (pdRole) => user.roles.includes(pdRole)) && (resource === pd.resource || pd.resource === '*') && (!!((pd === null || pd === void 0 ? void 0 : pd.grant) || {})[`${action}:${types_1.EPossession.own}`] || !!((pd === null || pd === void 0 ? void 0 : pd.grant) || {})[`*:${types_1.EPossession.own}`] || !!((pd === null || pd === void 0 ? void 0 : pd.grant) || {})[`${action}:${types_1.EPossession.any}`] || !!((pd === null || pd === void 0 ? void 0 : pd.grant) || {})[`*:${types_1.EPossession.any}`])); }); // prettier-ignore if (!anyAcPermission.granted && _.isEmpty(matchingCpds)) throw new Error(`SA-Permissions: own access granted but no matching PermissionDefinitions found: ` + `${utils_1.stringify({ user, action, resource })}`); _.each(matchingCpds, (cpd) => { const { isOwner, listOwned, limitOwned } = cpd; if (isOwner) isOwners.push(isOwner); if (listOwned) listOwneds.push(listOwned); if (limitOwned) limitOwneds.push(limitOwned); }); } break; } default: throw new Error(`SA-Permissions::grantPermit: invalid EPossession in queryPossession "${queryPossession}"`); } } const permit = new Permit_class_1.Permit(// constructor is best kept private, only we should use it! user, action, resource, resourceId, anyAcPermission, ownAcPermission, _.uniq(isOwners), _.uniq(listOwneds), _.uniq(limitOwneds), this._limitOwnReduce); // prettier-ignore if (!anyAcPermission.granted && ownAcPermission.granted) { // The following checks SHOULD NOT be needed, they should be caught at the addDefinitions() call. Please report to authors if you encounter them. const createError = (butDetail) => new Error(`SA-Permissions: grantPermit() "OWN" access granted but ${butDetail}. The error should have been caught at addDefinitions() call, please report to authors. GrantPermitQuery = ${utils_1.stringify({ user, action, resource, resourceId })}`); if (_.isEmpty(isOwners)) throw createError('no "isOwner" ownership hook found'); if (_.isEmpty(listOwneds) && _.isEmpty(limitOwneds)) throw createError('no "listOwned" nor "limitOwned" ownership hooks found'); if (!_.isEmpty(listOwneds) && !_.isEmpty(limitOwneds)) throw createError('found BOTH "listOwned" & "limitOwned" ownership hooks. Use one or the other, but not both'); if (resourceId) permit.resourceIdOwnPermissionGranted = await permit.isOwn(resourceId); } return permit; } // some helpers getRoles() { this.ensureHasBuild(); return this._acre.getRoles(); } getResources() { this.ensureHasBuild(); return this._acre.getResources(); } getActions() { this.ensureHasBuild(); return this._acre.getActions(); } /** * Returns a deep clone of [`AccessControl#getGrants()`](https://onury.io/accesscontrol/?api=ac#AccessControl#getGrants) (which according to its docs `Gets the internal grants object that stores all current grants.`), but omitting empty arrays eg `'rollover:any': []`. * * @see https://onury.io/accesscontrol/?api=ac#AccessControl#getGrants */ getGrants() { // @todo: typings this.ensureHasBuild(); // delete empty arrays, eg `'rollover:any': []` cause they are useless & break our `compare()` return utils_1.deleteEmptyArrayKeys(_.cloneDeep(this._accessControl.getGrants())); } compare(permissions1, permissions2 = this) { return json_diff_1.diff(permissions1.getGrants(), permissions2.getGrants()); } // Grab accessControl.getGrants(), BUT delete all empty / denied grants that /** Returns a list of the `PermissionDefinition` objects stored in this instance, with optional filtering & consolidations removing duplicates and redundant grants (**WARNING**: this is experimental) @param filter allows you to filter PDs: * Use an object eg `{ resource: 'document' }` as the `_.matches` iteratee shorthand. If this `_.matches` object is used, the props used for filtering are considered "default" and are omitted from each PD. * OR use a function returning boolean for each PD, eg (pd) => pd.resource === 'document' See https://lodash.com/docs/4.17.11#filter @param consolidateFlag is **experimental**, it tries to consolidate PermissionDefinitions, remove duplicates and merge compatible ones */ getDefinitions(filter, consolidateFlag = false) { const filteredPDs = _f.flow(_f.filter(filter), _f.reject((opd) => _.isEmpty(opd.grant)))(this._permissionDefinitionsInternal); const resultPDs = consolidateFlag ? consolidations_1.consolidatePermissionDefinitions(filter, consolidateFlag)(_.cloneDeep(this._permissionDefinitionsInternal)) : filteredPDs; const resultedSaPermissions = new Permissions({ permissionDefinitions: resultPDs, permissionDefinitionDefaults: filter, }).build(); const filteredInstancePermissions = new Permissions({ permissionDefinitions: filteredPDs, permissionDefinitionDefaults: filter, }).build(); const difference = this.compare(resultedSaPermissions, filteredInstancePermissions); if (difference !== undefined) { throw new Error(`SA-Permissions: getDefinitions diff: ${utils_1.stringify(difference)} Existing grants: ${utils_1.stringify(this.getGrants())} Generated grants: ${utils_1.stringify(resultedSaPermissions.getGrants())} `); } return resultPDs; } ensureHasBuild() { if (!this._acre) throw new Error(`SA-Permissions InvalidInvocation: calling permissions methods before having build()`); } ensureHasNotBuild() { if (this._acre) throw new Error(`SA-Permissions InvalidInvocation: calling addDefinitions() after having build()`); } } exports.Permissions = Permissions; //# sourceMappingURL=Permissions.js.map