Tenant Isolation — Interceptor-Based Organization Scoping
Audiences: developer, internal
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 torequest.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 viaOrganizationResolverService). After the interceptor runs, downstream services can safely userequest.organizationId(UUID). - Internal (ops/support): If a request fails with
401 Clinic identification required, theX-Clinic-IDheader is missing or the user is not assigned to any organization.TenantIsolationInterceptorlogs the user email and the fallback attempt atWARNlevel.
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.
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
| Component | Role |
|---|---|
NestInterceptor (NestJS) | Intercepts every request in the middleware-like pipeline |
OrganizationResolverService | Resolves slug ↔ UUID with 5-minute TTL cache (avoids N+1 DB round-trips) |
Reflector | Reads @PlatformScoped() metadata at handler and class level |
@PlatformScoped() decorator | Marks 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 key | Shared 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:35—TenantIsolationInterceptor implements NestInterceptor;isPlatformScopedcheck via Reflector at line 65; resolution priority order at lines 88–90; service accountX-Clinic-IDenforcement at line 93;OrganizationResolverService.resolve()call at line 128; multi-combinationbelongsToCliniccheck at lines 168–173ddx-api/src/platform/common/interceptors/tenant-isolation.interceptor.ts:88— clinic ID resolution priority:headerClinicId || userClinicId; header winsddx-api/src/platform/common/interceptors/tenant-isolation.interceptor.ts:128—resolved.slugnormalizesX-Clinic-IDheader for FHIR downstream at line 132;user.organizationIdupdated to UUID at line 135ddx-api/src/platform/auth/guards/gateway-auth.guard.ts:160— first gate: readsPLATFORM_SCOPED_KEYmetadata;validateClinicId()at line 334 usesisOrganizationIdentifier();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 && !isPlatformScoped→ForbiddenException('X-Clinic-ID header required')ddx-api/src/platform/common/decorators/platform-scoped.decorator.ts—@PlatformScoped()setsPLATFORM_SCOPED_KEY; used by bothGatewayAuthGuardandTenantIsolationInterceptorddx-api/src/platform/tenant.module.ts— module that registersTenantIsolationInterceptorasAPP_INTERCEPTORand exportsOrganizationResolverService
Operational Notes
Critical pitfalls:
X-Clinic-IDmust be the clinic slug, not the UUID in application headers —isOrganizationIdentifier()accepts both formats but the downstream FHIR and MinIO layers expect the slug. AfterTenantIsolationInterceptorresolves,request.organizationIdis the UUID andrequest.organizationSlugis the slug. Use the right one for the right context.- SSE
org:channels must useorganizationSlug—SSEChannels.org(orgSlug)notSSEChannels.org(organizationId). Therequest.organizationSlugset by this interceptor is the correct input. See sse-event-engine.md andddx-api/CLAUDE.md §Forbidden Patterns. - Service accounts always require
X-Clinic-IDunless the endpoint is@PlatformScoped(). Seeders calling clinic-scoped endpoints without the header get401 X-Clinic-ID header is required for API Key authentication. - 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.
- 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 (catchblock at line 138 does not rethrow). Downstream services that needorganizationIdwill receivenulland 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:
# 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.
Related Topics
- auth-jwt.md —
GatewayAuthGuardis the upstream that validatesX-Clinic-IDformat and existence beforeTenantIsolationInterceptorruns - 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-IDrequirements than JWT users - sse-event-engine.md —
org:SSE channels must userequest.organizationSlug(set here) not the UUID