PermDock
Guides

Support access

Let staff act inside a customer's account with better-supabase support sessions and a PermDock delegation, read-only by default, enforced in process and in Postgres, and attributed on every decision.

A support session lets a staff member see what a customer sees, without the customer's password and without becoming the customer. With better-supabase, the token belongs to the user and names the staff member in act; PermDock reads that as an actor and lets it reach only what a policy delegation names. A session is read-only unless better-supabase started it with read_only: false.

apps/examples/next-better-supabase runs every step on this page: tests/support-access.test.ts for the in-process decisions and supabase/tests/020_support_read_only.test.sql for Postgres.

Pick the shape

ShapePrincipalActorUse it when
better-supabase support sessionThe userThe adminStaff reproduce what one user sees, inside that user's grants
supportAccess with consentThe vendorThe engineerA vendor team works in a tenant under a consented, expiring grant

The first is this guide. The second is support access with tenant consent: the vendor holds its own via: 'support' membership, so it never reaches more than the tenant consented to, whoever the user is. Impersonation in the narrow sense (act.kind: 'impersonation') is a third token kind; it reaches nothing until a delegation names actor('impersonation'), and this guide never adds one.

Delegate to the support actor

A support token carries no delegation of its own, so every decision denies with no-delegation until the policy names the actor kind (policy delegations):

// src/policy.ts
export const policy = definePolicy(permissions, {
  roles: [owner, member, contact],
  delegations: [
    {
      from: roles.owner,
      to: actor("support"),
      permissions: [permissions.quotes],
    },
  ],
});

The session reaches the intersection of three sets: what the user holds, what the delegation names, and, for a read-only session, the permissions whose readOnlyHint is true (meta.readOnly, else a read or list action).

Sessionquotes.readquotes.updatestaff.list
The owner's owngrantedgrantedgranted
Support, read_only: truegranteddenied, not-delegateddenied
Support, no read_only claimgranteddenied, not-delegateddenied
Support, read_only: falsegrantedgranteddenied
Support without session_iddenieddenieddenied
act.kind: 'impersonation'denied, no-delegationdenied, no-delegationdenied

A support level without read_only is read-only, as better-supabase reads it. A support level without a session_id, or with a read_only that is not a boolean, is not proof of who acts: subjectFromSupabase returns the anonymous subject (actors).

Enforce it in Postgres

PermDock's generated RLS helpers read memberships, not act. A support token sent straight to the Data API is the user to Postgres and writes whatever the user may write. Set rls.readOnlyActors so permdock rls generate adds one restrictive policy per table and write command it grants, and the database applies the same read-only default:

permdock.config.ts
rls: {
  dialect: "supabase",
  readOnlyActors: true, // support and impersonation sessions
},

Each policy (quotes_update_read_only_actors, quotes_insert_read_only_actors, ...) refuses the write when act.kind is support or impersonation, or when act carries a session_id and no kind (better-supabase 0.5.0 tokens), unless act.read_only is false. A restrictive policy is combined with and, so it only ever removes rows the permissive policies allow, and reads are untouched. The policies do not narrow a session to the delegation's permissions; Postgres sees only the user's grants, so keep writable tables behind the server where the delegation applies, or add the delegation's limits to the policy. Read-only actors has the generated SQL and the options. A table whose policies are hand-written (--helpers-only) needs the same restrictive policy written by hand.

Audit

Every decision event carries both identities: subject.principal is the user and subject.actor is { id, kind: 'support' }. Send them to a decision sink and a reviewer can list what a staff member did in each session. better-supabase's audit log fills impersonated_by (the act.sub) and support_session_id (the act.session_id) on every write the session makes. The decision event carries the actor id but not the session id, so join the two logs on the actor id and time.

PermDock keeps no support-session state. A session ends when its token stops verifying, which is decided upstream (authentication).

Test it

const permdock = await createPermDock(
  policy,
  subjectFromSupabase(supportClaims),
  {
    tenant: organization,
  },
);
expect(permdock.decide(permissions.quotes.update, quote)).toMatchObject({
  outcome: "denied",
  denials: [{ reason: "not-delegated" }],
});

supabaseClaimFixtures in permdock/testing has supportSession, supportSessionReadOnly and impersonation tokens for the same cases (Supabase claims schema).

Last updated on

On this page