c77367ac1a
* 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>
112 lines
4.7 KiB
PL/PgSQL
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';
|