|
| 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. |
0 commit comments