DEV Community

Cover image for Hierarchical RBAC in Node.js — without deploying OpenFGA
syedsaab1303
syedsaab1303

Posted on

Hierarchical RBAC in Node.js — without deploying OpenFGA

Almost every SaaS app has the same shape:

organization → team → project → resource
Enter fullscreen mode Exit fullscreen mode

And almost every one hits the same authorization wall: if I make someone an admin of
an organization, they should automatically be an admin of every project inside it
— not
just the org row in my database.

Most RBAC libraries don't do this. They model flat roles ("is this user an admin?") and
leave the "walk up the parent chain" part to you. So you end up writing this in every
route:

const task = await db.getTask(id);
const project = await db.getProject(task.projectId);
const team = await db.getTeam(project.teamId);
const org = await db.getOrg(team.orgId);

if (
  hasRole(user, org, "owner") ||
  hasRole(user, team, "owner") ||
  hasRole(user, project, "editor") ||
  hasRole(user, task, "editor")
) { /* allow */ }
Enter fullscreen mode Exit fullscreen mode

Repeated everywhere. Forget one level and your org owner gets a 403 on their own data.

The two existing options

  1. Flat RBAC libraries (CASL, accesscontrol, Casbin) — great at roles, weak at resource-instance hierarchy. Parent → child cascading isn't their focus.
  2. Zanzibar-style FGA engines (OpenFGA, SpiceDB, Permify) — built exactly for this, at Google scale. But they're a separate service: a relationship graph, a policy DSL, and real operational overhead. Overkill for most apps.

There's a gap in the middle: I just want the inheritance idea from Zanzibar, in
in-process code, with zero infrastructure.

nested-rbac

So I built nested-rbac — a tiny,
dependency-free, TypeScript-first library that does one thing well: a role granted on a
parent resource is automatically inherited by every descendant.

npm install nested-rbac
Enter fullscreen mode Exit fullscreen mode
import { RBAC } from "nested-rbac";

const rbac = new RBAC({
  hierarchy: ["organization", "team", "project", "task"],
  roles: {
    owner: ["*"],
    editor: ["task:read", "task:write", "task:delete"],
    viewer: ["task:read"],
  },
});

// Priya is owner of the org — assigned ONCE.
const assignments = [
  { role: "owner", resource: { type: "organization", id: "acme" } },
];

// Can she delete a task buried deep in the tree?
rbac.can(
  assignments,
  "task:delete",
  { type: "task", id: "homepage" },
  [ // ancestors: parent -> root
    { type: "project", id: "web" },
    { type: "team", id: "eng" },
    { type: "organization", id: "acme" },
  ],
); // => true  ✅  (inherited from the org, no per-task assignment)
Enter fullscreen mode Exit fullscreen mode

How it works (the whole algorithm)

  1. Build the set of applicable nodes = the target + all its ancestors.
  2. For every role/permission assigned on any of those nodes, collect the grants (and denies — a "!" prefix means deny).
  3. Deny wins; otherwise a grant (with * / domain:* wildcard support) means allow.

That's it. Inheritance "just works" because ancestors are part of the applicable set.

The library deliberately doesn't resolve the ancestor chain for you — it can't know
your database. You pass a getAncestors(resource) function, which keeps the library
database-agnostic.

Express in one line

import { expressRBAC } from "nested-rbac";

const authorize = expressRBAC(rbac, {
  getAssignments: (req) => req.user.assignments,
  getAncestors: (r) => db.getAncestorChain(r),
});

app.delete(
  "/tasks/:id",
  authorize({ permission: "task:delete", resource: (req) => ({ type: "task", id: req.params.id }) }),
  deleteTaskHandler,
);
Enter fullscreen mode Exit fullscreen mode

No nested ifs. The middleware walks the chain and returns 403 when it should.

Also included

  • Wildcards: "*" (everything) and "billing:*" (a whole domain).
  • Deny rules: "!project:delete" overrides any grant — deny always wins.
  • Ad-hoc grants: attach permissions: [...] to an assignment without defining a role.
  • listPermissions(): get the resolved { granted, denied } set to drive your UI.
  • Dual ESM + CJS, full TypeScript types, and a 121-test suite.

When not to use it

If you need cross-tree relationships ("user is in group X which owns project Y"), or
you're operating at billions-of-objects scale, reach for OpenFGA or SpiceDB. nested-rbac
is for the 90% of apps that just need clean, inherited, in-process authorization.


Repo: https://github.com/syedsaab1303/nested-rbac
npm: https://www.npmjs.com/package/nested-rbac

If it's useful, a ⭐ helps others find it. Feedback and PRs welcome!

Top comments (1)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Leaving getAncestors to the caller is the right call, that's the one bit a library genuinely can't guess without owning your schema. Worth flagging for anyone reaching for this though. That function runs on every authorization check, so a naive version doing one query per level up the tree turns a single task delete into four round trips before the middleware even decides. A recursive CTE that returns the whole ancestor chain in one query, or a cached materialized path on each row, keeps the "deny wins" logic fast under real traffic. The inheritance model itself is clean, this is just where the cost sneaks in.