Files
accounted/supabase/migrations/20260810121000_log_committed_at_override.sql
Jakob Wennberg c77367ac1a feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1490)
* feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1444)

Migrations 20260806150000/160000 let backend writers (service_role,
no-claims direct SQL) preserve a preset committed_at on the draft-to-posted
transition, but nothing recorded that the transition timestamp was
overridden: BFNAR 2013:2 kap 8 wants a log of who did what, when. If a
trusted-role connection is ever used against a live company, there is now
an audit row separating real from preset commit time.

- audit_log accepts action COMMITTED_AT_OVERRIDE (types + Zod filter too)
- log_committed_at_override() SECURITY DEFINER writer: audit_log has RLS
  with no INSERT policy and service_role is not guaranteed BYPASSRLS in
  every harness; EXECUTE revoked from anon/authenticated so PostgREST
  cannot expose it as an RPC
- set_committed_at() calls the writer in the preserve branch; the stamping
  branch is unchanged from 20260806160000
- pg tests: override row content (preset value, wall clock, jwt role) for
  postgres and service_role writers, absence on the stamp paths, and the
  writer's privilege lockdown

Closes #1444

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review(audit): stamp wall_clock with clock_timestamp(), not now()

Seeding flows post many entries inside one transaction; now() would pin
every override row's wall_clock to the BEGIN instead of the actual
transition moment. Captured once so new_state and description agree.
Test posts inside an explicit transaction after pg_sleep and asserts
wall_clock moved past the transaction start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:11:09 +02:00

112 lines
4.7 KiB
PL/PgSQL

-- Migrations 20260806150000/160000 let trusted writers (service_role, or
-- no JWT claims at all: direct SQL, maintenance, pg tests) preserve a preset
-- committed_at on the draft-to-posted transition, which the seeding flows use
-- to backdate demo history. Nothing recorded that the transition timestamp was
-- overridden: BFNAR 2013:2 kap 8 wants behandlingshistorik showing who did
-- what, when. If a trusted-role connection is ever used against a live
-- company, there must be an audit row showing real vs. preset commit time.
-- (#1444)
--
-- Three pieces:
-- 1. audit_log accepts action 'COMMITTED_AT_OVERRIDE'.
-- 2. A SECURITY DEFINER helper writes the row. It cannot be a plain INSERT
-- inside the trigger: audit_log has RLS with no INSERT policy, and
-- service_role is not guaranteed BYPASSRLS everywhere the trigger runs
-- (the pg-test harness SET ROLE service_role has no such attribute).
-- EXECUTE is revoked from client roles so PostgREST cannot expose it as
-- an RPC for writing noise into the audit trail.
-- 3. set_committed_at() calls the helper whenever it preserves a preset
-- value. The stamping branch is unchanged, byte for byte, from
-- 20260806160000 (see that file's header for why 150000 and 160000 both
-- carry the same body).
-- ── 1. New audit action ──────────────────────────────────────────────────────
ALTER TABLE public.audit_log DROP CONSTRAINT IF EXISTS audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check
CHECK (action = ANY (ARRAY[
'INSERT','UPDATE','DELETE','COMMIT','REVERSE','CORRECT',
'LOCK_PERIOD','CLOSE_PERIOD','DOCUMENT_DELETE_BLOCKED',
'RETENTION_BLOCK','SECURITY_EVENT','INTEGRITY_FAILURE',
'COMMITTED_AT_OVERRIDE'
]));
-- ── 2. Audit writer ──────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.log_committed_at_override(
p_entry public.journal_entries,
p_jwt_role text
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
-- clock_timestamp(), not now(): the seeding flows post many entries inside
-- one transaction, and now() would stamp every override with the
-- transaction start instead of the moment it actually happened.
v_wall_clock timestamptz := clock_timestamp();
BEGIN
INSERT INTO public.audit_log (
user_id, company_id, action, table_name, record_id, actor_id,
old_state, new_state, description, actor_type, actor_label
)
VALUES (
COALESCE(auth.uid(), p_entry.user_id),
p_entry.company_id,
'COMMITTED_AT_OVERRIDE',
'journal_entries',
p_entry.id,
COALESCE(auth.uid(), p_entry.user_id),
NULL,
jsonb_build_object(
'preset_committed_at', p_entry.committed_at,
'wall_clock', v_wall_clock,
'jwt_role', COALESCE(NULLIF(p_jwt_role, ''), 'none')
),
format(
'Preserved preset committed_at %s on draft-to-posted; wall clock %s (jwt role: %s)',
p_entry.committed_at, v_wall_clock, COALESCE(NULLIF(p_jwt_role, ''), 'none')
),
COALESCE(NULLIF(current_setting('gnubok.actor_type', true), ''), 'system'),
NULLIF(current_setting('gnubok.actor_label', true), '')
);
END;
$$;
REVOKE ALL ON FUNCTION public.log_committed_at_override(public.journal_entries, text) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.log_committed_at_override(public.journal_entries, text) FROM anon;
REVOKE ALL ON FUNCTION public.log_committed_at_override(public.journal_entries, text) FROM authenticated;
GRANT EXECUTE ON FUNCTION public.log_committed_at_override(public.journal_entries, text) TO service_role;
-- ── 3. set_committed_at v3: preserve AND log ────────────────────────────────
CREATE OR REPLACE FUNCTION public.set_committed_at()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
v_jwt_role text;
BEGIN
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
v_jwt_role := coalesce(
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
nullif(current_setting('request.jwt.claim.role', true), ''),
''
);
IF NEW.committed_at IS NULL
OR NOT (v_jwt_role = '' OR v_jwt_role = 'service_role') THEN
NEW.committed_at := now();
ELSE
-- Trusted writer keeps its preset value: durable trace of the override.
PERFORM public.log_committed_at_override(NEW, v_jwt_role);
END IF;
END IF;
RETURN NEW;
END;
$$;
NOTIFY pgrst, 'reload schema';