Audit Logs
Track every change automatically using PostgreSQL triggers
Overview
Supasheet ships a built-in audit-logging system that records every INSERT, UPDATE, and DELETE against any table you opt in. The trail is stored in supasheet.audit_logs and surfaced in two places:
- Core → Audit Logs — A global, filterable view of all activity
- Resource detail → Audit tab — Per-record history
Both read directly from supasheet.audit_logs — there's no wrapper function or per-table permission to grant. Access is a plain SELECT grant on the table (already held by "user" and "x-admin") plus two RLS policies: x-admin sees every row; anyone else only sees rows where created_by = auth.uid() — i.e. their own actions, not everyone else's activity on the same table or record. See Authorization for how this fits into the rest of the access model.
The schema is defined in supabase/migrations/20250928062812_audit_logs.sql.
What Gets Tracked
For every audited mutation, a row is appended to supasheet.audit_logs with:
| Column | Meaning |
|---|---|
id (uuid) | Audit entry id |
created_at (timestamptz) | When the change happened |
operation | INSERT / UPDATE / DELETE |
schema_name, table_name | Where the change happened |
record_id | The primary key of the affected row |
created_by (uuid) | The user who made the change (resolved from auth.uid()) |
role (text) | The user's role at the time of the change (whatever string was in raw_app_meta_data->>'role') |
user_type | 'real_user' or 'system' (background / service-role writes) |
old_data (jsonb) | Snapshot before the change (for UPDATE / DELETE) |
new_data (jsonb) | Snapshot after the change (for INSERT / UPDATE) |
changed_fields (text[]) | Columns whose values differ (UPDATE only) |
is_error, error_message, error_code | Optional error context |
metadata (jsonb) | Extra context — open for your own use |
A set of indexes (created_at, created_by, role, operation, schema_name + table_name, record_id, partial on is_error, GIN on metadata) keeps queries fast even with large volumes.
Enabling Audit Logging on a Table
Attach the built-in trigger function supasheet.audit_trigger_function() for the operations you care about:
CREATE TRIGGER audit_tasks
AFTER INSERT OR UPDATE OR DELETE
ON desk.tasks
FOR EACH ROW
EXECUTE FUNCTION supasheet.audit_trigger_function();The generic single-trigger form above is the recommended pattern (matches the example domains in supabase/examples/). You can also create three separate triggers if you only want a subset.
For DELETE, the trigger captures the row state in old_data automatically using the standard OLD record exposed to row-level triggers.
Who Can See the Audit Trail
There's no permission to add and no wrapper function gating access — supasheet.audit_logs is a normal table, read directly by both UI surfaces, protected by two RLS policies:
create policy "Users can view their own audit logs" on supasheet.audit_logs
for select to authenticated
using (created_by = (select auth.uid()));
create policy "x-admin can view all audit logs" on supasheet.audit_logs
for select to authenticated
using (pg_has_role(current_user, 'x-admin', 'member'));RLS policies for the same command are OR'd together, so the net effect is: x-admin sees every row across every audited table; anyone else only sees rows where created_by is their own auth.uid() — their own actions, not their teammates' changes to the same table or even the same record. Both policies require the role to hold the table-level SELECT grant first, which is already seeded to "user" and "x-admin" in the base migration.
Want a different rule — e.g. a manager role that sees every row for records their team owns? Add another permissive select policy on supasheet.audit_logs rather than editing either of the two above; Postgres OR's all matching permissive policies together automatically.
See Authorization for how this fits into the rest of the access model.
Reading the Trail from SQL
Query supasheet.audit_logs directly — there's no RPC wrapper. Embed supasheet.users through the created_by foreign key to pull in the actor's name/email/avatar in one round trip (this works because both tables live in the supasheet schema — see Cross-Schema Joins for when a replica view would be needed instead):
-- Everything I changed today
SELECT *
FROM supasheet.audit_logs
WHERE created_by = auth.uid()
AND created_at >= date_trunc('day', now())
ORDER BY created_at DESC;
-- Updates to a single record with the changed columns
SELECT created_at, changed_fields, old_data, new_data
FROM supasheet.audit_logs
WHERE schema_name = 'desk'
AND table_name = 'tasks'
AND record_id = 'a1b2…'
AND operation = 'UPDATE'
ORDER BY created_at;
-- Errors that hit any audited table
SELECT * FROM supasheet.audit_logs WHERE is_error;From the client (PostgREST embed, same pattern the built-in Audit tab uses):
const { data } = await supabase
.schema("supasheet")
.from("audit_logs")
.select(
"*, ...users(created_by_name:name, created_by_email:email, created_by_picture_url:picture_url)"
)
.eq("schema_name", "desk")
.eq("table_name", "tasks")
.eq("record_id", recordId)
.order("created_at", { ascending: false })The ... prefix is PostgREST's spread embed — it flattens the joined users columns directly onto each row (aliased to created_by_name/created_by_email/created_by_picture_url) instead of nesting them under a users key, so there's no client-side mapping needed.
The Audit Tab in the UI
Audit history isn't a metadata-configured tab — it's a dedicated page (/$schema/resource/$resource/$resourceId/audit) that Supasheet automatically links to from a record's actions menu whenever the current user holds SELECT on supasheet.audit_logs (i.e. "user" or "x-admin" — everyone with the default grants). There's nothing to declare in the table's JSON comment for this. What that user actually sees once they open it is still gated by the two RLS policies above — a plain "user" sees only their own actions on that record, x-admin sees the full history.
Don't confuse this with the tabs metadata field documented in Metadata — that field only filters which related-table tabs show on the detail page (foreign key relationships). It has no effect on audit access (grant + RLS on supasheet.audit_logs, as above) or comment access (the table's existing SELECT grant — see Authorization).
Complete Example
-- Table
CREATE TABLE store.products (
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
name TEXT NOT NULL,
price NUMERIC(10, 2),
status product_status NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Audit trigger
CREATE TRIGGER audit_products
AFTER INSERT OR UPDATE OR DELETE
ON store.products
FOR EACH ROW
EXECUTE FUNCTION supasheet.audit_trigger_function();
-- Grants (audit visibility needs no grant of its own on store.products —
-- it's controlled entirely by the shared grant + RLS on supasheet.audit_logs)
REVOKE ALL ON TABLE store.products FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE ON TABLE store.products TO "user";
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE store.products TO "x-admin";
-- Metadata (audit visibility needs no entry here either)
COMMENT ON TABLE store.products IS '{
"icon": "Package"
}';Next Steps
- Authorization — How native roles and grants control resource access
- Comments — Pair audit with discussions on each record
- Notifications — Notify users when audited events happen