Files
accounted/supabase/migrations/20260821180000_peppol_access.sql
T
3ac80edc96 feat(peppol): gate Peppol per company: request access, operator enables with a sending cap (#1794)
* feat(peppol): gate Peppol per company: request access, operator enables with a sending cap

Peppol is no longer available to every company by default. Each transmission
is billed per document by the access point and each receiving identifier
consumes a contracted tenant slot, so the product now works like this:

- peppol_access (new table, RLS read-only for members, service-role writes):
  status requested | enabled | disabled, max_sends (null = no cap),
  receive_enabled as a separate grant, who asked and who enabled.
- POST /api/settings/peppol/access: the company asks from Settings >
  Fakturering; the row is written and the operators are e-mailed (best effort,
  the row is the source of truth).
- scripts/peppol/access.ts list | enable <company|orgnr> [--max-sends N]
  [--receive] | disable | show: the operator side.
- POST /api/invoices/[id]/peppol/send refuses PEPPOL_ACCESS_REQUIRED /
  PEPPOL_SEND_LIMIT_REACHED before touching the invoice; the invoice page's
  send item says so instead of pretending. Registration for receiving refuses
  PEPPOL_ACCESS_REQUIRED / PEPPOL_RECEIVING_NOT_ENABLED.
- Settings UI: access status row with "Begär åtkomst", sends used of cap,
  receiving switch only once receiving is granted.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* test(peppol): pass route params to the settings handlers; baseline-align the access row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* fix(peppol): revoke default table privileges from authenticated on the access and receiving tables

Supabase grants ALL on new tables to authenticated by default; the earlier
REVOKE covered PUBLIC and anon only, so a member's UPDATE on peppol_access was
an RLS-filtered no-op instead of a permission error (pg-real caught it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 17:27:45 +02:00

62 lines
3.1 KiB
SQL

-- Peppol access is granted per company, never self-served (#546).
--
-- Every Peppol transmission costs money at the Access Point and every
-- receiving identifier consumes a contracted tenant slot, so a company is
-- locked out by default, asks for access from the settings page, and the
-- operators enable it (with a sending cap) from the service role. The table
-- is readable by the company's members and written by nobody but the service
-- role: the browser can ask, it can never grant itself anything.
CREATE TABLE public.peppol_access (
company_id uuid PRIMARY KEY REFERENCES public.companies(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'requested'
CHECK (status IN ('requested', 'enabled', 'disabled')),
-- Cap on transmissions through the access point; null = no cap.
max_sends integer CHECK (max_sends IS NULL OR max_sends >= 0),
-- Receiving (publishing the company's identifier) is a separate, scarcer
-- grant: it consumes one of the contracted tenant slots.
receive_enabled boolean NOT NULL DEFAULT false,
requested_at timestamptz,
requested_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
request_note text CHECK (request_note IS NULL OR length(request_note) <= 2000),
enabled_at timestamptz,
-- Free-text label of who granted it (operator e-mail or script name);
-- operators are not application users, so no FK.
enabled_by text,
disabled_at timestamptz,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT peppol_access_status_shape CHECK (
(status = 'enabled' AND enabled_at IS NOT NULL)
OR (status = 'disabled' AND disabled_at IS NOT NULL)
OR status = 'requested'
)
);
CREATE INDEX peppol_access_status_idx ON public.peppol_access (status, requested_at);
CREATE TRIGGER set_peppol_access_updated_at
BEFORE UPDATE ON public.peppol_access
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
ALTER TABLE public.peppol_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company peppol access"
ON public.peppol_access FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
-- Supabase's default privileges hand every new table ALL to authenticated;
-- take that back so a member's UPDATE is a hard "permission denied" rather
-- than an RLS-filtered no-op, then grant the one thing members need.
REVOKE ALL ON public.peppol_access FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.peppol_access TO authenticated;
-- Same tightening for the two receiving tables from 20260821170000, which
-- revoked from PUBLIC and anon only (their RLS already blocked writes; this
-- makes the refusal explicit at the privilege level too).
REVOKE ALL ON public.peppol_registrations FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.peppol_registrations TO authenticated;
REVOKE ALL ON public.peppol_inbound_documents FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.peppol_inbound_documents TO authenticated;
NOTIFY pgrst, 'reload schema';