Ubiquitous Fine Grained Access Control With Node.js and RethinkDB
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 theRoles
of theUser
- Get All One off
Permissions
for theUser
- 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
orauth
- 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
ormodel
. This might bepost
,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!