expressive-hrbac v1.3.1
expressive-hrbac
Expressjs middleware builder to easily create arbitrary Hierarchical Role-Based Access Control middleware with resource granularity.
The problems it solves
Provide access to a given resource only when the user has been awarded a certain role.
Example: admin can edit any blog posts.
Provide access to a given resource only when a user has a particular right on the resource
Example: user can edit his own blog posts but not the posts from other users
Provide a way of combining logically any condition on roles or resouce access
Example: a blog post can be edited by admin or by user when he is the blog owner.
And much more...
Phylosophy
expressive-hrbac is function-based. You provide synchounous or asynchrounous functions that take the request and response objects as input and return a true boolean when access must be granted or a false boolean when access must be denied.
expressive-hrbac provide ways to build easy-to-reuse middleware from logical combinations of such functions.
Installation
npm install expressive-hrbac --save
Usage examples
Grant access to role admin
First build a function that return true when the user has role admin
.
(req, res) => req.user.role ==== 'admin'
A middleware can be created from such function using method middleware()
.
const router = require('express').Router();
const HRBAC = require('expressive-hrbac');
let hrbac = new HRBAC();
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware((req, res) => req.user.role === 'admin'),
controller
);
Now, endpoint /blogs/:blogId/posts/:postId
will return HTTP STatus 401
if property req.user.role
is not set to admin
.
Maybe you prefer asynchronous functions:
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware(async (req, res) => await getUserRole(req.user.id) === 'admin'),
controller
);
Associate functions to labels for easy reference
If you intend to use a function for more than one route you can avoid repeating its definition. You can associate it to a label using method addBoolFunc()
.
hrbac.addBoolFunc('is admin', async (req, res) => req.user.role === 'admin'));
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('is admin'),
controller
);
router.put(
'/blogs/:blogId/comments/:commentId',
hrbac.middleware('is admin'),
controller
);
NOTE: function middleware()
can be passed a synchounous/asynchrounous function or a string label associated to a function. This is true for every method that accepts functions.
Logically combine functions
Suppose you want to grant access to role admin
or to role user
but only when user
is the owner of the blog post, you first create all the functions you need and then combine everything in a single function using methods and()
, or()
, not()
.
hrbac.addBoolFunc('is admin', (req, res) => req.user.role = 'admin'));
hrbac.addBoolFunc('is user', (req, res) => req.user.role = 'user'));
hrbac.addBoolFunc('is post owner', async (req, res) => await Posts.findById(req.params.postId).ownerId === req.user.id ));
hrbac.addBoolFunc(
'is admin or post owner user',
hrbac.or(
'is admin',
hrbac.and(
'is user',
'is post owner'
)
)
);
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('is admin or post owner user'),
controller
);
To build a middleware you don't have to build your functions beforehand. If you don't intend to re-use a function, you can pass it directly to the middleware()
method while mixing it with already-associated functions.
hrbac.addBoolFunc('is admin', (req, res) => req.user.role === 'admin');
hrbac.addBoolFunc('is user', (req, res) => req.user.role === 'user');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware(
hrbac.or(
'is admin',
hrbac.and(
'is user',
async (req, res) => await Posts.findById(req.params.postId).ownerId === req.user.id
)
)
),
controller
);
Roles
So far we have used roles improperly. You should not provide functions checking for roles but use the addRole()
method instead.
hrbac.addRole('admin');
By so doing expressive-hrbac will automatically add a boolean function that checks for the admin
role and associates it to label admin
.
hrbac.addRole('admin');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
NOTE: as soon as you define a role, you can NOT use the role string as a label for you custom functions.
The role functions can be combined with other funcitons. You simply reference to it with the role string itself. For example here we provide access to admin
or to any user with ID different from 10.
hrbac.addRole('admin');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware(
hrbac.or(
'admin',
hrbac.not((req, res) => req.user.userId === 10)
),
controller
)
);
By deafult, expressive-hrbac will look into req.user.role
for the user role. You can change that behaviour providing a function that returns the role from the request object with method addGetRoleFunc()
.
hrbac.addGetRoleFunc(async (req, res) => await getUserRole(req.user.id));
hrbac.addRole('admin');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
Now, when a request arrives, expressive-hrbac
will first apply your function provided with addGetRoleFunc()
in order to extract the user role, and the will apply the role middleware checking if the role is admin
.
The role defined in the request object can be an array of roles. Meaning that a user can have multiple roles and expressive-hrbac will check if any one of them can be granted access.
// assume incoming request has property: req.user.role = ['admin', 'blog_admin']
hrbac.addRole('admin');
// Middleware below will GRANT access as user has
// role `admin` in the list of user's roles
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
The role in the request object
By way of the the function provided with method addGetRoleFunc()
or setting the request property req.user.role
a valid role must be provided to the expressive-hrbac middleware. A valid role consists in a string representing the user role or an array of strings representing the user roles. In case no valid role is provided expressive-hrbac will call next()
passing an instance of class Error
set to HTTP error 401 Unauthorized
(see section Errors below).
Role inheritance
A role can have one or more parent roles. The role will inherit all access permissions of each of its parent roles. If access is not granted for the role, a second check will be attenped for each parent role, and for each parent role of each parent role and so on.
Role parents are declared as a second argument to the addRole()
method.
Suppose role superadmin
should be able to access every resource that admin
can access.
hrbac.addRole('admin');
hrbac.addRole('superadmin', 'admin');
NOTE: a role must have been added before we can inherit from it
In case superadmin
should inherit from both admin
and blog_admin
you pass an array as the second parameter.
hrbac.addRole('admin');
hrbac.addRole('blog_admin');
hrbac.addRole('superadmin', ['admin', `blog_admin`]);
Now if superadmin
does not get access, expressive-hrbac will try again with role admin
and in case of failure with role blog_admin
.
If blog_admin
further inherited from user
, then if superadmin
does not get access, expressive-hrbac will try again with role admin
, role blog_admin
and role user
traversing the inheritance tree.
hrbac.addRole('user');
hrbac.addRole('blog_admin','user');
hrbac.addRole('admin');
hrbac.addRole('superadmin', ['admin', 'blog_admin']);
NOTE: when the request object contains an array of roles, the inheritance will be activated for each role in the array.
Singleton
So far we have worked with single instances of the HRBAC class. This implies that the HRBAC instance that you create an configure in a script will not be available in another script of your application. This might not be what you want. Typically you want to centralize your Access Control. To do so you can use the getInstance()
method to get a singleton so that you can easily access your Access Control from anywhere in your application.
file1.js
const HRBAC = require('expressive-hrbac');
let hrbac = HRBAC.getInstance('main');
hrbac.addRole('admin');
file2.js
const HRBAC = require('expressive-hrbac');
let hrbac = HRBAC.getInstance('main');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
Changing the label you provide to the getInstance()
you can create as many instances of the HRBAC class accessible from any script file just providing the right label to getIntance()
.
file1.js
const HRBAC = require('expressive-hrbac');
let hrbacMain = HRBAC.getInstance('main');
hrbacMain.addRole('admin');
let hrbacGroup = HRBAC.getInstance('groups');
hrbacGroup.addRole('groupadmin');
file2.js
const HRBAC = require('expressive-hrbac');
let hrbac = HRBAC.getInstance('main');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
file3.js
const HRBAC = require('expressive-hrbac');
let hrbac = HRBAC.getInstance('groups');
router.put(
'/groups/:groupId',
hrbac.middleware('groupadmin'),
controller
);
Errors
In case of denied access expressive-hrbac will call next()
passing an instance of class Error
set to HTTP error 401 Unauthorized
. You can change such behaviour providing a function to handle access denials using method addUnauthorizedErrorFunc()
. In the example below we return HTTP 403 Forbidden
except for user with id 10 which will get a 400 Bad Request
.
hrbac.addUnauthorizedErrorFunc((req, res, next) => {
let err = new Error();
if (req.user.id === 10) {
err.message = 'Bad Request';
err.status = 400;
} else {
err.message = 'Forbidden';
err.status = 403;
}
next(err);
})
The addUnauthorizedErrorFunc()
can also take a fourth argument to further customise, on a per-endpoint basis, the returned error in case of access denial. Start by passing a second argument to the middleware()
function that defines an access control rule for an endpoint.
router.put(
'/groups/:groupId',
hrbac.middleware('groupadmin', { userName: 'James', http: { code: 404, message = 'Not Found'}}),
controller
);
In case of access denial the function you defined with addUnauthorizedErrorFunc()
will be called with a fourth argument containing your extra argument.
hrbac.addUnauthorizedErrorFunc(async (req, res, next, myData) => {
let err = new Error();
if (await User.getName(req.user.id) === myData.name) {
err.message = myData.http.message;
err.status = myData.http.code;
} else {
err.message = 'Forbidden';
err.status = 403;
}
next(err);
})
In case of errors in the custom functions added using method addBoolFunc()
expressive-hrbac will call next()
passing an instance of class Error
set to HTTP error 500 Internal Server Error (<original error message>)
where <original error message>
will be set to the message of the thrown internal message. You can change such behaviour providing a function to handle error in custom functions using method addCustomFunctionErrorFunc()
. The first argument passed to addCustomFunctionErrorFunc()
is the error thrown by the server due to the error in the custom function. In the example below we return HTTP 500 This is very bad! This is what happened: <original error message>
.
hrbac.addCustomFunctionErrorFunc((err, req, res, next) => {
err.message = 'This is very bad! This is what happened: ' + err.message;
err.status = 500;
next(err);
});
Methods
addRole(role, parents = null)
Adds a role to the HRBAC instance. Also add a function associated to the role string.
Parameters:
role
: string - The role string to be addedparents
: (optional) [string | string[]] - The parent role or array of parent roles for this role
Returns:
- HRBAC current HRBAC instance.
Throws:
UndefinedParameterError
: Whenrole
is undefined.NullParameterError
: Whenrole
is null.EmptyParameterError
: Whenrole
is empty string.NotAStringError
: whenrole
is not a string.RoleAlreadyExistsError
: Ifrole
already exists.LabelAlreadyInUseError
: Ifrole
has already been used as label for a function.MissingRoleError
: If any parent role has not been added yet.
addGetRoleFunc(func)
Adds a function to get the role from the request object
Parameters:
func
: sync/async function - Function to be called
Returns:
- HRBAC current HRBAC instance.
Throws:
UndefinedParameterError
: Whenfunc
is undefined.NullParameterError
: Whenfunc
is null.NotAFunctionError
: whenfunc
is not a sync/async function.ParameterNumberMismatchError
: whenfunc
does not take exactly 2 arguments.
isDescendant(descendant, ancestor)
Informs if in currently configured HRBAC instance a role is descandant of another role
Parameters:
Returns:
- boolean
true
ifdescendant
role is a descendant ofancestor
role.false
otherwise.
NOTE: roles are NOT descendants of themselves.
Throws:
UndefinedParameterError
: Whendescendant
orancestor
is undefined.NullParameterError
: Whendescendant
orancestor
is null.EmptyParameterError
: Whendescendant
orancestor
is empty string.NotAStringError
: whendescendant
orancestor
is not a string.MissingRoleError
: Ifdescendant
orancestor
role has not been added yet.
addBoolFunc(label, func)
Adds a boolean function and associates it to the provided label
Parameters:
label
: string - The label to associate the function tofunc
: sync/async function - Function returning boolean.
Returns:
- HRBAC current HRBAC instance.
Throws:
UndefinedParameterError
: Whenlabel
orfunc
is undefined.NullParameterError
: Whenlabel
orfunc
is null.EmptyParameterError
: Whenlabel
is empty string.NotAStringError
: whenlabel
is not a string.NotAFunctionError
: whenfunc
is not a sync/async function.LabelAlreadyInUseError
: Iflabel
has already been used as label.ParameterNumberMismatchError
: whenfunc
does not take exactly 2 arguments.
or(func1, func2)
Combines two function with boolean OR.
Parameters:
func1
: string | sync/async function - Label or actual functionfunc2
: string | sync/async function - Label or actual function
Returns:
- sync/async function - Combined function
Throws:
UndefinedParameterError
: Whenfunc1
orfunc2
is undefined.NullParameterError
: Whenfunc1
orfunc2
is null.EmptyParameterError
: Whenfunc1
orfunc2
is a string which is empty.MissingFunctionError
: Whenfunc1
orfunc2
is a string but it is not associated to a functionNotAFunctionError
: Whenfunc1
orfunc2
is not a string and it is not a sync/async function.ParameterNumberMismatchError
: whenfunc1
orfunc2
is not a string and it is not a function which takes exactly 2 arguments.
and(func1, func2)
Combines two function with boolean AND.
Parameters:
func1
: string | sync/async function - Label or actual functionfunc2
: string | sync/async function - Label or actual function
Returns:
- sync/async function - Combined function
Throws:
UndefinedParameterError
: Whenfunc1
orfunc2
is undefined.NullParameterError
: Whenfunc1
orfunc2
is null.EmptyParameterError
: Whenfunc1
orfunc2
is a string which is empty.MissingFunctionError
: Iffunc1
orfunc2
is a string but it is not associated to a function.NotAFunctionError
: whenfunc1
orfunc2
is not a string and it is not a sync/async function.ParameterNumberMismatchError
: whenfunc1
orfunc2
is not a string and it is not a function which takes exactly 2 arguments.
not(func)
Returnes negated function
Parameters:
func
: string | sync/async function - Label or actual function
Returns:
- sync/async function - Negated function
Throws:
UndefinedParameterError
: Whenfunc
is undefined.NullParameterError
: Whenfunc
is null.EmptyParameterError
: Whenfunc
is a string which is empty.MissingFunctionError
: Iffunc
is a string but it is not associated to a functionNotAFunctionError
: whenfunc
is not a string and it is not a sync/async function.ParameterNumberMismatchError
: whenfunc
is not a string and it is not a function which takes exactly 2 arguments.
middleware(func, userArg = null)
Returns middleware function.
Parameters:
func
: string | sync/async function - Function label or actual functionuserArg
: (optional) any type - user-defined argument passed to error function in case of access denial
Returns:
- sync/async function - middleware
Throws:
UndefinedParameterError
: Whenfunc
is undefined.NullParameterError
: Whenfunc
is null.EmptyParameterError
: Whenfunc
is a string which is empty.MissingFunctionError
: Iffunc
is a string but it is not associated to a functionNotAFunctionError
: whenfunc
is not a string and it is not a sync/async function.ParameterNumberMismatchError
: whenfunc
is not a string and it is not a function which takes exactly 2 arguments.
getInstance(label = null)
Returns, and create if necessary, an HRBAC instance associated to label
. If label
is not provided will return an application-wide singleton.
Parameters:
label
: (optional) string - label to associate the instance to. If not provided will return an application-wide singleton.
Returns:
- HRBAC HRBAC instance associated to
label
if provided or an application-wide singleton.
Throws:
EmptyParameterError
: Whenlabel
is empty string.NotAStringError
: whenlabel
is not a string.
addUnauthorizedErrorFunc(func)
Adds a function to handle access denials.
Parameters:
func
: sync/async function - Function to be called
Returns:
- HRBAC current HRBAC instance.
Throws:
UndefinedParameterError
: Whenfunc
is undefined.NullParameterError
: Whenfunc
is null.NotAFunctionError
: whenfunc
is not a sync/async function.ParameterNumberMismatchError
: whenfunc
does not take 3 or 4 arguments.
addCustomFunctionErrorFunc(func)
Adds a function to handle errors with the boolean functions provided.
Parameters:
func
: sync/async function - Function to be called
Returns:
- HRBAC current HRBAC instance.
Throws:
UndefinedParameterError
: Whenfunc
is undefined.NullParameterError
: Whenfunc
is null.NotAFunctionError
: whenfunc
is not a sync/async function.ParameterNumberMismatchError
: whenfunc
does not take exactly 4 arguments.