PermDock
Concepts

Named scopes

A policy declares its scopes in order (an organization, the customers inside it); roles are held at one scope, memberships name a scope instance, and a grant reaches a row only through that scope's own key, with no cascade between scopes.

A B2B product rarely has one kind of tenant. A field-service platform has organizations whose staff hold one role each, and customers inside each organization whose portal contacts see their own quotes and invoices, nothing else. PermDock models both with named scopes: the policy declares them in order, a role says which scope it is held at, and a membership names one instance of that scope. Tenancy covers the rest of the model (custom roles, sources, instance methods); this page is the scope layer underneath it.

Declaring scopes

export const policy = definePolicy(
  { permissions, roles },
  {
    scopes: {
      organization: { key: "organization_id" },
      customer: { key: "customer_id", within: "organization" },
    },
    roles: [
      role(
        roles.admin,
        [allow([permissions.quote.read, permissions.quote.update])],
        { on: "organization" },
      ),
      role(
        roles.contact,
        [
          allow(permissions.quote.read, {
            where: { status: { in: ["sent", "accepted"] } },
          }),
        ],
        { on: "customer" },
      ),
      role(roles["platform-admin"], [allow(permissions.organization.disable)]),
    ],
    subject: (user: Principal | null) => user, // a Subject from subjectFromSupabase skips it
  },
);
  • The declaration order is the scope order. The first scope is the one the active tenant selects an instance of (principal.tenant, permdock.tenant(id), tenants()).
  • key is the row field that holds the scope's id.
  • within names the parent scope. It must name an earlier scope, and every scope after the first has one, so the scopes form a single tree and cycles cannot be written. definePolicy throws on an unknown or later parent, a missing within, a name that is not lower snake case, and the reserved names global and resource.
  • tenant and team are aliases for the first and second scope. role(..., { on: 'tenant' }), a { tenant, team } membership and a memberOf: 'team' relation keep working against any policy. A scope literally named tenant must be first and team second, and team must declare within: 'tenant'.
  • A policy without scopes evaluates with the implicit pair tenant and team (inside tenant) and no row keys.
  • role(name, grants, { on }) is typed against the declared names: on: 'customers' is a compile error when the policy declares customer.

Resources declare every scope key

A grant on scope S reaches a row only through the row's own S key. Every resource an instance grant on S touches declares a memberOf: S relation on that key; definePolicy throws when one is missing, because where(), the snapshot and RLS could not narrow the rows without it:

const inScopes = {
  organization: { field: "organization_id", memberOf: "organization" },
  customer: { field: "customer_id", memberOf: "customer" },
} as const;

export const permissions = definePermissions({
  quote: resource(Quote, {
    id: "id",
    actions: ["read", "update", "accept"],
    relations: inScopes,
  }),
  invoice: resource(Invoice, {
    id: "id",
    actions: ["read", "pay"],
    relations: inScopes,
  }),
});

A resource whose rows are the instances themselves, such as an organizations table whose own id is the organization id, declares that field instead of the key: relations: { self: { field: "id", memberOf: "organization" } }. When a resource has no memberOf relation on the scope's key, its one memberOf relation to the scope names the field, for can, where(), snapshots (scopes[].fields), whoCan and the generated RLS alike. A resource with several memberOf relations to a scope (from_org, to_org) must name one of them with the scope's key; definePolicy throws otherwise.

A row is checked against the membership's scope and every ancestor the resource declares, outermost first: a customer contact's quote must carry the contact's customer_id, and its organization_id must be the one in the membership's within. A mismatch on the first scope is tenant-mismatch; on a scope below it, scope.

Memberships

type Membership = {
  scope?: string; // a declared scope name
  id?: string; // the instance of that scope
  within?: Record<string, string>; // the id of every ancestor scope
  on?: { resource: string; id: string }; // or: a role on one resource
  roles: string[];
  via?: string; // the membership kind: 'staff', 'contact', 'group:<scim id>'
  expiresAt?: number; // Unix seconds
};

// a staff member of T who is also a portal contact of customer C in organization B
memberships: [
  { scope: "organization", id: "T", roles: ["member"], via: "staff" },
  {
    scope: "customer",
    id: "C",
    within: { organization: "B" },
    roles: ["contact"],
    via: "contact",
  },
];

via is audit and UI data unless a role declares for: then only memberships of those kinds hold it, so role(roles.admin, grants, { on: 'organization', for: ['staff'] }) never reaches a contact or guest membership, and a membership without via holds no role that declares for (ownership).

Evaluation only sees this canonical form. Every entry is normalised when the subject is resolved (createPermDock, snapshotFor, mayAccess, simulate), whatever produced it: alias names resolve to declared names, the { tenant, team } input shape becomes { scope, id, within }, and anything else is dropped (fail-closed). A nested membership without the id of every ancestor in within, a scope the policy does not declare, or a mix of scope, tenant and on grants nothing. permdock doctor PD025 reports each such entry in the doctor.memberships fixture.

Scope ids are text. A number or a bigint in a membership's id, within or on.id is read as its decimal text, and every comparison of a row's key column, a custom role's tenant or a caller's id with a membership id compares the two as text, so a bigint key that a client such as supabase-js reads as a number (customer_id: 42) matches the membership "42". Any other value is no id: the membership is dropped and the row matches nothing.

No implicit cascade

A role applies only at the scope it is declared on, through a membership of exactly that scope:

  • An organization owner reads every quote of the organization through organization_id. That does not make them a member of any customer: permitted_customer_ids is empty for them, and the portal shows nothing unless they are also a linked contact.
  • A customer membership that happens to name an organization role (owner on customer A) grants nothing, and an organization membership naming contact grants nothing either.
  • A membership nested under the first scope counts only inside the active tenant. The staff member above reads organization T's quotes while T is active; permdock.tenant('B') switches to the portal of customer C, where only the contact membership applies.
  • A global role never reaches scoped rows. A platform operator who must read tenant data gets that explicitly (a support-access delegation), not through a bypass role.

Collection actions (quote.create, quote.list) have no row: they need a membership of the role's scope inside the active tenant.

Checks without a row

An instance action checked without a row (permdock.can(permissions.customer.read, undefined) in a page guard) asks about the active tenant as a whole, so only memberships of the first scope answer it. A membership of a nested scope applies to an instance action only when one of these holds:

  • The decision has a row, and the row carries the nested scope's key with the membership's id (and the keys of its ancestors with the ids in within).
  • The caller selected that instance with permdock.team(id), for a membership of the second scope.

Otherwise the grant is skipped and the check is denied with scope. A portal contact of customer A therefore fails can(permissions.customer.read, undefined) in organization T, passes can(permissions.customer.read, customerA), and passes permdock.team('A').can(permissions.customer.read, undefined). A staff member of T who is also a contact passes the first check through the organization membership.

permdock.can(permissions.customer.read, undefined); // organization memberships only
permdock.team(customerId).can(permissions.customer.read, undefined); // the contact's own customer
permdock.can(permissions.customer.read, customer); // any membership whose scope keys match the row

A snapshot follows the same rule: fromSnapshot(...).can(permission, undefined) answers an instance action from the first-scope memberships of the active tenant, or from the instance team(id) selected, so a page guard gives the same outcome on the server and on the client. Collection actions keep answering from nested memberships on both, because where() and RLS narrow what a nested membership lists or creates to its own instance.

Collection checks

A collection action (quote.create, quote.list) has no row to narrow, so a membership of any scope inside the active tenant answers it. A portal contact who may create requests for customer A passes can(permissions.request.create), which is the question a portal's "New request" button asks. The write stays inside customer A through the row passed to the create (can(permissions.request.create, draft)), where() and RLS.

A staff navigation item or a page that lists every quote of the organization asks a different question: does the subject hold this through an organization membership? Name the scope with the scope option:

permdock.can(permissions.quote.list, undefined, { scope: "organization" }); // organization memberships only
permdock.can(permissions.quote.list, undefined, { scope: "customer" }); // customer memberships only
permdock.can(permissions.quote.list); // any membership in the active tenant

scope takes a declared scope name and works on every check, with or without a row, on the server and on a snapshot. Only memberships of that scope answer. Global roles still apply. A resource role or a membership of any other scope is skipped with scope, and a name the policy does not declare matches no membership. The UI hooks take no options, so pass it to the instance they return (usePermDock().can(permission, undefined, { scope: 'organization' }) in React).

Where scopes are read

SurfaceWhat it does with scopes
decide, can, filterMatches each scoped grant against a membership of its scope, inside the active tenant, then checks the row keys of the scope and its declared ancestors; an instance check without a row answers from the first scope, or the instance team(id) selected; the scope option limits any check to memberships of one scope
where()One equality per partitioning key on the membership's chain, outermost first (organization_id = 'T' and customer_id = 'A'), ORed across memberships; the result carries the scopes for toWhere
snapshot(), snapshotFor, fromSnapshotscopes lists { name, key, within, resources } in order; grants carry the scope name and the membership they came from (wire formats)
memberOf conditions and relationsA memberOf: S relation on the first scope means "the row is in the active tenant"; on any other scope, "the subject holds a membership in the row's instance of S"
Custom rolesCustomRole.scope (default the first scope) and optional id; the ceiling is keyed by scope name (custom roles)
RLSOne permitted_<scope>_ids(p_grant) and one membership-only member_<scope>_ids() helper per declared scope, applied to that scope's key (Postgres RLS)
Catalogscopes[] in order and roles[].on as a scope name or resource
Membership eventsscope, id and within, like a membership, so a role change at any depth is recorded (wire formats)

RLS

permdock rls generate emits permdock_has(p_grant) plus one permitted_<scope>_ids(p_grant) and one member_<scope>_ids() per declared scope, each stable security definer with search_path = '', called uncorrelated so Postgres runs it once per statement:

create policy quote_select on public.quote for select to authenticated using (
  organization_id in (select permdock.permitted_organization_ids('quote.read#1'))
  or (customer_id in (select permdock.permitted_customer_ids('quote.read#2')) and status = any (array['sent', 'accepted']))
);

In database mode each helper reads its scope's membership table (rls.memberships.scopes.<scope>); in jwt mode it reads the memberships claim entries whose scope is its own. Both narrow memberships under the first scope to the active-tenant claim. A portal contact then sees only their customer's quotes and invoices with no security-definer RPC in the app. RLS has the configuration.

Suspension

A disabled organization or user is a subject input, like a membership. A MembershipSource (or the subject function) leaves out every membership of a suspended scope instance and returns no roles for a suspended user; decide, where(), snapshots and filter then deny every path, the portal contact's included.

A few actions must still work on a suspended organization, such as restoring it or cancelling its scheduled deletion. A source returns such a membership with keep, the permission keys it still grants, and evaluation counts it for those permissions only: every other grant, allow or deny, skips it, and heldRoles and the assignment checks never see its roles. The Supabase sources fill keep from rls.suspension.scopes.<scope>.keep, and the generated RLS honours the same list (permissions a suspended scope keeps).

One membership can be suspended too, without touching the user's other memberships or their sign-in: the membership table's disabledAt column marks it, the row and its role stay, and the source leaves it out or returns it with keep from rls.suspension.memberships.keep (suspended memberships).

RLS reads the same status from the database. rls.suspension names a users table and a table per scope, each with a disabledAt timestamp or a status column. The generated helpers then drop a suspended user's roles, and every membership whose instance or ancestor instance is suspended, in database and jwt mode alike. A missing status row counts as suspended. The Supabase token hook applies the same filter when it writes the memberships claim (RLS).

Why

Real SaaS products nest tenants: organizations and their customers, districts and schools, workspaces and projects. A fixed tenant-and-team pair forced the second level to be either a team (roles cascade up to the tenant) or a hand-written condition on every grant. Named scopes keep the shape declared once and ordered, which is also what RLS needs to generate one helper per level.

No implicit cascade is the rule every access-control incident in multi-level tenancy argues for: an organization admin who can open a customer portal "because they are above it" leaks the portal's scoping, and a customer contact who inherits an organization role is worse. CentraKit, the field-service product this page's example comes from, had to route its portal through security-definer RPCs because staff table policies could not express contact access; per-scope helpers remove that workaround. Requiring each resource to declare every scope key it carries turns a silent parity gap (a row checked in memory but not narrowed by where() or RLS) into a definition-time error.

Suspension is checked live in jwt mode too, even though the helpers otherwise trust the token. A disabled organization is an incident response, and waiting up to an hour for every member's token to refresh is the wrong default. The cost is one indexed lookup per helper call, still once per statement. A missing status row suspends because the alternative (a row nobody created grants access) fails open.

A suspended membership keeps its row and its role instead of losing them, because a suspension is reversible: lifting it must restore exactly what the member held, and a deleted row would have to be recreated from an audit log. Holder counts skip it, so an instance cannot be left with a suspended last owner. It is read live in database mode only. In jwt mode it reaches the claim on the next token, like a removed membership, and in process the bumped authorization version makes fresh permissions deny at once: a live lookup per claim entry would read the membership tables the claim exists to avoid.

Kept permissions are listed per scope, not granted by a role, because a suspension is about the instance and not about who holds what: the owner who may restore an organization is the same owner whose other rights the suspension voids. Holding them through the member's roles keeps the role model the single source of who may act, so a suspended organization's viewer cannot restore it just because restoring is kept. The membership carries its kept keys rather than being dropped and re-added for a few checks, so can(), snapshots and RLS read one list and cannot drift.

A collection check without a row keeps answering from nested memberships, because a portal's own create and list flows depend on it: those are the same calls, from the same contact, that an organization-level guard makes. Narrowing collections by default would deny every portal create button. It would also make "can this contact create a request at all" impossible to ask without inventing a row. The organization-level question is the one that needs a name, so scope names it on the call. A call that names the scope still works when the policy later adds a nested level.

An instance check without a row answers from the first scope because that is the question a page guard or a navigation item asks: may this subject use this feature of the organization? A nested membership that answered it would let a portal contact of one customer through every organization-level guard that names a permission the contact holds on their own rows. The row, or an explicit team(id), says which nested instance the caller means; without either there is none to check.

A single tree rooted at the first scope keeps "the active tenant" meaningful: every nested membership lives inside exactly one instance of the first scope, so tenant switching, per-tenant snapshots and the active-tenant claim need no per-scope variant.

Last updated on

On this page