Relationship-Based Access Control in Ruby - Part 1
Recently I’ve been on an interesting journey in the relationship-based access control (ReBAC) space. I first came across the concept through AuthO’s open-source OpenFGA product, which is an open-source Go implementation of Google’s Zanzibar paper. The Zanzibar paper was published in 2019 and documented how Google developed and scaled its centralized authorization service. Zanzibar is used to authorize millions of user actions for Youtube, Google Drive, and numerous other Google products. Since the Zanzibar paper was published, a number of products inspired by the paper have emerged, such as OpenFGA, Permify, Oso, and Aserto.
I was excited to learn about ReBAC, because I’ve worked on several applications over the years which have proven a poor fit for role-based authentication (RBAC). Those applications have often had business logic along the lines of “this user can perform this action on this object only if they are an admin in the team that owns the object’s parent”. If you’ve ever tried to apply RBAC to something like that, you’ll know it gets really awkward. However, as far as I knew, the only alternative to RBAC was attribute-based access control (ABAC) or an access control list (ACL). ABAC quickly became very complex once more than a few attributes were required, and maintaining an ACL for every object in the system was not an acceptable option. ReBAC sits nicely between RBAC and ABAC, allowing for something of the simplicity and power of RBAC whilst permitting much of the flexibility of ABAC.
After playing around with OpenFGA for a little while, I was convinced that it and its competitors are fulfilling an underserved need for apps that have complex authorization models based on relationships between entities. However, I noted that they all required running a separate application (or leveraging a SaaS offering) that received authorization calls from one or more business applications. That makes complete sense in a microservices environment, but those working with a monolithic architecture might find managing an extra service less appealing. I’ve recently been learning Ruby, a language with a community that has often favoured a monolithic approach to software architecture, so I thought it would be an interesting experiment to build a Ruby library that provided a Zanzibar-style authorization system. In the end I decided that the library probably wasn’t very valuable, but it was an interesting journey nonetheless, so I decided to document it in a blog post. The rest of the article assumes familiarity with the Zanzibar concepts and language, so you might want to read it before you proceed any further, if you haven’t already.
Step 1 - An in-memory object graph
I started off by storing the relationship data in a simple Ruby array. Each relationship record has a subject, an object and a predicate describing the relationship between them. Below is a simple implementation with a test. The test provides a set of relations to an authorization checker and checks to confirm that a subject has access to a document based on its relationship with the document parent.
class CheckerTest < Minitest::Test
def setup
# Do nothing
end
def teardown
# Do nothing
end
def test_it_checks_if_a_user_has_a_relation_to_an_object_via_another_object
tuples = [
Rubac::Tuple.new(
Rubac::User.new("user:bob"),
"editor",
"folder:documents"
),
Rubac::Tuple.new(
Rubac::User.new("folder:documents"),
"parent",
"document:foo"
)
]
valid_tuple_keys = [
Rubac::TupleKey.new("user:bob", "editor", "document:foo")
]
checker = Rubac::Checker.new(tuples)
assert_all_tuple_keys_are_valid(checker, valid_tuple_keys)
end
def assert_all_tuple_keys_are_valid(checker, valid_tuple_keys)
valid_tuple_keys.each { |key| assert(checker.check?(key), "Failed on #{key}") }
end
end
class Checker
def initialize(tuples)
@tuples = tuples
end
# @param [Rubac::TupleKey] tuple_key
# @return [bool]
def check?(tuple_key)
# Get initial set of tuples that match on the object and the relation
matches = direct_object_matches tuple_key
# Get additional tuples that match the object via relationships
matches.concat related_object_matches(tuple_key.object)
# Select tuples that match the user
matches.any? { |tuple| (tuple.user.is_wildcard? || tuple.user.is?(tuple_key.user)) && (tuple.relation == tuple_key.relation) }
end
private
def direct_object_matches(tuple_key)
@tuples.filter do |tuple|
key_user = Rubac::User.new(tuple_key.user)
tuple.object == tuple_key.object && tuple.relation == tuple_key.relation && (
!key_user.is_userset? || key_user.relation == tuple.relation
)
end
end
def related_object_matches(object, collected_tuples = [])
# Find tuples that match the latest key object
new_matches = @tuples.filter do |tuple|
tuple.object == object
end
# For each new match, investigate the tuples to see whether there are potential further matches
new_matches.each do |tuple|
related_object_matches(
tuple.user.qualified_id,
collected_tuples
)
end
collected_tuples.concat new_matches
end
end
endThe checker walks the object graph, starting from the tuple key object (Zanzibar-style relations are a tuple containing an object, a subject and a predicate). Once it has compiled a list of all the possible matches, it checks to see if any share a subject with the tuple key.
Step 2 - Developing a schema defining the possible relations
Having got the basic graph traversal working (though some refactoring would certainly be beneficial), I moved on to developing a schema structure that could be used to guide the graph traversal and perform the all-important union, intersection and exclusion operations on sets of possible matching subjects (called usersets in the Zanzibar paper). This took more code than I care to embed in a blog post but you can check out the implementation on Github (opens in a new tab).
Step 3 - Persisting relationship data in a database
Next, it was time to move beyond in-memory storage towards storing the data in a database. The most popular way of interacting with a database in Ruby is the Active Record object-relational mapper (ORM) that comes bundled with the Rails framework, so I set out to write an Active Record model to represent the relationship entity. Here’s what my first attempt at a migration to create the database table for the model looked like:
class CreateRelations < ActiveRecord::Migration[7.0]
def change
create_table :relations do |t|
t.string :user_type
t.string :user_id
t.string :user_relation
t.string :object_relation
t.string :object_type
t.string :object_id
t.timestamps
end
add_index :relations, %i[object_type object_id]
end
endAt this point I started getting unpleasant flashbacks to my days of working with entity-attribute-value based content management systems. I realized that by encoding all of the relationships in the system using a separate database table, I was essentially moving all of the responsibility for maintaining those relationships into the application layer. While the schema helped abstract away the authorization decisions, the app would still have to do a lot of work to maintain the relationship state. Previously that work would have largely been taken care of by the ORM with the help of foreign key constraints on the database. Active Record already provides numerous flexible and powerful ways of defining relationships between entities. What if we leveraged these to build a ReBAC system? In part two of this blog post, I’m going to show you how to do exactly that.
© Ryan Brown.RSS