A zero-dependency package to do the heavy-lifting (in back-end APIs) of allowing or denying access based on a hierarchy of restrictions, permissions, roles, and users.
The main goal is to make implementing role-based access (with permission overrides) in your code as easy as the following example:
// The following promise will either return true or throw an 'Unauthorized' error
doorlock.evaluateAbilities(
// a user object (previously fetched by your code) to be evaluated against
// roles, permissions, and restrictions (a.k.a. "abilities")
user,
// options defining the "abilities" the user will be challenged against
{
// a group of users that share the 'author' role will be allowed access
roleHandles: ['author'],
// any single user with the 'doc-create' permission will be allowed access
// (regardless of whether they have the 'author' role or not)
permissionHandles: ['doc-create'],
// any single user with the 'deny-doc-create' restriction will be blocked and bounced-back
// (even if they have the 'author' role or the 'doc-create' permission)
restrictionHandles: ['deny-doc-create'],
},
).then(
() => {
next();
}
).catch(
(err) => {
res.status(401);
res.render('error', { error: err });
}
);
(Click here to see an example on RunKit that you can try out right now!)
- If you are also looking to implement login using Google, Facebook, Github, and/or implement your own custom SSO, take a look at Session SSO
This project is open to updates by its users, I ensure that PRs are relevant to the community. In other words, if you find a bug or want a new feature, please help us by becoming one of the contributors ✌️ ! See the contributing section
Please consider:
- Buying me a coffee ☕
- Supporting Simply Hexagonal on Open Collective 🏆
- Starring this repo on Github 🌟
In order to better understand how to properly use DoorLock, it's good to understand the basic entity definitions and assumptions driving the logic.
Firstly, these are the basic principles that detail access hierarchy:
- Permissions allow functionality to run
- Restrictions block functionality from running
- A Super Admin is a hard-coded user which has all permissions and no restrictions
- A set of permissions and restrictions are called abilities
- Roles have abilities
- Users have roles
- Users can also have abilities which override the user's role(s) abilities
- Roles and abilities have handles which are used to let doorlock know when a user is allowed to trigger specific functionality
From the previous, the following is assumed in regards to your user and access related data:
// Permissions will be stored with a structure containing at least the following properties:
{
entityId: string,
name: string,
handle: string,
description: string
}
// Restrictions will be stored with a structure containing at least the following properties:
{
entityId: string,
name: string,
handle: string,
description: string
}
// Roles will be stored with a structure containing at least the following properties:
{
entityId: string,
name: string,
handle: string,
description: string,
abilities: {
permissions: PermissionId[],
restrictions: RestrictionId[]
}
}
// User will be stored with a structure containing at least the following properties:
{
userId: string,
roles: RoleId[],
abilities: {
permissions: PermissionId[],
restrictions: RestrictionId[],
}
}
Finally, the following rule definitions dictate precedence, from top to bottom, where top-most rules trump any rules below them:
- User restrictions
- ⬇
- User permissions
- ⬇
- User roles (when a user has two "conflicting" roles [i.e. one role restricts what the other permits], role restrictions will be respected above role permissions)
- ⬇
- Role restrictions
- ⬇
- Role permissions
This project is meant to be self-contained, small, and easy to maintain. When you take a look at the source code you will see that much of the code is reutilized amongst DoorLock entities (restrictions, permissions, roles, and users).
Experience dictates that the logic behind groups and organizations will vary widely between projects.
For example, just wonder if you will need to support the use-case for a group comprised of users from two or more organizations, a cross-organizational group if you may. That right there would be a text-book example of the law of diminishing returns, where the effort to implement said use-case would be disproportionately higher to the benefit provided to a smaller percentage of the devs using DoorLock.
If your project is large enough to include groups and organizations as part of the access-management requirements, more than likely your team will have sufficient members to create this higher order of access hierarchy without much hassle. Specially taking into consideration that you'd already have DoorLock ready to throw as the cherry on top of your API cake 😉
Import DoorLock as you would any other package:
import DoorLock from 'doorlock';
DoorLock needs to be able to fetch roles, permissions, and restrictions (i.e. from your app's DB), thus anywhere on your code before your API's server start, define the following functions:
const fetchRolesById = async (roleIds) => /* your custom logic */;
const fetchPermissionsById = async (permissionIds) => /* your custom logic */;
const fetchRestrictionsById = async (restrictionIds) => /* your custom logic */;
const fetchRolesByHandle = async (roleHandles) => /* your custom logic */;
const fetchPermissionsByHandle = async (permissionHandles) => /* your custom logic */;
const fetchRestrictionsByHandle = async (restrictionHandles) => /* your custom logic */;
Also before starting the server, and more importantly, before defining any routes you wish to control access to, instantiate DoorLock with the following options (which include the aforementione fetch functions):
const doorlock = new DoorLock({
superAdminId: '...', // must be accessible from the user object as: user.id
fetchRolesById,
fetchPermissionsById,
fetchRestrictionsById,
fetchRolesByHandle,
fetchPermissionsByHandle,
fetchRestrictionsByHandle,
// The following are optional
verifyRoleExists: true, // defaults to: false (to save on performance)
verifyAbilitiesExist: true, // defaults to: false (to save on performance)
debug: true, // defaults to: false
logFn: (message: string) => console.log('MY CUSTOM LOG =>', message), // only works if debug: true
});
NOTE: The verifyRoleExists
and verifyAbilitiesExist
options should be turned on solely to debug
errors or inconsistencies that you may suspect to be caused by "ghost" roles or abilities left behind
after deletion of the original entity (i.e. a user has a role id of a role that was deleted).
Once instantiated you can implement DoorLock on each request, as in the following example:
server.post('/doc', (req, res) => {
const user = /* i.e. the user object returned after validating the access token on req.headers */;
doorlock.evaluateAbilities(
user,
{
roleHandles: ['author'], // <== Only allows access to users with the author role
permissionHandles: [],
restrictionHandles: [],
},
).then(() => {
res.status(200);
res.send(`User ${userId} is allowed to create documents`);
}).catch(() => {
res.status(401);
res.render('error', {error: `User ${userId} is NOT allowed to create documents`});
});
});
Or better yet, create access specific middlewares:
// Middleware that only allows users/roles with the 'doc-manipulation' permission
const docManipulationAccessControl = (req, res, next) => {
const user = /* i.e. the user object returned after validating the access token on req.headers */;
return doorlock.evaluateAbilities(
user,
{
roleHandles: [],
permissionHandles: ['doc-manipulation'],
restrictionHandles: [],
},
).then(() => {
next();
}).catch(() => {
res.status(401);
res.send('Unauthorized');
});
}
// Then the access logic becomes trivial to re-use and maintain
server.post('/doc', docManipulationAccessControl, (req, res) => { /* ... */ });
server.get('/doc', docManipulationAccessControl, (req, res) => { /* ... */ });
server.put('/doc', docManipulationAccessControl, (req, res) => { /* ... */ });
server.delete('/doc', docManipulationAccessControl, (req, res) => { /* ... */ });
If you would like a functioning example, you're welcome to try this dummy server with DoorLock example on Runkit.
And if you'd like a more thorough example take a look at the mock and test files under the specs
directory on this repository.
- If a user that's supposed to be given access keeps being blocked, there may be an error produced by
missing data or properties that is being suppressed by the
catch
logic. In these cases simply try and refactor the catch logic to log the error (i.e..catch((e) => console.log(e))
) to get more details to debug with. Although it would be advisable that you refactor the catch clause to verify the error and alert you of any error other thanUnauthorized
.
Rollup was chosen to handle the transpiling, compression, and any other transformations needed to get Typescript code running as quickly and performant as possible.
Yes, thank you! Projects like this thrive when they are community-driven.
Please update the docs and tests and add your name to the package.json file on any PR you submit.
Thanks goes to these wonderful people (emoji key):
Jean Lescure 🚧 💻 📓 |
Diana Lescure 📖 👀 🎨 |
Copyright (c) 2020-2021 DoorLock Contributors.
Licensed under the Apache License 2.0.