@devoven/rbac
v0.1.0
Published
RBAC module for NestJS — hexagonal architecture
Downloads
121
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 RbacModuleOptions {
roleRepository?: Type<RoleRepositoryPort> | RoleRepositoryPort;
}| Option | Type | Default | Description |
|--------|------|---------|-------------|
| roleRepository | Class or instance implementing RoleRepositoryPort | InMemoryRoleRepository | Persistence adapter for roles |
Pass a class and NestJS resolves its constructor dependencies. Pass an instance to provide a pre-configured object.
Async Registration
Use registerAsync when the repository depends on injected services or async configuration:
import { RbacModule } from '@devoven/rbac';
import { ConfigService } from '@nestjs/config';
import { PrismaRoleRepository } from './rbac/prisma-role.repository';
@Module({
imports: [
RbacModule.registerAsync({
useFactory: (config: ConfigService) => ({
roleRepository: config.get('USE_PRISMA')
? PrismaRoleRepository
: undefined,
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}REST API
All endpoints are served under the /roles prefix.
| Method | Path | Description | Status |
|--------|------|-------------|--------|
| POST | /roles | Create a role | 201 / 409 |
| GET | /roles | List all roles | 200 |
| GET | /roles/:name | Get a role by name | 200 / 404 |
| PATCH | /roles/:name/permissions | Add or remove a permission | 204 / 404 |
| DELETE | /roles/:name | Delete a role | 204 / 404 |
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-z_*]+:[a-z_*]+$ |
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-z_*]+:[a-z_*]+$ |
Response 204 No Content. Returns 404 Not Found if the role does not exist.
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, 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. If the handler has no permissions set, 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 |
| RoleRepositoryPort | 'RoleRepositoryPort' | InMemoryRoleRepository (default) |
CheckPermissionPort and CheckAnyPermissionPort are exported from the module and can be injected directly into guards in consuming apps.
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 })