Files
accounted/supabase/migrations/20260723003000_restrict_invoice_delivery_payload_select.sql
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00

662 lines
20 KiB
PL/PgSQL

-- Keep exact invoice email payloads visible only to the sending user.
-- Other company members consume a data-minimized SECURITY DEFINER summary.
DROP POLICY IF EXISTS invoice_deliveries_select ON public.invoice_deliveries;
CREATE POLICY invoice_deliveries_select
ON public.invoice_deliveries FOR SELECT TO authenticated
USING (
company_id = public.current_active_company_id()
AND user_id = auth.uid()
);
CREATE OR REPLACE FUNCTION public.list_invoice_delivery_summaries(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS TABLE (
id uuid,
channel text,
status text,
to_addresses text[],
cc_addresses text[],
provider text,
error_code text,
document_attachment_id uuid,
sent_at timestamptz,
failed_at timestamptz,
created_at timestamptz
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT
d.id,
d.channel,
d.status,
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
d.provider,
d.error_code,
d.document_attachment_id,
d.sent_at,
d.failed_at,
d.created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status <> 'preparing'
ORDER BY d.created_at DESC;
END;
$$;
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
'Returns active-company invoice delivery status with masked To and CC addresses. Exact payload and BCC remain server-side.';
-- Fixed CC and BCC settings can redirect every future invoice email. Keep
-- these fields owner/admin controlled even when PostgREST is called directly.
CREATE OR REPLACE FUNCTION public.enforce_invoice_email_recipient_settings_admin()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW.invoice_email_cc_addresses IS NOT DISTINCT FROM OLD.invoice_email_cc_addresses
AND NEW.invoice_email_bcc_addresses IS NOT DISTINCT FROM OLD.invoice_email_bcc_addresses
THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT'
AND NEW.invoice_email_cc_addresses IS NULL
AND NEW.invoice_email_bcc_addresses IS NULL
THEN
RETURN NEW;
END IF;
IF auth.role() = 'service_role' OR auth.uid() IS NULL THEN
RETURN NEW;
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = NEW.company_id
AND cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
) THEN
RAISE EXCEPTION 'owner or admin role required to change invoice email recipients'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_invoice_email_recipient_settings_admin
ON public.company_settings;
CREATE TRIGGER enforce_invoice_email_recipient_settings_admin
BEFORE INSERT OR UPDATE OF invoice_email_cc_addresses, invoice_email_bcc_addresses
ON public.company_settings
FOR EACH ROW
EXECUTE FUNCTION public.enforce_invoice_email_recipient_settings_admin();
-- Direct authenticated delivery writes can forge evidence. All delivery state
-- changes now go through service-role-only RPCs that bind the row to a verified
-- company member supplied by the server route.
DROP POLICY IF EXISTS invoice_deliveries_insert ON public.invoice_deliveries;
DROP POLICY IF EXISTS invoice_deliveries_update ON public.invoice_deliveries;
CREATE OR REPLACE FUNCTION public.authorize_invoice_delivery_service_actor(
p_company_id uuid,
p_actor_user_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' THEN
RAISE EXCEPTION 'invoice delivery writes require a server-controlled service role'
USING ERRCODE = '42501';
END IF;
IF p_actor_user_id IS NULL OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = p_actor_user_id
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RAISE EXCEPTION 'invoice delivery actor is not a writable company member'
USING ERRCODE = '42501';
END IF;
RETURN p_actor_user_id;
END;
$$;
REVOKE ALL ON FUNCTION public.authorize_invoice_delivery_service_actor(uuid, uuid)
FROM PUBLIC, anon, authenticated;
-- A preparing row contains no recipient or message payload. It may be removed
-- after a bounded timeout so another authorized sender can recover from a
-- crashed render without deleting accounting evidence.
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'preparing'
AND OLD.created_at <= now() - interval '15 minutes'
THEN
RETURN OLD;
END IF;
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
description
) VALUES (
OLD.user_id,
OLD.company_id,
'SECURITY_EVENT',
'invoice_deliveries',
OLD.id,
auth.uid(),
public.invoice_delivery_audit_state(OLD),
'Blocked deletion of immutable invoice delivery history.'
);
RETURN NULL;
END IF;
IF OLD.status = 'preparing' THEN
IF NEW.status <> 'pending'
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.provider IS NOT NULL
OR NEW.provider_message_id IS NOT NULL
OR NEW.error_code IS NOT NULL
OR NEW.sent_at IS NOT NULL
OR NEW.failed_at IS NOT NULL
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS NOT NULL
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'pending' THEN
IF NEW.status NOT IN ('sent', 'failed') THEN
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
USING ERRCODE = '23514';
END IF;
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
OR NEW.from_name IS DISTINCT FROM OLD.from_name
OR NEW.subject IS DISTINCT FROM OLD.subject
OR NEW.body_text IS DISTINCT FROM OLD.body_text
OR NEW.body_html IS DISTINCT FROM OLD.body_html
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR (
NEW.status = 'sent'
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
)
OR (
NEW.status = 'failed'
AND NEW.document_attachment_id IS NOT NULL
)
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status IN ('sent', 'failed')
AND OLD.pii_redacted_at IS NULL
AND CURRENT_DATE >= OLD.retention_expires_at
AND NEW.pii_redacted_at IS NOT NULL
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
AND NEW.status IS NOT DISTINCT FROM OLD.status
AND cardinality(NEW.to_addresses) = 0
AND cardinality(NEW.cc_addresses) = 0
AND cardinality(NEW.bcc_addresses) = 0
AND NEW.reply_to IS NULL
AND NEW.from_name IS NULL
AND NEW.subject IS NULL
AND NEW.body_text IS NULL
AND NEW.body_html IS NULL
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
AND NEW.provider_message_id IS NULL
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
AND NEW.attachment_filename IS NULL
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
AND NEW.attachment_sha256 IS NULL
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END;
$$;
CREATE OR REPLACE FUNCTION public.reserve_invoice_delivery(
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
delivery_id uuid;
existing_actor_id uuid;
existing_created_at timestamptz;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF NOT EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.id = p_invoice_id AND i.company_id = p_company_id
) THEN
RAISE EXCEPTION 'invoice not found for delivery reservation'
USING ERRCODE = 'P0002';
END IF;
LOOP
BEGIN
INSERT INTO public.invoice_deliveries (
company_id, user_id, invoice_id, channel, status
) VALUES (
p_company_id, actor_id, p_invoice_id, 'email', 'preparing'
)
RETURNING id INTO delivery_id;
RETURN delivery_id;
EXCEPTION WHEN unique_violation THEN
SELECT d.id, d.user_id, d.created_at
INTO delivery_id, existing_actor_id, existing_created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status = 'preparing'
FOR UPDATE;
IF delivery_id IS NULL THEN
CONTINUE;
END IF;
IF existing_actor_id = actor_id THEN
RETURN delivery_id;
END IF;
IF existing_created_at > now() - interval '15 minutes' THEN
RAISE EXCEPTION 'invoice delivery is already being prepared'
USING ERRCODE = '55P03';
END IF;
DELETE FROM public.invoice_deliveries d WHERE d.id = delivery_id;
delivery_id := NULL;
END;
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION public.capture_invoice_delivery_payload(
p_delivery_id uuid,
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid,
p_to_addresses text[],
p_cc_addresses text[],
p_bcc_addresses text[],
p_reply_to text,
p_from_name text,
p_subject text,
p_body_text text,
p_body_html text,
p_document_attachment_id uuid,
p_attachment_filename text,
p_attachment_content_type text,
p_attachment_sha256 text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
captured_id uuid;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
UPDATE public.invoice_deliveries
SET status = 'pending',
to_addresses = p_to_addresses,
cc_addresses = p_cc_addresses,
bcc_addresses = p_bcc_addresses,
reply_to = p_reply_to,
from_name = p_from_name,
subject = p_subject,
body_text = p_body_text,
body_html = p_body_html,
document_attachment_id = p_document_attachment_id,
attachment_filename = p_attachment_filename,
attachment_content_type = p_attachment_content_type,
attachment_sha256 = p_attachment_sha256
WHERE id = p_delivery_id
AND company_id = p_company_id
AND invoice_id = p_invoice_id
AND user_id = actor_id
AND status = 'preparing'
RETURNING id INTO captured_id;
IF captured_id IS NULL THEN
RAISE EXCEPTION 'invoice delivery reservation cannot capture payload'
USING ERRCODE = '23514';
END IF;
RETURN captured_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_invoice_delivery(
p_delivery_id uuid,
p_company_id uuid,
p_actor_user_id uuid,
p_status text,
p_provider text,
p_provider_message_id text,
p_error_code text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
finalized_id uuid;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF p_status = 'sent' THEN
UPDATE public.invoice_deliveries
SET status = 'sent',
provider = p_provider,
provider_message_id = p_provider_message_id,
error_code = NULL,
sent_at = now(),
failed_at = NULL
WHERE id = p_delivery_id
AND company_id = p_company_id
AND user_id = actor_id
AND status = 'pending'
RETURNING id INTO finalized_id;
ELSIF p_status = 'failed' THEN
UPDATE public.invoice_deliveries
SET status = 'failed',
provider = p_provider,
provider_message_id = NULL,
error_code = COALESCE(p_error_code, 'provider_failed'),
document_attachment_id = NULL,
sent_at = NULL,
failed_at = now()
WHERE id = p_delivery_id
AND company_id = p_company_id
AND user_id = actor_id
AND status = 'pending'
RETURNING id INTO finalized_id;
ELSE
RAISE EXCEPTION 'invoice delivery terminal status must be sent or failed'
USING ERRCODE = '22023';
END IF;
IF finalized_id IS NULL THEN
RAISE EXCEPTION 'invoice delivery cannot be finalized'
USING ERRCODE = '23514';
END IF;
RETURN finalized_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.record_manual_invoice_delivery(
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid,
p_sent_at timestamptz DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
delivery public.invoice_deliveries%ROWTYPE;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF NOT EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.id = p_invoice_id
AND i.company_id = p_company_id
AND i.status IN ('sent', 'overdue', 'partially_paid', 'paid', 'credited')
) THEN
RAISE EXCEPTION 'invoice is not in a manually sent state'
USING ERRCODE = '23514';
END IF;
INSERT INTO public.invoice_deliveries (
company_id, user_id, invoice_id, channel, status, sent_at
) VALUES (
p_company_id, actor_id, p_invoice_id, 'manual', 'marked_sent',
COALESCE(p_sent_at, now())
)
RETURNING * INTO delivery;
RETURN to_jsonb(delivery);
END;
$$;
-- Deferred booking needs only the archived document id, not the email payload.
CREATE OR REPLACE FUNCTION public.latest_sent_invoice_delivery_document(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
document_id uuid;
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to find delivered invoice document'
USING ERRCODE = '42501';
END IF;
SELECT d.document_attachment_id
INTO document_id
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status = 'sent'
AND d.document_attachment_id IS NOT NULL
ORDER BY d.sent_at DESC
LIMIT 1;
RETURN document_id;
END;
$$;
-- Full statutory exports are an explicit need-to-know exception to routine
-- sender-only payload access. Authenticated callers must be owner/admin; server
-- service paths are already authorized before invoking the exporter.
CREATE OR REPLACE FUNCTION public.export_invoice_delivery_evidence(
p_company_id uuid
)
RETURNS SETOF public.invoice_deliveries
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' AND (
auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
)
) THEN
RAISE EXCEPTION 'owner or admin role required to export invoice delivery evidence'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT d.*
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id;
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.capture_invoice_delivery_payload(
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
uuid, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_invoice_delivery(
uuid, uuid, uuid, text, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
TO service_role;
GRANT EXECUTE ON FUNCTION public.capture_invoice_delivery_payload(
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
uuid, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_invoice_delivery(
uuid, uuid, uuid, text, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
TO service_role;
REVOKE ALL ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.export_invoice_delivery_evidence(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_invoice_delivery_evidence(uuid)
TO authenticated, service_role;
COMMENT ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid) IS
'Service-only reservation for an invoice email delivery. Reclaims payload-free reservations after 15 minutes.';
COMMENT ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid) IS
'Returns only the latest sent delivery document id for active-company deferred booking.';
COMMENT ON FUNCTION public.export_invoice_delivery_evidence(uuid) IS
'Returns exact company delivery evidence only to owner/admin audit exports and service-role server paths.';
NOTIFY pgrst, 'reload schema';