Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,23 @@ MEDINTELOS_DATABASE_POOL_MAX_SIZE=10
POSTGRES_USER=medintelos
POSTGRES_PASSWORD=replace-with-a-long-random-secret
POSTGRES_DB=medintelos

# Durable audit log: "memory" (default) or "postgres" (requires
# MEDINTELOS_DATABASE_URL, same as MEDINTELOS_FHIR_BACKEND above — they can
# be set independently, but most deployments set both together).
MEDINTELOS_AUDIT_BACKEND=memory

# OAuth2/OIDC bearer-token auth, alongside (not instead of) the API key
# above. Disabled by default. All three of ISSUER/AUDIENCE/JWKS_URL are
# required together when enabled — see docs/DEPLOYMENT.md.
MEDINTELOS_OAUTH_ENABLED=false
MEDINTELOS_OAUTH_ISSUER=
MEDINTELOS_OAUTH_AUDIENCE=
MEDINTELOS_OAUTH_JWKS_URL=
MEDINTELOS_OAUTH_JWKS_CACHE_SECONDS=300

# In-memory, per-process rate limiting (token bucket). See docs/DEPLOYMENT.md
# for the multi-instance boundary.
MEDINTELOS_RATE_LIMIT_ENABLED=true
MEDINTELOS_RATE_LIMIT_REQUESTS_PER_MINUTE=120
MEDINTELOS_RATE_LIMIT_BURST=20
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes will be documented here.

## Unreleased (targeting 0.4.0 — Production-grade authentication)

- Added OAuth2/OIDC bearer-token authentication (`oauth.py`,
`api/auth.py`'s `CombinedAuthenticator`), alongside the existing API-key
path. Disabled by default (`MEDINTELOS_OAUTH_ENABLED=false`).
- Added SMART v1-style scope enforcement on FHIR routes
(`require_fhir_scope`, `scope_permits`). API-key clients remain full-access
(system-level), matching prior behavior; OAuth clients are scope-limited.
- Added in-memory token-bucket rate limiting (`rate_limit.py`), enabled by
default, with a `Retry-After` header on `429`. `/health` is exempt.
- Added `PostgresAuditChain`, a durable, hash-chain-compatible audit backend
selected via `MEDINTELOS_AUDIT_BACKEND=postgres`, serialized across
processes with a Postgres advisory lock. Extracted the hashing logic
(`compute_entry_hash`) so both audit backends produce identical hashes for
identical inputs.
- Added migration `0002_audit_entries.py`.
- Marked `security.py`'s `APIKeyAuthenticator` as superseded by
`CombinedAuthenticator` (kept for backward compatibility; logic unchanged).

## Unreleased (targeting 0.3.0 — Persistent FHIR store)

- Added `PostgresFHIRStore`, a drop-in Postgres-backed implementation of the
Expand Down
71 changes: 65 additions & 6 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,69 @@ read-only root filesystem, and exposes port 8080.
| `MEDINTELOS_DATABASE_URL` | Postgres DSN; required when backend is `postgres` | unset |
| `MEDINTELOS_DATABASE_POOL_MIN_SIZE` | Connection pool floor | `1` |
| `MEDINTELOS_DATABASE_POOL_MAX_SIZE` | Connection pool ceiling | `10` |
| `MEDINTELOS_AUDIT_BACKEND` | `memory` or `postgres` | `memory` |
| `MEDINTELOS_OAUTH_ENABLED` | Enable Bearer JWT auth alongside API key | `false` |
| `MEDINTELOS_OAUTH_ISSUER` | Expected `iss` claim; required if OAuth enabled | unset |
| `MEDINTELOS_OAUTH_AUDIENCE` | Expected `aud` claim; required if OAuth enabled | unset |
| `MEDINTELOS_OAUTH_JWKS_URL` | JWKS endpoint; required if OAuth enabled | unset |
| `MEDINTELOS_OAUTH_JWKS_CACHE_SECONDS` | JWKS cache TTL | `300` |
| `MEDINTELOS_RATE_LIMIT_ENABLED` | Enable per-client rate limiting | `true` |
| `MEDINTELOS_RATE_LIMIT_REQUESTS_PER_MINUTE` | Sustained rate per client | `120` |
| `MEDINTELOS_RATE_LIMIT_BURST` | Burst capacity per client | `20` |

Production mode refuses the built-in API key and requires at least 24 characters.
This length check is only a configuration guard, not a credential-management
solution.

## OAuth2/OIDC Authentication

The API key remains the default and is treated as a trusted system-level
credential with unrestricted access — nothing changes for existing
deployments. Setting `MEDINTELOS_OAUTH_ENABLED=true` additionally accepts
`Authorization: Bearer <jwt>`, validated against `MEDINTELOS_OAUTH_JWKS_URL`
(RS256 only). An OAuth-authenticated caller is scope-limited per request; an
API-key caller is not — see `api/auth.py`'s module docstring for why that
split exists and what it does not yet cover (full SMART App Launch is
0.5.0).

### Scopes

FHIR routes enforce SMART v1-style scopes from the token's `scope` claim:

- `<compartment>/<resourceType>.<action>`, e.g. `patient/Observation.read`
- `*` is accepted for the resource (`patient/*.read`) or the action
(`user/Patient.*`)
- A `write` scope also satisfies a `read` check
- The compartment (`patient`/`user`/`system`) is accepted but not yet
enforced distinctly — every compartment behaves the same today

A request without sufficient scope gets `403`, not `401` — the token is
valid, it just doesn't authorize this action.

### Trying it against a real identity provider

Any standards-compliant OIDC provider works (Keycloak, Auth0, Okta, etc.).
Point `MEDINTELOS_OAUTH_JWKS_URL` at its JWKS endpoint (commonly
`<issuer>/.well-known/jwks.json` or `<issuer>/protocol/openid-connect/certs`
for Keycloak), and set `MEDINTELOS_OAUTH_ISSUER` / `MEDINTELOS_OAUTH_AUDIENCE`
to match how that provider issues tokens. `tests/test_oauth.py` and
`tests/test_api_oauth.py` show the exact claim shape expected, using a
locally generated key instead of a real provider.

## Rate Limiting

Enabled by default. An in-memory token-bucket limiter keys on the presented
credential (API key or bearer token value) when present, falling back to
client IP otherwise. `/health` is never limited. Exceeding the limit returns
`429` with a `Retry-After` header.

**Boundary:** the limiter's state lives in one process. Running multiple API
instances behind a load balancer means each instance enforces the configured
limit independently — the effective ceiling across the fleet is
`instances × MEDINTELOS_RATE_LIMIT_REQUESTS_PER_MINUTE`, not a global cap. A
shared limiter (Redis-backed token bucket, or similar) is required before
that matters; not yet implemented.

## Persistent Storage (Postgres)

The default `memory` backend loses all data on restart — fine for a quick
Expand Down Expand Up @@ -91,12 +149,13 @@ restores — an untested backup is not a backup.
## Production Readiness Gate

Do not expose the reference container to patient data. `MEDINTELOS_FHIR_BACKEND=postgres`
replaces volatile storage, but a production program must still add TLS and an
identity provider (`docs/ROADMAP.md` 0.4.0), enforce authorization per resource
and purpose, validate FHIR profiles and terminology (0.5.0), encrypt durable
data at rest, isolate tenants, automate and test backups beyond the manual
`pg_dump` steps above, monitor security events, and complete clinical and
regulatory validation (0.6.0).
replaces volatile storage, and `MEDINTELOS_OAUTH_ENABLED=true` plus rate
limiting cover authentication and abuse throttling for a single instance —
but a production program must still add TLS termination, validate FHIR
profiles and terminology (0.5.0), encrypt durable data at rest, isolate
tenants, automate and test backups beyond the manual `pg_dump` steps above,
add a shared (multi-instance) rate limiter, monitor security events, and
complete clinical and regulatory validation (0.6.0).

## Contract Deployment

Expand Down
11 changes: 7 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@ Nothing here is a committed date; it is a dependency-ordered plan.

## 0.4.0 — Production-grade authentication

- [ ] OAuth2/OIDC support alongside the existing API-key path (API key remains
- [x] OAuth2/OIDC support alongside the existing API-key path (API key remains
for local development only, gated by `environment != "production"`)
- [ ] SMART-on-FHIR-style scopes on API routes
- [ ] Rate limiting middleware
- [ ] Durable audit log (Postgres-backed, building on 0.3.0)
- [x] SMART-on-FHIR-style scopes on API routes (compartment accepted but not
yet distinguished — every compartment behaves the same; tightening
this is part of 0.5.0's SMART App Launch work)
- [x] Rate limiting middleware (in-memory/per-process; a shared limiter is
needed before running multiple instances — see docs/DEPLOYMENT.md)
- [x] Durable audit log (Postgres-backed, building on 0.3.0)
- **Boundary:** still no tenant isolation, still no KMS-backed secrets by
default (documented as an operator responsibility).

Expand Down
6 changes: 4 additions & 2 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ separate trust domains. Do not infer trust from network location alone.

| Threat | Reference mitigation | Required deployment work |
|---|---|---|
| Unauthorized API access | Constant-time API-key comparison | OIDC, scopes, MFA where appropriate, rotation, rate limits |
| Unauthorized API access | Constant-time API-key comparison; optional OIDC bearer-token auth with SMART-style scopes (0.4.0) | MFA at the identity provider where appropriate, key/token rotation, a real IdP in front of MEDINTELOS_OAUTH_JWKS_URL |
| Excessive/abusive request volume | Per-client token-bucket rate limiting (0.4.0), in-memory per process | Shared (multi-instance) limiter before running more than one API process; gateway-level limits as defense in depth |
| Resource overwrite | `If-Match` version checks | Durable transactions, authorization, history, backups |
| Sensitive logging | Audit stores action metadata only | Log review, redaction tests, SIEM access policy |
| Malicious model update | Shape checks and basic norm outlier detection | Signatures, attestation, robust aggregation, quarantine |
| Privacy leakage from models | Optional clipping/noise experiment | Formal accountant, sampling proof, privacy review |
| Smart-contract privilege abuse | Owner checks and explicit proxy authorization | Multisig, timelocks, monitoring, independent audit |
| On-chain privacy leakage | Documentation prohibits PHI | Data classification, linkage analysis, retention design |
| Clinical automation bias | Explicit warnings and deterministic explanations | Human-factors testing, governance, monitoring, override review |
| Denial of service | Body-size limit and bounded API request lists | Gateway limits, queues, autoscaling, circuit breakers |
| Denial of service | Body-size limit, bounded API request lists, per-client rate limiting | Gateway limits, queues, autoscaling, circuit breakers |
| Audit chain forgery/loss | Hash-chained entries; Postgres backend serializes appends via advisory lock so concurrent writers can't fork the chain (0.4.0) | Independent anchoring (e.g. periodic external timestamping), WORM storage, backup of the audit database itself |

## Non-Goals

Expand Down
42 changes: 42 additions & 0 deletions migrations/versions/0002_audit_entries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Create audit_entries table

Revision ID: 0002
Revises: 0001
Create Date: 2026-09-07
"""

from __future__ import annotations

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"audit_entries",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("entry_id", sa.Text(), nullable=False, unique=True),
sa.Column("timestamp", sa.Text(), nullable=False),
sa.Column("actor", sa.Text(), nullable=False),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("resource", sa.Text(), nullable=False),
sa.Column("metadata", sa.dialects.postgresql.JSONB(), nullable=False),
sa.Column("previous_hash", sa.Text(), nullable=False),
sa.Column("entry_hash", sa.Text(), nullable=False),
)
# Append order matters for chain verification (list_entries orders by
# this), and every append reads "the last row" — an index keeps that
# cheap as the table grows.
op.create_index("ix_audit_entries_id", "audit_entries", ["id"])


def downgrade() -> None:
op.drop_index("ix_audit_entries_id", table_name="audit_entries")
op.drop_table("audit_entries")
21 changes: 21 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ postgres = [
"alembic>=1.13,<2",
"psycopg[binary,pool]>=3.1,<4",
]
oauth = [
"httpx>=0.27,<1",
"pyjwt[crypto]>=2.8,<3",
]
dev = [
"alembic>=1.13,<2",
"httpx>=0.27,<1",
"mypy>=1.11,<2",
"psycopg[binary,pool]>=3.1,<4",
"pyjwt[crypto]>=2.8,<3",
"pytest>=8,<10",
"pytest-asyncio>=0.24,<2",
"pytest-cov>=5,<8",
Expand Down Expand Up @@ -64,6 +69,22 @@ target-version = "py311"
select = ["E", "F", "I", "B"]
ignore = ["E501"]

[tool.ruff.lint.flake8-bugbear]
# Without this, B008 (no function calls in argument defaults) fires or not
# depending on whether the annotated parameter type happens to be a builtin
# ruff already knows is immutable (str, bool, int...) versus a custom class
# like AuthContext — an accident of ruff's mutability heuristic, not a real
# risk. Depends()/Query()/etc. are FastAPI's documented dependency-injection
# pattern and are safe regardless of return type.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
"fastapi.Header",
"fastapi.Body",
"fastapi.Path",
"fastapi.Cookie",
]

[tool.mypy]
python_version = "3.12"
check_untyped_defs = true
Expand Down
Loading
Loading