fix security

This commit is contained in:
voltsrage
2026-06-21 19:53:19 +08:00
parent a37fad0e57
commit 7170b6efad
14 changed files with 422 additions and 27 deletions
+40 -15
View File
@@ -2,11 +2,11 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** Twenty-six planned phases are complete through Phase 31 — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **FHIR R4 Inbound Facade** for EHR integration, and **Role-Based Access Control (RBAC) with clinical audit logging**. Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, and FHIR bundle transaction rollback. See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
**Implementation status:** Twenty-six planned phases are complete through Phase 31 — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **FHIR R4 Inbound Facade** for EHR integration, and **Role-Based Access Control (RBAC) with clinical audit logging**. Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, and **JWT signing key validation** at startup. See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API or through the FHIR R4 inbound facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody) into the internal domain. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT, and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter — either directly via the REST API or through the FHIR R4 facade that maps HL7 FHIR resources from integration engines (Mirth Connect, Rhapsody) into the internal domain. The FHIR facade also exposes read and search interactions so EHR systems can query patient and encounter data back in standard FHIR R4 format. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Composite scoring engines (NEWS2, GCS, SOFA, qSOFA) aggregate multiple vitals and labs into acuity scores. The sepsis pathway follows Sepsis-3 consensus: qSOFA ≥ 2 creates a bedside screening alert recommending SOFA labs; when SOFA delta ≥ 2 from baseline confirms organ dysfunction, a `SOFA_SEPSIS` alert triggers the treatment bundle. Clinicians authenticate via JWT, and role-based access control (RBAC) gates every endpoint by clinical role (Nurse, Physician, Admin, Integration). Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All clinical write actions — patient registration, alert acknowledgment, threshold changes, encounter transitions — are recorded in an append-only audit log with user identity, IP address, correlation ID, and before/after state. All events flow through Kafka so the Elasticsearch dashboard, scoring engines, and data lake writer consume the same stream independently.
```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
@@ -54,7 +54,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Patient Registration** — register patients with MRN generation; optional blood type, allergies, and emergency contact; paginated list with name (`ILIKE`) and MRN (exact) search; patient detail with active encounter summary
- **Encounter Management** — open encounters against a patient with optional room/bed and admission reason; encounter status state machine (`scheduled → active → discharged / cancelled`) with 409 on illegal transitions; optional discharge diagnosis on discharge; encounter timeline as a merged chronological view across status changes, observation summaries, and alerts
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`) for 12 observation codes; thresholds pre-loaded into Redis on startup; write-through cache invalidation on update
- **Alert Threshold Management** — configure per-observation-code numeric bounds (`criticalLow`, `warningLow`, `warningHigh`, `criticalHigh`) for 12 observation codes; thresholds pre-loaded into Redis on startup; write-through cache invalidation on update and delete; `DELETE /alert-thresholds/{id}` removes a threshold with `THRESHOLD_DELETED` audit trail and Redis cache invalidation
- **Observation Ingest** — `POST /encounters/:id/observations` accepts single or small batch (up to 10); idempotency via `Idempotency-Key` header (partial unique index); plausibility validation per observation code; synchronous critical alert creation within the ingest transaction; warning-range breaches evaluated asynchronously by `WarningAlertService` (Kafka consumer group `warning-evaluator`); outbox event written in the same commit; cursor-paginated history on `(encounter_id, observation_code, recorded_at DESC)`
- **Warning Threshold Alerts** — `WarningEvaluator` reads thresholds from Redis; creates `WARNING`-severity alerts for values above `warningHigh` or below `warningLow` that are not also critical breaches; idempotent `INSERT WHERE NOT EXISTS` per encounter and alert type while status is `OPEN` or `ACKNOWLEDGED`; warning alerts are indexed in Elasticsearch but not published to the RabbitMQ paging queue
- **Clinical Order Management** — `POST /encounters/:id/orders` create; `GET /encounters/:id/orders` list with optional status filter; `GET /orders/:id` detail; `PATCH /orders/:id/status` status transitions; `PATCH /orders/:id/result` record result and transition to `Resulted`; status machine enforces `Pending → InProgress → Resulted` and terminal `Cancelled`
@@ -73,9 +73,10 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning and NEWS2 alert details when a mapped drug was administered within the correlation window (default 90 min); drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (NEWS2-sorted), patient detail (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel), alert center (global acknowledge/resolve), vital sign trend charts with local replay scrubbing, NEWS2 history chart, alert reasoning with optional medication context, and clinician feedback on every alert; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyMiddleware` authenticates via `X-Api-Key` header; `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 16 granular permissions (`patients:read`, `alerts:acknowledge`, `thresholds:write`, `fhir:ingest`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap`; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `audit:read`, and `users:admin`; integration accounts get write-only access for FHIR ingest; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; eight audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 granular permissions (`patients:read`, `alerts:acknowledge`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection
- **Clinical Audit Logging** — append-only `clinical_audit_logs` table records clinical write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID; ten audit actions (`THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`); `AuditService` writes log entries inline with domain operations; `GET /audit-logs` admin-only query endpoint with filters by entity type, entity ID, user ID, action, and time range; indexed on entity type, entity ID, user ID, and timestamp
- **Health Check Endpoints** — `GET /health/live` (liveness — always returns 200 if the process is running) and `GET /health/ready` (readiness — checks PostgreSQL, Redis, Kafka, RabbitMQ, and Elasticsearch connectivity); both return structured JSON with per-check status and duration; anonymous access; suitable for Kubernetes probes and load balancer health checks
- **Kafka Poison Pill Protection** — `PoisonPillGuard` prevents a single un-processable message from blocking a consumer partition forever; permanent errors (malformed JSON, bad format) are skipped immediately; transient errors are retried up to `MaxPoisonRetries` (default 5) before the offset is committed and the message is abandoned; all eight Kafka consumers use the guard; skipped messages are logged at CRITICAL with full payload and tracked by Prometheus `kafka_poison_pills_skipped_total` (labeled by consumer group and topic)
- **Outbox Dead-Letter with Retry Tracking** — `OutboxRelayService` tracks `RetryCount`, `LastError`, and `FailedAt` per event; events that fail `OutboxMaxRetries` (default 10) Kafka produce attempts are marked permanently failed (`FailedAt` set) and excluded from future relay polls; uses `FOR UPDATE SKIP LOCKED` for safe concurrent relay instances; idempotent Kafka producer (`EnableIdempotence = true`) prevents duplicate messages from network-level retries
@@ -190,6 +191,7 @@ VigilCareClinicalAPI/
│ ├── SofaController.cs # Current SOFA score and cursor-paginated history
│ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID
│ ├── FhirIngestController.cs # FHIR R4 ingest: Patient, Encounter, Observation, MedicationAdministration, Bundle
│ ├── FhirReadController.cs # FHIR R4 read/search: GET Patient/{id}, GET Patient, GET Encounter/{id}, GET Encounter
│ ├── FhirMetadataController.cs # FHIR R4 CapabilityStatement (GET /fhir/R4/metadata)
│ └── AnalyticsController.cs # Elasticsearch-backed patient search, trend, alert summary, population
├── Domains/
@@ -214,7 +216,7 @@ VigilCareClinicalAPI/
│ └── Enums/
│ ├── EncounterStatus.cs # Scheduled, Active, Discharged, Cancelled
│ ├── ClinicalRole.cs # Nurse, Physician, Admin, Integration
│ ├── AuditAction.cs # ThresholdCreated/Updated, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin
│ ├── AuditAction.cs # ThresholdCreated/Updated/Deleted, AlertAcknowledged/Resolved, EncounterStatusChanged, PatientRegistered, SuppressionWindowSet, UserLogin, AuthorizationDenied
│ ├── EncounterType.cs # Inpatient, Outpatient, Emergency
│ ├── AlertSeverity.cs # Warning, Critical
│ ├── AlertStatus.cs # Open, Acknowledged, Resolved, Escalated
@@ -248,7 +250,7 @@ VigilCareClinicalAPI/
│ └── FhirOperationOutcomeBuilder.cs # Builds FHIR OperationOutcome from exceptions and error codes
├── Authorization/
│ ├── AuthorizePermissionAttribute.cs # [AuthorizePermission("patients:read")] attribute
│ ├── ClinicalPermissions.cs # 16 permission constants (patients:read, thresholds:write, audit:read, …)
│ ├── ClinicalPermissions.cs # 17 permission constants (patients:read, thresholds:write, fhir:read, audit:read, …)
│ ├── ClinicalRolePermissionMap.cs # Role → permission set (Nurse, Physician, Admin, Integration)
│ ├── PermissionAuthorizationHandler.cs # ASP.NET Core authorization handler resolving role claims
│ ├── PermissionPolicyProvider.cs # Dynamic policy provider for perm:* policies
@@ -284,7 +286,7 @@ VigilCareClinicalAPI/
├── Validators/ # FluentValidation — RegisterPatient, OpenEncounter, IngestObservation, CreateMedicationAdministration, …
├── Observability/
│ └── Metrics/
│ └── ClinicalMetrics.cs # Prometheus metric families (counters, histograms, gauges)
│ └── ClinicalMetrics.cs # Prometheus metric families (counters, histograms, gauges); includes FHIR read and authorization failure metrics
├── BackgroundServices/
│ ├── ThresholdCacheLoader.cs # Pre-loads all thresholds into Redis on startup
│ ├── KafkaTopicProvisioner.cs # Creates topics with NumPartitions from config
@@ -327,7 +329,7 @@ VigilCareClinicalAPI/
│ ├── ReconciliationJobOptions.cs
│ ├── MedicationCorrelationOptions.cs # Drug-vital mappings + correlation window
│ ├── PatientOptions.cs # MRN prefix + digit count for sequence-based generation
│ ├── FhirOptions.cs # API key, identifier systems, department/class maps, defaults
│ ├── FhirOptions.cs # API key (single + rotation array), identifier systems, department/class maps, defaults
│ ├── JwtOptions.cs # Issuer, audience, signing key, expiration (default 8 hours)
│ └── DashboardOptions.cs # CORS origins for ward dashboard frontend
├── Sepsis/
@@ -382,7 +384,7 @@ VigilCareClinicalAPI/
│ ├── DomainException.cs
│ └── ValidationException.cs
├── Middlewares/
│ ├── FhirApiKeyOrJwtMiddleware.cs # Dual auth for /fhir/* routes: JWT bearer or X-Api-Key → Integration identity
│ ├── FhirApiKeyOrJwtMiddleware.cs # Dual auth for /fhir/* routes: JWT bearer or X-Api-Key (multi-key rotation, constant-time compare) → Integration identity
│ ├── CorrelationIdMiddleware.cs
│ └── ExceptionHandlerMiddleware.cs
└── Migrations/
@@ -800,6 +802,8 @@ See `docs/plans/phase-8-plan.md` through `docs/plans/phase-12-plan.md` for manua
| `outbox_pending_events` | Gauge | — | `OutboxPendingCollector` — unprocessed outbox rows |
| `kafka_consumer_lag` | Gauge | `consumer_group` | `KafkaConsumerLagCollector` — `es-indexer`, `sepsis-engine`, `notification-publisher`, `data-lake-writer` |
| `fhir_ingest_total` | Counter | `resource_type`, `outcome` | `FhirIngestController` — per resource type (`Patient`, `Encounter`, `Observation`, `MedicationAdministration`, `Bundle`) with `success` / `error` outcome |
| `fhir_read_total` | Counter | `resource_type`, `interaction`, `outcome` | `FhirReadController` — per resource type (`Patient`, `Encounter`) with interaction (`read`, `search`) and `success` outcome |
| `authorization_failures_total` | Counter | `permission`, `role` | `PermissionAuthorizationHandler` — authorization denials by required permission and user role |
| `fhir_mapping_errors_total` | Counter | `resource_type` | `FhirExceptionFilter` — mapping/validation failures by resource type |
| `kafka_poison_pills_skipped_total` | Counter | `consumer_group`, `topic` | `PoisonPillGuard` — messages skipped as permanently un-processable (malformed JSON, format errors, or transient failures exceeding MaxPoisonRetries) |
@@ -894,6 +898,7 @@ scheduled → active → discharged
| GET | `/alert-thresholds` | List all thresholds (paginated) |
| GET | `/alert-thresholds/{id}` | Get a threshold by ID |
| PUT | `/alert-thresholds/{id}` | Update a threshold; invalidates Redis cache |
| DELETE | `/alert-thresholds/{id}` | Delete a threshold; invalidates Redis cache; audit logged |
**Body:**
@@ -1176,6 +1181,7 @@ When a correlated drug was given within the `MedicationCorrelation.CorrelationWi
| `orders:write` | yes | yes | yes | — |
| `medications:write` | yes | yes | yes | yes |
| `fhir:ingest` | — | — | yes | yes |
| `fhir:read` | — | — | yes | yes |
| `audit:read` | — | — | yes | — |
| `users:admin` | — | — | yes | — |
@@ -1189,29 +1195,39 @@ All endpoints except `POST /auth/login` and `GET /fhir/R4/metadata` require auth
**GET `/audit-logs` query params:** `entityType`, `entityId`, `userId`, `action`, `from`, `to`, `page`, `pageSize`
**Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`
**Audit actions:** `THRESHOLD_CREATED`, `THRESHOLD_UPDATED`, `THRESHOLD_DELETED`, `ALERT_ACKNOWLEDGED`, `ALERT_RESOLVED`, `ENCOUNTER_STATUS_CHANGED`, `PATIENT_REGISTERED`, `SUPPRESSION_WINDOW_SET`, `USER_LOGIN`, `AUTHORIZATION_DENIED`
Each audit log entry includes `action`, `entityType`, `entityId`, `userId`, `userDisplayName`, `previousValueJson` (JSONB), `newValueJson` (JSONB), `reason`, `ipAddress`, `correlationId`, and `createdAt`.
### FHIR R4 Ingest
All FHIR endpoints are under `/fhir/R4`, accept `application/fhir+json`, and return FHIR R4 JSON responses. Authentication is via JWT bearer token or `X-Api-Key` header (configured in `Fhir:ApiKey`; disabled when blank). When a valid JWT is present, the API key check is skipped — this allows both integration engines (API key) and authenticated admin users (JWT) to ingest FHIR resources. Errors return a FHIR `OperationOutcome` with appropriate issue codes.
All FHIR endpoints are under `/fhir/R4`, accept `application/fhir+json`, and return FHIR R4 JSON responses. Authentication is via JWT bearer token or `X-Api-Key` header (configured in `Fhir:ApiKey` or the `Fhir:ApiKeys` array for zero-downtime key rotation; disabled when blank). API key validation uses `CryptographicOperations.FixedTimeEquals` to prevent timing attacks. When a valid JWT is present, the API key check is skipped — this allows both integration engines (API key) and authenticated admin users (JWT) to access FHIR resources. Write endpoints (`POST`) require `fhir:ingest` permission; read endpoints (`GET`) require `fhir:read` permission. Errors return a FHIR `OperationOutcome` with appropriate issue codes.
| Method | Path | Description |
|---|---|---|
| GET | `/fhir/R4/metadata` | CapabilityStatement — supported resource types and interactions |
| POST | `/fhir/R4/Patient` | Upsert a Patient by hospital identifier (MRN); idempotent |
| GET | `/fhir/R4/Patient/{id}` | Read a Patient by internal ID; returns FHIR R4 Patient resource |
| GET | `/fhir/R4/Patient` | Search Patients by `identifier` (system\|value) or list all; returns Bundle (searchset) |
| POST | `/fhir/R4/Encounter` | Upsert an Encounter by visit identifier; resolves patient by identifier |
| GET | `/fhir/R4/Encounter/{id}` | Read an Encounter by internal ID; returns FHIR R4 Encounter resource |
| GET | `/fhir/R4/Encounter` | Search Encounters by `patient` (UUID), `status` (`in-progress`/`finished`/`cancelled`); returns Bundle (searchset) |
| POST | `/fhir/R4/Observation` | Ingest an Observation; maps LOINC/SNOMED codes to internal codes; supports component observations |
| POST | `/fhir/R4/MedicationAdministration` | Record a medication administration; resolves encounter by identifier |
| POST | `/fhir/R4` | Process a transaction Bundle (Patient → Encounter → Observation/MedicationAdministration in dependency order) |
**CapabilityStatement:** `GET /fhir/R4/metadata` advertises `create`, `read`, and `searchType` interactions for Patient and Encounter; `create` only for Observation and MedicationAdministration; and Bundle transaction support.
**Identifier resolution:** FHIR resources reference each other by hospital identifiers (e.g. MRN in `Patient.identifier`, visit number in `Encounter.identifier`). The `ExternalResourceIdentifier` table maps these to internal UUIDs. On first ingest, a new internal record is created and the identifier is linked. Subsequent requests with the same identifier update the existing record (idempotent upsert).
**LOINC code mapping:** 19 LOINC codes and 3 SNOMED CT fallback codes map to internal observation codes (see `LoincCodeMapper`). Unsupported codes return `422` with an `OperationOutcome`. Temperature observations in Fahrenheit (`[degF]`) are automatically converted to Celsius.
**Transaction Bundles:** `POST /fhir/R4` accepts `Bundle.type=transaction`. Entries are processed in dependency order (Patient first, then Encounter, then Observation/MedicationAdministration). On first failure, processing stops (transaction semantics) and the response includes the `OperationOutcome`.
**Read interactions:** `GET /fhir/R4/Patient/{id}` and `GET /fhir/R4/Encounter/{id}` return the internal resource mapped back to a FHIR R4 resource with hospital identifiers resolved from `ExternalResourceIdentifier`. Returns `404` with an `OperationOutcome` if the resource is not found.
**Search interactions:** `GET /fhir/R4/Patient?identifier=system|value` searches by external identifier; omitting `identifier` lists all patients (up to `_count`, default 20). `GET /fhir/R4/Encounter?patient={uuid}&status=in-progress` filters encounters by patient and/or FHIR status (`in-progress`, `finished`, `cancelled`). Both return a `Bundle` with `type=searchset`.
**Integration with Mirth Connect:** HL7v2 ADT messages (A01 admit, A03 discharge, A08 update) and ORU R01 lab results can be mapped to FHIR Bundles via Mirth channels. See `docs/integration/mirth-fhir-channels.md`.
---
@@ -1471,7 +1487,7 @@ lastLoginAt DateTimeOffset?
```
id Guid PK
action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN
action string required (max 50) — THRESHOLD_CREATED | THRESHOLD_UPDATED | THRESHOLD_DELETED | ALERT_ACKNOWLEDGED | ALERT_RESOLVED | ENCOUNTER_STATUS_CHANGED | PATIENT_REGISTERED | SUPPRESSION_WINDOW_SET | USER_LOGIN | AUTHORIZATION_DENIED
entityType string required (max 100) — e.g. AlertThreshold, ClinicalAlert, Encounter, Patient, ClinicalUser
entityId Guid required
userId Guid? FK → ClinicalUser (null for system-initiated actions)
@@ -1785,7 +1801,7 @@ Twenty-six phases from the project roadmap are implemented and verified, includi
| 28 | **Frontend GCS + SOFA + sepsis UI refactor** — `GcsEntryForm.vue` (bedside GCS component entry); `SofaScorePanel.vue` (organ-system breakdown with staleness indicators); `useGcs` / `useSofa` composables; `scoring` Pinia store; `ScoresPanel` updated with GCS/SOFA display; `SepsisBundlePanel` and `AlertReasoning` refactored for Sepsis-3 alert types; Vitest tests for GCS entry, SOFA panel, scores panel, alert labels; `run-phase28-verification.sh` | Done |
| 29 | **Simulator scenario expansion + clinical validation** — three new scenarios (`neurological-decline-gcs-01`, `sepsis-sofa-progression-01`, `sofa-partial-spo2-fallback-01`); existing scenarios enriched with GCS/SOFA observations; `ScenarioReplayHelper` for end-to-end test replay; `ClinicalRefactorEndToEndTests` validates qSOFA screen → SOFA labs → bundle workflow; simulator polls GCS/SOFA scores; `run-phase29-verification.sh` | Done |
| 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 16 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (8 audit actions); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with `localStorage` token persistence; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (10 audit actions); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with `localStorage` token persistence; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, GCS entry, SOFA panel, scores panel, alert labels).
@@ -1797,4 +1813,13 @@ Twenty-six phases from the project roadmap are implemented and verified, includi
**RBAC + audit logging (Phase 31):** JWT authentication with role-based permission gating on every endpoint. Four clinical roles with granular permissions. Append-only audit logging records who did what, when, and why — with before/after state snapshots for compliance and incident review. Frontend login page with token-based session management.
**Post-phase hardening (after Phase 31):**
- **FHIR R4 read/search** — `FhirReadController` adds `GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient` (search by `identifier`), `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter` (search by `patient`/`status`); new `fhir:read` permission for Admin and Integration roles; CapabilityStatement updated to advertise `read` and `searchType` interactions for Patient and Encounter
- **Alert threshold deletion** — `DELETE /alert-thresholds/{id}` with `THRESHOLD_DELETED` audit action and Redis cache invalidation
- **FHIR API key rotation** — `Fhir:ApiKeys` array alongside existing `Fhir:ApiKey` for zero-downtime key rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals` prevents timing attacks
- **Authorization failure logging** — `PermissionAuthorizationHandler` logs denied requests with structured details (username, user ID, role, required permission, endpoint); Prometheus `authorization_failures_total` counter with `permission` and `role` labels
- **JWT signing key validation** — startup guard rejects keys shorter than 256 bits (HMAC-SHA256 minimum); prevents silent misconfiguration that would weaken token verification
- **New Prometheus metrics** — `fhir_read_total` (resource_type, interaction, outcome), `authorization_failures_total` (permission, role)
- **New audit actions** — `THRESHOLD_DELETED`, `AUTHORIZATION_DENIED`
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.