Skip to content

Commit 6c9d061

Browse files
César Pereiroclaude
andcommitted
docs(demo): reproducible end-to-end transcript + sample DICOM generator
Adds docs/demo.md, a curl-by-curl walkthrough of one DICOM going through /v1/anonymize, /v1/usage, /v1/audit/verify, /v1/billing/checkout-session. Every response shown was captured against a real uvicorn instance; nothing is mocked in the document. The reader can reproduce the transcript locally with: python scripts/make_demo_dicom.py demo/sample_with_phi.dcm uvicorn dcm_anon_vault.app:app --port 8081 curl ... scripts/make_demo_dicom.py reads pydicom's bundled CT_small.dcm and overwrites the patient-identifying tags with recognisable fake values (DEMO^Jane, DEMO-PATIENT-001, Demo Hospital Madrid, ...) so the scrubbing is obvious in the resulting tag-by-tag diff. demo/ artifacts (database, audit dir, server log, zip output, generated DICOMs) added to .gitignore so the directory stays clean between runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 62036ee commit 6c9d061

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ build/
1616
.coverage
1717
.coverage.*
1818
htmlcov/
19+
demo/audit/
20+
demo/demo-vault.db
21+
demo/server.log
22+
demo/result.zip
23+
demo/sample_*.dcm
24+
nohup.out

docs/demo.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# dcm-anon-vault — End-to-end demo
2+
3+
A reproducible transcript of one DICOM file going through the hosted API:
4+
authentication, pseudonymization, audit chain, usage quota, and the
5+
billing flow. All command output below was captured against a real
6+
`uvicorn` instance on `127.0.0.1:8081`; nothing is mocked.
7+
8+
To re-run this on your own machine:
9+
10+
```bash
11+
pip install -e ".[dev]"
12+
python scripts/make_demo_dicom.py demo/sample_with_phi.dcm
13+
14+
DCM_API_KEYS="demo-customer:demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm" \
15+
DCM_ADMIN_KEYS="demo-customer" \
16+
DCM_DB_URL="sqlite:///./demo/demo-vault.db" \
17+
DCM_AUDIT_DIR="./demo/audit" \
18+
python -m uvicorn dcm_anon_vault.app:app --port 8081
19+
```
20+
21+
Then `curl` the endpoints below from a second shell.
22+
23+
---
24+
25+
## 0. Generate a sample DICOM with populated PHI
26+
27+
`scripts/make_demo_dicom.py` adapts the `CT_small.dcm` shipped with
28+
`pydicom` and overwrites the patient-identifying tags with recognisable
29+
fake values so the scrubbing is obvious:
30+
31+
```
32+
PatientName : DEMO^Jane
33+
PatientID : DEMO-PATIENT-001
34+
PatientBirthDate : 19800101
35+
ReferringPhysician: SMITH^John
36+
Institution : Demo Hospital Madrid
37+
StudyDescription : Demo abdominal CT for dcm-anon-vault docs
38+
AccessionNumber : ACC-2026-0001
39+
StudyInstanceUID : 1.3.6.1.4.1.5962.1.2.1.20040119072730.12322
40+
SOPInstanceUID : 1.3.6.1.4.1.5962.1.1.1.1.1.20040119072730.12322
41+
```
42+
43+
## 1. Health check
44+
45+
```bash
46+
$ curl -s http://127.0.0.1:8081/health
47+
{"status":"ok","version":"0.3.0"}
48+
```
49+
50+
## 2. Anonymize one file
51+
52+
```bash
53+
$ curl -s -D - -o demo/result.zip \
54+
-X POST http://127.0.0.1:8081/v1/anonymize \
55+
-H "X-API-Key: demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm" \
56+
-F "files=@demo/sample_with_phi.dcm"
57+
```
58+
59+
Response headers captured verbatim:
60+
61+
```
62+
HTTP/1.1 200 OK
63+
content-disposition: attachment; filename=anonymized.zip
64+
x-files-processed: 1
65+
x-files-failed: 0
66+
x-files-rejected-burnedin: 0
67+
x-audit-sha256: 579c78206b8f53cdf1260ee74957d4c62d890b0e985f958605d7d9271f0f4e96
68+
content-length: 25316
69+
content-type: application/zip
70+
x-request-id: ddd3f22d24bb46a2
71+
```
72+
73+
`x-audit-sha256` is the hash recorded in the audit chain for this call.
74+
`x-request-id` is the correlation id you will see in the access log.
75+
76+
## 3. Inspect the scrubbed output
77+
78+
The returned zip contains the pseudonymized DICOM under `out/`:
79+
80+
```
81+
demo/result.zip
82+
└── out/sample_with_phi.dcm 38916 bytes
83+
```
84+
85+
Tag-by-tag diff between input and output (run with the helper at the
86+
end of `scripts/make_demo_dicom.py` or any DICOM viewer):
87+
88+
| Tag | Input | Output |
89+
|---------------------------|--------------------------------------------|-----------------------------------------|
90+
| PatientName | `'DEMO^Jane'` | `'ANON'` |
91+
| PatientID | `'DEMO-PATIENT-001'` | `'0'` |
92+
| PatientBirthDate | `'19800101'` | `'19000101'` |
93+
| ReferringPhysicianName | `'SMITH^John'` | `''` (cleared) |
94+
| InstitutionName | `'Demo Hospital Madrid'` | `''` (cleared) |
95+
| StudyDescription | `'Demo abdominal CT for dcm-anon-vault…'` | `''` (cleared) |
96+
| AccessionNumber | `'ACC-2026-0001'` | `''` (cleared) |
97+
| SOPInstanceUID | `1.3.6.1.4.1.5962.1.1.1.1.1.20040119072…` | `2.25.544243826543649187543291941998888…`|
98+
| StudyInstanceUID | `1.3.6.1.4.1.5962.1.2.1.20040119072730.…` | `2.25.1116774253602043340137466276891…` |
99+
100+
UIDs are remapped through the `2.25.<UUID-as-int>` form so the
101+
re-identification chain in the source institution is broken while the
102+
Study/Series/SOP relationships across files of the same study remain
103+
coherent.
104+
105+
## 4. Check usage and remaining quota
106+
107+
```bash
108+
$ curl -s http://127.0.0.1:8081/v1/usage \
109+
-H "X-API-Key: demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm"
110+
{"tier":"free","files_used_mtd":1,"quota":50,"reset_at":"2026-06-01T00:00:00+00:00"}
111+
```
112+
113+
After two more calls (using `sample_2.dcm` and `sample_3.dcm` as copies
114+
of the same input — UIDs deterministic per API key, so they collapse):
115+
116+
```bash
117+
$ curl -s http://127.0.0.1:8081/v1/usage \
118+
-H "X-API-Key: demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm"
119+
{"tier":"free","files_used_mtd":3,"quota":50,"reset_at":"2026-06-01T00:00:00+00:00"}
120+
```
121+
122+
Free tier quota exhaustion returns `429 Too Many Requests` with a
123+
`Retry-After` header and an `X-Upgrade-URL` hint (the path through
124+
`/v1/anonymize` past 50 files in a month is covered by
125+
`tests/test_anonymize_route.py::test_free_tier_quota_returns_429`).
126+
127+
## 5. Verify the audit chain
128+
129+
`GET /v1/audit/verify` walks every row of the audit log and recomputes
130+
the SHA-256 chain. Returns the first row id where the chain breaks,
131+
or `null` if the chain is intact. Requires admin role
132+
(`DCM_ADMIN_KEYS` allow-list):
133+
134+
```bash
135+
$ curl -s http://127.0.0.1:8081/v1/audit/verify \
136+
-H "X-API-Key: demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm"
137+
{
138+
"status": "ok",
139+
"first_broken_id": null
140+
}
141+
```
142+
143+
A non-admin caller gets `403 Admin role required`.
144+
145+
The chain links each row to its predecessor via
146+
`row_hash = sha256(canonical_json(this_row, prev_hash))`. Any retroactive
147+
edit of an audit row makes every subsequent `row_hash` wrong and
148+
`first_broken_id` will point at the tampered row. This is what makes the
149+
audit log defensible against a procurement officer who asks "and how do
150+
we know the operator didn't alter the trail after the fact".
151+
152+
## 6. Billing — checkout when Stripe is not yet configured
153+
154+
The service refuses to silently fall back. With no Stripe keys set,
155+
the checkout endpoint returns `503` with an explicit reason:
156+
157+
```bash
158+
$ curl -s -X POST http://127.0.0.1:8081/v1/billing/checkout-session \
159+
-H "X-API-Key: demo-api-key-not-secret-just-for-docs-PHkjQ8ZxLm" \
160+
-H "Content-Type: application/json" \
161+
-d '{"success_url":"https://example.com/ok","cancel_url":"https://example.com/cancel"}'
162+
{"detail":"Stripe is not configured on this instance. Set STRIPE_API_KEY, STRIPE_PRICE_ID and STRIPE_WEBHOOK_SECRET."}
163+
HTTP_STATUS=503
164+
```
165+
166+
With real keys set, the response is a `{"checkout_url": "...", "session_id": "..."}` object that points at a hosted Stripe Checkout
167+
page with a 14-day Pro trial enabled. The same applies to
168+
`POST /v1/billing/portal-session`, which issues a short-lived link to
169+
the Stripe Customer Portal for self-service cancellation. Both flows
170+
are covered end-to-end (with the Stripe SDK mocked) by
171+
`tests/test_billing.py``TestCheckoutSession`, `TestPortalSession`,
172+
`TestSubscriptionLifecycle`.
173+
174+
## 7. What this demo does NOT exercise
175+
176+
- **Webhook signature verification.** Mandatory in production; the
177+
service refuses unsigned events with `503`. Tested in
178+
`TestWebhook::test_503_when_webhook_secret_unset`.
179+
- **`customer.subscription.deleted` and `customer.subscription.updated`**
180+
webhook handlers (covered by `TestSubscriptionLifecycle`).
181+
- **OIDC authentication** (`require_oidc_or_api_key`). Tested in
182+
`tests/test_oidc.py`.
183+
- **Outgoing webhooks with retries / dead-letter**
184+
(`tests/test_webhook_delivery.py`).
185+
- **GDPR Art. 17 retention sweep**
186+
(`tests/test_retention.py`).
187+
- **Burned-in pixel PHI rejection** with `BurnedInAnnotation==YES`
188+
(`tests/test_integration_real_dicom.py::test_burned_in_phi_is_rejected`).
189+
190+
The full pytest suite is 78 tests; everything in this demo is exercised
191+
in the suite at every commit.

scripts/make_demo_dicom.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Generate a demo DICOM file with populated PHI tags.
2+
3+
Reads pydicom's bundled CT_small.dcm, overwrites the patient-identifying
4+
tags with explicitly fake but recognisable PHI (so the demo output makes
5+
the scrubbing obvious), and writes the result to the requested path.
6+
7+
Usage::
8+
9+
python scripts/make_demo_dicom.py demo/sample_with_phi.dcm
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
from pathlib import Path
17+
18+
import pydicom
19+
from pydicom.data import get_testdata_file
20+
21+
22+
def build_demo_dicom(out_path: Path) -> None:
23+
source = get_testdata_file("CT_small.dcm")
24+
if source is None:
25+
raise SystemExit("pydicom test data not available; pip install pydicom")
26+
ds = pydicom.dcmread(source)
27+
ds.PatientName = "DEMO^Jane"
28+
ds.PatientID = "DEMO-PATIENT-001"
29+
ds.PatientBirthDate = "19800101"
30+
ds.PatientSex = "F"
31+
ds.ReferringPhysicianName = "SMITH^John"
32+
ds.InstitutionName = "Demo Hospital Madrid"
33+
ds.StudyDescription = "Demo abdominal CT for dcm-anon-vault docs"
34+
ds.AccessionNumber = "ACC-2026-0001"
35+
out_path.parent.mkdir(parents=True, exist_ok=True)
36+
ds.save_as(out_path, enforce_file_format=True)
37+
print(f"Wrote demo DICOM to {out_path}")
38+
print(f" PatientName : {ds.PatientName}")
39+
print(f" PatientID : {ds.PatientID}")
40+
print(f" PatientBirthDate : {ds.PatientBirthDate}")
41+
print(f" ReferringPhysician: {ds.ReferringPhysicianName}")
42+
print(f" Institution : {ds.InstitutionName}")
43+
print(f" StudyInstanceUID : {ds.StudyInstanceUID}")
44+
print(f" SOPInstanceUID : {ds.SOPInstanceUID}")
45+
46+
47+
def main(argv: list[str]) -> int:
48+
parser = argparse.ArgumentParser(description=__doc__)
49+
parser.add_argument("output", type=Path, help="Destination DICOM path")
50+
args = parser.parse_args(argv)
51+
build_demo_dicom(args.output)
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)