Files
accounted/supabase/migrations/20260527130000_allow_document_supersession.sql
T
Mattsson 8979f6eda3 Bug/year end failure (#575)
* feat: implement findNextPeriod function and integrate into year-end closing logic

* feat: add integrity check for PDF documents and enhance user feedback for corrupt files

* Refactor year-end service and period creation logic for improved UTC handling and error messaging

- Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names.
- Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues.
- Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details.
- Introduce structured error messages for year-end processes in `structured-errors.ts`.
- Add tests for document integrity checks, ensuring proper authentication and error handling.
- Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership.
- Update migration scripts to reflect changes in document immutability enforcement.

* fix: add comment to clarify GUC behavior in document supersession logic
2026-05-27 14:19:30 +02:00

205 lines
8.7 KiB
PL/PgSQL

-- Restore document version supersession on posted entries.
--
-- Background: 20260506150000 extended enforce_document_metadata_immutability
-- to also block changes to journal_entry_id, journal_entry_line_id, AND
-- is_current_version when the document is linked to a posted/reversed entry.
-- The journal_entry_id / line_id additions are correct — they close a real
-- BFL 7 kap 2§ bypass (UPDATE journal_entry_id = NULL → DELETE).
--
-- The is_current_version addition was overreach: the create_document_version
-- RPC must flip the OLD row from is_current_version = true to false (and
-- set superseded_by_id) as part of the legitimate WORM-compliant supersession
-- flow. Every replace attempt on a doc linked to a posted verifikat now
-- raises "Cannot modify metadata or journal entry link of document linked
-- to a posted journal entry (BFL 7 kap)" — which surfaces in the Bilagor
-- modal as "Kunde inte ladda upp ny version".
--
-- This blocks the only path users have to fix corrupt underlag: a PDF that
-- was uploaded with bad bytes (e.g. via the MCP server before magic-byte
-- validation landed in 20260526) is now permanently unreadable on a posted
-- entry, with no replacement possible.
--
-- Fix: introduce a transaction-local gnubok.allow_supersede GUC. Unlike
-- gnubok.allow_delete, this is NOT a blanket bypass — the trigger continues
-- to enforce immutability on every field except is_current_version and
-- superseded_by_id even when the GUC is set. Combined with caller-identity
-- checks inside create_document_version (auth.uid() match + company
-- membership), an attacker who sets the GUC manually still cannot mutate
-- journal_entry_id, sha256_hash, storage_path, or any other audit-critical
-- field on a posted document.
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_entry_status text;
v_allow_supersede boolean;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN NEW;
END IF;
v_allow_supersede := current_setting('gnubok.allow_supersede', true) = 'true';
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
-- Even with allow_supersede, every field other than is_current_version and
-- superseded_by_id remains immutable. The bypass is intentionally narrow
-- so that a session that obtains the GUC (e.g. via SQL injection) cannot
-- mutate journal_entry_id, sha256_hash, storage_path, or any other field
-- the BFL 7 kap audit trail depends on.
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id
OR NEW.journal_entry_line_id IS DISTINCT FROM OLD.journal_entry_line_id
THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata or link modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata or journal entry link of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
-- is_current_version and superseded_by_id may only be changed under the
-- supersede GUC. Without it, those flips are also blocked.
IF NOT v_allow_supersede
AND (NEW.is_current_version IS DISTINCT FROM OLD.is_current_version
OR NEW.superseded_by_id IS DISTINCT FROM OLD.superseded_by_id)
THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked is_current_version/superseded_by_id flip without supersede GUC on document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify is_current_version of document linked to a % journal entry without supersede GUC (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$function$;
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
v_is_member boolean;
BEGIN
-- Caller identity: the RPC is SECURITY DEFINER, so without this guard a
-- direct PostgREST call from any authenticated user could supplant
-- p_user_id with an arbitrary UUID. Reject any mismatch — the route layer
-- already passes the user's own id, so a mismatch is always malicious.
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Authentication required to create document version';
END IF;
IF p_user_id IS DISTINCT FROM v_caller THEN
RAISE EXCEPTION 'p_user_id does not match authenticated user';
END IF;
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
-- Company membership: the caller must be a member of the document's
-- company. RLS would block a direct SELECT in non-DEFINER contexts, but
-- inside this SECURITY DEFINER function we bypass RLS and must enforce
-- the tenant boundary ourselves.
SELECT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = v_current.company_id
AND cm.user_id = v_caller
) INTO v_is_member;
IF NOT v_is_member THEN
-- Audit the cross-tenant attempt so it shows up in security review.
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (v_caller, v_current.company_id, 'SECURITY_EVENT', 'document_attachments', p_original_doc_id,
'Blocked cross-company create_document_version attempt');
RAISE EXCEPTION 'User is not a member of the document''s company';
END IF;
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
-- Insert new version. The supersede GUC is set *before* both DML statements
-- so the trigger sees a consistent state across the whole supersession.
PERFORM set_config('gnubok.allow_supersede', 'true', true);
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash
)
RETURNING id INTO v_new_id;
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
-- Audit the legitimate supersession. BFL 7 kap requires the supersession
-- chain to be reconstructible from immutable storage; the row chain itself
-- carries the data, but an explicit audit row makes the event visible to
-- SOC 2 monitoring without joining version timelines.
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
VALUES (
v_caller, v_current.company_id, 'UPDATE', 'document_attachments', p_original_doc_id, v_caller,
'Document superseded: v' || v_current.version || ' (' || v_current.sha256_hash || ') → v' || v_next_version || ' (' || p_sha256_hash || '); new id=' || v_new_id
);
RETURN v_new_id;
END;
$function$;
NOTIFY pgrst, 'reload schema';