Files
accounted/supabase/migrations/20260510140000_harden_invoice_number_rpcs.sql
Mattsson 75043632de Fix/compliance and invoices (#433)
* feat(invoices): implement inline membership checks for invoice-number RPCs and enhance error handling

* fix(invoices): enhance unpaid amount calculation to support currency-specific rounding

* fix(api-client): handle response.text() error for 403 status in skvRequest
2026-05-10 19:09:57 +02:00

129 lines
4.1 KiB
PL/PgSQL

-- Harden invoice-number RPCs with inline membership check + empty search_path.
--
-- Compliance hardening triggered by external audit (OWASP V8.2.1, SOC 2 CC6.1,
-- ISO 27001 A.8.28).
--
-- generate_invoice_number and peek_next_invoice_number are SECURITY DEFINER,
-- which means they execute with elevated privileges and bypass the caller's
-- RLS. They are reached from the /api/invoices/next-number route, which
-- already validates company membership at the application layer
-- (lib/company/context.ts → withRouteContext). This migration adds two layers
-- of defense-in-depth at the DB layer:
--
-- 1. Inline membership check using auth.uid() against public.company_members.
-- Skipped when auth.uid() is NULL (service role / cron / pg-real
-- superuser tests that bypass JWT context). This mirrors how Supabase's
-- service role bypasses RLS by design.
--
-- 2. SET search_path = '' (was 'public'). All table/function references
-- below are now schema-qualified so they cannot be hijacked by an
-- object created in another schema later on the search path.
-- pg_catalog is searched implicitly so built-in functions (LPAD,
-- GREATEST, COALESCE, length, now) still resolve without qualification.
--
-- Behaviour for the LPAD / proforma / GREATEST(3, length) logic is preserved
-- verbatim from 20260510130000_invoice_number_no_truncate.sql.
CREATE OR REPLACE FUNCTION public.generate_invoice_number(
p_company_id uuid,
p_invoice_id uuid,
p_document_type text DEFAULT 'invoice'
)
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_existing text;
v_prefix text;
v_number integer;
v_final text;
BEGIN
-- Defense-in-depth: refuse to operate on companies the caller is not a
-- member of. NULL auth.uid() (service role / cron / superuser) is allowed
-- through; those code paths are trusted and need to operate across tenants.
IF auth.uid() IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
SELECT invoice_number INTO v_existing
FROM public.invoices
WHERE id = p_invoice_id AND company_id = p_company_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Invoice % not found in company %', p_invoice_id, p_company_id;
END IF;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
UPDATE public.company_settings
SET next_invoice_number = next_invoice_number + 1,
updated_at = now()
WHERE company_id = p_company_id
RETURNING invoice_prefix, next_invoice_number - 1
INTO v_prefix, v_number;
IF v_number IS NULL THEN
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
END IF;
v_final := CASE
WHEN p_document_type = 'proforma' THEN 'PF-'
ELSE COALESCE(v_prefix, '')
END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
UPDATE public.invoices
SET invoice_number = v_final
WHERE id = p_invoice_id AND company_id = p_company_id;
RETURN v_final;
END;
$function$;
CREATE OR REPLACE FUNCTION public.peek_next_invoice_number(
p_company_id uuid,
p_document_type text DEFAULT 'invoice'
)
RETURNS text
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $function$
DECLARE
v_prefix text;
v_number integer;
BEGIN
IF auth.uid() IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.company_members
WHERE user_id = auth.uid() AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
SELECT invoice_prefix, next_invoice_number INTO v_prefix, v_number
FROM public.company_settings
WHERE company_id = p_company_id;
IF v_number IS NULL THEN
RETURN NULL;
END IF;
RETURN CASE
WHEN p_document_type = 'proforma' THEN 'PF-'
ELSE COALESCE(v_prefix, '')
END || LPAD(v_number::text, GREATEST(3, length(v_number::text)), '0');
END;
$function$;
NOTIFY pgrst, 'reload schema';