10

Security and tenancy

Executive summary

Tenant isolation is enforced by PostgreSQL itself: every tenant table forces row-level security keyed on a clinic id that is set per transaction from the authenticated session, never from the request. Static lints prove every table is covered and every route uses a scoped connection, and an adversarial cross-tenant suite runs in CI on every change. Around that sit layered controls — signed short-lived tokens, MFA, a strict content security policy, upload scanning, a web application firewall — and a written STRIDE threat model with a residual-risk register.

Tenant isolation

  • Authenticated session
    clinic id from server context
  • Scoped transaction
    SET LOCAL clinic id
  • FORCE ROW LEVEL SECURITY
    policy on every tenant table
  • Explicit clinic predicates
    joint ownership checks
  • Static lints
    RLS coverage · scoped access
  • Adversarial CI suite
    cross-tenant CRUD denied
Connections (2)
  • Authenticated session → Scoped transaction
  • Scoped transaction → FORCE ROW LEVEL SECURITY
Three independent layers. Any one of them failing alone does not expose another clinic’s rows.
ALTER TABLE <tenant_table> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <tenant_table> FORCE ROW LEVEL SECURITY;

-- one policy per command
CREATE POLICY tenant_isolation_select ON <tenant_table> FOR SELECT USING (clinic_id = current_clinic_id());
CREATE POLICY tenant_isolation_insert ON <tenant_table> FOR INSERT WITH CHECK (clinic_id = current_clinic_id());
CREATE POLICY tenant_isolation_update ON <tenant_table> FOR UPDATE
  USING (clinic_id = current_clinic_id()) WITH CHECK (clinic_id = current_clinic_id());
CREATE POLICY tenant_isolation_delete ON <tenant_table> FOR DELETE USING (clinic_id = current_clinic_id());
The shape of every tenant policy. The clinic id is set with SET LOCAL inside the transaction, so it cannot leak across pooled connections.
  • Clinic id comes from the server
    How it is enforced
    Derived from the authenticated session, never from request data (TENANT-01)
  • Every transaction is scoped
    How it is enforced
    withClinicScope sets the clinic id transaction-locally; cross-tenant work uses an explicit, audited platform scope
  • Every table is covered
    How it is enforced
    The RLS-coverage lint fails on a tenant table without a forced policy; its exemption snapshot is empty
  • Every route uses a scoped connection
    How it is enforced
    A lint rejects unscoped database access in routes; remaining service-level uses are frozen in a snapshot that may only shrink
  • Isolation is attacked in CI
    How it is enforced
    An adversarial integration suite proves foreign-clinic SELECT, INSERT, UPDATE and DELETE are denied for every tenant table (TENANT-04)
  • Objects are owned jointly
    How it is enforced
    Patient, encounter, session, artifact and fact ids are checked together; one valid id cannot unlock another patient’s object (TENANT-05)
  • Exceptions are written down
    How it is enforced
    A tenant-isolation exception register, enforced by a test, lists every accepted exception

Identity, sessions and devices

  • Access tokens are short-lived JWTs signed with PS256 by a key in Cloud KMS; public keys are published as JWKS and rotated with overlap.
  • Refresh tokens are hashed, single-use and rotate on every use; replaying a used token revokes the whole token family.
  • TOTP multi-factor authentication with backup codes; an idle-session policy shared by client and server.
  • A lost device can be revoked by its owner from any other device, killing its token families and closing its sessions in one transaction.
  • WebSockets authenticate with 60-second signed tickets and are re-checked against revocation every 15 seconds.

Perimeter and application hardening

  • Edge
    Control
    Cloud Armor web application firewall and rate limiting; bot challenge on public forms
  • Headers
    Control
    Helmet with a per-request CSP nonce; no-store on every route carrying clinical data
  • Rate limits
    Control
    Per route class: authentication, data, AI per clinic, admin
  • Uploads
    Control
    ClamAV scanning and magic-byte checks before any processing
  • Input
    Control
    Zod schemas at every ingress; body limits of 1 MB by default
  • Output
    Control
    PII scanning of AI replies; logs and errors carry correlation ids, never request bodies or clinical state (TENANT-11)
  • Cron and internal calls
    Control
    OIDC-verified service identity on every scheduled endpoint
  • Supply chain
    Control
    CodeQL and Semgrep in CI, a dependency audit gate, a software bill of materials per build

Threat model

THREAT_MODEL.md is a STRIDE catalogue over the system’s trust boundaries and data flows, extended for every surface added since — voice, imaging, the native apps, the Data Room, the local integration bridge — with a residual-risk register and penetration-test priorities. Imaging and the local bridge have their own threat models. A bug-bounty policy and a security contact are published, and a workflow tracks the breach-notification clock.

SOURCE OF TRUTH
  • server/db/rls-migration.sql
  • server/db/connection.ts
  • scripts/lint-rls-coverage.ts
  • scripts/lint-getdb-routes.ts
  • tests/integration/rlsCrossTenant.test.ts
  • docs/clinical-invariants/tenant-isolation.md
  • THREAT_MODEL.md
  • SECURITY.md
Every figure in this specification is generated from the repository and checked in CI.