diff --git a/supabase/migrations/20260520040202_cli_warning_rbac_appid_bug.sql b/supabase/migrations/20260520040202_cli_warning_rbac_appid_bug.sql new file mode 100644 index 0000000000..e1ff1bf7d9 --- /dev/null +++ b/supabase/migrations/20260520040202_cli_warning_rbac_appid_bug.sql @@ -0,0 +1,151 @@ +-- Warn users on @capgo/cli versions older than 7.107.0 when their API key +-- would hit the appid-passthrough RBAC bug fixed by PR #2282. +-- +-- The bug: is_allowed_action_org_action(orgid, actions) calls check_min_rights +-- with app_id = NULL. For an RBAC-managed key (mode IS NULL) with +-- limited_to_apps set, on an org with use_new_rbac = true, +-- rbac_check_permission_direct denies the call because the key is restricted +-- to apps but no app context was provided. The CLI surfaces this as the +-- misleading "Plan upgrade required for upload" error. +-- +-- The fix shipped in @capgo/cli@7.107.0. For users still running older CLIs, +-- this warning fires fatally during checkRemoteCliMessages (which runs BEFORE +-- checkPlanValidUpload), replacing the misleading billing error with an +-- actionable one that explains the bug, the upgrade target, and the +-- workaround. +-- +-- Scope: RBAC v2 keys only (mode IS NULL with role_bindings). Legacy keys +-- (mode IN ('read','write','upload','all')) with limited_to_apps would also +-- hit the bug on use_new_rbac orgs, but the user-requested scope is RBAC v2. + +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[]; + request_apikey text; + api_key public.apikeys%ROWTYPE; + fallback_app_id text; + has_org_read boolean; + org_uses_new_rbac boolean; + cli_version_match text[]; + cli_version_parts int[]; + -- Lowest @capgo/cli release that contains the PR #2282 fix. + fix_cli_version constant int[] := ARRAY[7, 107, 0]; +BEGIN + PERFORM cli_version; + + has_org_read := public.cli_check_permission( + permission_key := public.rbac_perm_org_read(), + org_id := orgid + ); + + SELECT public.get_apikey_header() INTO request_apikey; + + IF request_apikey IS NOT NULL AND request_apikey <> '' THEN + SELECT * INTO api_key + FROM public.find_apikey_by_value(request_apikey) + LIMIT 1; + END IF; + + IF NOT has_org_read THEN + IF api_key.id IS NOT NULL + AND COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 + THEN + SELECT public.apps.app_id INTO fallback_app_id + FROM public.apps + WHERE public.apps.owner_org = orgid + AND public.apps.app_id = ANY(api_key.limited_to_apps) + ORDER BY public.apps.app_id + LIMIT 1; + + IF fallback_app_id IS NOT NULL THEN + has_org_read := public.cli_check_permission( + permission_key := public.rbac_perm_org_read(), + org_id := orgid, + app_id := fallback_app_id + ); + END IF; + END IF; + END IF; + + IF NOT has_org_read THEN + messages := array_append(messages, jsonb_build_object( + 'message', 'API key does not have read access to this organization', + 'fatal', true + )); + 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; + + -- PR #2282 warning. Triggers only when ALL of the following are true: + -- 1. Caller's API key is RBAC v2 (mode IS NULL). + -- 2. Key has limited_to_apps set (the gate that trips inside RBAC). + -- 3. Org has use_new_rbac = true (the gate routes through + -- rbac_check_permission_direct only when RBAC is enabled). + -- 4. CLI version parses cleanly and sits below 7.107.0. + IF api_key.id IS NOT NULL + AND api_key.mode IS NULL + AND COALESCE(array_length(api_key.limited_to_apps, 1), 0) > 0 + THEN + SELECT COALESCE(o.use_new_rbac, false) INTO org_uses_new_rbac + FROM public.orgs o + WHERE o.id = orgid; + + IF COALESCE(org_uses_new_rbac, false) THEN + -- Parse leading X.Y.Z. Unparseable versions (empty string, "dev", + -- "next") fall through without firing the warning - safer to be + -- silent than to nag on non-release builds. + cli_version_match := regexp_match(cli_version, '^([0-9]+)\.([0-9]+)\.([0-9]+)'); + IF cli_version_match IS NOT NULL THEN + cli_version_parts := ARRAY[ + cli_version_match[1]::int, + cli_version_match[2]::int, + cli_version_match[3]::int + ]; + + IF cli_version_parts < fix_cli_version THEN + messages := array_append(messages, jsonb_build_object( + 'message', + 'Your CLI version (' || cli_version || ') has a known bug affecting RBAC-managed API keys restricted to specific apps.\n' || + 'Uploads with this key fail with "Plan upgrade required for upload" even when your plan is healthy.\n' || + 'Fix: upgrade to @capgo/cli@7.107.0 or newer:\n' || + ' npm i -g @capgo/cli@latest\n' || + 'Workaround if you cannot upgrade: remove the app restriction (limited_to_apps) on this API key, leaving only the org restriction. See https://github.com/Cap-go/capgo/pull/2282 for context.', + 'fatal', true + )); + END IF; + END IF; + END IF; + 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 EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO anon; +GRANT EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_organization_cli_warnings(uuid, text) +TO service_role; diff --git a/tests/cli-warning-rbac-appid-bug.test.ts b/tests/cli-warning-rbac-appid-bug.test.ts new file mode 100644 index 0000000000..8a1be860e2 --- /dev/null +++ b/tests/cli-warning-rbac-appid-bug.test.ts @@ -0,0 +1,307 @@ +// Regression test for the CLI warning that fires on @capgo/cli versions +// older than 7.107.0 when the caller's API key meets the conditions that +// trigger the PR #2282 appid-passthrough bug. +// +// The condition matrix this test exercises: +// +// | mode | limited_to_apps | use_new_rbac | cli_version | fires? | +// |---------|-----------------|--------------|--------------|--------| +// | NULL | non-empty | true | 7.106.0 | YES | (the bug case) +// | NULL | non-empty | true | 7.107.0 | NO | (cutoff) +// | NULL | non-empty | true | 7.107.1 | NO | (above cutoff) +// | 'all' | non-empty | true | 7.106.0 | NO | (not RBAC v2) +// | NULL | empty | true | 7.106.0 | NO | (no app restriction) +// | NULL | non-empty | false | 7.106.0 | NO | (RBAC off for org) +// +// Test plumbing matches tests/plan-check-appid-passthrough.test.ts: +// - Auth admin createUser for the FK on public.users. +// - The apikeys_force_server_key trigger overrides whatever `key` we pass, +// so we read the actual plaintext back from INSERT ... RETURNING. + +import type { Database } from '../src/types/supabase.types' +import { randomUUID } from 'node:crypto' +import { env } from 'node:process' +import { createClient } from '@supabase/supabase-js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { getSupabaseClient, normalizeLocalhostUrl, SUPABASE_ANON_KEY } from './test-utils.ts' + +const SUPABASE_URL = normalizeLocalhostUrl(env.SUPABASE_URL) ?? '' +const USE_CLOUDFLARE_WORKERS = env.USE_CLOUDFLARE_WORKERS === 'true' + +if (!SUPABASE_URL) + throw new Error('SUPABASE_URL is required for cli-warning-rbac-appid-bug tests') +if (!SUPABASE_ANON_KEY) + throw new Error('SUPABASE_ANON_KEY is required for cli-warning-rbac-appid-bug tests') + +const serviceRoleSupabase = getSupabaseClient() + +const SUITE_ID = randomUUID() +const OWNER_EMAIL = `cli-warn-owner-${SUITE_ID}@capgo.test` +const CUSTOMER_ID = `cus_cli_warn_${SUITE_ID.replace(/-/g, '')}` +const APP_ID = `com.capgo.test.cli-warn.${SUITE_ID}` + +let ownerUserId: string +let orgId: string +let appUuid: string + +interface KeyHandle { + id: number + rbacId: string + plain: string +} + +interface CreateKeyOptions { + mode: 'all' | null + limitedToApps: string[] +} + +async function provisionKey({ mode, limitedToApps }: CreateKeyOptions): Promise { + const rbacId = randomUUID() + const { data: row, error } = await serviceRoleSupabase + .from('apikeys') + .insert({ + user_id: ownerUserId, + key: randomUUID(), + mode, + name: `cli-warn-${mode ?? 'rbacv2'}-${randomUUID()}`, + limited_to_apps: limitedToApps, + limited_to_orgs: [orgId], + rbac_id: rbacId, + }) + .select('id, rbac_id, key') + .single() + if (error) + throw error + if (!row.rbac_id) + throw new Error('Expected apikey insert to return rbac_id') + if (!row.key) + throw new Error('Expected plaintext key in apikey insert RETURNING row') + + if (mode === null) { + // RBAC v2 keys need at least one role binding so the org-read check inside + // get_organization_cli_warnings doesn't reject the key with the "API key + // does not have read access" warning before our new check runs. + const { data: orgRole, error: roleError } = await serviceRoleSupabase + .from('roles').select('id').eq('name', 'org_member').single() + if (roleError) + throw roleError + const { error: bindError } = await serviceRoleSupabase.from('role_bindings').insert({ + principal_type: 'apikey', + principal_id: row.rbac_id, + role_id: orgRole.id, + scope_type: 'org', + org_id: orgId, + granted_by: ownerUserId, + is_direct: true, + }) + if (bindError) + throw bindError + } + + return { id: row.id, rbacId: row.rbac_id, plain: row.key } +} + +function capgkeyClient(key: string) { + return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + global: { headers: { capgkey: key } }, + }) +} + +interface Warning { + message: string + fatal: boolean +} + +async function callWarnings(key: string, cliVersion: string): Promise { + const client = capgkeyClient(key) + const { data, error } = await client.rpc('get_organization_cli_warnings', { + orgid: orgId, + cli_version: cliVersion, + }) + if (error) + throw error + return (data ?? []) as unknown as Warning[] +} + +const RBAC_BUG_MARKER = 'CLI version' +const RBAC_BUG_HINT = '7.107.0' + +function hasRbacBugWarning(warnings: Warning[]) { + return warnings.some(w => + w.fatal === true + && w.message.includes(RBAC_BUG_MARKER) + && w.message.includes(RBAC_BUG_HINT), + ) +} + +async function cleanupKey(handle: KeyHandle) { + await serviceRoleSupabase.from('role_bindings').delete().eq('principal_id', handle.rbacId) + await serviceRoleSupabase.from('apikeys').delete().eq('id', handle.id) +} + +describe.skipIf(USE_CLOUDFLARE_WORKERS)('CLI warning: RBAC v2 + limited_to_apps + old CLI (PR #2282)', () => { + beforeAll(async () => { + const { data: authUser, error: authUserError } = await serviceRoleSupabase.auth.admin.createUser({ + email: OWNER_EMAIL, + password: `Capgo!${SUITE_ID}`, + email_confirm: true, + }) + if (authUserError) + throw authUserError + ownerUserId = authUser.user.id + + const { error: userError } = await serviceRoleSupabase.from('users').insert({ + id: ownerUserId, + email: OWNER_EMAIL, + }) + if (userError) + throw userError + + const { data: planRow, error: planError } = await serviceRoleSupabase + .from('plans').select('stripe_id').order('created_at', { ascending: true }).limit(1).single() + if (planError) + throw planError + + const { error: stripeError } = await serviceRoleSupabase.from('stripe_info').insert({ + customer_id: CUSTOMER_ID, + product_id: planRow.stripe_id, + status: 'succeeded', + subscription_anchor_start: new Date(Date.now() - 10 * 86400_000).toISOString(), + subscription_anchor_end: new Date(Date.now() + 20 * 86400_000).toISOString(), + }) + if (stripeError) + throw stripeError + + const { data: orgRow, error: orgError } = await serviceRoleSupabase + .from('orgs') + .insert({ + created_by: ownerUserId, + name: `CLI Warn Test Org ${SUITE_ID}`, + management_email: OWNER_EMAIL, + customer_id: CUSTOMER_ID, + use_new_rbac: true, + }) + .select('id') + .single() + if (orgError) + throw orgError + orgId = orgRow.id + + const { data: appRow, error: appError } = await serviceRoleSupabase + .from('apps') + .insert({ + app_id: APP_ID, + name: 'CLI Warn Test App', + icon_url: 'https://example.test/icon.png', + user_id: ownerUserId, + owner_org: orgId, + }) + .select('id') + .single() + if (appError) + throw appError + if (!appRow.id) + throw new Error('Expected app insert to return id') + appUuid = appRow.id + }) + + afterAll(async () => { + if (appUuid) + await serviceRoleSupabase.from('apps').delete().eq('id', appUuid) + if (orgId) + await serviceRoleSupabase.from('orgs').delete().eq('id', orgId) + await serviceRoleSupabase.from('stripe_info').delete().eq('customer_id', CUSTOMER_ID) + if (ownerUserId) { + await serviceRoleSupabase.from('users').delete().eq('id', ownerUserId) + await serviceRoleSupabase.auth.admin.deleteUser(ownerUserId) + } + }) + + it('fires fatal for RBAC v2 key with limited_to_apps on use_new_rbac org running CLI < 7.107.0', async () => { + const key = await provisionKey({ mode: null, limitedToApps: [APP_ID] }) + try { + const warnings = await callWarnings(key.plain, '7.106.0') + expect(hasRbacBugWarning(warnings)).toBe(true) + const w = warnings.find(x => hasRbacBugWarning([x]))! + expect(w.message).toContain('npm i -g @capgo/cli@latest') + expect(w.message).toContain('limited_to_apps') + } + finally { + await cleanupKey(key) + } + }) + + it('does NOT fire on CLI == 7.107.0 (cutoff)', async () => { + const key = await provisionKey({ mode: null, limitedToApps: [APP_ID] }) + try { + const warnings = await callWarnings(key.plain, '7.107.0') + expect(hasRbacBugWarning(warnings)).toBe(false) + } + finally { + await cleanupKey(key) + } + }) + + it('does NOT fire on CLI > 7.107.0', async () => { + const key = await provisionKey({ mode: null, limitedToApps: [APP_ID] }) + try { + const warnings = await callWarnings(key.plain, '7.108.0') + expect(hasRbacBugWarning(warnings)).toBe(false) + } + finally { + await cleanupKey(key) + } + }) + + it('does NOT fire for legacy mode=all key on old CLI (scope is RBAC v2 only)', async () => { + const key = await provisionKey({ mode: 'all', limitedToApps: [APP_ID] }) + try { + const warnings = await callWarnings(key.plain, '7.106.0') + expect(hasRbacBugWarning(warnings)).toBe(false) + } + finally { + await cleanupKey(key) + } + }) + + it('does NOT fire when limited_to_apps is empty', async () => { + const key = await provisionKey({ mode: null, limitedToApps: [] }) + try { + const warnings = await callWarnings(key.plain, '7.106.0') + expect(hasRbacBugWarning(warnings)).toBe(false) + } + finally { + await cleanupKey(key) + } + }) + + it('does NOT fire when org has use_new_rbac=false (RBAC off, no bug to warn about)', async () => { + const { error: flipError } = await serviceRoleSupabase + .from('orgs').update({ use_new_rbac: false }).eq('id', orgId) + if (flipError) + throw flipError + const key = await provisionKey({ mode: null, limitedToApps: [APP_ID] }) + try { + const warnings = await callWarnings(key.plain, '7.106.0') + expect(hasRbacBugWarning(warnings)).toBe(false) + } + finally { + await cleanupKey(key) + await serviceRoleSupabase.from('orgs').update({ use_new_rbac: true }).eq('id', orgId) + } + }) + + it('does NOT fire on unparseable CLI version strings (dev/next builds)', async () => { + const key = await provisionKey({ mode: null, limitedToApps: [APP_ID] }) + try { + for (const v of ['dev', 'next', '', 'not-a-version']) { + const warnings = await callWarnings(key.plain, v) + expect(hasRbacBugWarning(warnings)).toBe(false) + } + } + finally { + await cleanupKey(key) + } + }) +})