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 →