Compliance control matrix
What this is, and what it is not
This maps control families from HIPAA, GDPR, NDPR, ISO 27001, SOC 2 and PCI-DSS to the artifact in this repository that implements them, with a file path or a test name.
⚠⚠ It does not claim compliance with any of them. Compliance is a finding an auditor makes about an organisation, not a property a codebase can assert about itself. What a codebase can say is "this control is implemented here, and this is how you verify it", which is what every row below does.
⚠ The PARTIAL rows are the point. A matrix listing only what is done is marketing; an auditor
will find the gaps anyway, and finding them undocumented is worse than finding them named. Each
PARTIAL row states the gap and, where one exists, the issue tracking it. They are also the next
milestone's backlog.
⚠ Rows marked with an MR number cite work that is merged or in review at the time of writing.
A matrix is only as honest as its citations, so a path that does not yet exist on main says which
merge request brings it rather than reading as an existing control.
Status vocabulary
| Status | Means |
|---|---|
IMPLEMENTED | The control exists in this repo, at the cited path, with the cited test |
PARTIAL | Something real exists and a named gap remains |
EXTERNAL | Owned by ehr/deployment or the infrastructure, verified by M33-015's conformance test |
NOT APPLICABLE | Out of scope, with the reason |
⚠ Note the difference between EXTERNAL and IMPLEMENTED. A control living in ehr/deployment is
never IMPLEMENTED here — this repo cannot know whether it is switched on. It is EXTERNAL, and
it earns that only when a conformance check can fail on it.
Encryption at rest
| Control | Status | Evidence |
|---|---|---|
| PHI encrypted at rest (HIPAA §164.312(a)(2)(iv); GDPR Art.32(1)(a)) | IMPLEMENTED | common/.../security/crypto/PiiProtector.java, PiiCipher.java — AES-256-GCM pii:v1:<keyId>:<iv>:<ct+tag> |
| Every PHI column classified and enforced | IMPLEMENTED | docs/site/docs/operations/phi-data-classes.md is the enforced list; PhiDataClassConformanceTest fails on any unclassified column in a governed table |
| Demographics, identifiers, clinical values, form payloads, notifications | IMPLEMENTED | SEC-001, M33-007/008/009/010 — see the registry for the per-column disposition |
| Searchability under encryption | IMPLEMENTED | PiiBlindIndex.java — keyed HMAC bi:<keyId>:<base64>, versioned (M33-005B) |
| Key rotation without data loss | IMPLEMENTED | PiiKeyring.java; every envelope carries its key id, so old rows stay readable |
health_card identifiers | ⚠ PARTIAL | card_number, qr_code_value, nfc_identifier are plaintext and the table is outside GOVERNED_TABLES — #403 |
| Database/volume encryption (TDE) | ⚠ PARTIAL | Infra-owned and not verifiable from here — pg_stat_ssl describes the wire, not the disk. Needs an infrastructure attestation; see infrastructure-security-requirements.md (!531) |
| Backup encryption, separate keys, tested restore | ⚠ PARTIAL | Same — no conformance check is possible from an application connection. ehr/deployment#2 |
Encryption in transit
| Control | Status | Evidence |
|---|---|---|
| Response hardening headers | IMPLEMENTED | common/.../security/SecurityResponseHeaders.java; SecurityHeadersIntegrationTest reads a real response through the running chain |
| HSTS | IMPLEMENTED, inert until TLS | Withheld from a request the app considers insecure — deliberate, and it means HSTS never fires until the ingress forwards X-Forwarded-Proto: https |
| Ingress TLS with a valid chain | EXTERNAL | DeployedEnvironmentSecurityConformanceTest.ingressServesValidTls (!531) — ⚠ failing today: PKIX path building failed. ehr/deployment#2 |
| Plain HTTP refused | EXTERNAL | …plainHttpIsNotServed — ⚠ failing today: plain HTTP returns 200 |
| App→database TLS | EXTERNAL | …databaseConnectionIsEncryptedInTransit asserts pg_stat_ssl.ssl for the app's own backend |
Access control and accountability
| Control | Status | Evidence |
|---|---|---|
| Unique user identification (HIPAA §164.312(a)(2)(i)) | IMPLEMENTED | user_account; every audit row attributes to a user_account_id |
| Role- and object-level authorization | IMPLEMENTED | @RequiresAccess + AccessDecisionService; RequiresAccessArchitectureTest fails any write mapping that omits it |
| Minimum necessary / patient-scoped reads | IMPLEMENTED | core/.../clinical/service/PatientAccessGuard.java — service layer, because engines call in-process and a controller check would be bypassed |
| Emergency access ("break the glass", §164.312(a)(2)(ii)) | IMPLEMENTED | BreakGlassServiceImpl.java — reason, elevated permission, time box, audit, SecurityEventType.BREAK_GLASS_GRANTED |
| Audit controls (§164.312(b)) | IMPLEMENTED | audit_event / access_log / security_event; AuditEventAttribution.java refuses caller-supplied identity |
| Audit trail carries no PHI | IMPLEMENTED | Registry marks audit free text REDACTED, never ENCRYPTED — encrypting it would make it unreadable to the people investigating an incident |
| Service-to-service identity | IMPLEMENTED | SEC-018 — service_identity + OAuth2 client credentials; signed claims, not the caller-controlled header |
Authentication
| Control | Status | Evidence |
|---|---|---|
| Credential storage | IMPLEMENTED | BCrypt (user_account.password_hash) |
| Brute-force resistance | IMPLEMENTED | Account lockout + RateLimitProperties.java per-IP and per-(IP, username) buckets |
| Multi-factor authentication | IMPLEMENTED, opt-in | M33-012 — TotpVerifier.java (RFC 6238 vectors asserted), MfaServiceImpl.java. ⚠ PARTIAL as a policy: MFA_REQUIRED_FOR_ALL_USERS defaults false, and enforcing needs FE-364's enrolment screen |
| Session token integrity | IMPLEMENTED | M33-011 — ES256 with a kid, JWKS at /api/v1/auth/jwks.json. ⚠ ES256 activates only once the EC key pair is configured; otherwise HS256 with a startup warning |
| Refresh-token theft detection | IMPLEMENTED | M33-004 — reuse revokes the whole token family; SecurityEventType.REFRESH_TOKEN_REUSE |
| MFA in Keycloak mode | ⚠ NOT APPLICABLE (documented) | The password grant has nowhere to interpose a factor; KeycloakAuthService.completeMfaLogin refuses rather than pretending. Deployed profile is KEYCLOAK_ENABLED=false |
Browser trust boundary
| Control | Status | Evidence |
|---|---|---|
| Content Security Policy | ⚠ PARTIAL | Report-Only today. Violations are collected and triaged (M33-012B, csp_violation_group, !530); enforcement is M33-013 (#336), gated on a representative window of evidence |
| CSP violation evidence | IMPLEMENTED | core/.../engines/security/helpers/CspUriReducer.java + CspReportIngestServiceImpl.java (!530); ⚠ receives nothing until FE-368 adds report-to |
| CORS | PASS | The platform is deployed same-origin, so no CORS policy exists and none is needed. ⚠ The dead CORS_ALLOWED_ORIGINS values were removed 2026-09-10 (M33-013) — they were set in the Helm values and the local runbook, read by no Java code, and had already been mistaken for a live control. Two guards keep it that way: CorsIsDeliberatelyAbsentTest for the Java, CorsConfigStaysDeadGateTest for every other file. Adopting split-host means replacing both deliberately |
| CSRF | NOT APPLICABLE, deliberately | Every state-changing endpoint authenticates by bearer token, which a cross-site form cannot set; the one cookie-authenticated endpoint (/auth/refresh) is SameSite=Lax. ⚠ This reasoning stops holding the moment CORS allows credentials from another origin — which is why #336 decides both together |
Data residency (NDPR, GDPR Ch.V)
| Control | Status | Evidence |
|---|---|---|
| One cell per country; blast radius is the cell | PARTIAL (designed) | MILESTONE29_PLAN.md; DECISIONS_2026-08-16.md D19 |
| Key material never leaves the country | PARTIAL (designed) | Per-cell keys; ⚠ AWS has no Nigeria region, which is why cell-NG runs in-country storage and key custody rather than a foreign region |
| No PHI to third-party processors | IMPLEMENTED | M33-012B self-hosts CSP collection — report-uri.com and Sentry would both be PHI egress to a processor with no BAA |
| Residency enforced rather than declared | ⚠ PARTIAL | The conformance hook exists (ProductionSecurityPostureValidator.java); the per-cell endpoint allow-list is M29 work |
PCI-DSS
| Control | Status | Evidence |
|---|---|---|
| Cardholder data protection | ⚠ NOT APPLICABLE | No payment card data is stored or transmitted. GatewayChargeDto carries gatewayReference and an authorizationUrl for redirect checkout; the schema has no card_number/cvv/expiry columns for payment cards. Card data goes to the gateway, never through this system |
| — the exception that looks like one | see above | ⚠ health_card.card_number is an insurance card, not a payment card. It is a HIPAA identifier problem (#403), not a PCI one |
⚠ Saying "not applicable, and here is the reason" is stronger than silence. A reviewer who greps for
card_number will find health_card and needs to be told immediately which of the two things it is.
ISO 27001 / SOC 2 mappings
| Family | Status | Evidence |
|---|---|---|
| A.9 / CC6 Access control | IMPLEMENTED | see Access control and accountability |
| A.10 / CC6.7 Cryptography | IMPLEMENTED (app) + PARTIAL (infra) | see Encryption at rest |
| A.12.4 / CC7.2 Logging and monitoring | IMPLEMENTED | audit_event, security_event; M28-010's configuration-health endpoint |
| A.14 / CC8 Secure development | IMPLEMENTED | Checkstyle, PMD, SpotBugs, JaCoCo ≥80% enforced on every module; ArchUnit rules for authorization, scheduling and PHI classification |
| A.17 / CC7.5 Availability, backups | ⚠ PARTIAL | Infra-owned; no restore test is evidenced. ehr/deployment#2 |
| CC6.1 Boundary protection | ⚠ PARTIAL | Ingress TLS failing today — see Encryption in transit |
How to answer a question with this
"Is
observation.value_textencrypted, and how do I verify it?"
phi-data-classes.md→## observationclassifies itFREE_TEXT / ENCRYPTED.PhiDataClassConformanceTestfails if that column were dropped from the registry.PiiProtector.protectwrites thepii:v1:envelope;PiiEncryptionAtRestTestasserts ciphertext reaches the database.- M33-007's ticket log records why
value_coded_non_codedandvalue_numericare deliberately not encrypted, and what would break if they were.
That chain — registry, enforcing test, implementation, recorded reasoning — is what every
IMPLEMENTED row above is claiming.