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>
This commit is contained in:
@@ -853,3 +853,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
@@ -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<OverrideAuditRow[]> {
|
||||
// Read as postgres: audit_log SELECT is restricted to the owning user.
|
||||
const { rows } = await getPool().query<OverrideAuditRow>(
|
||||
`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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3401,6 +3401,7 @@ export type AuditAction =
|
||||
| 'RETENTION_BLOCK'
|
||||
| 'SECURITY_EVENT'
|
||||
| 'INTEGRITY_FAILURE'
|
||||
| 'COMMITTED_AT_OVERRIDE'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user