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

Source: https://bettersupabase.com/docs/extending/authorization-providers

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.

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "your-authorization-package";

export default defineConfig({
  authorization: authorizationProvider(),
  sql: { modules: { access: { model: "provider" }, organizations: {} } },
});
```

[PermDock](https://permdock.com/docs/adapters/better-supabase) 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](/docs/extending/authorizers)
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 [#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](/docs/blocks/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](/docs/cli/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 [#the-contract]

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

```ts
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]

`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 [#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 [#tool-approvals]

`canApprove` decides who may approve or deny a waiting AI tool call in the
[ai-chat block](/docs/blocks/ai-chat). 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.

```ts
functions: {
  // ...
  canApprove: "authz.can_approve_tool({tool}, {tenant}::text)",
},
approvals: { distinctApprover: true },
```

## Bucket and topic policies [#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.

```ts title="src/storage/documents.ts"
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`:

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

```ts title="tests/provider.test.ts"
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.