src/Permissions.ts
The main class - see Basic Usage
Methods |
|
Accessors |
constructor(undefined: IPermissionsOptions
|
|||||
|
Defined in src/Permissions.ts:67
|
|||||
|
Parameters :
|
| Public addDefinitions | ||||||||||||
addDefinitions(permissionDefinitions: PermissionDefinition
|
||||||||||||
|
Defined in src/Permissions.ts:78
|
||||||||||||
|
Parameters :
Returns :
void
|
| Public build |
build()
|
|
Defined in src/Permissions.ts:194
|
|
Returns :
this
|
| Public compare | ||||||||||||
compare(permissions1: Permissions
|
||||||||||||
|
Defined in src/Permissions.ts:383
|
||||||||||||
|
Parameters :
Returns :
any
|
| Public getActions |
getActions()
|
|
Defined in src/Permissions.ts:366
|
|
Returns :
string[]
|
| Public getDefinitions | |||||||||||||||
getDefinitions(filter?: literal type, consolidateFlag: boolean | "force")
|
|||||||||||||||
|
Defined in src/Permissions.ts:403
|
|||||||||||||||
|
Returns a list of the
Parameters :
Returns :
Partial[]
|
| Public getGrants |
getGrants()
|
|
Defined in src/Permissions.ts:376
|
|
Returns a deep clone of
Returns :
object
|
| Public getResources |
getResources()
|
|
Defined in src/Permissions.ts:361
|
|
Returns :
string[]
|
| Public getRoles |
getRoles()
|
|
Defined in src/Permissions.ts:356
|
|
Returns :
string[]
|
| Public Async grantPermit | |||||
grantPermit(undefined: GrantPermitQuery
|
|||||
|
Defined in src/Permissions.ts:213
|
|||||
|
The The method responds with an instance of Permit that holds all known information about the queried user, resource and action. In short, the question is "can some of We are checking all roles for both any & own, while collecting all
Parameters :
Returns :
Promise<Permit<TUserId, TResourceId>>
Promise |
| isBuilt |
getisBuilt()
|
|
Defined in src/Permissions.ts:190
|
|
Check is this Permissions instance has been built (so no more .addDefinitions() allowed)
Returns :
boolean
|
import * as _ from 'lodash';
import * as _f from 'lodash/fp';
import { diff } from 'json-diff';
import { AccessControl, IQueryInfo, Permission } from 'accesscontrol';
// own
import { AccessControlRe } from 'accesscontrol-re';
import {
EPossession,
GrantPermitQuery,
isValidIUser,
Tid,
TisOwner,
TlimitOwned,
TlimitOwnReduce,
TlistOwned,
} from './types';
import {
buildAccessControl,
deleteEmptyArrayKeys,
hasSomeOwnGrant,
stringify,
projectPDWithDefaultsToInternal,
} from './utils';
import { consolidatePermissionDefinitions } from './consolidations';
import { Permit } from './Permit.class';
import {
PermissionDefinition,
PermissionDefinitionDefaults,
PermissionDefinitionInternal,
} from './PermissionDefinitions';
import { getLogger } from './logger';
/**
The options passed at the `Permissions` constructor
*/
export interface IPermissionsOptions<
TUserId extends Tid = number,
TResourceId extends Tid = number
> {
permissionDefinitions?:
| PermissionDefinition<TUserId, TResourceId>
| PermissionDefinition<TUserId, TResourceId>[];
permissionDefinitionDefaults?: PermissionDefinitionDefaults;
limitOwnReduce?: TlimitOwnReduce<TUserId, any>;
}
/**
The main class - see [Basic Usage](/additional-documentation/basic-usage.html)
*/
export class Permissions<TUserId extends Tid = number, TResourceId extends Tid = number> {
private _permissionDefinitionsInternal: PermissionDefinitionInternal[] = [];
private _accessControl: AccessControl;
private _acre: AccessControlRe;
private _rolesNotFound = {};
private roles: string[];
private _limitOwnReduce: TlimitOwnReduce<TUserId, any>;
private _isBuilt = false;
constructor({
permissionDefinitions,
permissionDefinitionDefaults,
limitOwnReduce,
}: IPermissionsOptions<TUserId, TResourceId> = {}) {
this._limitOwnReduce = limitOwnReduce;
this.addDefinitions(permissionDefinitions || [], permissionDefinitionDefaults);
}
public addDefinitions(
permissionDefinitions:
| PermissionDefinition<TUserId, TResourceId>
| PermissionDefinition<TUserId, TResourceId>[],
permissionDefinitionDefaults: PermissionDefinitionDefaults = {}
) {
this.ensureHasNotBuild();
if (!permissionDefinitions)
throw new Error(
`SA-Permissions: in addDefinitions(), invalid permissionDefinitions: ${stringify(
permissionDefinitions
)}`
);
if (!_.isArray(permissionDefinitions)) permissionDefinitions = [permissionDefinitions];
const ipdsToAdd = permissionDefinitions.map(
projectPDWithDefaultsToInternal(permissionDefinitionDefaults)
);
// sanity checks before adding ipds
_.each(ipdsToAdd, (ipdToAdd, ipdToAddIdx): any => {
// 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: ${stringify(ipdToAdd.grant[firstConflictingAction])}
While adding PD: ${stringify(ipdToAdd)}
Conflicted with PD: ${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]
);
getLogger().warn(
`addDefinitions() redefining action in a PD with same attributes is obsolete:`,
{
action: firstConflictingAction,
attributes: ipdToAdd.grant[firstConflictingAction],
permissionDefinition: ipdToAdd,
}
);
}
if (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 = ${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: ${stringify(permissionDefinitions[ipdToAddIdx])}
Conflicted with PD: ${stringify(conflictedPD)}`
);
}
this._permissionDefinitionsInternal.push(ipdToAdd); // all ok, add it!
});
}
/**
* Check is this Permissions instance has been built (so no more .addDefinitions() allowed)
*/
public get isBuilt(): boolean {
return this._isBuilt;
}
public build() {
this._isBuilt = true;
if (this._acre) return this;
[this._accessControl, this._acre] = 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.
*/
public async grantPermit({
// <TUserId extends Tid = number, TResourceId extends Tid = number>
user,
action,
resource,
resourceId,
}: GrantPermitQuery<TUserId, TResourceId>): Promise<Permit<TUserId, TResourceId>> {
this.ensureHasBuild();
if (!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: Permission; // = { granted: false } as any;
let anyAcPermission: Permission;
let ownAcPermission: Permission;
// The `Permit` values
const isOwners: TisOwner<TUserId, TResourceId>[] = [];
const listOwneds: TlistOwned<TUserId, TResourceId>[] = [];
const limitOwneds: TlimitOwned<any, TUserId>[] = [];
// 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 [EPossession.any, EPossession.own]) {
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;
getLogger().warn(
`SA-Permissions(): at grantPermit(), role not found: ${rl} (will not warn again about this role)`
);
}
});
const queryInfo: IQueryInfo = {
role: roles,
action: `${action}:${queryPossession}`,
resource,
};
try {
acPermission = this._acre.permission(queryInfo);
} catch (error) {
// @todo: handle
throw error;
}
getLogger().debug('grantPermit: this._accessControl.permission(queryInfo)', {
queryInfo,
'permission.granted': acPermission.granted,
'permission.attributes': acPermission.attributes,
});
switch (queryPossession) {
case EPossession.any: {
anyAcPermission = acPermission;
break;
}
case 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?.grant || {})[`${action}:${EPossession.own}`] ||
!!(pd?.grant || {})[`*:${EPossession.own}`] ||
!!(pd?.grant || {})[`${action}:${EPossession.any}`] ||
!!(pd?.grant || {})[`*:${EPossession.any}`])
);
});
// prettier-ignore
if (!anyAcPermission.granted && _.isEmpty(matchingCpds))
throw new Error(
`SA-Permissions: own access granted but no matching PermissionDefinitions found: ` +
`${stringify({ user, action, resource })}`,
);
_.each(matchingCpds, (cpd) => {
const { isOwner, listOwned, limitOwned } = cpd;
if (isOwner) isOwners.push(isOwner as any);
if (listOwned) listOwneds.push(listOwned as any);
if (limitOwned) limitOwneds.push(limitOwned as any);
});
}
break;
}
default:
throw new Error(
`SA-Permissions::grantPermit: invalid EPossession in queryPossession "${queryPossession}"`
);
}
}
const permit = new (Permit as any)( // 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: string) =>
new Error(`SA-Permissions: grantPermit() "OWN" access granted but ${butDetail
}. The error should have been caught at addDefinitions() call, please report to authors. GrantPermitQuery = ${
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 as any).resourceIdOwnPermissionGranted = await permit.isOwn(resourceId);
}
return permit as Permit<TUserId, TResourceId>;
}
// some helpers
public getRoles(): string[] {
this.ensureHasBuild();
return this._acre.getRoles();
}
public getResources(): string[] {
this.ensureHasBuild();
return this._acre.getResources();
}
public getActions(): string[] {
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
*/
public getGrants(): object {
// @todo: typings
this.ensureHasBuild();
// delete empty arrays, eg `'rollover:any': []` cause they are useless & break our `compare()`
return deleteEmptyArrayKeys(_.cloneDeep(this._accessControl.getGrants()));
}
public compare(permissions1: Permissions<any, any>, permissions2: Permissions<any, any> = this) {
return 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
*/
public getDefinitions(
filter?: {
[key: string]: any;
},
consolidateFlag: boolean | 'force' = false
): Partial<PermissionDefinitionInternal>[] {
const filteredPDs = _f.flow(
_f.filter(filter),
_f.reject((opd) => _.isEmpty(opd.grant))
)(this._permissionDefinitionsInternal);
const resultPDs = consolidateFlag
? consolidatePermissionDefinitions(
filter,
consolidateFlag
)(_.cloneDeep(this._permissionDefinitionsInternal))
: filteredPDs;
const resultedSaPermissions = new Permissions({
permissionDefinitions: resultPDs,
permissionDefinitionDefaults: filter,
}).build();
const filteredInstancePermissions = new Permissions({
permissionDefinitions: filteredPDs as any,
permissionDefinitionDefaults: filter,
}).build();
const difference = this.compare(resultedSaPermissions, filteredInstancePermissions);
if (difference !== undefined) {
throw new Error(
`SA-Permissions: getDefinitions diff:
${stringify(difference)}
Existing grants:
${stringify(this.getGrants())}
Generated grants:
${stringify(resultedSaPermissions.getGrants())}
`
);
}
return resultPDs;
}
private ensureHasBuild() {
if (!this._acre)
throw new Error(
`SA-Permissions InvalidInvocation: calling permissions methods before having build()`
);
}
private ensureHasNotBuild() {
if (this._acre)
throw new Error(
`SA-Permissions InvalidInvocation: calling addDefinitions() after having build()`
);
}
/**
*
* @param pdi a PermissionDefinitionInternal
* @param strict true means we dont care if redefining action is _.equal. Duplicating is bad enough!
*/
private filterPDsWithDuplicateGrantActions = (
pdi: PermissionDefinitionInternal,
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]))
)
);
}