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.
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)
A role with activation is never held directly. A membership lists it under eligible, and permdock.activate mints the elevated membership to write.
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:
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 is the only deny override. It overrides deny grants whose name it lists, and nothing else; deny-overrides-allow holds everywhere else.
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
supportAccess is a role a vendor holds only through a consented, time-bound via: 'support' membership.
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:
{ 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). 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
context.purpose is a decision input:
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.
Last updated on
Relationships
Object hierarchies (nested folders, sub-teams, reporting lines, account delegates) as relation grants that walk a parent chain, decided in process through a RelationSource and in Postgres through a closure table.
Link capabilities
A share link is a signed capability that holds roles on one resource, optionally narrowed to a few permissions; it resolves to a link principal, expires, can be revoked or used once, and reaches RLS through a short-lived Supabase token.