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..eccee0fadd --- /dev/null +++ b/supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql @@ -0,0 +1,82 @@ +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; + 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[]), + orgid, + NULL::varchar, + NULL::bigint + ) + 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. + 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 := pg_catalog.array_append(messages, pg_catalog.jsonb_build_object( + 'message', + '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 + )); + END IF; + + RETURN messages; +END; +$$; + +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; 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', () => {