@nestarc/rbac
v0.2.2
Published
Tenant-aware RBAC and permission guards for production NestJS SaaS applications.
Readme
@nestarc/rbac
Tenant-aware RBAC and permission guards for production NestJS SaaS applications.
Why @nestarc/rbac
@nestarc/rbac gives NestJS backends a small authorization layer that stays separate
from authentication. Your auth stack identifies the request subject, and RBAC decides
whether that subject has a tenant, global, or resource-scoped role with the required
permission.
- Works with Nest HTTP route guards and transport-neutral service-level checks.
- Supports tenant-required, tenant-optional, and global-only decisions.
- Handles exact permissions, suffix wildcards such as
reports.*, and*. - Keeps persistence optional with in-memory storage for tests and Prisma/PostgreSQL storage for production apps.
Installation
npm install @nestarc/rbacInstall NestJS peer dependencies in applications that do not already have them:
npm install @nestjs/common @nestjs/core reflect-metadata rxjsFor Prisma/PostgreSQL storage, install the optional Prisma peers in the consuming application:
npm install @prisma/client @prisma/adapter-pg pg
npm install -D prismaThe adapter is required by Prisma 7 direct database clients. Prisma 5 and 6
consumers can keep their existing engine-based PrismaClient setup.
For focused setup notes, see docs/installation.md. The tested runtime, framework, database-client, and optional integration matrix is documented in docs/compatibility.md. Peer ranges describe install compatibility; the exact combinations listed there are the combinations continuously or explicitly verified by this repository.
| Maintained axis | Exact automated evidence | | ----------------- | ------------------------------------------------------------------------------------------ | | Node.js | Source and strict packed-consumer gates on Node 22 and 24 | | NestJS | Nest 10.4.22 packed on Node 24; Nest 11.2.1 and 12.0.1 packed on Node 22 and 24 | | Prisma/PostgreSQL | Prisma 5.22.0, 6.19.3, and 7.10.0 adapter contracts on PostgreSQL 16; Prisma 8 unsupported |
These are representative lanes, not a promise that every Cartesian combination
has a separate test. Release publishing repeats the compatibility lanes and first
checks that the release tag, target, package version, and main ancestry agree.
Maintenance Status
The repository's canonical maintenance queue is the source of truth for current work. Older PRDs, specifications, and execution plans are retained as historical design records; their status labels and unchecked boxes are not active backlog.
Quickstart
import { Module } from '@nestjs/common';
import { InMemoryRbacStorage, RbacModule } from '@nestarc/rbac';
@Module({
imports: [
RbacModule.forRoot({
storage: new InMemoryRbacStorage(),
tenant: { requiredByDefault: true },
}),
],
})
export class AppModule {}Seed a role and binding through RbacService:
await rbac.createRole({
tenantId: 'tenant_1',
key: 'viewer',
permissions: ['reports.read'],
});
await rbac.assignRole({
tenantId: 'tenant_1',
subject: { type: 'user', id: 'user_1', tenantId: 'tenant_1' },
roleKey: 'viewer',
});Typed Permission Contracts
Use defineRbacPermissions() when an application wants autocomplete and literal
types for permission keys while keeping persisted values as strings.
import { Can, defineRbacPermissions } from '@nestarc/rbac';
export const permissions = defineRbacPermissions({
reports: {
read: 'reports.read',
export: 'reports.export',
},
} as const);
@Can(permissions.reports.read, { tenant: 'required' })
readReport() {
return { ok: true };
}Existing string permissions such as @Can('reports.read') continue to work.
Strict Options
createStrictRbacOptions() provides a fail-closed starting point without changing
the package defaults.
import { InMemoryRbacStorage, RbacModule, createStrictRbacOptions } from '@nestarc/rbac';
RbacModule.forRoot(
createStrictRbacOptions({
storage: new InMemoryRbacStorage(),
}),
);The helper enables requireMetadata, tenant-required defaults, tenant boundary
write validation, and denied storage-error behavior. Explicit overrides remain
available for routes or apps that need compatibility behavior.
The preset does not create a separate authorization engine. Source-conflict and custom-storage output checks apply in both default and strict configurations; the preset changes the missing-metadata, missing-tenant, and write-validation defaults:
| Behavior | Plain RbacModule.forRoot() | createStrictRbacOptions() |
| ------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ |
| Route without RBAC metadata | Allowed unless requireMetadata is enabled | Denied unless explicitly overridden; use @SkipRbac() for public routes |
| Requirement without an explicit tenant mode | Tenant-optional | Tenant-required |
| Global roles during tenant checks | Disabled unless explicitly enabled | Disabled unless explicitly overridden |
| Storage errors during decisions | Denied unless storageErrors: 'throw' | Denied unless explicitly overridden |
| Tenant/resource write validation | Opt-in flags | Tenant mismatch and resource-without-tenant checks enabled |
Adopt the preset incrementally and test existing public routes and data before turning it on application-wide. See the 0.2 migration guide.
Protecting Routes
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Can, CurrentRbacSubject, RbacGuard, type RbacSubject } from '@nestarc/rbac';
@Controller('reports')
export class ReportsController {
@UseGuards(RbacGuard)
@Can('reports.read', { tenant: 'required' })
@Get(':reportId')
readReport(@CurrentRbacSubject() subject: RbacSubject) {
return { viewedBy: subject.id };
}
}Use @Can() or @RequirePermissions() for permissions, @RequireRole() for role
keys, and @SkipRbac() for health checks or public routes. See
docs/guards.md.
The 0.2.x RbacGuard, route/parameter decorator pipeline, built-in resource
declarations, and default/integration resolvers support Nest HTTP requests only.
Custom resolvers can change how that HTTP pipeline obtains trusted values, but do
not make the complete Guard work on GraphQL, RPC, or WebSockets. For another
transport, extract its carrier values in application-owned code and call
RbacService.can() or assertCan(). Those transports remain unverified and are
not package-supported Guard integrations. See the
transport contract ADR.
The default HTTP subject resolver reconciles valid request.rbacSubject,
request.user, and API-key identities by exact (type, id, tenantId). Conflicting
sources fail closed before authorization. request.user.type remains a supported
custom namespace for compatibility and defaults to user only when absent;
configure subjectResolver when the application requires a fixed namespace.
Subject type is part of identity, so equal IDs in different namespaces never share
role bindings. See the subject source policy.
Tenant-Aware Checks
Tenant-aware checks reconcile the tenant ID from the subject, request fields,
headers, and a custom tenant resolver. When configured, the custom resolver is
authoritative: its string or explicit global null result must agree with every
populated HTTP source, while undefined falls back to consistent HTTP sources.
Conflicts deny before authorization. When tenant.requiredByDefault is true,
protected routes also deny if no tenant can be resolved. See
the tenant source policy for global checks and the
deprecated legacy-fallback migration option.
import { InMemoryRbacStorage, RbacModule } from '@nestarc/rbac';
import { createTenancyTenantResolver } from '@nestarc/rbac/integrations/tenancy';
const tenantContext = {
getTenantId: () => 'tenant_1',
};
RbacModule.forRoot({
storage: new InMemoryRbacStorage(),
tenantResolver: createTenancyTenantResolver(() => tenantContext.getTenantId()),
tenant: {
requiredByDefault: true,
allowGlobalRolesInTenant: false,
},
});For direct service checks:
await rbac.can({
subject: { type: 'user', id: 'user_1', tenantId: 'tenant_1' },
tenantId: 'tenant_1',
tenantMode: 'required',
permission: 'reports.read',
});Identifier Canonicalization
RbacService and both built-in storage adapters trim leading and trailing
whitespace from tenant IDs, non-API-key subject types and IDs, role IDs and keys,
binding IDs, resource types and IDs, and permissions. Whitespace-only identifiers
are rejected. The canonical value is used consistently for writes, lookups,
authorization decisions, audit events, and policy-change events.
API-key subject IDs remain exact opaque values, as do API-key tenant values on the
resolved subject. They are never repaired by trimming or coercion, so " key_1 "
and "key_1" identify different API keys. RBAC performs no case folding or Unicode
normalization for any identifier.
Existing Prisma rows are not rewritten automatically. Before upgrading a database that may contain identifiers with outer whitespace, inventory the affected role, binding, resource, tenant, and permission columns; resolve any collisions that trimming would create; then migrate them to canonical values. Non-canonical effective rows fail closed during authorization rather than being silently aliased.
Indexed Role Lookup For Strict Writes
Strict assignRole({ roleId }) validation must resolve the referenced role before
checking its tenant boundary. Both built-in adapters expose the optional
RbacStorage.findRoleById({ roleId }) capability: the in-memory adapter uses its
role-ID map, while the Prisma adapter issues an ID-constrained query and loads
permissions only for that role. This path never calls listRoles({}) or reads the
complete role/permission graph.
Existing custom adapters remain source-compatible in 0.2.x. When the capability
is absent, RBAC uses the previous listRoles({}) scan, but that compatibility
fallback is deprecated and is a 0.3-or-later removal candidate. Custom adapters
should implement RbacStorageRoleLookupCapability and return the exact role or
null:
async findRoleById({ roleId }: FindRoleByIdInput): Promise<RbacRole | null> {
return rolesById.get(roleId) ?? null;
}The role ID is canonicalized at the public adapter boundary. Database-backed implementations should use an indexed unique/primary-key lookup and avoid loading unrelated roles or permissions.
Mutation Outcomes And Events
Both built-in storage adapters expose the optional RbacStorage.mutationResults
capability. RbacService uses its created, updated, deleted, no-op, and
conflict outcomes so each committed write attempts at most one audit event and
one policy-change event. Idempotent duplicates, missing deletes/revocations, and
other no-ops do not emit success events. updateRole() never creates a missing
role and throws RbacRoleNotFoundError; the legacy storage-level upsertRole()
remains the explicit upsert API.
createRole() keeps its existing upsert-by-tenant-and-key behavior. A new role
emits role.created, a changed existing role emits role.updated, and an
identical duplicate emits neither. Duplicate active assignments and already
granted permissions are also no-ops; reactivating an expired assignment is an
update.
Custom adapters should implement RbacStorageMutationCapability through the
optional property. Adapters without it remain source-compatible in 0.2.x, but
use a deprecated result-less fallback: RBAC must assume a resolved mutation
changed storage and may emit a success event for an adapter-internal no-op. This
fallback is a 0.3 removal candidate.
Audit and change delivery remains best effort after storage commit. Logger or publisher failures do not roll back writes, and RBAC does not promise external exactly-once delivery, atomic storage-plus-publisher transactions, or a transactional outbox.
Resource-Scoped Roles
Resource-scoped bindings let one subject hold a role only for a specific object, such as one project.
await rbac.createRole({
tenantId: 'tenant_1',
key: 'project-maintainer',
permissions: ['project.member.invite'],
});
await rbac.assignRole({
tenantId: 'tenant_1',
subject: { type: 'user', id: 'user_1' },
roleKey: 'project-maintainer',
resource: { type: 'project', id: 'project_1' },
});@Can('project.member.invite', {
tenant: 'required',
resource: { type: 'project', idParam: 'projectId' },
})
@Post(':projectId/invitations')
invite() {
return { ok: true };
}Unscoped bindings still satisfy resource checks, which keeps tenant-wide admin roles useful.
Prisma Setup
Install Prisma in the consuming app, copy the example RBAC models from
prisma/schema.prisma.example, and apply prisma/migrations/0001_init_rbac.sql
through your migration workflow.
Prisma 7 applications should keep their prisma-client generator and datasource
URL in prisma.config.ts, then create the generated client with the driver adapter
for their database. PrismaRbacStorage accepts both that client and the legacy
Prisma 5/6 @prisma/client shape. See docs/prisma.md for the
separate, copyable Prisma 5/6 and Prisma 7 setup and verification procedures.
import { Module } from '@nestjs/common';
import { RbacModule } from '@nestarc/rbac';
import { PrismaRbacStorage } from '@nestarc/rbac/prisma';
import { PrismaModule } from './prisma.module';
import { PrismaService } from './prisma.service';
@Module({
imports: [
RbacModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: (prisma: PrismaService) => ({
storage: new PrismaRbacStorage(prisma),
}),
}),
],
})
export class AppModule {}Do not use npm run test:prisma alone as database evidence: without a configured
database URL the suite is intentionally skipped. See docs/prisma.md
for the complete PostgreSQL URL → generate → migrate → test sequence and the
required skip 0 check.
API Key Recipe
API key auth should validate the key first and attach the canonical Nestarc
context to request.apiKey. The RBAC subject resolver then maps that record to
an api_key subject.
import { InMemoryRbacStorage, RbacModule } from '@nestarc/rbac';
import { createApiKeySubjectResolver } from '@nestarc/rbac/integrations/api-keys';
RbacModule.forRoot({
storage: new InMemoryRbacStorage(),
subjectResolver: createApiKeySubjectResolver(),
tenant: { requiredByDefault: true },
});The resolver reads opaque string keyId or id values, preserves tenantId
exactly when present, and stores the source record on subject.attributes.
Deprecated request.apiKeyContext is used only when request.apiKey is absent;
conflicting dual sources fail closed. See
examples/api-keys.
Testing Utilities
import { Test } from '@nestjs/testing';
import { RbacService } from '@nestarc/rbac';
import { TestRbacModule, expectAllowed, rbacUser } from '@nestarc/rbac/testing';
const moduleRef = await Test.createTestingModule({
imports: [
TestRbacModule.forRoot({
tenant: { requiredByDefault: true },
subject: rbacUser('user_1', 'tenant_1'),
}),
],
}).compile();
const rbac = moduleRef.get(RbacService);
await rbac.createRole({
tenantId: 'tenant_1',
key: 'viewer',
permissions: ['reports.read'],
});
await rbac.assignRole({
tenantId: 'tenant_1',
subject: rbacUser('user_1', 'tenant_1'),
roleKey: 'viewer',
});
await expectAllowed(rbac, {
subject: rbacUser('user_1', 'tenant_1'),
tenantId: 'tenant_1',
permission: 'reports.read',
});See docs/testing.md.
RbacService.can() returns the producer-accurate RbacServiceDecision type. Its
details are always present for server-side diagnostics and auditing, but HTTP
error responses expose only a stable message and error code. See the
public decision and error contract
for the produced reason set, compatibility types, and deprecated dormant APIs.
Change Events
Apps that cache effective permissions can subscribe to mutation events without using audit logs as cache invalidation messages.
RbacModule.forRoot({
storage,
changePublisher: {
publish(event) {
cache.invalidate(event);
},
},
});Trust Boundaries
RBAC authorizes an identity supplied by the application; it does not authenticate credentials or establish tenant membership. The boundaries are:
| Boundary | Application or adapter responsibility | RBAC enforcement |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Subject carriers | Auth code validates credentials and writes request.rbacSubject, request.user, or canonical request.apiKey; a custom subjectResolver must return a trusted identity. | Default HTTP subject carriers must agree on exact (type, id, tenantId). Conflicts or malformed API-key carriers fail closed. A configured custom subject resolver is authoritative and is not reconciled with default carriers. |
| Tenant carriers | Tenancy/auth middleware owns the configured resolver and any request/header tenant values. A client-controlled header is not trusted merely because it exists. | A configured tenant resolver is authoritative by default and its string/null result must agree with populated HTTP sources; undefined delegates to consistent HTTP sources. Conflicts fail closed. |
| API-key compatibility | API-key auth validates the key and writes request.apiKey. Custom or stale middleware must migrate away from request.apiKeyContext. | Canonical and legacy records must agree exactly when both exist. IDs are opaque strings and are not trimmed, normalized, case-folded, or coerced. |
| Storage results | A custom RbacStorage must query the requested subject and omit revoked rows; secure transport and side effects remain adapter concerns. | Effective rows are rechecked for tenant/global scope, finite expiry, resource shape, canonical identifiers, and permission shape before use. The effective-row interface has no subject or revokedAt, so RBAC cannot independently prove those two facts. |
These checks apply to both plain and strict options. Detailed HTTP source behavior is in docs/guards.md; API-key and tenancy integration behavior is in docs/integrations.md; custom storage limits are in docs/prisma.md.
Security Notes
- Authentication is not included. Use your existing auth guard to attach
request.user,request.rbacSubject, or a customsubjectResolver; simultaneous default HTTP sources must resolve to the same exact subject identity. - Tenant-required routes fail closed when tenant identity is missing.
- Wildcards are limited to
*and suffix wildcards such asreports.*. - Global roles do not apply inside tenants by default.
- Use
NoopRbacAuditLoggerfrom the root package when an app wants to pass an explicit audit logger without emitting events. - Use
createAuditLogRbacLogger()from@nestarc/rbac/integrations/audit-logto map RBAC audit events to a structural audit logger. - Do not log raw subject attributes unless your application has reviewed the data stored by the auth integration.
