All blocks
Foundation
Tenant memberships and permission helper
Memberships with roles, member_organization_ids() and has_organization_role() for RLS policies, and membership_claims() for the access token hook. Adopt an existing memberships table through sql.modules.tenant.
npx better-supabase sql add tenantimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by tenant; sql add installs them too.
"updated-at": {},
// Modes: "managed", "adopt", "custom". The default is "managed".
tenant: { mode: "managed" },
},
},
});npx better-supabase sql add tenant writes these files into your declarative schema with the default layout. See SQL modules for the migration step and the layout options.
supabase/schemas/900_better_supabase_01_updated_at.sqlschemaupdated-at100 linessupabase/better-supabase-data/900_better_supabase_01_updated_at.sqldataupdated-at12 linessupabase/schemas/900_better_supabase_04_tenant.sqlschema181 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldata12 lines
supabase/schemas/900_better_supabase_04_tenant.sql
-- better-supabase module: tenant
-- @bs-module tenant@2 managed
-- Memberships with roles, member_organization_ids() and has_organization_role() for RLS policies, and membership_claims() for the access token hook. Adopt an existing memberships table through sql.modules.tenant.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` 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;
create table if not exists "better_supabase"."memberships" (
"organization_id" uuid not null,
"user_id" uuid not null references auth.users (id) on delete cascade,
"role" text not null default 'member',
"created_at" timestamptz not null default now(),
primary key ("organization_id", "user_id")
);
create index if not exists memberships_user_idx on "better_supabase"."memberships" ("user_id");
alter table "better_supabase"."memberships" add column if not exists "updated_at" timestamptz not null default now();
drop trigger if exists bs_updated_at on "better_supabase"."memberships";
create trigger bs_updated_at before update on "better_supabase"."memberships"
for each row execute function better_supabase.set_updated_at('updated_at');
alter table "better_supabase"."memberships" add column if not exists "last_used_at" timestamptz;
alter table "better_supabase"."memberships" add column if not exists "disabled_at" timestamptz;
alter table "better_supabase"."memberships" drop constraint if exists memberships_role_check;
alter table "better_supabase"."memberships"
add constraint memberships_role_check check ("role" in ('owner', 'admin', 'member', 'viewer'));
alter table "better_supabase"."memberships" enable row level security;
grant select on "better_supabase"."memberships" to authenticated;
grant all on "better_supabase"."memberships" to service_role;
-- Disabled tenants and users get no permissions and no membership claims
-- (modules.access.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;
-- The tenant the server resolved (the better_supabase.tenant setting over Postgres,
-- the x-bs-tenant header over the Data API), then the `tenant_id` claim.
-- A tenant the caller isn't a member of counts as none.
create or replace function better_supabase.current_tenant_id()
returns uuid
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(
(select m."organization_id"::text from "better_supabase"."memberships" m where m."organization_id"::text = nullif(current_setting('better_supabase.tenant', true), '') and m."user_id" = auth.uid() limit 1),
(select m."organization_id"::text from "better_supabase"."memberships" m where m."organization_id"::text = nullif(current_setting('request.headers', true)::jsonb ->> 'x-bs-tenant', '') and m."user_id" = auth.uid() limit 1),
(select m."organization_id"::text from "better_supabase"."memberships" m where m."organization_id"::text = nullif(coalesce(auth.jwt() ->> 'tenant_id', auth.jwt() -> 'app_metadata' ->> 'tenant_id'), '') and m."user_id" = auth.uid() limit 1)
)::uuid
$$;
revoke execute on function better_supabase.current_tenant_id() from public;
grant execute on function better_supabase.current_tenant_id() to anon, authenticated, service_role, supabase_auth_admin;
-- Policies compare against the set once per statement:
-- using (organization_id in (select better_supabase.member_organization_ids('{owner,admin}')))
-- has_organization_role(organization) answers for one organization, in functions and checks.
-- Prefer the access contract (better_supabase.can, tenant_ids_with) for
-- permission checks; these answer by role name.
create or replace function better_supabase.member_organization_ids(roles text[] default null)
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 (member_organization_ids.roles is null or m."role" = any (member_organization_ids.roles))
and not better_supabase.user_disabled(m."user_id")
and not better_supabase.tenant_disabled(m."organization_id") and m."disabled_at" is null
$$;
create or replace function better_supabase.has_organization_role(organization uuid, roles text[] default null)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1
from "better_supabase"."memberships" m
where m."organization_id" = has_organization_role.organization
and m."user_id" = auth.uid()
and (has_organization_role.roles is null or m."role" = any (has_organization_role.roles))
and not better_supabase.user_disabled(m."user_id")
and not better_supabase.tenant_disabled(m."organization_id") and m."disabled_at" is null
)
$$;
-- The role of a user in a tenant, or null when they aren't a member.
create or replace function better_supabase.organization_member_role(organization uuid, member uuid)
returns text
language sql
stable
security definer
set search_path = ''
as $$
select m."role" from "better_supabase"."memberships" m where m."organization_id" = organization_member_role.organization and m."user_id" = organization_member_role.member
$$;
drop policy if exists bs_memberships_read on "better_supabase"."memberships";
create policy bs_memberships_read on "better_supabase"."memberships"
for select to authenticated
using ("user_id" = (select auth.uid()) or "organization_id" in (select better_supabase.member_organization_ids()));
revoke execute on function better_supabase.member_organization_ids(text[]) from public, anon;
revoke execute on function better_supabase.has_organization_role(uuid, text[]) from public, anon;
revoke execute on function better_supabase.organization_member_role(uuid, uuid) from public, anon, authenticated;
grant execute on function better_supabase.member_organization_ids(text[]) to authenticated, service_role;
grant execute on function better_supabase.has_organization_role(uuid, text[]) to authenticated, service_role;
grant execute on function better_supabase.organization_member_role(uuid, uuid) to service_role;
-- The memberships claim. `claimFormat: 'array'` (the default) is
-- [{ scope, id, roles }]; `'map'` is { [tenant id]: role }. When an
-- authorization provider's hook writes this claim, use its hook instead.
-- Otherwise call it from your custom access token hook:
-- return jsonb_set(event, '{claims,memberships}',
-- better_supabase.membership_claims((event ->> 'user_id')::uuid));
-- Disabled users and tenants (modules.access.disabled) are left out.
create or replace function better_supabase.membership_claims(user_id uuid)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(jsonb_agg(jsonb_build_object(
'scope', 'tenant',
'id', m."organization_id",
'roles', jsonb_build_array(m."role")
) order by m."created_at", m."organization_id"), '[]'::jsonb)
from "better_supabase"."memberships" m
where m."user_id" = membership_claims.user_id
and not better_supabase.user_disabled(membership_claims.user_id)
and not better_supabase.tenant_disabled(m."organization_id") and m."disabled_at" is null
$$;
revoke execute on function better_supabase.membership_claims(uuid) from public, anon, authenticated;
grant execute on function better_supabase.membership_claims(uuid) to service_role, supabase_auth_admin;
create schema if not exists better_supabase;
create table if not exists better_supabase.modules (
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.modules enable row level security;
revoke all on better_supabase.modules from anon, authenticated;
grant select on better_supabase.modules to service_role;
supabase/better-supabase-data/900_better_supabase_04_tenant.sql
-- better-supabase module: tenant
-- @bs-module-data tenant
-- Rows and settings a schema diff doesn't capture. Run `better-supabase sql data`
-- after the schema migration to put them in a migration.
-- Managed by `better-supabase sql add`; re-running it overwrites this file.
-- Change it through `sql.modules` in better-supabase.config.ts and the module's SQL hooks.
insert into better_supabase.modules (name, version, mode)
values ('tenant', 2, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block is SQL only: call its functions from your policies, RPCs and the other modules.
This module writes no outbox events.