Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

One clinical authorization boundary

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31 · Related: ADR-0009 (RLS and tenant isolation), ADR-0011 (feature shape), ADR-0015 (care roles, can()), ADR-0028 (privilege matrix)

Enforcement: packages/api/src/platform/clinical/clinical-procedure.test.ts (the six refusals, direct call()), packages/api/src/platform/clinical/gate.test.ts (each check in isolation), packages/api/src/platform/clinical/router-guard.test.ts (every procedure of a Wundklar clinical feature carries the CLINICAL_METADATA symbol), packages/api/src/platform/clinical/access-state.test.ts (plan §1.5.1), packages/api/src/platform/clinical/redact.test.ts (US-06).

Spec gate 1 (docs/wundklar/SPEC_GATE_1.md) found two independent gaps that both reduce to the same cause: clinical authorization was spread across procedures, sync handlers, key delivery and object downloads, and the AVV / patient-scope / access-state checks ran only on create. A read, a sync pull, a signed GET or a report job could therefore return patient data for an Organization that had not accepted the AVV, in read_only state, or for a nurse looking at a patient outside her tour.

This ADR records the single gate every clinical path goes through from v1 onward.

Decision

One builder, clinicalProcedure, builds every clinical procedure. It is orgProcedure + a six-step gate in the order of plan §1.3.1:

#CheckError on failure
1Session + membership (orgProcedure)UNAUTHORIZED / FORBIDDEN
2Session assurance ≥ policy (requireStrongAuth default true; strong or a currently valid, bound device attestation satisfies)ASSURANCE_REQUIRED
3AVV accepted at least once (RN-04)AVV_REQUIRED
4Access state (§1.5.1); a write recorded before the trial/cancellation cutover is accepted for 48 h; closed always refusesACCESS_READ_ONLY / ACCESS_EXPORT_ONLY
5Device (x-wundklar-device) names an active device of this User in this Organization — only when the procedure declares needsDevice: trueDEVICE_WIPED / DEVICE_PENDING_ACTIVATION / DEVICE_NOT_ENROLLED
6Role permission via can(role, permission, policy) from team.rulesROLE_NOT_PERMITTED
7Patient scope: owner/admin/wound_expert see all; nurse/aide only patients on tours for date ± 1 or whom they wrote in the last 7 daysPATIENT_OUT_OF_SCOPE

Three sister helpers reuse the same gate for the paths that do not go through clinicalProcedure:

  • clinicalMutationAccess(state, mutation) — the sync dispatcher (ticket 21) calls it per mutation of a sync.push batch; the kind descriptor supplies permission/mode/needsDevice.
  • clinicalObjectAccess(state, object) — upload staging (ticket 22) and signed GETs; needsDevice defaults to true because objects always carry ciphertext.
  • clinicalJobAccess(systemDb, { organizationId, mode, reason }) — background jobs; narrower (no session → no assurance / scope / device), checks only AVV + access state, and takes a named SystemReason so check-system-call-sites.mjs still catches the connection-role path.

redactOutOfScope nulls identifying ciphertext (name_ct, address_ct, contact name_ct / phone_ct) for out-of-scope rows before anything leaves the boundary. Every enrolled device holds the Organization data key, so hiding fields in the UI alone is not enforcement.

Introspection, not naming. Every chain clinicalProcedure(...) returns is wrapped in a Proxy that stamps CLINICAL_METADATA on every object the chain returns, including the final procedure after .handler(...). A router guard test walks appRouter, detects the outer procedure through procedure["~orpc"].handler, and reads the symbol from that outer procedure. Coverage includes orgSettings, objects, and shareLinks. Exact non-clinical exceptions carry a reason: orgSettings.get bootstraps access/AVV state, and devices.challenge and devices.attest establish device assurance. New public share-link or admin exceptions require an explicit entry once their boundary exists. A feature that opens a clinical procedure built from plain orgProcedure fails that test — on the next CI run, in that feature's PR, not later.

expectClinicalRefusals(callProcedure, input) hard-asserts all six refusals. A successful response or a different refusal code fails the test. Probes use a strong session, a wiped device, a closed Organization, an empty patient scope, and an unknown role to isolate each check. A procedure that cannot express a probe passes unavailable: { device: "reason" } (or the corresponding axis) on that call. Empty reasons fail. Export procedures pass accessCode: "ACCESS_EXPORT_ONLY" for the closed-state probe.

How the gate reads its state

loadClinicalMiddleware runs once per request. It parallelises:

  • care_service (access state + transition timestamps),
  • avv_acceptance (presence),
  • org_policy latest version (requireStrongAuth),
  • session_assurance for the current session and User, including the attested device, Organization, and expiry,
  • device row for x-wundklar-device (only when the header is sent),
  • visit_schedule for the nurse's tour on the current Europe/Berlin date ± 1,
  • authored clinical records for nurse/aide scope, from Berlin midnight seven calendar days before today through now. Future timestamps never grant scope. Sources are patient creation, wounds, classification, risks, consent and revocation, visits, photos, measurements, assessments, reviews, plans and decisions, prescriptions and requests, stock movements, reminders, reports, share links, deliveries, and conflict records. Indirect wound/assessment joins include Organization equality. Rows without author metadata and access logs do not establish authorship.

All reads run as app_tenant through context.db: RLS already narrows every row to the Active Organization. session_assurance has a tenant SELECT policy on ownedByCurrentUser(user_id), so the tenant handle sees it. Tests attach an override through makeContext under a private Symbol; requests cannot supply it.

Device assurance requires an unexpired, non-null expires_at, the current session and User, the Active Organization, and a device_id equal to x-wundklar-device. The reader discards assurance when that binding fails. The gate checks the device's current enrolment status even when the procedure does not otherwise require a device. Revoking or wiping the attested device cannot be bypassed by naming another active device. Strong ceremonies may have a null expiry because the authenticated Session bounds their lifetime.

The 48-hour grace

A nurse records a visit offline on Tuesday at 09:00. The Organization transitions to read_only on Tuesday at 23:00 (end-of-trial billing cron). She syncs on Wednesday at 10:00. Her mutation's occurredAt is before the transition, and the transition is less than 48 h old, so the gate accepts the write. A later sync of a mutation whose occurredAt is after the transition is refused with ACCESS_READ_ONLY. The grace window preserves work recorded while writes were allowed. It applies only to read_only after trial_ends_at or export_only after cancel_effective_at, only when the transition is between zero and 48 hours old, and only for occurredAt inside the verified continuous write-enabled interval ending at that cutover. A later transition to closed grants no new grace, including for writes recorded during the preceding restricted period.

The pure gate requires writeEnabledSince as the start of that verified interval. Every access-state transition is appended to care_service_access_event (provisioning writes trial, the platform admin's activation active, the daily job read_only/export_only/closed at the cutover it applies, not the tick time). writeEnabledSinceOf in access-state.ts walks back from the cutover over the unbroken run of trial/active events and returns its start; a restricted event or an empty history before the cutover returns null and refuses grace. So read_only → active → export_only grants grace only to work recorded after the reactivation. Rows that existed before the history table were backfilled only where the columns prove it (trial at trial_started_at, read_only at trial_ends_at); paid states without evidence keep failing closed. updated_at is not evidence of activation.

Where the metadata symbol lives

CLINICAL_METADATA = Symbol.for("wundklar.clinical.v1") is a global registry symbol. A duplicate symbol (two bundles, two copies of this module) still compares equal, so the router-guard test survives a code-split. The metadata itself is a flat record: { permission, mode, needsDevice }. It is read for introspection only — the runtime path uses the state the middleware produces.

Consequences

  • A later ticket cannot add a clinical procedure without going through this builder: the router guard would catch it, and expectClinicalRefusals would be its first test.
  • The sync dispatcher and job gates share the gate; a change in policy (say, requireStrongAuth = false) flows into every path on the next request without a per-feature edit.
  • redactOutOfScope keeps the pseudonymous rows a nurse must see (status, display number, timestamps) while nulling the ciphertext she must not. The UI never has to make its own judgement.
  • The 48 h grace is enforced by the application boundary. RLS independently enforces tenant and author constraints.
  • team.rules imports from platform/clinical: the lint rule D5 (platform does not depend on features) is relaxed on one line in gate.ts with a disable comment. The import was always intended: team.rules.ts's header names the clinical boundary as its sole non-feature caller.

Alternatives rejected

  • Re-use oRPC middleware per feature. The old design was already this: each feature chose which gates to apply, and three independent gaps opened. A feature forgetting a middleware is a silent regression; a feature forgetting to use clinicalProcedure is a router-guard test failure.
  • Enforce in RLS alone. requireStrongAuth, patient scope and AVV live outside the row and would require per-row joins against session_assurance, avv_acceptance and the nurse's visit_schedule. Even if they could be joined into every policy, the gate's errors (ASSURANCE_REQUIRED with the required factor, ACCESS_READ_ONLY with the grace date) carry data a WHERE clause cannot return.
  • Query data per check inside its middleware. Would multiply round-trips. The single loader fires everything in parallel once per request.
  • A big enum of permissions on clinicalProcedure. The permission is already an enum (CarePermission, team.rules); re-defining it here would double-maintain. The gate just forwards it to can().

Hardening verification (ticket 17b)

reader.test.ts exercises the real tenant handle on local PostgreSQL: device/session/Organization/expiry binding, revoked and wiped devices, the October 31 23:30 UTC tour window, and every authorship source at the Berlin midnight cutoff across DST. Other authors and Organizations never grant scope. testing.test.ts proves that callbacks omitting device, access, scope, or role checks fail the refusal helper, including a callback enforcing only assurance and AVV. router-guard.test.ts uses a non-empty real oRPC router with one protected and one unprotected procedure. access-state.test.ts covers active, read-only, and closed transitions.

Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.