home shape

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.

scroll down line

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:

step 1

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:

step 2

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:

step 3

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:

step 4

Next switch back to the Info tab and take note of the Path that has appeared underneath the service information:

step 4a

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:

step 5

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.jsmemberof.jssessions.jsusergroups.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:

step 6

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:

step 7

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:

  1. We now have access to the active user object with the req.user property.
  2. 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 patients
  • view_patients controls whether a user can view general information about a patient
  • change_patients controls whether a user can add general information about a patient
  • remove_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:

step 8

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:

step 9

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 information
  • access_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:

step 10

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_usersdemo_memberOf and demo_usergroups.

step 11

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:

step 12

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:

user group document

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:

step 14

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:

step 15

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:

step 16

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.