@superawesome/permissions
Version:
Fine grained permissions / access control with ownerships & attribute picking, done right.
761 lines (621 loc) • 30.1 kB
HTML
<html class="no-js" lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>SuperAwesome Permissions (@superawesome/permissions)</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="../images/favicon.ico">
<link rel="stylesheet" href="../styles/style.css">
<link rel="stylesheet" href="../styles/postmark.css">
</head>
<body>
<div class="navbar navbar-default navbar-fixed-top visible-xs">
<a href="../" class="navbar-brand">SuperAwesome Permissions (@superawesome/permissions)</a>
<button type="button" class="btn btn-default btn-menu ion-ios-menu" id="btn-menu"></button>
</div>
<div class="xs-menu menu" id="mobile-menu">
<div id="book-search-input" role="search"><input type="text" placeholder="Type to search"></div> <compodoc-menu></compodoc-menu>
</div>
<div class="container-fluid main">
<div class="row main">
<div class="hidden-xs menu">
<compodoc-menu mode="normal"></compodoc-menu>
</div>
<!-- START CONTENT -->
<div class="content interface">
<div class="content-data">
<ol class="breadcrumb">
<li>Interfaces</li>
<li>IPermissionsOptions</li>
</ol>
<ul class="nav nav-tabs" role="tablist">
<li class="active">
<a href="#info" role="tab" id="info-tab" data-toggle="tab" data-link="info">Info</a>
</li>
<li >
<a href="#source" role="tab" id="source-tab" data-toggle="tab" data-link="source">Source</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade active in" id="c-info">
<p class="comment">
<h3>File</h3>
</p>
<p class="comment">
<code>src/Permissions.ts</code>
</p>
<p class="comment">
<h3>Description</h3>
</p>
<p class="comment">
<p>The options passed at the <code>Permissions</code> constructor</p>
</p>
<section>
<h3 id="index">Index</h3>
<table class="table table-sm table-bordered index-table">
<tbody>
<tr>
<td class="col-md-4">
<h6><b>Properties</b></h6>
</td>
</tr>
<tr>
<td class="col-md-4">
<ul class="index-list">
<li>
<span class="modifier">Optional</span>
<a href="#limitOwnReduce">limitOwnReduce</a>
</li>
<li>
<span class="modifier">Optional</span>
<a href="#permissionDefinitionDefaults">permissionDefinitionDefaults</a>
</li>
<li>
<span class="modifier">Optional</span>
<a href="#permissionDefinitions">permissionDefinitions</a>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</section>
<section>
<h3 id="inputs">Properties</h3>
<table class="table table-sm table-bordered">
<tbody>
<tr>
<td class="col-md-4">
<a name="limitOwnReduce"></a>
<span class="name"><b>limitOwnReduce</b><a href="#limitOwnReduce"><span class="icon ion-ios-link"></span></a></span>
</td>
</tr>
<tr>
<td class="col-md-4">
<code>limitOwnReduce: <code><a href="../miscellaneous/typealiases.html#TlimitOwnReduce" target="_self" >TlimitOwnReduce<TUserId | any></a></code>
</code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Type : </i> <code><a href="../miscellaneous/typealiases.html#TlimitOwnReduce" target="_self" >TlimitOwnReduce<TUserId | any></a></code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Optional</i>
</td>
</tr>
</tbody>
</table>
<table class="table table-sm table-bordered">
<tbody>
<tr>
<td class="col-md-4">
<a name="permissionDefinitionDefaults"></a>
<span class="name"><b>permissionDefinitionDefaults</b><a href="#permissionDefinitionDefaults"><span class="icon ion-ios-link"></span></a></span>
</td>
</tr>
<tr>
<td class="col-md-4">
<code>permissionDefinitionDefaults: <code><a href="../classes/PermissionDefinitionDefaults.html" target="_self" >PermissionDefinitionDefaults</a></code>
</code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Type : </i> <code><a href="../classes/PermissionDefinitionDefaults.html" target="_self" >PermissionDefinitionDefaults</a></code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Optional</i>
</td>
</tr>
</tbody>
</table>
<table class="table table-sm table-bordered">
<tbody>
<tr>
<td class="col-md-4">
<a name="permissionDefinitions"></a>
<span class="name"><b>permissionDefinitions</b><a href="#permissionDefinitions"><span class="icon ion-ios-link"></span></a></span>
</td>
</tr>
<tr>
<td class="col-md-4">
<code>permissionDefinitions: <code><a href="../miscellaneous/typealiases.html#PermissionDefinition" target="_self" >PermissionDefinition<TUserId | TResourceId> | PermissionDefinition<TUserId, TResourceId>[]</a></code>
</code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Type : </i> <code><a href="../miscellaneous/typealiases.html#PermissionDefinition" target="_self" >PermissionDefinition<TUserId | TResourceId> | PermissionDefinition<TUserId, TResourceId>[]</a></code>
</td>
</tr>
<tr>
<td class="col-md-4">
<i>Optional</i>
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="tab-pane fade tab-source-code" id="c-source">
<pre class="line-numbers compodoc-sourcecode"><code class="language-typescript">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]))
)
);
}
</code></pre>
</div>
</div>
</div><div class="search-results">
<div class="has-results">
<h1 class="search-results-title"><span class='search-results-count'></span> result-matching "<span class='search-query'></span>"</h1>
<ul class="search-results-list"></ul>
</div>
<div class="no-results">
<h1 class="search-results-title">No results matching "<span class='search-query'></span>"</h1>
</div>
</div>
</div>
<!-- END CONTENT -->
</div>
</div>
<script>
var COMPODOC_CURRENT_PAGE_DEPTH = 1;
var COMPODOC_CURRENT_PAGE_CONTEXT = 'interface';
var COMPODOC_CURRENT_PAGE_URL = 'future-roadmap.html';
</script>
<script src="../js/libs/custom-elements.min.js"></script>
<script src="../js/libs/lit-html.js"></script>
<!-- Required to polyfill modern browsers as code is ES5 for IE... -->
<script src="../js/libs/custom-elements-es5-adapter.js" charset="utf-8" defer></script>
<script src="../js/menu-wc.js" defer></script>
<script src="../js/libs/bootstrap-native.js"></script>
<script src="../js/libs/es6-shim.min.js"></script>
<script src="../js/libs/EventDispatcher.js"></script>
<script src="../js/libs/promise.min.js"></script>
<script src="../js/libs/zepto.min.js"></script>
<script src="../js/compodoc.js"></script>
<script src="../js/tabs.js"></script>
<script src="../js/menu.js"></script>
<script src="../js/libs/clipboard.min.js"></script>
<script src="../js/libs/prism.js"></script>
<script src="../js/sourceCode.js"></script>
<script src="../js/search/search.js"></script>
<script src="../js/search/lunr.min.js"></script>
<script src="../js/search/search-lunr.js"></script>
<script src="../js/search/search_index.js"></script>
<script src="../js/lazy-load-graphs.js"></script>
</body>
</html>