-- t-paliad-214 Slice 1 โ€” create paliad.system_audit_log as the 6th source -- in the AuditService.ListEntries union. Captures org-wide / scope-spanning -- actions that don't naturally belong on any single project_events row. -- -- Design: docs/design-paliad-data-export-2026-05-19.md ยง4. -- -- Initial use case is data-export auditing (every export run writes one row, -- before the artifact is generated, then is patched with row_counts + -- file_size_bytes on completion). The table is intentionally generic -- (`event_type` + `metadata jsonb`) so future org-wide actions can land here -- without a new table per concept. -- -- Idempotent: CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS. -- audit_reason set_config required by the mig 079 trigger pattern when -- migrations touch the database โ€” universal convention even for pure-DDL -- migrations. SELECT set_config( 'paliad.audit_reason', 'mig 102: add paliad.system_audit_log for org-wide / scope-spanning audit events (t-paliad-214 Slice 1 โ€” data-export audit chain)', true); CREATE TABLE IF NOT EXISTS paliad.system_audit_log ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event_type text NOT NULL, actor_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL, -- actor_email is captured at write time so the audit row survives a -- subsequent user-deletion (FK above sets NULL, but the historical -- identity stays readable). actor_email text NOT NULL, scope text NOT NULL CHECK (scope IN ('org', 'project', 'personal')), -- scope_root is the project_id for scope='project'; NULL otherwise. -- Not a hard FK because we want the audit row to outlive a project -- deletion. Resolution happens at read time. scope_root uuid, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- Indexes mirror the read patterns: -- - actor lookup ("show me what I've exported"): actor_id + created_at desc -- - scope rollup ("how much org-wide activity in the last 30 days"): event_type + created_at desc CREATE INDEX IF NOT EXISTS system_audit_log_actor_id_created_at_idx ON paliad.system_audit_log (actor_id, created_at DESC); CREATE INDEX IF NOT EXISTS system_audit_log_event_type_created_at_idx ON paliad.system_audit_log (event_type, created_at DESC); -- RLS: every authenticated user can SELECT their own rows (actor_id = auth.uid()); -- global_admins see everything. INSERT / UPDATE happen via the Go service path -- under the migration-runner role (no end-user write surface) so no INSERT -- policy is needed for end users. ALTER TABLE paliad.system_audit_log ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS system_audit_log_select_self ON paliad.system_audit_log; CREATE POLICY system_audit_log_select_self ON paliad.system_audit_log FOR SELECT USING (actor_id = auth.uid()); DROP POLICY IF EXISTS system_audit_log_select_admin ON paliad.system_audit_log; CREATE POLICY system_audit_log_select_admin ON paliad.system_audit_log FOR SELECT USING ( EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin' ) ); COMMENT ON TABLE paliad.system_audit_log IS 'Org-wide / scope-spanning audit events. 6th source of AuditService union. Generic event_type + metadata jsonb. Initial users: data-export audit chain (t-paliad-214). Audit rows persist forever; artifact retention is separate.'; COMMENT ON COLUMN paliad.system_audit_log.actor_email IS 'Captured at write time so the audit row survives user deletion (actor_id FK uses ON DELETE SET NULL).'; COMMENT ON COLUMN paliad.system_audit_log.scope_root IS 'project_id for scope=project; NULL otherwise. Not a hard FK so audit survives project deletion.';