# Named scopes

Source: https://permdock.com/docs/concepts/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](/docs/concepts/tenancy) covers the rest of the model (custom roles, sources, instance methods); this page is the scope layer underneath it.

## Declaring scopes [#declaring-scopes]

```ts
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 [#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:

```ts
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 [#memberships]

```ts
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](/docs/concepts/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 [#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 [#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.

```ts
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 [#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:

```ts
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 [#where-scopes-are-read]

| Surface | What it does with scopes |
| --- | --- |
| `decide`, `can`, `filter` | Matches 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`, `fromSnapshot` | `scopes` lists `{ name, key, within, resources }` in order; grants carry the scope name and the membership they came from ([wire formats](/docs/concepts/wire-formats)) |
| `memberOf` conditions and relations | A `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 roles | `CustomRole.scope` (default the first scope) and optional `id`; the ceiling is keyed by scope name ([custom roles](/docs/concepts/custom-roles)) |
| RLS | One `permitted_<scope>_ids(p_grant)` and one membership-only `member_<scope>_ids()` helper per declared scope, applied to that scope's key ([Postgres RLS](/docs/standards/postgres-rls)) |
| Catalog | `scopes[]` in order and `roles[].on` as a scope name or `resource` |
| Membership events | `scope`, `id` and `within`, like a membership, so a role change at any depth is recorded ([wire formats](/docs/concepts/wire-formats)) |

## RLS [#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:

```sql
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](/docs/cli/rls) has the configuration.

## Suspension [#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](/docs/cli/rls#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](/docs/cli/rls#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](/docs/cli/rls)).

## Why [#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.
