11-registration-and-onboardingwave: W6filled4 citations

Tenant Provisioner — New Clinic Onboarding

Audiences: internal, developer

Tenant Provisioner — New Clinic Onboarding

Provisioning a new clinic in Dudoxx HMS is a single transactional operation that atomically creates a Prisma organization record, a HAPI FHIR partition, and five MinIO buckets — the only way to onboard a tenant and the only path that guarantees all three data stores remain in sync.

Business Purpose

A healthcare SaaS platform selling to multiple clinics must guarantee that patient data never leaks between tenants and that every new tenant arrives with a fully provisioned environment on day one. The tenant provisioner is the single point of truth for clinic creation: it creates the Postgres record, provisions the FHIR partition on HAPI (so FHIR resources are stored in the correct shard), and creates all five MinIO buckets (attachments, documents, recordings, reports, branding) — in that order. Skipping any step leaves the tenant in a partially-provisioned state that causes silent data-routing bugs, which is why POST /organizations is explicitly forbidden and only POST /super-admin/tenants is permitted.

Audiences

  • Investor: Multi-tenancy is a prerequisite for a per-seat SaaS revenue model. Each clinic is fully isolated at the data layer — Postgres (organizationId scoping), FHIR (partition), and MinIO (bucket prefix by slug). This architecture supports hundreds of concurrent clinics.
  • Clinical buyer (doctor/nurse/receptionist): The provisioning is invisible to clinical staff — they arrive to a fully configured tenant with roles, department structure, and storage ready. Their data is isolated from all other clinics.
  • Developer/partner: The only safe entry point is POST /super-admin/tenants (requires SUPER_ADMIN role + service account X-API-Key). The endpoint is orchestrated by TenantPartitionService; never call sub-services directly.
  • Internal (ops/support): TenantPartitionService.onApplicationBootstrap() runs a boot-time idempotency check — it ensures all active tenants already have their 5 MinIO buckets, recovering from any partial-provisioning failures on restarts.

Architecture

The provisioner is split across ddx-api/src/organization/super-admin/ into five focused services under services/tenant/:

POST /super-admin/tenants
  → SuperAdminController
      → TenantPartitionService.createTenantWithPartition()

  Step 1: TenantCrudService.createTenant()
          → prisma.organization.create() in ddx_api_main
          → Generate slug from name (if not provided)

  Step 2: TenantPartitionService.provisionFhirPartition()
          → POST /fhir-server/admin/partitions (HAPI FHIR Admin API)
          → Registers org as FHIR Organization resource
          → Stores fhirPartitionId + fhirOrganizationId on Prisma record

  Step 3: TenantPartitionService.provisionMinioBuckets()
          → S3Client.CreateBucketCommand × 5
          → Buckets: {slug}-attachments, {slug}-documents, {slug}-recordings,
                     {slug}-reports, {slug}-branding
          → private buckets get NO bucket policy (MinIO rejects the AWS-only
            aws:PrincipalTag condition key); isolation is app-layer.
            {slug}-branding alone gets a public-read policy (logos in <img>).

  Step 4: TenantCrudService.activateTenant()
          → prisma.organization.update({ isActive: true })

Boot-time safety net (OnApplicationBootstrap):
  TenantPartitionService.ensureAllTenantBuckets()
  → Queries all active orgs, provisions any missing buckets (idempotent)
  TenantPartitionService.ensureAllTenantBotUsers()
  → Ensures every tenant has a DDXBot system user

The TenantPartitionService implements OnApplicationBootstrap (not OnModuleInit) so provisioning runs after all NestJS modules are fully wired but before app.listen() accepts traffic. Both boot tasks are fire-and-forget — failures are logged but never crash the server.

TenantCrudService holds the TenantDto shape including fhirPartitionId and fhirOrganizationId, proving FHIR integration is mandatory, not optional.

Tech Stack & Choices

ConcernChoiceRationale
Prisma DBddx_api_main Organization modelCentral registry; slug is the routing key for all downstream systems
FHIR partitionHAPI FHIR Admin REST API (/fhir-server/admin/partitions)Native HAPI multi-tenancy; partition integer maps 1:1 to clinic slug
MinIO buckets@aws-sdk/client-s3 S3ClientSame S3 client used throughout HMS; forcePathStyle: true for MinIO compatibility
Bucket isolationApp-layer (no per-bucket policy)Private buckets carry NO policy — MinIO rejects the AWS-only aws:PrincipalTag condition, and buckets are owner-only by default. ddx-api holds root MinIO creds and serves files via short-lived presigned URLs after the RBAC + tenant check. The {slug}-branding bucket is the sole exception (public-read for <img> logos).
OnApplicationBootstrapensureAllTenantBuckets()Boot-time idempotency guard — partial provisioning from failed deploys is self-healed on restart
Slug generationDerived from org nameUsed as bucket prefix, FHIR X-Clinic-ID header, and cache key across all services

Data Flow

  1. Ops admin calls POST /super-admin/tenants with SUPER_ADMIN JWT + service X-API-Key.
  2. TenantCrudService.createTenant() inserts Organization row with isActive=false.
  3. TenantPartitionService calls HAPI FHIR Admin API → gets back partitionId integer.
  4. HAPI FHIR Admin API auto-creates the Organization FHIR resource in the new partition.
  5. fhirPartitionId and fhirOrganizationId are written back to the Prisma row.
  6. S3Client provisions 5 MinIO buckets named {slug}-{type}; private buckets get no policy (isolation is app-layer via presigned URLs + root creds), and {slug}-branding gets a public-read policy.
  7. Organization.isActive is set to true — tenant is now live.
  8. Seeder essentials phase (POST /bootstrap/tenants) calls this same pipeline for automated provisioning during environment setup.

On every NestJS restart, ensureAllTenantBuckets() queries organization WHERE isActive=true AND slug IS NOT NULL and calls provisionMinioBucketWithStats() for each — HeadBucketCommand checks existence first; CreateBucketCommand only fires if the bucket is missing.

Implicated Code

  • ddx-api/src/organization/super-admin/services/tenant/tenant-partition.service.ts:29TenantPartitionService; OnApplicationBootstrap at :89; ensureAllTenantBuckets() at :94; S3Client init at :69; HAPI FHIR partition provisioning in provisionFhirPartition()
  • ddx-api/src/organization/super-admin/services/tenant/tenant-crud.service.ts:73TenantCrudService; CreateTenantDto at :17; TenantDto shape with fhirPartitionId at :52
  • ddx-api/src/organization/super-admin/services/tenant/tenant-user.service.ts:37TenantUserService; DDXBot system user creation per tenant on bootstrap
  • ddx-api/src/organization/super-admin/services/tenant/tenant-configuration.service.ts:27TenantConfigurationService; post-provision org settings defaults
  • ddx-api/src/organization/tenant-provisioner/templates/tenant.env.tpl — environment variable template for new tenant configuration

Operational Notes

  • Forbidden pattern: POST /organizations does NOT provision FHIR partitions or MinIO buckets. It is explicitly prohibited. Always use POST /super-admin/tenants. See ddx-api/CLAUDE.md Forbidden Patterns table.
  • HAPI FHIR required: HAPI_FHIR_API_TOKEN env var is validated in the TenantPartitionService constructor — the service throws InternalServerErrorException at boot if missing. HAPI must be reachable at HAPI_FHIR_BASE_URL (default http://localhost:8080/fhir).
  • MinIO credentials: MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_ENDPOINT, MINIO_REGION env vars drive the S3Client.
  • Boot-time bucket check is fire-and-forget: failures log WARN but do not block startup. Monitor logs for Boot MinIO: failed for tenant {slug} after deploys.
  • Seeder integration: ddx-seeder-ts EssentialsSeeder calls POST /bootstrap/tenants (the same endpoint) for each organization in data/00-init/organizations.json. This is the canonical path for CI environment seeding.
  • Slug uniqueness: Slugs are the routing key for X-Clinic-ID headers, MinIO bucket prefixes, and FHIR partition cache. Slug collisions silently corrupt routing — validate uniqueness before provisioning.