PermDock
Adapters

Supabase token hook

permdock supabase hook generate compiles the app's membership sources into one custom_access_token_hook, with the active scope first, a size budget, an authorization version for sensitive permissions, and protection for memberships the identity provider owns.

A Supabase app usually reads memberships in two places: the server asks its tables on every request, and the access token carries a copy so RLS and the browser do not have to. The two drift apart as soon as they are written by hand. permdock supabase hook generate removes the second copy of the logic: the app declares its membership sources once, passes them to createPermDock as memberships, and the generator compiles the same sources into the Custom Access Token Hook.

Sources

permdock/supabase exports two SQL membership sources. Each one runs a single parameterised select at runtime and hands the same select to the generator, so the claim the hook writes and the memberships the server reads come from identical SQL.

// src/memberships.ts
import { fromJunction, fromTable, type SqlQuery } from "permdock/supabase";

const suspension = {
  users: { table: "profiles", id: "id", disabledAt: "disabled_at" },
  scopes: {
    organization: {
      table: "organizations",
      id: "id",
      disabledAt: "disabled_at",
    },
  },
};

export function sources(query?: SqlQuery) {
  const shared = query === undefined ? { suspension } : { query, suspension };
  return [
    // one row per user, scope, instance and role
    fromTable({
      table: "memberships",
      columns: {
        via: "via",
        expiresAt: "expires_at",
        managedBy: "managed_by",
        seats: "seats",
      },
      ...shared,
    }),
    // portal contacts: every row is a customer membership with the fixed role `contact`
    fromJunction({
      table: "customer_contacts",
      scope: "customer",
      within: { organization: "organization_id" },
      roles: ["contact"],
      via: "contact",
      ...shared,
    }),
  ];
}
  • fromTable({ table, columns?, query?, suspension? }) reads a table that holds every scope. columns.user, scope, id and role default to user_id, scope, scope_id and role. columns.role may be a reference to a roles table, and columns.user a reference to a table that holds the user id (below). within names a jsonb column of ancestor ids; via, expiresAt, disabledAt (a nullable timestamp that suspends the row: suspended memberships), managedBy (a text column where idp marks an IdP-owned row), seats (a text[] column) and x (a jsonb column of app data, read into Membership.x and never written into claims) are optional. Rows for the same instance, kind, expiry, owner, seats and x merge into one membership with every role.
  • fromJunction({ table, scope, roles, user?, id?, within?, via?, expiresAt?, disabledAt?, managedBy?, seats?, x?, query?, suspension? }) reads a table of one scope. roles is a role column, a reference to a roles table (below), or fixed roles (['contact']), so a contact table needs no role column. user is the user id column (default user_id) or a reference to a table that holds the user id (below). id defaults to <scope>_id, within maps each ancestor scope to its column, via is the kind every row has, and managedBy is 'idp' (every row) or { column }.
  • query(text, values) runs one statement: pg's client.query and postgres's sql.unsafe both fit. Without it a source only describes SQL, which is all permdock.config.ts needs.
  • suspension has the shape of rls.suspension: rows of a suspended user, instance or ancestor instance are left out, and a missing status row counts as suspended. A scope with keep keeps the rows of its suspended instances instead, each with a keep column of the permission keys it still grants (permissions a suspended scope keeps); a source without keep adds the column only next to one that has it. A row whose disabledAt column is set is a suspended membership: it is left out, or with suspension.memberships.keep kept with the keys it still grants (suspended memberships).
  • Each source also implements list({ scope, id }), which returns every member of one instance for member lists, share dialogs and access reviews.

A membership table that stores a role id reads the role key through the roles table, with the same { through, on, column } shape as global roles:

// organization_users (user_id, organization_id, role_id references roles (id))
// roles (id, scope, key, organization_id): built-in rows have no organization, custom rows have one
fromJunction({
  table: "organization_users",
  scope: "organization",
  roles: { through: "roles", on: { role_id: "id" }, column: "key" },
  via: "staff",
  ...shared,
});

on maps exactly one column of the membership table to the roles table column it references, and an unqualified through is in the membership table's schema. The hook, the in-process membershipsFor and list, and the database mode helpers all run the same select with the join, so a membership's roles hold keys. A tenant's custom role is a roles row with a key unique within the tenant; the membership carries that key and resolves as a custom role of its tenant. A tenant row keyed like a declared role (owner) holds that declared role, so keep the keys apart with a check constraint or the code that writes roles.

A membership row that holds roles in more than one column (a plan tier and a role id) lists them as role sources: fromTable columns.role takes an array, and fromJunction takes roles: { sources }, because an array of strings there is fixed roles.

// organization_users (user_id, organization_id, tier text, role_id references roles (id))
fromJunction({
  table: "organization_users",
  scope: "organization",
  roles: {
    sources: [
      "tier",
      { through: "roles", on: { role_id: "id" }, column: "key" },
    ],
  },
});

The row holds every non-null key of its sources: a user with tier = 'member' and a role_id keyed admin gets roles: ["admin", "member"], and a row whose sources are all null holds no membership. The hook, membershipsFor, list and the database mode helpers expand the row with one cross join lateral, and a key rename on any referenced roles table bumps every holder's authorization version. Each source column is a deciding column, and the manifest lists the sources as an array under role.

A membership table whose rows name a profile instead of a login reads the user id through the profile table, with the same shape. CentraKit's portal contacts are customer_contacts (customer_id, organization_id, contact_profile_id), and the login is contact_profiles.user_id:

// contact_profiles (id, user_id references auth.users (id) on delete set null)
// customer_contacts (customer_id, organization_id, contact_profile_id references contact_profiles (id))
fromJunction({
  table: "customer_contacts",
  scope: "customer",
  id: "customer_id",
  within: { organization: "organization_id" },
  user: {
    through: "contact_profiles",
    on: { contact_profile_id: "id" },
    column: "user_id",
  },
  roles: ["customer"],
  via: "contact",
  ...shared,
});

The select joins the profile table and filters on its user column, so a row whose profile is missing or has no user_id holds no membership, and suspension.users checks the joined user id. A source may read both its user and its role through other tables. The hook declares the lookup variable with the profile column's type, so its index applies; rls generate indexes the profile's user column and the reference.

A source's deciding columns (the user, scope, id, within, role, via and expiry columns, the id and key columns of a roles table its role references, and the id and user columns of a table its user references) must not be client-writable, or a user can give themselves a membership: a portal contact who may edit their own customer_contacts row could set user_id on another row, or customer_id on their own. Revoke insert and update on the table from anon and authenticated and grant update back only on the columns clients edit:

revoke insert, update on public.customer_contacts from anon, authenticated;
grant update (name, phone) on public.customer_contacts to authenticated;

A column-level revoke update (user_id) alone is not enough: Postgres keeps a table-level update grant (Supabase's default privileges give one on every public table), and has_column_privilege still answers true. Doctor PD028 warns on each deciding column the migrations leave writable.

On the server, pass the sources as an array. createPermDock composes them with composeMemberships: memberships are merged and de-duplicated, entries that differ in via, expiry, owner or seats stay separate, and a source that throws makes the whole lookup fail closed.

import { claimsFirst, createPermDock } from "permdock";
import { authzVersion, subjectFromSupabase } from "permdock/supabase";

const query: SqlQuery = async (text, values) =>
  (await pool.query(text, [...values])).rows;
const memberships = claimsFirst(sources(query), {
  version: authzVersion({ query }),
});

const { data } = await supabase.auth.getClaims();
const permdock = await createPermDock(
  policy,
  subjectFromSupabase(data?.claims ?? null),
  { memberships },
);

claimsFirst(sources, { version?, onStale? }) keeps the memberships the verified token carries and reads the sources only when the token says it dropped some (memberships_truncated), or, with onStale: 'reread', when the token's authz_ver is behind the version table (one version read, then one memberships read only when the token is behind). Without claimsFirst, a memberships source is always read and the token's copy is ignored. getPermDock in permdock/next takes the same memberships option.

A stored user over PostgREST

The sources above run SQL through SqlQuery. A backend that reaches Postgres only through supabase-js reads the same answers from subject_for(p_user uuid) returns jsonb, which the hook file defines next to the hook: the global roles, the memberships of every source with their expiry and suspension filters, the authorization version, and in database mode with rls.customRoles the custom roles the user holds with their grants and includes. A suspended user comes back as { id, active: false }, an unknown one as null. execute is revoked from public, anon and authenticated, because the caller names the user; grant it to the role your backend client uses. The hook file also defines members_of(p_scope text, p_id text) returns jsonb: every live membership of one scope instance as [{ principal: { id }, membership }], from the same sources with the same expiry and suspension filters, under the same grants. It also defines authz_version_for(p_user uuid) returns bigint: the authorization version subject_for reports, null for an unknown or suspended user, read without the roles and memberships.

The Data API serves only exposed schemas, and permdock is not one. Set supabase.hook.api and hook generate writes a wrapper for each of the three in an exposed schema:

permdock.config.ts
supabase: {
  hook: {
    memberships: [/* ... */],
    api: { schema: "public", prefix: "permdock_" }, // both are the defaults
  },
},

That writes public.permdock_subject_for(p_user uuid), public.permdock_members_of(p_scope text, p_id text) and public.permdock_authz_version_for(p_user uuid). Each is a security definer function with an empty search_path that calls the function in the hook schema, revoked from public, anon and authenticated and granted to service_role: the only grant the hook makes to it, and only with api. Without api, no wrapper is written and nothing is granted to service_role. generate refuses an api that would replace the function it wraps (the hook schema with an empty prefix).

postgrestSources(client, { api?, schema?, fn?, membersFn?, versionFn?, policy? }) in permdock/supabase calls subject_for once per user and members_of once per instance, and returns the three inputs createPermDock takes. sources.memberships.version calls authz_version_for (or the record already read), so claimsFirst(sources.memberships, { onStale: "reread" }) checks the token's freshness with one cheap call per request and reads subject_for only when the token is behind. When the version function does not exist yet, version falls back to subject_for; regenerate the hook file to get it. Pass policy when the app has custom roles: customRoles then reads nothing while every role the subject holds is declared, and version calls subject_for instead of authz_version_for when the token claims a role the policy does not declare, because the custom roles need that record anyway. A current token with only declared roles costs one version call; a custom-role token costs one subject_for call, fresh or stale. sources.memberships.list(query) is the member list countHolders and whoCan read, so a role change can pass the holders decideRoleChange needs:

import { claimsFirst, countHolders, createPermDock } from "permdock";
import { postgrestSources, subjectFromSupabase } from "permdock/supabase";

const sources = postgrestSources(admin, {
  api: { schema: "public", prefix: "permdock_" }, // the supabase.hook.api setting
  policy,
});

// a request: the token's memberships, the database's when it was truncated
const subject = subjectFromSupabase(claims);
const permdock = await createPermDock(policy, subject, {
  memberships: claimsFirst(sources.memberships),
  ...(subject.principal === null
    ? {}
    : { customRoles: sources.customRoles(subject.principal) }),
});

// a job acting for a stored user
const actor = await createPermDock(policy, await sources.subject(userId), {
  customRoles: sources.customRoles({ id: userId }),
});

// the holders a role change counts
const holders = await countHolders(sources.memberships, {
  scope: "organization",
  id: organizationId,
  role: roles.owner,
});

api sets the defaults of schema, fn, membersFn and versionFn to the wrappers supabase.hook.api writes; any of the four still overrides it. Without api they default to the hook schema's own functions, for a client that can reach it. sources.memberships answers membershipsFor and version, so claimsFirst freshness works without a SqlQuery. sources.customRoles(principal) returns the custom roles that principal holds; a role-management page that needs every role of a tenant composes customRoleSource over its own read. sources.subject(userId) is the anonymous subject for a suspended or unknown user, and a failed call rejects, which createPermDock turns into a denial.

Generate

// permdock.config.ts
import { sources } from "./src/memberships.ts";

export default defineConfig({
  policy: "./src/policy.ts",
  supabase: {
    hook: {
      memberships: sources(),
      attrs: {
        table: "profiles",
        columns: ["region", "clearance", "app_metadata.regions"],
      },
    },
  },
});
permdock supabase hook generate --out supabase/migrations/20260929_permdock_hook.sql
permdock supabase hook generate --active-from profiles.active_organization_id --budget 2048
permdock supabase hook generate --check

The output is one idempotent migration:

  • custom_access_token_hook(event jsonb), stable, search_path = '', which writes:
    • roles (every global role) and user_role (a string for one role, an array for several; absent for none, so app_metadata still applies) from supabase.hook.roles, by default rls.roles, else <schema>.user_roles (user_id, role). A role of { through: 'roles', on: { role_id: 'id' }, column: 'key' } reads the key through a roles table, which also gets the auth-admin read. When rls.customRoleWrites.roles names that table with a tenant column, only its rows without a tenant are global roles;
    • memberships: a union all over every source, in the canonical { scope, id, within?, roles, via?, expiresAt?, managedBy?, entitlements?, keep? } form;
    • the active tenant claim (rls.tenantClaim, default tenant_id), only when the user holds a membership in it. The generated RLS helpers narrow to it unless rls.tenants is 'all' (active tenant). The active first-scope id comes from app_metadata.active_<first scope> by default; --active-from (or supabase.hook.activeFrom) takes app_metadata.<key>, <table>.<column> joined on id, or { table, id, column };
    • attrs: the allow-listed attribute columns and app_metadata keys (below), never other columns;
    • authz_ver: the user's authorization version (below);
    • the claims other packages own, from supabase.hook.claims (below).
  • The supabase_auth_admin grants: usage on the schema, execute on the hook, select and a read policy on every table the hook reads (sources, their suspension tables, global roles, every roles table and user table a through references, the attrs table, version). execute is revoked from authenticated, anon and public. Nothing is granted to service_role, except to the supabase.hook.api wrappers when you set it.
  • permdock_authz_version (user_id, version) and the permdock_bump_authz_version trigger on every source table and the global-roles table. With through on the global roles or on a source, permdock_bump_authz_version_role_keys on each referenced roles table bumps every user who holds a renamed role, globally or through a membership, when its key or id changes, since a rename changes their roles and memberships claims. A source that reads its user through another table gets permdock_bump_authz_version_member_users instead, which bumps the users its old and new rows reference, and the referenced table gets permdock_bump_authz_version_linked_users on updates of its user and id columns and on delete, which bumps both the old and the new user when a profile is re-linked to another login. That trigger skips a login that no longer exists in auth.users, so deleting a user whose profile is set to null still succeeds.
  • permdock_bump_authz_version_for(p_users uuid[]), which bumps each listed user once, for membership data the hook does not read (below).
  • The permdock_protect_managed trigger on sources with managedBy.
  • The config.toml block, printed and repeated as a comment:
[auth]
jwt_expiry = 900

[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"

jwt_expiry = 900 bounds how long a demotion can take to reach a token: 15 minutes, set with supabase.hook.jwtExpiry. The command refuses a source whose scope the policy does not declare, a single-scope source without a within column for every ancestor (such a membership grants nothing), a non-positive budget and unsafe identifiers.

Declarative schemas

With pg-delta ([experimental.pgdelta] enabled = true), supabase db schema declarative sync keeps grants and policies, so the hook file keeps its grants. Without --out, hook generate writes it to supabase/schemas/<schema>/functions/custom_access_token_hook.sql, under declarative_schema_path when that is set.

A project that writes migrations with supabase db diff instead cannot keep every privilege in the hook file: db diff drops schema and function privileges, so the hook would land executable by public and not by the auth server. --grants-out moves those statements to a migration of their own and leaves a comment in the hook file that says where they went:

permdock supabase hook generate --out supabase/schemas/identity/056_permdock_hook.sql --grants-out -
supabase db diff -f permdock_hook
supabase migration new permdock_hook_grants
permdock supabase hook generate --out supabase/schemas/identity/056_permdock_hook.sql \
  --grants-out supabase/migrations/<timestamp>_permdock_hook_grants.sql

The grants file starts with -- permdock:grants v1 schema=<schema> and holds usage on each schema the hook reads, execute on the hook and on each supabase.hook.claims function, the revoke on the hook from authenticated, anon and public, the revokes on the version and protection trigger functions, and the revoke on the hook schema from public. The select grants and permdock_auth_admin_read_* policies on the tables the hook reads stay in the hook file, because db diff diffs table privileges and policies and would drop them from a migration that held them. --check compares both files. permdock rls generate --split helpers,policies,hook writes the hook as one part next to the helpers and policies and takes the same --grants-out (declarative schemas). Doctor PD042 errors while no migration from the one that creates the hook on carries the grants.

Attributes

supabase.hook.attrs fills the attrs claim that attribute conditions read: where: { region: principal.claims.attrs.region } compiles to region = ((select auth.jwt()) -> 'attrs' ->> 'region') in RLS and reads principal.claims.attrs.region from subjectFromSupabase in the app, from the same claim.

attrs: {
  table: 'profiles',          // joined on `id` (set `id` for another column)
  columns: ['region', 'clearance', 'app_metadata.regions'],
}
  • A plain entry is a column of table; app_metadata.<key> reads auth.users.raw_app_meta_data, which only the server writes. Each entry's last segment is the claim key, and must match ^[A-Za-z_][A-Za-z0-9_]*$ so a nested ref can name it.
  • Only server-owned values may become attributes. generate refuses user_metadata and raw_user_meta_data in any form, auth.users as the table (use app_metadata.<key>), prototype keys and a key named twice.
  • The migration starts with a guard: when anon or authenticated can insert or update any listed column (has_column_privilege, which counts table-level and public grants), it raises 42501 and installs nothing. Grant clients column-level update on other columns only. permdock doctor PD028 reports the same from your migrations, before you run them.
  • The hook drops any attrs the incoming claims carry and writes its own, so an attribute is never whatever the client sent.

Claims other packages own

supabase.hook.claims adds claims PermDock does not own to the one hook Supabase allows, for example per-tenant plan features from a billing module. PermDock owns the hook and the claims the SQL helpers read; the other package owns its claim and the function that computes it (better-supabase shows one such split):

supabase: {
  hook: {
    memberships: sources(),
    claims: { features: 'public.feature_claims' },
  },
}
  • Each value is a schema-qualified function (user_id uuid) returns jsonb; the hook runs with search_path = '', so an unqualified name is refused. The hook sets the claim to the result, and a null result leaves the claim out.

  • A name PermDock or Supabase Auth writes is refused: roles, user_role, memberships, memberships_truncated, attrs, authz_ver, the tenant claim, and sub, aud, role, exp, iat, iss, aal, amr, session_id, is_anonymous, email, phone, app_metadata, user_metadata.

  • The hook drops each named claim from the incoming claims before it calls the function, so the value is always the function's.

  • The claims sit outside the size budget: the budget decides which memberships a token keeps, and a claim the hook cannot size must not push memberships out. Keep each one small; the function owns its size.

  • A suspended user gets none of them, and the function is not called. A user with no memberships still gets them.

  • The migration grants supabase_auth_admin usage on each function's schema and execute on the function. A function that raises makes the hook fail, so Supabase Auth issues no token.

  • A function that needs the user's memberships calls <schema>.member_<scope>_ids_for(user_id) instead of reading the membership tables itself, so it agrees with the hook and RLS about expiry and suspension:

    create function public.feature_claims(user_id uuid) returns jsonb
    language sql stable as $$
      select jsonb_object_agg(f.organization_id, f.features)
      from public.organization_features f
      where f.organization_id in (select permdock.member_organization_ids_for(user_id))
    $$;

    permdock rls generate writes these helpers (SQL helper contract); with supabase.hook.claims set, the hook migration (or the --grants-out file) grants supabase_auth_admin execute on each, so apply the helpers migration first.

    Why. Auth runs the hook as supabase_auth_admin before a token exists, so auth.uid() and auth.jwt() are empty and member_<scope>_ids() returns nothing. A copy of the membership query in each package drifts from the hook's as soon as a source gains expiry or suspension; one helper that takes the user keeps a single rule. The grant only exists with hook.claims, so a hook with no claims of its own still installs before the helpers.

Checks before the hook

supabase.hook.before runs checks of your own, or of another package, inside the one hook Supabase allows, before PermDock computes any claim. A typical check refuses a password sign-in for a user whose email domain enforces single sign-on, or a sign-in to a locked account:

supabase: {
  hook: {
    memberships: sources(),
    before: 'auth_checks.require_sso', // or a list, run in order
  },
}
create function auth_checks.require_sso(event jsonb) returns jsonb
language plpgsql stable as $$
begin
  if auth_checks.sso_required(event ->> 'user_id')
    and event ->> 'authentication_method' is distinct from 'sso/saml' then
    return jsonb_build_object('error', jsonb_build_object(
      'http_code', 403, 'message', 'Sign in with single sign-on'));
  end if;
  return event;
end;
$$;
  • Each entry is a schema-qualified function (event jsonb) returns jsonb; the hook runs with search_path = '', so an unqualified name is refused.
  • The hook calls each one first, in order, with the event it received. A result with an error key is returned as it is, so Supabase Auth refuses the token with that status and message, and no later function and no PermDock claim runs. A null or non-object result is refused the same way, with status 500 and <function> returned no event.
  • Otherwise the result is the event the hook continues with, so a check may also change incoming claims. PermDock still drops and writes the claims it owns afterwards, so a check cannot set roles, memberships or the tenant claim.
  • The migration (or the --grants-out file) grants supabase_auth_admin usage on each function's schema and execute on the function. Grant it whatever the function reads.
  • permdock supabase inspect lists the functions as hook.before in the manifest.

Why. Supabase Auth calls one access token hook. A package that has to refuse a sign-in, such as single sign-on enforcement, would otherwise need its own hook and lose PermDock's claims, or ask the application to hand-edit the generated function on every regeneration. A check that runs first and can only refuse or pass the event keeps one hook and leaves the claims PermDock owns to PermDock.

Claim validation

supabase.hook.validate: true makes the hook check its own claims against supabase-claims-v1.json with pg_jsonschema, as the last step before it returns:

if not extensions.jsonb_matches_schema('<permdockClaims>'::json, claims) then
  claims := claims - 'user_role' - 'roles' - 'memberships' - 'memberships_truncated' - 'attrs' - 'authz_ver' - 'tenant_id';
end if;
  • The schema is the permdockClaims definition with its membership entries, keep included.
  • On a mismatch the hook drops every claim it owns and returns the event. The user signs in with no roles or memberships, so every PermDock check denies; sign-in itself does not fail.
  • A mismatch comes from data the hook did not produce, such as an incoming tenant claim of the wrong type that no active tenant replaced. hook.claims entries belong to other packages and stay.
  • The migration runs create extension if not exists pg_jsonschema with schema extensions and grants supabase_auth_admin usage on extensions and execute on extensions.jsonb_matches_schema(json, jsonb).
  • Off by default: the check costs one schema compile per token.

Why. A token whose PermDock claims break the schema would fail later, in every reader at once. Dropping them at the source turns that into a sign-in with no access, which is the fail-closed outcome, and keeps a bad row from locking a user out with an Auth error.

Size budget

Claims travel in the session cookie and on every request. The budget, supabaseMembershipsBudget bytes of JSON (1024 by default, measured with octet_length; set with --budget or supabase.hook.budget), covers attrs and memberships together. attrs is sized first: when it alone exceeds the budget it is left out whole and memberships_truncated: true is set, so attribute conditions deny rather than read half an object. The hook then adds memberships in order, the active tenant's first, then source order, and stops before the two together would exceed the budget, again setting memberships_truncated: true.

subjectFromSupabase surfaces the flag as principal.membershipsTruncated. With claimsFirst, the subject then reads every membership from the sources, so a user in 40 organizations still gets all 40 on the server while the token stays small. RLS in jwt mode only sees the kept entries; use database mode (RLS) when members routinely exceed the budget.

Authorization version

A token is a copy, and a copy can be stale: a demoted admin keeps their token until it expires. For most permissions jwt_expiry bounds that. For sensitive ones (removing a member, creating a payout), list them in the policy:

definePolicy(permissions, {
  fresh: [permissions.member.remove, permissions.payout.create],
  // ...
});

The generated triggers bump permdock_authz_version.version for the affected user on every insert, update or delete in a source table or the roles table, and the hook writes the current value as authz_ver. authzVersion({ query }) reads the same table. When the subject's memberships come from the token (claimsFirst without truncation), createPermDock compares the two. If the token is behind, or either side is missing, the subject is stale and every fresh permission denies with reason stale-credentials; other permissions still use the token. Memberships read live from a source are never stale. A stale subject's snapshot omits allows for fresh permissions, so the browser denies them too. A suspension change does not bump the version; RLS checks suspension live instead (RLS).

A table that changes what a user may do without being a hook source, such as better-supabase's entitlement_members, bumps its users with permdock_bump_authz_version_for(array[...]) from its own trigger. The function is security definer, and execute is revoked from public, anon and authenticated and granted to no one, so only the function's owner can call it: a security definer trigger owned by the table owner, or a migration. The manifest names it in authzVersionBump.

Memberships the identity provider owns

A membership provisioned by SCIM belongs to the IdP: an edit in the app would be undone by the next sync. Such memberships carry managedBy: 'idp'. directoryMembershipSource sets it on every group membership, and the SQL sources read it from managedBy. isExternallyManaged(membership) tells a member list to render the row read-only, and decideRoleChange refuses a change whose target.managedBy is idp with reason externally-managed. In the database, permdock_protect_managed raises 42501 when anon or authenticated inserts, updates or deletes an IdP-owned row; the server and the SCIM relay, which connect with their own role, are unaffected.

Plans and seats

entitlements on createPermDock is an EntitlementSource: entitlementsFor(principal, { tenant }) returns plan names for the active tenant, merged into principal.plans, so plan('<name>') grants apply. It is read for the validated active tenant only; a requested tenant without a membership gets none. fromStripeEntitlements({ stripe, customer }) reads Stripe's active entitlements (stripe.entitlements.activeEntitlements.list, every page) for the tenant's Stripe customer and returns their lookup_keys. memoryEntitlementSource is the in-process default.

A seat belongs to one membership, not the tenant: a Dev Mode seat in one organization says nothing about another. Membership.entitlements holds seats (from the sources' seats column), and a plan() grantee matches a seat only through a membership that applies under the active tenant.

Manifest

permdock supabase inspect --out permdock.manifest.json writes what the hook and helpers expect as one JSON file, and --check fails CI when it falls behind the config. It is the stable contract for packages that build on the hook: better-supabase reads it instead of PermDock's config or its generated SQL.

FieldWhat a reader learns
hook, markersWhere custom_access_token_hook lives and the majors of the -- permdock:hook / -- permdock:grants lines its migrations start with
claims, tenantClaim, budget, authzVersion, authzVersionBumpEvery claim the hook writes, who owns it, what the budget measures, and the function that bumps authz_ver for a list of users
helpers, rlsThe helper schema, each helper's arguments, return type and the roles that may execute it, the RLS mode (jwt or database) and each scope's id type
membershipsEach source's table and the columns (or fixed values) for user and role (each with the table it reads through), scope, id, within, via and expiry
rls.membershipsThe same shape for the tables member_<scope>_ids_for reads, which differ from the hook's when rls.memberships or rls.membershipSources is set
rls.helpers (_for and assignment forms)The _for helpers trusted SQL calls for a stored user, and permdock_can_assign_any with the other assignment checks when a role declares assigns
rls.customRoles, rls.roles, rls.suspension, rls.assignmentsWhether custom roles live in tables, the global-roles table, the suspension tables, and the tables the assignment triggers guard
decidingColumnsEvery schema.table.column a membership or an attrs claim is computed from, which clients must not be able to write

The format, its JSON Schema (schemas/supabase-manifest-v1.json) and the versioning rule are on wire formats; supabaseHookManifestFixture in permdock/testing is a sample to test a reader against.

Why

  • A manifest, not a shared config. A package next to the hook needs what the generated SQL reads, not how PermDock's config spells it. A versioned file that only gains fields keeps the two packages on separate release schedules, and a committed copy lets its doctor check a project without loading the PermDock config.
  • One definition, two readers. The sources run the exact select the hook compiles, and the integration suite checks that the claim equals what composeMemberships returns for the same user. A hand-written hook next to a hand-written source is two definitions of who belongs where.
  • The active scope first, then truncation. A token that cannot hold every membership should hold the ones the next request needs. Truncating silently would turn a large customer into a user with random missing access; the flag makes the server read the rest.
  • Roles are read by key, through a roles table when the app has one. Role grants, role_permissions seeds, the roles claim and each membership's roles all name roles by key, while apps that manage roles in a UI usually keep user_roles.role_id and organization_users.role_id referencing a roles table. Copying keys into those tables would make a rename a data migration; joining at read time keeps one source of truth, and the version trigger on roles makes a rename reach existing tokens at the next fresh check. Global roles and membership sources take the same { through, on, column } shape, so one roles table serves both and gets one trigger.
  • A membership's user can be read through a profile table. Portal contacts are often rows of a contact or profile table that a login is attached to later, or moved between logins. Copying user_id onto every membership row would make each re-link a fan-out update that the app has to keep consistent; reading it through the profile keeps one place to change, and the trigger on that column bumps both logins, so the old one loses the memberships and the new one gains them at the next fresh check.
  • A version, not a shorter expiry, for sensitive verbs. Dropping jwt_expiry to a minute taxes every request to protect a few. The version costs one indexed lookup per request that uses claimsFirst with version, and only fresh permissions depend on it. Missing either side counts as stale, because a check that passes when it cannot compare fails open.
  • Attributes are server-owned or absent. An attribute that decides row access and that the user can edit is a self-grant. Refusing user_metadata catches the obvious case; the migration guard catches a profiles table with a broad update grant, which is the common one.
  • IdP ownership is enforced where edits happen. The member list, decideRoleChange and the database all refuse, so a client that skips the UI still cannot edit a SCIM-provisioned row.
  • Grants in their own migration for db diff. db diff reads the schema files back from a shadow database and leaves out what it cannot diff for the auth role. A separate, hand-created migration keeps those statements in the history in the right order; putting them in the schema file would make them silently disappear, and a hook the auth server cannot call fails every sign-in. pg-delta diffs grants, which was checked against declarative sync in Supabase CLI 2.119, so under pg-delta the grants stay in the hook file.
  • uid is a uuid. Supabase Auth issues uuid user ids, so the hook casts user_id once and a malformed one fails the sign-in. Each %type variable is then assigned from a uuid, which supabase db lint accepts.
  • Plans from billing, seats per membership. Stripe recommends reading entitlements as a projection of billing state; a per-membership seat keeps "which products this person uses here" apart from permissions and from other tenants.

Last updated on

On this page