Secure PHI GitOps

Secure PHI GitOps — PHI / non-PHI Separation via Neon Branching

This is the branching + environment architecture that lets us keep developer velocity with database branching while enforcing strict HIPAA containment. It rests on three pillars: two isolated Neon projects (non-PHI vs PHI) with matching object-storage buckets (§6), an isolated compute boundary for the rare PHI-touching work, and automated Git ↔ Neon synchronization.

This decouples the coding-agent choice from HIPAA. Everyday development runs entirely against the non-PHI Staging project on developers' local machines, so any coding agent is fair game there — Claude Code, GitHub Copilot, Cursor, whatever — because no PHI ever reaches a laptop or a model. Only release/* / hotfix/* work touches real PHI, and it is confined to a hardened Coder workspace inside the GCP boundary, where tooling is restricted to an approved, in-boundary assistant. That confinement is what frees the everyday coding-agent choice from any compliance weight — the ~99% non-PHI case can use any assistant, while the specific in-boundary assistant for PHI work is a separate, still-open decision (WIP — the AI-Native Dev Stack effort).


1. Neon Project Configuration

Two separate Neon projects — separate projects, not two branches of one, so the PHI boundary is a hard connection-level boundary, not a policy convention.

Project A — Staging (non-PHI)

  • Purpose: durable sandbox for everyday development.
  • Data profile: sanitized, masked, or synthetic data only. Zero PHI.
  • Access: open to all developers from standard local IDEs (localhost).
  • Branching: CI creates an ephemeral Neon branch off this project's primary branch for every feature/* PR. This is the existing PR Preview Environment mechanism, now explicitly rooted in the non-PHI project.

Project B — Production (PHI)

  • Purpose: live application data and pre-release verification against real data shapes.
  • Data profile: real PHI.
  • Access: highly restricted. Database connections permitted only from:
    1. Live GCP production application instances.
    2. Authorized CI/CD runner identities (Workload Identity Federation; Neon IP Allow).
    3. Isolated Coder workspaces running inside the secure GCP subnet.
  • Branching: CI creates ephemeral Neon branches off this project's primary branch only for release/* and hotfix/* PRs.
Staging (Project A)Production (Project B)
DataMasked / synthetic — no PHIReal PHI
Branch triggerfeature/*devrelease/*, hotfix/*main
Who connectsAny dev, localhostProd app · CI runners · Coder-in-subnet only
Ephemeral branchpr-<n>release-<version>

This refines the earlier "copy-on-write from prod, then redact" model: feature work never branches from PHI at all — it branches from a separate masked project, so there is no window in which a preview branch holds real PHI. Production branches exist only for in-boundary release verification.


2. Compute & Network Boundary (GCP + Coder)

To prevent local PHI contamination during release troubleshooting:

  • Deploy Coder into a dedicated, private GCP subnet, reachable only over the company tailnet (Tailscale) — the same tailnet that gates previews / staging (see Infrastructure › Tailnet Access).
  • Configure VPC firewall rules so that only this subnet may reach the Neon Production project (Neon IP Allow / private networking). Nothing outside the subnet can open a connection to Project B.
  • The Coder workspace image enforces an approved, in-boundary coding assistant (e.g., Gemini Code Assist; the specific coding-agent choice is still WIP, tracked under the AI-Native Dev Stack effort — not blocked on it, since any approved in-boundary tool satisfies the boundary) — arbitrary local agents are not used against PHI.
  • Disable clipboard sharing and local file download/upload from the Coder workspace via policy.
  • All PHI-adjacent compute stays inside the GCP BAA boundary; PHI never lands on an endpoint device.

The boundary is the point: a developer debugging a release connects to the Neon Prod branch only from inside the Coder subnet, using in-boundary tooling, with exfiltration paths (clipboard, downloads) closed. Everyday work — the vast majority — happens on the non-PHI Staging project with no such constraints.

Tailscale needs no BAA here. The Coder session is the one place PHI could traverse the tailnet; Tailscale is WireGuard, end-to-end encrypted (its relays pass ciphertext only, never plaintext), so it qualifies as a HIPAA conduit (like an ISP) — no Tailscale BAA. Keep PHI plaintext off any relay/coordination hop. See Infrastructure › Tailnet Access.


3. GitHub Branch Strategy & Protection

Two long-lived trunks — main (production) and dev (staging / integration) — plus short-lived feature/*, release/*, and hotfix/* branches. Configure protection via Settings → Branches.

main (Production)

  • Require a pull request before merging: on.
  • Require approvals: 1–2, and Require review from Code Owners: ON — this is the gate that routes schema / RLS changes to the CTO (see §4).
  • Restrict who can push/merge: limited to the Release Managers / Lead Engineers team (this — not CODEOWNERS — is how "only release managers land main" is enforced).
  • Require status checks: Integration Tests, E2E Tests, Migration Checks.
  • Do not allow bypassing the above settings: on.

dev (Staging / Integration)

  • Require a pull request before merging: on.
  • Require approvals: 1 (peer review / quality gate). Require review from Code Owners: OFF — so schema changes flow freely into dev without the CTO gate.
  • Require status checks: Unit Tests, Staging DB Migration Checks.

release/* (Release Candidates)

  • Require a pull request before merging: on.
  • Require status checks: the full suite run against a Neon Production fork (real-data-shape verification).

hotfix/* (Production Hotfixes)

  • Require a pull request before merging: on.
  • Require status checks: the same full release suite, run against a Neon Production branch — a hotfix/* branches off Production and targets main, mirroring release/*. It exists to patch prod urgently without waiting on the normal release train.
  • After merge to main, the automated backmerge (maindev) carries the fix downstream (Workflow C).

CODEOWNERS is path-based, not branch-based. The per-branch differences above (who approves dev vs who can land main) come from branch-protection rules + "restrict who can push," not from CODEOWNERS. CODEOWNERS decides which files require whose review on any PR — see §4.


4. CODEOWNERS Configuration

.github/CODEOWNERS enforces the quality gate by file path. Adapted to this monorepo — schema/migrations are Drizzle Kit (single migration history; the Python side reflects — no Prisma, no Supabase, no Alembic), so the paths differ from the common boilerplate:

# App code — peer + lead review
/ui/                     @your-org/frontend       @your-org/lead-engineers
/backend/                @your-org/backend         @your-org/lead-engineers
 
# CI/CD + infrastructure — DevOps review
/.github/workflows/      @your-org/devops
/infra/                  @your-org/devops
 
# Schema, migrations + RLS policies — the top-level schema/ dir (Drizzle Kit owns
# the single migration history). Owner = the CTO for now (swap for a
# @your-org/database-admins team as the org grows).
/schema/                 @your-org/cto
 
# Analytics models (Neon → BigQuery)
/dbt/                    @your-org/data

The stock template ships /prisma/schema.prisma and /supabase/migrations/ lines — delete those. This stack has neither Prisma nor Supabase; DDL, migrations, and RLS live in Drizzle Kit under the monorepo (see Backend and Libraries).

Open schema commits on dev, CTO-gated into main. CODEOWNERS is a single repo-wide, path-based file — but "Require review from Code Owners" is a per-branch protection toggle. Turn it ON for main only and leave it OFF for dev. Then a schema / RLS change merges into dev on a normal peer approval (anyone), but reaching main — which only happens through a release/* PR — requires the CTO's Code-Owner approval. One CODEOWNERS entry, asymmetric enforcement: everyone evolves the schema upstream; only the CTO okays it for prod. (GitHub Rulesets express the same per-branch-pattern rule if you prefer them to classic branch protection.)


5. CI/CD Pipeline Workflows (GitHub Actions)

Four workflows orchestrate Neon and GitHub. GCP auth is Workload Identity Federation throughout (no long-lived keys — see PR Environments).

Workflow A — Feature Development (feature/*dev)

  1. Trigger: PR opened against dev.
  2. Create a branch on the Neon Staging project named pr-<number> (neondatabase/create-branch-action).
  3. Run Drizzle Kit migrations against the Staging branch; post the neondatabase/schema-diff-action comment as the review gate.
  4. Run application tests.
  5. Teardown: on PR merge/close, delete the Neon Staging branch.
  6. Post-merge: run migrations on Staging main to update the baseline schema future forks branch from.

Workflow B — Release / Hotfix Verification (release/* / hotfix/*main)

  1. Trigger: PR opened against main (branch matches release/* or hotfix/*).
  2. Create a branch on the Neon Production project named release-<version> (or hotfix-<id>).
  3. Spin up secure CI compute (in-boundary runner) and connect to the Neon Prod branch.
  4. Immediately execute pending Drizzle migrations.
  5. Run integration / E2E tests against the migrated real-data-shape schema.
  6. Intervention (if needed): a developer opens a Coder workspace in the secure subnet, connects to the Neon Prod branch to debug, and commits fixes directly to the release/* branch.
  7. Teardown: on merge to main, delete the Neon Prod branch.

Workflow C — Automated Backmerge (maindev)

  1. Trigger: push / merge on main.
  2. Attempt a fast-forward or clean merge of main into dev.
  3. On success: push the updated dev.
  4. On conflict: create sync-main-to-dev-<timestamp>, open a PR into dev, and assign the release PR's author to resolve.

Workflow D — Staging Data Refresh (Cron)

  1. Trigger: weekly schedule (e.g. Sunday 02:00).
  2. Take a logical dump of Neon Productioninside the boundary.
  3. Pipe the dump through a HIPAA-capable masking / synthetic-data tool (e.g., Neosync — OSS, anonymization + synthetic; alternatives: Tonic, postgresql-anonymizer, Snaplet seed).
  4. Restore the masked dump into the Neon Staging project, giving developers realistic data volumes and shapes with zero PHI.
  5. Refresh the Staging blob base too (§6): regenerate synthetic blob fixtures into the Staging bucket for every object the masked dataset references, so masked rows resolve to masked blobs — no dangling keys, no PHI. Same in-boundary masking pass; only masked/synthetic bytes ever reach Staging.

The masking step is the compliance boundary of this workflow: it runs entirely in-boundary, and only masked output is ever written to Staging. Never restore an unmasked production dump into the Staging project, and never run the dump on a developer endpoint.


6. Object Storage (GCS) — same boundary, overlay reads

A Neon branch is only half a preview: its rows reference blobs (clinical-note attachments, uploaded documents) that must resolve to a matching, isolated store. So blobs follow the same PHI split as the database — per-class buckets plus an overlay (copy-on-write) read so a branch sees its own writes layered over a read-only base, and never mutates the base.

Buckets

BucketDataOverlay base (read-through)Writable byRetention
…-uploads-prod (CMEK)real PHI— (authoritative)prod app onlyversioned / lifecycle
…-uploads-phi-previewPHI, per-branch…-uploads-prod (read-only)release/* / hotfix/* CI + Coder-in-subnetshort TTL (~7d) + teardown
…-uploads-stagingmasked / synthetic— (base)Workflow D refreshrolling
…-uploads-nonphi-previewmasked / synthetic, per-branch…-uploads-staging (read-only)feature/* CI + any devshort TTL + teardown

Each preview branch writes under its own prefix: …-preview/<branch>/<key>.

Overlay semantics (copy-on-write)

  • Read key: try the preview bucket at <branch>/<key>; on miss, read the base bucket at <key>. The base is read-only — prod/staging blobs are never touched by a preview.
  • Write key: always the preview bucket at <branch>/<key>. New uploads and overwrites land in the branch's own layer.
  • Delete key: removes only the preview copy. (If a preview must hide a base object, write a zero-byte tombstone under <branch>/<key> that the read path treats as "not found" — rarely needed.)
  • Teardown: delete the <branch>/ prefix on PR close; the short-TTL lifecycle rule is the backstop.

The PHI-preview class deliberately reads through to the prod bucket — acceptable because that class is a first-class PHI environment (release/* / hotfix/*, Coder-subnet-only). The non-PHI class reads through to the masked Staging base and holds no IAM whatsoever on the prod bucket. That asymmetry is the whole safety story: only the already-contained PHI environment can ever see real blobs.

IAM is the write-barrier

Grant each preview identity objectViewer on its base bucket (read-only) and objectAdmin on its own preview bucket only; the non-PHI preview SA gets nothing on …-uploads-prod. So "the service can't edit prod" is not a code convention — a stray write to the base is IAM-denied. (Same WIF / least-privilege model as §2 and Secret Management.)

Store logical keys, not gs:// URIs

Persist a logical object key (e.g. tenant/<id>/note/<id>) in Postgres — never an absolute gs://bucket/… URI. The storage layer composes bucket + branch prefix from environment config at call time, so the same branched DB row resolves to the prod bucket in prod, the phi-preview overlay in a release branch, and the nonphi-preview overlay in a feature branch — for free, no row rewriting.

Transparency lives in the adapter, not in GCS

GCS has no native overlay / read-through / union-bucket setting — no bucket config says "fall back to bucket A on a miss" (Anywhere Cache caches one bucket; versioning/soft-delete are within a bucket; a fronting proxy is just infra you'd write). So the overlay lives in a thin BlobStorageService every consumer goes through:

get(key)               # overlay read: preview/<branch>/key → base/key
put(key, data)         # always preview/<branch>/key
delete(key)            # preview copy only
signed_read_url(key)   # resolve which bucket holds it, then sign THAT object
signed_write_url(key)  # always sign the preview object

Consumers pass logical keys only and never learn which bucket answered. The environment class (prod | phi-preview | nonphi-preview), base bucket, write bucket, and branch prefix are injected as config — the same place the Neon branch DATABASE_URL is injected. In prod, write-bucket == base-bucket and there is no overlay, so one class covers every environment. Because a signed URL points at a specific object in a specific bucket, a read URL is a server-side exists-check-then-sign — you can't hand a client a fallback URL.


How it all fits

ConcernMechanism
PHI never on a laptop / in a modelLocal dev → Staging (non-PHI) project only
PHI debugging is possible but containedCoder in a private GCP subnet (reached over the tailnet), in-boundary tooling, no clipboard/downloads
Feature branches can't leak PHIEphemeral branches come off the masked Staging project, never prod
Release tested against real shapesrelease/* verifies on a Production Neon branch, in-boundary
Blobs follow the same PHI boundaryPer-class GCS buckets + overlay reads — branch layer over a read-only base; writes only to the preview bucket (§6)
Preview can't corrupt prod/staging blobsIAM: read-only on the base, write only on the preview bucket; non-PHI preview has no prod-bucket access
Schema/RLS always reviewedCODEOWNERS on the Drizzle path + Code-Owner review on main/dev
Coding-agent choice is unconstrainedCompliance weight lives only in the Coder boundary, not the everyday IDE

See also: