03-security-and-tenantswave: W1filled6 citations

RBAC — Role-Based Access Control

Audiences: developer, internal

RBAC — Role-Based Access Control

Dudoxx HMS enforces role-based access using a layered system of guards, interceptors, and decorators. The global GatewayAuthGuard identifies the caller; RolesGuard checks their role against endpoint requirements; RbacEnforcementInterceptor detects 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=enforce flips the RbacEnforcementInterceptor from warn to 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):

diagram
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

ComponentFileRole
RolesGuardguards/roles.guard.tsPer-route role check via @Roles() decorator
RbacEnforcementInterceptorcommon/interceptors/rbac-enforcement.interceptor.tsGlobal: detects undecorated handlers
@Roles(...)decorators/roles.decorator.tsSets ROLES_KEY metadata
@Public()decorators/public.decorator.tsSets IS_PUBLIC_KEY; bypasses GatewayAuthGuard
@RbacExempt()defined in rbac-enforcement.interceptor.ts:46Suppresses RbacEnforcementInterceptor; JWT still required
@PlatformScoped()common/decorators/platform-scoped.decorator.tsBypasses X-Clinic-ID requirement for shared definitions
@RequireAdminRole()auth decoratorsShorthand for @Roles(SUPER_ADMIN, CLINIC_ADMIN)
@RequireClinicStaff()auth decoratorsAll clinic roles
@RequireMedicalStaff()auth decoratorsDOCTOR | NURSE
RBAC_ENFORCEMENT env varrbac-enforcement.interceptor.ts:59code 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

diagram
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)

diagram
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)

diagram
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:10ROLES_KEY = 'roles'; Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles) — single line; uses UserRole enum from @ddx/prisma-main
  • ddx-api/src/platform/auth/guards/roles.guard.ts:14RolesGuard.canActivate(): reads ROLES_KEY via Reflector; service account fallback at line 36 (user.isServiceAccount path); array role check at line 42; single-role fallback at line 47
  • ddx-api/src/platform/common/interceptors/rbac-enforcement.interceptor.ts:46RbacExempt = () => SetMetadata(RBAC_EXEMPT_KEY, true) — decorator definition lives here (not in auth/decorators/)
  • ddx-api/src/platform/common/interceptors/rbac-enforcement.interceptor.ts:51RbacEnforcementInterceptor: mode read from RBAC_ENFORCEMENT env at line 59; migration plan comment at line 61 references CR-RBAC-FLIP; undecorated handler message at line 99
  • ddx-api/src/platform/auth/decorators/public.decorator.ts@Public() sets IS_PUBLIC_KEY; read by both GatewayAuthGuard (line 125) and RbacEnforcementInterceptor (line 77)
  • ddx-api/src/platform/common/decorators/platform-scoped.decorator.ts@PlatformScoped() sets PLATFORM_SCOPED_KEY; read by GatewayAuthGuard (line 160) and TenantIsolationInterceptor (line 65)

Current Security Vulnerability — @RbacExempt on Voice Routes

CRITICAL — flagged in context/REMINDERS.md as highest-priority security gap.

Two endpoints currently carry @RbacExempt() without a justifying role guard:

  • POST /voice/tools/execute
  • GET /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:

bash
# 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:

  1. Deploy with RBAC_ENFORCEMENT=warn — observe logs for [RBAC] Undecorated handler: lines.
  2. For each undecorated handler add exactly one of: @Public(), @Roles(...), @RequirePermission(...), or @RbacExempt() with a justification comment.
  3. The RbacEnforcementInterceptor comment at line 61 (CR-RBAC-FLIP) tracks the 42 controllers identified in the initial dry-run.
  4. Once all handlers are decorated, flip RBAC_ENFORCEMENT=enforce in .env.

RBAC bootstrap on fresh deploy:

bash
# 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:

bash
# 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" ...
  • auth-jwt.mdGatewayAuthGuard is the upstream that populates request.user.role which RolesGuard reads
  • auth-api-token.mdApiKeyStrategy injects roles: [SUPER_ADMIN]; how that interacts with RolesGuard is described here
  • tenant-isolation.md — tenant scoping and RBAC share the same @PlatformScoped() decorator; both interceptors read it
  • sse-event-engine.mdSseController uses @RbacExempt() — a legitimate use case documented in the SSE page, distinguishable from the voice-route misuse
    RBAC — Role-Based Access Control — Dudoxx Docs | Dudoxx