77becf3d65
The 03:00 WORM verification cron stamped last_integrity_check_at on document_attachments. enforce_period_lock_documents() fires on any UPDATE of a row whose journal entry sits in a closed or locked period, without checking whether the entry link actually changed, so a read-only integrity stamp was rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the rejected rows re-sorted to the head every night and the batch became permanently 200/200 blocked. Both call sites discarded the update error, so nothing logged and nothing alerted. Prod state: 34 557 current-version documents, 24 083 never checked, last successful stamp 2026-08-31 03:00, nightly successes already decayed to single digits. Migration 017's enforcement triggers are legally required and never-touch, so this does not narrow the trigger. The verification outcome moves to its own document_integrity_checks table and the cron stops writing document_attachments altogether, which takes the trigger off the write path. The legacy column stays in place. Failures are now counted, logged and reported in the route's summary: the silence is why this went unnoticed for weeks. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
12 KiB
PL/PgSQL
237 lines
12 KiB
PL/PgSQL
-- Document integrity ledger: move the WORM verification stamp OFF
|
|
-- document_attachments.
|
|
--
|
|
-- The nightly cron (app/api/documents/verify/cron/route.ts, "0 3 * * *" in
|
|
-- vercel.json) recorded every check by UPDATEing
|
|
-- document_attachments.last_integrity_check_at. That UPDATE runs through
|
|
-- enforce_period_lock_documents() (migration 20240101000017), which is
|
|
-- BEFORE INSERT OR UPDATE FOR EACH ROW with no WHEN clause and no OLD/NEW
|
|
-- comparison: it raises for ANY update of a document whose journal entry sits
|
|
-- in a closed or locked fiscal period, even when the entry link is untouched.
|
|
-- Because the queue ordered by last_integrity_check_at ASC NULLS FIRST, every
|
|
-- rejected row sorted straight back to the head of the queue the next night.
|
|
-- Measured on prod 2026-09-01: the whole batch was 200 of 200 rejected, 24 095
|
|
-- of 34 569 current-version documents had never been verified, and the newest
|
|
-- successful stamp was 2026-08-31 03:00. Both call sites discarded the update
|
|
-- error, so nothing logged and the control had been dead for weeks.
|
|
--
|
|
-- Migration 017's enforcement triggers are legally required and are never
|
|
-- touched (CLAUDE.md), so the fix is to stop writing to document_attachments
|
|
-- at all: the outcome of each verification becomes a row in its own
|
|
-- append-only ledger and the trigger leaves the write path entirely.
|
|
--
|
|
-- document_attachments.last_integrity_check_at is deliberately LEFT IN PLACE
|
|
-- and is now legacy: lib/core/documents/document-service.ts still selects it
|
|
-- and historical rows carry real values. Nothing writes it any more. Its index
|
|
-- does go (section 6): the column keeps its history, the index served only the
|
|
-- query this migration retires.
|
|
--
|
|
-- The new table is classified in lib/reports/full-archive-export.ts as
|
|
-- deliberately outside the säkerhetsbackup. It is verification metadata ABOUT
|
|
-- räkenskapsinformation, not räkenskapsinformation itself, and the checks that
|
|
-- carry legal weight (the failures) already reach the archive through
|
|
-- audit_log as INTEGRITY_FAILURE.
|
|
|
|
-- =============================================================
|
|
-- 1. The ledger
|
|
-- =============================================================
|
|
|
|
CREATE TABLE public.document_integrity_checks (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
|
document_id UUID NOT NULL REFERENCES public.document_attachments(id) ON DELETE CASCADE,
|
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
-- What the check compared. expected_sha256 is the hash recorded on the
|
|
-- document at upload; computed_sha256 is what re-hashing the stored object
|
|
-- produced, and is NULL when the object could not be downloaded at all.
|
|
expected_sha256 TEXT NOT NULL,
|
|
computed_sha256 TEXT,
|
|
-- The storage key that was actually read. Kept on the row because the
|
|
-- cron falls back to the company-scoped layout when the stored pointer is
|
|
-- stale, so "which object did we hash" is not derivable afterwards.
|
|
storage_path TEXT NOT NULL,
|
|
result TEXT NOT NULL CHECK (result IN ('passed', 'hash_mismatch', 'object_missing')),
|
|
-- Human-readable detail for the failing results (download error, the two
|
|
-- hashes). NULL on a pass.
|
|
detail TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
-- No updated_at: append-only table.
|
|
);
|
|
|
|
-- =============================================================
|
|
-- 2. Row-level security
|
|
-- =============================================================
|
|
--
|
|
-- Same shape as processing_history (20260418130000) and audit_log: a
|
|
-- company-scoped SELECT policy through user_company_ids(), and no
|
|
-- INSERT/UPDATE/DELETE policies at all, so only the service-role cron can
|
|
-- append. Service-role-only-with-no-policies (the connector_* ledgers) was the
|
|
-- other candidate and was rejected: this ledger is the evidence a company
|
|
-- shows that its verifikat archive is actually being verified, so the tenant
|
|
-- must be able to read its own rows. Nobody, tenant included, may write them.
|
|
|
|
ALTER TABLE public.document_integrity_checks ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "document_integrity_checks_select" ON public.document_integrity_checks
|
|
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
|
|
|
-- Defense in depth on top of RLS, the same lockdown exchange_rates got in
|
|
-- 20260710100000, but stated explicitly instead of leaning on Supabase's
|
|
-- default privileges (which hand anon and authenticated full DML on every new
|
|
-- public table): a company member reads, the cron appends, nobody else has the
|
|
-- privilege at all. service_role gets no UPDATE or DELETE either, since the
|
|
-- ledger is append-only; the ON DELETE CASCADE above still fires, because a
|
|
-- referential action runs with the constraint owner's rights, not the
|
|
-- caller's.
|
|
REVOKE ALL ON public.document_integrity_checks FROM anon, authenticated, service_role;
|
|
GRANT SELECT ON public.document_integrity_checks TO authenticated;
|
|
GRANT SELECT, INSERT ON public.document_integrity_checks TO service_role;
|
|
|
|
-- =============================================================
|
|
-- 3. Indexes
|
|
-- =============================================================
|
|
|
|
-- The cron's queue. next_documents_for_integrity_check() orders by the most
|
|
-- recent check per document, and that timestamp lives in THIS table, so no
|
|
-- index on document_attachments can drive the ordering. The plan is instead a
|
|
-- scan of the current-version documents, one index probe per document into the
|
|
-- index below, and a top-N heapsort of p_limit rows: measured against prod on
|
|
-- 2026-09-01, ~34.6k rows scanned in 42 ms end to end. The
|
|
-- (document_id, checked_at DESC) column order is exactly the lateral's
|
|
-- "WHERE document_id = ? ORDER BY checked_at DESC LIMIT 1", so each probe is
|
|
-- an index-only scan of one entry.
|
|
CREATE INDEX idx_document_integrity_checks_document
|
|
ON public.document_integrity_checks (document_id, checked_at DESC);
|
|
|
|
-- Tenant-facing read: "show me this company's integrity checks, newest first".
|
|
-- Matches the SELECT policy's company_id filter.
|
|
CREATE INDEX idx_document_integrity_checks_company
|
|
ON public.document_integrity_checks (company_id, checked_at DESC);
|
|
|
|
-- =============================================================
|
|
-- 4. Immutability
|
|
-- =============================================================
|
|
-- Own one-line function rather than the shared audit_log_immutable(): reusing
|
|
-- it works, but it raises "Audit log entries cannot be modified or deleted"
|
|
-- from a table that is not the audit log, and that message is already
|
|
-- pattern-matched as an audit-log signal elsewhere in the app
|
|
-- (app/api/transactions/[id]/route.ts). A per-ledger function is the house
|
|
-- pattern for exactly this reason: skatteverket_api_audit_log (20260517135000)
|
|
-- and company_migration_resets (20260818084050) each carry their own. It also
|
|
-- decouples this table from a shared function that keeps being amended for
|
|
-- sandbox teardown.
|
|
--
|
|
-- UPDATE only, not DELETE: the FKs above cascade when a company or a document
|
|
-- is legally deleted after its retention window, and a BEFORE DELETE trigger
|
|
-- would block that cascade.
|
|
|
|
-- SECURITY INVOKER (the default), like audit_log_immutable(): the body only
|
|
-- raises, so it reads and writes nothing that definer rights could reach.
|
|
CREATE OR REPLACE FUNCTION public.document_integrity_check_immutable()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SET search_path = public
|
|
AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'Document integrity check entries cannot be modified or deleted';
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER document_integrity_checks_no_update
|
|
BEFORE UPDATE ON public.document_integrity_checks
|
|
FOR EACH ROW EXECUTE FUNCTION public.document_integrity_check_immutable();
|
|
|
|
-- =============================================================
|
|
-- 5. Queue for the nightly cron
|
|
-- =============================================================
|
|
--
|
|
-- Least-recently-verified first, never-verified before that. The tie-break on
|
|
-- created_at is free: the ordering key is a column of the joined table, so the
|
|
-- sort cannot be index-driven either way, and adding the second key costs
|
|
-- nothing while turning an arbitrary heap-order queue into a deterministic
|
|
-- FIFO drain. Heap order is precisely what let the same 200 rows occupy the
|
|
-- head of the old queue every single night.
|
|
|
|
CREATE OR REPLACE FUNCTION public.next_documents_for_integrity_check(
|
|
p_limit integer DEFAULT 200
|
|
)
|
|
RETURNS TABLE (
|
|
id uuid,
|
|
user_id uuid,
|
|
company_id uuid,
|
|
storage_path text,
|
|
sha256_hash text,
|
|
file_name text,
|
|
last_checked_at timestamptz
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path = public, pg_temp
|
|
AS $$
|
|
SELECT
|
|
d.id,
|
|
d.user_id,
|
|
d.company_id,
|
|
d.storage_path,
|
|
d.sha256_hash,
|
|
d.file_name,
|
|
last_check.checked_at
|
|
FROM public.document_attachments d
|
|
LEFT JOIN LATERAL (
|
|
SELECT c.checked_at
|
|
FROM public.document_integrity_checks c
|
|
WHERE c.document_id = d.id
|
|
ORDER BY c.checked_at DESC
|
|
LIMIT 1
|
|
) last_check ON true
|
|
WHERE d.is_current_version = true
|
|
ORDER BY last_check.checked_at ASC NULLS FIRST, d.created_at ASC
|
|
LIMIT LEAST(GREATEST(COALESCE(p_limit, 200), 1), 1000);
|
|
$$;
|
|
|
|
-- The cron is the only caller and runs as the service role, which bypasses
|
|
-- RLS; nothing else has any business enumerating every tenant's documents.
|
|
REVOKE ALL ON FUNCTION public.next_documents_for_integrity_check(integer) FROM PUBLIC, anon, authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.next_documents_for_integrity_check(integer) TO service_role;
|
|
|
|
COMMENT ON TABLE public.document_integrity_checks IS
|
|
'Append-only outcome of each WORM archive integrity check (SHA-256 recompute) on document_attachments. Written only by the nightly cron under the service role; readable by the owning company. Replaces document_attachments.last_integrity_check_at, whose UPDATE was rejected by enforce_period_lock_documents() for every document linked to a closed/locked period.';
|
|
|
|
COMMENT ON COLUMN public.document_attachments.last_integrity_check_at IS
|
|
'LEGACY. Superseded by public.document_integrity_checks (20260901130000). Historical values only: nothing writes this column any more, because any UPDATE of a document linked to an entry in a closed/locked period is rejected by enforce_period_lock_documents().';
|
|
|
|
-- =============================================================
|
|
-- 6. Retire the index that served the old queue
|
|
-- =============================================================
|
|
--
|
|
-- idx_document_attachments_integrity_check (20260330120000) is
|
|
-- (last_integrity_check_at ASC NULLS FIRST) WHERE is_current_version = true.
|
|
-- It existed for exactly one query, the old cron's ORDER BY, and that query no
|
|
-- longer exists: the new queue orders by a column in ANOTHER table, so no index
|
|
-- on document_attachments can drive it. Prod on 2026-09-01 shows the shape
|
|
-- precisely: 152 scans since the index was created in March (one per nightly
|
|
-- run) against 3.8 M on idx_document_attachments_journal_entry_id, and 616 kB
|
|
-- kept up to date on every insert and update of the busiest document table for
|
|
-- reads that are now zero. It does not survive as a filter for the new queue
|
|
-- either: EXPLAIN on prod for the new outer scan (is_current_version = true
|
|
-- over 34.6k rows) picks a Seq Scan, not this partial index.
|
|
--
|
|
-- Dropped rather than kept "just in case": the column it indexes is frozen, so
|
|
-- the index can never become useful again without a new migration that also
|
|
-- resurrects the writer. Nothing else in the repo names it (checked), so the
|
|
-- only cost is a moment's ACCESS EXCLUSIVE lock on a 34.6k-row table. Plain
|
|
-- DROP INDEX, not CONCURRENTLY: migrations run inside a transaction, which
|
|
-- CONCURRENTLY forbids, and dropping needs the lock rather than a rebuild.
|
|
-- Between this migration and the deploy that follows it, the old cron code
|
|
-- degrades to a sequential scan of 34.6k rows once a night, which is
|
|
-- irrelevant, and it was rejecting 200 of 200 writes anyway.
|
|
|
|
DROP INDEX IF EXISTS public.idx_document_attachments_integrity_check;
|
|
|
|
-- =============================================================
|
|
-- 7. Schema reload
|
|
-- =============================================================
|
|
|
|
NOTIFY pgrst, 'reload schema';
|