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.