RBAC — Role-Based Access Control
Audiences: developer, internal
Dudoxx HMS enforces role-based access using a layered system of guards, interceptors, and decorators. The global
GatewayAuthGuardidentifies the caller;RolesGuardchecks their role against endpoint requirements;RbacEnforcementInterceptordetects undecorated handlers; and@RbacExempt()provides an escape hatch that is currently misused on security-critical voice routes.
Business Purpose
A multi-tenant healthcare platform must ensure that a receptionist cannot see a doctor's clinical notes, a patient cannot access billing records of other patients, and a nurse cannot delete prescriptions. RBAC maps each user to a role in the hierarchy and gates every API endpoint against the minimum required role.
The system additionally subsumes the legacy ACL approach (W1-acl scope) — all access control decisions flow through the same request.user.role field set by GatewayAuthGuard and then enforced by RolesGuard or RbacEnforcementInterceptor. There is no separate ACL table or per-resource permission bitmask for the standard clinical endpoints; the role alone is sufficient to determine access.
Business outcome: A nurse logs in; their JWT payload carries role: NURSE; every API call they make attaches this via X-Gateway-User-Role through Next.js; RolesGuard checks that the endpoint's @Roles(DOCTOR, NURSE) list includes NURSE and allows the call; a PATIENT hitting the same endpoint gets a 403. The check adds zero database round-trips because the role is in request.user (built from gateway headers).
Audiences
- Investor: The RBAC layer is a compliance prerequisite for HIPAA and GDPR. A clear role hierarchy with auditable per-endpoint decoration demonstrates enterprise-grade access governance.
- Clinical buyer (doctor/nurse/receptionist): Each staff member only sees what their role allows. Doctors see full clinical records; receptionists see scheduling and billing; patients see only their own data.
- Developer/partner: Use
@Roles(UserRole.DOCTOR, UserRole.NURSE)on any endpoint that should be restricted. The@Public(),@RbacExempt(),@RequireAdminRole(),@RequireClinicStaff(), and@RequireMedicalStaff()decorators provide common groupings. Add@PlatformScoped()for endpoints managing globally-shared definitions (roles, permissions, vaccine schedules). - Internal (ops/support):
RBAC_ENFORCEMENT=enforceflips theRbacEnforcementInterceptorfromwarnto hard 403 for undecorated handlers. This flip is currently deferred pending remediation of the 42 unguarded controllers identified in the dry-run pass.
Architecture
RBAC is enforced in two places in the global request pipeline (order from ddx-api/CLAUDE.md §Request Pipeline):
| Pipeline position | Component | Responsibility |
|---|---|---|
| Step 1 | GatewayAuthGuard (APP_GUARD, position 1) | builds request.user { role, roles, permissions, isServiceAccount, ... } |
| Step 7 | RbacEnforcementInterceptor (APP_INTERCEPTOR, position 7) | detects handlers missing any recognized RBAC decorator; mode 'warn' (bare code-level fallback) or 'enforce' (RBAC_ENFORCEMENT=enforce) |
| Per-route (applied where needed) | RolesGuard | reads @Roles() metadata, checks request.user.roles[] |
| Per-route (applied where needed) | DynamicPermissionGuard | permission-level check (blocks wildcard '*' on mutations) |
NOTE: the
ddx-docker-full-hmscompose file sets${RBAC_ENFORCEMENT:-enforce}, so a Docker deploy runs in'enforce'by default;'warn'is only the code fallback when the env var is entirely unset.
Role hierarchy (from ddx-api/CLAUDE.md, ordered highest to lowest privilege):
SUPER_ADMIN > ORG_ADMIN > CLINIC_ADMIN > DOCTOR / NURSE > RECEPTIONIST > ACCOUNTANT > STAFF > PATIENT
Roles are stored in ddx_api_main as UserRoleAssignment records linking a userId to a roleId to an organizationId. The @ddx/prisma-main UserRole enum is the authoritative list.
Subsumed ACL layer: the legacy ACL approach (per-document access lists) was previously a parallel system. As of the W1 audit, all clinical ACL checks flow through the same request.user + RolesGuard mechanism — there is no separate ACL interceptor in the active request pipeline.
Tech Stack & Choices
| Component | File | Role |
|---|---|---|
RolesGuard | guards/roles.guard.ts | Per-route role check via @Roles() decorator |
RbacEnforcementInterceptor | common/interceptors/rbac-enforcement.interceptor.ts | Global: detects undecorated handlers |
@Roles(...) | decorators/roles.decorator.ts | Sets ROLES_KEY metadata |
@Public() | decorators/public.decorator.ts | Sets IS_PUBLIC_KEY; bypasses GatewayAuthGuard |
@RbacExempt() | defined in rbac-enforcement.interceptor.ts:46 | Suppresses RbacEnforcementInterceptor; JWT still required |
@PlatformScoped() | common/decorators/platform-scoped.decorator.ts | Bypasses X-Clinic-ID requirement for shared definitions |
@RequireAdminRole() | auth decorators | Shorthand for @Roles(SUPER_ADMIN, CLINIC_ADMIN) |
@RequireClinicStaff() | auth decorators | All clinic roles |
@RequireMedicalStaff() | auth decorators | DOCTOR | NURSE |
RBAC_ENFORCEMENT env var | rbac-enforcement.interceptor.ts:59 | code fallback 'warn'; 'enforce' when set. The ddx-docker-full-hms compose default is enforce (${RBAC_ENFORCEMENT:-enforce}), so Docker deploys enforce by default |
Data Flow
Per-request role check
Entry: request arrives (user already on request.user from GatewayAuthGuard)
→ RolesGuard.canActivate().
| # | Condition | Result |
|---|---|---|
| 1 | reflector.getAllAndOverride(ROLES_KEY, [handler, class]) | → requiredRoles[] (from @Roles(...) decorator) |
| 2 | if requiredRoles is empty | return true (no restriction) |
| 3 | if user.isServiceAccount | return requiredRoles.some(r => user.roles.includes(r)) |
| 4 | if user.roles[] (array) | return requiredRoles.some(r => user.roles.includes(r)) |
| 5 | fallback | return requiredRoles.includes(user.role) (single-role path) |
RBAC enforcement interceptor (global, runs on every request)
RbacEnforcementInterceptor.intercept() checks four metadata keys:
| # | Check | Source decorator |
|---|---|---|
| 1 | IS_PUBLIC_KEY | @Public() |
| 2 | ROLES_KEY | @Roles() |
| 3 | PERMISSION_KEY | @RequirePermission() |
| 4 | RBAC_EXEMPT_KEY | @RbacExempt() |
isDecorated = any of the above is truthy. Then:
| Condition | Outcome |
|---|---|
!isDecorated AND mode='enforce' | throw ForbiddenException(403) |
!isDecorated AND mode='warn' | logger.warn('[RBAC] Undecorated handler: ...') |
Permission bootstrap (fresh deploy)
POST /super-admin/permissions/seed (idempotent) materializes the
permission-data.ts catalog:
| Catalog source | Materializes into |
|---|---|
PERMISSIONS[] | permission table rows |
ROLE_PERMISSIONS[] | role_permissions join table rows |
Must run BEFORE any non-SUPER_ADMIN role attempts endpoint access.
Business outcome → Technical mechanism: A receptionist clicks "Book Appointment". The Next.js gateway sends X-Gateway-User-Role: RECEPTIONIST. GatewayAuthGuard builds request.user.role = 'RECEPTIONIST'. The POST /appointments endpoint has @Roles(DOCTOR, NURSE, RECEPTIONIST). RolesGuard finds RECEPTIONIST in requiredRoles, returns true. The same receptionist tries DELETE /clinical-notes/:id which has @Roles(DOCTOR) — RolesGuard returns false → 403.
Implicated Code
ddx-api/src/platform/auth/decorators/roles.decorator.ts:10—ROLES_KEY = 'roles';Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)— single line; usesUserRoleenum from@ddx/prisma-mainddx-api/src/platform/auth/guards/roles.guard.ts:14—RolesGuard.canActivate(): readsROLES_KEYvia Reflector; service account fallback at line 36 (user.isServiceAccountpath); array role check at line 42; single-role fallback at line 47ddx-api/src/platform/common/interceptors/rbac-enforcement.interceptor.ts:46—RbacExempt = () => SetMetadata(RBAC_EXEMPT_KEY, true)— decorator definition lives here (not inauth/decorators/)ddx-api/src/platform/common/interceptors/rbac-enforcement.interceptor.ts:51—RbacEnforcementInterceptor:moderead fromRBAC_ENFORCEMENTenv at line 59; migration plan comment at line 61 referencesCR-RBAC-FLIP; undecorated handler message at line 99ddx-api/src/platform/auth/decorators/public.decorator.ts—@Public()setsIS_PUBLIC_KEY; read by bothGatewayAuthGuard(line 125) andRbacEnforcementInterceptor(line 77)ddx-api/src/platform/common/decorators/platform-scoped.decorator.ts—@PlatformScoped()setsPLATFORM_SCOPED_KEY; read byGatewayAuthGuard(line 160) andTenantIsolationInterceptor(line 65)
Current Security Vulnerability — @RbacExempt on Voice Routes
CRITICAL — flagged in
context/REMINDERS.mdas highest-priority security gap.
Two endpoints currently carry @RbacExempt() without a justifying role guard:
POST /voice/tools/executeGET /voice/config
@RbacExempt() suppresses RbacEnforcementInterceptor — it does NOT bypass GatewayAuthGuard. A caller with any valid X-API-Key (including GATEWAY_API_KEY_SEEDER) can reach these endpoints regardless of their role. Because the voice tool execution endpoint triggers Mastra agent operations, this represents a potential privilege escalation: a seeder or cron job with the gateway key could invoke AI tools at the voice layer.
Required fix: replace @RbacExempt() on both endpoints with @Roles(DOCTOR, NURSE) (or the appropriate role set for voice workflows). Do not start new features before this is remediated.
Detection:
# Find all @RbacExempt usages — each must have a justification comment rg -n '@RbacExempt' ddx-api/src/ --type ts # Current violations (as of 2026-05-18): # ddx-api/src/voice/voice.controller.ts — /voice/tools/execute, /voice/config
This gap is tracked under REMINDERS.md §Critical Pitfalls: [SECURITY] @RbacExempt on /voice/tools/execute and /voice/config.
Operational Notes
Rollout plan for RBAC_ENFORCEMENT=enforce:
- Deploy with
RBAC_ENFORCEMENT=warn— observe logs for[RBAC] Undecorated handler:lines. - For each undecorated handler add exactly one of:
@Public(),@Roles(...),@RequirePermission(...), or@RbacExempt()with a justification comment. - The
RbacEnforcementInterceptorcomment at line 61 (CR-RBAC-FLIP) tracks the 42 controllers identified in the initial dry-run. - Once all handlers are decorated, flip
RBAC_ENFORCEMENT=enforcein.env.
RBAC bootstrap on fresh deploy:
# 1. Seed permissions (idempotent) curl -X POST http://localhost:6100/api/v1/super-admin/permissions/seed \ -H "X-API-Key: $GATEWAY_API_KEY_SEEDER" # 2. Then run essentials + staff phases cd ddx-seeder-ts && pnpm seed --phase essentials cd ddx-seeder-ts && pnpm seed --phase staff
Without step 1, non-SUPER_ADMIN roles cannot authorize any endpoint access because role_permissions table is empty.
@PlatformScoped() usage: apply at class level on controllers managing globally-shared definitions (UserRole, Permission, VaccineSchedule, etc.). Both GatewayAuthGuard and TenantIsolationInterceptor recognize it and skip X-Clinic-ID enforcement for service accounts and SUPER_ADMIN. See ddx-api/CLAUDE.md §RBAC Decorators.
Testing role guards:
# Test as DOCTOR (should succeed on clinical endpoints)
curl -H "X-API-Key: $GATEWAY_API_KEY_NEXTJS" \
-H "X-Clinic-ID: ddx-demo-clinic" \
-H "X-Gateway-User-Role: DOCTOR" \
http://localhost:6100/api/v1/visits
# Test as PATIENT (should 403 on doctor-only endpoints)
curl -H "X-Gateway-User-Role: PATIENT" ...
Related Topics
- auth-jwt.md —
GatewayAuthGuardis the upstream that populatesrequest.user.rolewhichRolesGuardreads - auth-api-token.md —
ApiKeyStrategyinjectsroles: [SUPER_ADMIN]; how that interacts withRolesGuardis described here - tenant-isolation.md — tenant scoping and RBAC share the same
@PlatformScoped()decorator; both interceptors read it - sse-event-engine.md —
SseControlleruses@RbacExempt()— a legitimate use case documented in the SSE page, distinguishable from the voice-route misuse