Hierarchical Role-Based Access Control middleware for expressjs
npm install expressive-hrbac
- The problems it solves
- Phylosophy
- Installation
- Usage examples
- Grant access to role admin
- Associate functions to labels for easy reference
- Logically combine functions
- Roles
- The role in the request object
- Role inheritance
- Singleton
- Named singletons
- Errors
- Methods
- addRole(role, parents = null)
- addGetRoleFunc(func)
- isDescendant(descendant, ancestor)
- addBoolFunc(label, func)
- or(func1, func2)
- and(func1, func2)
- not(func)
- middleware(func)
- getInstance(label = null)
- addUnauthorizedErrorFunc(func)
- addCustomFunctionErrorFunc(func)
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...
expressive-hrbac provide ways to build easy-to-reuse middleware from logical combinations of such functions.
sh
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.
`js
(req, res) => req.user.role ==== 'admin'
`A middleware can be created from such function using method
middleware().`js
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:
`js
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().`js
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().`js
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.`js
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.`js
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.`js
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.`js
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().`js
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.
`js
// 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
);
`$3
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).$3
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.`js
hrbac.addRole('admin');
hrbac.addRole('superadmin', 'admin');
`
> NOTE: a role must have been added before we can inherit from itIn case
superadmin should inherit from both admin and blog_admin you pass an array as the second parameter.`js
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.`js
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
`js
const HRBAC = require('expressive-hrbac');let hrbac = HRBAC.getInstance('main');
hrbac.addRole('admin');
`file2.js
`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
`js
const HRBAC = require('expressive-hrbac');let hrbacMain = HRBAC.getInstance('main');
hrbacMain.addRole('admin');
let hrbacGroup = HRBAC.getInstance('groups');
hrbacGroup.addRole('groupadmin');
`file2.js
`js
const HRBAC = require('expressive-hrbac');let hrbac = HRBAC.getInstance('main');
router.put(
'/blogs/:blogId/posts/:postId',
hrbac.middleware('admin'),
controller
);
`file3.js
`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.`js
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.`js
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.`js
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 ( where 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: .`js
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 added
- parents: (_optional_) [string | string[]] - The parent role or array of parent roles for this roleReturns:
- [HRBAC] current HRBAC instance.
Throws:
-
UndefinedParameterError: When role is undefined.
- NullParameterError: When role is null.
- EmptyParameterError: When role is empty string.
- NotAStringError: when role is not a string.
- RoleAlreadyExistsError: If role already exists.
- LabelAlreadyInUseError: If role 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 objectParameters:
-
func: [sync/async function] - Function to be calledReturns:
- [HRBAC] current HRBAC instance.
Throws:
-
UndefinedParameterError: When func is undefined.
- NullParameterError: When func is null.
- NotAFunctionError: when func is not a sync/async function.
- ParameterNumberMismatchError: when func does not take exactly 2 arguments.isDescendant(descendant, ancestor)
Informs if in currently configured HRBAC instance a role is descandant of another roleParameters:
-
descendant: [string] - The descendant role string
- ancestor: [string] - The ancestor role stringReturns:
- [boolean]
true if descendant role is a descendant of ancestor role. false otherwise.NOTE: roles are NOT descendants of themselves.
Throws:
-
UndefinedParameterError: When descendant or ancestor is undefined.
- NullParameterError: When descendant or ancestor is null.
- EmptyParameterError: When descendant or ancestor is empty string.
- NotAStringError: when descendant or ancestor is not a string.
- MissingRoleError: If descendant or ancestor role has not been added yet.addBoolFunc(label, func)
Adds a boolean function and associates it to the provided labelParameters:
-
label: [string] - The label to associate the function to
- func: [sync/async function] - Function returning boolean.Returns:
- [HRBAC] current HRBAC instance.
Throws:
-
UndefinedParameterError: When label or func is undefined.
- NullParameterError: When label or func is null.
- EmptyParameterError: When label is empty string.
- NotAStringError: when label is not a string.
- NotAFunctionError: when func is not a sync/async function.
- LabelAlreadyInUseError: If label has already been used as label.
- ParameterNumberMismatchError: when func does not take exactly 2 arguments.or(func1, func2)
Combines two function with boolean OR.Parameters:
-
func1: [string | sync/async function] - Label or actual function
- func2: [string | sync/async function] - Label or actual functionReturns:
- [sync/async function] - Combined function
Throws:
-
UndefinedParameterError: When func1 or func2 is undefined.
- NullParameterError: When func1 or func2 is null.
- EmptyParameterError: When func1 or func2 is a string which is empty.
- MissingFunctionError: When func1 or func2 is a string but it is not associated to a function
- NotAFunctionError: When func1 or func2 is not a string and it is not a sync/async function.
- ParameterNumberMismatchError: when func1 or func2 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 function
- func2: [string | sync/async function] - Label or actual functionReturns:
- [sync/async function] - Combined function
Throws:
-
UndefinedParameterError: When func1 or func2 is undefined.
- NullParameterError: When func1 or func2 is null.
- EmptyParameterError: When func1 or func2 is a string which is empty.
- MissingFunctionError: If func1 or func2 is a string but it is not associated to a function.
- NotAFunctionError: when func1 or func2 is not a string and it is not a sync/async function.
- ParameterNumberMismatchError: when func1 or func2 is not a string and it is not a function which takes exactly 2 arguments.not(func)
Returnes negated functionParameters:
-
func: [string | sync/async function] - Label or actual functionReturns:
- [sync/async function] - Negated function
Throws:
-
UndefinedParameterError: When func is undefined.
- NullParameterError: When func is null.
- EmptyParameterError: When func is a string which is empty.
- MissingFunctionError: If func is a string but it is not associated to a function
- NotAFunctionError: when func is not a string and it is not a sync/async function.
- ParameterNumberMismatchError: when func 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 function
- userArg: (_optional_) [any type] - user-defined argument passed to error function in case of access denialReturns:
- [sync/async function] - middleware
Throws:
-
UndefinedParameterError: When func is undefined.
- NullParameterError: When func is null.
- EmptyParameterError: When func is a string which is empty.
- MissingFunctionError: If func is a string but it is not associated to a function
- NotAFunctionError: when func is not a string and it is not a sync/async function.
- ParameterNumberMismatchError: when func 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: When label is empty string.
- NotAStringError: when label is not a string.addUnauthorizedErrorFunc(func)
Adds a function to handle access denials.Parameters:
-
func: [sync/async function] - Function to be calledReturns:
- [HRBAC] current HRBAC instance.
Throws:
-
UndefinedParameterError: When func is undefined.
- NullParameterError: When func is null.
- NotAFunctionError: when func is not a sync/async function.
- ParameterNumberMismatchError: when func 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 calledReturns:
- [HRBAC] current HRBAC instance.
Throws:
-
UndefinedParameterError: When func is undefined.
- NullParameterError: When func is null.
- NotAFunctionError: when func is not a sync/async function.
- ParameterNumberMismatchError: when func` does not take exactly 4 arguments.