Modeling Hierarchical Roles and Permission Inheritance

You have a senior_engineer role that should automatically hold every permission a engineer holds, plus a few more — but copying permission lists by hand has already drifted out of sync, and one bad edit just gave an intern admin rights through a cycle. This page, part of the Choosing Between RBAC and ABAC guide, shows how to model role hierarchies so inheritance is computed, not copied, and so escalation through transitive or circular edges becomes impossible.

Root cause: inheritance is a graph problem

A role hierarchy is a directed acyclic graph (DAG). Each role is a node; an edge senior_engineer → engineer means “senior_engineer inherits engineer’s permissions.” The effective permission set of a role is the union of its own grants and the grants of every role reachable by following those edges — the transitive closure of the node.

Two failure modes follow directly from getting the graph wrong:

  • Cycles. If a → b → c → a, the closure never terminates and every role in the loop collapses into a single super-role. A naive recursive resolver either infinite-loops or, worse, silently grants the union of the entire cycle. This is the classic transitive privilege-escalation bug.
  • Unbounded depth. Even acyclic hierarchies that nest too deep make every decision a multi-join graph walk, blowing your authorization latency budget and making the effective permissions of any role hard for a human to reason about.

The diagram below shows a correct inheritance DAG. Arrows point from a senior role to the junior role whose permissions it absorbs; the closure of org_admin is every node reachable downward.

Role inheritance DAG org_admin inherits from team_lead and auditor; team_lead inherits from engineer; engineer and auditor both inherit from viewer. Arrows point from senior to junior roles. No cycles exist. org_admin team_lead auditor engineer viewer

Representing the hierarchy in SQL

Store edges, not flattened lists. A self-referential closure via a parent_role_id column or a dedicated role_inheritance edge table keeps the source of truth normalized. An edge table is more flexible because it permits multiple inheritance (org_admin inheriting from both team_lead and auditor):

CREATE TABLE roles (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL,
  name varchar(64) NOT NULL,
  UNIQUE (tenant_id, name)
);

-- Each row: child_role inherits parent_role's permissions.
CREATE TABLE role_inheritance (
  child_role_id  uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  parent_role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  PRIMARY KEY (child_role_id, parent_role_id),
  CHECK (child_role_id <> parent_role_id) -- blocks the trivial self-loop
);

The CHECK blocks one-hop self-loops, but it cannot catch a three-node cycle. Compute the transitive closure — and refuse to insert an edge that would close a cycle — with a recursive common table expression.

-- Resolve every permission a role holds, directly or via inheritance.
WITH RECURSIVE inherited(role_id) AS (
  SELECT $1::uuid                       -- the starting role
  UNION
  SELECT ri.parent_role_id
  FROM role_inheritance ri
  JOIN inherited i ON ri.child_role_id = i.role_id
)
SELECT DISTINCT p.resource, p.action
FROM inherited
JOIN role_permissions rp ON rp.role_id = inherited.role_id
JOIN permissions p       ON p.id = rp.permission_id;

UNION (not UNION ALL) is doing security-critical work here: it deduplicates rows, so even if a cycle slipped into the data the recursion terminates instead of looping forever. Treat that as a safety net, not the primary defense — reject cycles at write time.

Preventing cycles at write time

Before inserting an edge child → parent, verify that child is not already reachable from parent. If it is, the new edge would close a loop. Run this as a guard inside the same transaction as the insert:

// cycleGuard.ts — refuse an inheritance edge that would create a cycle.
import type { PoolClient } from "pg";

export async function addInheritanceEdge(
  db: PoolClient,
  childRoleId: string,
  parentRoleId: string,
): Promise<void> {
  if (childRoleId === parentRoleId) {
    throw new Error("A role cannot inherit from itself");
  }

  // Would adding child -> parent create a cycle? It does iff `child` is
  // already an ancestor of `parent` (i.e. reachable upward from parent).
  const { rows } = await db.query(
    `WITH RECURSIVE ancestors(role_id) AS (
       SELECT $1::uuid
       UNION
       SELECT ri.parent_role_id
       FROM role_inheritance ri
       JOIN ancestors a ON ri.child_role_id = a.role_id
     )
     SELECT 1 FROM ancestors WHERE role_id = $2 LIMIT 1`,
    [parentRoleId, childRoleId],
  );

  if (rows.length > 0) {
    throw new Error("Edge rejected: would create an inheritance cycle");
  }

  await db.query(
    `INSERT INTO role_inheritance (child_role_id, parent_role_id) VALUES ($1, $2)`,
    [childRoleId, parentRoleId],
  );
}

Running the check and the insert in one transaction (SERIALIZABLE isolation, or a row lock on the affected roles) closes the race where two concurrent edge insertions each individually look safe but together form a cycle.

Resolving inheritance at evaluation time

You have two strategies, and the right one depends on your read/write ratio.

Resolve on read. Run the recursive CTE per authorization check. Always correct, no staleness, but it costs a graph walk every request — acceptable only with shallow hierarchies and good indexes.

Materialize the closure. Precompute each role’s effective permission set into a flat table whenever the graph or grants change, and read that flat table on the hot path. This trades write-time work and a cache-invalidation obligation for O(1) reads.

// resolveEffective.ts — in-memory transitive closure with cycle detection,
// suitable for building a materialized closure or resolving at login.
type RoleId = string;

export function effectivePermissions(
  roleId: RoleId,
  edges: Map<RoleId, RoleId[]>,          // child -> parents
  grants: Map<RoleId, Set<string>>,      // role -> direct permissions
): Set<string> {
  const result = new Set<string>();
  const visited = new Set<RoleId>();

  const walk = (id: RoleId) => {
    if (visited.has(id)) return;          // guards against cycles AND diamonds
    visited.add(id);
    for (const perm of grants.get(id) ?? []) result.add(perm);
    for (const parent of edges.get(id) ?? []) walk(parent);
  };

  walk(roleId);
  return result;
}

The visited set is the load-bearing line: it makes the walk terminate on any cycle that escaped the write-time guard and deduplicates diamond inheritance (where two paths reach the same ancestor) so a permission is never counted twice. Whichever strategy you pick, this resolution belongs behind a single enforcement boundary; for distributed systems, pair it with a policy enforcement point in microservices so every service consumes the same resolved closure.

The trade-off versus flat roles

Hierarchy is not free. A flat model — where every role lists its full permission set explicitly, with no inheritance — is dumber but has real advantages: a role’s effective permissions are visible at a glance, audits need no graph traversal, and there is no cycle class of bug because there are no edges. Its cost is duplication and drift: change a permission that ten roles “should” share and you edit ten rows, hoping you found them all.

Choose hierarchy when roles share large, genuinely overlapping permission sets and the org chart maps cleanly onto seniority tiers. Choose flat roles when the catalog is small, the overlaps are coincidental rather than structural, or auditability outranks DRY. Many teams land on a deliberate middle ground — at most one or two levels of inheritance — which captures most of the reuse while keeping closures shallow enough to resolve on read. That bounded-depth choice is exactly the kind of structural decision weighed in the broader RBAC versus ABAC comparison, since deep hierarchies are often a signal you actually wanted attribute-based context instead.

Security implications

Role inheritance is a privilege-escalation vector by construction: every edge widens the closure of its child. The specific risks to monitor:

  • Accidental escalation through a new edge. Adding team_lead → org_admin (inverted by mistake) silently grants every team lead full admin. Require review on inheritance changes and log the before/after closure diff.
  • Cycle-induced collapse. A cycle merges all member roles into one. Reject cycles at write time as shown, and keep the UNION-based CTE as a runtime backstop.
  • Stale materialized closures. If you precompute closures, a revocation that does not invalidate the cache leaves elevated permissions live. Bump a closure_version on every graph or grant change and bust caches on mismatch.

Prevention & monitoring hooks

  • CI assertion on graph shape. In your pipeline, load the role graph and assert it is acyclic and within a maximum depth (for example, ≤ 3). Fail the build otherwise.
  • Closure-diff audit log. On any inheritance or grant change, write an append-only record of the affected role’s effective-permission set before and after, with the actor and timestamp. This is your SOC 2 / ISO 27001 evidence and your incident forensics.
  • Alert on closure growth. Emit a metric for the size of each role’s effective permission set and alert when it jumps unexpectedly — a sudden spike usually means an inverted or cyclic edge.
  • Periodic reconciliation. Diff materialized closures against a freshly recomputed transitive closure on a schedule; any mismatch is a stale-cache bug and should force invalidation.

Frequently Asked Questions

Should inheritance edges point from senior to junior, or junior to senior?

Point from the senior (more-privileged) role to the junior role it absorbs — senior_engineer → engineer. The senior role’s effective permissions are then the transitive closure following edges outward. Keeping the direction consistent matters because the cycle check and the closure query both depend on it; mixing directions in one table is a common source of “why does this junior role have admin rights” bugs.

Can a role inherit from more than one parent?

Yes — that is multiple inheritance, and an edge table (rather than a single parent_role_id column) supports it directly. It is safe as long as the overall graph stays acyclic. Be aware of diamond inheritance, where two paths reach a shared ancestor; the visited set in the resolver deduplicates it so the shared permissions are not double-counted, and UNION does the same in SQL.

Resolve on read with a recursive CTE, or materialize the closure?

Resolve on read when hierarchies are shallow and writes are rare relative to reads — it is always correct and has no invalidation burden. Materialize the closure when authorization is on a hot path with a tight latency budget; you trade O(1) reads for write-time recomputation and a cache-invalidation obligation. Many systems materialize at login (resolving once per session) as a middle ground.

How deep should a role hierarchy go?

Prefer one or two levels. Each level adds a join on read and makes a role’s effective permissions harder for a human to reason about. If you find yourself needing four or five levels to express access, that is usually a signal the decision actually depends on context (ownership, tenant, time) and belongs in attribute-based policies rather than ever-deeper role nesting.

What stops a database write from creating a cycle?

A CHECK constraint blocks one-hop self-loops, but multi-node cycles must be caught in application logic: before inserting child → parent, run a recursive query to confirm child is not already an ancestor of parent, and do it in the same SERIALIZABLE transaction as the insert so concurrent writes cannot collude to form a loop. Keep the UNION-based CTE as a runtime backstop so resolution terminates even if a cycle ever slips through.