All blocks
Foundation
Organizations
create_organization(attrs) with slug rules and an after-create hook, a deferred owner check, an assignment ceiling, and member functions (role change, remove, leave, transfer ownership) on the access contract.
npx better-supabase sql add organizationsimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Required by organizations; sql add installs them too.
"updated-at": {},
tenant: {},
access: {},
// Modes: "managed", "adopt", "custom". The default is "managed".
organizations: { mode: "managed" },
},
},
});npx better-supabase sql add organizations 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.sqlschematenant156 linessupabase/better-supabase-data/900_better_supabase_04_tenant.sqldatatenant12 linessupabase/schemas/900_better_supabase_19_access.sqlschemaaccess380 linessupabase/better-supabase-data/900_better_supabase_19_access.sqldataaccess12 linessupabase/schemas/900_better_supabase_21_organizations.sqlschema738 linessupabase/better-supabase-data/900_better_supabase_21_organizations.sqldata12 lines
supabase/schemas/900_better_supabase_21_organizations.sql
-- better-supabase module: organizations
-- @bs-module organizations@1 managed
-- create_organization(attrs) with slug rules and an after-create hook, a deferred owner check, an assignment ceiling, and member functions (role change, remove, leave, transfer ownership) on the access contract.
-- 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 or replace function better_supabase.actor_may_write()
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when auth.jwt() is null then true
when coalesce(auth.jwt() -> 'act' ->> 'read_only', '') = 'false' then true
when auth.jwt() -> 'act' ->> 'kind' = 'agent'
then coalesce(auth.jwt() -> 'act' ->> 'read_only', '') <> 'true'
when auth.jwt() -> 'act' ->> 'kind' in ('support', 'impersonation') then false
when auth.jwt() -> 'act' ->> 'session_id' is not null
and auth.jwt() -> 'act' ->> 'kind' is null then false
else true
end
$$;
revoke execute on function better_supabase.actor_may_write() from public;
grant execute on function better_supabase.actor_may_write() to anon, authenticated, service_role;
create or replace function better_supabase.raise_actor_readonly()
returns void
language plpgsql
volatile
set search_path = ''
as $$
begin
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end;
$$;
revoke execute on function better_supabase.raise_actor_readonly() from public;
grant execute on function better_supabase.raise_actor_readonly() to anon, authenticated, service_role;
create schema if not exists better_supabase;
grant usage on schema better_supabase to anon, authenticated, service_role;
create table if not exists "better_supabase"."organizations" (
"id" uuid primary key default gen_random_uuid(),
"name" text not null check (length(btrim("name")) > 0),
"slug" text not null,
"created_by" uuid references auth.users (id) on delete set null,
"created_at" timestamptz not null default now(),
"disabled_at" timestamptz,
"deleted_at" timestamptz
);
create unique index if not exists organizations_slug_idx on "better_supabase"."organizations" (lower("slug"::text)) where "deleted_at" is null;
create index if not exists organizations_created_by_idx on "better_supabase"."organizations" ("created_by");
alter table "better_supabase"."organizations" add column if not exists "updated_at" timestamptz not null default now();
drop trigger if exists bs_updated_at on "better_supabase"."organizations";
create trigger bs_updated_at before update on "better_supabase"."organizations"
for each row execute function better_supabase.set_updated_at('updated_at');
alter table "better_supabase"."organizations" enable row level security;
revoke all on "better_supabase"."organizations" from anon, authenticated;
grant select on "better_supabase"."organizations" to authenticated;
grant all on "better_supabase"."organizations" to service_role;
drop policy if exists bs_organizations_read on "better_supabase"."organizations";
create policy bs_organizations_read on "better_supabase"."organizations" for select to authenticated
using ("id" in (select better_supabase.member_organization_ids()));
do $$
begin
if not exists (
select 1 from pg_constraint
where conrelid = '"better_supabase"."memberships"'::regclass and conname = 'memberships_organization_fkey'
) then
alter table "better_supabase"."memberships" add constraint "memberships_organization_fkey"
foreign key ("organization_id") references "better_supabase"."organizations" ("id") on delete cascade not valid;
begin
alter table "better_supabase"."memberships" validate constraint "memberships_organization_fkey";
exception when foreign_key_violation then
raise warning '% has rows without a match, so only new rows are checked. Delete them, then run: alter table % validate constraint %',
'"better_supabase"."memberships"', '"better_supabase"."memberships"', 'memberships_organization_fkey';
end;
end if;
end;
$$;
-- Why a slug can't be used, or null: 'invalid' (length and pattern from
-- sql.modules.organizations.options), 'reserved' or 'taken'. except_organization skips the
-- organization being renamed.
create or replace function "better_supabase"."organization_slug_problem"(value text, except_organization uuid default null)
returns text
language sql
stable
security definer
set search_path = ''
as $$
select case
when value is null then null
when length(value) < 2 or length(value) > 48 or value !~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$' then 'invalid'
when false then 'reserved'
when exists (
select 1 from "better_supabase"."organizations" o
where lower(o."slug"::text) = lower(value) and o."deleted_at" is null
and o."id" is distinct from except_organization
) then 'taken'
end
$$;
revoke execute on function "better_supabase"."organization_slug_problem"(text, uuid) from public, anon;
grant execute on function "better_supabase"."organization_slug_problem"(text, uuid) to authenticated, service_role;
-- Creates an organization from attrs (its name, slug and the columns in
-- sql.modules.organizations.options.attributes) and makes the caller its owner. The
-- service role passes the owner as attrs.owner_id.
create or replace function "better_supabase"."create_organization"(attrs jsonb)
returns uuid
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
service boolean := coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin');
owner uuid := case when service then nullif(attrs ->> 'owner_id', '')::uuid else auth.uid() end;
organization uuid;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if owner is null then
raise exception 'An organization needs an owner' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if better_supabase.user_disabled(owner) then
raise exception 'The user is disabled' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
case "better_supabase"."organization_slug_problem"(coalesce(attrs ->> 'slug', ''), null)
when 'invalid' then raise exception 'Invalid slug "%"', attrs ->> 'slug' using errcode = '23514', hint = 'ORGANIZATION_SLUG_INVALID';
when 'reserved' then raise exception 'The slug "%" is reserved', attrs ->> 'slug' using errcode = '23514', hint = 'ORGANIZATION_SLUG_RESERVED';
when 'taken' then raise exception 'The slug "%" is taken', attrs ->> 'slug' using errcode = '23505', hint = 'ORGANIZATION_SLUG_TAKEN';
else null;
end case;
declare
v_hook regprocedure := to_regprocedure('"public"."before_organization_create"(jsonb, uuid)');
begin
if v_hook is not null then
execute format('select %s($1::jsonb, $2::uuid)', v_hook::oid::regproc)
using attrs, owner;
end if;
end;
insert into "better_supabase"."organizations" ("name", "slug", "created_by")
select r."name", r."slug", owner
from jsonb_populate_record(null::"better_supabase"."organizations", attrs) r
returning "id" into organization;
insert into "better_supabase"."memberships" ("organization_id", "user_id", "role")
values (organization, owner, 'owner');
declare
v_hook regprocedure := to_regprocedure('"public"."after_organization_create"(uuid, uuid)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid)', v_hook::oid::regproc)
using organization, owner;
end if;
end;
null;
return organization;
end;
$$;
-- Updates the name, slug and attribute columns present in attrs.
create or replace function "better_supabase"."update_organization"(organization uuid, attrs jsonb)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin')) and not coalesce(better_supabase.member_can(auth.uid(), organization, 'organization.update'), false) then
raise exception 'Not allowed to update the organization' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if attrs ? 'slug' then
case "better_supabase"."organization_slug_problem"(coalesce(attrs ->> 'slug', ''), organization)
when 'invalid' then raise exception 'Invalid slug "%"', attrs ->> 'slug' using errcode = '23514', hint = 'ORGANIZATION_SLUG_INVALID';
when 'reserved' then raise exception 'The slug "%" is reserved', attrs ->> 'slug' using errcode = '23514', hint = 'ORGANIZATION_SLUG_RESERVED';
when 'taken' then raise exception 'The slug "%" is taken', attrs ->> 'slug' using errcode = '23505', hint = 'ORGANIZATION_SLUG_TAKEN';
else null;
end case;
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."before_organization_update"(uuid, jsonb)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::jsonb)', v_hook::oid::regproc)
using organization, attrs;
end if;
end;
update "better_supabase"."organizations" o
set "name" = case when attrs ? 'name' then r."name" else o."name" end,
"slug" = case when attrs ? 'slug' then r."slug" else o."slug" end
from jsonb_populate_record(null::"better_supabase"."organizations", attrs) r
where o."id" = organization;
if not found then
raise exception 'No organization %', organization using errcode = 'P0002', hint = 'ORGANIZATION_NOT_FOUND';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_organization_update"(uuid, jsonb)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::jsonb)', v_hook::oid::regproc)
using organization, attrs;
end if;
end;
null;
return true;
end;
$$;
-- Deletes an organization (modules.organizations.options.deleteMode: hard, with its memberships).
create or replace function "better_supabase"."delete_organization"(organization uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin')) and not coalesce(better_supabase.member_can(auth.uid(), organization, 'organization.delete'), false) then
raise exception 'Not allowed to delete the organization' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
delete from "better_supabase"."organizations" where "id" = organization;
if not found then
return false;
end if;
delete from "better_supabase"."memberships" where "organization_id" = organization;
null;
return true;
end;
$$;
-- Checked at commit, so one transaction can promote one owner and demote
-- another. An organization may have several owners.
create or replace function "better_supabase"."ensure_organization_owner"()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
-- The row lock serializes concurrent demotions and leaves, so two
-- transactions can't each remove a different last owner.
perform 1 from "better_supabase"."organizations" o where o."id" = old."organization_id" for update;
if found and not exists (
select 1 from "better_supabase"."memberships" m where m."organization_id" = old."organization_id" and m."role" = 'owner' and m."disabled_at" is null
) then
raise exception 'An organization needs an owner' using errcode = '23514', hint = 'ORGANIZATION_OWNER_REQUIRED';
end if;
return null;
end;
$$;
drop trigger if exists "bs_organization_owner" on "better_supabase"."memberships";
create constraint trigger "bs_organization_owner" after update of "role", "organization_id", "disabled_at" or delete on "better_supabase"."memberships"
deferrable initially deferred
for each row execute function "better_supabase"."ensure_organization_owner"();
-- No client grants a role above their own permissions (can_assign), demotes
-- someone above them, or changes their own role. Only writes made as anon or
-- authenticated are checked: the service role, direct admin connections and
-- security definer functions (the module's own and the app's, which check
-- their own ceilings) pass.
-- The checks, as the module's owner, so the client needs no rights on the
-- roles tables. It only raises, so a direct call reveals nothing.
create or replace function "better_supabase"."guard_membership_role"(target_tenant uuid, target_member uuid, target_role text, previous_tenant uuid, previous_role text)
returns void
language plpgsql
stable
security definer
set search_path = ''
as $$
begin
if target_member = auth.uid() then
raise exception 'You cannot change your own role' using errcode = '42501', hint = 'ORGANIZATION_SELF_ROLE';
end if;
if not better_supabase.can_assign(target_tenant, target_role::text)
or (previous_tenant is not null and not better_supabase.can_assign(previous_tenant, previous_role::text)) then
raise exception 'That role is above your own' using errcode = '42501', hint = 'ORGANIZATION_ROLE_CEILING';
end if;
end;
$$;
revoke execute on function "better_supabase"."guard_membership_role"(uuid, uuid, text, uuid, text) from public, anon;
grant execute on function "better_supabase"."guard_membership_role"(uuid, uuid, text, uuid, text) to authenticated, service_role;
-- Security invoker, so current_user is the role that wrote the row.
create or replace function "better_supabase"."guard_membership"()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if not (current_user in ('anon', 'authenticated')) then
return new;
end if;
if tg_op = 'UPDATE' and new."role" is not distinct from old."role" and new."disabled_at" is not distinct from old."disabled_at" then
return new;
end if;
perform "better_supabase"."guard_membership_role"(
new."organization_id", new."user_id", new."role"::text,
case when tg_op = 'UPDATE' then old."organization_id" end,
case when tg_op = 'UPDATE' then old."role"::text end
);
return new;
end;
$$;
drop trigger if exists "bs_organization_role_guard" on "better_supabase"."memberships";
create trigger "bs_organization_role_guard" before insert or update on "better_supabase"."memberships"
for each row execute function "better_supabase"."guard_membership"();
revoke execute on function "better_supabase"."guard_membership"() from public, anon, authenticated;
-- Errors carry a code in the hint: ORGANIZATION_FORBIDDEN, ORGANIZATION_DISABLED, ORGANIZATION_NOT_MEMBER,
-- ORGANIZATION_SELF, ORGANIZATION_SELF_ROLE, ORGANIZATION_ROLE_CEILING, ORGANIZATION_ROLE_UNKNOWN, ORGANIZATION_OWNER_REQUIRED.
create or replace function "better_supabase"."update_member_role"(organization uuid, member uuid, role text)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
previous text;
previous_assignable text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin') or coalesce(better_supabase.member_can(auth.uid(), organization, 'members.update_role'), false)) then
raise exception 'Not allowed to change roles' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if not (exists (select 1 from "better_supabase"."organizations" o where o."id" = organization and o."deleted_at" is null and o."disabled_at" is null) and not better_supabase.tenant_disabled(organization)) then
raise exception 'The organization is unavailable' using errcode = '42501', hint = 'ORGANIZATION_DISABLED';
end if;
if not (role = any (array['owner', 'admin', 'member', 'viewer']::text[])) then
raise exception 'Unknown role %', role using errcode = '22023', hint = 'ORGANIZATION_ROLE_UNKNOWN';
end if;
select m."role", m."role"::text into previous, previous_assignable
from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = member;
if not found then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
-- Checked here whatever sql.modules.organizations.options.assignmentGuard
-- says: a guard that checks only client writes never sees this function's.
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin')) and member = auth.uid() then
raise exception 'You cannot change your own role' using errcode = '42501', hint = 'ORGANIZATION_SELF_ROLE';
end if;
if not better_supabase.can_assign(organization, previous_assignable)
or not better_supabase.can_assign(organization, (role)::text) then
raise exception 'That role is above your own' using errcode = '42501', hint = 'ORGANIZATION_ROLE_CEILING';
end if;
update "better_supabase"."memberships" set "role" = role
where "organization_id" = organization and "user_id" = member;
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, member, 'role';
end if;
end;
null;
return true;
end;
$$;
create or replace function "better_supabase"."remove_member"(organization uuid, member uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
current_role_value text;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if member = auth.uid() then
raise exception 'Leave the organization instead' using errcode = '22023', hint = 'ORGANIZATION_SELF';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin') or coalesce(better_supabase.member_can(auth.uid(), organization, 'members.remove'), false)) then
raise exception 'Not allowed to remove members' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
select m."role"::text into current_role_value from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = member;
if not found then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
if not better_supabase.can_assign(organization, current_role_value) then
raise exception 'That member''s role is above your own' using errcode = '42501', hint = 'ORGANIZATION_ROLE_CEILING';
end if;
delete from "better_supabase"."memberships" where "organization_id" = organization and "user_id" = member;
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, member, 'removed';
end if;
end;
null;
return true;
end;
$$;
create or replace function "better_supabase"."leave_organization"(organization uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
me uuid := auth.uid();
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
delete from "better_supabase"."memberships" where "organization_id" = organization and "user_id" = me;
if not found then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, me, 'left';
end if;
end;
null;
return true;
end;
$$;
-- Makes new_owner an owner in one transaction; a calling owner becomes
-- former_role (modules.organizations.options.formerOwnerRole).
create or replace function "better_supabase"."transfer_ownership"(organization uuid, new_owner uuid, former_role text default 'admin')
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
me uuid := auth.uid();
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin') or coalesce(better_supabase.member_can(auth.uid(), organization, 'organization.transfer_ownership'), false)) then
raise exception 'Not allowed to transfer ownership' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
-- Only an owner hands ownership on, so the permission alone can't make its
-- holder an owner.
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin')) and not exists (
select 1 from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = me and m."role" = 'owner'
) then
raise exception 'Only an owner can transfer ownership' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if not (exists (select 1 from "better_supabase"."organizations" o where o."id" = organization and o."deleted_at" is null and o."disabled_at" is null) and not better_supabase.tenant_disabled(organization)) then
raise exception 'The organization is unavailable' using errcode = '42501', hint = 'ORGANIZATION_DISABLED';
end if;
if not (former_role = any (array['owner', 'admin', 'member', 'viewer']::text[])) then
raise exception 'Unknown role %', former_role using errcode = '22023', hint = 'ORGANIZATION_ROLE_UNKNOWN';
end if;
if not exists (select 1 from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = new_owner) then
raise exception 'The new owner must be a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
if better_supabase.user_disabled(new_owner) then
raise exception 'The new owner is disabled' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if exists (select 1 from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = new_owner and m."disabled_at" is not null) then
raise exception 'The new owner is suspended' using errcode = '42501', hint = 'ORGANIZATION_MEMBER_SUSPENDED';
end if;
-- One statement for both rows, so a statement-level guard on the number of
-- owners sees the transfer as a whole.
update "better_supabase"."memberships" m set "role" = case
when m."user_id" = new_owner then 'owner'
else former_role
end
where m."organization_id" = organization
and (m."user_id" = new_owner
or (me is not null and me <> new_owner and m."user_id" = me and m."role" = 'owner'));
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, new_owner, 'owner';
end if;
end;
null;
return true;
end;
$$;
-- Records that the caller opened the organization (the last_used_at column of
-- memberships, when it has one), for "recent organizations" lists.
create or replace function "better_supabase"."mark_used"(organization uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
update "better_supabase"."memberships" set "last_used_at" = now()
where "organization_id" = organization and "user_id" = auth.uid();
return found;
end;
$$;
create or replace function "better_supabase"."suspend_member"(organization uuid, member uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
current_role_value text;
is_owner boolean;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if member = auth.uid() then
raise exception 'You cannot suspend yourself' using errcode = '22023', hint = 'ORGANIZATION_SELF';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin') or coalesce(better_supabase.member_can(auth.uid(), organization, 'members.remove'), false)) then
raise exception 'Not allowed to suspend members' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if not (exists (select 1 from "better_supabase"."organizations" o where o."id" = organization and o."deleted_at" is null and o."disabled_at" is null) and not better_supabase.tenant_disabled(organization)) then
raise exception 'The organization is unavailable' using errcode = '42501', hint = 'ORGANIZATION_DISABLED';
end if;
perform 1 from "better_supabase"."organizations" o where o."id" = organization for update;
select m."role"::text, m."role" = 'owner' into current_role_value, is_owner
from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = member;
if not found then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
if not better_supabase.can_assign(organization, current_role_value) then
raise exception 'That member''s role is above your own' using errcode = '42501', hint = 'ORGANIZATION_ROLE_CEILING';
end if;
if is_owner and not exists (
select 1 from "better_supabase"."memberships" o
where o."organization_id" = organization and o."user_id" <> member and o."role" = 'owner' and o."disabled_at" is null
) then
raise exception 'An organization needs an owner' using errcode = '23514', hint = 'ORGANIZATION_OWNER_REQUIRED';
end if;
update "better_supabase"."memberships" set "disabled_at" = now()
where "organization_id" = organization and "user_id" = member and "disabled_at" is null;
if not found then
return false;
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, member, 'suspended';
end if;
end;
null;
return true;
end;
$$;
create or replace function "better_supabase"."resume_member"(organization uuid, member uuid)
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
current_role_value text;
is_owner boolean;
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if member = auth.uid() then
raise exception 'You cannot resume yourself' using errcode = '22023', hint = 'ORGANIZATION_SELF';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin') or coalesce(better_supabase.member_can(auth.uid(), organization, 'members.remove'), false)) then
raise exception 'Not allowed to resume members' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
if not (exists (select 1 from "better_supabase"."organizations" o where o."id" = organization and o."deleted_at" is null and o."disabled_at" is null) and not better_supabase.tenant_disabled(organization)) then
raise exception 'The organization is unavailable' using errcode = '42501', hint = 'ORGANIZATION_DISABLED';
end if;
perform 1 from "better_supabase"."organizations" o where o."id" = organization for update;
select m."role"::text, m."role" = 'owner' into current_role_value, is_owner
from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = member;
if not found then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
if not better_supabase.can_assign(organization, current_role_value) then
raise exception 'That member''s role is above your own' using errcode = '42501', hint = 'ORGANIZATION_ROLE_CEILING';
end if;
update "better_supabase"."memberships" set "disabled_at" = null
where "organization_id" = organization and "user_id" = member and "disabled_at" is not null;
if not found then
return false;
end if;
declare
v_hook regprocedure := to_regprocedure('"public"."after_member_change"(uuid, uuid, text)');
begin
if v_hook is not null then
execute format('select %s($1::uuid, $2::uuid, $3::text)', v_hook::oid::regproc)
using organization, member, 'resumed';
end if;
end;
null;
return true;
end;
$$;
-- Makes organization the caller's active organization (modules.access.activeTenant).
create or replace function "better_supabase"."switch_organization"(organization uuid)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
#variable_conflict use_variable
declare
me uuid := auth.uid();
begin
if not (better_supabase.actor_may_write()) then
raise exception 'This session is read-only' using errcode = '42501', hint = 'ACTOR_READ_ONLY';
end if;
if me is null or not exists (select 1 from "better_supabase"."memberships" m where m."organization_id" = organization and m."user_id" = me) then
raise exception 'Not a member' using errcode = 'P0002', hint = 'ORGANIZATION_NOT_MEMBER';
end if;
if not (exists (select 1 from "better_supabase"."organizations" o where o."id" = organization and o."deleted_at" is null and o."disabled_at" is null) and not better_supabase.tenant_disabled(organization)) then
raise exception 'The organization is unavailable' using errcode = '42501', hint = 'ORGANIZATION_DISABLED';
end if;
-- The server's tenant resolver picks the tenant per request.
update "better_supabase"."memberships" set "last_used_at" = now() where "organization_id" = organization and "user_id" = me;
null;
return jsonb_build_object('organization_id', organization, 'refresh', false);
end;
$$;
drop function if exists "better_supabase"."list_my_organizations"();
create or replace function "better_supabase"."list_my_organizations"()
returns table (
id uuid,
name text,
slug text,
role text,
disabled_at timestamptz
)
language sql
stable
security definer
set search_path = ''
as $$
select o."id", o."name", o."slug", m."role"::text, m."disabled_at"
from "better_supabase"."memberships" m
join "better_supabase"."organizations" o on o."id" = m."organization_id"
where m."user_id" = (select auth.uid()) and o."deleted_at" is null and o."disabled_at" is null
and not better_supabase.tenant_disabled(o."id")
order by o."name"
$$;
drop function if exists "better_supabase"."list_members"(uuid);
create or replace function "better_supabase"."list_members"(organization uuid)
returns table (user_id uuid, role text, disabled_at timestamptz)
language plpgsql
stable
security definer
set search_path = ''
as $$
begin
if not coalesce(better_supabase.member_can((select auth.uid()), organization, 'members.read'), false) then
raise exception 'Not allowed to list members' using errcode = '42501', hint = 'ORGANIZATION_FORBIDDEN';
end if;
return query
select m."user_id", m."role"::text, m."disabled_at"
from "better_supabase"."memberships" m
where m."organization_id" = organization
order by m."user_id";
end;
$$;
revoke execute on function "better_supabase"."create_organization"(jsonb) from public, anon;
grant execute on function "better_supabase"."create_organization"(jsonb) to authenticated, service_role;
revoke execute on function "better_supabase"."update_organization"(uuid, jsonb) from public, anon;
grant execute on function "better_supabase"."update_organization"(uuid, jsonb) to authenticated, service_role;
revoke execute on function "better_supabase"."delete_organization"(uuid) from public, anon;
grant execute on function "better_supabase"."delete_organization"(uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."update_member_role"(uuid, uuid, text) from public, anon;
grant execute on function "better_supabase"."update_member_role"(uuid, uuid, text) to authenticated, service_role;
revoke execute on function "better_supabase"."remove_member"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."remove_member"(uuid, uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."leave_organization"(uuid) from public, anon;
grant execute on function "better_supabase"."leave_organization"(uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."transfer_ownership"(uuid, uuid, text) from public, anon;
grant execute on function "better_supabase"."transfer_ownership"(uuid, uuid, text) to authenticated, service_role;
revoke execute on function "better_supabase"."mark_used"(uuid) from public, anon;
grant execute on function "better_supabase"."mark_used"(uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."switch_organization"(uuid) from public, anon;
grant execute on function "better_supabase"."switch_organization"(uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."list_my_organizations"() from public, anon;
grant execute on function "better_supabase"."list_my_organizations"() to authenticated, service_role;
revoke execute on function "better_supabase"."list_members"(uuid) from public, anon;
grant execute on function "better_supabase"."list_members"(uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."suspend_member"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."suspend_member"(uuid, uuid) to authenticated, service_role;
revoke execute on function "better_supabase"."resume_member"(uuid, uuid) from public, anon;
grant execute on function "better_supabase"."resume_member"(uuid, uuid) to authenticated, service_role;
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_21_organizations.sql
-- better-supabase module: organizations
-- @bs-module-data organizations
-- 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 ('organizations', 1, 'managed')
on conflict (name) do update
set version = excluded.version, mode = excluded.mode, updated_at = now();
This block has no React entry yet. Its TypeScript side is in better-supabase/blocks/organizations, which runs on the server.
With the outbox installed, the module writes these events in the same transaction as the change.
- organization.created
- organization.deleted
- organization.member_left
- organization.member_removed
- organization.member_resumed
- organization.member_suspended
- organization.ownership_transferred
- organization.role_changed
- organization.switched
- organization.updated