Appearance
ADR-0014 · Public data exposure & least-privilege grants
Status: 🟢 Accepted — adopted 2026-07-25 in response to a live production exposure · Depends on: — · Related: ADR-0006 (authorization model), ADR-0001 (identity/tenancy), Edge Function kit standards
The one-line thesis
Row-Level Security filters rows, never columns. Anonymous roles therefore get explicit column-level GRANT SELECT on a reviewed allow-list — never blanket table privileges — and every policy names its audience with a TO clause, because an unqualified policy silently applies to PUBLIC.
Context — the incident that forced this
While reviewing the authentication architecture on 2026-07-25, an audit of the baseline schema found that public.profiles was readable by the anon role:
sql
GRANT ALL ON TABLE public.profiles TO anon;
CREATE POLICY "Public can view profiles by slug" ON public.profiles
FOR SELECT USING (is_deleted = false AND slug IS NOT NULL); -- ← no TO clauseTwo independent mistakes compounded:
GRANT ALL … TO anongave the anonymous role every privilege on the table, gated only by RLS.- The policy carried no
TOclause. Postgres defaults such a policy toPUBLIC— every role, includinganon. Nothing in the statement says "anonymous", which is why it survived review.
Because RLS restricts rows but not columns, any holder of the publishable anon key — which ships inside every client build and is not a secret — could run:
sql
select email, mobile_number, gstin, city, pin_code from profiles where slug is not null;and exfiltrate every merchant's contact details and tax identifier from qr-setu-prod, which holds real user data. Under India's DPDP Act that is a personal-data breach, not merely a hardening gap.
Blast radius (measured, not assumed)
Of 45 SELECT policies lacking a TO clause, 29 are saved by an auth.uid() predicate that is null for anon. 16 tables are genuinely anonymous-readable; 15 of those are intentionally public — published menus, bio/setu pages, templates, business domains, subscription tiers — and were verified as deliberate. profiles was the only leak. But GRANT ALL … TO anon exists on 56 tables, so the next policy written without a TO clause becomes public instantly. The systemic problem is the standing over-grant, not the single policy.
Why the intended design was already correct — and bypassed
CLAUDE.md already bans supabase.from() in app code and requires public reads to go through SECURITY DEFINER RPCs precisely so that projections are explicit. The leak exists because the table was reachable directly with the anon key, which makes the application-layer rule unenforceable: an attacker never runs our code.
Decision
- Anonymous roles receive column-level grants only.
REVOKE ALL ON <table> FROM anon, thenGRANT SELECT (col, col, …) ON <table> TO anonnaming every column. The column list is the security boundary and extending it requires an ADR amendment. NeverGRANT SELECTon a whole table toanon. - Every policy names its audience.
TO anon/TO authenticated/TO service_roleis mandatory. Relying on thePUBLICdefault is prohibited. authenticatedgets verbs, notALL. Grant only the verbs actually used (typicallySELECT, INSERT, UPDATE); rows remain gated by RLS. Do not rely on "no DELETE policy exists" — refuse it at the privilege layer too.- Anonymous writes are exceptional and must be named. Today exactly one exists:
digital_menu_qr_scan_analytics("Public can insert scan analytics") for QR scan counting. Any addition needs an ADR amendment. This is why a blanket write-revoke across all tables is not the remediation. - The end state for public reads is a
SECURITY DEFINERRPC with an explicit projection (get_public_profile_by_slug), after which direct table access foranonis removed entirely. Column grants are the immediate, zero-breakage stopgap — not the destination. - Two enforcement layers, because review demonstrably failed:
- Static:
tools/check-sql-grants.js(npm run check:sql, wired intobackend-ci) fails any migration withGRANT ALL … TO anonor a policy lackingTO. The pre-remediation baseline dump is exempt by design — it is the subject of this ADR, and failing on it would force a permanent ignore that teaches everyone to distrust the gate. - Runtime: pgTAP asserts privileges, not rows —
supabase/tests/database/profiles_anon_exposure_test.sqluseshas_column_privilege()to proveanoncannot reachemail/mobile_number/gstin/role, can still reach the public card fields, and that no policy onprofilesapplies toPUBLIC. A row-level test would pass while the table was wide open, which is exactly the trap here.
- Static:
Options considered
A — Column-level grants now, RPC migration next · [adopted]
- For: closes a live exposure in a single migration with zero frontend breakage (verified: the only anonymous reader in the tree,
useStatusBanner.js, selectsid, business_hours— both on the allow-list); the privilege system rejects a bad query before RLS is consulted; leaves the cleaner RPC end-state intact. - Against: column grants are easy to overlook in review — mitigated by the pgTAP privilege test.
B — Drop the public policy immediately, move all public reads to an RPC
- For: the correct end state in one step.
- Against: breaks any live anonymous consumer the moment it ships, and consumers must change first. Right destination, wrong first move for a live exposure.
C — Tighten the row predicate (e.g. add is_published = true)
- Against: does nothing about columns — the actual defect — and silently changes which cards render publicly, which is a product decision. Rejected as a security measure.
D — Rely on the from() ban plus RPC discipline
- Against: application-layer rules cannot bind an attacker holding the anon key. Rejected.
Consequences
- Migration
20260725173747_restrict_anon_profile_columns.sqlrevokes the blanket grants, grants the reviewed public column allow-list toanon, narrowsauthenticatedtoSELECT, INSERT, UPDATE, and recreates the policy with an explicitTO anon, authenticated. Must be promoted to both projects (Dev → Prod) per the promotion runbook — Prod is where the exposed data lives, so this is not a Dev-first-then-whenever change. - The public column allow-list is now a reviewed artefact. Adding
email,mobile_number,gstin,pin_code,role,permissions,attributes,subscription_tieror thereminder_*columns to it re-opens the breach. - ✅ Done 2026-07-26 —
20260726093952_least_privilege_anon_all_tables.sql+20260726095831_get_public_profile_by_slug_rpc.sql. The remaining 57 tables'GRANT ALL … TO anon(the true count, measured — not the 55 estimated when this ADR was first written) are narrowed: theALTER DEFAULT PRIVILEGESroot cause is closed first, thenanonis re-granted SELECT-only on the 15 tables that back public pages, plus INSERT-only (never SELECT) ondigital_menu_qr_scan_analyticsfor QR scan counting. Two holes that were actively exploitable (not merely defence-in-depth gaps, likeprofilesbefore it) are closed:digital_menu_item_favorites(FOR ALL USING (true)+ a blanket grant — any anonymous caller could read, write and delete every row) andvw_public_page_ops_cron_job_status(an ungoverned view exposing internal cron/ops state).get_public_profile_by_slugships alongside as the intendedSECURITY DEFINERend state, though the column grant onprofilesis not yet retired — that is gated on migratinglegacy/.../useStatusBanner.jsand any other directanonreader onto the RPC first. Full detail: dev-tracker. - Disclosure assessment is required, not optional. Whether the exposure was exercised is a log question, and the DPDP notification obligation follows from the answer. Recorded in the dev-tracker with a timeline.
- The pgTAP privilege-test pattern (
has_column_privilege) becomes the template for every future public-facing table.
Related
- ADR-0006 — the authorization model this defends in depth.
supabase/docs/PROMOTION_RUNBOOK.md— Dev → Prod promotion + parity verification SQL.- CLAUDE.md — "Migrations & DB rules" (RLS on every table) and the
supabase.from()ban.