Ubiquitous Fine Grained Access Control With Node.js and RethinkDB

A

cess control is apart of virtually every application. Certain users should be able to see / do X, but never be able to do Y. Usually these kind of requirements are expressed in overly simple terms, such as:

Admin users should see that button but regular users should not.
--Every Product Manager. Ever

With traditional RDBM Systems, the direct approach is to set up a Many-To-Many relation ship between Users and Roles Where roles are basically like tags. Subsequently code paths are created to check if a user has a role named admin . Which gets very ugly as these sorts of simple checks quickly have to cover increasingly complex logic for users with multiple roles and for roles that were not originally planned for. What is worse is that every week, the boss is promising specific individual users super secret access to parts of the system that even the admin users don't have.

The next course of action is to expand on the solution be adding a Many-To-Many relation from Role to Permission and for the cases where the boss over promises, we'll add a Many-To-Many from User to Permission as well. This covers all of the requirements and as far as we can for see, all of the use cases. But it is going to be expensive. If we want to check if a user has a specific permission we'll have:

  • Get All Permissions for all of the Roles of the User
  • Get All One off Permissions for the User
  • Combine them into a unique set of individual Permissions
  • Check for the permission in question.

You can do this fairly efficiently in SQL with a UNION of the two queries.

select  
auth_permission.*  
from authentication_user  
inner join authentication_user_user_permissions on user_id = authentication_user.id  
inner join auth_permission on authentication_user_user_permissions.permission_id = auth_permission.id  
where authentication_user.id = 1

UNION

select  
auth_permission.*  
from authentication_user  
inner join authentication_user_groups on user_id = authentication_user.id  
inner join authentication_group on authentication_group.group_id = authentication_user_groups.group_id  
inner join authentication_group_permissions on authentication_group_permissions.group_id = authentication_group.group_id  
inner join auth_permission on authentication_group_permissions.permission_id = auth_permission.id  
where authentication_user.id = 1  

Not too bad. But, this is a join across 5 tables and this is just to get a set of permissions. We still need to determine what it all means which usually comes down to comparing IDs. In most all situations, we won't have IDs of permissions as you really don't want to hard code IDs all over your application. That means that we effectively need to loop through a potentially massive list of permissions to see if the user in question has one. Gross.

Sure we can make this work, but its complicated, slow and really clunky.

Use cases like this really show some of the shortcomings of RDBM systems. Sure we can make this work, but its complicated, slow and really clunky. We really want a nested document that we can quickly traverse through to locate a single value. Lately, I have been really interested in RethinkDB. It really sits in the sweet spot between a document store and relational database allowing you to store JSON documents in tables and perform complex joins! More importantly, It makes the problem of permissions, simple. We can break this down into two basic constructs - User and Role

Anatomy Of A Role

A role, in most applications is construct use for authorization; a way to categorize users and the actions they are able to perform within the application or system. In this example, a role is a rethink document that has two properties - name and permissions.

Role ( rōl ) -n, --noun;

The function(s) assumed or part played by a person or thing in a particular situation

The name is the human readable part of the role, it is also the primary key as it is how we want to look them up, and they have to be unique. The other, permissions is a sub document or object that stores permissions as a 3 level hierarchy. The main parts of the hierarchy:

  • Component: This is the top level, generic bucket. This might be forum, blog or auth
  • Entity: This describes the thing in particular we which to take action against. An easy way to think of this is as a database table or model. This might be post, user, article. But it certainly doesn't have to be that way. An entity can be anything you want.
  • Action: This is the literal action being taken - edit, delete, create, reset, etc. They can all be anything you want them to be.
{
  "name":"admin",
  "permissions":{
     "blog":{
        "post":{
          "create":true
        }
     }
  }
}

With the example above, an admin can create a blog post. However, an admin can not delete a blog post. The absence of an action also implies a can not behavior.

Anatomy Of A User

A user in this use case is rather simple, We only need to store an array of role names.

{
   "username":"bob",
   "roles":["admin"]
}

User (ˈyo͞ozər ), -n., --noun

a person who uses or operates something, especially a computer or other machine

Because the name of the role is the primary key We can just store the human readable name on the user record, making it easy to display and reason about. More importantly, because the permissions are stored on the role document, this becomes a single join & merge to get all of the permissions for a given user.

r  
  .db('mydatabase')
  .table("user")
  .merge(function( user ){
    return r.object(
      'permissions',  // <- create a permissions object on the user document 
      user("roles").eqJoin(function( id ){ //  <- join the roles tables
         return id
      }, r.db('mydatabase').table( 'auth_role')).zip()('permissions') 
      .reduce(function( left, right ){
        return left.merge( right )  // <- merge all of the permissions together
      })
    )      
});

The query will return user records with a single permissions object describing all of that users permissions

{
   "username":"bob",
   "roles":["admin", "blogger"],
   "permissions":{
     "auth":{
        "user":{
          "create":true,
          "reset_password":true
        }
      },
      "blog":{
        "post":{
           "create":true,
           "edit":true
        }
      }
   }
}

We could take our original ReQL query to exclude users from the results if they don't have a certain set of permissions adding a simple field check filter

  .filter( function( user ){  
    return user.hasFields({
      permissions:{
         blog:{
            post: true
         }
      }
    })
  })

This filter would only return users that have permissions to operate on posts under our blog component. Not bad! A single query with one join to get all of a users permissions. All that is left is to write a small function that can retrieve values from nested objects - which might look something like this:

Javascript

function has(obj, prop, sep){  
    var parts = prop.split(sep || ':' ),
        last = parts.pop();

    while (prop = parts.shift()) {
        obj = obj[prop];
        if (obj == null){
           return false;
        }
    }
    return !!obj[last];
}

has( user, 'auth:user:create' ) // true  
has( user, 'auth:user:delete' ) // false  
has( user, 'blog:post:delete' ) // false  
has( user, 'auth' ) // true  
has( user, 'fake' ) // false  

This is just as simple to do in just about any language you can think of.

Python

def has( obj, prop='', sep=':' ):  
    parts = prop.split(sep)
    last = parts.pop()

    while ( len(parts) ):
        try:
           prop = parts.pop(0)
        except IndexError:
           break

        obj = obj.get( prop )

        if ( obj is None ):
           return false

    return bool( obj.get( last ) )
}

Simple, effective and environment agnostic. You put a simple API in front of a dedicated Rethink cluster and use this as a simple auth / auth service. This will work in all recent version of node, and once you deliver the document to a client, checking authorization is dead simple. More over, this scales very well even for users with many roles and hundreds of permissions. Both the client side and server side applications alike can use the same permission based logic.

  • 2 Tables
  • 1 query
  • 1 ubiquitous function

Ship it!

javascript rethinkdb acl node