Foxx Fine-Grained Permissions
In this tutorial we’ll create a simple HTTP API for managing patient records and then extend it with authentication and a permission model to limit our employees’ access. You can find the code for this tutorial on GitHub.
As of ArangoDB 3.0 it’s very easy to create a new Foxx service from scratch. However because we want to create a simple REST API the quickest way to get started is to let the web admin interface create the boilerplate for us. Find the Services tab in the web interface and press the Add Service button:
We’ll use the mount path /demo
in this tutorial but you can choose whatever path you like. Click the New Service tab to let ArangoDB generate a service for you:
Feel free to enter whatever information you find necessary but make sure to add the following document collections:
patients
users
usergroups
sessions
You’ll also need to add the following edge collections:
hasPerm
memberOf
Once you click on the Generate button, a small Foxx service with a full REST API for all these collections will be created and appears in the service list with the chosen mount path:
Click on the new service in the list to enter the service view and you’ll find an interactive API explorer under the API tab. ArangoDB has dutifully created a full REST API for every collection we specified. Since our API is only really concerned with patient data, we’ll remove the redundant routes shortly.
Feel free to stay a while and explore the routes before continuing to the next step:
To be able to iterate more quickly, we’ll switch the service to development mode and edit its files directly. To do this, switch to the Settings tab in the service view and press the big yellow Set Development button:
Next switch back to the Info tab and take note of the Path that has appeared underneath the service information:
This is the filesystem path of the folder in which ArangoDB has deployed your service. Fire up your editor or IDE of choice and open that folder:
We’ll switch back and forth between the editor and the web interface from now on so keep both open and easily accessible.
Cleaning up the boilerplate
Before we continue, we need to tell ArangoDB that usernames should be unique. Our users will log in with the value of their username
property instead of simply storing their username as their _key
. This allows us to change usernames later but means we need to create a uniqueness constraint. Let’s do this by adding the following lines to the setup.js
file in the scripts
folder:
const users = module.context.collection('users'); users.ensureIndex({ type: 'hash', fields: ['username'], unique: true });
The setup script will be re-run during development mode every time we access our service. This means the uniqueness constraint will be picked up automatically the next time we need it.
The main.js
file is the entry point to our service and mounts all the REST routers we generated. Before adding more code, let’s get rid of the code we don’t need: remove all the extra calls to module.context.use
so only the patients
routes remain:
'use strict'; module.context.use('/patients', require('./routes/patients'), 'patients');
Feel free to remove the corresponding files hasperm.js
, memberof.js
, sessions.js
, usergroups.js
and users.js
in the routes
folder and all files except patient.js
in the models
folder as well if you want. We’re not going to use them in this tutorial but leaving them around won’t do any harm either.
If you reload the API explorer, you’ll notice that the extra routes have vanished:
Login and signup
To authenticate our users we’re going to use cookies that will store our users’ session IDs. Thankfully Foxx provides its own session framework to make this easy. Open the file main.js
again and append the following code:
const sessionsMiddleware = require('@arangodb/foxx/sessions'); const sessions = sessionsMiddleware({ storage: module.context.collection('sessions'), transport: 'cookie' }); module.context.use(sessions);
This imports the session framework, configures the session middleware to use our collection with cookies and tells our service to use the middleware for every route by mounting it directly. Our routes will now also have access to two special properties of the request object: session
, containing the session data for the request, and sessionStorage
providing access to the storage that will hold our sessions (in this case the sessions
collection).
To add authentication and handle permissions we’ll need a couple of helpers, so let’s quickly create a util
folder. Inside of it create a short JavaScript file auth.js
containing the following:
'use strict'; const createAuth = require('@arangodb/foxx/auth'); module.exports = createAuth();
This creates a hash-based password authenticator. You should replace it with something more secure before going to production but it’s still better than storing passwords in plain text.
Next we’ll use the authenticator and the sessions provided by the sessions middleware to implement a simple login and signup system. Create a new file in the routes
folder also called auth.js
and start by adding the following:
'use strict'; const joi = require('joi'); const createRouter = require('@arangodb/foxx/router'); const auth = require('../util/auth'); const users = module.context.collection('users'); const router = createRouter(); module.exports = router;
This makes sure we have a new router and the joi
module lets us provide input validation.
To log an existing user in, we need a request with a username and password, validate the password and then assign the request’s session to that user. Let’s add this as a /login
route:
router.post('/login', function (req, res) { const username = req.body.username; const user = users.firstExample({username}); const valid = auth.verify( user ? user.authData : {}, req.body.password ); if (!valid) res.throw('unauthorized'); req.session.uid = user._key; req.sessionStorage.save(req.session); res.send({sucess: true}); }) .body(joi.object({ username: joi.string().required(), password: joi.string().required() }).required(), 'Credentials') .description('Logs a registered user in.');
Note that we’re not only adding the user’s _key
to the session but also telling the session storage to save this change. Foxx will not pick up these changes automatically so make sure to do this whenever you make changes to the session data.
We now have a way to authenticate, but we don’t have any users to log in with, yet. Let’s address this by creating a simple signup route:
router.post('/signup', function (req, res) { const user = {}; try { user.authData = auth.create(req.body.password); user.username = req.body.username; user.perms = []; const meta = users.save(user); Object.assign(user, meta); } catch (e) { // Failed to save the user // We'll assume the uniqueness constraint has been violated res.throw('bad request', 'Username already taken', e); } req.session.uid = user._key; req.sessionStorage.save(req.session); res.send({success: true}); }) .body(joi.object({ username: joi.string().required(), password: joi.string().required() }).required(), 'Credentials') .description('Creates a new user and logs them in.');
Note that we’re assigning no default permissions and automatically logging in the user after creating the account. In a real world scenario we would probably want to introduce a verification step and extend the login route with a small check to make sure the user has been verified before they can log in.
To find out whether the login worked, we’ll add another route which simply returns our username:
router.get('/whoami', function (req, res) { try { const user = users.document(req.session.uid); res.send({username: user.username}); } catch (e) { res.send({username: null}); } }) .description('Returns the currently active username.');
And finally let’s clear the session user on logout:
router.post('/logout', function (req, res) { if (req.session.uid) { req.session.uid = null; req.sessionStorage.save(req.session); } res.send({success: true}); }) .description('Logs the current user out.');
Make sure to actually mount the routes before trying them out. Open the main.js
again and add the following line after the patients router:
module.context.use('/auth', require('./routes/auth'), 'auth');
Let’s give our login system a spin. Refresh the API explorer and try signing up with the username “demo”, then using the whoami
route to confirm that it worked and finally logging out again:
Great! Next we’ll set up two more helper functions to let us control access to routes based on a user’s permissions.
Checking Permissions with User Groups
Before we go on, we need easy access to the active user in all routes that want to restrict access based on permissions, so let’s go back to our main.js
and add the following at the end:
const users = module.context.collection('users'); module.context.use(function (req, res, next) { if (req.session.uid) { try { req.user = users.document(req.session.uid) } catch (e) { req.session.uid = null; req.sessionStorage.save(); } } next(); });
This makes sure of two things:
- We now have access to the active user object with the
req.user
property. - If the user was deleted, they are automatically logged out.
With that out of the way, create a file called hasPerm.js
in the util
folder we created earlier and add the following:
'use strict'; const db = require('@arangodb').db; const aql = require('@arangodb').aql; const hasPerm = module.context.collection('hasPerm'); const memberOf = module.context.collection('memberOf'); module.exports = function (user, name, objectId) { if (!user) return false; if (user.perms.includes(name)) return true; if (objectId && hasPerm.firstExample({ _from: user._id, _to: objectId, name })) return true; const groupHasPerm = db._query(aql` FOR group IN 1..100 OUTBOUND ${user._id} ${memberOf} FILTER ${name} IN group.perms LIMIT 1 RETURN true `).next() || false; if (groupHasPerm || !objectId) return groupHasPerm; return db._query(aql` LET groupIds = ( FOR group IN 1..100 OUTBOUND ${user._id} ${memberOf} RETURN group._id ) FOR perm IN ${hasPerm} FILTER perm.name == ${name} && perm._from IN groupIds && perm._to == ${objectId} LIMIT 1 RETURN true `).next() || false; };
This function can be a bit daunting but don’t worry, it’s actually rather straightforward. To determine whether a user has a given permission for a given object, we need to check four things:
- whether the user has the general permission (via
user.perms
) - whether the user has the permission for the given object (via an edge in
hasPerm
) - whether the user is in a group that has the general permission (via
group.perms
) - whether the user is in a group that has the permission for the given object (via an edge in
hasPerm
)
Sometimes it doesn’t make sense to check the permission for a specific object so in those cases we only want to check for general permissions. Also we want to allow nesting user groups to support more complex scenarios, so we’re using a graph traversal query to find all relevant groups for the user.
The next helper function is a lot simpler. Create a file called restrict.js
in the same folder and add the following:
'use strict'; const hasPerm = require('./hasPerm'); module.exports = function (name) { return function (req, res, next) { if (!hasPerm(req.user, name)) res.throw(403, 'Not authorized'); next(); }; };
Note that this function returns another function. The function it returns looks a lot like the request handlers we used for routing but takes a third argument. This means we can use it as middleware. We’ve seen middleware earlier when we created a session middleware and used it directly in our service.
Enforcing Document Level Permissions
Right now all of our patient data is out in the open and accessible to everyone. Let’s think about changing that. We want to introduce some permissions for general CRUD operations:
add_patients
controls whether a user is allowed to create new patientsview_patients
controls whether a user can view general information about a patientchange_patients
controls whether a user can add general information about a patientremove_patients
controls whether a user is allowed to delete existing patients
To keep things simple, we want all authorized users to be able to create new patients and view existing ones. Users who created a patient should also be able to delete them.
Let’s put the helpers we created to use. Open the patients.js
file in the routes
folder and make the following changes:
Import the restrict
and hasPerm
helpers:
const createRouter = require('@arangodb/foxx/router'); > const restrict = require('../util/restrict'); > const hasPerm = require('../util/hasPerm');
Add the hasPerm
collection as perms
(to avoid naming conflicts with our helper):
const patients = module.context.collection('patients'); > const perms = module.context.collection('hasPerm');
Restrict the patients list to users with the view_patients
general permission:
< router.get(function (req, res) { > router.get(restrict('view_patients'), function (req, res) {
Restrict the ability to create patients to users with the add_patients
general permission and give the user who created the patient the permissions to edit and delete it:
< router.post(function (req, res) { > router.post(restrict('add_patients'), function (req, res) { let meta; try { meta = patients.save(patient); } catch (e) { if (e.isArangoError && e.errorNum === ARANGO_DUPLICATE) { throw httpError(HTTP_CONFLICT, e.message); } throw e; } Object.assign(patient, meta); > perms.save({_from: req.user._id, _to: patient._id, name: 'change_patients'}); > perms.save({_from: req.user._id, _to: patient._id, name: 'remove_patients'});
Restrict the patient details to users with the view_patients
permission for the given patient:
router.get(':key', function (req, res) { const key = req.pathParams.key; > const patientId = `${patients.name()}/${key}`; > if (!hasPerm(req.user, 'view_patients', patientId)) res.throw(403, 'Not authorized');
Restrict editing patients to users with the change_patients
permission for the given patient:
router.put(':key', function (req, res) { const key = req.pathParams.key; > const patientId = `${patients.name()}/${key}`; > if (!hasPerm(req.user, 'change_patients', patientId)) res.throw(403, 'Not authorized'); router.patch(':key', function (req, res) { const key = req.pathParams.key; > const patientId = `${patients.name()}/${key}`; > if (!hasPerm(req.user, 'change_patients', patientId)) res.throw(403, 'Not authorized');
Restrict deleting patients to users with the remove_patients
permission for the given patient and make sure all object permissions for the patient are also deleted:
const key = req.pathParams.key; > const patientId = `${patients.name()}/${key}`; > if (!hasPerm(req.user, 'remove_patients', patientId)) res.throw(403, 'Not authorized'); > for (const perm of perms.inEdges(patientId)) { > perms.remove(perm); > }
When trying to access any of the patients routes in the API explorer you should now see a HTTP 403 (“forbidden”) error:
Don’t worry about the stacktrace in the server response. It is only emitted in development mode to provide you with additional information to determine the source of the error.
Our patient routes currently deny access from everyone equally, authenticated or not. We said all new users should have the permissions add_patients
and view_patients
, so let’s open up our auth.js
file in the routes
folder and adjust the signup route to provide new users with the default permissions:
user.authData = auth.create(req.body.password); user.username = req.body.username; < user.perms = []; > user.perms = ['add_patients', 'view_patients'];
Try signing up for a new account and accessing the patient list again:
If you already created some patients when playing around with the API explorer they should show up here. Otherwise you should see an empty list but no error.
Adding Field Level Permissions
Right now our patients are just empty objects. To make things a little more interesting, let’s give them some properties. Open up the file patient.js
in the models
folder and extend the schema a bit:
schema: { // Describe the attributes with joi here < _key: joi.string() > _key: joi.string(), > name: joi.string().required(), > dob: joi.string().regex(/^\d{4}-\d{2}-\d{2}$/).required(), > medical: joi.array().optional(), > billing: joi.array().optional() },
With more information, we should also address confidentiality. Only medical personnel should have access to a patient’s medical information, for example, and only accounting needs access to billing information. So let’s extend our permission list with:
access_patients_medical
controls whether a user is allowed to access medical informationaccess_patients_billing
controls whether a user is allowed to access billing information
We also want to represent medical staff and accounting as two separate user groups and give them their respective privileges rather than handling them for each user separately.
First let’s enforce these permissions. Open the patien.js
file in routes
again and change the following routes:
Hide the billing
and medical
fields in the list view:
router.get(restrict('view_patients'), function (req, res) { < res.send(patients.all()); > res.send(patients.toArray().map(patient => { > delete patient.billing; > delete patient.medical; > return patient; > }));
Make sure the fields are only stored when creating patients if the user is authorized to manage them:
router.post(restrict('add_patients'), function (req, res) { const patient = req.body; > if (!hasPerm(req.user, 'access_patients_billing')) delete patient.billing; > if (!hasPerm(req.user, 'access_patients_medical')) delete patient.medical;
Filter out the fields when viewing the patient without the appropriate permissions:
router.get(':key', function (req, res) { const key = req.pathParams.key; const patientId = `${patients.name()}/${key}`; if (!hasPerm(req.user, 'view_patients', patientId)) res.throw(403, 'Not authorized'); let patient try { patient = patients.document(key); } catch (e) { if (e.isArangoError && e.errorNum === ARANGO_NOT_FOUND) { throw httpError(HTTP_NOT_FOUND, e.message); } throw e; } > if (!hasPerm(req.user, 'access_patients_billing', patientId)) delete patient.billing; > if (!hasPerm(req.user, 'access_patients_medical', patientId)) delete patient.medical; res.send(patient); }, 'detail')
Preserve the existing values when a patient is replaced but the user is not allowed to change them:
router.put(':key', function (req, res) { const key = req.pathParams.key; const patientId = `${patients.name()}/${key}`; if (!hasPerm(req.user, 'change_patients', patientId)) res.throw(403, 'Not authorized'); > const canAccessBilling = hasPerm(req.user, 'access_patients_billing', patientId); > const canAccessMedical = hasPerm(req.user, 'access_patients_medical', patientId); const patient = req.body; let meta; try { > const old = patients.document(key); > if (!canAccessBilling) patient.billing = old.billing; > if (!canAccessMedical) patient.medical = old.medical; meta = patients.replace(key, patient); } catch (e) { if (e.isArangoError && e.errorNum === ARANGO_NOT_FOUND) { throw httpError(HTTP_NOT_FOUND, e.message); } if (e.isArangoError && e.errorNum === ARANGO_CONFLICT) { throw httpError(HTTP_CONFLICT, e.message); } throw e; } Object.assign(patient, meta); > if (!canAccessBilling) delete patient.billing; > if (!canAccessMedical) delete patient.medical; res.send(patient);
And finally prevent unauthorized users from overriding the details on update:
router.patch(':key', function (req, res) { const key = req.pathParams.key; const patientId = `${patients.name()}/${key}`; if (!hasPerm(req.user, 'change_patients', patientId)) res.throw(403, 'Not authorized'); > const canAccessBilling = hasPerm(req.user, 'access_patients_billing', patientId); > const canAccessMedical = hasPerm(req.user, 'access_patients_medical', patientId); const patchData = req.body; let patient; try { > if (!canAccessBilling) delete patchData.billing; > if (!canAccessMedical) delete patchData.medical; patients.update(key, patchData); patient = patients.document(key); } catch (e) { if (e.isArangoError && e.errorNum === ARANGO_NOT_FOUND) { throw httpError(HTTP_NOT_FOUND, e.message); } if (e.isArangoError && e.errorNum === ARANGO_CONFLICT) { throw httpError(HTTP_CONFLICT, e.message); } throw e; } > if (!canAccessBilling) delete patient.billing; > if (!canAccessMedical) delete patient.medical; res.send(patient);
Now we’ve fully locked down our API.
Assigning Permissions Manually
Locking down the API is all well and good but we want some users to be able to access the parts we just sealed off. Implementing an API for managing user permissions, editing users and groups and assigning users to groups is beyond the scope of this guide but for demonstrative purposes it’s very easy to do this manually.
First create a couple of users with the signup route from the API explorer. For this guide we’ll go with dr_doom
and dr_acula
, our medical staff, and e_scrooge
, our accountant. Feel free to substitute your own names as you see fit:
Next we’ll switch to the *Collections* list in the web interface. Depending on which mount path you chose, your collections will look differently. We used /demo
as our mount path so the collections we’re looking for are demo_users
, demo_memberOf
and demo_usergroups
.
Let’s open demo_usergroups
and create a group by pressing the “plus” button. The _key
doesn’t matter so we’ll leave it empty and let ArangoDB generate one for us:
Append two new fields, “name” and “perms”, using the buttons on the left. You may need to set the type of “perms” to Array
by using the same buttons. Set the “name” to “doctors” and add access_patients_medical
to the “perms” array. Feel free to switch from *Tree* to *Code* view if you find it easier to just enter raw JSON. Finally press “Save” to save the usergroup:
Take note of the value in the _id
field at the top. We’ll need this in a few seconds.
Go back to the collection list and open the demo_users
collection. Try to find the medical staff we created earlier. If you have created a number of users this can be tricky at a glance so try using the Filter (funnel) button and filter by username:
Note down the _id
values of all medical staff. In our case they look something like demo_users/69320
. Once you’re confident you’ve found all users that should be considered medical staff, open the demo_memberOf
collection and click the “plus” button to create new edges:
Enter the _id
of the first user into the _from
field and the _id
of the user group into the _to
field. Press the “Create” button to create the new edge. You don’t need to do anything else with the document so feel free to return to the collection view. Repeat the process for the other users you want to be part of the medical group.
Create a second user group with the name “accountants” and give it the access_patients_billing
permission, then repeat the process to add all accounting users to that group with edges in the demo_memberOf
collection.
Finally return to the API explorer, log in as one of the medical users and try creating a patient with a medical record:
That’s it! You now have an API that supports field-level permissions with user groups.
If you want to explore the permission system we created further, consider adding an API for managing the users and user groups, or adding additional fields to our patients that you want to restrict to specific user types.
Have another look at the patients listing: we went the easy way and just removed all privileged fields but you could also write an AQL query that filters the result according to the user and groups permissions.
This guide only aims to show you a simple scenario in-depth. The permissions model in this guide is actually based on a real-world system that handles far more intricate rules for thousands of users in production. By collocating your permissions system with your data access layer in Foxx, even the most complex scenarios become manageable.