Skip to content
A Bekkour
Writing

Roles that contain roles: an RBAC resolved by a recursive Postgres query

5 min read

Permissions are easy until roles can contain other roles. The moment “Manager” includes “Estimator” which includes “Reader,” your neat has_many :permissions is a lie — a user’s real abilities are the transitive closure of a graph, and computing that in Ruby means loading the whole role table and walking it in memory on every request. On the SaaS I built the backend for, I pushed that walk down to the database, where it belongs. This is that system: a recursive CTE for the graph, aggressive per-tenant caching because the graph rarely changes, and method_missing to make the check sites read like plain Ruby.

Roles are a graph; resolve it in SQL

A role links to its children through a join table, so the roles form a directed graph. To get everything a role grants, you have to collect the role and every descendant. That’s a textbook recursive query, and Postgres does it in one statement:

def tree_sql
  <<-SQL.squish
    WITH RECURSIVE search_tree(id, path) AS (
      SELECT id, ARRAY[]::INTEGER[] FROM roles WHERE id = #{id}
      UNION ALL
      SELECT role_children.child_id, path || role_children.child_id
      FROM search_tree
      JOIN role_children ON role_children.parent_id = search_tree.id
      WHERE NOT role_children.child_id = ANY(path)
    )
    SELECT id FROM search_tree ORDER BY path
  SQL
end

Two details carry this. The path array accumulates the ids visited on the way down, and the WHERE NOT ... = ANY(path) clause is cycle detection — without it, a role graph with a loop (Manager → Estimator → Manager, however it got created) sends the query into an infinite recursion that takes the database with it. Application code can’t reliably prevent someone wiring a cycle, so the query has to survive one. The ORDER BY path gives a stable, deterministic result, which makes the cached value stable too.

From the flattened id list, the available actions are just the roles’ own action strings, deduped:

def available_actions
  tree.select { |role| role.resource_id.nil? }.map(&:actions).compact.flatten.uniq
end

Actions are plain strings like invoices:show / invoices:own / invoices:delete — a resource:verb convention. Flattening the whole tree into one Set of those strings is the entire permission model; everything else is how you make it fast and how you check it.

Cache it, because a role tree almost never changes

Resolving the tree is a database round-trip, and it’s identical between two saves of the role graph — which is to say, for hours or days at a time. So it’s cached, scoped by tenant so two customers can’t ever see each other’s roles even in the cache:

def tree
  key = "tenant/#{Apartment::Tenant.current}/role/#{id}/tree"
  Rails.cache.fetch(key, expires_in: 1.hour) do
    self.class.where(id: tree_ids).to_a
  end
end

Caching a graph is the easy half. The half people get wrong is invalidation: when you edit a role, you haven’t only changed that role’s tree — you’ve changed the tree of every role that inherits it. So invalidation has to walk outward to parents and children, not just clear one key:

after_save    :clear_role_tree_cache
after_destroy :clear_role_tree_cache

def clear_role_tree_cache
  tenant_key = ->(rid) { "tenant/#{Apartment::Tenant.current}/role/#{rid}/tree" }
  Rails.cache.delete(tenant_key.call(id))
  (parents.pluck(:id) + children.pluck(:id)).each do |rid|
    Rails.cache.delete(tenant_key.call(rid))
  end
end

The edges (the join records) invalidate on their own writes too, so re-parenting a role busts the caches on both sides of the moved edge. This is the unglamorous work that makes caching safe: a cached permission that’s stale by even a minute is a security bug, not a performance nit.

Checking a permission should read like Ruby

All that graph work would be wasted if every controller had to do current_user.authorized_actions.include?("invoices:show"). It’s noisy and it’s easy to typo a string. Instead the Pundit policy turns a normal-looking predicate into a lookup via method_missing:

def method_missing(name, *args)
  if name.to_s.last == '?'
    name = name.to_s.gsub("?", "").gsub("show", "(show|own)")
    name = Regexp.new inferred_action(name)
    user_actions.index { |action| name.match action } != nil
  else
    super
  end
end

So policy(invoice).show? infers invoices:show, and — the nice touch — treats show? as “can see all or can see own,” because show expands to the regex (show|own). That “own” case is where authorization stops being a yes/no and becomes a query. If a user can only see their own records, the policy scope has to build a WHERE from whichever ownership columns the model actually has:

OWN_FIELDS = ["created_by_id", "manager_id", "employee_id", "estimator_id", "assignee_id"]

def resolve
  table = inferred_record_name
  if user.authorized_actions.include?("#{table}:show")
    scope                                   # full access
  elsif user.authorized_actions.include?("#{table}:own")
    attributes = OWN_FIELDS.select { |a| scope.attribute_method?(a) }.map { |a| "#{table}.#{a}" }
    args = attributes.map { user.contact.id }
    scope.where(attributes.join(" = ? OR ") + " = ?", *args)
  else
    scope.none                              # no access
  end
end

It inspects the model (attribute_method?) to find which ownership columns exist and ORs them together, so the same scope logic works across every resource without a per-model rule. A model with a project_id even reaches through to the project’s owners. It’s metaprogramming, which I’m usually wary of, but here it earns its place: one scope definition covers dozens of resources, and the alternative is dozens of near-identical hand-written scopes that drift apart over time.

Why this shape

Pieces of this could be simpler in isolation — you could denormalize permissions onto users, or flatten roles on write instead of read. What made the recursive-CTE version the right call was that the role graph is edited by admins, read on every request: reads dominate writes by orders of magnitude, so paying for the graph walk once and caching it (with correct outward invalidation) beats maintaining a denormalized copy that can silently fall out of sync. And keeping the traversal in SQL means the database — which already has the data and a WITH RECURSIVE built for exactly this — does the walk, instead of Ruby loading the table to do it worse.

Roles-that-contain-roles is one of those features that looks like a checkbox and turns into a graph-theory problem the first time someone nests them three deep. Reach for the recursive query early; retrofitting it after you’ve shipped the in-Ruby version is the harder path.


I’ve built and scaled multi-tenant Rails systems where authorization, caching correctness, and Postgres-level tricks all had to hold up under real load. If you’re untangling permissions in a Rails app, I’m happy to talk.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing