Relationship-Based Access Control in Ruby - Part 2
In my previous blog post I explored relationship-based access control (ReBAC) and considered what a Ruby implementation might look like. After an initial attempt based around a Google Zanzibar-style database schema, I decided to consider whether Active Record relations could be used to make relationship-based access decisions. Using Active Record would enable working with a conventional relational database schema, avoiding the need to maintain the relationships between objects in our system within our application code.
To demonstrate the technique, we're going to build an authorization model for a Google Drive/Dropbox type of filesystem application. The authorization model of the application is as follows:
- The owner of a document can perform all actions on a document
- The owner of a folder can perform all actions on documents in the folder
- A user can be assigned as an editor, a commenter, or a viewer on a document
- A user can be assigned as an editor, a commenter, or a viewer on a folder
- Documents inherit permissions from their parent folder.
- Folders inherit permissions from their parent folder, if they have one
- Users belong to an organization
- Permission to edit, comment on or view a document may be granted to all users in an organization
Allowing the owner to perform all actions on the document
First, let's define the basic relationships between the objects in our system.
class User < ActiveRecord::Base
belongs_to :organization
end
class Organization < ActiveRecord::Base
has_many :users
has_many :folders
end
class Folder < ActiveRecord::Base
belongs_to :organization
belongs_to :owner, class_name: "User", foreign_key: "user_id"
belongs_to :parent, class_name: "Folder", optional: true
has_many :documents
end
class Document < ActiveRecord::Base
belongs_to :folder
belongs_to :owner, class_name: "User", foreign_key: "user_id"
endTo check if a user has permission to perform an action on a document, we want to calculate the set of users that can perform the action and check if the user is contained within that set. Therefore, we define a userset method for the editor role on the document object:
def editor_userset
User.where(id: self[:user_id])
endCurrently that userset only includes the document owner (the user whose id matches the document's user_id value). Note that we don't resolve the query yet. This is because we potentially want to reference our userset in other userset queries. In fact, let's do that now when we define our commenter and viewer usersets:
def commenter_userset
editor_userset
end
def viewer_userset
viewer_userset
endEffectively, we've ensured that any editors also automatically receive the commenter and viewer roles, and that commenters are also viewers.
At this point we can check whether a user is part of a userset by doing the following:
document.editor_userset.include? userIf you're using the popular pundit gem for Rails, you could put this check in your policy method.
Allowing users to be assigned a role on a specific document
To allow users other than the owner to be assigned a role on a document we need a new many-to-many relationship between documents and users. To achieve this, we'll need at least one new join table in our database. You could create a separate table for each role, but in this case I've decided to keep them all in a single table. Here's the migration:
class CreateDocumentUsers < ActiveRecord::Migration[7.0]
def change
create_table :document_users do |t|
t.string :role
t.belongs_to :document, foreign_key: true
t.belongs_to :user, foreign_key: true
t.timestamps
end
end
endThen we need to add some associations to our Document model. Note the use of a modifier to restrict each document user association to a particular role in the join table.
has_many :viewer_document_users, -> { where role: :viewer }, class_name: "DocumentUser", foreign_key: "document_id"
has_many :viewers, class_name: "User", through: :viewer_document_users, source: :user
has_many :editor_document_users, -> { where role: :editor }, class_name: "DocumentUser", foreign_key: "document_id"
has_many :editors, class_name: "User", through: :editor_document_users, source: :user
has_many :commenter_document_users, -> { where role: :commenter }, class_name: "DocumentUser", foreign_key: "document_id"
has_many :commenters, class_name: "User", through: :commenter_document_users, source: :userNow we need to include the users that have the editor role in our editor userset.
def editor_userset
User.union_all(User.where(id: self[:user_id]), editors)
end"What's this union_all method?", I hear you ask. You're quite right, union_all is not part of the default Action Record API. However, UNION and its fellow SQL set operations, INTERSECT and EXCEPT, are needed to effectively express Zanzibar-style userset computations as SQL queries. I have therefore added the active_record_extended gem to the project to provide these extra methods. Unfortunately at time of writing active_record_extended only supports PostgreSQL, but MySQL and SQLite also support these operations.
The union_all method simply creates a query that returns the the union of its subqueries, so in our case we get a userset that contains the document owner and any users assigned the editor role for the document. We could use plain old union, but union takes it upon itself to deduplicate our results, which is expensive across large usersets. Given we don't care how many times a user is included in our set, we may as well use union_all and save ourselves the effort. Let's update our commenter and viewer usersets.
def commenter_userset
User.union_all(commenters, editor_userset)
end
def viewer_userset
User.union_all(viewers, commenter_userset)
endFinally, assigning a user the editor role for a document is as simple as doing the following:
document.editor_document_users.create(user: user)Inheriting permissions from the parent folder
One of the problems ReBAC solves very well is parent-child based authorization. To enable a document to inherit the permissions of its parent folder, we simply need to union the folder userset with our current document userset.
def editor_userset
User.union_all(User.where(id: self[:user_id]), editors, folder.editor_userset)
end
def commenter_userset
User.union_all(commenters, folder.commenter_userset, editor_userset)
end
def viewer_userset
User.union_all(viewers, folder.viewer_userset, commenter_userset)
endOf course, before we can reference the usersets on the folder, we need to define them. We'll start with creating another join table, this time between our folders table and our users table.
class CreateFolderUsers < ActiveRecord::Migration[7.0]
def change
create_table :folder_users do |t|
t.string :role
t.belongs_to :folder, foreign_key: true
t.belongs_to :user, foreign_key: true
t.timestamps
end
end
endNote that you could also make the document users table polymorphic, but I'd probably recommend against doing that in case you ever decide you want to store different metadata against the two relationships, and because you would be unable to use foreign key constraints.
Now we can define the associations and usersets on our Folder model.
has_many :editor_folder_users, -> { where role: :editor }, class_name: "FolderUser", foreign_key: "folder_id"
has_many :editors, class_name: "User", through: :editor_folder_users, source: :user
has_many :commenter_folder_users, -> { where role: :commenter }, class_name: "FolderUser", foreign_key: "folder_id"
has_many :commenters, class_name: "User", through: :commenter_folder_users, source: :user
has_many :viewer_folder_users, -> { where role: :viewer }, class_name: "FolderUser", foreign_key: "folder_id"
has_many :viewers, class_name: "User", through: :viewer_folder_users, source: :user
def editor_userset
User.union_all(User.where(id: self[:user_id]), editors)
end
def commenter_userset
User.union_all(commenters, editor_userset)
end
def viewer_userset
User.union_all(viewers, commenter_userset)
endYou can assign users to a folder exactly the same way you did for a document.
folder.editor_folder_users.create(user: user)Subfolders
We also want permissions to cascade down a tree of folders. Fortunately, we can use the parent association we defined earlier on the Folder model to achieve this. We can add a reference to the parent userset to our folder usersets. Note that because the parent association is optional, we have to check for existence and we can union a none query if no parent exists.
def editor_userset
User.union_all(User.where(id: self[:user_id]), editors, parent ? parent.editor_userset : User.none)
end
def commenter_userset
User.union_all(commenters, editor_userset, parent ? parent.commenter_userset : User.none)
end
def viewer_userset
User.union_all(viewers, commenter_userset, parent ? parent.viewer_userset : User.none)
endHappily, because we are already referencing the folder usersets within our document userset definitions, we don't need to do any additional work to apply the same behaviour to our document object authorization. You can see how ReBAC and Action Record together provide an extremely elegant and concise way of modelling complex authorization scenarios.
Organization Access
Our final challenge is to handle the assignment of a document or folder to an entire organization. Let's start with creating a join table between documents and organizations, with an additional role column. I'm assuming you've already taken care of the join table between orgnizations and users (no roles required on that one).
class CreateDocumentOrganizations < ActiveRecord::Migration[7.0]
def change
create_table :document_organizations do |t|
t.string :role
t.belongs_to :document, foreign_key: true
t.belongs_to :organization, foreign_key: true
t.timestamps
end
end
endNow we need to define some further relations on our Document model.
has_many :editor_document_organization, -> { where role: :editor }, class_name: "DocumentOrganization", foreign_key: "document_id"
has_many :editor_organizations, class_name: "Organization", through: :editor_document_organization, source: :organization
has_many :editor_organization_users, class_name: "User", through: :editor_organizations, source: :users
has_many :commenter_document_organization, -> { where role: :commenter }, class_name: "DocumentOrganization", foreign_key: "document_id"
has_many :commenter_organizations, class_name: "Organization", through: :commenter_document_organization, source: :organization
has_many :commenter_organization_users, class_name: "User", through: :commenter_organizations, source: :users
has_many :viewer_document_organization, -> { where role: :viewer }, class_name: "DocumentOrganization", foreign_key: "document_id"
has_many :viewer_organizations, class_name: "Organization", through: :viewer_document_organization, source: :organization
has_many :viewer_organization_users, class_name: "User", through: :viewer_organizations, source: :usersThe principle is identical to our direct user role assignment. On this occasion we join the document and orgnization tables, and then add an extra association to the users table. That will enable us to include all of the users from an organization in each userset. Here are the final userset definitions for the document object:
def editor_userset
User.union_all(User.where(id: self[:user_id]), editors, editor_organization_users, folder.editor_userset)
end
def commenter_userset
User.union_all(commenters, commenter_organization_users, folder.commenter_userset, editor_userset)
end
def viewer_userset
User.union_all(viewers, viewer_organization_users, folder.viewer_userset, commenter_userset)
endI'll leave you to work out how to do the same for the Folder model, as I'm sure you're getting the idea by now. With that, we've expressed the entire authorization model with a handful of active record associations and some basic set computations. Before I wrap up, it's worth mentioning that I haven't done any load testing of this solution. Active Record (and ORMs) in general are notorious for producing inefficient SQL queries, and building the numerous different usersets required to express this complex model, wherever it is done, is going to be relatively expensive. However, the UNION ALL operation is usually pretty efficient, so as long as all the tables are indexed correctly then you should be okay at small scales. Certain caching strategies might help if you have commonly requested usersets, and of course you can always look into leveraging one of the external Zanzibar-like services once the strain on your database starts to grow. In any case, I hope this article has gone some way to demonstrating the utility and simplicity of applying ReBAC to suitable use cases within a monolithic Ruby or Rails application.