diff --git a/DECISIONS.md b/DECISIONS.md index 73bd6cfd..cfb93b69 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -853,3 +853,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-10] RC VAT template fix scoped to template-library.ts (applyTemplate + deriveTemplateLinesFromBooking): counterparty/SIE learned patterns already strip fiktiv-moms legs and regenerate them via generateReverseChargeLines with the gross base, so mapping-engine/counterparty paths were already correct and stay untouched. [2026-08-10] Receipt hunt cron keeps searchMail=false: the mailbox leg stays manual until its time budget is proven. A sweep of one 172-message mailbox took over 600s while the route's maxDuration is 300, so enabling it nightly would time out mid-run. Flip both this flag and RECEIPT_HUNT_COMPANY_IDS together once the per-company budget is measured. [2026-08-10] Account 2012 reverted from BAS_REFERENCE and the EF F-skatt template moved to 2013 (#1409): the primary source (bas.se BAS 2026 v2 PDF) has no 2012 anywhere; the EF equity block is 2010/2011/2013/2017/2018/2019. 2012 "Avräkning för skatter och avgifter" is a Visma/Bokio/BL program convention that the swedish-year-end-closing skill had absorbed as if standard. Kept per-company charts that already have 2012 untouched (their history is legal and the account is a valid free slot); only the reference, the seeded template and the skill guidance change. +[2026-08-10] committed_at overrides audited via a dedicated COMMITTED_AT_OVERRIDE action and a SECURITY DEFINER writer (#1444): reusing SECURITY_EVENT would overload attack semantics onto a sanctioned backdate, and a plain INSERT inside set_committed_at() fails under the pg harness where SET ROLE service_role has no BYPASSRLS and audit_log has no INSERT policy. EXECUTE revoked from anon/authenticated so PostgREST cannot expose the writer as an audit-noise RPC. diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 7506f4b1..e8d0f528 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2175,6 +2175,7 @@ const auditActions = [ 'INSERT', 'UPDATE', 'DELETE', 'COMMIT', 'REVERSE', 'CORRECT', 'LOCK_PERIOD', 'CLOSE_PERIOD', 'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK', 'SECURITY_EVENT', 'INTEGRITY_FAILURE', + 'COMMITTED_AT_OVERRIDE', ] as const satisfies readonly AuditAction[] export const AuditTrailQuerySchema = z.object({ diff --git a/supabase/migrations/20260810121000_log_committed_at_override.sql b/supabase/migrations/20260810121000_log_committed_at_override.sql new file mode 100644 index 00000000..68de3cb2 --- /dev/null +++ b/supabase/migrations/20260810121000_log_committed_at_override.sql @@ -0,0 +1,111 @@ +-- 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'; diff --git a/tests/pg/committed-at-preservation.pg.test.ts b/tests/pg/committed-at-preservation.pg.test.ts index 8bb2340c..8a65f8d9 100644 --- a/tests/pg/committed-at-preservation.pg.test.ts +++ b/tests/pg/committed-at-preservation.pg.test.ts @@ -17,6 +17,29 @@ import { seedCompany, insertDraftJournalEntry, insertBalancedLines } from './fix const BACKDATED = '2026-03-15T10:00:00Z' const BACKDATED_ISO = '2026-03-15T10:00:00.000Z' +interface OverrideAuditRow { + action: string + new_state: { + preset_committed_at: string + wall_clock: string + jwt_role: string + } + created_at: Date +} + +async function fetchOverrideRows(entryId: string): Promise { + // Read as postgres: audit_log SELECT is restricted to the owning user. + const { rows } = await getPool().query( + `SELECT action, new_state, created_at + FROM public.audit_log + WHERE table_name = 'journal_entries' + AND record_id = $1 + AND action = 'COMMITTED_AT_OVERRIDE'`, + [entryId], + ) + return rows +} + async function seedBackdatedDraft(): Promise<{ entryId: string; userId: string }> { const { userId, companyId, fiscalPeriodId } = await seedCompany() const entryId = await insertDraftJournalEntry({ @@ -141,3 +164,109 @@ describe('set_committed_at trusted-writer preservation', () => { expect(rows[0].committed_at!.getTime()).toBeLessThanOrEqual(after + 60_000) }) }) + +// Migration 20260810121000: preserving a preset committed_at is a sanctioned +// override of the behandlingshistorik timestamp, so BFNAR 2013:2 kap 8 wants a +// durable trace: who kept which preset value, and what the wall clock said. +describe('committed_at override audit trail', () => { + it('writes a COMMITTED_AT_OVERRIDE audit row when postgres preserves a preset', async () => { + const { entryId } = await seedBackdatedDraft() + const before = Date.now() + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + const after = Date.now() + + const rows = await fetchOverrideRows(entryId) + expect(rows).toHaveLength(1) + expect(new Date(rows[0].new_state.preset_committed_at).toISOString()).toBe(BACKDATED_ISO) + expect(rows[0].new_state.jwt_role).toBe('none') + // The row separates real from preset time: wall_clock is the transition + // moment, not the backdated stamp. + const wallClock = new Date(rows[0].new_state.wall_clock).getTime() + expect(wallClock).toBeGreaterThanOrEqual(before - 60_000) + expect(wallClock).toBeLessThanOrEqual(after + 60_000) + }) + + it('writes the audit row under SET ROLE service_role, despite audit_log RLS', async () => { + // service_role has no BYPASSRLS in this harness and audit_log has no + // INSERT policy: only the SECURITY DEFINER writer makes this pass. + const { entryId } = await seedBackdatedDraft() + await runAsServiceRole(async (client) => { + await client.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + }) + + const rows = await fetchOverrideRows(entryId) + expect(rows).toHaveLength(1) + expect(rows[0].new_state.jwt_role).toBe('service_role') + expect(new Date(rows[0].new_state.preset_committed_at).toISOString()).toBe(BACKDATED_ISO) + }) + + it('stamps wall_clock at the update moment, not the transaction start', async () => { + // The seeding flows post many entries inside one transaction; now() would + // pin every override to the BEGIN. The writer must use clock_timestamp(). + const { entryId } = await seedBackdatedDraft() + const client = await getPool().connect() + try { + await client.query('BEGIN') + const { + rows: [{ txn_start }], + } = await client.query<{ txn_start: Date }>(`SELECT now() AS txn_start`) + await client.query(`SELECT pg_sleep(1.2)`) + await client.query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + await client.query('COMMIT') + const rows = await fetchOverrideRows(entryId) + expect(rows).toHaveLength(1) + const wallClock = new Date(rows[0].new_state.wall_clock).getTime() + expect(wallClock).toBeGreaterThanOrEqual(txn_start.getTime() + 1_000) + } finally { + client.release() + } + }) + + it('writes no override row when an authenticated member posts (stamp path)', async () => { + const { entryId, userId } = await seedBackdatedDraft() + await withUserContext(userId, async (client) => { + const updated = await client.query( + `UPDATE public.journal_entries SET status = 'posted' WHERE id = $1 RETURNING id`, + [entryId], + ) + expect(updated.rowCount).toBe(1) + }) + expect(await fetchOverrideRows(entryId)).toHaveLength(0) + }) + + it('writes no override row when the draft carries no committed_at', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ + userId, + companyId, + fiscalPeriodId, + entryDate: '2026-03-15', + }) + await insertBalancedLines(entryId) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [ + entryId, + ]) + expect(await fetchOverrideRows(entryId)).toHaveLength(0) + }) + + it('does not expose the writer to client roles', async () => { + // PostgREST would surface any EXECUTE-granted SECURITY DEFINER function in + // public as an RPC; a member must not be able to write audit noise. + const { entryId, userId } = await seedBackdatedDraft() + await withUserContext(userId, async (client) => { + await expect( + client.query( + `SELECT public.log_committed_at_override(je, 'authenticated') + FROM public.journal_entries je WHERE je.id = $1`, + [entryId], + ), + ).rejects.toThrow(/permission denied/) + }) + }) +}) diff --git a/types/index.ts b/types/index.ts index 73ac5235..9804020d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -3401,6 +3401,7 @@ export type AuditAction = | 'RETENTION_BLOCK' | 'SECURITY_EVENT' | 'INTEGRITY_FAILURE' + | 'COMMITTED_AT_OVERRIDE' export interface AuditLogEntry { id: string