From ab517b5ffe370bb3a40300d11bdb75f99ecb6a75 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 7 May 2026 19:10:13 +0200 Subject: [PATCH 1/2] fix(api): allow scoped keys past cli warnings --- ...cli_warning_read_fatal_for_scoped_keys.sql | 50 +++++++++++ tests/rbac-permissions.test.ts | 82 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql diff --git a/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql b/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql new file mode 100644 index 0000000000..246b824539 --- /dev/null +++ b/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql @@ -0,0 +1,50 @@ +CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) +RETURNS jsonb[] +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + messages jsonb[] := ARRAY[]::jsonb[]; + has_read_access boolean; +BEGIN + PERFORM cli_version; + + SELECT public.check_min_rights( + 'read'::public.user_min_right, + public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]), + orgid, + NULL::varchar, + NULL::bigint + ) + INTO has_read_access; + + IF NOT COALESCE(has_read_access, false) THEN + -- Upload performs app-scoped permission and plan checks after this RPC. + -- App-scoped API keys may legitimately upload without org-level read access, + -- so skip org warnings instead of blocking the upload here. + RETURN messages; + END IF; + + IF ( + public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true + AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true + AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false + ) THEN + messages := array_append(messages, jsonb_build_object( + 'message', + 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', + 'fatal', + true + )); + END IF; + + RETURN messages; +END; +$$; + +ALTER FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "anon"; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "service_role"; diff --git a/tests/rbac-permissions.test.ts b/tests/rbac-permissions.test.ts index fe15ff7afe..61296e5a45 100644 --- a/tests/rbac-permissions.test.ts +++ b/tests/rbac-permissions.test.ts @@ -739,6 +739,88 @@ describe('rbac permission system', () => { expect(deniedResult.rows[0].allowed).toBe(false) expect(allowedResult.rows[0].allowed).toBe(true) }) + + it('returns no CLI warnings for an app-scoped API key without org read', async () => { + const id = randomUUID() + const orgId = randomUUID() + const appUuid = randomUUID() + const appId = `com.cli.warning.${id}` + const scopedKey = `rbac-cli-warning-${id}` + + await query(` + INSERT INTO public.orgs (id, name, management_email, created_by, use_new_rbac) + VALUES ($1::uuid, $2, $3, $4::uuid, true) + `, [orgId, `CLI Warning Org ${id}`, `cli-warning-${id}@capgo.app`, USER_ID]) + + await query(` + INSERT INTO public.apps (id, app_id, icon_url, owner_org, name) + VALUES ($1::uuid, $2, $3, $4::uuid, $5) + `, [appUuid, appId, 'https://example.com/icon.png', orgId, `CLI Warning App ${id}`]) + + await query(` + INSERT INTO public.apikeys (user_id, key, key_hash, mode, name, limited_to_orgs, limited_to_apps) + VALUES ($1::uuid, $2, NULL, NULL, $3, ARRAY[$4::uuid], ARRAY[$5]::varchar[]) + `, [USER_ID, scopedKey, `CLI warning scoped ${id}`, orgId, appId]) + + await query(` + INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + reason, + is_direct + ) + SELECT + public.rbac_principal_apikey(), + ak.rbac_id, + r.id, + public.rbac_scope_app(), + $2::uuid, + $3::uuid, + $4::uuid, + 'cli warning app-scoped key regression', + true + FROM public.apikeys ak + CROSS JOIN public.roles r + WHERE ak.key = $1 + AND r.name = public.rbac_role_app_admin() + LIMIT 1 + `, [scopedKey, orgId, appUuid, USER_ID]) + + await query(`SELECT set_config('request.headers', jsonb_build_object('capgkey', $1::text)::text, true)`, [scopedKey]) + + const orgReadResult = await query(` + SELECT public.check_min_rights( + 'read'::public.user_min_right, + public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]), + $1::uuid, + NULL::varchar, + NULL::bigint + ) AS allowed + `, [orgId]) + + const uploadResult = await query(` + SELECT public.check_min_rights( + 'upload'::public.user_min_right, + public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]), + $1::uuid, + $2, + NULL::bigint + ) AS allowed + `, [orgId, appId]) + + const warningsResult = await query(` + SELECT cardinality(public.get_organization_cli_warnings($1::uuid, '7.95.12')) AS warning_count + `, [orgId]) + + expect(orgReadResult.rows[0].allowed).toBe(false) + expect(uploadResult.rows[0].allowed).toBe(true) + expect(warningsResult.rows[0].warning_count).toBe(0) + }) }) describe('feature flag routing', () => { From 5f7041061647f915b131a61a14fbf38757bf9ce9 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 7 May 2026 19:10:13 +0200 Subject: [PATCH 2/2] fix(api): allow scoped keys past cli warnings --- ...cli_warning_read_fatal_for_scoped_keys.sql | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql b/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql index 246b824539..eccee0fadd 100644 --- a/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql +++ b/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql @@ -1,5 +1,8 @@ -CREATE OR REPLACE FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) -RETURNS jsonb[] +CREATE OR REPLACE FUNCTION public.get_organization_cli_warnings( + orgid uuid, + cli_version text +) +RETURNS jsonb [] LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' @@ -7,9 +10,21 @@ AS $$ DECLARE messages jsonb[] := ARRAY[]::jsonb[]; has_read_access boolean; + api_key_text text; + api_key public.apikeys%ROWTYPE; BEGIN PERFORM cli_version; + SELECT public.get_apikey_header() + INTO api_key_text; + + IF api_key_text IS NOT NULL THEN + SELECT * + INTO api_key + FROM public.find_apikey_by_value(api_key_text) + LIMIT 1; + END IF; + SELECT public.check_min_rights( 'read'::public.user_min_right, public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]), @@ -20,6 +35,15 @@ BEGIN INTO has_read_access; IF NOT COALESCE(has_read_access, false) THEN + IF api_key_text IS NULL OR api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN + messages := pg_catalog.array_append(messages, pg_catalog.jsonb_build_object( + 'message', + 'API key does not have read access to this organization', + 'fatal', + true + )); + END IF; + -- Upload performs app-scoped permission and plan checks after this RPC. -- App-scoped API keys may legitimately upload without org-level read access, -- so skip org warnings instead of blocking the upload here. @@ -31,9 +55,12 @@ BEGIN AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false ) THEN - messages := array_append(messages, jsonb_build_object( + messages := pg_catalog.array_append(messages, pg_catalog.jsonb_build_object( 'message', - 'You have exceeded your storage limit.\nUpload will fail, but you can still download your data.\nMAU and bandwidth limits are not exceeded.\nIn order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', + 'You have exceeded your storage limit. +Upload will fail, but you can still download your data. +MAU and bandwidth limits are not exceeded. +In order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.', 'fatal', true )); @@ -43,8 +70,13 @@ BEGIN END; $$; -ALTER FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) OWNER TO "postgres"; -REVOKE ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) FROM PUBLIC; -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "anon"; -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "authenticated"; -GRANT ALL ON FUNCTION "public"."get_organization_cli_warnings"("orgid" uuid, "cli_version" text) TO "service_role"; +ALTER FUNCTION public.get_organization_cli_warnings(uuid, text) +OWNER TO postgres; +REVOKE ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text) +FROM public; +GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO anon; +GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO authenticated; +GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO service_role;