@devoven/rbac
v0.1.1
Published
RBAC module for NestJS — hexagonal architecture
Readme
@devoven/rbac
Role-based access control engine for NestJS. It answers one question: "does this role have this permission?"
Installation
npm install @devoven/rbac
# or
pnpm add @devoven/rbacPeer dependencies
This package requires the standard NestJS peer dependencies. You likely already have most of these. The two that are not part of a vanilla NestJS install are class-validator and class-transformer, which are required for DTO validation:
npm install class-validator class-transformerQuick Start
import { RbacModule } from '@devoven/rbac';
@Module({
imports: [RbacModule.register({})],
})
export class AppModule {}This uses InMemoryRoleRepository by default, which is suitable for development and testing.
Module Options
interface RbacAdapterOptions {
roleRepository?: Type<RoleRepositoryPort> | RoleRepositoryPort;
useControllers?: boolean;
allowedPermissions?: string[];
}| Option | Type | Default | Description |
|--------|------|---------|-------------|
| roleRepository | Class or instance implementing RoleRepositoryPort | InMemoryRoleRepository (dev/test only) | Persistence adapter for roles |
| useControllers | boolean | off | Mounts the /roles routes. Off: no routes exist at all. |
| allowedPermissions | string[] | none | The menu of permissions the /roles routes may grant. Required when useControllers is on (startup throws otherwise); [] means nothing is grantable. Each entry must match the permission format. No wildcards (*): startup throws, since a granted *:manage would pass rbac:manage. |
All three are wired statically at registration (also for registerAsync, where they sit next to useFactory, not inside it), so a class repository gets full DI. Returning one from the factory (the 0.1.x way) stops the app at startup instead of being ignored.
rbac:manage is not added to allowedPermissions for you; list it if admins should be able to grant it.
Async Registration
registerAsync takes the same options (adapter options stay next to useFactory):
import { RbacModule } from '@devoven/rbac';
import { ConfigService } from '@nestjs/config';
import { PrismaRoleRepository } from './rbac/prisma-role.repository';
@Module({
imports: [
RbacModule.registerAsync({
useFactory: (config: ConfigService) => ({}),
inject: [ConfigService],
roleRepository: PrismaRoleRepository,
useControllers: true,
allowedPermissions: ['rbac:manage', 'order:read', 'order:write'],
}),
],
})
export class AppModule {}REST API
Mounted only when useControllers: true. Every route requires the rbac:manage permission (via PermissionsGuard, reading request.user.roles). Signed-out handling is your app's own auth guard; with no request.user the routes answer 403.
| Method | Path | Description | Status |
|--------|------|-------------|--------|
| POST | /roles | Create a role | 201 / 400 / 409 |
| GET | /roles | List all roles | 200 |
| GET | /roles/permissions | The allowedPermissions menu (string[]) | 200 |
| GET | /roles/:name | Get a role by name | 200 / 404 |
| PATCH | /roles/:name/permissions | Add or remove one permission; returns the updated role | 200 / 400 / 404 |
| DELETE | /roles/:name | Delete a role | 204 / 404 |
Rules:
- A permission outside
allowedPermissionsis400on create and on PATCHadd(and the menu can't hold a wildcard, so*:*can't be granted through the API). PATCHremoveis not menu-checked, so stale grants can still be cleaned up. - A new role name must be 1-64 lowercase letters, digits,
_or-(400otherwise; route matching ignores case, soAdminandadminwould share a URL).permissionsis reserved (400): it would collide withGET /roles/permissions. - Nothing stops an admin deleting the
adminrole or removingrbac:managefrom it. Restore it in your app's boot step.
POST /roles
Request body:
{
"name": "editor",
"permissions": ["article:read", "article:write"]
}| Field | Type | Validation |
|-------|------|------------|
| name | string | Required |
| permissions | string[] | At least 1 item, each matching ^[a-z0-9_*]+:[a-z0-9_*]+$ |
Response 201 Created:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "editor",
"permissions": ["article:read", "article:write"],
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}Returns 409 Conflict if a role with the same name already exists.
GET /roles
Response 200 OK: array of role objects (same shape as above).
GET /roles/:name
Response 200 OK: single role object. Returns 404 Not Found if the role does not exist.
PATCH /roles/:name/permissions
Request body:
{
"action": "add",
"permission": "article:delete"
}| Field | Type | Validation |
|-------|------|------------|
| action | "add" or "remove" | Required |
| permission | string | Must match ^[a-z0-9_*]+:[a-z0-9_*]+$ |
Response 200 OK: the updated role. 400 if add names a permission outside allowedPermissions; 404 if the role does not exist.
GET /roles/permissions
Response 200 OK: the allowedPermissions array, e.g. ["order:read", "rbac:manage"].
DELETE /roles/:name
Response 204 No Content. Returns 404 Not Found if the role does not exist.
Permission Format
Permissions use the resource:action pattern with lowercase letters, digits, underscores, and wildcards:
order:read exact match
order:* matches any action on "order"
*:read matches "read" on any resource
*:* matches everythingWildcard matching is handled by the Permission value object in the domain layer. A * in either segment matches any value in the corresponding position.
Guards and Decorators
Protect any route by combining @RequirePermissions with PermissionsGuard:
import { RequirePermissions, PermissionsGuard } from '@devoven/rbac';
import { UseGuards, Get } from '@nestjs/common';
@RequirePermissions('order:read')
@UseGuards(PermissionsGuard)
@Get('orders')
findAll() { ... }The guard reads request.user.roles (a string[] of role names) and checks whether any of those roles has the required permission via CheckAnyPermissionPort. If request.user or request.user.roles is absent, access is denied. Metadata is read from the handler, then the class. If neither sets permissions, the guard allows the request through.
Your authentication layer must populate request.user.roles before the guard runs.
Architecture
The module follows hexagonal architecture. The composition root (rbac.module.ts) is the only place that wires concrete adapters to port tokens.
Port / Token / Use Case Mapping
| Port Interface | DI Token | Use Case |
|----------------|----------|----------|
| CheckPermissionPort | 'CheckPermissionPort' | CheckPermissionUseCase |
| CheckAnyPermissionPort | 'CheckAnyPermissionPort' | CheckAnyPermissionUseCase |
| CreateRolePort | 'CreateRolePort' | CreateRoleUseCase |
| GetRolePort | 'GetRolePort' | GetRoleUseCase |
| ListRolesPort | 'ListRolesPort' | ListRolesUseCase |
| AddPermissionPort | 'AddPermissionPort' | AddPermissionUseCase |
| RemovePermissionPort | 'RemovePermissionPort' | RemovePermissionUseCase |
| DeleteRolePort | 'DeleteRolePort' | DeleteRoleUseCase |
| GetPermissionsForRolesPort | 'GetPermissionsForRolesPort' | GetPermissionsForRolesUseCase (deduped union; unknown roles ignored) |
| RoleRepositoryPort | 'RoleRepositoryPort' | InMemoryRoleRepository (default) |
CheckPermissionPort, CheckAnyPermissionPort and GetPermissionsForRolesPort are exported from the module and can be injected directly into services, guards, or controllers in consuming apps. The most common ones to inject are CheckPermissionPort (single-role check) and CheckAnyPermissionPort (multi-role check, used by PermissionsGuard). RoleRepositoryPort is the only driven port — it is not exported because consumers interact with it indirectly through the driving ports.
Custom Adapters
Implement RoleRepositoryPort to use any backing store:
import { Injectable } from '@nestjs/common';
import { RoleRepositoryPort, Role, Permission } from '@devoven/rbac';
@Injectable()
export class PrismaRoleRepository implements RoleRepositoryPort {
constructor(private readonly prisma: PrismaService) {}
async save(role: Role): Promise<void> { /* ... */ }
async findByName(name: string): Promise<Role | null> { /* ... */ }
async findByNames(names: string[]): Promise<Role[]> { /* ... */ }
async findAll(): Promise<Role[]> { /* ... */ }
async delete(name: string): Promise<void> { /* ... */ }
}When reading from the database, use Role.reconstitute(id, name, permissions, createdAt, updatedAt) to rebuild domain entities, and Permission.fromString('resource:action') to parse stored permission strings.
Then register it with the module:
RbacModule.register({ roleRepository: PrismaRoleRepository })