Skip to content

Onboarding an isolated tenant

This runbook is the end-to-end procedure for giving one organisation its own dedicated clinical database, S3 bucket, and KMS key. It covers all three Organisation columns that control isolation: clinical_db_connection_ref, clinical_storage_bucket, and clinical_kms_key_arn.

All three are nullable. NULL in any column means "use the global shared resource" for that dimension. You can onboard each dimension independently — for example, a dedicated DB without a dedicated bucket — or set all three at once.

Changes apply within 30 seconds of updating the Organisation columns; no service restart is needed.

Prerequisites

  • Direct write access to the platform database (Organisation table), or a member of the team who has.
  • AWS IAM permissions to create RDS instances, S3 buckets, and KMS CMKs in the target account/region.
  • The clinical_db_connection_ref you will use — a short, lowercase, hyphenated token that identifies the org's DB, e.g. acme-eu-west-1. This token must be unique across all isolated tenants.

Step 1 — Provision and configure the per-org clinical database

  1. Create the database. Spin up a MySQL instance for the tenant in the target account/region. Record the connection URL (e.g. mysql://user:pass@acme-mysql.example.com:3306/clinical).

  2. Run the Prisma migration against it.

DATABASE_URL="mysql://user:pass@acme-mysql.example.com:3306/clinical" npx prisma migrate deploy --schema services/clinical-api/prisma/schema.prisma

This applies all clinical-domain migrations from services/clinical-api/prisma/migrations/ to the new database. Do not use prisma migrate dev. See database-migration.md for general migration guidance.

  1. Set the env var on every clinical-api instance.

Derive the env-var name from the ref token by uppercasing it and replacing hyphens with underscores:

Ref token Env var
acme-eu-west-1 CLINICAL_DB_URL_ACME_EU_WEST_1
beta-us-east-1 CLINICAL_DB_URL_BETA_US_EAST_1

Set the env var to the connection URL recorded above. Apply the change to every running clinical-api instance (ECS task definition revision, Kubernetes ConfigMap/Secret, etc.) and redeploy.

  1. Set Organisation.clinical_db_connection_ref.
UPDATE organisation
SET    clinical_db_connection_ref = 'acme-eu-west-1'
WHERE  id = '<org-uuid>';

Within 30 seconds the ClinicalConnectionRegistry refresh cycle will pick up the new ref, open a dedicated PrismaClient pool for it, and ClinicalPrismaResolver.forOrg('<org-uuid>') will route to the isolated DB.

If the env var is missing: the registry logs a warning and the resolver falls back to the shared client — the tenant's data does not disappear; it is just served from shared until the var is added. Fix the env var and wait one refresh cycle; no manual intervention in the DB is needed.


Step 2 — Provision the per-org S3 bucket

  1. Create the bucket in the same region as the org's clinical database.
  2. Enable versioning (recommended for accidental-delete recovery).
  3. Block all public access.

  4. Set the bucket's default encryption to SSE-KMS using the org's CMK (created in Step 3). If you are provisioning the bucket and key together, set the default encryption after the key exists:

  5. In the S3 console: Bucket → Properties → Default encryption → SSE-KMS → Choose a key → <CMK ARN>.
  6. Via AWS CLI:

    aws s3api put-bucket-encryption \
      --bucket acme-clinical-images \
      --server-side-encryption-configuration '{
        "Rules": [{
          "ApplyServerSideEncryptionByDefault": {
            "SSEAlgorithm": "aws:kms",
            "KMSMasterKeyID": "<CMK_ARN>"
          },
          "BucketKeyEnabled": true
        }]
      }'
    
  7. Grant clinical-api's IAM role access to the bucket (if bucket policy is enforced):

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::<ACCOUNT_ID>:role/clinical-api-task-role" },
  "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
  "Resource": ["arn:aws:s3:::acme-clinical-images", "arn:aws:s3:::acme-clinical-images/*"]
}
  1. Set Organisation.clinical_storage_bucket.
UPDATE organisation
SET    clinical_storage_bucket = 'acme-clinical-images'
WHERE  id = '<org-uuid>';

Within 30 seconds OrgStorageRegistry will start routing this org's new image and histology uploads to the dedicated bucket. Read paths (presigned GET URLs) always use the bucket stored on each Image/HistologyReport row, so existing objects remain accessible from wherever they were originally uploaded.


Step 3 — Provision the per-org KMS key

  1. Create a symmetric CMK in the same region as the bucket.
  2. Key type: Symmetric
  3. Key usage: Encrypt and decrypt
  4. Key alias: alias/clinical-<org-slug> (e.g. alias/clinical-acme-eu-west-1)

  5. Set the key policy so that clinical-api's IAM role can encrypt and decrypt with it:

{
  "Sid": "AllowClinicalApi",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::<ACCOUNT_ID>:role/clinical-api-task-role"
  },
  "Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
  "Resource": "*"
}

Dual-key requirement: after steps 3.2 and 3.3, clinical-api's IAM role must hold kms:Decrypt on both the new per-org key (so new DEKs can be unwrapped) and the previous/global key (so existing DEKs wrapped before this onboarding stay decryptable — DEK migration is lazy; there is no re-wrap job).

  1. Retain kms:Decrypt on the previous (global or org-level) key.

Existing DEKs for this org's patients are wrapped with the key that was in use before this change (typically the global KMS_CMK_ARN). The system uses lazy migration: existing DEKs are NOT re-wrapped; they keep decrypting with the old key. New DEKs use the org's new key. For this to work, clinical-api's IAM role must keep kms:Decrypt permission on the old key indefinitely (or until you have explicitly re-wrapped all existing DEKs, which is a separate out-of-scope operation).

Verify the IAM role's policy still includes the old key:

{
  "Sid": "AllowDecryptLegacyKey",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::<ACCOUNT_ID>:role/clinical-api-task-role" },
  "Action": "kms:Decrypt",
  "Resource": "arn:aws:kms:<REGION>:<ACCOUNT_ID>:key/<PREVIOUS_KEY_ID>"
}
  1. Set Organisation.clinical_kms_key_arn.
UPDATE organisation
SET    clinical_kms_key_arn = 'arn:aws:kms:<REGION>:<ACCOUNT_ID>:key/<KEY_ID>'
WHERE  id = '<org-uuid>';

Within 30 seconds OrgStorageRegistry will make KeyProviderResolver use the org's CMK to wrap all new DEKs for this tenant. Existing DEKs (scheme-tagged kms: with the old ARN, or legacy un-prefixed local: DEKs) continue to be unwrapped transparently — the scheme tag tells the resolver which provider to use, and AWS Decrypt is self-describing for symmetric CMKs.


Step 4 — Verify

After all three columns are set, confirm the following within 60 seconds (two refresh cycles):

Check How
New case uploads to the per-org bucket Create a test case and verify the Image.s3Bucket column matches the per-org bucket name.
Isolated DB receives clinical writes Run a SELECT COUNT(*) on both the shared DB and the isolated DB after creating the test case. Count should increment on the isolated DB only.
Admin endpoints see the isolated tenant Call GET /admin/cases?org_id=<org-uuid> and GET /admin/patients?org_id=<org-uuid> — rows should be returned from the isolated DB.
No errors in clinical-api logs Watch for ClinicalConnectionRegistry, OrgStorageRegistry, or KeyProviderResolver error lines.

Step 5 — Existing data is NOT migrated automatically

Setting the three Organisation columns affects new writes only. Any rows already on the shared clinical DB, any objects already in the global bucket, and any existing DEKs remain where they are. The reads still work (existing Image.s3Bucket / HistologyReport.s3Bucket values point to the old bucket; DekResolver follows the scheme tag or falls back to the global key). Backfilling an existing tenant's data into isolated infrastructure is a separate, planned operation that is out of scope for this runbook.


Rollback

To revert the tenant to shared infrastructure, clear the relevant columns and wait one refresh cycle (≤ 30 seconds):

UPDATE organisation
SET    clinical_db_connection_ref = NULL,
       clinical_storage_bucket    = NULL,
       clinical_kms_key_arn       = NULL
WHERE  id = '<org-uuid>';

You can also use FEATURE_PER_ORG_CLINICAL_DB=false as a global kill-switch that collapses all routing back to the shared DB and global bucket/key regardless of column values — see deployment-rollback.md for how to deploy an env-var change.

After rollback, the tenant's isolated DB data is still intact; if you re-set the column later the data is reachable again.