Skip to content

Impersonation ​

Audience

This article is for GCM platform operators. Church admins should see Users & Roles β€” impersonation is only available to staff with the platform_admin or superadmin claim, and is the single most audited action in the platform.

Impersonation lets a staff user enter a customer's workspace as if they were that org's admin, so they can reproduce a bug, run a one-off fix, or walk a customer through a screen on a call. It is a regular feature, not a back door β€” every entry and exit is captured in platform_audit_log, scoped to a sessionStorage key (not localStorage, not a cookie), and ends automatically when the tab closes.

Impersonation handoff from org detail sheet

How to start a session ​

From the Organisations list, open any row's detail sheet (the chevron at the end of the row, or Open from the Billing ops table). The right-side sheet shows plan, status, staff, and modules. The blue Open account button at the top of the actions row is the entry point.

Click it, and three things happen in order:

  1. A confirmation dialog asks for a free-text reason. This is required.
  2. The frontend calls the start_impersonation Postgres RPC with the target org id and the reason.
  3. If the RPC succeeds, the row in admin_impersonation is created, the org id is written to sessionStorage under the key gcm_impersonation, the React Query cache is cleared (to prevent cross-org data leakage), and the user is redirected to / β€” which now resolves to the customer's tenant shell.

A red banner reading "Viewing as ... (impersonation)" appears across the top of the tenant shell. The customer's admin pages render as if you were them: their dashboard, their members, their giving. You can click any button their admin could click.

One target at a time

Starting a new impersonation while one is in progress is blocked by the RPC. End the current session first. The behaviour is intentional β€” the audit table assumes one active row per actor.

What the reason is for ​

The reason field is mandatory and ends up in three places:

  • The admin_impersonation.reason column.
  • The platform_audit_log row with action = 'impersonation_started'.
  • The 7-day "Impersonations" KPI on the audit-log tab.

Write something a future auditor can read. "Support ticket #4421 β€” donor sees a 500 on giving form" is good. "checking" is not. The auditor is you, in 90 days, after the customer complains.

How RLS keeps you scoped ​

While you are impersonating, the current_org_id() SQL helper returns the target org id rather than your own. Every RLS policy on the platform uses current_org_id() (not raw JWT claims), so reads and writes are silently scoped to the customer's data. You can read their members because the policy passes; you cannot read another org's members because the helper does not return their id.

This is why we use current_org_id() and not auth.jwt() ->> 'organization_id' in policies. The latter would leak through impersonation.

There is one extra safety net: edge functions read the impersonation context through getCallerContext so they too know they are running under a customer's id, not yours. Audit logs include both actor_id (you) and target_org_id (the customer) so a forensic trail exists either way.

How a session ends ​

A session ends in three ways:

  1. You return to platform mode β€” click Return to platform in the banner. The frontend calls end_impersonation, clears sessionStorage, clears the React Query cache, writes an impersonation_ended audit row, and pushes you to /platform.
  2. You close the tab β€” sessionStorage is wiped automatically. The admin_impersonation row stays open until you log in again, but the JWT scope is gone and the staff user's next login closes it. Reason this design exists: a stolen device cannot resume a session by reopening the tab.
  3. You sign out β€” same as closing the tab from the impersonation point of view.

Compounding (impersonating, refreshing the tab, impersonating someone else) is blocked because the new RPC call ends the prior row before creating a new one.

What you should and should not do ​

Do

  • Reproduce the bug the customer reported, then return.
  • Walk a customer through a screen on a call β€” they see your cursor, you see their data.
  • Run a documented one-off fix (e.g. seed a missing default fund) when they have approved the change in writing.

Don't

  • Edit configuration the customer would not expect you to touch. If you are not sure, ask first and link the request in the reason field.
  • Send messages, post to channels, or trigger workflows as the customer. The recipient sees the org's name and number, not yours β€” anything you send is permanently attributed to them.
  • Stay in impersonation longer than the task takes. The banner is visible to anyone who walks past your monitor.

Reading the impersonation audit trail ​

The Audit log tab carries a 7-day KPI for impersonations and the AuditLogViewer lets you filter by action. Three actions matter:

  • impersonation_started β€” successful start. Metadata includes target_org_id, org_name, reason.
  • impersonation_ended β€” clean exit via Return to platform.
  • impersonation_failed β€” the start_impersonation RPC rejected the call. The metadata includes the underlying error. A failed attempt is usually an RLS misfire, a target org that no longer exists, or a staff account that just lost its claim.

Filter by actor_email = <you> to see your own sessions over the last 7 days. Filter by metadata->>'target_org_id' = <id> to see every staff session against one customer β€” a useful answer to "has anyone from your team logged into our account recently?".

When something feels wrong ​

If you arrive on a tenant page and the data does not look like the customer's β€” wrong member names, wrong language, wrong org logo β€” return to platform mode immediately. Two failure modes can cause this:

  1. Stale cache β€” the React Query cache survived the entry. Hard-refresh the page; if that fixes it, the bug is in the entry path and worth a Sentry issue.
  2. Stuck impersonation row β€” your previous session was not closed cleanly and current_org_id() is returning the old target. Sign out fully, sign back in, and the auth-hook will reset the JWT.

In both cases, write an audit entry afterwards explaining what you saw. The whole point of the impersonation surface being auditable is that we learn from edge cases.