anon-harden: least-privilege anon/authenticated grants + scoped storefront policies - #10
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finding (verified against the live DB via PostgREST with the anon/public key)
anonrole 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.tenants_public_select(anon, qual=true) andlocations_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.Fix —
supabase/migrations/20260605000200_least_privilege_grants.sqlREVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public FROM anon, thenGRANT USAGE ON SCHEMA+GRANT SELECTto 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 totenants, orders, payments, customers, memberships, staff, subscriptions, audit_log, etc. (denied at the grant layer, independent of RLS).REVOKE ALL ... FROM authenticated, thenGRANT 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/admintenants_selectfrom the tenancy core is untouched.locations_public_selectwithfor 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 readstenantsdirectly (anon has no grant there).store_settingsusing(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_rolekey (readSupabaseConfig()prefers it;src/lib/db/supabase.tsfilters every query bytenant_id/location_id). The/shopRSC +/api/shop/*routes resolve the location/menu/settings server-side throughgetPosDriver()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 readtenants,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 Postgresrls-isolationCI job (theauth_shimcreates the anon/authenticated/service_role roles).Verification
typecheck,lint,build,test:run(106 tests) — all green.package.json/lockfile untouched.Files changed
supabase/migrations/20260605000200_least_privilege_grants.sql(new)supabase/tests/rls_isolation.sqlsupabase/README.md,docs/PRODUCTION_READINESS.mdplans/anon-harden-least-privilege.md(new)🤖 Generated with Claude Code