Relationship-Based Access Control in Ruby - Part 1

You,ruby

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
end

The 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
end

At 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