Asia/Dhaka
Projects

RBAC System — Production-Grade Role-Based Access Control Library

image
October 1, 2025
Open Source Infrastructure Library

Production-Grade RBAC System

A full-stack, TypeScript-native Role-Based Access Control library built as a Turborepo monorepo. Ships as a drop-in NestJS microservice, a framework-agnostic SDK, or a standalone React admin panel — covering every layer from relational schema to CLI scaffolding.

TypeScriptNestJSTurborepoPostgreSQLMongoDBDrizzle ORMReact 18ViteREST / OpenAPIMulti-tenant SaaS

What this system is, and why it matters

Authorization is one of the most under-engineered parts of any B2B SaaS product. Most teams bolt it on as an afterthought — a handful of boolean flags in a users table, or a single isAdmin check scattered across business logic. That approach collapses the moment a product needs multi-tenancy, dynamic roles, compliance audit trails, or anything beyond two access levels.

This library solves the problem correctly, at the infrastructure layer. It gives engineering teams a normalized relational schema, a DAG-traversal permission engine, a NestJS guard system with metadata decorators, and an embeddable React admin panel — all from one monorepo, independently publishable as focused npm packages. Teams install only what they need: just the core engine, just the NestJS API, or the entire stack.

The design philosophy throughout is explicit over implicit. Every permission check produces an immutable audit log entry. Every role mutation executes inside a database transaction. Every inherited permission traces back through a visited-set cycle-guarded DAG traversal, never silently passing access it shouldn't. This is the level of rigor that production authorization requires.

Scope: This portfolio entry documents the full technical architecture — the core data model, the permission engine, how permissions propagate to real HTTP resources, and the engineering trade-offs made along the way. It reflects end-to-end ownership from database schema design through NestJS integration to the React admin UI.


Seven focused packages. One coherent system.

The repository is structured as a Turborepo monorepo with each package published independently to npm. This lets a consumer adopt incrementally — a team that already has their own auth layer can install only @rbac/core and @rbac/adapter, while a team building greenfield can take the entire stack. The build pipeline uses Turborepo's task graph to run type checks, tests, and builds in the correct dependency order with remote caching.

Package dependency graph
RBAC monorepo package dependency graphConsumer appNestJS / Express / Next.js@rbac/cliScaffolding wizard@rbac/presetsIndustry role packs@rbac/authOAuth 2.0 / OIDC@rbac/uiReact 18 admin panel@rbac/apiNestJS REST + OpenAPI@rbac/corePermission engine · Zero DB@rbac/adapterMongo · Postgres · CustomRequired dependencyOptional integration
@rbac/core

The permission engine — framework-agnostic, zero database dependency

Exposes the RbacEngine.can() method, DAG traversal logic, wildcard matching, and the IStorageAdapter interface. Pure TypeScript. Can be used in any Node.js runtime without NestJS.

@rbac/adapter

Database abstraction — pluggable storage layer

Ships a PostgresAdapter (Drizzle ORM) and a MongoAdapter (Mongoose). Custom adapters for Redis, DynamoDB, or any other store can be plugged in by implementing the IStorageAdapter interface.

@rbac/api

NestJS REST API — full RBAC operations over HTTP

Exposes all role, permission, and user-assignment operations as resource-oriented REST routes with auto-generated Swagger/OpenAPI documentation. Designed to run as a standalone microservice or mount inside an existing NestJS app.

@rbac/auth

Identity provider — OAuth 2.0, OIDC, MFA, SSO

A drop-in identity layer that handles authentication before RBAC enforces authorization. Integrates with Google, GitHub, and any OIDC-compliant provider. Eliminates the need for Auth0 or Clerk in self-hosted deployments.

@rbac/ui

React 18 admin panel — deployable standalone or embedded

A Vite SPA that lets non-engineers manage roles, users, and permissions through a visual interface. Ships as a single <RbacAdmin apiUrl="..." /> component that embeds inside any React application.

@rbac/presets

Industry role packs — seed with one function call

Pre-built role/permission definitions for SaaS, e-commerce, healthcare, education, HR, and legal domains. Idempotent upserts mean seeds are safe to re-run in CI without duplicating data.

@rbac/cli

CLI scaffolding wizard — zero-config setup

An interactive npx @rbac/cli init wizard that asks for database type, framework, and desired packages, then generates the boilerplate configuration, migrations, and environment setup automatically.


The four tables that model all of access control

The entire permission system reduces to relationships between four primary entities: roles, permissions, user_roles, and role_permissions. Understanding what each table represents — and why it is structured the way it is — is the key to understanding how the system behaves at runtime.

Relational schema — entity relationships
RBAC entity relationship diagramusersidPKemailUNIQUEpassword_hashprovidersJSONBcreated_atrolesidPKtenant_idnullablenamedescriptionpermissionsidPK"action:resource"actionread · write · *resourceinvoice · user · *descriptionuser_rolesuser_idFK → usersrole_idFK → rolestenant_idassigned_byaudit trailexpires_atTTL grantrole_permissionsrole_idFK → rolesactiondenormalizedresourcedenormalizeddescriptionNo FK to permissionsrole_inheritancerole_idFK → rolesextends_idFK → rolesPRIMARY KEY(role_id, extends_id)N1N

The roles table — named permission containers

A role is a named grouping of permissions, optionally scoped to a tenant. The tenant_id column is the architectural linchpin of multi-tenancy: when it is NULL, the role is global and applies across the entire application. When it holds a value, the role exists only within that tenant's context, meaning a billing-admin role created by tenant A is completely isolated from tenant B's roles of the same name.

This design eliminates cross-tenant permission leakage at the data model level — not just at the application level. A query for user roles filtered by tenant_id can never accidentally return a different tenant's roles because the column constraint makes it structurally impossible. Global roles (system admins, support engineers with cross-tenant access) are managed separately by leaving tenant_id null and checking for null in the engine's tenant scope resolution.

The id is an opaque TEXT primary key rather than a sequential integer. This is intentional — it allows roles to be referenced by a meaningful slug ("billing-admin", "saas-owner") or a UUID depending on what the consumer's system already uses, without requiring a migration to change the key type.

Multi-tenancy design note: In a B2B SaaS context, roles like "admin" mean very different things in different accounts. The tenant_id scope ensures each customer can customize their own role definitions without those customizations ever bleeding into another account's authorization context.

The permissions table — the global action catalog

The permissions table is a global catalog of every valid action-resource pair in the system. Its primary key is a composite string formatted as "action:resource" — for example, "read:invoice", "delete:user", or "*:*". This auto-generated key is both the identifier and the permission itself, which means a permission check needs no additional join to know what the permission does.

The action column follows a CRUD-plus-custom convention. Standard actions are read, write, delete, and create. Domain-specific actions are expressed as verbs: approve, publish, void, archive. The resource column is a noun representing the system entity: invoice, user, document, workspace.

The wildcard character * in either column creates a broad grant. A permission with action: "*" grants all actions on its resource. A permission with resource: "*" grants its action across all resources. A permission with both set to "*" is a superadmin grant — it matches every check in the system. The engine's wildcard matching evaluates these at check time, not at assignment time, which means a new resource added to the system is automatically covered by any existing wildcard grants without requiring an update.

It's worth noting that the permissions table is a reference catalog, not the source of truth for what a role can do at query time. The actual permission data used during checks lives denormalized in role_permissions — a deliberate trade-off discussed below.

The role_permissions table — denormalized for read speed

This is the most consequential design decision in the entire schema. Instead of normalizing permissions by creating a three-way join between roles, role_permissions, and permissions — which is the textbook relational approach — the system denormalizes the action and resource columns directly into role_permissions.

In a fully normalized design, checking whether a user has permission to perform an action would require traversing: user_roles → roles → role_permissions → permissions. That is a four-table join on every HTTP request. Under high concurrent load — a SaaS platform handling thousands of API calls per second — this creates a compounding database bottleneck that grows proportionally with the depth of the role hierarchy.

By embedding action and resource text directly in role_permissions, the permission check query reduces to a single index scan: fetch all role_permissions rows where role_id is in the user's role set, then evaluate the wildcard match in application memory. The role_permissions table intentionally has no foreign key to permissions — it is its own self-contained data. When a role is deleted, ON DELETE CASCADE from roles cleans up the corresponding role_permissions rows automatically.

The trade-off is write complexity. If a permission's description changes, that change must be applied both to the permissions catalog and to every role_permissions row that references it. The system handles this through the updateRole transactional mutation, which atomically replaces the role's full permission set in a single database transaction, making stale data impossible.

Trade-off summary: Denormalization sacrifices write simplicity for read speed. In an authorization system, reads happen on every request (millions per day), while writes happen when an admin updates a role (dozens per week). Optimizing the hot path is the correct engineering choice.

The user_roles table — assignments with full audit metadata

The user_roles table maps users to roles with significantly more metadata than a simple join table would carry. Each row records not just which role was assigned, but who assigned it (assigned_by), when it was assigned (assigned_at), and when it expires (expires_at). This is not decoration — it reflects the real requirements of enterprise compliance frameworks.

The assigned_by column creates a chain of custody for every access grant. When an auditor asks "who gave this user admin access on March 3rd?", the system can answer precisely from the row data alone, without replaying event logs or querying a separate audit table. This matters for SOC 2 Type II audits, where auditors require evidence that access grants were performed by authorized personnel.

The expires_at column supports temporal role grants — a pattern used when temporary elevated access is needed. An on-call engineer might be granted a production-admin role for 24 hours, with expires_at set to exactly that deadline. The engine checks expiration during every can() call, automatically treating expired assignments as if they do not exist. A background job or MongoDB TTL index can physically delete expired rows asynchronously without affecting correctness — the runtime check handles them first.

The tenant_id column on user_roles enables a user to hold different roles in different tenant contexts simultaneously. A consultant who works across multiple client accounts can be an admin in one tenant, a viewer in another, and have no access at all in a third — all while using the same user account. The engine resolves roles by joining on both user_id and tenant_id, so the correct context is always used.


How a permission check actually works — step by step

The heart of the system is the RbacEngine.can(userId, action, resource, context?) method. Every authorization decision in the system — whether triggered by a NestJS guard, a service-level check, or a frontend visibility toggle — flows through this single function. Understanding its internals explains every design decision in the schema.

can() execution path
Execution flow of the RbacEngine can() methodStep 1 — Load direct rolesQuery user_roles by userId + tenantIdStep 2 — DAG traversalRecursive DFS with visited-set cycle guardResolves full effective role set incl. inheritedrole_inheritanceparent role graphStep 3 — Fetch permissionsSingle scan of role_permissions by role IDsStep 4 — Wildcard matchaction === '*' || action === requestedresource === '*' || resource === requestedALLOWEmit audit event (success)DENYEmit audit event (failure)

NestJS Route Protection

For NestJS applications, the library provides a clean, declarative approach to protecting HTTP endpoints. This avoids writing manual authorization checks inside controller methods, keeping routes readable and maintainable.

The @RequirePermission Decorator

A custom metadata decorator that binds the required action and resource scope directly to the controller route handler.

export const RequirePermission = (action: string, resource: string) =>
  SetMetadata(PERMISSION_KEY, { action, resource });

The RbacGuard

A NestJS guard that evaluates the metadata on incoming requests and enforces the permission check.

@UseGuards(RbacGuard)
@RequirePermission('read', 'invoice')
getInvoices() { ... }

Guard Execution Flow: The RbacGuard reads the route's permission metadata via the NestJS Reflector, extracts the user object from the HTTP request context (populated by authentication middleware), and queries RbacEngine.can(). If authorization fails, it automatically throws a 403 ForbiddenException.


REST API Routes

The @rbac/api package exposes a complete set of RESTful HTTP endpoints for managing roles, permissions, user assignments, and audit trails. All endpoints are fully documented using auto-generated OpenAPI/Swagger specifications.

Roles Management

Method & PathDescription
POST /rolesCreate a new role with associated permissions and parent role links (inheritance).
GET /rolesList all roles defined in the system.
GET /roles/:idRetrieve the full definition of a specific role by ID.
PATCH /roles/:idUpdate a role atomically (updates permissions and inheritance in a database transaction).
DELETE /roles/:idDelete a role from the system (cascades automatically to assignments and permissions).

User Assignments

Method & PathDescription
POST /users/:userId/rolesAssign a role to a user (supports custom tenant_id scope, assigned_by audit, and expires_at TTL).
DELETE /users/:userId/roles/:roleIdRevoke a role from a user.
GET /users/:userId/rolesList all roles currently assigned to a user, including scoped tenants and expirations.
GET /users/:userId/can/:action/:resourcePerform a runtime permission check for a user. Used by external microservices or frontends.

Presets & Auditing

Method & PathDescription
POST /presets/seedSeed the database with an industry-specific template (SaaS, school, hospital, etc.).
GET /presetsList available presets.
GET /system/exportExport the current database state (roles, permissions, inheritance) as a shareable preset JSON.
POST /system/importImport a preset JSON to overwrite or seed the database state.
GET /audit-logQuery the paginated, filterable audit log database.

React Admin UI

The @rbac/ui package ships as a pre-built React admin panel. It can be deployed as a standalone Single Page Application (SPA) or embedded directly inside any existing React application as a single component:

<RbacAdmin apiUrl="https://rbac.myapp.com" />

Dashboard View

Provides an at-a-glance summary of total roles, users, permissions, and active temporal grants across all tenants.

Roles & Permissions

A visual editor to create roles, assign scopes, toggle actions/resources, and configure extends-inheritance hierarchies via drag-and-drop.

User Directory

List and search users, view their active permissions map, and manage role assignments with explicit expiration dates.

Audit Logs Explorer

A filterable, paginated datagrid displaying every access check, assignment, and mutation with search and export capabilities.


Industry Presets

To accelerate development, @rbac/presets provides pre-built role and permission catalogs for common industries. These can be seeded into the database with a single, idempotent function call.

await rbac.seed(getPreset('ecommerce'));

SaaS Tenant Preset

Roles: owner, admin, member, billing, viewer

Key Permissions: manage:workspace, invite:member, update:billing

E-Commerce Preset

Roles: admin, manager, warehouse, customer

Key Permissions: manage:product, process:refund, read:order

School/LMS Preset

Roles: admin, teacher, student, parent

Key Permissions: write:grade, write:attendance, read:class

Healthcare Preset

Roles: doctor, nurse, receptionist, billing

Key Permissions: read:records, write:prescription, invoice:patient


Key Engineering Decisions

The architecture of this library was designed around specific performance, security, and developer-experience goals. Here is the rationale behind the most critical decisions:

01

Pluggable Adapter Pattern

By abstracting all database operations behind the IStorageAdapter interface, we decouple the core permission engine from specific database drivers. Developers can run on SQL or NoSQL stores without changes to the core traversal logic.

02

Cycle-Guarded DAG Traversal

Role inheritance is a Directed Acyclic Graph (DAG). We implement recursion with an active visited Set. If a circular loop is introduced (e.g. Role A extends B, which extends A), the engine breaks the traversal immediately rather than failing with stack overflows.

03

Denormalized Permission Reads

We choose to denormalize the permission catalog into the role_permissions join table. This prioritizes authorization read speeds (hot path on every request) over permission-write schema compliance.

04

Transactional Integrity

Any mutations to roles, hierarchies, or permissions execute inside database transactions. This guarantees that role updates are atomic — preventing half-saved states from leaking incorrect permissions.


SDK Initialization

Wired together in just a few lines. The SDK integrates easily into any Node.js environment, whether using NestJS, Express, Koa, or serverless lambda handlers:

import { createRbac } from '@rbac/core';
import { PostgresAdapter } from '@rbac/adapter';
import { getPreset } from '@rbac/presets';

// 1. Instantiate the DB adapter
const adapter = new PostgresAdapter(drizzleConnection);

// 2. Create the engine (wires EventEmitter to audit log)
const rbac = createRbac({ adapter });

// 3. Seed with a preset (idempotent upserts)
await rbac.seed(getPreset('saas'));

// 4. Use anywhere in application code
const allowed = await rbac.can(userId, 'manage', 'workspace');

Design Trade-offs & FAQ

A critical look at the architectural trade-offs, security considerations, and engineering decisions made during development.

Why build a custom library instead of using managed services like Auth0, Clerk, or Casbin?
Non-Technical Trade-off

Managed services (like Clerk/Auth0) charge per monthly active user or session, which scales exponentially as a SaaS business grows. By owning the RBAC logic and data inside our own database, we achieve zero scaling cost and absolute data privacy.

Technical Trade-off

Libraries like Casbin use a complex, custom Domain-Specific Language (DSL) that is prone to runtime configuration errors and hard to typecheck. AccessControl libraries in-memory do not scale to B2B multi-tenant applications where roles are dynamically customized by tenant admins in real-time. This monorepo provides a database-backed, type-safe, transactional solution designed specifically for SaaS tenancy structures.

Why denormalize permissions onto roles instead of keeping the database fully normalized?
Non-Technical Trade-off

In a traditional database, checking if a user has access requires joining several disconnected tables together like puzzle pieces, which slows down page loads. Storing permissions directly on roles ensures authorization checks are instant.

Technical Trade-off

A fully normalized schema requires a 4-way join: users → user_roles → roles → role_permissions → permissions. Under high concurrent request volumes, this creates a major database bottleneck. Denormalizing the flat action and resource text directly into the role_permissions table (with cascade-deletes) reduces the query execution path to a single index scan.

Recursive DAG traversal for inheritance is clean, but what if the hierarchy contains cyclic loops or stack overflows?
Non-Technical Trade-off

Role hierarchies are designed to model human organizations (e.g. Owner → Manager → Employee), which are naturally short. We build automated guards to prevent the application from freezing or crashing if someone accidentally creates a loop.

Technical Trade-off

To defend against circular references, the recursion uses a visited Set cycle guard. In a production environment with extremely deep hierarchies (100+ levels), call-stack recursion could trigger a stack overflow. Bounding the recursion depth to a maximum limit (e.g., 20 levels) or refactoring the engine to use an iterative, stack-based Depth-First Search (DFS) ensures deterministic stack safety.

Async fire-and-forget audit logging is fast, but what if the process crashes before writing? Aren't security logs critical?
Non-Technical Trade-off

We prioritized speed: users get their responses instantly instead of waiting for database receipts. In the event of a rare crash, a split second of logs might be lost, but the system remains fast and highly available.

Technical Trade-off

By emitting audit events asynchronously via an EventEmitter, we decouple the authorization check latency from audit DB write latency. If strict compliance (SOC2/HIPAA) is required, the adapter can be configured to execute database writes synchronously within the database transaction or write logs to a durable message broker like Redis Streams or Kafka for guaranteed delivery.

Querying the database on every HTTP request is a performance bottleneck. How is scaling handled?
Non-Technical Trade-off

Instead of asking the database every single time a page is loaded, we temporarily store the answer in a super-fast memory cache. We only update it when permissions actually change.

Technical Trade-off

To avoid database bottlenecks at scale, the engine supports a cache interface (ICacheProvider). Permissions are cached in Redis with a configurable Time-To-Live (TTL). When a role or permission mutation occurs, a pub/sub event invalidates the cache globally across all application instances, keeping authorization checks localized and sub-millisecond.


What This Demonstrates

This project reflects end-to-end ownership of a non-trivial infrastructure library:

✓

Data Modeling

Normalized relational schema with a dedicated DAG table for role hierarchy, separate from the permissions catalog, optimized via selective denormalization.

✓

Algorithm Design

Recursive DAG traversal with a visited-set cycle guard for safe, deep role inheritance resolution.

✓

API Design

RESTful resource-oriented routes following strict HTTP semantics, documented with OpenAPI.

✓

Framework Integration

NestJS guards + metadata decorators for declarative, zero-boilerplate route-level access control.

✓

Frontend Design

Embeddable React admin panel with real-time server state via React Query and responsive styling.

✓

DX Tooling

CLI scaffolding wizard, industry presets, and Changeset-based versioning for the npm ecosystem.

Hey! Got questions?Ask me anything 👋