Skip to main content

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 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Community Edition

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:

  1. Actor-specific: Find active records matching both patientId and actorReference. Filter by period validity (UTC), resource type, and operation letter. First match wins.
  2. Patient-level fallback: Find active records for the patient with no actor restriction (actorReference null or blank). Same filters.
  3. User-context fallback: Find active user/ context records for the actor (scopeContext = 'user', no patient ID โ€” SMART v2.2 clinician-level access). Same filters.
  4. 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.
  5. Deny by default: No match โ†’ deny.

Operation letters (permittedOperations field):

LetterHTTPFHIR operation
rGET /{id}Read
sGET ?param=Search
cPOSTCreate
uPUT / PATCHUpdate
dDELETEDelete

These are derived automatically from SMART scope strings. patient/Observation.rs โ†’ permittedOperations = "rs".

READ vs SEARCH

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 Consent resource 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:

OrderPathMechanismSession
1/fhir/**JWT BearerStateless
2/api/**JWT Bearer + rolesStateless
3/consent/**, /login/**OAuth2 loginSession
4/actuator/**JWT Bearer / public healthStateless

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 โ†’