Med-Core v1.0
Enterprise Healthcare Intake & Clinical Data Platform
- Python
- FastAPI
- SQLAlchemy
- PostgreSQL
- Redis
- Azure
- Docker
- Terraform
- GitHub Actions
- OAuth2
- JWT
- Trivy
Trade-off Analysis & Lessons Learned
What Worked
- Encrypting PHI at the ingestion boundary kept plaintext out of every downstream service
- Fernet data keys wrapped by Key Vault KEKs made key rotation a re-encryption job
- The Redis token blacklist revoked sessions instantly without a session store
- Rate limiting in front of auth protected token endpoints from abuse
What Failed
- The first scale-to-zero cutover dropped warm JWT key caches, spiking cold-start latency
- Field-level encryption slowed bulk intake batches until key caching was added
- The initial pipeline had no container image scanning; Trivy was added in CI after a base-image advisory
Architectural Iterations
- Per-request KMS calls replaced by Fernet envelope encryption with a local key cache in v1.0.0-rc
- Redis token revocation blacklist added after a logout-and-reuse review of long-lived JWTs
- CI hardened with dependency and container scanning before v1.0 GA
Lessons Learned
- Encrypt PHI at the boundary so data plane code never sees plaintext
- Token revocation belongs in a Redis blacklist, not in the stateless JWT
- Rate limiting belongs in front of auth, not behind it
Overview
Med-Core is an enterprise healthcare intake and clinical data platform on Python 3.12, FastAPI, and SQLAlchemy 2.0 against PostgreSQL 15. PHI is encrypted at the field level at the ingestion boundary — plaintext exists only inside the request handler, and everything downstream operates on ciphertext with explicit key provenance.
The core invariant — "Patient data is a protected asset. Security is a system requirement, not a feature." — shapes every layer: requests authenticate via OAuth2 and JWT bearer tokens with scoped role-based access, revoked sessions die instantly through a Redis token revocation blacklist, Redis rate limits run in front of auth so token endpoints are never the attack surface, and an immutable audit trail records every access to protected fields. A DevSecOps pipeline ships the service to Azure Container Apps through Terraform, with Trivy container scanning as a deploy gate.
The demo environment accepts synthetic intake payloads only; no real patient data has ever transited the pipeline.
Architecture
The intake pipeline is a single request path — authenticate, authorize, rate limit, validate, encrypt, persist — where the encryption boundary is enforced before any data plane logic runs. The diagram below is interactive: switch tabs for the system context, container, and Azure deployment views, and zoom with the buttons or the mouse wheel.
The Intake Pipeline
Every submission flows through one dependency chain, in order: bearer token
validation, scope authorization, rate limiting, idempotency check, schema
validation, field-level encryption, and persistence. SQLAlchemy 2.0's async
ORM maps the encrypted payload to PostgreSQL 15 with VARBINARY ciphertext
columns:
@router.post("/intake", status_code=202, dependencies=[Depends(rate_limit("intake"))])
async def submit_intake(
payload: IntakeSubmission,
auth: AuthContext = Depends(require_scope("intake:write")),
idempotency: IdempotencyKey = Depends(IdempotencyKey.from_header()),
cipher: PhiCipher = Depends(get_phi_cipher),
):
if await idempotency.seen():
return {"status": "already-received"} # duplicate retry → no-op
encrypted = cipher.encrypt_fields(payload.model_dump(), tenant=auth.tenant)
submission_id = await repository.store(encrypted)
await audit.log(auth.subject, "intake.create", submission_id)
await idempotency.record(submission_id)
return {"id": submission_id, "status": "received"}
Because encryption happens in the handler, the validation, storage, and eventing layers never see a plaintext field — the boundary is enforced by pipeline shape, not by discipline.
PHI Security Model
PHI is protected with Fernet envelope encryption: a per-tenant, per field-group data key encrypts the payload, and the data key itself is wrapped by a key encryption key (KEK) held in Azure Key Vault. Fernet's symmetric AEAD construction gives authenticated encryption with a bounded token lifetime out of the box — every ciphertext field is time-stamped and tamper-evident. The API caches unwrapped data keys locally for a bounded window, so steady-state encryption never pays a per-request KMS round trip — the original v1.0.0-rc design did, and it slowed bulk intake batches until the cache landed.
Key provenance is explicit: every ciphertext column records the data key id that encrypted it, so key rotation is a re-encryption job rather than a forensic mystery. The PostgreSQL store contains ciphertext only; a database leak without the keys is not a PHI disclosure.
Authentication & Token Revocation
Clients authenticate with OAuth2 client-credentials or authorization-code flows and present JWTs with scoped claims. Every route declares the scope it requires, and the dependency chain validates issuer, audience, expiry, and scope on every request:
{
"iss": "https://auth.med-core.dev",
"aud": "https://api.med-core.dev",
"sub": "user_01HZ5QX8R2XJTV3K4QZKXHFY8B",
"scope": "intake:write intake:read",
"tenant": "northshore-clinic",
"jti": "01HZ5QX8R2XJTV3K4QZKXHFY8C",
"exp": 1754000000
}
JWTs are stateless, so revocation can't be a flag in the token. On logout or
compromise, the jti goes into a Redis token revocation blacklist with a
TTL matching the token's remaining lifetime — a revoked session dies
instantly, and the blacklist entry expires with the token. The audit trail
records every grant, revocation, and access to protected fields, so a
regulatory "who saw what" question is a query, not an investigation.
Rate Limiting & Idempotency
Redis enforces a sliding-window rate limit per API key and per IP in front
of auth, so token endpoints and intake endpoints are both protected from
abuse before a single authentication attempt is even parsed. Submissions
carry an Idempotency-Key header; Redis records the key against the created
submission id, so webhook retries and client retries are no-ops:
async def rate_limit(key: str):
async def dependency(request: Request) -> None:
allowed = await redis.eval(
SLIDING_WINDOW_LUA,
2, f"rl:{key}", f"ts:{key}",
int(time.time()), 60, 100,
)
if allowed is None:
raise HTTPException(429, "Rate limit exceeded")
return dependency
The sliding window kept the limit exact under burst load without the clock skew problems of a naive fixed window.
DevSecOps Pipeline
CI is a gate, not a formality. On every push to main, GitHub Actions runs
linting, unit and property tests, a dependency audit, a Trivy container
scan, then builds and pushes the image to Azure Container Registry, runs
terraform plan against the state, and only a passing policy gate deploys to
Container Apps:
jobs:
build-and-deploy:
steps:
- run: uv run ruff check src
- run: uv run pytest
- run: uv run pip-audit
- uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.ACR }}/med-core:${{ github.sha }}
severity: HIGH,CRITICAL
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
Scanning was added after a base-image advisory made it clear that trusting
latest tags is a liability, not a shortcut.
Validation Metrics
The security surface is verified continuously: the authentication suite covers token validation, scope enforcement, and token revocation; the PHI encryption suite asserts ciphertext-only at rest and authenticated decryption at the boundary; the audit trail suite proves every access is recorded and immutable; and Trivy scanning gates every container image at HIGH and CRITICAL severity.
Azure Deployment Topology
Med-Core runs on Azure Container Apps with scale-to-zero, in front of Azure Front Door (global HTTPS ingress and WAF). PostgreSQL 15 Flexible Server holds the ciphertext store, Azure Cache for Redis backs rate limiting, idempotency, and the token revocation blacklist, and Key Vault holds the KEKs and connection strings. All infrastructure is Terraform-managed — see the Deployment tab above for the topology.
Status
Live at v1.0.0, MIT-licensed and open source, deployed to Azure Container
Apps via Terraform with scale-to-zero. The demo environment accepts
synthetic intake payloads only.