03-security-and-tenantswave: W1filled5 citations

Tenant Isolation — Interceptor-Based Organization Scoping

Audiences: developer, internal

Tenant Isolation — Interceptor-Based Organization Scoping

Every NestJS request is scoped to a single clinic (organization) via the TenantIsolationInterceptor. This interceptor runs fourth in the global pipeline, resolves the clinic identifier to a UUID, attaches it to request.organizationId, and rejects requests where the authenticated user does not belong to the requested clinic.

Business Purpose

Dudoxx HMS hosts multiple independent clinics on one deployment. A doctor at ddx-hamburg-clinic must never see patient records from ddx-berlin-clinic, even if they share the same database server. Tenant isolation is the architectural guarantee that prevents cross-clinic data leakage — it is the multi-tenancy primitive that every domain service (Prisma queries, FHIR operations, MinIO bucket naming, SSE channels) depends on.

The isolation is interceptor-based, not database-level row-security. The interceptor runs early in the request pipeline (step 4 of 9) and attaches the resolved organizationId (UUID) and organizationSlug (clinic slug) to the request object. Domain services receive these via @ClinicId() and @CurrentUser() decorators and use them as where clause filters in Prisma queries and as FHIR partition headers.

Business outcome: A nurse at Hamburg clinic sends GET /patients; the Next.js gateway adds X-Clinic-ID: ddx-hamburg-clinic; TenantIsolationInterceptor resolves the slug to org-uuid-hamburg, sets request.organizationId = "org-uuid-hamburg"; the PatientsService issues prisma.patient.findMany({ where: { organizationId: "org-uuid-hamburg" } }). Berlin patients never appear in the result set.

Audiences

  • Investor: Interceptor-based isolation is a mandatory SaaS multi-tenancy control. It demonstrates architectural maturity required for HIPAA compliance and enterprise clinic procurement — each clinic's data is logically partitioned at the application layer.
  • Clinical buyer: Clinic data is invisible to other clinics. Administrators can onboard staff without risk of cross-clinic data exposure.
  • Developer/partner: Set X-Clinic-ID: <slug> on all calls. The interceptor handles slug-to-UUID resolution (5-minute cache via OrganizationResolverService). After the interceptor runs, downstream services can safely use request.organizationId (UUID).
  • Internal (ops/support): If a request fails with 401 Clinic identification required, the X-Clinic-ID header is missing or the user is not assigned to any organization. TenantIsolationInterceptor logs the user email and the fallback attempt at WARN level.

Architecture

The isolation mechanism has two entry points that work in tandem:

1. GatewayAuthGuard (step 1): validates X-Clinic-ID format (isOrganizationIdentifier — UUID or ddx-*-clinic slug) and checks clinic existence via OrganizationService.getOrganizationId(). It also validates that the authenticated user's organizationId matches the requested clinic (except for SUPER_ADMIN). This is the first gate.

2. TenantIsolationInterceptor (step 4): resolves the final clinicId (header > JWT org > JWT orgs[0]), calls OrganizationResolverService.resolve() to get { id: UUID, slug: string } with a 5-minute cache, sets request.organizationId, request.organizationSlug, and request.clinicId. It also normalizes the X-Clinic-ID header to always be the slug for downstream FHIR operations.

diagram
GatewayAuthGuard (step 1)
  └── validates X-Clinic-ID format + existence
  └── checks user belongs to the clinic
  └── sets request.user { organizationId: slug_or_uuid }

PermissionContextInitializerInterceptor (step 2)
IdNormalizationInterceptor (step 3)

TenantIsolationInterceptor (step 4)   ← PRIMARY ISOLATION POINT
  └── resolves clinicId priority:
        1. X-Clinic-ID header (explicit)
        2. user.organizationId (from JWT payload)
        3. user.organizationIds[0] (from strategy enrichment)
  └── OrganizationResolverService.resolve(clinicId) → { id, slug }  [5min cache]
  └── sets request.organizationId = UUID
  └── sets request.organizationSlug = slug
  └── normalizes X-Clinic-ID header to slug (for FHIR downstream)
  └── validates user belongs to resolved org (UUID + slug cross-match)

The architecture snapshot is in coding_context/ddx-hms-context.md. RBAC guards that use tenant context are documented in rbac-roles.md.

Tech Stack & Choices

ComponentRole
NestInterceptor (NestJS)Intercepts every request in the middleware-like pipeline
OrganizationResolverServiceResolves slug ↔ UUID with 5-minute TTL cache (avoids N+1 DB round-trips)
ReflectorReads @PlatformScoped() metadata at handler and class level
@PlatformScoped() decoratorMarks controllers that manage globally-shared definitions (no clinic context)
isOrganizationIdentifier() (id-utils.ts)Validates X-Clinic-ID is UUID or ddx-*-clinic slug format
PLATFORM_SCOPED_KEY metadata keyShared between GatewayAuthGuard and TenantIsolationInterceptor

Why interceptor, not middleware: NestJS interceptors run after guards and have access to the Reflector (for @PlatformScoped() metadata). Middleware runs before guards and cannot read decorator metadata. The isolation check needs request.user (set by GatewayAuthGuard) to validate clinic membership.

Why slug as the canonical X-Clinic-ID value: slugs are URL-safe, stable across UUID migrations, and directly usable as FHIR partition headers and MinIO bucket prefixes (e.g. ddx-hamburg-clinic-documents). UUIDs are used for Prisma where clauses only (after resolution).

Why 5-minute cache in OrganizationResolverService: organization records change infrequently. Caching prevents a database lookup on every request for high-traffic clinical endpoints.

platform sentinel: X-Clinic-ID: platform (or absence of X-Clinic-ID on @PlatformScoped() endpoints) sets request.organizationId = null and request.clinicId = 'platform'. This is the context for SUPER_ADMIN operations and seeder bootstrap phases.

Data Flow

Standard clinic request (JWT user)

Browser → Next.js (validates JWT, extracts user.organizationId = "ddx-hamburg-clinic")
  → NestJS: X-API-Key + X-Clinic-ID: ddx-hamburg-clinic + X-Gateway-User-ID: <uuid>

TenantIsolationInterceptor.intercept():
  1. isPublic? → skip (this request is not @Public)
  2. isPlatformScoped? → no
  3. headerClinicId = "ddx-hamburg-clinic"
  4. userClinicId   = "ddx-hamburg-clinic" (from request.user.organizationId)
  5. clinicId = headerClinicId (header takes precedence)
  6. request.clinicId = "ddx-hamburg-clinic"
  7. OrganizationResolverService.resolve("ddx-hamburg-clinic")
       → { id: "org-uuid-hamburg", slug: "ddx-hamburg-clinic" }   [5min cache]
  8. request.organizationId   = "org-uuid-hamburg"
  9. request.organizationSlug = "ddx-hamburg-clinic"
  10. X-Clinic-ID header normalized to "ddx-hamburg-clinic" (slug)
  11. user.organizationId = "org-uuid-hamburg" (updated to UUID for downstream)
  12. belongsToClinic check:
       userOrganizationIds.includes("ddx-hamburg-clinic") OR
       userOrganizationIds.includes("org-uuid-hamburg") → true
  13. return next.handle()

Service account request (API key, no user JWT)

Seeder → NestJS: X-API-Key: <seeder-key> + X-Clinic-ID: ddx-hamburg-clinic

TenantIsolationInterceptor.intercept():
  1. user.isServiceAccount === true
  2. isPlatformScoped? → no
  3. headerClinicId = "ddx-hamburg-clinic"  (REQUIRED for service accounts)
  4. isServiceAccount && !headerClinicId → throw 401 (enforced)
  5. resolve slug → UUID (same as above)
  6. isServiceAccount → return next.handle() (no membership check for service accounts)

Platform-scoped request (SUPER_ADMIN or seeder, no clinic context)

Seeder → NestJS: X-API-Key: <seeder-key>  (no X-Clinic-ID)
  endpoint: @PlatformScoped() class

TenantIsolationInterceptor.intercept():
  1. isPlatformScoped && (isServiceAccount || isSuperAdmin) → true
  2. request.clinicId = 'platform'
  3. request.organizationId = null
  4. request.organizationSlug = 'platform'
  5. return next.handle()

Business outcome → Technical mechanism: A seeder running the init phase calls POST /super-admin/permissions/seed which is on a @PlatformScoped() controller. No clinic exists yet — so there is no X-Clinic-ID to provide. TenantIsolationInterceptor recognizes the isServiceAccount && isPlatformScoped combination, sets organizationId = null, and lets the seeder proceed. Without this, the seeder would deadlock trying to resolve a clinic that hasn't been created yet.

Implicated Code

  • ddx-api/src/platform/common/interceptors/tenant-isolation.interceptor.ts:35TenantIsolationInterceptor implements NestInterceptor; isPlatformScoped check via Reflector at line 65; resolution priority order at lines 88–90; service account X-Clinic-ID enforcement at line 93; OrganizationResolverService.resolve() call at line 128; multi-combination belongsToClinic check at lines 168–173
  • ddx-api/src/platform/common/interceptors/tenant-isolation.interceptor.ts:88 — clinic ID resolution priority: headerClinicId || userClinicId; header wins
  • ddx-api/src/platform/common/interceptors/tenant-isolation.interceptor.ts:128resolved.slug normalizes X-Clinic-ID header for FHIR downstream at line 132; user.organizationId updated to UUID at line 135
  • ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:160 — first gate: reads PLATFORM_SCOPED_KEY metadata; validateClinicId() at line 334 uses isOrganizationIdentifier(); validateUserClinicAccess() at line 374 checks UUID equality (resolves both sides before compare)
  • ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:167!clinicId && !isSuperAdminEndpoint && !isPlatformScopedForbiddenException('X-Clinic-ID header required')
  • ddx-api/src/platform/common/decorators/platform-scoped.decorator.ts@PlatformScoped() sets PLATFORM_SCOPED_KEY; used by both GatewayAuthGuard and TenantIsolationInterceptor
  • ddx-api/src/platform/tenant.module.ts — module that registers TenantIsolationInterceptor as APP_INTERCEPTOR and exports OrganizationResolverService

Operational Notes

Critical pitfalls:

  1. X-Clinic-ID must be the clinic slug, not the UUID in application headers — isOrganizationIdentifier() accepts both formats but the downstream FHIR and MinIO layers expect the slug. After TenantIsolationInterceptor resolves, request.organizationId is the UUID and request.organizationSlug is the slug. Use the right one for the right context.
  2. SSE org: channels must use organizationSlugSSEChannels.org(orgSlug) not SSEChannels.org(organizationId). The request.organizationSlug set by this interceptor is the correct input. See sse-event-engine.md and ddx-api/CLAUDE.md §Forbidden Patterns.
  3. Service accounts always require X-Clinic-ID unless the endpoint is @PlatformScoped(). Seeders calling clinic-scoped endpoints without the header get 401 X-Clinic-ID header is required for API Key authentication.
  4. OrganizationResolverService cache is process-local — on multi-replica deploys, each pod has its own in-memory cache. A slug change (rare) requires pod restarts to propagate. This is a known limitation, not a bug.
  5. Resolution failure is non-fatal — if OrganizationResolverService.resolve() throws (e.g. clinic deleted mid-request), the interceptor logs a warning but does NOT block the request (catch block at line 138 does not rethrow). Downstream services that need organizationId will receive null and must handle accordingly. This is a deliberate tradeoff to avoid cascading failures on transient DB errors.

Env vars / configuration: tenant isolation has no dedicated env vars — it depends on GatewayAuthGuard's X-API-Key and X-Clinic-ID configuration. The cache TTL is hardcoded at 5 minutes in OrganizationResolverService.

Validation commands:

bash
# Verify tenant isolation: a user from clinic A cannot access clinic B
curl -H "X-API-Key: $GATEWAY_API_KEY_NEXTJS" \
     -H "X-Clinic-ID: ddx-berlin-clinic" \
     -H "X-Gateway-User-ID: <hamburg-user-uuid>" \
     -H "X-Gateway-User-Role: DOCTOR" \
     http://localhost:6100/api/v1/patients
# Expected: 401 "You do not have access to clinic: ddx-berlin-clinic"

# Verify platform-scoped bypass (seeder init phase)
curl -X POST http://localhost:6100/api/v1/super-admin/permissions/seed \
     -H "X-API-Key: $GATEWAY_API_KEY_SEEDER"
# Expected: 200 (no X-Clinic-ID needed, @PlatformScoped() controller)

Tenant creation: only via POST /super-admin/tenants — this endpoint creates the HAPI FHIR partition, FHIR Organization resource, and MinIO buckets atomically. Never use POST /organizations directly. Full workflow: ddx-api/docs/TENANT_CREATION.md.

Memory shard reference: ~/.claude/projects/.../memory/project_tenant_isolation_enhancement.md documents the interceptor-based scoping approach adopted in the 2026-04 enhancement cycle.

  • auth-jwt.mdGatewayAuthGuard is the upstream that validates X-Clinic-ID format and existence before TenantIsolationInterceptor runs
  • rbac-roles.md@PlatformScoped() is shared between RBAC and tenant isolation; documented in rbac-roles for decorator inventory
  • auth-api-token.md — service accounts (API key auth) face stricter X-Clinic-ID requirements than JWT users
  • sse-event-engine.mdorg: SSE channels must use request.organizationSlug (set here) not the UUID
    Tenant Isolation — Interceptor-Based Organization Scoping — Dudoxx Docs | Dudoxx