Authorization providers
Let another authorization system answer permission checks in the SQL modules, bucket and topic policies, API keys and doctor, through one versioned config key.
An authorization provider is a plain object in the authorization key of
better-supabase.config.ts. It tells better-supabase which SQL functions
answer "does this user hold this permission", which scopes exist, which
membership tables the provider reads, and which claims its access token hook
writes. better-supabase never imports the provider: the object is data, and
the provider's own package builds it, usually from the files that package
generates.
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "your-authorization-package";
export default defineConfig({
authorization: authorizationProvider(),
sql: { modules: { access: { model: "provider" }, organizations: {} } },
});PermDock builds one from its manifest and catalog.
The provider covers the database side. Permission checks in your server
code (a resource's permissions, an action's or MCP tool's permission, a
route guard) go to a runtime authorizer
instead, passed as authorizer to the server. An authorization package can
ship both: the provider for the config, and an authorizer for the server
that answers with the same permission keys.
What reads it
| Feature | What it takes from the provider |
|---|---|
sql.modules.access.model: "provider" | can() and the modules' checks call functions, at tenantScope |
entitlements.memberships: "provider" | has_entitlement and entitlement_members() call memberIds and memberIdsFor |
Bucket and topic access policies with sql | the idsWith and isPlatform templates, for any scope in scopes |
API keys with claim | nothing at runtime; the claim names follow the provider's functions |
better-supabase sql add tenant | refuses when tokenHook.ownedClaims has memberships (--force writes it) |
better-supabase gen | refuses a bucket or topic key that isn't sqlComplete: true |
| Doctor BS107, BS213, BS214, BS324, BS404, BS405, BS407 to BS409, BS411 | requires, decidingColumns, permissions, memberships, tokenHook |
entitlements.memberships defaults to "provider" when authorization is
set. The access model stays what sql.modules.access.model says.
The contract
interface AuthorizationProvider {
apiVersion: 1;
name: string;
scopes: { name: string; idType?: string; parent?: string }[];
tenantScope: string;
functions: AuthorizationFunctions;
requires?: { function: string; args?: string; role: string }[];
permissions?: { key: string; sqlComplete?: boolean; scopes?: string[] }[];
memberships?: {
table: string;
userColumn: string;
scope: { column: string } | { value: string };
idColumn: string;
}[];
suspension?: { user?: DisabledRow; tenant?: DisabledRow };
roleSources?: AuthorizationRoleSource[];
decidingColumns?: string[];
tokenHook?: AuthorizationTokenHook;
approvals?: { distinctApprover?: boolean };
problems?: string[];
}AuthorizationProvider and its parts are exported from
better-supabase/config. A scope name is lower case ([a-z][a-z0-9_]*),
because it goes into templates unquoted. problems lists what the provider
found wrong while building the object; doctor reports each one.
The CLI validates the object when it loads the config. Every scope name in
tenantScope and parent must be declared, parents must not form a cycle,
the tenant scope needs an idType, and idType is uuid, text, bigint
or integer. Each decidingColumns entry is schema.table.column, and
tokenHook.budget.bytes is a positive integer. An object with another
apiVersion gets one message naming both versions instead of a list of field
errors.
Functions
Each function is a SQL template. better-supabase fills the placeholders and puts the result into a policy or a module function:
| Template | Placeholders | Returns |
|---|---|---|
idsWith | {permission}, {scope} | a set of the {scope} ids where the caller holds the key |
isPlatform | {permission} | boolean: the caller holds the key platform-wide |
idsWithFor | {user}, {permission}, {scope} | as idsWith, for another user |
isPlatformFor | {user}, {permission} | as isPlatform, for another user |
memberIds | {scope} | the {scope} ids the caller is a member of |
memberIdsFor | {user}, {scope} | as memberIds, for another user |
canAssign | {role}, {tenant}, {scope} | boolean: the caller may give {role} in {tenant} |
canAssignFor | {user}, {role}, {tenant}, {scope} | as canAssign, for another user |
permissionsFor | {user}, {tenant}, {scope} | text[]: the permission keys {user} holds in {tenant} |
canApprove | {tool}, {tenant}, {scope} | boolean: the caller may decide the tool call {tool} |
idsWith and isPlatform are required. A template that leaves out a For
variant makes the module function answer for the caller only: called for
another user, it raises SQLSTATE 0A000. Without canAssign, only the
service role assigns roles: the access module's can_assign answers false
for every member, owners included.
Some modules call the optional templates under the provider model:
invitations calls idsWithFor, isPlatformFor and canAssignFor to check
the inviter again when an invitation is accepted; inbox, comments, sso,
notifications, connectors and workflow-sdk-world call idsWithFor; and
usage reads memberships through memberIds. Doctor (BS411) warns for each
installed module whose template the provider doesn't set.
permissionsFor fills better_supabase.member_permissions and
permission_claims, so the access token hook can carry permission keys. The
claims cover the tenants memberIdsFor returns; without memberIdsFor they
stay empty. Without permissionsFor, both functions return nothing, as
before.
functions: {
idsWith: "authz.{scope}_ids_with({permission})",
isPlatform: "authz.is_platform({permission})",
memberIds: "authz.member_{scope}_ids()",
canAssign: "authz.can_assign({role}, {tenant}::text)",
},
requires: [
{ function: "authz.organization_ids_with", args: "text", role: "authenticated" },
{ function: "authz.is_platform", args: "text", role: "authenticated" },
{ function: "authz.member_organization_ids", role: "authenticated" },
{ function: "authz.can_assign", args: "text, text", role: "authenticated" },
],requires lists every function the templates call, with {scope} filled,
and the role that must be able to execute it. Doctor (BS411) checks the
database has each one, and the conformance block checks the list is complete.
idType is the Postgres type of a scope's ids: uuid, text, bigint or
integer.
Permissions
permissions lists the keys the provider knows. sqlComplete: true says its
SQL functions answer the key completely: a grant has no row condition beyond
the scope. Bucket and topic policies and the SQL modules check by role and
scope only, so gen, sql add and doctor (BS214, BS411) refuse a key that
isn't marked. Leave row-conditioned keys to the provider's table policies.
Token hook
tokenHook describes the custom access token hook the provider generates:
| Field | Used for |
|---|---|
function | BS404 checks its grants, BS405 its shape |
tenantClaim | BS409 compares it with claims.tenant |
ownedClaims | BS407 reports another hook that writes them; sql add tenant stops on memberships |
registeredClaims | BS407 accepts these claims from the functions named, such as features |
budget | BS405 measures the claims it lists against bytes for doctor --as |
markers | comment lines in the provider's files, so doctor recognises its hook and grants migration |
grantsCommand | the command BS404 suggests |
Tool approvals
canApprove decides who may approve or deny a waiting AI tool call in the
ai-chat block. Under the provider model,
decide_ai_tool_approval lets the service role and any caller canApprove
allows decide the call; {tenant} is the chat's tenant and {tool} the
tool's name. approvals.distinctApprover: true keeps the chat's owner, whose
agent asked for the call, from deciding it, for four-eyes approval. Without
canApprove, the chat's owner decides.
functions: {
// ...
canApprove: "authz.can_approve_tool({tool}, {tenant}::text)",
},
approvals: { distinctApprover: true },Bucket and topic policies
An access policy calls the access contract (tenant_ids_with,
is_platform) by default. Give it the provider's templates in sql, and it
calls those instead, for any scope they take. sql: "provider" uses the
provider's idsWith and isPlatform: better-supabase gen writes them into
the generated bucket meta, and better-supabase sql sync passes them to each
topic's sql(). A copy of the templates also works, so the bucket module
doesn't import the config; doctor (BS214) warns when the copy differs from the
provider.
import { defineBucket } from "better-supabase/storage";
export const documents = defineBucket({
id: "documents",
path: "{organizationId}/{documentId}/{name}",
policy: {
access: { read: "documents.browse", write: "documents.write" },
scope: "organization",
sql: "provider",
},
});A topic with sql: "provider" throws when its sql() runs without
templates. Pass them yourself outside sql sync:
topic.sql({ functions: config.authorization.functions });The scope id is compared as text, so a path or topic segment must be the id's
lowercase form. segment picks another path segment than {organizationId}.
Test a provider
testAuthorizationProvider from better-supabase/testing checks API v1,
plain JSON, valid scopes (known names, no parent cycle, a tenant scope with an
idType), the placeholders of each template (each uses its {permission},
{user}, {tenant} and {role}, and idsWith and idsWithFor use
{scope} when there is more than one scope), that templates are safe to
inline (no function call without a schema, no $$, ; or --), that
requires lists every function the templates call, the permission list, and
the shapes of
memberships, suspension, roleSources, decidingColumns and
tokenHook: ownedClaims is not empty, a registeredClaims entry is not also
owned, budget.claims are owned or registered, and budget.bytes is a
positive integer. See conformance blocks.
import { testAuthorizationProvider } from "better-supabase/testing";
import { it } from "vitest";
it("conforms", () => testAuthorizationProvider(authorizationProvider()));A provider for a new contract version sets another apiVersion; this release
refuses anything but 1 with a message naming both versions.
Last updated on
Authorizers
The runtime authorization decision point that resources, actions, route guards and MCP tools ask for their permission, in the AuthZEN request model, failing closed.
Document formats
Render the API model to a standard better-supabase does not ship, such as a Postman collection or a GraphQL schema, with defineDocumentFormat and testDocumentFormat.