305f469fc3
* harden(security): payment-row company-consistency triggers (tenant backstop) invoice_payments and supplier_invoice_payments are the only two child tables carrying BOTH a parent FK and their own company_id. A row whose company_id disagrees with its parent's company_id is a tenant-isolation defect that would surface a foreign tenant's payment in this company's AR/AP ledger. RLS scopes by company_id but never cross-checks the parent, so nothing at the DB layer guaranteed the invariant. - Pre-flight DO block: fail the migration loudly (listing offending ids) if any existing row already violates child.company_id = parent.company_id, rather than arm a trigger over dirty data that can never be updated again. - enforce_payment_company_consistency(): one INVOKER trigger function parameterized on TG_TABLE_NAME, wired BEFORE INSERT OR UPDATE OF (company_id, parent_fk) on both payment tables; raises on mismatch. Matches the SECURITY posture of the sibling enforcement triggers in migration 017. - pg-real coverage in tests/pg/payment-company-consistency.pg.test.ts: matching pair inserts ok; cross-tenant insert + cross-tenant UPDATE raise; both the customer and supplier side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(security): tenant guards on six SECURITY DEFINER write RPCs (backstop) bulk_book_transactions, match_batch_allocate, mark_entry_as_opening_balance, reserve_voucher_range, release_voucher_range and rotate_company_inbox are all SECURITY DEFINER and EXECUTE-able by `authenticated`, so an authenticated user could call them via PostgREST with ANOTHER company's p_company_id. Three already carried an auth.uid()-based membership check and rotate_company_inbox an owner/admin gate, but the two voucher-range RPCs had NO tenant check at all. Adds the canonical claims-based guard (mirrors 20260615120000_link_voucher_rpcs_tenant_guard.sql lines 54-69) at the top of each body: for anon/authenticated callers, membership of p_company_id (public.user_company_ids()) is required else RAISE 42501; service_role and no-claims callers (migrations, pg-harness, MCP / API-key paths whose company scoping happens in TS) bypass BY DESIGN. Each function body is otherwise copied verbatim from its latest definition; existing GRANTs re-applied. pg-real coverage in tests/pg/securitydefiner_write_rpc_tenant_guards.pg.test.ts: per RPC — userA session targeting companyB raises 42501; targeting own company passes the guard (succeeds or yields a non-42501 domain outcome, documented inline); a no-claims bare-pool cross-tenant call bypasses the new guard, proving the service-role / MCP paths are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(db): renumber tenant-backstop migrations to 20260619130000/130100 PR1 (agent attribution) claimed the 20260619120000 version slot in the same batch; Supabase migration versions must be unique across the repo, so the tenant-backstop pair moves to 130000/130100. Filename-only change plus the matching doc-comment references in the two pg tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(db): restore source comments dropped in copied RPC bodies The guarded redefinitions of bulk_book_transactions and match_batch_allocate must be byte-verbatim copies of their latest sources (modulo the inserted tenant-guard block) so the next CREATE OR REPLACE copy keeps full provenance. Restores the Round-2/Round-3 compliance-fix annotations that were lost in the copy. Verified mechanically: zero residual diff vs sources after stripping the guard block, for all six functions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db): drop raise-guards from bulk_book/match_batch — they break the jsonb error contract Local full-migration replay + pg-real run surfaced that prepending the 42501 raise-guard to bulk_book_transactions and match_batch_allocate changes their error contract for authenticated cross-tenant callers: both already enforce membership in-function and return structured domain errors (BULK_BOOK_UNAUTHORIZED / BATCH_UNAUTHORIZED) that routes, MCP tools, and their existing pg tests branch on. The guard added no isolation (they were tenant-safe) but broke that contract. The migration now guards only the four RPCs where it is sound: mark_entry_as_opening_balance (P0001→42501, still an exception), rotate_company_inbox (already 42501), and the two genuinely unguarded voucher-range RPCs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db): compliance-review round — log hygiene, explicit INVOKER, anon revoke, UPDATE-path test Addresses the compliance-swarm findings on this PR: - Pre-flight dirty-data check now raises with COUNTS only; the row ids move to RAISE NOTICE so error pipelines do not ingest identifier dumps (ASVS V8.2.1 / SOC 2 CC6.1). - enforce_payment_company_consistency() declares SECURITY INVOKER explicitly — the default was already INVOKER; this makes the security model self-documenting. - REVOKE ... FROM PUBLIC, anon on reserve/release_voucher_range and rotate_company_inbox, matching the mark_entry_as_opening_balance pattern. - Adds the missing supplier_invoice_payments UPDATE-path trigger probe (SOC 2 PI1.3). Dismissed as by-design: the JWT-claim trust boundary (set_config requires direct SQL access, which already bypasses by design — same model as 20260615120000). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(db): voucher-range compliance guards + FK-rerouting trigger probes Review round 2 on this PR: Swedish compliance review (both pre-existing function behaviour, hardened while the PR owns these bodies): - reserve/release_voucher_range now refuse closed/locked fiscal periods (BFL 5 kap 5§ — the sequence of a locked period is räkenskapsinformation; mirrors mark_entry_as_opening_balance). - release_voucher_range asserts no verifikat exist in the released range before rolling last_number back (BFL 5 kap 6-7§ — never re-issue or orphan posted verifikationsnummer). Neither guard can fire in the legit SIE-import flow, which only releases numbers above its highest inserted verifikat into an open period — and the import caller treats a failed release as non-fatal. Greptile P2: the UPDATE OF <parent_fk> trigger leg was never probed — added cross-tenant FK-rerouting rejection tests for both payment tables (the supplier company_id UPDATE probe landed in the previous commit). Verified: full migration replay on fresh supabase/postgres + 333/333 pg-real green on an origin/main merge (incl. merged #678). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(test): release-succeeds probe must persist — callBare rolls back The legit-path release test asserted last_number after calling the RPC via callBare, whose BEGIN...ROLLBACK wrapper undoes the UPDATE before the assertion reads it (caught in CI; the local pre-push replay had validated the branch's committed state, not the then-uncommitted test). Call the RPC directly on the pool, like the engine pg tests do for persisting calls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
5.3 KiB
PL/PgSQL
112 lines
5.3 KiB
PL/PgSQL
-- Payment row company-consistency triggers (P0 tenant backstop).
|
|
--
|
|
-- invoice_payments and supplier_invoice_payments are the only two child tables
|
|
-- that carry BOTH a parent FK (invoice_id / supplier_invoice_id) and their own
|
|
-- company_id column (added by 20260330130000_multi_tenant_company_refactor). A
|
|
-- row whose company_id disagrees with its parent's company_id is a tenant-
|
|
-- isolation defect: it would surface a foreign tenant's payment in this
|
|
-- company's ledger (and vice-versa) and corrupt AR/AP reconciliation. RLS scopes
|
|
-- reads/writes by company_id but does NOT cross-check the parent, and the write
|
|
-- RPCs always pass a consistent pair — so nothing at the DB layer guarantees the
|
|
-- invariant. These BEFORE INSERT/UPDATE triggers make it impossible to persist a
|
|
-- mismatched pair regardless of how the row is written (RPC, direct PostgREST,
|
|
-- service role, or a future code path).
|
|
--
|
|
-- (journal_entry_lines, invoice_items and supplier_invoice_items carry a parent
|
|
-- FK but NO company_id of their own, so they cannot drift and are out of scope.)
|
|
--
|
|
-- SECURITY posture: like the sibling enforcement triggers in migration 017, the
|
|
-- trigger function is a plain (INVOKER) trigger function — it only reads the
|
|
-- parent's company_id via the FK and raises; it needs no elevated privilege.
|
|
|
|
-- =============================================================================
|
|
-- 0. PRE-FLIGHT: fail loudly if any existing row already violates the invariant,
|
|
-- rather than arming a trigger over dirty data that can never be updated again.
|
|
-- =============================================================================
|
|
DO $$
|
|
DECLARE
|
|
v_bad_invoice_payments uuid[];
|
|
v_bad_supplier_payments uuid[];
|
|
BEGIN
|
|
SELECT array_agg(p.id)
|
|
INTO v_bad_invoice_payments
|
|
FROM public.invoice_payments p
|
|
JOIN public.invoices i ON i.id = p.invoice_id
|
|
WHERE p.company_id IS DISTINCT FROM i.company_id;
|
|
|
|
SELECT array_agg(p.id)
|
|
INTO v_bad_supplier_payments
|
|
FROM public.supplier_invoice_payments p
|
|
JOIN public.supplier_invoices si ON si.id = p.supplier_invoice_id
|
|
WHERE p.company_id IS DISTINCT FROM si.company_id;
|
|
|
|
IF v_bad_invoice_payments IS NOT NULL OR v_bad_supplier_payments IS NOT NULL THEN
|
|
-- Row ids go to NOTICE (server log, operator-visible at apply time); the
|
|
-- exception itself carries only counts so error pipelines/aggregators do
|
|
-- not ingest identifier dumps (OWASP ASVS V8.2.1 / SOC 2 CC6.1).
|
|
RAISE NOTICE 'mismatched invoice_payments ids: %',
|
|
COALESCE(v_bad_invoice_payments, ARRAY[]::uuid[]);
|
|
RAISE NOTICE 'mismatched supplier_invoice_payments ids: %',
|
|
COALESCE(v_bad_supplier_payments, ARRAY[]::uuid[]);
|
|
RAISE EXCEPTION 'Cannot arm payment company-consistency triggers over dirty data: % invoice_payments and % supplier_invoice_payments row(s) mismatched — see preceding NOTICEs for ids.',
|
|
COALESCE(array_length(v_bad_invoice_payments, 1), 0),
|
|
COALESCE(array_length(v_bad_supplier_payments, 1), 0);
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- =============================================================================
|
|
-- 1. Trigger function: assert child.company_id matches the parent's company_id.
|
|
-- Parameterized on TG_TABLE_NAME so one function covers both payment tables
|
|
-- (mirrors the single-function-per-concern style of migration 017).
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.enforce_payment_company_consistency()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
AS $$
|
|
DECLARE
|
|
v_parent_company_id uuid;
|
|
BEGIN
|
|
IF TG_TABLE_NAME = 'invoice_payments' THEN
|
|
SELECT company_id INTO v_parent_company_id
|
|
FROM public.invoices
|
|
WHERE id = NEW.invoice_id;
|
|
|
|
IF v_parent_company_id IS DISTINCT FROM NEW.company_id THEN
|
|
RAISE EXCEPTION
|
|
'invoice_payments.company_id (%) does not match invoices.company_id (%) for invoice %',
|
|
NEW.company_id, v_parent_company_id, NEW.invoice_id;
|
|
END IF;
|
|
ELSIF TG_TABLE_NAME = 'supplier_invoice_payments' THEN
|
|
SELECT company_id INTO v_parent_company_id
|
|
FROM public.supplier_invoices
|
|
WHERE id = NEW.supplier_invoice_id;
|
|
|
|
IF v_parent_company_id IS DISTINCT FROM NEW.company_id THEN
|
|
RAISE EXCEPTION
|
|
'supplier_invoice_payments.company_id (%) does not match supplier_invoices.company_id (%) for supplier invoice %',
|
|
NEW.company_id, v_parent_company_id, NEW.supplier_invoice_id;
|
|
END IF;
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
-- =============================================================================
|
|
-- 2. Wire the trigger to both payment tables. BEFORE INSERT OR UPDATE OF the
|
|
-- columns that could break the invariant (company_id and the parent FK).
|
|
-- =============================================================================
|
|
DROP TRIGGER IF EXISTS enforce_payment_company_consistency ON public.invoice_payments;
|
|
CREATE TRIGGER enforce_payment_company_consistency
|
|
BEFORE INSERT OR UPDATE OF company_id, invoice_id ON public.invoice_payments
|
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_payment_company_consistency();
|
|
|
|
DROP TRIGGER IF EXISTS enforce_payment_company_consistency ON public.supplier_invoice_payments;
|
|
CREATE TRIGGER enforce_payment_company_consistency
|
|
BEFORE INSERT OR UPDATE OF company_id, supplier_invoice_id ON public.supplier_invoice_payments
|
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_payment_company_consistency();
|
|
|
|
NOTIFY pgrst, 'reload schema';
|