Tenant Provisioner — New Clinic Onboarding
Audiences: internal, developer
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 (
organizationIdscoping), 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(requiresSUPER_ADMINrole + service accountX-API-Key). The endpoint is orchestrated byTenantPartitionService; 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
| Concern | Choice | Rationale |
|---|---|---|
| Prisma DB | ddx_api_main Organization model | Central registry; slug is the routing key for all downstream systems |
| FHIR partition | HAPI 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 S3Client | Same S3 client used throughout HMS; forcePathStyle: true for MinIO compatibility |
| Bucket isolation | App-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). |
| OnApplicationBootstrap | ensureAllTenantBuckets() | Boot-time idempotency guard — partial provisioning from failed deploys is self-healed on restart |
| Slug generation | Derived from org name | Used as bucket prefix, FHIR X-Clinic-ID header, and cache key across all services |
Data Flow
- Ops admin calls
POST /super-admin/tenantswithSUPER_ADMINJWT + serviceX-API-Key. TenantCrudService.createTenant()insertsOrganizationrow withisActive=false.TenantPartitionServicecalls HAPI FHIR Admin API → gets backpartitionIdinteger.- HAPI FHIR Admin API auto-creates the
OrganizationFHIR resource in the new partition. fhirPartitionIdandfhirOrganizationIdare written back to the Prisma row.- S3Client provisions 5 MinIO buckets named
{slug}-{type}; private buckets get no policy (isolation is app-layer via presigned URLs + root creds), and{slug}-brandinggets a public-read policy. Organization.isActiveis set totrue— tenant is now live.- Seeder
essentialsphase (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:29—TenantPartitionService;OnApplicationBootstrapat:89;ensureAllTenantBuckets()at:94; S3Client init at:69; HAPI FHIR partition provisioning inprovisionFhirPartition()ddx-api/src/organization/super-admin/services/tenant/tenant-crud.service.ts:73—TenantCrudService;CreateTenantDtoat:17;TenantDtoshape withfhirPartitionIdat:52ddx-api/src/organization/super-admin/services/tenant/tenant-user.service.ts:37—TenantUserService; DDXBot system user creation per tenant on bootstrapddx-api/src/organization/super-admin/services/tenant/tenant-configuration.service.ts:27—TenantConfigurationService; post-provision org settings defaultsddx-api/src/organization/tenant-provisioner/templates/tenant.env.tpl— environment variable template for new tenant configuration
Operational Notes
- Forbidden pattern:
POST /organizationsdoes NOT provision FHIR partitions or MinIO buckets. It is explicitly prohibited. Always usePOST /super-admin/tenants. Seeddx-api/CLAUDE.mdForbidden Patterns table. - HAPI FHIR required:
HAPI_FHIR_API_TOKENenv var is validated in theTenantPartitionServiceconstructor — the service throwsInternalServerErrorExceptionat boot if missing. HAPI must be reachable atHAPI_FHIR_BASE_URL(defaulthttp://localhost:8080/fhir). - MinIO credentials:
MINIO_ACCESS_KEY,MINIO_SECRET_KEY,MINIO_ENDPOINT,MINIO_REGIONenv vars drive the S3Client. - Boot-time bucket check is fire-and-forget: failures log
WARNbut do not block startup. Monitor logs forBoot MinIO: failed for tenant {slug}after deploys. - Seeder integration:
ddx-seeder-tsEssentialsSeedercallsPOST /bootstrap/tenants(the same endpoint) for each organization indata/00-init/organizations.json. This is the canonical path for CI environment seeding. - Slug uniqueness: Slugs are the routing key for
X-Clinic-IDheaders, MinIO bucket prefixes, and FHIR partition cache. Slug collisions silently corrupt routing — validate uniqueness before provisioning.
Related Topics
- Organization Management — day-to-day org settings, departments, and locations post-provisioning
- FHIR Partitions & Tenancy — HAPI FHIR partition model; partition integer assigned during provisioning
- Seeder — 19 Phases —
essentialsseeder phase uses the bootstrap endpoint for automated environment setup