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.rspermittedOperations = "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 → azpaud[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 →