PermDock
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.

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.

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

On this page