-
Notifications
You must be signed in to change notification settings - Fork 136
refactor(billing): security_invoker views + RLS on base tables #709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| -- Convert grida_billing's API surface to the canonical Supabase pattern: | ||
| -- security_invoker = true views over RLS-protected base tables, instead | ||
| -- of SECURITY DEFINER views over a fully-locked schema. Functionally | ||
| -- equivalent for the API contract; satisfies splinter lint 0010 | ||
| -- (security_definer_view) and aligns with the rest of the codebase. | ||
| -- | ||
| -- Key shifts: | ||
| -- 1. authenticated gets USAGE on grida_billing (needed because | ||
| -- security_invoker views resolve `grida_billing.*` as the caller). | ||
| -- 2. account / subscription / audit lose their "deny everything" | ||
| -- RESTRICTIVE policies. They get SELECT-only PERMISSIVE policies | ||
| -- that mirror what the views' WHERE clauses used to enforce. Writes | ||
| -- stay service-role only (no INSERT/UPDATE/DELETE grant). | ||
| -- 3. product_catalogue / stripe_event remain fully locked — no API | ||
| -- consumer reads them. | ||
| -- 4. Both public.v_billing_* views flip to security_invoker = true and | ||
| -- drop their auth-side WHERE clauses (RLS now does the row scoping). | ||
| -- Their grants are scrubbed of Supabase's default-ALL on `public` | ||
| -- so anon doesn't see them in the GraphQL schema. | ||
| -- | ||
| -- All operations are idempotent. | ||
|
|
||
| BEGIN; | ||
|
|
||
| -- ─── 1. Schema USAGE ────────────────────────────────────────────── | ||
| REVOKE ALL ON SCHEMA grida_billing FROM PUBLIC; | ||
| GRANT USAGE ON SCHEMA grida_billing TO authenticated, service_role; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Granting Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| -- ─── 1a. Lock internal SECURITY DEFINER routines ────────────────── | ||
| -- Schema USAGE alone makes every existing function in grida_billing | ||
| -- callable by authenticated, because EXECUTE on functions is granted | ||
| -- to PUBLIC by default in Postgres and authenticated inherits PUBLIC. | ||
| -- The DEFAULT PRIVILEGES line in the original migration only revoked | ||
| -- from authenticated/anon explicitly — not from PUBLIC — so existing | ||
| -- routines still let signed-in users call e.g. fn_apply_stripe_event | ||
| -- directly and bypass webhook-signature verification. | ||
| -- | ||
| -- Strip every existing function in grida_billing to service_role-only, | ||
| -- then patch the DEFAULT PRIVILEGES so future functions are locked | ||
| -- down at create time too. | ||
| REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA grida_billing FROM PUBLIC, anon, authenticated; | ||
| GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA grida_billing TO service_role; | ||
| ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE EXECUTE ON ROUTINES FROM PUBLIC; | ||
|
|
||
|
|
||
| -- ─── 2a. account ───────────────────────────────────────────────── | ||
| DROP POLICY IF EXISTS default_deny_authenticated ON grida_billing.account; | ||
| DROP POLICY IF EXISTS default_deny_anon ON grida_billing.account; | ||
| DROP POLICY IF EXISTS member_can_select ON grida_billing.account; | ||
|
|
||
| GRANT SELECT ON TABLE grida_billing.account TO authenticated; | ||
| CREATE POLICY member_can_select ON grida_billing.account | ||
| FOR SELECT TO authenticated | ||
| USING ( | ||
| organization_id IN ( | ||
| SELECT om.organization_id FROM public.organization_member om | ||
| WHERE om.user_id = (SELECT auth.uid()) | ||
| ) | ||
| ); | ||
|
|
||
|
|
||
| -- ─── 2b. subscription ──────────────────────────────────────────── | ||
| DROP POLICY IF EXISTS default_deny_authenticated ON grida_billing.subscription; | ||
| DROP POLICY IF EXISTS default_deny_anon ON grida_billing.subscription; | ||
| DROP POLICY IF EXISTS member_can_select ON grida_billing.subscription; | ||
|
|
||
| GRANT SELECT ON TABLE grida_billing.subscription TO authenticated; | ||
| CREATE POLICY member_can_select ON grida_billing.subscription | ||
| FOR SELECT TO authenticated | ||
| USING ( | ||
| organization_id IN ( | ||
| SELECT om.organization_id FROM public.organization_member om | ||
| WHERE om.user_id = (SELECT auth.uid()) | ||
| ) | ||
| ); | ||
|
|
||
|
|
||
| -- ─── 2c. audit (owner-only) ────────────────────────────────────── | ||
| DROP POLICY IF EXISTS default_deny_authenticated ON grida_billing.audit; | ||
| DROP POLICY IF EXISTS default_deny_anon ON grida_billing.audit; | ||
| DROP POLICY IF EXISTS owner_can_select ON grida_billing.audit; | ||
|
|
||
| GRANT SELECT ON TABLE grida_billing.audit TO authenticated; | ||
| CREATE POLICY owner_can_select ON grida_billing.audit | ||
| FOR SELECT TO authenticated | ||
| USING ( | ||
| organization_id IN ( | ||
| SELECT o.id FROM public.organization o | ||
| WHERE o.owner_id = (SELECT auth.uid()) | ||
| ) | ||
| ); | ||
|
|
||
| -- The owner_can_select subquery looks up `public.organization` by | ||
| -- `owner_id`, which has no index by default. Without this, every RLS | ||
| -- evaluation on the audit table would seq-scan organization. Cheap fix. | ||
| -- (organization_member.user_id is already indexed.) | ||
| CREATE INDEX IF NOT EXISTS organization_owner_id_idx | ||
| ON public.organization (owner_id); | ||
|
|
||
|
|
||
| -- ─── 3. product_catalogue and stripe_event stay locked ────────── | ||
| -- (no changes — no API consumer; service_role-only access). | ||
|
|
||
|
|
||
| -- ─── 4a. v_billing_subscription ───────────────────────────────── | ||
| CREATE OR REPLACE VIEW public.v_billing_subscription | ||
| WITH (security_invoker = true) | ||
| AS | ||
| SELECT | ||
| s.organization_id, | ||
| s.plan, | ||
| s.is_free, | ||
| s.status, | ||
| s.quantity, | ||
| s.cancel_at_period_end, | ||
| s.current_period_start, | ||
| s.current_period_end, | ||
| s.stripe_subscription_id, | ||
| acc.stripe_customer_id | ||
| FROM grida_billing.subscription s | ||
| LEFT JOIN grida_billing.account acc ON acc.organization_id = s.organization_id | ||
| WHERE s.status <> 'canceled'; | ||
| -- Row scoping is enforced by `member_can_select` on the underlying tables. | ||
|
|
||
| REVOKE ALL ON public.v_billing_subscription FROM PUBLIC, anon, authenticated; | ||
| GRANT SELECT ON public.v_billing_subscription TO authenticated, service_role; | ||
|
|
||
|
|
||
| -- ─── 4b. v_billing_audit ──────────────────────────────────────── | ||
| CREATE OR REPLACE VIEW public.v_billing_audit | ||
| WITH (security_invoker = true) | ||
| AS | ||
| SELECT | ||
| a.id, | ||
| a.organization_id, | ||
| a.user_id, | ||
| a.operation, | ||
| a.stripe_event_id, | ||
| a.stripe_subscription_id, | ||
| a.stripe_invoice_id, | ||
| a.stripe_customer_id, | ||
| a.member_user_id, | ||
| a.prev_quantity, | ||
| a.new_quantity, | ||
| a.plan, | ||
| a.status, | ||
| a.event_type, | ||
| a.billing_reason, | ||
| a.attempt_count, | ||
| a.amount_cents, | ||
| a.note, | ||
| a.created_at | ||
| FROM grida_billing.audit a; | ||
| -- Owner-only filter is enforced by `owner_can_select` on grida_billing.audit. | ||
|
|
||
| REVOKE ALL ON public.v_billing_audit FROM PUBLIC, anon, authenticated; | ||
| GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; | ||
|
|
||
| COMMIT; | ||
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,13 +20,25 @@ | ||||||||||
| CREATE SCHEMA IF NOT EXISTS grida_billing; | |||||||||||
| ALTER SCHEMA grida_billing OWNER TO postgres; | |||||||||||
|
|
|||||||||||
| -- Schema lockdown. No USAGE for anon/authenticated. | |||||||||||
| -- Schema USAGE: authenticated needs it because security_invoker=true views | |||||||||||
| -- in `public` resolve `grida_billing.*` as the calling role. Granting USAGE | |||||||||||
| -- alone exposes nothing — table-level GRANTs (further down) decide what | |||||||||||
| -- authenticated can actually read. anon has no USAGE here at all; the | |||||||||||
| -- public views aren't granted to anon either, so anon never reaches in. | |||||||||||
| REVOKE ALL ON SCHEMA grida_billing FROM PUBLIC; | |||||||||||
| GRANT USAGE ON SCHEMA grida_billing TO authenticated, service_role; | |||||||||||
|
|
|||||||||||
| ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON TABLES TO service_role; | |||||||||||
| ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON ROUTINES TO service_role; | |||||||||||
| ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA grida_billing GRANT ALL ON SEQUENCES TO service_role; | |||||||||||
| ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON TABLES FROM authenticated, anon; | |||||||||||
| ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON ROUTINES FROM authenticated, anon; | |||||||||||
| ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE ALL ON SEQUENCES FROM authenticated, anon; | |||||||||||
| -- Default-privileges only revoke from authenticated/anon explicitly; PUBLIC | |||||||||||
| -- still inherits EXECUTE on every routine and authenticated inherits PUBLIC. | |||||||||||
| -- Strip PUBLIC too so the schema USAGE we grant above doesn't make signed-in | |||||||||||
| -- users able to call internal SECURITY DEFINER functions like the projector. | |||||||||||
| ALTER DEFAULT PRIVILEGES IN SCHEMA grida_billing REVOKE EXECUTE ON ROUTINES FROM PUBLIC; | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
@@ -41,11 +53,23 @@ | ||||||||||
| updated_at timestamptz NOT NULL DEFAULT now() | |||||||||||
| ); | |||||||||||
|
|
|||||||||||
| -- Read access pattern: authenticated org members may SELECT their account | |||||||||||
| -- row through the SELECT grant + permissive policy below. Writes stay | |||||||||||
| -- service-role only (no INSERT/UPDATE/DELETE grant; no permissive policy | |||||||||||
| -- for those ops → RLS denies). anon has no grant at all and cannot reach | |||||||||||
| -- the table. | |||||||||||
| ALTER TABLE grida_billing.account ENABLE ROW LEVEL SECURITY; | |||||||||||
| CREATE POLICY default_deny_authenticated ON grida_billing.account AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); | |||||||||||
| CREATE POLICY default_deny_anon ON grida_billing.account AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); | |||||||||||
| REVOKE ALL ON TABLE grida_billing.account FROM anon, authenticated; | |||||||||||
| GRANT ALL ON TABLE grida_billing.account TO service_role; | |||||||||||
| GRANT ALL ON TABLE grida_billing.account TO service_role; | |||||||||||
| GRANT SELECT ON TABLE grida_billing.account TO authenticated; | |||||||||||
| CREATE POLICY member_can_select ON grida_billing.account | |||||||||||
| FOR SELECT TO authenticated | |||||||||||
| USING ( | |||||||||||
| organization_id IN ( | |||||||||||
| SELECT om.organization_id FROM public.organization_member om | |||||||||||
| WHERE om.user_id = (SELECT auth.uid()) | |||||||||||
| ) | |||||||||||
| ); | |||||||||||
|
Comment on lines
+65
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify indexes exist on public.organization_member(user_id) and
# public.organization(owner_id) — the columns referenced in the new RLS policies.
rg -n --type sql -i 'create.*index.*organization_member.*user_id\|create.*index.*user_id.*organization_member' \
|| echo "No index on organization_member.user_id found"
rg -n --type sql -i 'create.*index.*organization.*owner_id\|create.*index.*owner_id.*organization[^_]' \
|| echo "No index on organization.owner_id found"Repository: gridaco/grida Length of output: 143 Add indexes on RLS join-key columns to avoid sequential scans during policy evaluation. All three new
Each RLS-gated row scan will trigger a sequential table scan, once per candidate row. Under load this becomes a hot path. Add these indexes to the SQL FixCREATE INDEX IF NOT EXISTS organization_member_user_id_idx
ON public.organization_member (user_id);
CREATE INDEX IF NOT EXISTS organization_owner_id_idx
ON public.organization (owner_id);Applies to lines: 60-67, 119-126, 261-268 🤖 Prompt for AI Agents |
|||||||||||
|
|
|||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
@@ -91,11 +115,20 @@ | ||||||||||
| CREATE INDEX subscription_organization_id_idx | |||||||||||
| ON grida_billing.subscription (organization_id); | |||||||||||
|
|
|||||||||||
| -- Same pattern as account: members SELECT their org's row; writes are | |||||||||||
| -- webhook-projector / service-role only. | |||||||||||
| ALTER TABLE grida_billing.subscription ENABLE ROW LEVEL SECURITY; | |||||||||||
| CREATE POLICY default_deny_authenticated ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); | |||||||||||
| CREATE POLICY default_deny_anon ON grida_billing.subscription AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); | |||||||||||
| REVOKE ALL ON TABLE grida_billing.subscription FROM anon, authenticated; | |||||||||||
| GRANT ALL ON TABLE grida_billing.subscription TO service_role; | |||||||||||
| GRANT ALL ON TABLE grida_billing.subscription TO service_role; | |||||||||||
| GRANT SELECT ON TABLE grida_billing.subscription TO authenticated; | |||||||||||
| CREATE POLICY member_can_select ON grida_billing.subscription | |||||||||||
| FOR SELECT TO authenticated | |||||||||||
| USING ( | |||||||||||
| organization_id IN ( | |||||||||||
| SELECT om.organization_id FROM public.organization_member om | |||||||||||
| WHERE om.user_id = (SELECT auth.uid()) | |||||||||||
| ) | |||||||||||
| ); | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
@@ -223,11 +256,27 @@ | ||||||||||
| ON grida_billing.audit (stripe_invoice_id) | |||||||||||
| WHERE stripe_invoice_id IS NOT NULL; | |||||||||||
|
|
|||||||||||
| -- Owner-only read: only the org owner sees audit rows (TC-BILLING-OPS-009/010). | |||||||||||
| -- Members other than the owner are excluded by the policy USING clause. | |||||||||||
| -- Writes stay webhook-projector only. | |||||||||||
| ALTER TABLE grida_billing.audit ENABLE ROW LEVEL SECURITY; | |||||||||||
| CREATE POLICY default_deny_authenticated ON grida_billing.audit AS RESTRICTIVE FOR ALL TO authenticated USING (false) WITH CHECK (false); | |||||||||||
| CREATE POLICY default_deny_anon ON grida_billing.audit AS RESTRICTIVE FOR ALL TO anon USING (false) WITH CHECK (false); | |||||||||||
| REVOKE ALL ON TABLE grida_billing.audit FROM anon, authenticated; | |||||||||||
| GRANT ALL ON TABLE grida_billing.audit TO service_role; | |||||||||||
| GRANT ALL ON TABLE grida_billing.audit TO service_role; | |||||||||||
| GRANT SELECT ON TABLE grida_billing.audit TO authenticated; | |||||||||||
| CREATE POLICY owner_can_select ON grida_billing.audit | |||||||||||
| FOR SELECT TO authenticated | |||||||||||
| USING ( | |||||||||||
| organization_id IN ( | |||||||||||
| SELECT o.id FROM public.organization o | |||||||||||
| WHERE o.owner_id = (SELECT auth.uid()) | |||||||||||
| ) | |||||||||||
| ); | |||||||||||
|
|
|||||||||||
| -- The owner_can_select subquery looks up `public.organization` by owner_id, | |||||||||||
| -- which has no other index. Without this, every RLS evaluation on the | |||||||||||
| -- audit table seq-scans organization. Cheap fix. | |||||||||||
| CREATE INDEX IF NOT EXISTS organization_owner_id_idx | |||||||||||
| ON public.organization (owner_id); | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| -- ============================================================================ | |||||||||||
|
|
@@ -545,7 +594,7 @@ | ||||||||||
|
|
|||||||||||
| -- Map Stripe price → plan via the catalogue. Fail closed: an unknown or | |||||||||||
| -- newly-provisioned price must not silently project as 'pro' (would | |||||||||||
| -- mis-entitle the org). RAISE so Stripe retries while ops fixes the | |||||||||||
| -- catalogue mapping. | |||||||||||
| v_plan := NULL; | |||||||||||
| IF v_price_id IS NOT NULL THEN | |||||||||||
|
|
@@ -787,16 +836,31 @@ | ||||||||||
| $$; | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| -- All grida_billing.* routines stay service-role only. The schema USAGE | |||||||||||
| -- granted to authenticated above makes them reachable; explicit revoke | |||||||||||
| -- here closes the privilege-escalation path PUBLIC EXECUTE would create | |||||||||||
| -- (e.g. signed-in users calling fn_apply_stripe_event directly and | |||||||||||
| -- bypassing webhook signature verification). | |||||||||||
| REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA grida_billing FROM PUBLIC, anon, authenticated; | |||||||||||
| GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA grida_billing TO service_role; | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| -- ============================================================================ | |||||||||||
| -- public.* wrapper surface (PostgREST) | |||||||||||
| -- ============================================================================ | |||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
| -- [public.v_billing_subscription] | |||||||||||
| -- | |||||||||||
| -- security_invoker = true: the view runs as the caller, so RLS on the | |||||||||||
| -- base tables (grida_billing.subscription / .account) does the row | |||||||||||
| -- filtering. The view body keeps only the business filter | |||||||||||
| -- (status <> 'canceled') — auth-side scoping is done by the table-level | |||||||||||
| -- `member_can_select` policies. This satisfies splinter lint 0010. | |||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
|||||||||||
| CREATE OR REPLACE VIEW public.v_billing_subscription | |||||||||||
| WITH (security_invoker = false) | |||||||||||
| WITH (security_invoker = true) | |||||||||||
| AS | |||||||||||
| SELECT | |||||||||||
| s.organization_id, | |||||||||||
|
|
@@ -811,14 +875,13 @@ | ||||||||||
| acc.stripe_customer_id | |||||||||||
| FROM grida_billing.subscription s | |||||||||||
| LEFT JOIN grida_billing.account acc ON acc.organization_id = s.organization_id | |||||||||||
| WHERE s.status <> 'canceled' | |||||||||||
| AND s.organization_id IN ( | |||||||||||
| SELECT om.organization_id | |||||||||||
| FROM public.organization_member om | |||||||||||
| WHERE om.user_id = (SELECT auth.uid()) | |||||||||||
| ); | |||||||||||
| WHERE s.status <> 'canceled'; | |||||||||||
|
|
|||||||||||
| GRANT SELECT ON public.v_billing_subscription TO authenticated, service_role; | |||||||||||
| -- REVOKE before GRANT: Supabase's default privileges on `public` grant ALL | |||||||||||
| -- to anon/authenticated/service_role on new objects. Strip first, then add | |||||||||||
| -- back only the roles that should actually see the view. | |||||||||||
| REVOKE ALL ON public.v_billing_subscription FROM PUBLIC, anon, authenticated; | |||||||||||
| GRANT SELECT ON public.v_billing_subscription TO authenticated, service_role; | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
@@ -828,7 +891,7 @@ | ||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
|||||||||||
| CREATE OR REPLACE VIEW public.v_billing_audit | |||||||||||
| WITH (security_invoker = false) | |||||||||||
| WITH (security_invoker = true) | |||||||||||
| AS | |||||||||||
| SELECT | |||||||||||
| a.id, | |||||||||||
|
|
@@ -850,13 +913,12 @@ | ||||||||||
| a.amount_cents, | |||||||||||
| a.note, | |||||||||||
| a.created_at | |||||||||||
| FROM grida_billing.audit a | |||||||||||
| WHERE a.organization_id IN ( | |||||||||||
| SELECT o.id FROM public.organization o | |||||||||||
| WHERE o.owner_id = (SELECT auth.uid()) | |||||||||||
| ); | |||||||||||
| FROM grida_billing.audit a; | |||||||||||
| -- Owner-only filter is enforced by the `owner_can_select` RLS policy on | |||||||||||
| -- grida_billing.audit (security_invoker = true means RLS runs as caller). | |||||||||||
|
|
|||||||||||
| GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; | |||||||||||
| REVOKE ALL ON public.v_billing_audit FROM PUBLIC, anon, authenticated; | |||||||||||
| GRANT SELECT ON public.v_billing_audit TO authenticated, service_role; | |||||||||||
|
|
|||||||||||
|
|
|||||||||||
| --------------------------------------------------------------------- | |||||||||||
|
|
|||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
Repository: gridaco/grida
Length of output: 585
Quote the
urlargument in thepsqlcall.Line 43 currently passes
{{url}}unquoted to psql. Change to"{{url}}"to safely handle DSNs containing shell-significant characters (spaces, wildcards, dollar signs, etc.).🤖 Prompt for AI Agents