Skip to content

RBAC Domain

The rbac domain provides role-based access control (RBAC) for TypeScript backends. It is structured in strict Clean Architecture layers with zero npm dependencies in the domain and application layers.

Terminal window
npx @backcap/cli add rbac

The Role entity is the aggregate root of the RBAC domain. It holds the role’s identity, name, description, and a list of permissions.

import { Role } from "./domains/rbac/domain/entities/role.entity";
import { Permission } from "./domains/rbac/domain/entities/permission.entity";
const perm = Permission.create({
id: crypto.randomUUID(),
action: "read",
resource: "posts",
}).unwrap();
const result = Role.create({
id: crypto.randomUUID(),
name: "editor",
description: "Can read and edit posts",
permissions: [perm],
});
if (result.isOk()) {
const role = result.unwrap();
console.log(role.name, role.permissions.length);
}
FieldTypeDescription
idstringUnique identifier (UUID)
namestringRole name (must not be empty)
descriptionstringHuman-readable description
permissionsPermission[]List of granted permissions
createdAtDateSet on creation
updatedAtDateSet on creation, updated on mutation

Role.create() returns Result<Role, InvalidRoleName>. If the name is empty, the result is a failure.

role.addPermission(perm) and role.removePermission(id) return new Role instances (immutable).

import { Permission } from "./domains/rbac/domain/entities/permission.entity";
const result = Permission.create({
id: crypto.randomUUID(),
action: "manage",
resource: "posts",
conditions: { ownOnly: true },
});
// Result<Permission, Error>

permission.matches(action, resource) checks if a permission covers the given action and resource. The manage action acts as a wildcard and matches all other actions.

Valid actions: create, read, update, delete, manage.

  • manage includes all other actions.
  • action.includes(other) checks containment.
  • action.equals(other) compares values.

Validates resource names against ^[a-z][a-z0-9]*(-[a-z0-9]+)*$. Examples: posts, user-profiles, audit-logs.

Error ClassConditionMessage
InvalidRoleNameRole name is emptyInvalid role name: "<name>". Role name cannot be empty
DuplicateRoleRole name already existsRole already exists with name: "<name>"
RoleNotFoundNo role found for the given IDRole not found with id: "<id>"
PermissionDeniedInvalid action or resourcePermission denied: invalid action "<value>"
EventEmitted ByPayload
RoleAssignedAssignRole use caseuserId, roleId, organizationId?, occurredAt
RoleRevokedRevokeRole use caseuserId, roleId, occurredAt
PermissionGrantedCreateRole use caseroleId, permissionId, action, resource, occurredAt

Creates a new role with optional permissions. Emits PermissionGranted events for each permission.

import { CreateRole } from "./domains/rbac/application/use-cases/create-role.use-case";
const createRole = new CreateRole(roleRepository);
const result = await createRole.execute({
name: "editor",
description: "Can edit posts",
permissions: [
{ action: "read", resource: "posts" },
{ action: "update", resource: "posts" },
],
});
// Result<{ roleId: string; events: PermissionGranted[] }, Error>

Possible failures: DuplicateRole, InvalidRoleName, PermissionDenied

Assigns a role to a user.

import { AssignRole } from "./domains/rbac/application/use-cases/assign-role.use-case";
const assignRole = new AssignRole(roleRepository);
const result = await assignRole.execute({ userId: "user-1", roleId: "role-1", organizationId: "org-1" });
// Result<{ event: RoleAssigned }, Error>

Possible failures: RoleNotFound

Revokes a role from a user. Verifies the user actually has the role before revoking.

import { RevokeRole } from "./domains/rbac/application/use-cases/revoke-role.use-case";
const revokeRole = new RevokeRole(roleRepository);
const result = await revokeRole.execute({ userId: "user-1", roleId: "role-1" });
// Result<{ event: RoleRevoked }, Error>

Possible failures: RoleNotFound

Checks if a user has a specific permission. Optionally scoped to an organization.

import { CheckPermission } from "./domains/rbac/application/use-cases/check-permission.use-case";
const checkPermission = new CheckPermission(permissionResolver);
const result = await checkPermission.execute({
userId: "user-1",
action: "update",
resource: "posts",
organizationId: "org-1", // optional — checks org-scoped permissions
});
// Result<boolean, PermissionDenied>

When organizationId is provided, only permissions assigned within that organization are considered. A user with admin role in org A has no admin privileges in org B.

Possible failures: PermissionDenied (invalid action or resource)

Returns all roles in the system.

Returns all permissions for a given user.

export interface IRoleRepository {
findById(id: string): Promise<Role | null>;
findByName(name: string): Promise<Role | null>;
findByUserId(userId: string, organizationId?: string): Promise<Role[]>;
findAll(): Promise<Role[]>;
save(role: Role): Promise<void>;
delete(id: string): Promise<void>;
assignToUser(userId: string, roleId: string, organizationId?: string): Promise<void>;
revokeFromUser(userId: string, roleId: string): Promise<void>;
}
export interface IPermissionResolver {
getUserPermissions(userId: string, organizationId?: string): Promise<Permission[]>;
hasPermission(userId: string, action: string, resource: string, organizationId?: string): Promise<boolean>;
}

When organizationId is provided, the resolver filters permissions to those assigned within the specified organization scope. Without it, global (non-org-scoped) permissions are returned.

import {
createAuthorizationService,
IAuthorizationService,
} from "./domains/rbac/contracts";
const authorizationService: IAuthorizationService = createAuthorizationService({
roleRepository,
permissionResolver,
});
// IAuthorizationService interface:
// createRole(input): Promise<Result<{ roleId: string }, Error>>
// assignRole(input): Promise<Result<{ event: RoleAssigned }, Error>>
// revokeRole(input): Promise<Result<{ event: RoleRevoked }, Error>>
// checkPermission(input): Promise<Result<boolean, PermissionDenied>>
// listRoles(): Promise<Result<RoleDTO[], Error>>
// getUserPermissions(userId, organizationId?): Promise<Result<PermissionDTO[], Error>>

This is the only import consumers need. The internal use case classes are implementation details.

domains/rbac/
domain/
entities/role.entity.ts
entities/permission.entity.ts
value-objects/permission-action.vo.ts
value-objects/resource-type.vo.ts
errors/invalid-role-name.error.ts
errors/duplicate-role.error.ts
errors/role-not-found.error.ts
errors/permission-denied.error.ts
events/role-assigned.event.ts
events/role-revoked.event.ts
events/permission-granted.event.ts
application/
use-cases/create-role.use-case.ts
use-cases/assign-role.use-case.ts
use-cases/revoke-role.use-case.ts
use-cases/check-permission.use-case.ts
use-cases/list-roles.use-case.ts
use-cases/get-user-permissions.use-case.ts
ports/role-repository.port.ts
ports/permission-resolver.port.ts
dto/create-role-input.dto.ts
dto/assign-role-input.dto.ts
dto/revoke-role-input.dto.ts
dto/check-permission-input.dto.ts
dto/get-user-permissions-input.dto.ts
contracts/
rbac.contract.ts
rbac.factory.ts
index.ts
shared/
result.ts