All blocks
Operations
Audit log
Records inserts, updates and deletes with the actor and changed columns for tables you register, plus semantic events through audit_event(), with redaction, an append-only guard, a tenant read policy and per-tenant retention as options.
npx better-supabase sql add auditimport { defineConfig } from "better-supabase/config";
export default defineConfig({
sql: {
modules: {
// Modes: "managed", "adopt". The default is "managed".
audit: { mode: "managed" },
},
},
});npx better-supabase sql add audit 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_03_audit.sqlschema1013 linessupabase/better-supabase-data/900_better_supabase_03_audit.sqldata21 lines
supabase/schemas/900_better_supabase_03_audit.sql
-- better-supabase module: audit
-- @bs-module audit@7 managed
-- Records inserts, updates and deletes with the actor and changed columns for tables you register, plus semantic events through audit_event(), with redaction, an append-only guard, a tenant read policy and per-tenant retention as options.
-- 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 table if not exists better_supabase.audited_tables (
target regclass primary key,
ignore text[] not null default '{}'
);
-- The primary key columns, read when the table is registered; composite keys are joined with ','.
alter table better_supabase.audited_tables add column if not exists key_columns text[] not null default '{id}';
-- Per-table event naming and redaction.
alter table better_supabase.audited_tables add column if not exists redact text[] not null default '{}';
alter table better_supabase.audited_tables add column if not exists category text;
alter table better_supabase.audited_tables add column if not exists event_prefix text;
alter table better_supabase.audited_tables add column if not exists target_type text;
alter table better_supabase.audited_tables add column if not exists tenant_column text;
alter table better_supabase.audited_tables add column if not exists label_column text;
alter table better_supabase.audited_tables enable row level security;
revoke all on better_supabase.audited_tables from anon, authenticated;
-- The request headers, or null outside a Data API request.
create or replace function better_supabase.request_headers()
returns jsonb
language plpgsql
stable
set search_path = ''
as $$
begin
return nullif(current_setting('request.headers', true), '')::jsonb;
exception when others then
return null;
end;
$$;
-- One request header, or null outside a Data API request.
create or replace function better_supabase.request_header(name text)
returns text
language plpgsql
stable
set search_path = ''
as $$
begin
return nullif(current_setting('request.headers', true), '')::jsonb ->> request_header.name;
exception when others then
return null;
end;
$$;
create or replace function better_supabase.request_id_or_null(value text)
returns text
language sql
immutable
set search_path = ''
as $$
select case when value ~ '^[A-Za-z0-9._:;,@/+=-]{1,128}$' then value end;
$$;
-- The client address the API gateway appended to x-forwarded-for (the
-- right-most hop; clients can forge the ones before it), or null.
create or replace function better_supabase.request_ip()
returns inet
language plpgsql
stable
set search_path = ''
as $$
begin
return nullif(trim(reverse(split_part(reverse(current_setting('request.headers', true)::json ->> 'x-forwarded-for'), ',', 1))), '')::inet;
exception when others then
return null;
end;
$$;
create table if not exists "better_supabase"."audit_events" (
"id" bigint generated always as identity primary key,
"table_name" text,
"record_id" text,
"op" text not null,
"old_record" jsonb,
"new_record" jsonb,
"changed" text[],
"actor_id" uuid,
"actor_role" text,
"organization_id" uuid,
"occurred_at" timestamptz not null default now()
);
alter table "better_supabase"."audit_events" drop constraint if exists audit_log_op_check;
do $$
begin
if coalesce(obj_description((
select c.oid from pg_catalog.pg_constraint c
where c.conrelid = '"better_supabase"."audit_events"'::regclass and c.conname = 'bs_audit_op_check'
), 'pg_constraint'), '') <> 'better-supabase check 50fe4698' then
alter table "better_supabase"."audit_events" drop constraint if exists "bs_audit_op_check";
alter table "better_supabase"."audit_events" add constraint "bs_audit_op_check" check ("op" in ('insert', 'update', 'delete', 'event'));
comment on constraint "bs_audit_op_check" on "better_supabase"."audit_events" is 'better-supabase check 50fe4698';
end if;
end;
$$;
alter table "better_supabase"."audit_events" alter column "table_name" drop not null;
-- Set when an admin acted as the user (the act claim).
alter table "better_supabase"."audit_events" add column if not exists "impersonated_by" uuid;
alter table "better_supabase"."audit_events" add column if not exists "impersonation_reason" text;
alter table "better_supabase"."audit_events" add column if not exists "support_session_id" uuid;
-- Semantic events (audit_event) and the per-table registry fill these.
alter table "better_supabase"."audit_events" add column if not exists "event_type" text;
alter table "better_supabase"."audit_events" add column if not exists "category" text;
alter table "better_supabase"."audit_events" add column if not exists "outcome" text;
alter table "better_supabase"."audit_events" add column if not exists "source" text;
alter table "better_supabase"."audit_events" add column if not exists "target_type" text;
alter table "better_supabase"."audit_events" add column if not exists "metadata" jsonb;
alter table "better_supabase"."audit_events" add column if not exists "idempotency_key" text;
-- Who acted and on what, as it was then, for audit pages.
alter table "better_supabase"."audit_events" add column if not exists "actor_kind" text;
alter table "better_supabase"."audit_events" add column if not exists "actor_label" text;
alter table "better_supabase"."audit_events" add column if not exists "tenant_label" text;
alter table "better_supabase"."audit_events" add column if not exists "target_label" text;
alter table "better_supabase"."audit_events" add column if not exists "summary" text;
alter table "better_supabase"."audit_events" add column if not exists "request_id" text;
alter table "better_supabase"."audit_events" add column if not exists "correlation_id" text;
alter table "better_supabase"."audit_events" add column if not exists "scope" text;
create index if not exists audit_events_record_idx on "better_supabase"."audit_events" ("table_name", "record_id", "occurred_at" desc);
create index if not exists audit_events_organization_idx on "better_supabase"."audit_events" ("organization_id", "occurred_at" desc);
create index if not exists audit_events_occurred_at_idx on "better_supabase"."audit_events" ("occurred_at");
create index if not exists audit_events_correlation_idx on "better_supabase"."audit_events" ("correlation_id") where "correlation_id" is not null;
create unique index if not exists audit_events_idempotency_idx on "better_supabase"."audit_events" ("idempotency_key", "organization_id") nulls not distinct where "idempotency_key" is not null;
alter table "better_supabase"."audit_events" enable row level security;
revoke all on "better_supabase"."audit_events" from anon, authenticated;
grant select on "better_supabase"."audit_events" to service_role;
create or replace function better_supabase.audit_row_change()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
entry record;
settings jsonb;
entry_id "better_supabase"."audit_events"."id"%type;
old_row jsonb := case when tg_op <> 'INSERT' then to_jsonb(old) end;
new_row jsonb := case when tg_op <> 'DELETE' then to_jsonb(new) end;
row_data jsonb := coalesce(new_row, old_row);
changed_columns text[];
changed_values jsonb;
row_tenant uuid;
v_jwt jsonb := auth.jwt();
v_headers jsonb := better_supabase.request_headers();
begin
if tg_nargs > 0 then
settings := tg_argv[0]::jsonb;
select array(select jsonb_array_elements_text(coalesce(settings -> 'ignore', '[]'))) as ignore,
coalesce(
nullif(array(select jsonb_array_elements_text(coalesce(settings -> 'key_columns', '[]'))), '{}'),
(select array_agg(c.attname::text order by k.ord)
from pg_catalog.pg_index i
cross join lateral unnest(i.indkey) with ordinality k(attnum, ord)
join pg_catalog.pg_attribute c on c.attrelid = i.indrelid and c.attnum = k.attnum
where i.indrelid = tg_relid and i.indisprimary),
'{id}'
) as key_columns,
array(select jsonb_array_elements_text(coalesce(settings -> 'redact', '[]'))) as redact,
settings ->> 'category' as category, settings ->> 'event_prefix' as event_prefix,
settings ->> 'target_type' as target_type, settings ->> 'tenant_column' as tenant_column,
settings ->> 'label_column' as label_column
into entry;
else
select coalesce(a.ignore, '{}') as ignore, coalesce(a.key_columns, '{id}') as key_columns,
coalesce(a.redact, '{}') as redact, a.category, a.event_prefix, a.target_type, a.tenant_column,
a.label_column
into entry
from (select 1) one
left join better_supabase.audited_tables a on a.target = tg_relid::regclass;
end if;
row_tenant := case when row_data ->> coalesce(entry.tenant_column, 'organization_id') ~* '^[0-9a-f-]{36}$' then (row_data ->> coalesce(entry.tenant_column, 'organization_id'))::uuid end;
old_row := old_row - entry.ignore;
new_row := new_row - entry.ignore;
if tg_op = 'UPDATE' then
select array_agg(key order by key) into changed_columns
from jsonb_each(new_row) n
where n.value is distinct from old_row -> n.key;
if changed_columns is null then
return null;
end if;
end if;
-- Redacted columns stay in changed, with their values masked.
old_row := old_row || coalesce((select jsonb_object_agg(k, '"[redacted]"'::jsonb) from unnest(entry.redact) k where old_row ? k), '{}');
new_row := new_row || coalesce((select jsonb_object_agg(k, '"[redacted]"'::jsonb) from unnest(entry.redact) k where new_row ? k), '{}');
insert into "better_supabase"."audit_events" ("table_name", "record_id", "op", "old_record", "new_record", "changed", "actor_id", "actor_role", "organization_id", "impersonated_by", "impersonation_reason", "support_session_id", "event_type", "category", "outcome", "source", "target_type", "actor_kind", "actor_label", "tenant_label", "target_label", "summary", "request_id", "correlation_id", "scope")
values (
tg_table_schema || '.' || tg_table_name,
(select string_agg(row_data ->> k.name, ',' order by k.ord) from unnest(entry.key_columns) with ordinality k(name, ord)),
lower(tg_op),
old_row,
new_row,
changed_columns,
auth.uid(),
coalesce(v_jwt ->> 'role', current_user),
row_tenant,
case when v_jwt -> 'act' ->> 'sub' ~ '^[0-9a-f-]{36}$' then (v_jwt -> 'act' ->> 'sub')::uuid end,
v_jwt -> 'act' ->> 'reason',
case when v_jwt -> 'act' ->> 'session_id' ~ '^[0-9a-f-]{36}$' then (v_jwt -> 'act' ->> 'session_id')::uuid end,
coalesce(entry.event_prefix, tg_table_name) || '.' || case tg_op when 'INSERT' then 'created' when 'UPDATE' then 'updated' else 'deleted' end,
coalesce(entry.category, 'data'),
'success',
'database',
coalesce(entry.target_type, tg_table_name),
case
when coalesce(v_jwt ->> 'role', '') = 'service_role' then 'service'
when v_jwt -> 'act' ->> 'kind' = 'support' then 'support'
when v_jwt -> 'act' is not null then 'impersonation'
when v_jwt ->> 'client_id' is not null then 'oauth-client'
when auth.uid() is not null then 'user'
else 'system'
end,
coalesce(v_jwt -> 'user_metadata' ->> 'full_name', v_jwt ->> 'email'),
null,
row_data ->> entry.label_column,
null,
coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.request_id', true)), better_supabase.request_id_or_null((v_headers ->> 'x-request-id'))),
coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.correlation_id', true)), better_supabase.request_id_or_null((v_headers ->> 'x-correlation-id'))),
case when row_tenant is null then 'platform' else 'tenant' end
)
returning "id" into entry_id;
return null;
end;
$$;
create or replace function better_supabase.replace_equivalent_triggers(
target regclass,
module_trigger text,
pattern text,
replace_trigger boolean
)
returns void
language plpgsql
set search_path = ''
as $$
declare
found record;
begin
for found in
select t.tgname as name, p.proname as fn
from pg_catalog.pg_trigger t
join pg_catalog.pg_proc p on p.oid = t.tgfoid
where t.tgrelid = replace_equivalent_triggers.target
and not t.tgisinternal
and t.tgname <> replace_equivalent_triggers.module_trigger
and p.proname ~* replace_equivalent_triggers.pattern
loop
if replace_trigger then
execute format('drop trigger %I on %s', found.name, target);
else
raise warning '% already has trigger % (%), which does what % does. Pass replace_trigger => true to drop it.',
target, found.name, found.fn, module_trigger;
end if;
end loop;
end;
$$;
revoke execute on function better_supabase.replace_equivalent_triggers(regclass, text, text, boolean) from public, anon, authenticated;
drop function if exists better_supabase.audit(regclass, text[]);
drop function if exists better_supabase.audit(regclass, text[], boolean);
drop function if exists better_supabase.audit(regclass, text[], boolean, text[], text, text, text, text);
-- select better_supabase.audit('public.customers', ignore => '{updated_at}');
-- redact => '{api_key}' masks values but keeps them in changed;
-- event_prefix, category and target_type name the entries (event_prefix.created);
-- tenant_column overrides the module's tenant column for this table, and
-- label_column names the column kept as the entry's target label.
-- replace_trigger => true drops another audit trigger on the table.
create or replace function better_supabase.audit(
target regclass,
ignore text[] default '{}',
replace_trigger boolean default false,
redact text[] default '{}',
category text default null,
event_prefix text default null,
target_type text default null,
tenant_column text default null,
label_column text default null
)
returns void
language plpgsql
set search_path = ''
as $$
declare
keys text[];
begin
perform better_supabase.replace_equivalent_triggers(
target, 'bs_audit', 'audit', replace_trigger
);
select array_agg(c.attname::text order by k.ord) into keys
from pg_catalog.pg_index i
cross join lateral unnest(i.indkey) with ordinality k(attnum, ord)
join pg_catalog.pg_attribute c on c.attrelid = i.indrelid and c.attnum = k.attnum
where i.indrelid = audit.target and i.indisprimary;
delete from better_supabase.audited_tables a where a.target = audit.target;
execute format('drop trigger if exists bs_audit on %s', target);
execute format(
'create trigger bs_audit after insert or update or delete on %s for each row execute function better_supabase.audit_row_change(%L)',
target,
jsonb_strip_nulls(jsonb_build_object(
'ignore', to_jsonb(coalesce(audit.ignore, '{}')),
'redact', to_jsonb(coalesce(audit.redact, '{}')),
'key_columns', to_jsonb(keys),
'category', audit.category,
'event_prefix', audit.event_prefix,
'target_type', audit.target_type,
'tenant_column', audit.tenant_column,
'label_column', audit.label_column
))::text
);
end;
$$;
create or replace function better_supabase.audit_settings(target regclass)
returns jsonb
language sql
stable
set search_path = ''
as $$
select coalesce(
(select convert_from(substring(t.tgargs from 1 for position('\x00'::bytea in t.tgargs) - 1), 'utf8')::jsonb
from pg_catalog.pg_trigger t
where t.tgrelid = audit_settings.target and t.tgname = 'bs_audit' and t.tgnargs > 0),
(select jsonb_strip_nulls(jsonb_build_object(
'ignore', to_jsonb(a.ignore), 'redact', to_jsonb(a.redact), 'key_columns', to_jsonb(a.key_columns),
'category', a.category, 'event_prefix', a.event_prefix, 'target_type', a.target_type,
'tenant_column', a.tenant_column, 'label_column', a.label_column))
from better_supabase.audited_tables a where a.target = audit_settings.target)
)
$$;
create or replace function better_supabase.unaudit(target regclass)
returns void
language plpgsql
set search_path = ''
as $$
begin
execute format('drop trigger if exists bs_audit on %s', target);
delete from better_supabase.audited_tables a where a.target = unaudit.target;
end;
$$;
create or replace function better_supabase.unaudit(target text)
returns void
language plpgsql
set search_path = ''
as $$
declare
relation regclass := pg_catalog.to_regclass(unaudit.target);
begin
if relation is not null then
perform better_supabase.unaudit(relation);
end if;
delete from better_supabase.audited_tables a
where not exists (select 1 from pg_catalog.pg_class c where c.oid = a.target::oid);
end;
$$;
create or replace function better_supabase.audit_forget_dropped()
returns event_trigger
language plpgsql
set search_path = ''
as $$
begin
delete from better_supabase.audited_tables a
where a.target::oid in (
select d.objid from pg_catalog.pg_event_trigger_dropped_objects() d
where d.object_type = 'table'
);
end;
$$;
drop event trigger if exists bs_audit_forget_dropped;
create event trigger bs_audit_forget_dropped on sql_drop
when tag in ('DROP TABLE', 'DROP SCHEMA')
execute function better_supabase.audit_forget_dropped();
-- The audit() calls for every table in schema_name with tenant_column,
-- except tables whose name matches an exempt pattern (like 'audit_%').
-- Paste them into a schema file: static calls keep their place in a
-- pg-delta diff, where a loop over the catalog runs before the tables exist.
-- select better_supabase.audit_schema_calls('public', 'tenant_id', '{audit_%}');
create or replace function better_supabase.audit_schema_calls(
schema_name text,
tenant_column text,
exempt text[] default '{}'
)
returns setof text
language sql
stable
set search_path = ''
as $$
select format('select better_supabase.audit(%L);', format('%I.%I', n.nspname, c.relname))
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = schema_name
and c.relkind in ('r', 'p')
and not c.relispartition
and exists (
select 1 from pg_catalog.pg_attribute a
where a.attrelid = c.oid and a.attname = tenant_column and a.attnum > 0 and not a.attisdropped
)
and not exists (select 1 from unnest(exempt) e where c.relname like e)
order by c.relname
$$;
-- Registers those tables now, for a migration or a one-off script; tables
-- already registered keep their own settings. Returns how many it added.
create or replace function better_supabase.audit_schema(
schema_name text,
tenant_column text,
exempt text[] default '{}'
)
returns integer
language plpgsql
set search_path = ''
as $$
declare
registered integer := 0;
target regclass;
begin
for target in
select format('%I.%I', n.nspname, c.relname)::regclass
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where n.nspname = schema_name
and c.relkind in ('r', 'p')
and not c.relispartition
and exists (
select 1 from pg_catalog.pg_attribute a
where a.attrelid = c.oid and a.attname = audit_schema.tenant_column and a.attnum > 0 and not a.attisdropped
)
and not exists (select 1 from unnest(exempt) e where c.relname like e)
and not exists (
select 1 from pg_catalog.pg_trigger t where t.tgrelid = c.oid and t.tgname = 'bs_audit'
)
order by c.relname
loop
perform better_supabase.audit(target);
registered := registered + 1;
end loop;
return registered;
end;
$$;
revoke execute on function better_supabase.audit(regclass, text[], boolean, text[], text, text, text, text, text) from public, anon, authenticated;
revoke execute on function better_supabase.unaudit(regclass) from public, anon, authenticated;
revoke execute on function better_supabase.unaudit(text) from public, anon, authenticated;
revoke execute on function better_supabase.audit_forget_dropped() from public, anon, authenticated;
revoke execute on function better_supabase.audit_settings(regclass) from public, anon, authenticated;
revoke execute on function better_supabase.audit_schema_calls(text, text, text[]) from public, anon, authenticated;
revoke execute on function better_supabase.audit_schema(text, text, text[]) from public, anon, authenticated;
-- Records a semantic app event (invoice.sent, member.invited) next to the
-- row changes. A repeated idempotency_key returns the first entry's id.
-- actor_id, actor_kind, actor_label, ip, user_agent, session_id, request_id
-- and scope are honoured for the service role and direct admin connections,
-- which never get the request's own address, user agent or session; everyone
-- else gets auth.uid() and the request's own values. support_session,
-- impersonated_by and impersonation_reason fill the impersonation columns;
-- other callers may only name themselves as impersonated_by, from a token
-- without an act claim (an admin starting or ending their own support
-- session), and otherwise get the act claim's values. restricted goes to the
-- restricted table, with no row when every restricted value is empty; without
-- that table, passing it fails instead of dropping the details.
drop function if exists better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid);
drop function if exists better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text);
drop function if exists better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text);
drop function if exists better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text);
drop function if exists better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text);
create or replace function better_supabase.audit_event(
event_type text,
category text default null,
outcome text default 'success',
source text default null,
target_type text default null,
record_id text default null,
tenant uuid default null,
metadata jsonb default '{}',
idempotency_key text default null,
restricted jsonb default null,
actor_id uuid default null,
summary text default null,
target_label text default null,
correlation_id text default null,
actor_kind text default null,
actor_label text default null,
ip inet default null,
user_agent text default null,
session_id text default null,
request_id text default null,
scope text default null,
support_session uuid default null,
impersonated_by uuid default null,
impersonation_reason text default null
)
returns text
language plpgsql
security definer
set search_path = ''
as $$
declare
existing text;
entry_id "better_supabase"."audit_events"."id"%type;
begin
if restricted is not null or ip is not null or user_agent is not null or session_id is not null then
raise exception 'audit_event got restricted details, and the audit module has no restricted table'
using errcode = '22023', hint = 'Set sql.modules.audit.options.restricted to true.';
end if;
if not (coalesce(nullif((select auth.jwt()) ->> 'role', ''), session_user::text) in ('service_role', 'postgres', 'supabase_admin')) then
actor_id := auth.uid();
actor_kind := null;
actor_label := null;
ip := null;
user_agent := null;
session_id := null;
request_id := null;
scope := null;
if (select auth.jwt()) -> 'act' is not null or impersonated_by is distinct from auth.uid() then
support_session := null;
impersonated_by := null;
impersonation_reason := null;
end if;
elsif actor_id is null then
actor_id := auth.uid();
end if;
if idempotency_key is not null then
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('better_supabase.audit_event'), pg_catalog.hashtext(coalesce(tenant::text, '') || ':' || idempotency_key));
select l."id"::text into existing
from "better_supabase"."audit_events" l
where l."idempotency_key" = audit_event.idempotency_key
and l."organization_id" is not distinct from audit_event.tenant
limit 1;
if existing is not null then
return existing;
end if;
end if;
insert into "better_supabase"."audit_events" ("table_name", "record_id", "op", "actor_id", "actor_role", "organization_id", "impersonated_by", "impersonation_reason", "support_session_id", "event_type", "category", "outcome", "source", "target_type", "metadata", "idempotency_key", "actor_kind", "actor_label", "tenant_label", "target_label", "summary", "request_id", "correlation_id", "scope")
values (
null,
record_id,
'event',
actor_id,
coalesce(auth.jwt() ->> 'role', current_user),
tenant,
coalesce(audit_event.impersonated_by, case when auth.jwt() -> 'act' ->> 'sub' ~ '^[0-9a-f-]{36}$' then (auth.jwt() -> 'act' ->> 'sub')::uuid end),
coalesce(audit_event.impersonation_reason, auth.jwt() -> 'act' ->> 'reason'),
coalesce(audit_event.support_session, case when auth.jwt() -> 'act' ->> 'session_id' ~ '^[0-9a-f-]{36}$' then (auth.jwt() -> 'act' ->> 'session_id')::uuid end),
event_type,
coalesce(category, 'system'),
coalesce(outcome, 'success'),
coalesce(source, 'app'),
target_type,
coalesce(metadata, '{}'),
idempotency_key,
coalesce(actor_kind, case
when coalesce(auth.jwt() ->> 'role', '') = 'service_role' then 'service'
when auth.jwt() -> 'act' ->> 'kind' = 'support' then 'support'
when auth.jwt() -> 'act' is not null then 'impersonation'
when auth.jwt() ->> 'client_id' is not null then 'oauth-client'
when auth.uid() is not null then 'user'
else 'system'
end),
coalesce(actor_label, coalesce(auth.jwt() -> 'user_metadata' ->> 'full_name', auth.jwt() ->> 'email')),
null,
target_label,
summary,
coalesce(better_supabase.request_id_or_null(request_id), coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.request_id', true)), better_supabase.request_id_or_null(better_supabase.request_header('x-request-id')))),
coalesce(better_supabase.request_id_or_null(correlation_id), coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.correlation_id', true)), better_supabase.request_id_or_null(better_supabase.request_header('x-correlation-id')))),
coalesce(audit_event.scope, case when tenant is null then 'platform' else 'tenant' end)
)
returning "id" into entry_id;
return entry_id::text;
end;
$$;
revoke execute on function better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.audit_event(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text) to "service_role";
-- audit_event for the app's own security definer functions: it honours
-- actor_id, actor_kind, actor_label, scope and the request details from any
-- caller, so only the owner, the service role and
-- sql.modules.audit.options.trustedRoles may execute it. A function owned by
-- postgres calls it on behalf of a client with the real actor context.
drop function if exists better_supabase.audit_event_trusted(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text);
drop function if exists better_supabase.audit_event_trusted(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text);
create or replace function better_supabase.audit_event_trusted(
event_type text,
category text default null,
outcome text default 'success',
source text default null,
target_type text default null,
record_id text default null,
tenant uuid default null,
metadata jsonb default '{}',
idempotency_key text default null,
restricted jsonb default null,
actor_id uuid default null,
summary text default null,
target_label text default null,
correlation_id text default null,
actor_kind text default null,
actor_label text default null,
ip inet default null,
user_agent text default null,
session_id text default null,
request_id text default null,
scope text default null,
support_session uuid default null,
impersonated_by uuid default null,
impersonation_reason text default null
)
returns text
language plpgsql
security definer
set search_path = ''
as $$
declare
existing text;
entry_id "better_supabase"."audit_events"."id"%type;
begin
if restricted is not null or ip is not null or user_agent is not null or session_id is not null then
raise exception 'audit_event got restricted details, and the audit module has no restricted table'
using errcode = '22023', hint = 'Set sql.modules.audit.options.restricted to true.';
end if;
actor_id := coalesce(actor_id, auth.uid());
if idempotency_key is not null then
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('better_supabase.audit_event'), pg_catalog.hashtext(coalesce(tenant::text, '') || ':' || idempotency_key));
select l."id"::text into existing
from "better_supabase"."audit_events" l
where l."idempotency_key" = audit_event_trusted.idempotency_key
and l."organization_id" is not distinct from audit_event_trusted.tenant
limit 1;
if existing is not null then
return existing;
end if;
end if;
insert into "better_supabase"."audit_events" ("table_name", "record_id", "op", "actor_id", "actor_role", "organization_id", "impersonated_by", "impersonation_reason", "support_session_id", "event_type", "category", "outcome", "source", "target_type", "metadata", "idempotency_key", "actor_kind", "actor_label", "tenant_label", "target_label", "summary", "request_id", "correlation_id", "scope")
values (
null,
record_id,
'event',
actor_id,
coalesce(auth.jwt() ->> 'role', current_user),
tenant,
coalesce(audit_event_trusted.impersonated_by, case when auth.jwt() -> 'act' ->> 'sub' ~ '^[0-9a-f-]{36}$' then (auth.jwt() -> 'act' ->> 'sub')::uuid end),
coalesce(audit_event_trusted.impersonation_reason, auth.jwt() -> 'act' ->> 'reason'),
coalesce(audit_event_trusted.support_session, case when auth.jwt() -> 'act' ->> 'session_id' ~ '^[0-9a-f-]{36}$' then (auth.jwt() -> 'act' ->> 'session_id')::uuid end),
event_type,
coalesce(category, 'system'),
coalesce(outcome, 'success'),
coalesce(source, 'app'),
target_type,
coalesce(metadata, '{}'),
idempotency_key,
coalesce(actor_kind, case
when coalesce(auth.jwt() ->> 'role', '') = 'service_role' then 'service'
when auth.jwt() -> 'act' ->> 'kind' = 'support' then 'support'
when auth.jwt() -> 'act' is not null then 'impersonation'
when auth.jwt() ->> 'client_id' is not null then 'oauth-client'
when auth.uid() is not null then 'user'
else 'system'
end),
coalesce(actor_label, coalesce(auth.jwt() -> 'user_metadata' ->> 'full_name', auth.jwt() ->> 'email')),
null,
target_label,
summary,
coalesce(better_supabase.request_id_or_null(request_id), coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.request_id', true)), better_supabase.request_id_or_null(better_supabase.request_header('x-request-id')))),
coalesce(better_supabase.request_id_or_null(correlation_id), coalesce(better_supabase.request_id_or_null(current_setting('better_supabase.correlation_id', true)), better_supabase.request_id_or_null(better_supabase.request_header('x-correlation-id')))),
coalesce(audit_event_trusted.scope, case when tenant is null then 'platform' else 'tenant' end)
)
returning "id" into entry_id;
return entry_id::text;
end;
$$;
revoke execute on function better_supabase.audit_event_trusted(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text) from public, anon, authenticated;
grant execute on function better_supabase.audit_event_trusted(text, text, text, text, text, text, uuid, jsonb, text, jsonb, uuid, text, text, text, text, text, inet, text, text, text, text, uuid, uuid, text) to "service_role";
-- Entries are append-only. purge_audit_log deletes old ones: it runs as its
-- owner and sets better_supabase.audit_purge, and a role that only sets the
-- setting is not the owner.
create or replace function better_supabase.audit_append_only()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
-- The setting first: the owner lookup runs only for a purge.
if tg_op = 'DELETE' and current_setting('better_supabase.audit_purge', true) = 'on' then
if current_user = (
select r.rolname from pg_catalog.pg_proc p
join pg_catalog.pg_roles r on r.oid = p.proowner
where p.oid = to_regprocedure('better_supabase.purge_audit_log(interval, integer, uuid, boolean)')
) then
return old;
end if;
end if;
raise exception 'audit log entries are append-only'
using errcode = '42501', hint = 'Delete old entries with better_supabase.purge_audit_log()';
end;
$$;
revoke execute on function better_supabase.audit_append_only() from public, anon, authenticated;
drop trigger if exists "bs_audit_append_only" on "better_supabase"."audit_events";
create trigger "bs_audit_append_only" before update or delete on "better_supabase"."audit_events"
for each row execute function better_supabase.audit_append_only();
drop trigger if exists "bs_audit_no_truncate" on "better_supabase"."audit_events";
create trigger "bs_audit_no_truncate" before truncate on "better_supabase"."audit_events"
for each statement execute function better_supabase.audit_append_only();
drop policy if exists bs_audit_read on "better_supabase"."audit_events";
drop function if exists "better_supabase"."audit_read_tenants"();
drop function if exists "better_supabase"."audit_reads_all"();
-- Deletes up to batch entries older than older_than and returns how many.
-- With an audit_retention(tenant) function, each tenant keeps its own
-- interval (a plan's days, say); null falls back to older_than. With
-- for_tenant, only entries of tenant (null: entries without one).
-- Nightly with pg_cron or the jobs drain route:
-- select better_supabase.purge_audit_log();
drop function if exists better_supabase.purge_audit_log(interval, integer);
create or replace function better_supabase.purge_audit_log(
older_than interval default '1 year',
batch integer default 10000,
tenant uuid default null,
for_tenant boolean default false
)
returns integer
language plpgsql
security definer
set search_path = ''
as $$
declare
purged integer := 0;
gone integer;
v_tenant uuid;
v_older interval;
begin
perform set_config('better_supabase.audit_purge', 'on', true);
if not for_tenant and to_regprocedure('"public"."audit_retention"(uuid)') is not null then
-- One delete per tenant with that tenant's interval as a constant, so
-- each runs on the (tenant, occurred_at) index. The tenants come from a
-- skip scan of that index, not a scan of the log.
for v_tenant in
with recursive t (v) as (
(select l."organization_id" from "better_supabase"."audit_events" l where l."organization_id" is not null order by l."organization_id" limit 1)
union all
select (select l."organization_id" from "better_supabase"."audit_events" l where l."organization_id" > t.v order by l."organization_id" limit 1)
from t where t.v is not null
)
select t.v from t where t.v is not null
loop
exit when purged >= batch;
-- Not a literal name, so plpgsql_check passes without the hook.
execute format('select %s($1)', to_regprocedure('"public"."audit_retention"(uuid)')::oid::regproc)
into v_older using v_tenant;
v_older := coalesce(v_older, older_than);
with deleted as (
delete from "better_supabase"."audit_events"
where "id" in (
select l."id" from "better_supabase"."audit_events" l
where l."organization_id" = v_tenant and l."occurred_at" < now() - v_older
order by l."occurred_at"
limit batch - purged
)
returning 1
)
select count(*)::integer into gone from deleted;
purged := purged + gone;
end loop;
if purged < batch then
with deleted as (
delete from "better_supabase"."audit_events"
where "id" in (
select l."id" from "better_supabase"."audit_events" l
where l."organization_id" is null and l."occurred_at" < now() - older_than
order by l."occurred_at"
limit batch - purged
)
returning 1
)
select count(*)::integer into gone from deleted;
purged := purged + gone;
end if;
else
with deleted as (
delete from "better_supabase"."audit_events"
where "id" in (
select l."id" from "better_supabase"."audit_events" l
where l."occurred_at" < now() - older_than
and (not for_tenant or l."organization_id" is not distinct from purge_audit_log.tenant)
order by l."occurred_at"
limit batch
)
returning 1
)
select count(*)::integer into purged from deleted;
end if;
perform set_config('better_supabase.audit_purge', 'off', true);
return purged;
end;
$$;
revoke execute on function better_supabase.purge_audit_log(interval, integer, uuid, boolean) from public, anon, authenticated;
grant execute on function better_supabase.purge_audit_log(interval, integer, uuid, boolean) to service_role;
-- Tenants with entries older than older_than, for a retention callback in TypeScript.
create or replace function better_supabase.audit_events_tenants(older_than interval default '1 day')
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select distinct l."organization_id" from "better_supabase"."audit_events" l
where l."occurred_at" < now() - older_than
$$;
revoke execute on function better_supabase.audit_events_tenants(interval) from public, anon, authenticated;
grant execute on function better_supabase.audit_events_tenants(interval) to service_role;
-- A page of the entries the caller can read, newest first unless ascending:
-- the read policy decides (security invoker). Each filter takes several
-- values; search matches the event type, summary, labels, record and table.
-- Page with the last entry's occurred_at and id as cursor_at and cursor_id,
-- or open page N with skip (an offset) and count_audit_events for the total.
drop function if exists "better_supabase"."list_audit_events"(uuid, text, uuid, text, text, timestamptz, timestamptz, timestamptz, text, integer);
drop function if exists "better_supabase"."list_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz, timestamptz, text, integer, boolean);
create or replace function "better_supabase"."list_audit_events"(
for_tenants uuid[] default null,
for_event_types text[] default null,
for_actors uuid[] default null,
for_target_types text[] default null,
for_records text[] default null,
for_categories text[] default null,
for_outcomes text[] default null,
search text default null,
for_sources text[] default null,
for_actor_kinds text[] default null,
for_correlation_ids text[] default null,
since timestamptz default null,
until timestamptz default null,
cursor_at timestamptz default null,
cursor_id text default null,
max_items integer default 50,
ascending boolean default false,
skip integer default 0
)
returns jsonb
language plpgsql
stable
security invoker
set search_path = ''
as $$
declare
v_where text := 'true';
v_order text := case when ascending then 'asc' else 'desc' end;
result jsonb;
begin
if cardinality(for_tenants) > 0 then
v_where := v_where || $q$ and l."organization_id" = any ($1)$q$;
end if;
if cardinality(for_event_types) > 0 then
v_where := v_where || $q$ and l."event_type" = any ($2)$q$;
end if;
if cardinality(for_actors) > 0 then
v_where := v_where || $q$ and l."actor_id" = any ($3)$q$;
end if;
if cardinality(for_target_types) > 0 then
v_where := v_where || $q$ and l."target_type" = any ($4)$q$;
end if;
if cardinality(for_records) > 0 then
v_where := v_where || $q$ and l."record_id" = any ($5)$q$;
end if;
if cardinality(for_categories) > 0 then
v_where := v_where || $q$ and l."category" = any ($6)$q$;
end if;
if cardinality(for_outcomes) > 0 then
v_where := v_where || $q$ and l."outcome" = any ($7)$q$;
end if;
if cardinality(for_sources) > 0 then
v_where := v_where || $q$ and l."source" = any ($9)$q$;
end if;
if cardinality(for_actor_kinds) > 0 then
v_where := v_where || $q$ and l."actor_kind" = any ($10)$q$;
end if;
if cardinality(for_correlation_ids) > 0 then
v_where := v_where || $q$ and l."correlation_id" = any ($11)$q$;
end if;
if search <> '' then
v_where := v_where || $q$ and concat_ws(' ', l."event_type", l."summary", l."target_label", l."actor_label", l."tenant_label", l."record_id", l."table_name") ilike '%' || replace(replace(replace($8, '\', '\\'), '%', '\%'), '_', '\_') || '%'$q$;
end if;
if since is not null then
v_where := v_where || $q$ and l."occurred_at" >= $12$q$;
end if;
if until is not null then
v_where := v_where || $q$ and l."occurred_at" < $13$q$;
end if;
if cursor_at is not null then
v_where := v_where || case when ascending
then $q$ and (l."occurred_at", l."id") > ($14, $15::bigint)$q$
else $q$ and (l."occurred_at", l."id") < ($14, $15::bigint)$q$
end;
end if;
-- Only the filters passed reach the query, so the planner sees no
-- "is null or" branches and can use the (tenant, occurred_at) index.
execute $q$select coalesce(jsonb_agg(x.entry order by x.occurred_at $q$ || v_order || $q$, x.id $q$ || v_order || $q$), '[]')
from (
select jsonb_build_object('id', l."id", 'table', l."table_name", 'record', l."record_id", 'op', l."op", 'old', l."old_record", 'new', l."new_record", 'changed', l."changed", 'actorId', l."actor_id", 'actorRole', l."actor_role", 'actorKind', l."actor_kind", 'actorLabel', l."actor_label", 'tenant', l."organization_id", 'tenantLabel', l."tenant_label", 'occurredAt', l."occurred_at", 'impersonatedBy', l."impersonated_by", 'impersonationReason', l."impersonation_reason", 'supportSession', l."support_session_id", 'eventType', l."event_type", 'category', l."category", 'outcome', l."outcome") || jsonb_build_object('source', l."source", 'targetType', l."target_type", 'targetLabel', l."target_label", 'summary', l."summary", 'requestId', l."request_id", 'correlationId', l."correlation_id", 'scope', l."scope", 'metadata', l."metadata") as entry, l."occurred_at" as occurred_at, l."id" as id
from "better_supabase"."audit_events" l
where $q$ || v_where || $q$
order by l."occurred_at" $q$ || v_order || $q$, l."id" $q$ || v_order || $q$
limit $16
offset $17
) x$q$
into result
using for_tenants, for_event_types, for_actors, for_target_types, for_records, for_categories, for_outcomes, search, for_sources, for_actor_kinds, for_correlation_ids, since, until, cursor_at, cursor_id, least(greatest(coalesce(max_items, 50), 1), 1000), greatest(coalesce(skip, 0), 0);
return result;
end;
$$;
revoke execute on function "better_supabase"."list_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz, timestamptz, text, integer, boolean, integer) from public, anon;
revoke execute on function "better_supabase"."list_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz, timestamptz, text, integer, boolean, integer) from authenticated;
grant execute on function "better_supabase"."list_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz, timestamptz, text, integer, boolean, integer) to service_role;
create or replace function "better_supabase"."count_audit_events"(
for_tenants uuid[] default null,
for_event_types text[] default null,
for_actors uuid[] default null,
for_target_types text[] default null,
for_records text[] default null,
for_categories text[] default null,
for_outcomes text[] default null,
search text default null,
for_sources text[] default null,
for_actor_kinds text[] default null,
for_correlation_ids text[] default null,
since timestamptz default null,
until timestamptz default null
)
returns bigint
language plpgsql
stable
security invoker
set search_path = ''
as $$
declare
v_where text := 'true';
result bigint;
begin
if cardinality(for_tenants) > 0 then
v_where := v_where || $q$ and l."organization_id" = any ($1)$q$;
end if;
if cardinality(for_event_types) > 0 then
v_where := v_where || $q$ and l."event_type" = any ($2)$q$;
end if;
if cardinality(for_actors) > 0 then
v_where := v_where || $q$ and l."actor_id" = any ($3)$q$;
end if;
if cardinality(for_target_types) > 0 then
v_where := v_where || $q$ and l."target_type" = any ($4)$q$;
end if;
if cardinality(for_records) > 0 then
v_where := v_where || $q$ and l."record_id" = any ($5)$q$;
end if;
if cardinality(for_categories) > 0 then
v_where := v_where || $q$ and l."category" = any ($6)$q$;
end if;
if cardinality(for_outcomes) > 0 then
v_where := v_where || $q$ and l."outcome" = any ($7)$q$;
end if;
if cardinality(for_sources) > 0 then
v_where := v_where || $q$ and l."source" = any ($9)$q$;
end if;
if cardinality(for_actor_kinds) > 0 then
v_where := v_where || $q$ and l."actor_kind" = any ($10)$q$;
end if;
if cardinality(for_correlation_ids) > 0 then
v_where := v_where || $q$ and l."correlation_id" = any ($11)$q$;
end if;
if search <> '' then
v_where := v_where || $q$ and concat_ws(' ', l."event_type", l."summary", l."target_label", l."actor_label", l."tenant_label", l."record_id", l."table_name") ilike '%' || replace(replace(replace($8, '\', '\\'), '%', '\%'), '_', '\_') || '%'$q$;
end if;
if since is not null then
v_where := v_where || $q$ and l."occurred_at" >= $12$q$;
end if;
if until is not null then
v_where := v_where || $q$ and l."occurred_at" < $13$q$;
end if;
execute $q$select count(*) from "better_supabase"."audit_events" l where $q$ || v_where
into result
using for_tenants, for_event_types, for_actors, for_target_types, for_records, for_categories, for_outcomes, search, for_sources, for_actor_kinds, for_correlation_ids, since, until;
return result;
end;
$$;
revoke execute on function "better_supabase"."count_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz) from public, anon;
revoke execute on function "better_supabase"."count_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz) from authenticated;
grant execute on function "better_supabase"."count_audit_events"(uuid[], text[], uuid[], text[], text[], text[], text[], text, text[], text[], text[], timestamptz, timestamptz) to 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_03_audit.sql
-- better-supabase module: audit
-- @bs-module-data audit
-- 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.
drop event trigger if exists bs_audit_forget_dropped;
create event trigger bs_audit_forget_dropped on sql_drop
when tag in ('DROP TABLE', 'DROP SCHEMA')
execute function better_supabase.audit_forget_dropped();
-- Registrations of tables dropped before bs_audit_forget_dropped existed.
delete from better_supabase.audited_tables a
where not exists (select 1 from pg_catalog.pg_class c where c.oid = a.target::oid);
insert into better_supabase.modules (name, version, mode)
values ('audit', 7, '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/audit, which runs on the server.
This module writes no outbox events.