Defining Roles-based Security ACLs and Supporting Multitenancy in the Node.js Strongloop Loopback framework.

Strongloop Loopback (https://loopback.io) is a Node.js framework that extends Express.js and makes it easy for developers to create REST-based CRUD APIs in minutes.

In my experience, their proclamations about RAD API development generally holds true, however, their documentation and, in particular, their tutorials, really need a lot of work in terms of organization. While there’s a lot of information that’s presented, it doesn’t really flow to tell the complete story of how you would solve real-world problems and how all of the pieces really fit together.

Hopefully you’ll find that this post helps bridge that gap. I assume that you already have a very basic understanding of the Loopback framework and that you are also a movie nerd.

Let’s say, for example, that you have an e-commerce website that you plan to offer as Software as a Service. It has the following three tables:

  • Stores – a registry containing the stores that are being hosted
  • Users – including username, password, email, and a foreign key store_id that maps back to a record in the Stores table.
  • Orders – Orders placed by a user. Contains a foreign key to the Users table and a foreign key to the Stores table.

We need to create CRUD services for each of these tables, secure them with roles-based security and also support multi-tenancy. For example, an administrator for Store ID #2 should only be able to see Orders and Users that were added store ID #2. A superuser should be able to see everything.

Creating Tables to Support Roles-Based Security

Loopback has connectors for most commonly used databases. Connection data is stored in your project’s server/datasources.json file. In this case, I’ve created a custom datasource named “ecommerce”:

{
  "db": {
    "name": "db",
    "connector": "memory"
  },
  "ecommerce": {
    "host": "[some mysql host on Amazon]",
    "port": 3306,
    "database": "ecommerce",
    "name": "ecommerce",
    "user": "quizartshaderach",
    "password" : "ForH3IsTh3"
    "connector": "mysql"
  }
}

Loopback natively supports roles-based security out of the box. You simply need to have the framework automagically add its tables to your database by placing the following script in your project’s /server/ folder and running it.

var server = require('./server');
var ds = server.dataSources.ecommerce;
var lbTables = [
 'Application','User', 'AccessToken', 'ACL', 'RoleMapping', 'Role'
];
ds.automigrate(lbTables, function(er) {
  if (er) throw er;
  console.log('Loopback tables [' - lbTables - '] created in ', ds.adapter.name);
  ds.disconnect();
});

COOL!

Defining Roles

In the generated Role table, we’re going to add two roles — a “superuser” who is the ultimate supreme being (think Keanu Reeves as John Wick), and a “storeadmin” who manages a specific store or stores in our SaaS system. Loopback has $unauthenticated, $authenticated, and $everyone as built-in, immutable roles. So, we got that going for us:

Roles

Defining Users

You may have noted that Loopback has its own User table definition. You should actually hide this from the API and instead extend its properties into your own custom table as illustrated by the following screenshot:

UsersTableDef

Your corresponding /common/models/users.json file should resemble the following snippet and only list out properties that are *not* baked into the native loopback User model. Also note that I’m already using ACLs to deny general access to unauthenticated users (a built-in immutable loopback role) and grant full access to John Wick, my superuser who can kill five angry customers in a bar with a pencil. A f****ing pencil.


 {
  "name": "users",
  "plural": "users",
  "base": "User",
  "idInjection": true,
  "options": {
    "validateUpsert": true
  },
  "properties": {
    "firstname": {
      "type": "string"
    },
    "lastname": {
      "type": "string"
    },
    "disabled": {
      "type": "boolean"
    },
    "creationDate": {
      "type": "date"
    },
    "store_id": {
      "type": "number"
    }
  },
  "validations": [],
  "relations": {},
  "acls": [],
  "methods": {}
}

Creating a Superuser/SuperAdmin Account

Now that the basics of your security framework are in place, you can use Loopback’s APIs to create the superuser account. Loopback automatically generates a Swagger ui for all of its services and will automatically hash the password and store it in your database.

explorer

Note that Loopback will default to using the npm crypto library for hashing passwords. This dependency caused us some issues when deploying on Amazon elastic beanstalk, so we had to use the pure javascript cryptojs library instead, which is also supported by the framework. So you might need to execute the following commands to install the appropriate library for your deployment platform:


npm uninstall crypto
npm install cryptojs

After your superuser account has been added to your users table, you can assign it the superuser role by inserting a record to the RoleMapping table:

addingARoleMapping

Logging In

After you’ve added your account, verify that you can use it to login by executing the users/login service from the Loopback explorer. If your login was successful, the service will return an access token id:

So the following post call: http://localhost:3000/api/v1/users/login?include=user would return something similar to:

{
  "id": "OcCGkGHCKKQrLNr2mS1DskVMXxae7IJlOezkEttVvvYXjk74gwRdpBrW7LEBufG8",
  "ttl": 1209600,
  "created": "2018-09-12T11:17:14.824Z",
  "userId": 1,
  "user": {
    "firstname": "John",
    "lastname": "Wick",
    "disabled": false,
    "creationDate": "2018-09-05T01:47:07.000Z",
    "store_id": 1,
    "realm": null,
    "username": "Administrator",
    "email": "jwick@thecontinentalhotel.com",
    "emailVerified": true,
    "id": 1
  }
}

You can then copy and paste the returned id into Loopback Explorer’s accessToken field. Loopback Explorer will subsequently automatically append a query string variable named access_token to every request.

Securing Services

There are at least 4 ways to secure services:

  1. Statically apply ACLs
  2. Define ACLs in the ACL database table
  3. Programmatically hide public methods
  4. Dynamically verify permissions at runtime with an observer event

Statically applying ACLs

We use static ACLs to restrict methods of our ORDERS table to specific groups. In this particular user story, we want to deny access to anyone who hasn’t logged in, allow full access for anyone in the “superuser” role, and allow read/write access to anyone who’s authenticated:

(common/models/orders.json)

"acls": [
    {
      "accessType": "*",
      "principalType": "ROLE",
      "principalId": "$unauthenticated",
      "permission": "DENY"
    },
    {
      "accessType": "*",
      "principalType": "ROLE",
      "principalId": "superuser",
      "permission": "ALLOW"
    },
    {
      "accessType": "READ",
      "principalType": "ROLE",
      "principalId": "$authenticated",
      "permission": "ALLOW"
    },
    {
      "accessType": "WRITE",
      "principalType": "ROLE",
      "principalId": "$authenticated",
      "permission": "ALLOW"
    }
  ]

Defining ACLs in the Database

Note that you could also implement these rules by adding records to the ACL database table, which would give you a little bit more flexibility for tweaking security without having to modify source code or restart the node service.

dbsecurity

Hiding Public Methods

Our user story dictates that no user should be able to delete an order, so we attack this requirement by stripping the programatically hiding the method in the common/models/orders.js file. We also want to disable a user’s ability to update all records and any other services that we’re not intending to use in our application:

'use strict';

module.exports = function(Orders) {
	
// remove unused methods
Orders.disableRemoteMethodByName('deleteById'); // Removes (DELETE) /products/:id
Orders.disableRemoteMethodByName("prototype.patchAttributes"); // Removes (PATCH) /products/:id
Orders.disableRemoteMethodByName('createChangeStream'); // Removes (GET|POST) /products/change-stream
Orders.disableRemoteMethodByName("updateAll"); // Removes (POST) /products/update
}

Handling Multi-tenancy and Record-Level Permissions with Observers

Loopback includes a series of Operation Hooks that are triggered from all methods that execute a particular high-level create, read, update, or delete operation. For our user story, we want to implement the following business rules:

  1. John Wick can do whatever he needs to do because he’s wearing bulletproof eveningwear.
  2. A store admin can only access records that are related to their specific store Id.
  3. Only a superadmin or a storeadmin can update an Order record.
  4. A user can only only access records that they “own” (user_id foreign key relationship).
  5. Any authenticated user can create a new order.

Each observer gets passed a context (ctx) object. Through the context object, you can access the user’s ID and dynamically modify the SQL WHERE clause before it is passed to the database server. To implement our business rules, however, we also need additional information from the users table for the logged in account. This necessitates adding the following script to the server/boot folder which will lookup additional user info on every request and add it to the context object:

(server/boot/attach-user-info.js)

module.exports = function(app) {
  app.remotes().phases
    .addBefore('invoke', 'options-from-request')
    .use(function(ctx, next) {
      if (!ctx.args.options || !ctx.args.options.accessToken) return next();

      // attach user info to context options
      const User = app.models.users;
      User.findById(ctx.args.options.accessToken.userId, function(err, user) {
        if (err) return next(err);
        ctx.args.options.currentUser = user;
        next();
      });
    });
};

Now that we’ve marshalled all of our user information, dynamically adding where clauses to queries at runtime through observers becomes a relatively trivial process:

(common/models/orders.js)


// handle multitenancy read operations
Orders.observe('access', function limitToTenant(ctx, next) {
  let authorizedRoles = ctx.options.authorizedRoles;
  let userId = ctx.options.accessToken.userId;
  let storeId = ctx.options.currentUser.store_id;

  if (!authorizedRoles.superuser) {
   // non super-duper admins ("storeadmin") can only see orders bound to their "store"
   ctx.query.where = ctx.query.where || {};
   ctx.query.where.store_id = storeId;

   if (!authorizedRoles.storeadmin) {
    // non store admins can only see orders that they "own"
    ctx.query.where = ctx.query.where || {};
    ctx.query.where.user_id = userId;
   }
  }
  next();
});

// only allow admins to update records
Orders.observe('before save', function preventUpdatesFromNonAdmins(ctx, next) {  
  let authorizedRoles = ctx.options.authorizedRoles;
		
  // the presence of ctx.instance indicates an "insert" operation
  // the presence of ctx.instance.id indicates an update
  if (!ctx.instance || ctx.instance.id) { 
    if (!authorizedRoles.superuser && !authorizedRoles.storeadmin) {
     throw new Error("Security Exception - only admins can update an order record.");
    }
  }
  next();
});

And in the end…

John was once an associate of ours. They call him Baba Yaga. Well, John wasn’t exactly the boogeyman. He was the one you sent to kill the f***ing boogeyman!

If you’ve found this post to be helpful in defeating the Strongloop Looopback learning curve boogeyman, please add a comment below.

Happy coding!

3 thoughts on “Defining Roles-based Security ACLs and Supporting Multitenancy in the Node.js Strongloop Loopback framework.

Leave a comment