DEPRECATED
This project has been renamed to accesscontrol-plus.
npm install accesscontrol-plus

RBACPlus
Role based access control with inheritance, dynamic attribute tests, and more
npm install rbac-plus
Features
- Write policies that are easy to read
- Define roles using inheritance
- Write fine grained permissions
- Test arbitrary attributes (e.g. of the request or requested resource)
- Restrict permissions to fields on the resource
- Apply constraints to operations on the resource
- Get explanation why a permission was granted or denied
- Use wildcard matching in policies
- Define policies in parts
- Use Typescript
Quick start
import {RBACPlus} from 'rbac-plus';
const rbacPlus = new RBACPlus();
rbacPlus
.deny('public').resource('*').action('*')
.grant('user')
.resource('posts')
.create
.read.onFields('*', '!dontreadthisfield')
.update.where(userIsAuthor)
.delete.where(userIsAuthor)
.grant('admin').inherits('user')
.resource('users')
.action('*');
function userIsAuthor({user, post}) {
return user.id == post.authorId;
}
let permission;
permission = await rbacPlus.can('user', 'posts:create');
permission = await rbacPlus.can('user', 'users:create');
permission = await rbacPlus.can('admin', 'users:create');
permission = await rbacPlus.can(
'user',
'posts:update',
{ user: {id: 123}, post: {authorId: 123}}
);
Concepts
Role Based Access Control (RBAC) versus Attribute Based Access Control (ABAC)
Role based authorization defines permissions in terms of roles in an organization - users, editors, authors, etc. This is convenient, but RBAC relies on static definitions and can't use contextual information (time, location, dynamic group membership, etc) to determine access rights. In traditional RBAC, contextual tests must be performed in other layers of an application. On the other hand, ABAC allows use of contextual information, but is also more complicated, and is sometimes described as overkill for solving typical problems. For more discussion, see: https://iamfortress.net/2017/02/15/rbac-vs-abac/.
RBACPlus: RBAC with ABAC-powers
This library combines useful properties of RBAC and ABAC. You define roles and permissions, making it easy to define and manage your policies, like tradition RBAC, but also implement fine-grained context-sensitive tests, like ABAC.
The RBACPlus
class provides the top-level API of this library. Use it to define role permissions (using grant
or deny
), add conditions using where
, and
and or
, and test whether a permission (using can
). (See API).
const rbac = new RBACPlus();
rbacPlus.deny('public').scope('*:*');
rbacPlus.grant('author').scope('post:update')
.where(authorIsResourceOwner);
Effect: Grant or Deny
A grant permits a user
Definitions
Roles, Resources, Actions and Inheritance
Each role
(e.g., "admin" or "user") has scopes
which grant or deny permission to perform actions
on resources
, potentially limited to certain fields
of the resource. Roles can inherit scopes from other roles.
Scopes
A scope
name is a resource:action
pair or a resource:action:field
triplet. For example,
"post:read"
"post:read:text"
Shortcuts for creating scopes
const userRole = rbacPlus.grant('user');
userRole.scope('post:create')
userRole.resource('post').action('create')
userRole.resource('post').create
How a permission is determined
Given, a request for a user role to read the text field of a post resource:
const permission = rbacPlus.can('user', 'post:read:text', context);
- Look for the specified role (
user
)
- if
user
doesn't exist, look for the *
role
- if no role can be found, return a denied permission
- otherwise, continue
- Look for the specified resource (
post
) on the role
- if
post
resource doesn't exist, look for the *
resource
- if no resource can be found, return a denied permission
- Look for the
read
action
- if
read
action doesn't exist, look for the *
action
- if no action can be found, return a denied permission
- otherwise, there will be a list of one or more scopes defined for the action
- Iterate through each scope
- Check whether the field (if requested in the call to
can
) is granted by the scope, and whether the condition (if provided) is satisfied. If these tests are satisfied, generate a permission and return it
- If no scope can be found for the current role, repeat this process for all inherited roles until finished
- If no permission was found, return a permission where
denied
contains descriptions of all the scopes which matched but failed
Permissions
A permission
is an instance of the Permission
class returned by RBACPlus#can
:
const permission: Permission = await rbacPlus.can('user', 'post:read');
permission.granted === "grant:user:post:read:0:::"
permission.denied === [ "..." , "..." } ]
If constraints were defined for the scope, the permission will contain a constraint
key.
permission paths
Permission paths are strings structured as:
"{grant|deny}:{role}:{resource}:{action}:{scopeIndex}:{field}:{conditionName}"
Note: the scopeIndex
indicates which
Conditions
Scopes can be restricted with conditions
, javascript sync or async functions of the form:
type Condition = (ctx: Context)=> Promise<boolean> | boolean
Conditions should be named functions. The condition name is used to generate a description string, assigned to permission.grant
rbacPlus.grant('user').scope('post:update')
.where(userIsOwner);
function userIsOwner({user, resource}) {
return user.id === resource.ownerId;
}
permission = await rbacPlus.can('user', 'post:update',
{ user: { id: 1 },
resource: { ownerId: 1 }});
permission.granted
If a condition throws an error, it is treated as though it returned false
. (Note: this may cause unexpected behavior if a condition is used to deny
, so this behavior may change in the future, such that exceptions will be treated as true
for deny
).
Context
The context
is a developer-specified value that is passed to the test function can
, which in turn passes the value to various developer-defined functions involved in testing scopes. Arbitrary values such as the current user, the request parameters, time, environment and location can be passed in the context. See the example above under Conditions.
type Context = any;
Fields
Fields represent attributes of the resource. They can be allowed or denied using the onFields
method.
rbacPlus.grant('user').resource('post').read.onFields('*', '!stats');
rbac.can('user', 'post:read:stats');
rbac.can('user', 'post:read:foo');
permission = rbac.can('user', 'post:read');
Alternatively, you can request a permission for the action, and you will receive a permission with a fields
property which is an object describing which fields are accessible:
rbac.
Field permissions can also be calculated dynamically be providing a function (which can be async). The function returns an Object mapping field names to boolean values indicating whether the field is granted or not.
E.g., the following is equivalent to the `onFields` call shown above.
```typescript
rbacPlus.grant('user').resource('post').read.onDynamicFields((ctx: Context) => ({
'*': true, // grant all fields
stats: false
}));
API
RBACPlus
Top level object which exposes the API.
constructor
import {RBACPlus} from 'rbac-plus';
const rbacPlus = new RBACPlus();
grant
Returns a Role object which can be used to grant permissions
rbacPlus.grant('admin')
deny
Returns a Role object which can be used to deny permissions
rbacPlus.deny('admin')
can
Async function returning a permission indicating whether the given role can access the scope:
const context = { user: { id: 'the-user-id' } };
const permission = await rbacPlus.can('admin', 'delete:user', context);
if (permission.granted) {
} else {
}
The first argument can also be a list of role names.
Role
Represents a named role.
inherits
Inherit scopes from another role:
role.inherits('public');
resource
Access a resource of a particular role:
role.resource('article');
scope
Access a scope, a short cut for accessing a resource then accessing an action:
role.scope('article:read');
Resource
A resource object is obtained using the Role.resource
method
action
resource.action('read');
Note: you can create multiple scopes per action. This allows you to provide different constraints and fields for the same action:
resource
.action('read').where(foobar)
.withConstraint(FooBarConstraint).onFields('foo', 'bar')
.action('read').where(baz)
.withConstraint(BazConstraint).onFields('baz');
CRUD shortcuts
resource.create
resource.read
resource.update
resource.delete
Scope
Represents a specific permission, and enables setting conditions and constrains on the permission.
where
Sets one or more tests which must all pass for the permission to be granted. This method is equivalent to scope.and
, except for the name generated in the permission.grant
and permission.deny
:
function async ownsResource({ user, request }) {
const resource = await MyResource.loadFromDB({ id: request.params.id });
return resource.id === user.id;
}
scope.where(ownsResource);
and
Grants permission for the scope if all of the tests return truthy values:
scope.and(test1, test2, test3...);
or
Grants permission for the scope if any of the tests return a truthy value:
scope.or(test1, test2, test3...);
withConstraint
Note: constraints are deprecated and may be removed from a future version of the API.
Add a function which returns a constraint useful to the developer for passing to a function that accesses a resource:
rbacPlus.grant('user').scope('article:create')
.withConstraint(({user})=>({ ownerId: user.id}));
...
let permission = await rbacPlus.can('user', 'article:create', { user: { id: 123 }});
if (permission.granted) {
await Article.create(permission.constraint);
}
onFields
Restrict the grant/denial to specific fields. Provide a list of fieldNames. Use *
for all fields, !{fieldName}
to exclude a field:
rbacPlus.grant('admin').scope('user:read')
.onFields('*');
rbacPlus.can('admin', 'user:read:superPrivateData');
rbacPlus.grant('admin').scope('user:read')
.onFields('*', '!privateData');
permission = await rbacPlus.can('admin', 'user:read:privateData');
permission = await rbacPlus.can('admin', 'user:read:name');
rbacPlus.grant('admin').scope('user:read')
.onFields('name');
await rbacPlus.can('admin', 'user:read:name');
await rbacPlus.can('admin', 'user:read:phoneNumber');
onDynamicFields
Generate field grants dynamically, given a context. You can use async calls, if needed:
rbacPlus.grant('admin').scope('user:read')
.onDynamicFields(async ({admin, user}: Context) => {
const permissive = await myBackend.adminHasPermissionFromUser(admin, user);
if (permissive) {
return { '*': true };
} else {
return { 'id': true, 'userName': true, 'phoneNumber': true };
}
});
Permission
Object returned by RBACPlus#can
granted
If permission granted this will be a string describing the scope granted.
denied
If permission denied, this is set to an array of objects that contain
field
Tests whether permission was granted for the specified field. Accounts for wildcards and denied fields (!foo
) provided in .onFields
.
permission.field('foo')
Extended Example
import RBACPlus from 'rbac-plus';
function userIsResourceOwner({user, resource}) {
return user.id === resource.ownerId;
}
function userImpersonatesResourceOwner({user, resource}) {
return user.impersonationId === resource.ownerId;
}
function articleIsPublished({resource}) {
return resource.state === 'published';
}
const rbac = new RBACPlus();
rbac
.deny('public')
.scope('*:*')
.grant('public')
.scope('article:read')
.where(articleIsPublished)
.onFields('*', '!viewers')
.grant('author').inherits('public')
.resource('article')
.action('create')
.withConstraint(({user})=>({ ownerId: user.id }))
.action('read')
.where(userIsResourceOwner)
.action('update')
.where(userIsResourceOwner)
.grant('admin').inherits('author')
.resource('article')
.action('read').where(userImpersonatesResourceOwner)
.grant('superadmin').inherits('admin')
.resource('user')
.action('*');
const user = { id: 1234 };
const draft = { ownerId: 1234, state: 'draft', text: '...' };
const published = { ownerId: 1234, state: 'published', text: '...' };
const adminUser = { id: 999, impersonationId: 1234 };
const superAdmin = { id: 222 };
async function testPermissions {
let permission;
permission = await rbacPlus.can('public', 'article:read', { user: null, resource: published });
permission = await rbacPlus.can('public', 'article:read', { user: null, resource: draft });
permission = rbacPlus.can('author', 'article:read', { user, resource: draft });
permission = rbacPlus.can('user', 'article:update', { user: user, resource: draft });
permission = rbacPlus.can('admin', 'article:update', { user: adminUser, resource: draft});
permission = rbacPlus.can('admin', 'article:read', { user: adminUser, resource: draft});
permission = rbacPlus.can('superadmin', 'user:delete', { user: superAdmin, resource: user });
}