Files
accounted/tests
Jakob Wennberg 358c25094d fix(security): tenant guard on the GL-line read RPCs (#625)
* fix(security): tenant guard on the GL-line read RPCs (PR #624 follow-up)

get_unlinked_gl_lines and get_account_gl_lines_for_matching are SECURITY DEFINER
and EXECUTE-able by anon/authenticated, so any authenticated (or anonymous)
caller could invoke them directly over /rest/v1/rpc with another company's id and
read its general-ledger lines — a cross-tenant read that bypasses the API routes'
requireCompanyId() guard. Confirmed against the DB: anon and authenticated both
hold EXECUTE, and SECURITY DEFINER sidesteps RLS.

Add an in-function guard constraining anon/authenticated callers to their own
companies (the same boundary user_company_ids()/RLS enforces). Trusted callers
are untouched — service_role (the enable-banking reconciliation cron) and direct
/ superuser access (migrations, the pg-real harness) are not anon/authenticated,
so the predicate is a no-op and behaviour is unchanged. A foreign company id now
yields zero rows, not data.

Scope: hardens the two READ RPCs that expose ledger data. The remaining
company-scoped SECURITY DEFINER RPCs are writes / sequence generators with their
own internal authorization; a broader audit of that set is tracked separately.

pg-real coverage: a company-B member probing company A gets zero rows from both
RPCs, while a company-A member and direct/superuser access still see the data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): revoke EXECUTE from PUBLIC/anon on the GL-line read RPCs

Defense-in-depth follow-up to the tenant guard. The guard already returns zero
rows to an anon/authenticated caller probing another company; this additionally
strips the EXECUTE privilege so an unauthenticated (anon) caller cannot invoke
the financial-ledger RPCs at all. Supabase grants EXECUTE to PUBLIC as well as to
anon, and anon is a member of PUBLIC — revoking only anon is insufficient, so
revoke both, then keep the two legitimate callers: authenticated (the API routes
call via the user's session; the in-function guard scopes them to their own
companies) and service_role (the enable-banking reconciliation cron).

Verified on the DB: anon EXECUTE = false, authenticated/service_role = true.
Adds an anon-role pg test asserting the call is rejected at the privilege layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): read JWT role from claims object in the GL-line RPC guard

The 20260611120000 guard used auth.role() to detect the caller's role, but
auth.role() reads the individual request.jwt.claim.role GUC first and only some
installs fall back to the claims object. PostgREST sets the claims OBJECT (the
individual claim.* GUCs are deprecated), and the pg-real harness sets
request.jwt.claims (+ claim.sub for auth.uid()) but NOT claim.role — so on an
auth.role() without the object fallback it returns NULL and the guard's
NOT IN ('anon','authenticated') branch was TRUE, skipping the membership check.
A pg-real test caught it: an authenticated non-member could still read another
company's GL lines (the guard failed open in that environment).

Read the role straight from request.jwt.claims (exactly what auth.role() itself
falls back to), so the guard enforces in every environment regardless of which
JWT-claim GUCs are populated. Verified on the DB: an authenticated non-member
evaluates both guard branches false → row excluded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:24:18 +02:00
..