Access contract
permissionscan(), tenant_ids_with() and is_platform(): one permission contract for policies and SQL modules, over a roles list, a role and permission catalog, an authorization provider or the app's own functions (blocks.permissions.model).
Identity & accessReact hooksmanagedadoptcustom
organizationsRLS on
tenantroleupdated_atacmeowner2m agoglobexadmin1h agoinitechmember3d ago
Install
The command also installs updated-at, memberships, the modules it needs.
npx better-supabase add permissionsConfigure
The blocks entries in your config, with the default mode.
import { defineConfig } from "better-supabase/config";
export default defineConfig({
blocks: {
// Required by permissions; add installs them too.
"updated-at": {},
memberships: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
permissions: { mode: "managed" },
},
});Migrations
The files
npx better-supabase add permissions writes into your declarative schema with the default layout.6 files
supabase/schemas/900_better_supabase_19_permissions.sqlschema400 lines-- better-supabase module: permissions
-- @bsb-block permissions@1 managed
-- can(), tenant_ids_with() and is_platform(): one permission contract for policies and SQL modules, over a roles list, a role and permission catalog, an authorization provider or the app's own functions (blocks.permissions.model).
-- Managed by `better-supabase add`; re-running it overwrites this file.
-- Change it through `blocks` in better-supabase.config.ts and the module's SQL hooks.
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
grant usage on schema better_supabase to supabase_auth_admin;
-- Disabled tenants and users get no permissions and no membership claims
-- (blocks.permissions.disabled; with the managed organizations module, its
-- disabled_at and deleted_at columns).
create or replace function better_supabase.tenant_disabled(tenant uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select false
$$;
create or replace function better_supabase.user_disabled(user_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select false
$$;
revoke execute on function better_supabase.tenant_disabled(uuid) from public, anon, authenticated;
revoke execute on function better_supabase.user_disabled(uuid) from public, anon, authenticated;
grant execute on function better_supabase.tenant_disabled(uuid) to service_role, supabase_auth_admin;
grant execute on function better_supabase.user_disabled(uuid) to service_role, supabase_auth_admin;
-- `*` grants every key and `prefix.*` every key under prefix.
create or replace function better_supabase.permission_matches(granted text, wanted text)
returns boolean
language sql
immutable
set search_path = ''
as $$
select granted = '*' or granted = wanted
or (right(granted, 2) = '.*' and starts_with(wanted, left(granted, -1)))
$$;
-- The permission keys an OAuth scope grants a delegated token
-- (authorization.delegation.scopes, blocks.permissions.scopes). A scope
-- without an entry grants the key with its own name.
create or replace function better_supabase.scope_permissions(scope text)
returns text[]
language sql
immutable
set search_path = ''
as $$
select array[scope]
$$;
-- False when the token is narrower than the permission: a permission cap
-- (bsb_permission_cap) without the key, or a delegated token whose scopes
-- don't map to it. True for every other token, the service role included.
create or replace function better_supabase.cap_allows(permission text)
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when jwt is null then true
when jsonb_typeof(jwt -> 'bsb_permission_cap') = 'array'
and not exists (
select 1 from jsonb_array_elements_text(jwt -> 'bsb_permission_cap') c(key)
where better_supabase.permission_matches(c.key, cap_allows.permission)
) then false
when coalesce(jwt ->> 'client_id', '') = ''
and (jwt -> 'act' is null
or (jsonb_typeof(jwt -> 'act') = 'object'
and jwt -> 'act' ->> 'kind' in ('support', 'impersonation'))) then true
else exists (
select 1
from unnest(case jsonb_typeof(jwt -> 'scope')
when 'string' then regexp_split_to_array(btrim(jwt ->> 'scope'), '\s+')
when 'array' then array(select jsonb_array_elements_text(jwt -> 'scope'))
else '{}'::text[]
end) s(scope)
cross join lateral unnest(better_supabase.scope_permissions(s.scope)) k(key)
where s.scope <> ''
and better_supabase.permission_matches(k.key, cap_allows.permission)
)
end
from (select auth.jwt() as jwt) as token
$$;
-- The permission key an operation (<module>.<action>, or a name in
-- authorization.operations) checks, after renames and the provider's table.
-- Null for an operation nobody declared, which callers refuse.
create or replace function better_supabase.operation_permission(op text)
returns text
language sql
immutable
set search_path = ''
as $$
select null::text
$$;
-- The roles model (blocks.permissions.roles): each role's permission keys.
create or replace function better_supabase.role_permissions(role text)
returns text[]
language sql
immutable
set search_path = ''
as $$
select case role
when 'owner' then array['*']::text[]
when 'admin' then array['organization.read', 'organization.update', 'members.*', 'audit.read', 'audit.revert', 'webhooks.*', 'notifications.*', 'billing.*', 'api_keys.*', 'settings.*', 'usage.*', 'activity.read', 'attachments.*', 'organization.export', 'onboarding.*', 'inbox.*', 'ai_chat.*', 'ai.*', 'tags.*', 'teams.*', 'custom_fields.*', 'share_links.*', 'reminders.*', 'imports.*', 'views.*', 'relations.*', 'bulk.*', 'search.*', 'metrics.*', 'calendar.*', 'bookings.*', 'versions.*', 'trash.*', 'reactions.*', 'approvals.*', 'access_requests.*', 'documents.*', 'drive.*', 'forms.*', 'signatures.*', 'templates.*', 'time_tracking.*', 'service_principals.*', 'pipelines.*', 'parties.*', 'engagements.*', 'deals.*', 'work_items.*', 'quotes.*', 'invoices.*', 'sla.*', 'targets.*', 'hiring.*', 'employees.*', 'collections.*', 'messages.*', 'channels.*', 'mail.*', 'segments.*', 'assignment.*', 'enrichment.*', 'forecasts.*', 'history.*', 'domains.*', 'rules.*', 'reviews.*', 'leave.*', 'ledger.*', 'assets.*', 'scores.*', 'moderation.*', 'enrollments.*', 'guests.*', 'passes.*', 'billable_time.*', 'leases.*', 'expenses.*', 'donations.*', 'dispatch.*', 'shipments.*', 'contracts.*', 'controls.*', 'incidents.*', 'courses.*', 'sequences.*', 'posts.*']::text[]
when 'member' then array['organization.read', 'members.read', 'notifications.read', 'api_keys.own', 'settings.read', 'activity.read', 'attachments.read', 'attachments.upload', 'onboarding.read', 'inbox.read', 'inbox.reply', 'ai_chat.read', 'ai_chat.create', 'ai_chat.share', 'ai.read', 'ai.create', 'ai.share', 'custom_fields.read', 'teams.read', 'tags.read', 'tags.apply', 'share_links.read', 'reminders.read', 'views.read', 'relations.read', 'bulk.run', 'search.read', 'metrics.read', 'calendar.read', 'calendar.write', 'bookings.read', 'bookings.create', 'versions.read', 'versions.create', 'versions.restore', 'trash.read', 'trash.create', 'trash.restore', 'reactions.read', 'reactions.create', 'approvals.read', 'approvals.request', 'access_requests.create', 'documents.read', 'documents.write', 'documents.suggest', 'drive.read', 'drive.create', 'forms.read', 'forms.submit', 'signatures.read', 'signatures.request', 'templates.read', 'templates.apply', 'time_tracking.track', 'leave.request', 'pipelines.read', 'parties.read', 'parties.write', 'engagements.read', 'engagements.write', 'deals.read', 'deals.write', 'work_items.read', 'work_items.write', 'quotes.read', 'quotes.write', 'invoices.read', 'sla.read', 'targets.read', 'employees.read', 'collections.read', 'collections.write', 'domains.read', 'rules.read', 'assets.read', 'scores.read', 'moderation.report', 'enrollments.read', 'leases.maintain', 'expenses.submit', 'dispatch.read', 'dispatch.work', 'shipments.read', 'contracts.read', 'contracts.write', 'controls.read', 'controls.collect', 'controls.acknowledge', 'incidents.read', 'incidents.write', 'courses.read', 'sequences.read', 'sequences.enroll', 'posts.read', 'posts.write', 'messages.read', 'messages.send', 'channels.create', 'mail.read', 'mail.connect', 'mail.send', 'segments.read', 'assignment.read', 'enrichment.read', 'forecasts.read', 'history.read']::text[]
when 'viewer' then array['organization.read']::text[]
else '{}'::text[]
end
$$;
create or replace function better_supabase.role_grants(role text, permission text)
returns boolean
language sql
immutable
set search_path = ''
as $$
select exists (
select 1 from unnest(better_supabase.role_permissions(role)) g(key)
where better_supabase.permission_matches(g.key, permission)
)
$$;
revoke execute on function better_supabase.role_permissions(text) from public, anon, authenticated;
revoke execute on function better_supabase.role_grants(text, text) from public, anon, authenticated;
grant execute on function better_supabase.role_permissions(text) to service_role, supabase_auth_admin;
grant execute on function better_supabase.role_grants(text, text) to service_role, supabase_auth_admin;
create or replace function better_supabase.member_can(member uuid, tenant uuid, permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select member is not null
and not better_supabase.user_disabled(member)
and not better_supabase.tenant_disabled(tenant)
and (member is distinct from auth.uid() or better_supabase.cap_allows(member_can.permission))
and exists (
select 1 from "better_supabase"."memberships" m
where m."organization_id" = member_can.tenant and m."user_id" = member_can.member and m."disabled_at" is null
and better_supabase.role_grants(m."role", member_can.permission)
)
$$;
-- member_can without the disabled-tenant check, for the actions a member
-- takes while the tenant waits to be purged, such as cancelling the deletion.
create or replace function better_supabase.member_can_while_deleting(member uuid, tenant uuid, permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select member is not null
and not better_supabase.user_disabled(member)
and (member is distinct from auth.uid() or better_supabase.cap_allows(member_can_while_deleting.permission))
and exists (
select 1 from "better_supabase"."memberships" m
where m."organization_id" = member_can_while_deleting.tenant and m."user_id" = member_can_while_deleting.member and m."disabled_at" is null
and better_supabase.role_grants(m."role", member_can_while_deleting.permission)
)
$$;
-- The permission keys a member holds in a tenant (wildcards as configured).
create or replace function better_supabase.member_permissions(member uuid, tenant uuid)
returns text[]
language sql
stable
security definer
set search_path = ''
as $$
select coalesce((
select better_supabase.role_permissions(m."role") from "better_supabase"."memberships" m
where m."organization_id" = member_permissions.tenant and m."user_id" = member_permissions.member and m."disabled_at" is null
), '{}'::text[])
$$;
create or replace function better_supabase.platform_can(member uuid, permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select member is not null
and (platform_can.member is distinct from auth.uid() or auth.jwt() -> 'act' is null)
and not better_supabase.user_disabled(member)
and (member is distinct from auth.uid() or better_supabase.cap_allows(platform_can.permission))
and ((platform_can.member = auth.uid() and exists (
select 1
from jsonb_array_elements_text(
case when jsonb_typeof(coalesce(auth.jwt() -> 'platform_permissions', auth.jwt() -> 'app_metadata' -> 'platform_permissions')) = 'array'
then coalesce(auth.jwt() -> 'platform_permissions', auth.jwt() -> 'app_metadata' -> 'platform_permissions')
else '[]'::jsonb end
) g(key)
where better_supabase.permission_matches(g.key, platform_can.permission)
)))
$$;
create or replace function better_supabase.tenant_ids_with(permission text)
returns setof uuid
language sql
rows 1
stable
security definer
set search_path = ''
as $$
select m."organization_id"
from "better_supabase"."memberships" m
where m."user_id" = (select auth.uid())
and not better_supabase.user_disabled(auth.uid())
and not better_supabase.tenant_disabled(m."organization_id") and m."disabled_at" is null
and better_supabase.cap_allows(tenant_ids_with.permission)
and better_supabase.role_grants(m."role", tenant_ids_with.permission)
$$;
-- Members assign roles up to their own permissions: every key the role
-- grants must be one the member holds in the tenant.
create or replace function better_supabase.can_assign_as(member uuid, tenant uuid, role text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select member is not null
and exists (
select 1 from "better_supabase"."memberships" m
where m."organization_id" = can_assign_as.tenant and m."user_id" = can_assign_as.member and m."disabled_at" is null
)
and not exists (
select 1 from unnest(better_supabase.role_permissions(can_assign_as.role)) k(key) where true
and not coalesce(better_supabase.member_can(can_assign_as.member, can_assign_as.tenant, k.key), false)
)
$$;
create or replace function better_supabase.can_assign(tenant uuid, role text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(auth.jwt() ->> 'role', '') = 'service_role' or better_supabase.can_assign_as(auth.uid(), can_assign.tenant, can_assign.role)
$$;
create or replace function better_supabase.permission_claims(user_id uuid)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(jsonb_object_agg(m."organization_id"::text, to_jsonb(better_supabase.member_permissions(m."user_id", m."organization_id"))), '{}'::jsonb)
from "better_supabase"."memberships" m
where m."user_id" = permission_claims.user_id
and not better_supabase.user_disabled(permission_claims.user_id)
and not better_supabase.tenant_disabled(m."organization_id") and m."disabled_at" is null
$$;
-- The access contract. Policies and SQL modules call these, never a model's
-- tables, so the model can change without touching them. In a policy, use
-- the set, which runs once per statement and can use the tenant index:
-- using (organization_id in (select better_supabase.tenant_ids_with('invoices.read')))
-- can() with a column argument runs once per row, even inside (select ...);
-- keep it for checks on one row and inside functions.
create or replace function better_supabase.can(scope text, scope_id uuid, permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select case
when can.scope = 'platform' then coalesce(better_supabase.platform_can(auth.uid(), can.permission), false)
when can.scope in ('organization', 'tenant') then coalesce(better_supabase.member_can(auth.uid(), can.scope_id, can.permission), false)
else false
end
$$;
-- The same question for another user, for checks such as an inviter's
-- authority at accept time. Null when the model can't answer for others.
create or replace function better_supabase.can_user(member uuid, scope text, scope_id uuid, permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select case
when can_user.scope = 'platform' then better_supabase.platform_can(can_user.member, can_user.permission)
when can_user.scope in ('organization', 'tenant') then better_supabase.member_can(can_user.member, can_user.scope_id, can_user.permission)
else false
end
$$;
create or replace function better_supabase.is_platform(permission text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(better_supabase.platform_can(auth.uid(), is_platform.permission), false)
$$;
-- The caller's permission keys in a tenant, for client checks. With keys,
-- the ones can() grants: every model answers, including a provider and
-- delegated tokens. Without them, the keys the model lists for the member,
-- wildcards as granted (the custom model lists none).
create or replace function better_supabase.my_permissions(tenant uuid, keys text[] default null)
returns text[]
language sql
stable
security definer
set search_path = ''
as $$
select case
when auth.uid() is null or my_permissions.tenant is null then '{}'::text[]
when my_permissions.keys is not null then coalesce((
select array_agg(distinct k order by k)
from unnest(my_permissions.keys) as k
where better_supabase.can('tenant', my_permissions.tenant, k)
), '{}'::text[])
when better_supabase.tenant_disabled(my_permissions.tenant) then '{}'::text[]
else coalesce((
select array_agg(distinct p order by p)
from unnest(better_supabase.member_permissions(auth.uid(), my_permissions.tenant)) as p
where better_supabase.cap_allows(p)
), '{}'::text[])
end
$$;
revoke execute on function better_supabase.permission_matches(text, text) from public;
grant execute on function better_supabase.permission_matches(text, text) to anon, authenticated, service_role;
revoke execute on function better_supabase.scope_permissions(text) from public;
grant execute on function better_supabase.scope_permissions(text) to anon, authenticated, service_role;
revoke execute on function better_supabase.cap_allows(text) from public;
grant execute on function better_supabase.cap_allows(text) to anon, authenticated, service_role;
revoke execute on function better_supabase.operation_permission(text) from public;
grant execute on function better_supabase.operation_permission(text) to anon, authenticated, service_role;
revoke execute on function better_supabase.can(text, uuid, text) from public;
grant execute on function better_supabase.can(text, uuid, text) to anon, authenticated, service_role;
revoke execute on function better_supabase.tenant_ids_with(text) from public;
grant execute on function better_supabase.tenant_ids_with(text) to anon, authenticated, service_role;
revoke execute on function better_supabase.is_platform(text) from public;
grant execute on function better_supabase.is_platform(text) to anon, authenticated, service_role;
revoke execute on function better_supabase.can_assign(uuid, text) from public;
grant execute on function better_supabase.can_assign(uuid, text) to anon, authenticated, service_role;
revoke execute on function better_supabase.my_permissions(uuid, text[]) from public;
grant execute on function better_supabase.my_permissions(uuid, text[]) to anon, authenticated, service_role;
revoke execute on function better_supabase.member_can(uuid, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.member_can(uuid, uuid, text) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.member_can_while_deleting(uuid, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.member_can_while_deleting(uuid, uuid, text) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.member_permissions(uuid, uuid) from public, anon, authenticated;
grant execute on function better_supabase.member_permissions(uuid, uuid) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.platform_can(uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.platform_can(uuid, text) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.can_user(uuid, text, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.can_user(uuid, text, uuid, text) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.permission_claims(uuid) from public, anon, authenticated;
grant execute on function better_supabase.permission_claims(uuid) to service_role, supabase_auth_admin;
revoke execute on function better_supabase.can_assign_as(uuid, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.can_assign_as(uuid, uuid, text) to service_role, supabase_auth_admin;
-- The permission claim: { [tenant id]: permission keys }, for the access
-- token hook when the app checks permissions from the token:
-- return jsonb_set(event, '{claims,permissions}',
-- better_supabase.permission_claims((event ->> 'user_id')::uuid));
create schema if not exists better_supabase;
create table if not exists better_supabase.blocks (
name text primary key,
version integer not null,
mode text not null,
installed_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table better_supabase.blocks enable row level security;
revoke all on better_supabase.blocks from anon, authenticated;
grant select on better_supabase.blocks to service_role;
Client
The React hooks are in better-supabase/blocks/permissions/react. They return data and actions and render no UI, so you build the interface from your own components.
Events
With the outbox installed, the module writes these in the same transaction as the change.
This module writes no outbox events.
Dependencies
- Required: the install command adds it too
- Works with: optional
Requires
Works with
Recipes
More in Identity & access
Actor stampingcreated-by Sets created_by on insert and updated_by on every write from auth.uid(); an update keeps created_by and the impersonation stamp. Service writes keep the values they send. Data API grantsdata-api-grants Writes the complete Data API privileges of the tables and functions in the `expose` config for anon, authenticated and service_role, or derives table grants from the policies. A project with default privileges turned off grants new tables to no Data API role. MFA enforcementmfa mfa_satisfied() for restrictive policies: true when the caller has no verified factor, or verified one in this session (aal2).React hooks Session revocationsession-revocation session_active() for restrictive policies: false once the caller's session was signed out or expired, or the user was banned or deleted, so revoked access tokens stop working before they expire. options.policies writes the policy on every table.React hooks SSO and SCIMsso Verified email domains with auto-join and SSO enforcement, SAML providers per organization registered with Supabase Auth, and SCIM 2.0 users and groups that provision memberships and map groups to roles. Subjectssubjects The app-wide subject registry from config.records: subject_title, subject_url, subject_titles, can_subject, can_subject_for and record_summary for every registered type, my_work across the installed blocks, and Realtime presence on presence:<type>:<id> for readers of the subject.React hooks