Skip to content

Patient Impersonation

Clinic staff acting on a patient's behalf — assisted form fill, accessibility help, language barriers, technical support. The schema, middleware, and audit instrumentation ship in foundation 1B.13. This page is the feature-side reference.

Overview

Patient impersonation lets a clinic staff member open a time-bound, audited session in which they perform actions on behalf of a specific patient at their own clinic. Use cases:

  • Elderly patient phones in to fill an intake form; receptionist fills it on their behalf while talking them through the questions.
  • Patient with a vision impairment cannot navigate the booking UI; admin books an appointment for them.
  • Patient speaks limited Romanian / English; bilingual support staff translates and acts in the system.
  • Patient reports a UI bug; engineer reproduces the bug from the patient's view to debug.

Impersonation is clinic-internal — clinic staff acting on their own clinic's patients. It is distinct from the break-glass flow (1B.11), which governs platform staff accessing cross-tenant data.

Trust posture

  • Time-bound — sessions expire (default 1 hour, max 4); cannot be silently extended
  • Reason required — every session captures a free-text reason (min 10 chars) for compliance review
  • Audited end-to-end — every read and write inside an active session writes an audit_log row with action_context = 'impersonation' and impersonation_id linking to the session
  • Patient-visible — the patient sees every session in their access-history view, with full metadata
  • Permission-gatedpatients.impersonate (granted by default to admin + customer_support; can be added to custom roles)
  • Rate-limited — max 3 active sessions per staff principal per 5 minutes

The audit trail attributes the staff member as actor_id (not the patient). The patient is identified through the session record. This matches the regulator's expectation: when looking back at "who did this action", the answer is the staff member who actually clicked the button.


API

Open a session

bash
POST /v1/organizations/{org_id}/patient-impersonation-sessions
Authorization: Bearer <staff_token>
Content-Type: application/json

{
  "patient_id": "33333333-3333-3333-3333-333333333333",
  "reason": "Patient phoned in, requesting help completing the intake form",
  "expires_in_minutes": 30
}

Fields

FieldTypeRequiredNotes
patient_idUUIDyesThe patients.id row at this org
reasonstringyesMin 10 chars. Captured in audit; reviewable later
expires_in_minutesintnoDefault 60, max 240

Response: 201 Created

json
{
  "data": {
    "session": {
      "id": "77777777-7777-7777-7777-777777777777",
      "staff_principal_id": "88888888-8888-8888-8888-888888888888",
      "target_patient_id": "33333333-3333-3333-3333-333333333333",
      "organization_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
      "reason": "Patient phoned in, requesting help completing the intake form",
      "opened_at": "2026-04-30T10:00:00Z",
      "expires_at": "2026-04-30T10:30:00Z",
      "closed_at": null
    },
    "session_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}

The session_token is a short-lived JWT carrying the staff principal + session id. The staff client uses it as the Authorization: Bearer header for subsequent requests during the session; the auth middleware validates the session is open before handing the request through.

Errors

  • 400 reason_required — reason shorter than 10 characters
  • 403 forbidden — caller lacks patients.impersonate
  • 404 patient_not_found — patient does not exist at this org
  • 429 rate_limited — 3 active sessions per 5 minutes per staff principal exceeded

Close a session

bash
POST /v1/organizations/{org_id}/patient-impersonation-sessions/{session_id}/close

The opening principal can always close. Other staff can close if they hold patients.manage. Sessions auto-close at expires_at.

List sessions (clinic admin)

bash
GET /v1/organizations/{org_id}/patient-impersonation-sessions?staff_principal_id=&patient_id=&opened_after=&opened_before=

Gated by patients.manage. Server-side paginated, sorted, filtered. DataTable consumer in 1C.2.

Patient view of own sessions

bash
GET /v1/me/access-history?organization_id={org_id}

Returns sessions where target_patient_id is one of the calling human's patient profile ids (resolved via current_human_patient_profile_ids()). Includes the linked audit-log entries via impersonation_id so the patient can see what entities were touched.


Acting inside a session

After opening, the staff client makes any request with Authorization: Bearer <session_token>. The middleware:

  1. Decodes the token, looks up the session, validates opened_at < now < expires_at and closed_at IS NULL.
  2. Sets PrincipalContext:
    • principal_id = staff principal (used for audit + permissions)
    • acting_as_patient_id = target patient id (used for RLS scope so the staff sees what the patient sees and writes appear to come from the patient at the data layer)
    • impersonation_session_id = the session UUID

Routes that need impersonation use RequireImpersonation() — no scope argument, just a check that the request is running inside an active session.

Every audit row written during the request:

actor_id          = <staff principal>
actor_type        = 'human'
impersonation_id  = <session id>
action_context    = 'impersonation'

Forms or appointments authored during the session have their data-layer authorship attributed to the patient (submitted_by_principal_id = patient's principal). The audit row is the forensic source of truth for "the staff member did this on the patient's behalf."


Patient transparency

Always-recorded:

  • Every session appears in the patient's "Access history" page (1C.3) with: who opened it, when, the reason, how long it lasted, and the entities touched (linked via impersonation_id from audit_log).

No real-time email notification in v1. The recorded history is the transparency mechanism. If a Romanian DPA review or a clinic procurement requirement asks for stronger notification, real-time email lands as a follow-up — at that point we'd add a per-patient setting toggle.


Use cases

Assisted form fill

bash
# 1. Patient calls in
POST /v1/organizations/{org_id}/patient-impersonation-sessions
{
  "patient_id": "...",
  "reason": "Patient phoned, requesting help completing intake form for first appointment",
  "expires_in_minutes": 30
}

# 2. Staff submits form using session_token
PATCH /v1/forms/{form_id}
Authorization: Bearer <session_token>
{ "values": { ... } }

# 3. Form is recorded as patient-authored at the data layer.
#    audit_log row: actor_id = staff, impersonation_id = session,
#    action_context = 'impersonation'.

Accessibility booking

Patient with vision impairment:

bash
POST /v1/organizations/{org_id}/patient-impersonation-sessions
{
  "patient_id": "...",
  "reason": "Vision-impaired patient, booking follow-up appointment over the phone"
}

# Staff books the appointment as if the patient were doing it themselves;
# audit captures who really did it.

When a patient gives verbal consent over the phone (e.g. an addition to their treatment plan):

bash
POST /v1/organizations/{org_id}/patient-impersonation-sessions
{
  "patient_id": "...",
  "reason": "Patient verbally agreed to telemedicine consent during phone consult; recording on their behalf"
}

# Staff signs the telemedicine consent form via the session.
# Resulting consents row has source='form' + source_form_id;
# audit_log row attributes the action to the staff with impersonation_id set.
# The form record itself bears patient authorship at the data layer.

This is the verbal-consent-via-impersonation path — distinct from source='staff_action' in the consents ledger (which is for staff flipping a SaaS-style toggle on a patient's behalf without a form). For Tier B medical consents that need a signed form, this is how staff capture the patient's verbal agreement when the patient cannot sign in person.


Best practices

For staff

  • Always provide a clear reason — specific enough to explain in a compliance review six months later. The reason is free-text but reviewable.
  • Minimise session duration: don't open expires_in_minutes: 240 for a 5-minute task.
  • Verify the patient's identity before opening (call-back number, security question, etc.) — the audit trail captures that you opened a session but not whether the person on the phone was actually the patient.

For development

  • Routes that need impersonation gate with RequireImpersonation(). The middleware does the session lookup; the handler trusts the acting_as_patient_id in PrincipalContext.
  • Never bypass audit logging during a session — it's the entire transparency mechanism.

For compliance

  • Per-clinic monthly review of impersonation sessions (DataTable export).
  • Flag patterns: high-frequency impersonators, sessions with vague reasons, sessions opened just before expiry.
  • Anomaly investigation goes through standard staff-discipline channels — the audit data is the source of truth.

Foundation reference

The schema, middleware, audit instrumentation, and visibility model are owned by foundation 1B.13. This page is the feature-side reference; design changes happen in foundation.