Skip to content

Patient Impersonation

Clinic staff opening a patient's own portal, read-only, to help them over the phone. The session primitive ships in foundation 1B.13; the viewing feature on top of it was audited and repaired on 2026-08-22. This page is the feature-side reference.

Overview

A staff member with patients.impersonate can open a time-bound, audited, read-only view of a patient's own Patient Portal — the real Portal, at the patient's own clinic, showing what the patient sees. Use cases:

  • Patient phones in and cannot find their exercise programme; the receptionist opens their portal and talks them through it.
  • Patient says an appointment "isn't showing"; staff look at the same screen the patient is describing.
  • Patient reports a bug; an engineer reproduces it from the patient's view.

It is clinic-internal — staff acting on their own clinic's patients — and distinct from break-glass (1B.11), which governs platform staff reaching across tenants.

It is a viewing feature, not an acting-on-behalf-of feature.

Earlier revisions of this page described assisted form fill, booking on the patient's behalf, and capturing verbal consent by signing a form inside a session. None of that is built, and the writes are refused at the API. If you need one of them, it is a product decision, not a missing wire — see What it deliberately does not do.

The scoping rule

Impersonation shows the patient's view of this clinic. Settled 2026-08-22, and it is the rule every layer implements:

  • Org-scoped clinical data renders as the patient sees it, independent of what the impersonating staff member's own role grants. Their forms, their appointments, their consents at this clinic, their programmes and history.
  • Platform-scope and cross-clinic surfaces are excluded, not translated. Staff do not learn which other clinics the patient attends, which sessions another clinic opened against them, who else is on their family account, or the contents of their platform_terms / platform_privacy_notice agreements with RestartiX.

The second half is not a refinement — it is what stops impersonation becoming a way around the per-clinic boundary. Registering a patient discloses that patient's record to you; it does not disclose their relationships with anyone else. Handing Clinic A facts about Clinic B is the GDPR Art. 26 controller-blending the boundary exists to prevent.

Trust posture

  • Time-bound — default 1 hour, hard ceiling 4 by DB CHECK; no silent extension. A cron sweep closes orphaned rows every 15 minutes.
  • Reason required — free text, minimum 10 trimmed characters, enforced by CHECK. The patient reads it on their Privacy page, verbatim. That is the strongest control the feature has: an audit trail nobody opens deters nothing, while a sentence the patient will see makes casual browsing something a person has to explain in advance.
  • Read-only — enforced at the API, not by hiding buttons. Any unsafe method on the patient surface returns 403 impersonation_read_only.
  • One at a time — a partial unique index on (staff_principal_id, organization_id) WHERE closed_at IS NULL. Helping Ana? Close it before starting Mihai.
  • Mutually exclusive with break-glass — neither can open while the other is active for the same principal and org.
  • Permission-gatedpatients.impersonate, granted by default to admin and customer_support. Specialist is deliberately excluded: this is a service task, not a clinical one.
  • Patient-visible — every session appears in the patient's access history with who, when, why, and for how long.
  • Rate-limited — per-principal, RATELIMIT_PATIENT_IMPERSONATION_OPEN_LIMIT / _WINDOW.

The audit trail attributes the staff member as actor_id, never the patient, with impersonation_id linking to the session. When somebody asks "who did this", the answer is the person who clicked the button; the session id beside it is how you learn whose record they were in.


How it works

Four steps, and the interesting one is the third.

1. Staff open a session

bash
POST /v1/organizations/{org_id}/patient-impersonation-sessions
Content-Type: application/json

{
  "patient_id": "33333333-3333-3333-3333-333333333333",
  "reason": "Patient phoned, cannot find her exercise programme",
  "expires_in_minutes": 30
}
FieldTypeRequiredNotes
patient_idUUIDyesThe patients.id row at this org
reasontextyes≥ 10 trimmed chars. The patient reads it.
expires_in_minutesintnoDefault 60, max 240

201 returns the session row plus a one-time ticket — not a JWT, and not a credential for the patient:

json
{
  "data": {
    "session": {
      "id": "77777777-…",
      "staff_principal_id": "88888888-…",
      "target_patient_id": "33333333-…",
      "organization_id": "9f8e7d6c-…",
      "reason": "Patient phoned, cannot find her exercise programme",
      "opened_at": "2026-08-22T10:00:00Z",
      "expires_at": "2026-08-22T10:30:00Z",
      "closed_at": null
    },
    "ticket": "hTq2…"
  }
}

Errors: 400 reason_too_short · 400 invalid_expiry · 403 forbidden · 404 patient_not_found · 409 cross_context_active (a break-glass session is open) · 429.

Opening a session while one is already active for the same (staff, org) returns the existing session, unchanged — so a double-clicked button does not produce two rows. The Clinic app checks the returned target_patient_id against the one it asked for, because "already helping Ana" plus a click on Mihai would otherwise land on Ana's portal under a dialog that said Mihai.

2. The browser is handed off to the Portal

The Clinic app opens https://{slug}.portal.…/impersonate?t={ticket} in a new tab.

Why a ticket rather than a cookie. The Clinic app and the Portal are different origins, and on a clinic's own custom domain they are different registrable domains with no shared cookie and no Clerk at all — which is exactly where white-label clinics live. A cookie-crossing design would have worked on *.restartix.pro and nowhere else, so the feature would have been absent precisely for the clinics that paid for their own domain.

Why Redis rather than a signed token. A signed ticket cannot be un-issued. Single use would need a replay list anyway, which is the Redis entry with extra steps. GETDEL makes redemption atomic, so two tabs racing the same URL produce one session and one failure rather than two.

The ticket lives 60 seconds and is single-use — it exists to survive one redirect, and a URL is the least private place a credential can live.

3. The Portal redeems it — for the STAFF member's own session

bash
POST /v1/public/impersonation/session
{ "ticket": "hTq2…" }

Public, because there is no principal yet: the ticket is the whole authorization. Every failure — unknown, expired, spent, session closed in the meantime — returns the same 401 impersonation_ticket_invalid, because distinguishing them tells whoever is guessing which guesses were close.

It returns an rxs_ portal session belonging to the staff member, stored in its own impersonation-token cookie, separate from any patient session already in that browser (a shared front-desk machine is the ordinary case).

The session is never the patient's. Minting a patient session would have recorded the patient as having done whatever staff did — on a platform whose privacy page exists to tell patients what staff did, that is backwards.

4. /v1/me/* answers about the patient

AttachImpersonatedPatient runs at the top of /v1 and, for the patient surface only, resolves patientscope to the target patient. The staff principal, their permissions, and the audit actor are unchanged. The Portal renders the patient's own pages with no portal-side change at all.

At the database layer, attachRLSTx calls set_app_impersonation_context(session_id), which binds — deriving everything from the session row, so the middleware can name a session and nothing else:

GUCValue
app.current_org_idthe session's org
app.current_impersonated_profile_idthe target patient's portable profile
app.current_impersonation_idthe session id — the audit linkage
app.current_action_contextimpersonation

current_human_patient_profile_ids() returns exactly that one profile, replacing the caller's own rather than adding to it — so a staff member who is also a patient at the same clinic does not see their own record inside somebody else's portal.

Ending the session deletes the cookie and closes the row; the banner does both.


What it deliberately does not do

Every write is refused. GET / HEAD / OPTIONS on the patient surface, nothing else. A method check cannot be incomplete the way a hand-kept route list can — a /v1/me write added next month is refused without anyone remembering the file.

Two carve-outs, both narrow and both deliberate:

  • The staff member's own login stays writablePATCH /v1/me and the /v1/me/email-change routes. They resolve through the caller's principal and never through patientscope, so the block has nothing to protect; without the exemption a staff member with a session open could not edit their own account or switch the Clinic app's language.
  • /v1/session-runs/* counts as the patient surface despite the URL. A run is a patient doing their exercises; the routes sit outside /v1/me only because the kiosk and TV app reach them with a display token. Programmes, assigned sessions and run history all render; starting a run is refused, because the run and its telemetry land in the patient's adherence record.

The read-only posture is also enforced in RLS for the writes that would create a record that is hard to undo — the consent ledger, the portable profile, runs and their events — so the HTTP check is not the only thing standing there.

Earlier revisions described staff capturing a patient's verbal phone consent by signing a consent form inside a session. That was never built and is not going to be smuggled in behind a read-only feature. Minting a ledger row saying a person agreed, on a page they are not looking at, needs its own review — legal basis, evidence, what the patient sees afterwards — not a relaxed method check.

Where a form is not required, staff record a consent through the ordinary staff path, which the ledger marks source='staff_action' and attributes to them. Where a signed form is required, the patient signs it.


Patient transparency

Every session appears on the patient's Privacy page (GET /v1/me/patient-impersonation-sessions) with who opened it, when, the reason text, and how long it lasted. The list is cross-clinic for the patient — a person asking "who has been in my record" is asking about all of it — and narrowed to the impersonating clinic when staff are the ones looking.

No real-time email in v1. The recorded history is the transparency mechanism.

There is no per-action drill-down, and the reason is worth stating plainly: the platform has no general read log. audit.ActionRead exists but has one call site (the CNP endpoint), so a read-only session produces no per-entity audit rows. What the patient sees is that a session happened, when, and why — not a list of pages visited. Do not describe it as more than that in a compliance artefact. The deferred general-access-log design lives in features.md → F11.5.


Oversight

GET /v1/organizations/{org_id}/patient-impersonation-sessions[?staff_principal_id=&patient_id=&only_active=&limit=&offset=], gated by patients.manage, server-side paginated. POST .../{id}/close closes a session — the opening principal always may, others need patients.manage.

No UI consumes this yet.

The endpoints work and are tested; the Clinic-side oversight screen (1D.2) and the Console card are not built — the Console's is currently a hardcoded empty array. Per-clinic monthly review of impersonation sessions is not achievable today. Do not cite it as an implemented control.


For developers

  • The scoping rule above is enforced in RLS, not in handlers, and it is folded into the migrations that create each thing rather than living in one correction file — 000006 (the helper's impersonation arm, the GUC reader, patient_profiles UPDATE, patient_caregivers SELECT), 000008 (the consents trio), 000012 (invitations), 000013 (set_app_impersonation_context + all three session-table SELECT arms), 000023 (runs and their events). If you add a patient-branch RLS policy that reaches across orgs, decide explicitly whether impersonation should see it and add AND current_app_impersonated_profile_id() IS NULL if not.
  • Use current_app_impersonated_profile_id() for "is this request answering as the patient", not current_app_impersonation_id(). The latter is set by any request wanting its audit rows linked — including the ones that open and close a session, which are ordinary staff requests acting as themselves.
  • Anything asking "which person is this request about" goes through patientscope. A handler that resolves off subject.PrincipalID will answer about the staff member; that class of bug is what the 2026-08-22 audit was mostly made of.
  • Coverage lives in impersonation_view_test.go — the session primitive's own tests are beside it and cover a different thing.

For staff

  • Write the reason for the patient, because the patient reads it.
  • Keep sessions short; do not open four hours for a five-minute call.
  • Verify who you are talking to before opening. The audit trail records that you opened a session, not whether the person on the phone was really the patient.

Foundation reference