# Elevated access

Source: https://permdock.com/docs/concepts/elevated-access

Time-bound, attributed, justified access. Just-in-time role activation makes a role eligible-only and mints a short-lived membership, break-glass is the only deny override and carries obligations, and support access lets a vendor act inside a tenant with consent and an actor. Purpose of use is a decision input.

Three features share one primitive: a membership that is time-bound (`expiresAt`), attributed (`grantedBy`), and justified (`reason`). PermDock never writes a membership; the app writes it to its own table. The primitive rides the existing `expiresAt` check and C3's `authz_ver` revocation counter, and `grantedBy`, `reason` and `member.group` round-trip through the Supabase custom access token hook claim, the snapshot and every event.

```ts
type Membership = {
  // ...scope, id, within, roles, via, expiresAt, managedBy, entitlements
  eligible?: readonly string[]; // roles the holder may activate, not hold
  grantedBy?: string; // who elevated, consented to, or granted this
  reason?: string; // why it was written
  member?: { group: string }; // a subgroup inside the instance
};
```

## Role activation (just-in-time) [#role-activation-just-in-time]

A role with `activation` is never held directly. A membership lists it under `eligible`, and `permdock.activate` mints the elevated membership to write.

```ts
role("admin", ADMIN, {
  on: "organization",
  activation: {
    maxDuration: "4h",
    justification: "required",
    approval: { by: roles.owner },
    assurance: { maxAge: 300 },
  },
});
```

`permdock.activate({ role, scope, id, within, duration, reason })` returns a `Decision`. It is `approval-required` when `activation.approval` is set. Once granted it carries the membership to write under `elevation`:

```ts
const decision = permdock.activate({
  role: "admin",
  scope: "organization",
  id: orgId,
  duration: "2h",
  reason: "covering the on-call shift",
});
if (decision.outcome === "granted") {
  await db.memberships.insert(decision.elevation);
  // { scope, id, roles: ['admin'], via: 'elevated', expiresAt, grantedBy, reason }
}
```

Fail-closed: an unknown role, an ineligible subject, a missing justification when `justification: 'required'`, or stale authentication all deny (`unknown-role`, `no-membership`, `reason-required`, `insufficient-user-authentication`). `duration` is capped at `maxDuration`. The elevation's `within` is copied from the eligible membership, never from the input: a `within` that disagrees with it is denied with `no-membership`, and the approval token covers it, so an approval for a team in one organization cannot activate the same team id in another. Expiry is the existing `expiresAt` check plus the revocation counter; the Supabase hook already includes the elevated row. `permdock doctor` PD033 warns on an `activation` without `maxDuration`, and on an activation role a fixture membership also holds standing.

## Break-glass [#break-glass]

Break-glass is the only deny override. It overrides deny grants whose `name` it lists, and nothing else; deny-overrides-allow holds everywhere else.

```ts
breakGlass(permissions.patient.read, {
  overrides: ["restricted-record"],
  requires: {
    purpose: ["BTG", "ETREAT"],
    reason: true,
    assurance: { maxAge: 60 },
  },
  maxDuration: "1h",
  obligations: ["notify", "review"],
});

deny(permissions.patient.read, {
  where: { restricted: true },
  name: "restricted-record",
});
```

A break-glass grant is engaged when the caller asserts `context.purpose`. When engaged and satisfied, the outcome is `granted` with `matched.breakGlass: true` and `obligations: [{ kind: 'notify' }, { kind: 'review' }, { kind: 'justify', reason }]` — there are still only three outcomes, never a fourth. Missing requirements deny with `purpose` (the asserted purpose is not one it lists), `reason-required` (no `context.reason`), or `insufficient-user-authentication` (the `assurance` was not met). `DecisionEvent` records `purpose` and `reason`, and `toOcsf` maps a break-glass decision to high severity.

RLS never compiles break-glass: the grant stays non-portable. The server reads restricted rows through a `security definer` function, `permdock.permdock_break_glass_<resource>(permission)`, that checks a signed break-glass session and writes an audit row. It lifts the deny, never the tenant boundary: a break-glass grant on a role reads only the rows of the scope instances where the subject holds it (its `role_permissions` row has the grant key `<permission>#break-glass`, which no table policy and no `authorize()` call matches), and a grant to no role reads the rows of the root scope instances the subject is a member of, or every row when the policy declares no scopes. `permdock doctor` PD034 flags a policy that tries to compile it under an `rls` config.

## Support access with tenant consent [#support-access-with-tenant-consent]

`supportAccess` is a role a vendor holds only through a consented, time-bound `via: 'support'` membership.

```ts
supportAccess({
  role: "support",
  actorRequired: true,
  consent: { by: roles.owner, durations: ["1d", "7d", "30d"] },
  forbid: [permissions.billing, permissions.security],
});
```

A tenant owner grants access through an `ApprovalRequest` the tenant resolves. On consume it produces the membership to write:

```ts
{ scope: 'organization', id, via: 'support', roles: ['support'],
  member: { group: 'vendor-support' }, expiresAt, grantedBy }
```

Group members ride C3's `fromJunction` `group` option (`member: { group }`). With `actorRequired: true`, every decision under a support membership denies with `actor-required` unless the subject carries an `act`; impersonation is never modelled as the user's own session. `forbid` compiles to deny grants scoped to `via: 'support'`, so it holds in RLS too. The lifecycle emits `access.started`, `access.ended` and `access.revoked` events (`accessEvent`), each a CloudEvents type with an OCSF Account Change mapping (`accessToOcsf`); revoking consent bumps the revocation counter. `permdock doctor` PD035 warns on a support role without `actorRequired`.

A better-supabase support session is the other way round: the token is the user's, `subject.principal` is the user and the admin is `subject.actor` with kind `support` ([Supabase](/docs/adapters/supabase#support-and-impersonation-actors)). It satisfies `actorRequired`, which accepts any actor, and reaches only what a policy delegation to `actor('support')` names; `read_only: true` narrows that to read-only permissions.

## Purpose of use [#purpose-of-use]

`context.purpose` is a decision input:

```ts
allow(permissions.record.read, { purpose: ["treatment"] });
```

The grant applies only when the caller asserts a matching purpose. It is not portable; RLS honours it only through the break-glass session function.
