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):
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)
NOTE: the ddx-docker-full-hms compose 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.
Per-route (applied where needed):
RolesGuard ─── reads @Roles() metadata, checks request.user.roles[]
DynamicPermissionGuard ─── permission-level check (blocks wildcard '*' on mutations)
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
request arrives (user already on request.user from GatewayAuthGuard)
└── RolesGuard.canActivate()
├── reflector.getAllAndOverride(ROLES_KEY, [handler, class])
│ → requiredRoles[] (from @Roles(...) decorator)
├── if requiredRoles is empty: return true (no restriction)
├── if user.isServiceAccount:
│ return requiredRoles.some(r => user.roles.includes(r))
├── if user.roles[] (array): return requiredRoles.some(r => user.roles.includes(r))
└── fallback: return requiredRoles.includes(user.role) (single-role path)
RBAC enforcement interceptor (global, runs on every request)
RbacEnforcementInterceptor.intercept()
├── checks IS_PUBLIC_KEY (from @Public())
├── checks ROLES_KEY (from @Roles())
├── checks PERMISSION_KEY (from @RequirePermission())
└── checks RBAC_EXEMPT_KEY (from @RbacExempt())
→ isDecorated = any of the above is truthy
→ if !isDecorated AND mode='enforce': throw ForbiddenException(403)
→ if !isDecorated AND mode='warn': logger.warn('[RBAC] Undecorated handler: ...')
Permission bootstrap (fresh deploy)
POST /super-admin/permissions/seed (idempotent)
└── materializes permission-data.ts catalog:
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