-
-
Notifications
You must be signed in to change notification settings - Fork 76
fix: lock down identity org RPC exposure #1709
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
Open
riderx
wants to merge
1
commit into
main
Choose a base branch
from
riderx/fix-apikey-oracle
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+208
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
208 changes: 208 additions & 0 deletions
208
supabase/migrations/20260227000000_fix_identity_oracle_and_org_rpc_exposure.sql
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| -- Security hardening: remove API key identity oracle and enforce org RPC caller checks | ||
|
|
||
| -- Identity helpers must remain internal to RLS/auth logic and should not be directly callable | ||
| -- via PostgREST anonymous/authenticated roles. | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode"[]) FROM "anon"; | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode"[]) FROM "authenticated"; | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode"[], "org_id" "uuid") FROM "anon"; | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode"[], "org_id" "uuid") FROM "authenticated"; | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_org_appid" ( | ||
| "keymode" "public"."key_mode"[], | ||
| "org_id" "uuid", | ||
| "app_id" character varying | ||
| ) FROM "anon"; | ||
| REVOKE ALL ON FUNCTION "public"."get_identity_org_appid" ( | ||
| "keymode" "public"."key_mode"[], | ||
| "org_id" "uuid", | ||
| "app_id" character varying | ||
| ) FROM "authenticated"; | ||
|
|
||
| -- Keep these helpers available for internal background/job contexts. | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode"[]) TO "postgres"; | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_apikey_only" ("keymode" "public"."key_mode"[]) TO "service_role"; | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode"[], "org_id" "uuid") TO "postgres"; | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_org_allowed" ("keymode" "public"."key_mode"[], "org_id" "uuid") TO "service_role"; | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_org_appid" ( | ||
| "keymode" "public"."key_mode"[], | ||
| "org_id" "uuid", | ||
| "app_id" character varying | ||
| ) TO "postgres"; | ||
| GRANT EXECUTE ON FUNCTION "public"."get_identity_org_appid" ( | ||
| "keymode" "public"."key_mode"[], | ||
| "org_id" "uuid", | ||
| "app_id" character varying | ||
| ) TO "service_role"; | ||
|
|
||
| -- Remove broad default privileges so future objects do not inherit anonymous/authenticated access. | ||
| ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON FUNCTIONS FROM "anon"; | ||
| ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON FUNCTIONS FROM "authenticated"; | ||
| ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON TABLES FROM "anon"; | ||
| ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" REVOKE ALL ON TABLES FROM "authenticated"; | ||
|
|
||
| -- Harden direct org lookup by user id: callable only when caller identity matches the requested user. | ||
| DROP FUNCTION IF EXISTS public.get_orgs_v6(userid uuid); | ||
|
|
||
| CREATE OR REPLACE FUNCTION public.get_orgs_v6(userid uuid) | ||
| RETURNS TABLE ( | ||
| gid uuid, | ||
| created_by uuid, | ||
| logo text, | ||
| name text, | ||
| role character varying, | ||
| paying boolean, | ||
| trial_left integer, | ||
| can_use_more boolean, | ||
| is_canceled boolean, | ||
| app_count bigint, | ||
| subscription_start timestamptz, | ||
| subscription_end timestamptz, | ||
| management_email text, | ||
| is_yearly boolean, | ||
| stats_updated_at timestamp without time zone, | ||
| next_stats_update_at timestamptz, | ||
| credit_available numeric, | ||
| credit_total numeric, | ||
| credit_next_expiration timestamptz, | ||
| require_apikey_expiration boolean, | ||
| max_apikey_expiration_days integer | ||
| ) LANGUAGE plpgsql STABLE SECURITY DEFINER | ||
| SET search_path = '' AS $$ | ||
| DECLARE | ||
| caller_user_id uuid; | ||
| BEGIN | ||
| SELECT public.get_identity('{read,upload,write,all}'::public.key_mode[]) INTO caller_user_id; | ||
|
|
||
| IF auth.role() IS DISTINCT FROM 'service_role' AND auth.role() IS DISTINCT FROM 'postgres' THEN | ||
| IF caller_user_id IS DISTINCT FROM userid THEN | ||
| PERFORM public.pg_log( | ||
| 'deny: GET_ORGS_V6_UNAUTHORIZED_USER', | ||
| jsonb_build_object('requested_user_id', userid, 'resolved_user_id', caller_user_id, 'role', auth.role()) | ||
| ); | ||
| RAISE EXCEPTION 'Permission denied'; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| RETURN QUERY | ||
| WITH app_counts AS ( | ||
| SELECT owner_org, COUNT(*) as cnt | ||
| FROM public.apps | ||
| GROUP BY owner_org | ||
| ), | ||
| paying_orgs_ordered AS ( | ||
| SELECT | ||
| o.id, | ||
| ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count | ||
| FROM public.orgs o | ||
| JOIN public.stripe_info si ON o.customer_id = si.customer_id | ||
| WHERE ( | ||
| (si.status = 'succeeded' | ||
| AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) | ||
| AND si.subscription_anchor_end > NOW()) | ||
| OR si.trial_at > NOW() | ||
| ) | ||
| ), | ||
| billing_cycles AS ( | ||
| SELECT | ||
| o.id AS org_id, | ||
| CASE | ||
| WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) | ||
| > NOW() - date_trunc('MONTH', NOW()) | ||
| THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') | ||
| + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) | ||
| ELSE date_trunc('MONTH', NOW()) | ||
| + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) | ||
| END AS cycle_start | ||
| FROM public.orgs o | ||
| LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id | ||
| ), | ||
| -- Calculate 2FA access status for user/org combinations | ||
| two_fa_access AS ( | ||
| SELECT | ||
| o.id AS org_id, | ||
| (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa | ||
| FROM public.orgs o | ||
| JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id | ||
| ), | ||
| -- Calculate password policy access status for user/org combinations | ||
| password_policy_access AS ( | ||
| SELECT | ||
| o.id AS org_id, | ||
| NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password | ||
| FROM public.orgs o | ||
| JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id | ||
| ) | ||
| SELECT | ||
| o.id AS gid, | ||
| o.created_by, | ||
| o.logo, | ||
| o.name, | ||
| ou.user_right::varchar AS role, | ||
| -- Redact sensitive fields if user doesn't have 2FA or password policy access | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false | ||
| ELSE (si.status = 'succeeded') | ||
| END AS paying, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 | ||
| ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer | ||
| END AS trial_left, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false | ||
| ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0)) | ||
| END AS can_use_more, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false | ||
| ELSE (si.status = 'canceled') | ||
| END AS is_canceled, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint | ||
| ELSE COALESCE(ac.cnt, 0) | ||
| END AS app_count, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz | ||
| ELSE bc.cycle_start | ||
| END AS subscription_start, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz | ||
| ELSE (bc.cycle_start + INTERVAL '1 MONTH') | ||
| END AS subscription_end, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text | ||
| ELSE o.management_email | ||
| END AS management_email, | ||
| CASE | ||
| WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false | ||
| ELSE COALESCE(si.price_id = p.price_y_id, false) | ||
| END AS is_yearly, | ||
| o.stats_updated_at, | ||
| CASE | ||
| WHEN poo.id IS NOT NULL THEN | ||
| public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) | ||
| ELSE NULL | ||
| END AS next_stats_update_at, | ||
| COALESCE(ucb.available_credits, 0) AS credit_available, | ||
| COALESCE(ucb.total_credits, 0) AS credit_total, | ||
| ucb.next_expiration AS credit_next_expiration, | ||
| o.require_apikey_expiration, | ||
| o.max_apikey_expiration_days | ||
| FROM public.orgs o | ||
| JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id | ||
| JOIN two_fa_access tfa ON tfa.org_id = o.id | ||
| JOIN password_policy_access ppa ON ppa.org_id = o.id | ||
| LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id | ||
| LEFT JOIN public.plans p ON si.product_id = p.stripe_id | ||
| LEFT JOIN app_counts ac ON ac.owner_org = o.id | ||
| LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id | ||
| LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id | ||
| LEFT JOIN billing_cycles bc ON bc.org_id = o.id; | ||
| END; | ||
| $$; | ||
|
|
||
| ALTER FUNCTION public.get_orgs_v6(userid uuid) OWNER TO "postgres"; | ||
|
|
||
| -- Only trusted roles may execute the user-id overload directly. | ||
| REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM PUBLIC; | ||
| REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM "anon"; | ||
| REVOKE ALL ON FUNCTION public.get_orgs_v6(userid uuid) FROM "authenticated"; | ||
| GRANT EXECUTE ON FUNCTION public.get_orgs_v6(userid uuid) TO "postgres"; | ||
| GRANT EXECUTE ON FUNCTION public.get_orgs_v6(userid uuid) TO "service_role"; | ||
Oops, something went wrong.
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.
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.
Revoking
EXECUTEfromanon/authenticatedonget_identity_org_allowed(and similarlyget_identity_org_appid) will break any RLS policy that calls these helpers for those roles, because policy expressions still require function execute privileges; for example,supabase/migrations/20260226000100_fix_org_rls_2fa_function_permissions.sqldefines ananon/authenticatedpolicy that directly callsget_identity_org_allowed, so those requests will start failing withpermission denied for function ...instead of applying RLS.Useful? React with 👍 / 👎.