Architecture
Component overviewโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AJ FHIR Consent Manager :8082 โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ HAPI Interceptors โ โ REST API /api/consent โ โ
โ โ โ โ โ โ
โ โ ConsentEnforcement โ โ POST /api/consent โ โ
โ โ Interceptor โ โ GET /api/consent/{id} โ โ
โ โ (pre-request) โ โ PUT /api/consent/{id} โ โ
โ โ โ โ POST /{id}/revoke โ โ
โ โ ConsentFhirWrite โ โ POST /api/consent/evaluateโ โ
โ โ Interceptor โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ (post-commit) โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ Patient Portal โ โ
โ โโโโโโโโโผโโโโโโโ โ /consent/portal/** โ โ
โ โConsentServiceโ โ (dashboard, history, โ โ
โ โ evaluate() โ โ detail, revoke) โ โ
โ โ create() โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ update() โ โ
โ โ revoke() โ โ
โ โโโโโโโโโฌโโโโโโโ โ
โ โ โ
โ โโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ JPA (PostgreSQL) โ โ
โ โ consent_record ยท consent_audit_event โ โ
โ โโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ async โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ-โโ
โ
โโโโโโโโโโผโโโโโโโ โโโโโโโโโโโโโโโโโโโ
โ HAPI FHIR โ โ Auth Server โ
โ :8080 โ โ (any JWKS) โ
โ Consent, โ โ JWKS, JWT โ
โ AuditEvent โ โ validation โ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
The Community Edition does not include the break-glass, OAuth2 consent screen (/oauth2/consent), or admin portal (/consent/admin/**). Those components are in the Enterprise Edition.
Decision engineโ
ConsentService.evaluate() is the single authority for all consent decisions.
Five-step algorithm:
- Actor-specific: Find active records matching both
patientIdandactorReference. Filter by period validity (UTC), resource type, and operation letter. First match wins. - Patient-level fallback: Find active records for the patient with no actor restriction (
actorReferencenull or blank). Same filters. - User-context fallback: Find active
user/context records for the actor (scopeContext = 'user', no patient ID โ SMART v2.2 clinician-level access). Same filters. - System-context fallback: Find active
system/context records for the actor (scopeContext = 'system'โ backend SMART services without patient launch context). Same filters. Evaluated last so patient-specific and clinician records always take priority. - Deny by default: No match โ deny.
Operation letters (permittedOperations field):
| Letter | HTTP | FHIR operation |
|---|---|---|
r | GET /{id} | Read |
s | GET ?param= | Search |
c | POST | Create |
u | PUT / PATCH | Update |
d | DELETE | Delete |
These are derived automatically from SMART scope strings. patient/Observation.rs โ permittedOperations = "rs".
GET is used for both reads (GET /Observation/{id}) and searches (GET /Observation?patient=X). The Consent Manager uses HAPI's RestOperationTypeEnum to distinguish these โ they are checked against separate permission letters. Most consent implementations collapse both to r. This one does not.
Interceptorsโ
Two HAPI interceptors are registered in FhirServerConfig:
ConsentEnforcementInterceptorโ
- Pointcut:
SERVER_INCOMING_REQUEST_PRE_HANDLED - Fires: Before every FHIR request, after HAPI's own scope validation
- Exempt resources:
AuditEvent,Consent,CapabilityStatement,StructureDefinition,OperationDefinition,SearchParameter - Patient ID: Extracted from JWT claim first, then from the FHIR request URL (Epic pattern โ patient ID not in JWT body)
- Actor ID: Five-tier resolution: configured claim โ
azpโaud[0](Epic) โsubโ UNKNOWN sentinel
ConsentFhirWriteInterceptorโ
- Pointcut:
STORAGE_POSTCOMMIT_RESOURCE_CREATED/UPDATED/DELETED - Fires: After HAPI commits a
Consentresource write - Purpose: Keeps the JPA consent cache in sync when Consent resources are written directly to HAPI, bypassing the Consent Manager REST API
- POSTCOMMIT not PRECOMMIT: fires after the transaction commits so JPA queries see consistent state
Data modelโ
consent_record
โโโ id, version (optimistic lock โ 409 on conflict)
โโโ fhir_consent_id (links to HAPI Consent resource)
โโโ patient_id, actor_reference, scope_context
โโโ status: draft/proposed/active/rejected/inactive/entered_in_error
โโโ provision_type: permit/deny
โโโ permitted_operations: VARCHAR(10) e.g. "rs", "cruds", ""
โโโ period_start, period_end
โโโ regulatory_basis, organisation_id
โโโ created_at, updated_at, note
consent_resource_class (element collection)
โโโ resource_class: e.g. "Observation", "Patient"
consent_scope (element collection)
โโโ scope_value: e.g. "patient/Observation.rs"
consent_audit_event
โโโ event_type, action (C/R/U/D/E)
โโโ outcome (0=success, 4=minor failure, 8=serious)
โโโ patient_id, agent_id, resource_type, http_method
โโโ consent_decision (permit/deny)
โโโ purpose_of_use (from JWT claim, default TREATMENT)
โโโ fhir_audit_event_id (HAPI cross-reference)
โโโ recorded_at
FHIR syncโ
The JPA table is the fast-lookup cache. HAPI FHIR is the canonical store.
ConsentFhirSyncService.syncToFhir() runs @Async("consentAsyncExecutor") with @Transactional. It accepts a record ID (not the entity) โ this avoids detached-entity problems that arise when an entity is passed across a transaction boundary to an async thread.
The FHIR Consent resource produced by ConsentFhirMapper.toFhir():
{
"resourceType": "Consent",
"status": "active",
"scope": {
"coding": [{ "system": "http://terminology.hl7.org/CodeSystem/consentscope", "code": "patient-privacy" }]
},
"patient": { "reference": "Patient/patient-123" },
"provision": {
"type": "permit",
"period": { "start": "2025-01-01", "end": "2027-12-31" },
"actor": [{
"role": { "coding": [{ "code": "IRCP", "display": "information recipient" }] },
"reference": { "reference": "Device/my-smart-app" }
}],
"class": [
{ "system": "http://hl7.org/fhir/resource-types", "code": "Observation" }
]
},
"extension": [
{
"url": "http://ajfhir.org/fhir/StructureDefinition/consent-regulatory-basis",
"valueString": "GDPR Art.9"
},
{
"url": "http://ajfhir.org/fhir/StructureDefinition/consent-scope-values",
"valueString": "patient/Observation.rs"
}
]
}
Scope values are stored in a custom extension for round-trip fidelity โ fromFhir() reads them back and re-derives permittedOperations.
Security filter chainsโ
Four SecurityFilterChain beans in priority order:
| Order | Path | Mechanism | Session |
|---|---|---|---|
| 1 | /fhir/** | JWT Bearer | Stateless |
| 2 | /api/** | JWT Bearer + roles | Stateless |
| 3 | /consent/**, /login/** | OAuth2 login | Session |
| 4 | /actuator/** | JWT Bearer / public health | Stateless |
The portal chain (order 3) does not include /oauth2/consent โ that path is part of the Enterprise Edition consent screen.
Async executorโ
All audit writes and FHIR syncs use a named ThreadPoolTaskExecutor:
Thread pool: consentAsyncExecutor
Core: 4 threads
Max: 16 threads
Queue: 200 tasks
Prefix: consent-async-
Using a named executor rather than the default SimpleAsyncTaskExecutor prevents unbounded thread creation under load.
Next: Configuration โ