All blocks
Foundation
Actor stamping
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.
npx better-supabase sql add actorimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Modes: "managed". The default is "managed".
actor: { mode: "managed" },
},
},
});npx better-supabase sql add actor 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_02_actor.sqlschema153 linessupabase/better-supabase-data/900_better_supabase_02_actor.sqldata12 lines
supabase/schemas/900_better_supabase_02_actor.sql
-- better-supabase module: actor
-- @bs-module actor@1 managed
-- 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.
-- 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;
create or replace function better_supabase.set_actor()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
actor uuid := auth.uid();
begin
if actor is null then
return new;
end if;
if tg_argv[0] <> '' then
new := jsonb_populate_record(new, jsonb_build_object(
tg_argv[0],
case when tg_op = 'INSERT' then to_jsonb(actor) else to_jsonb(old) -> tg_argv[0] end
));
end if;
if tg_argv[1] = 'updated_by' then
new.updated_by := actor;
elsif tg_argv[1] <> '' then
new := jsonb_populate_record(new, jsonb_build_object(tg_argv[1], actor));
end if;
-- The admin behind an impersonated write (the act claim). A user's own
-- update keeps the stamp, so it cannot clear the record of an earlier one.
if tg_nargs > 2 and tg_argv[2] <> '' then
new := jsonb_populate_record(new, jsonb_build_object(
tg_argv[2],
case
when auth.jwt() -> 'act' ->> 'sub' is not null then to_jsonb(auth.jwt() -> 'act' ->> 'sub')
when tg_op = 'INSERT' then 'null'::jsonb
else to_jsonb(old) -> tg_argv[2]
end
));
end if;
return new;
end;
$$;
drop function if exists better_supabase.track_actor(regclass, text, text);
-- select better_supabase.track_actor('public.customers');
-- impersonated_by => 'impersonated_by' also stamps the impersonating admin.
create or replace function better_supabase.track_actor(
target regclass,
created_by text default 'created_by',
updated_by text default 'updated_by',
impersonated_by text default null
)
returns void
language plpgsql
set search_path = ''
as $$
begin
execute format('drop trigger if exists bs_actor on %s', target);
execute format(
'create trigger bs_actor before insert or update on %s for each row execute function better_supabase.set_actor(%L, %L, %L)',
target,
coalesce(created_by, ''),
coalesce(updated_by, ''),
coalesce(impersonated_by, '')
);
end;
$$;
revoke execute on function better_supabase.track_actor(regclass, text, text, text) from public, anon, authenticated;
-- False for a support or impersonation act, and for an act that only carries
-- session_id, unless act.read_only is false. A token without act may write.
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;
-- using (better_supabase.token_scope_allows('posts:write'))
-- True for a token no OAuth client or agent acts with. A delegated token
-- (a client_id, or an act that is not a support or impersonation level)
-- needs p_scope in its scope claim, a space-separated string or a list.
create or replace function better_supabase.token_scope_allows(p_scope text)
returns boolean
language sql
stable
security invoker
set search_path = ''
as $$
select case
when jwt is null then true
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
when coalesce(p_scope, '') = '' then false
when jsonb_typeof(jwt -> 'scope') = 'string'
then p_scope = any(regexp_split_to_array(btrim(jwt ->> 'scope'), '\s+'))
when jsonb_typeof(jwt -> 'scope') = 'array'
then jwt -> 'scope' @> jsonb_build_array(p_scope)
else false
end
from (select auth.jwt() as jwt) as token
$$;
revoke execute on function better_supabase.token_scope_allows(text) from public;
grant execute on function better_supabase.token_scope_allows(text) to anon, 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_02_actor.sql
-- better-supabase module: actor
-- @bs-module-data actor
-- 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 ('actor', 1, '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.