Hierarchical RBAC in Node.js — without deploying OpenFGA

작성자

카테고리:

← 피드로
DEV Community · syedsaab1303 · 2026-07-04 개발(SW)
Cover image for Hierarchical RBAC in Node.js — without deploying OpenFGA

syedsaab1303

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!

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다