75043632de
* 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
129 lines
4.1 KiB
PL/PgSQL
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';
|