@dreamtree-org/rbac-authz
v0.4.4-alpha
Published
Dreamtree's reusable Authorization & RBAC-as-a-Service platform — multi-tenant RBAC, policy engine (deny-by-default, explicit-deny-overrides-allow), API keys, BYODB, and a CLI to bootstrap a client project.
Maintainers
Readme
@dreamtree-org/rbac-authz
Multi-tenant authorization that answers one question, consistently and explainably:
"Can this subject perform this action on this resource?"
Deny-by-default · every answer is an explainable decision, never a silent boolean · audit-chained · bring your own database (SQLite / PostgreSQL / MySQL) or none at all.
60-second start
npm install @dreamtree-org/rbac-authz # + sqlite3 / pg / mysql2 to match your dbimport { rbac } from "@dreamtree-org/rbac-authz";
const authz = await rbac({ db: "sqlite:./app.db" }); // rbac() alone → env vars, or in-memory for dev
await authz.define({
roles: {
accountant: ["invoice.read.all", "invoice.create.own"],
admin: { superadmin: true },
},
});
await authz.assign("user_123", "accountant");
await authz.require("user_123", "invoice.read.all"); // ✓ passes
await authz.require("user_123", "invoice.delete.all"); // ✗ throws AuthzDeniedError
// e.decision → { allowed: false, reason: "Denied by default: no policy grants invoice.delete.all", … }That's the whole core. Node ≥ 22.
Pick your integration
| You have | Do this |
|---|---|
| An existing Express route to protect | authz.guard("invoice.read.all") — recipe |
| An existing app, any framework | authz.middleware() populates req.authz — Express/Fastify/Hono/Next |
| No infra yet, want a mountable API too | add authz.router() alongside authz.middleware() — same section |
| A UI to click around in | npx rbac-authz-api init/setup/start — the admin console |
| A legacy authorizer to retire safely | the shadow gate — recipe |
guard() is the quickest one-liner but Express-only at the front door (other frameworks:
/sdk's createRequirePermission); authz.middleware() ships all four wrappers directly.
The four ideas
- Permission — a string:
resource.action.scope. Scopes:own · team · org · all(broader satisfies narrower).invoice.read.own= read your own invoices. - Role — a named list of permissions.
define()is declarative and idempotent: re-running it reconciles each role to exactly what you wrote. - Subject — whoever is acting: an id string, or
{ type: "service_account", id }. - Decision — every check produces a full
AuthzDecision(reason/matchedPolicy/deniedBy) on a tamper-evident audit chain.allowed()is just its boolean projection — nothing is ever a silent boolean.
Recipes
Guard an Express route (Fastify/Hono/Next wrappers live in /sdk):
app.get("/invoices", authz.guard("invoice.read.all"), listInvoices);
// reads req.user.id; custom: authz.guard(perm, { subject: (req) => req.session.uid })
// deny → 403 with the full decision; no subject on the request → 500, fail closedBind once per request; ownership scopes:
const user = authz.for(req.user.id);
await user.require("doc.read.own", { resourceId: "d9", owner: doc.ownerId });
await user.require("doc.read.team", { owner: doc.ownerId, team: teamMemberIds });
if (await user.allowed("report.export.org")) showExportButton();Narrow one user below their role (direct grants can never widen — REQ-SUB-7):
await authz.grant("temp_hire", "invoice.read.own"); // overrides the role's invoice.read.all
await authz.revoke("temp_hire", "invoice.read.own"); // role grant applies againMulti-tenant (invisible until you need it):
const acme = await authz.tenant("acme"); // auto-created, fully isolated
await acme.assign("u1", "accountant"); // grants in "acme" exist nowhere elseGate by subscription plan (narrow-only hook — it can remove grants, never add):
const authz = await rbac({
db: "postgres://…",
permissionFilter: (statements, { tenantId }) =>
statements.filter((s) => planCovers(tenantId, s.permission)),
});Migrate off a legacy authorizer — zero data migration, one-flag rollback:
import { BillingRbacAdapter, createShadowGate, shadowFlagsFromEnv } from "@dreamtree-org/rbac-authz/migration";
const adapter = new BillingRbacAdapter({ db: { rows: (t, w) => knex(t).where(w) } });
const gate = createShadowGate({
flags: shadowFlagsFromEnv(), // RBAC_AUTHZ_SHADOW: run both, enforce legacy, log diffs
legacy: legacyAuthorize, // RBAC_AUTHZ_ENABLED: cut over
engine: (await rbac({ db: adapter })).client,
onDiff: (d) => log.warn("authz shadow diff", d),
});Manage the data (CRUD, by name — no ids, ever)
await authz.roles.list(); // [{ name, permissions, superadmin, weight, active }]
await authz.roles.rename("accountant", "finance");
await authz.roles.deactivate("finance"); // soft — grants nothing until .activate()
await authz.roles.remove("finance"); // hard — cascades assignments + permission links
await authz.permissions.list();
await authz.permissions.remove("invoice.export.all"); // cascades out of roles, modules, direct grants
await authz.subjects.get("user_123"); // "what can this subject do, and why?"
// { roles: ["accountant"], grants: ["report.export.own"],
// permissions: [ …the effective set the engine evaluates ], superadmin: false }
await authz.tenants.list();
await authz.tenants.deactivate("acme"); // the whole tenant denies until reactivatedCreation stays declarative: define() for roles/permissions, assign()/grant() for
access, authz.tenant() for tenants — these namespaces are the read/manage half.
Embed it — no separate process
Skip running anything standalone: mount a subject-populating middleware and a tenant-scoped slice of the REST API straight into your own Express/Fastify app.
import { rbac } from "@dreamtree-org/rbac-authz";
const authz = await rbac({ db: "sqlite:./app.db" });
const app = express();
app.use(authz.middleware()); // req.authz = { check, require, allowed } for req.user.id
app.use(authz.router()); // mounts /authz/check, /api/audit, /api/info
app.listen(3000);Mount authz.router() at the app's root — no path prefix, its routes are already
namespaced. It's deliberately narrow: no /admin, no cross-tenant registry, no
/api/roles//api/permissions (those need a service key scoped to your own tenant, or
the standalone console below). authz.middleware() has .fastify()/.hono()/.next()
wrappers too; authz.router() is Express/Fastify only (.fastify()).
The admin console & HTTP API
Prefer clicking? The same package ships a zero-dependency admin console + JSON API:
npx rbac-authz-api init # scaffolds services/rbac/ + .env.example in your project
# fill in .env: RBAC_DATABASE_URL=./rbac.sqlite RBAC_TENANT_NAME=dev
npx rbac-authz-api setup # migrates (expand-only, safe to rerun), seeds your tenant +
# a superadmin, prints a one-time admin password (never stored)
npx rbac-authz-api start # → http://localhost:4001/adminThe console covers applications, tenants, subjects, roles (+ module bundles),
permissions, API keys (shown once, hash-only), the audit log with chain verification,
and an authorization playground. The same server exposes the REST API — /authz/check,
/api/roles, /api/permissions, /api/applications, /api/tenants, /api/keys,
/api/audit. In production set RBAC_ADMIN_PASSWORD (console login) and
RBAC_REQUIRE_API_KEY=1 (service endpoints need a Bearer dt_rbac_… key).
Guarantees (they hold under every sugar above)
- Deny by default; an explicit deny overrides any allow, even for a superadmin.
- Explainable + audited — every decision carries its reason and lands on a
hash-chained audit log (pass
auditKeyin production). - Tenant-isolated at the storage layer;
allnever means cross-tenant. - Narrow-only extension points —
permissionFilter, direct grants, and API-key scopes can reduce access, never widen it; forged or edited grants reject the check. - No plaintext secrets — API keys are shown once, stored as hash + prefix + last4.
Going deeper
| You want | Where |
|---|---|
| Full SDK: client, checker, hooks, principal cache, middleware family | @dreamtree-org/rbac-authz/sdk |
| The pure engine: evaluate, permission grammar, API keys, audit chain | root import (advanced exports) |
| Your own storage: the RbacDatabaseAdapter port + adapters | @dreamtree-org/rbac-authz/db-adapters |
| Legacy migration kit: mapper, compat adapter, shadow gate | @dreamtree-org/rbac-authz/migration |
| HTTP API + admin console | npx rbac-authz-api init && npx rbac-authz-api setup && npx rbac-authz-api start |
| Teach your AI coding agent this library | npx rbac-authz init --ai claude · MCP: npx @dreamtree-org/rbac-authz mcp |
| Architecture, security model, BYODB, requirements | the source repo docs/ |
Versioning
Pre-1.0.0, the minor version is the effective "major"; anything that can flip an
allow/deny outcome is never a silent patch. @latest always points at the newest
release. Full policy: docs/NPM_PUBLISHING.md in the source repo.
License
MIT © Dreamtree — see LICENSE.
