Skip to content

anon-harden: least-privilege anon/authenticated grants + scoped storefront policies - #10

Merged
snackman merged 2 commits into
mainfrom
anon-harden
Jun 5, 2026
Merged

anon-harden: least-privilege anon/authenticated grants + scoped storefront policies#10
snackman merged 2 commits into
mainfrom
anon-harden

Conversation

@snackman

@snackman snackman commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Finding (verified against the live DB via PostgREST with the anon/public key)

  1. Over-broad anon GRANTs — the anon role held effectively ALL privileges (SELECT/INSERT/UPDATE/DELETE/TRUNCATE/TRIGGER/REFERENCES) on essentially every public table (orders, payments, customers, memberships, audit_log, staff, subscriptions, …). RLS still gated rows, but a blanket grant to the public role is a serious footgun.
  2. Registry enumerationtenants_public_select (anon, qual=true) and locations_public_select (anon, qual=true) let the public read the ENTIRE tenant + location registry of ALL tenants. The storefront only needs to resolve ONE location/menu by slug.
  3. authenticated over-broad — also held TRUNCATE/TRIGGER/REFERENCES; trimmed to least-privilege DML.

Fix — supabase/migrations/20260605000200_least_privilege_grants.sql

  • REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public FROM anon, then GRANT USAGE ON SCHEMA + GRANT SELECT to anon on the storefront-public surface only: menu_categories, menu_items, item_sizes, modifier_groups, modifiers, item_modifier_groups, location_menu_overrides, store_settings, locations. anon loses all access to tenants, orders, payments, customers, memberships, staff, subscriptions, audit_log, etc. (denied at the grant layer, independent of RLS).
  • REVOKE ALL ... FROM authenticated, then GRANT SELECT, INSERT, UPDATE, DELETE (no TRUNCATE/TRIGGER/REFERENCES) on the tenancy + domain operational tables. Rows stay RLS-gated.
  • DROP POLICY tenants_public_select — no more anon tenant-registry read. The member/admin tenants_select from the tenancy core is untouched.
  • Replace locations_public_select with for select to anon using (public.is_active_tenant(locations.tenant_id)) — a new SECURITY DEFINER helper, so anon resolves a location by slug only for an active tenant and never reads tenants directly (anon has no grant there).
  • Menu / store_settings using(true) SELECT policies are intentionally kept.

Decision on anon menu read: KEPT (narrow), app path is server-side

The app's real data path is server-side via the service_role key (readSupabaseConfig() prefers it; src/lib/db/supabase.ts filters every query by tenant_id/location_id). The /shop RSC + /api/shop/* routes resolve the location/menu/settings server-side through getPosDriver() and never use the anon role. So tightening anon cannot break the app. We keep a narrow anon SELECT on the storefront-public surface so the menu could be read client-side with the anon key later without re-granting — but it is not on the current app path. The service-role path is confirmed unaffected.

Tests — supabase/tests/rls_isolation.sql (extended)

As anon: CAN read both menus / categories / store settings / a location-by-slug; CANNOT read tenants, orders, payments, customers, staff, memberships (permission-denied or 0 rows). Kept anon-cannot-write-menu and all authenticated cross-tenant assertions. Added customers/staff/store_settings fixtures. Runs in the optional non-blocking Postgres rls-isolation CI job (the auth_shim creates the anon/authenticated/service_role roles).

Verification

  • Local, zero env vars: typecheck, lint, build, test:run (106 tests) — all green.
  • No app/UI/behavior changes; package.json/lockfile untouched.

Files changed

  • supabase/migrations/20260605000200_least_privilege_grants.sql (new)
  • supabase/tests/rls_isolation.sql
  • supabase/README.md, docs/PRODUCTION_READINESS.md
  • plans/anon-harden-least-privilege.md (new)

🤖 Generated with Claude Code

…front policies

Corrective security migration for a live least-privilege finding (PostgREST,
anon key): anon/authenticated held effectively ALL privileges on every public
table, and tenants_public_select/locations_public_select let the public
enumerate the entire tenant+location registry. RLS gated rows, but the GRANT
layer was wide open.

- New migration 20260605000200_least_privilege_grants.sql:
  * REVOKE ALL from anon on all tables/sequences/functions; GRANT SELECT only on
    the storefront-public surface (menu tables + location_menu_overrides +
    store_settings + locations).
  * REVOKE ALL from authenticated; GRANT SELECT,INSERT,UPDATE,DELETE only (no
    TRUNCATE/TRIGGER/REFERENCES) on the tenancy + domain operational tables.
  * DROP tenants_public_select (no more anon tenant-registry read).
  * Replace locations_public_select with an active-tenant-scoped policy via a new
    SECURITY DEFINER helper is_active_tenant() (anon never reads tenants directly).
- Extend supabase/tests/rls_isolation.sql: anon CAN read menus/categories/store
  settings/a location-by-slug; anon CANNOT read tenants/orders/payments/
  customers/staff/memberships. Added customers/staff/store_settings fixtures.
- Docs: supabase/README.md + docs/PRODUCTION_READINESS.md document the model.

App path unaffected: storefront reads server-side via service_role; anon role is
never used by the app.

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

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pos Ready Ready Preview, Comment Jun 5, 2026 5:42pm

Request Review

A SELECT as anon still evaluates the *_write FOR ALL policies' USING (which
call is_platform_admin() -> auth.uid()); the auth shim casts the claims GUC to
json before extracting 'sub', and ''::json is invalid. Resetting to '{}' yields
auth.uid() = NULL cleanly while the menu/location using(true)/is_active_tenant
policies still permit the public read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@snackman
snackman marked this pull request as ready for review June 5, 2026 17:45
@snackman
snackman merged commit 9b7ebe1 into main Jun 5, 2026
5 checks passed
@snackman
snackman deleted the anon-harden branch June 5, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant