diff --git a/cli/src/api/app.ts b/cli/src/api/app.ts index 25f82dfff0..ee25ad3de5 100644 --- a/cli/src/api/app.ts +++ b/cli/src/api/app.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Database } from '../types/supabase.types' import { log } from '@clack/prompts' -import { getPMAndCommand, hasCliPermission, show2FADeniedError } from '../utils' +import { formatCapgoApiErrorBody, getPMAndCommand, hasCliPermission, resolveCapgoPublicApiHost, show2FADeniedError } from '../utils' export async function checkAppExists(supabase: SupabaseClient, appid: string) { const { data: app } = await supabase @@ -98,23 +98,34 @@ export async function findAppInOrganization( } export async function completePendingOnboardingApp( - supabase: SupabaseClient, + _supabase: SupabaseClient, orgId: string, appId: string, + apikey: string, + options?: { supaHost?: string, supaAnon?: string }, ): Promise { - const { data, error } = await supabase - .from('apps') - .update({ need_onboarding: false }) - .select('app_id') - .eq('owner_org', orgId) - .eq('app_id', appId) - .eq('need_onboarding', true) + // Prefer Capgo API host (or self-hosted /functions/v1) with the API key so + // org.create_app keys can finish pending onboarding without app.update_settings. + const apiHost = await resolveCapgoPublicApiHost(options) + const response = await fetch(`${apiHost}/app/${encodeURIComponent(appId)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': apikey, + 'capgkey': apikey, + }, + body: JSON.stringify({ + need_onboarding: false, + }), + }) - if (error) { - throw new Error(`Could not complete onboarding for app ${appId}: ${error.message}`) + const data = await response.json().catch(() => null) + if (!response.ok) { + const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}` + throw new Error(`Could not complete onboarding for app ${appId}: ${details}`) } - if (!data?.length) { + if (!(data as { app_id?: string } | null)?.app_id) { throw new Error(`Could not complete onboarding for app ${appId} in org ${orgId}: app was not found or is no longer pending onboarding`) } } diff --git a/cli/src/app/add.ts b/cli/src/app/add.ts index 1d1b3666c2..796b055587 100644 --- a/cli/src/app/add.ts +++ b/cli/src/app/add.ts @@ -1,7 +1,5 @@ -import type { SupabaseClient } from '@supabase/supabase-js' import type { Buffer } from 'node:buffer' import type { AppOptions } from '../schemas/app' -import type { Database } from '../types/supabase.types' import type { Organization } from '../utils' import { existsSync, readFileSync } from 'node:fs' import { intro, log, outro } from '@clack/prompts' @@ -12,11 +10,13 @@ import { assertCliPermission, createSupabaseClient, findSavedKey, + formatCapgoApiErrorBody, formatError, getAppId, getConfig, getContentType, getOrganizationWithPermission, + resolveCapgoPublicApiHost, resolveUserIdFromApiKey, sendEvent, } from '../utils' @@ -54,7 +54,7 @@ function ensureOptions(appId: string, options: AppOptions, silent: boolean) { } async function ensureAppDoesNotExist( - supabase: SupabaseClient, + supabase: Awaited>, appId: string, silent: boolean, ) { @@ -75,25 +75,61 @@ async function ensureAppDoesNotExist( export type AppCreateSource = 'cli-direct' | 'onboarding' | 'mcp' -function isMissingAppBuildOnboardingSchemaError(error: { message?: string, details?: string, hint?: string, code?: string | null } | null) { - if (!error) - return false - - const text = [error.message, error.details, error.hint, error.code] - .filter(Boolean) - .join(' ') - .toLowerCase() - - return text.includes('created_from_onboarding') - || text.includes('onboarding_completed_at') -} - export function resolveAppCreateSource(explicit?: AppCreateSource): AppCreateSource { if (explicit) return explicit return getInvocationSource() === 'mcp' ? 'mcp' : 'cli-direct' } +async function createAppViaApi( + apikey: string, + params: { + ownerOrg: string + appId: string + name: string + iconUrl: string + createdFromOnboarding: boolean + supaHost?: string + supaAnon?: string + }, +) { + // Prefer Capgo API host (or self-hosted /functions/v1) with the API key. + // Avoid supabase.functions.invoke: it always sends Authorization: Bearer . + const apiHost = await resolveCapgoPublicApiHost({ + supaHost: params.supaHost, + supaAnon: params.supaAnon, + }) + const response = await fetch(`${apiHost}/app`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': apikey, + 'capgkey': apikey, + }, + body: JSON.stringify({ + owner_org: params.ownerOrg, + app_id: params.appId, + name: params.name, + icon: params.iconUrl, + need_onboarding: false, + created_from_onboarding: params.createdFromOnboarding, + }), + }) + + const data = await response.json().catch(() => null) + if (!response.ok) { + const details = formatCapgoApiErrorBody(data) || `HTTP ${response.status}` + throw new Error(details) + } + + const createdAppId = (data as { app_id?: string } | null)?.app_id + if (!createdAppId) { + throw new Error('App create API returned no app_id') + } + + return data as { app_id: string, icon_url?: string, name?: string } +} + export async function addAppInternal( initialAppId: string, options: AppOptions, @@ -165,6 +201,8 @@ export async function addAppInternal( const iconPath = getAppIconStoragePath(organizationUid, appId) let iconUrl = defaultAppIconPath + // Icon upload is best-effort. Storage RLS issues must not block app creation; + // the web onboarding path already continues without an icon on upload failure. if (iconBuff && iconType) { const { error } = await supabase.storage .from('images') @@ -175,42 +213,33 @@ export async function addAppInternal( if (error) { if (!silent) - log.error(`Could not add app ${formatError(error)}`) - throw new Error(`Could not add app ${formatError(error)}`) + log.warn(`Could not upload app icon (${formatError(error)}). Continuing with the default icon.`) + } + else { + iconUrl = iconPath } - - iconUrl = iconPath } const appCreateSource = resolveAppCreateSource(source) - const baseAppInsert = { - icon_url: iconUrl, - owner_org: organizationUid, - user_id: userId, - name, - app_id: appId, - } - const insertApp = (withOnboardingMetrics: boolean) => supabase - .from('apps') - .insert({ - ...baseAppInsert, - ...(withOnboardingMetrics - ? { - created_from_onboarding: true, - } - : {}), - }) - const insertResult = await insertApp(appCreateSource === 'onboarding') - let dbError = insertResult.error - if (dbError && appCreateSource === 'onboarding' && isMissingAppBuildOnboardingSchemaError(dbError)) { - const fallbackInsertResult = await insertApp(false) - dbError = fallbackInsertResult.error + try { + // Use the same authorized API path as the web console. Direct PostgREST inserts + // hit apps/storage RLS and fail for common API-key + pending-onboarding setups. + await createAppViaApi(options.apikey!, { + ownerOrg: organizationUid, + appId, + name, + iconUrl, + createdFromOnboarding: appCreateSource === 'onboarding', + supaHost: options.supaHost, + supaAnon: options.supaAnon, + }) } - if (dbError) { + catch (error) { + const message = formatError(error) if (!silent) - log.error(`Could not add app ${formatError(dbError)}`) - throw new Error(`Could not add app ${formatError(dbError)}`) + log.error(`Could not add app ${message}`) + throw new Error(`Could not add app ${message}`) } await sendEvent(options.apikey!, { diff --git a/cli/src/init/command.ts b/cli/src/init/command.ts index 7bd3e3527e..9135544e6d 100644 --- a/cli/src/init/command.ts +++ b/cli/src/init/command.ts @@ -24,17 +24,17 @@ import { getRepoStarStatus, isRepoStarredInSession, starAllRepositories, starRep import { createKeyInternal } from '../key' import { doLoginExists, loginInternal } from '../login' import { writeOnboardingSupportBundle, writeSupportBundleFiles } from '../onboarding-support' -import { contactSupport } from '../support/contact-support' -import { uploadSupportLogs } from '../support/support-upload' -import { copyToClipboard, revealInFinder } from '../support/clipboard' -import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log' import { showReplicationProgress } from '../replicationProgress' import { formatRunnerCommand, splitRunnerCommand } from '../runner-command' +import { copyToClipboard, revealInFinder } from '../support/clipboard' +import { contactSupport } from '../support/contact-support' +import { appendInternalLog, getInternalLogPath, startInternalLog } from '../support/internal-log' +import { uploadSupportLogs } from '../support/support-upload' import { consoleWebUrl, createSupabaseClient, defaultApiHost, findBuildCommandForProjectType, findMainFile, findMainFileForProjectType, findProjectType, findRoot, findSavedKey, findSavedKeySilent, formatError, getAllPackagesDependencies, getAppId, getBundleVersion, getConfig, getConfigForWrite, getLocalConfig, getNativeProjectResetAdvice, getOrganizationListWithPermission, getPackageScripts, getPMAndCommand, hasCliPermission, PACKNAME, projectIsMonorepo, resolveUserIdFromApiKey, updateConfigbyKey, updateConfigUpdater, validateIosUpdaterSync } from '../utils' import { buildAppIdConflictSuggestions, isAppAlreadyExistsError } from './app-conflict' import { cancel as pCancel, confirm as pConfirm, intro as pIntro, isCancel as pIsCancel, log as pLog, outro as pOutro, select as pSelect, spinner as pSpinner, text as pText } from './prompts' -import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' import { finishActiveCliReplay, getActiveCliReplaySessionId, isCliTelemetryDisabled, startInitReplay } from './replay' +import { appendInitStreamingLine, clearInitStreamingOutput, setInitCodeDiff, setInitEncryptionSummary, setInitVersionWarning, startInitStreamingOutput, stopInitInkSession, updateInitStreamingStatus } from './runtime' import { formatInitResumeMessage, initOnboardingSteps, renderInitOnboardingComplete, renderInitOnboardingFrame, renderInitOnboardingWelcome } from './ui' import { CAPGO_UPDATER_PACKAGE, getUpdaterInstallState } from './updater' @@ -1866,6 +1866,7 @@ async function maybeReusePendingOnboardingApp( apikey: string, appId: string | undefined, supabase: Awaited>, + options?: Pick, ) { const pendingApps = await listPendingOnboardingApps(supabase, organization.gid) const selectedApp = await selectPendingOnboardingApp(organization.gid, apikey, appId, pendingApps) @@ -1888,7 +1889,10 @@ async function maybeReusePendingOnboardingApp( const cleanupSpinner = pSpinner() cleanupSpinner.start(`Preparing ${selectedAppId} for real onboarding`) try { - await completePendingOnboardingApp(supabase, organization.gid, selectedAppId) + await completePendingOnboardingApp(supabase, organization.gid, selectedAppId, apikey, { + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }) cleanupSpinner.stop('Pending onboarding app prepared ✅') } catch (error) { @@ -2055,11 +2059,16 @@ async function completeExistingAppPendingOnboarding( supabase: Awaited>, organization: Organization, appId: string, + apikey: string, + options?: Pick, ) { const s = pSpinner() s.start(`Preparing existing app ${appId}`) try { - await completePendingOnboardingApp(supabase, organization.gid, appId) + await completePendingOnboardingApp(supabase, organization.gid, appId, apikey, { + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }) s.stop('Existing app prepared ✅') } catch (error) { @@ -2091,7 +2100,7 @@ async function resolveExistingAppConflict( if (useExistingApp === true) { if (existingApp.need_onboarding) - await completeExistingAppPendingOnboarding(supabase, organization, appId) + await completeExistingAppPendingOnboarding(supabase, organization, appId, apikey, options) await saveAppIdToCapacitorConfig(appId) return 'use-existing' @@ -4943,7 +4952,7 @@ export async function initApp(apikeyCommand: string, appId: string, options: Sup globalAppId = appId } - const pendingOnboardingSelection = await maybeReusePendingOnboardingApp(organization, options.apikey, appId, supabase) + const pendingOnboardingSelection = await maybeReusePendingOnboardingApp(organization, options.apikey, appId, supabase, options) appId = pendingOnboardingSelection.appId ?? appId await ensureCapacitorProjectReady(orgId, options.apikey, appId, pendingOnboardingSelection.pendingApp) selectedPackageJsonPath = path.resolve(globalPathToPackageJson ?? join(findRoot(cwd()), PACKNAME)) diff --git a/cli/src/organization/set.ts b/cli/src/organization/set.ts index 2ad41d4161..88ce3d02d6 100644 --- a/cli/src/organization/set.ts +++ b/cli/src/organization/set.ts @@ -5,12 +5,10 @@ import { assertOrgPermission, check2FAAccessForOrg, createSupabaseClient, - defaultApiHost, findSavedKey, formatError, - getLocalConfig, - getRemoteConfig, - normalizeSupabaseHost, + resolveCapgoPublicApiHost, + resolveConfiguredCapgoPublicApiHost, sendEvent, } from '../utils' @@ -36,29 +34,10 @@ interface OrganizationUpdateResponse { message?: string } -interface OrganizationApiHostConfig { - hostApi: string - supaHost?: string - supaKey?: string -} - -export function resolveConfiguredOrganizationUpdateApiHost(config: OrganizationApiHostConfig) { - if (config.supaHost && config.supaKey && config.hostApi === defaultApiHost) - return `${normalizeSupabaseHost(config.supaHost)}/functions/v1` - - return config.hostApi -} +export const resolveConfiguredOrganizationUpdateApiHost = resolveConfiguredCapgoPublicApiHost export async function resolveOrganizationUpdateApiHost(options: Pick, silent: boolean) { - if (options.supaHost && options.supaAnon) - return `${normalizeSupabaseHost(options.supaHost)}/functions/v1` - - const localConfig = await getLocalConfig(silent) - if (localConfig.supaHost && localConfig.supaKey) - return resolveConfiguredOrganizationUpdateApiHost(localConfig) - - const config = await getRemoteConfig(silent) - return config.hostApi + return resolveCapgoPublicApiHost(options, silent) } async function updateOrganizationViaApi(apikey: string, payload: OrganizationUpdatePayload, silent: boolean, apiHost: string) { diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 1c832fad0c..bb9cb6877c 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -203,7 +203,6 @@ interface TrackOptions { timestamp?: number | Date } - export function wait(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms) @@ -723,6 +722,40 @@ export function normalizeSupabaseHost(host: string): string { return `${parsed.origin}${normalizedPath}` } +export function formatCapgoApiErrorBody(body: unknown): string { + if (!body || typeof body !== 'object') + return '' + const record = body as { error?: string, message?: string, status?: string } + return [record.error, record.message, record.status].filter(Boolean).join(' | ') +} + +/** Resolve Capgo public API base URL for CLI mutations (app create/update, etc.). */ +export function resolveConfiguredCapgoPublicApiHost(config: { + hostApi: string + supaHost?: string + supaKey?: string +}): string { + if (config.supaHost && config.supaKey && config.hostApi === defaultApiHost) + return `${normalizeSupabaseHost(config.supaHost)}/functions/v1` + + return config.hostApi +} + +export async function resolveCapgoPublicApiHost( + options?: { supaHost?: string, supaAnon?: string }, + silent = true, +): Promise { + if (options?.supaHost && options?.supaAnon) + return `${normalizeSupabaseHost(options.supaHost)}/functions/v1` + + const localConfig = await getLocalConfig(silent) + if (localConfig.supaHost && localConfig.supaKey) + return resolveConfiguredCapgoPublicApiHost(localConfig) + + const config = await getRemoteConfig(silent) + return config.hostApi +} + export async function createSupabaseClient(apikey: string, supaHost?: string, supaKey?: string, silent = false, instrument = true, signal?: AbortSignal) { const config = await getRemoteConfig(silent, signal) if (supaHost && supaKey) { diff --git a/private/cli-mcp-tests b/private/cli-mcp-tests new file mode 160000 index 0000000000..c27b55c89b --- /dev/null +++ b/private/cli-mcp-tests @@ -0,0 +1 @@ +Subproject commit c27b55c89bb21793ebea45fad7251564bf11d231 diff --git a/supabase/functions/_backend/public/app/post.ts b/supabase/functions/_backend/public/app/post.ts index c5ee1da93c..7462990e73 100644 --- a/supabase/functions/_backend/public/app/post.ts +++ b/supabase/functions/_backend/public/app/post.ts @@ -12,6 +12,7 @@ export interface CreateApp { owner_org: string icon?: string need_onboarding?: boolean + created_from_onboarding?: boolean existing_app?: boolean ios_store_url?: string android_store_url?: string @@ -45,7 +46,8 @@ export async function post(c: Context, body: CreateApp): retention: 2592000, default_upload_channel: 'dev', need_onboarding: body.need_onboarding ?? false, - created_from_onboarding: body.need_onboarding ?? false, + // Keep CLI init metrics independent from pending web-onboarding apps. + created_from_onboarding: body.created_from_onboarding ?? body.need_onboarding ?? false, existing_app: body.existing_app ?? false, ios_store_url: body.ios_store_url ?? null, android_store_url: body.android_store_url ?? null, diff --git a/supabase/functions/_backend/public/app/put.ts b/supabase/functions/_backend/public/app/put.ts index 8e8c659d1d..e27addcc86 100644 --- a/supabase/functions/_backend/public/app/put.ts +++ b/supabase/functions/_backend/public/app/put.ts @@ -1,12 +1,13 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' -import { trackBentoEvent } from '../../utils/bento.ts' import { deleteAppStatus } from '../../utils/appStatus.ts' +import { trackBentoEvent } from '../../utils/bento.ts' import { createIfNotExistStoreInfo } from '../../utils/cloudflare.ts' import { lockOnboardingApp, unlockOnboardingApp } from '../../utils/demo.ts' import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' +import { closeClient, getPgClient } from '../../utils/pg.ts' import { checkPermission } from '../../utils/rbac.ts' import { createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts' import { supabaseAdmin, supabaseApikey } from '../../utils/supabase.ts' @@ -32,10 +33,6 @@ export async function put(c: Context, appId: string, bod if (!isValidAppId(appId)) { throw quickError(400, 'invalid_app_id', 'App ID must be a reverse domain string', { app_id: appId }) } - // Auth context is already set by middlewareKey - if (!(await checkPermission(c, 'app.update_settings', { appId }))) { - throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId }) - } if (body.retention && body.retention >= 63113904) { throw quickError(400, 'retention_to_big', 'Retention cannot be bigger than 63113903 (2 years)', { retention: body.retention }) @@ -44,25 +41,103 @@ export async function put(c: Context, appId: string, bod throw quickError(400, 'retention_to_small', 'Retention cannot be smaller than 0', { retention: body.retention }) } - const { data: previousApp, error: previousAppError } = await supabaseApikey(c, apikey.key) + const canUpdateSettings = await checkPermission(c, 'app.update_settings', { appId }) + + // Service-role load is only for the pending-onboarding completion path so keys + // with org.create_app can finish apps they cannot yet read via RLS. + const previousAppClient = canUpdateSettings || body.need_onboarding !== false + ? supabaseApikey(c, apikey.key) + : supabaseAdmin(c) + const { data: previousApp, error: previousAppError } = await previousAppClient .from('apps') .select('need_onboarding, owner_org, name, app_id') .eq('app_id', appId) .single() if (previousAppError || !previousApp) { + if (!canUpdateSettings) { + throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId }) + } throw simpleError('cannot_load_app', 'Cannot load app before update', { supabaseError: previousAppError }) } - const normalizedIcon = normalizeImagePath(body.icon) const shouldSerializeOnboardingCompletion = previousApp.need_onboarding === true && body.need_onboarding === false + const canCompleteOnboarding = !canUpdateSettings + && shouldSerializeOnboardingCompletion + && await checkPermission(c, 'org.create_app', { orgId: previousApp.owner_org }) + + if (!canUpdateSettings && !canCompleteOnboarding) { + throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId }) + } + + // Completing pending onboarding with only org.create_app must not allow arbitrary + // settings changes. Restrict the writable fields in that case. + if (canCompleteOnboarding) { + const disallowedFields = [ + body.name, + body.icon, + body.retention, + body.expose_metadata, + body.allow_device_custom_id, + body.existing_app, + body.block_provider_infra_requests, + body.ios_store_url, + body.android_store_url, + ].some(value => value !== undefined) + if (disallowedFields) { + throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId }) + } + } + + const normalizedIcon = normalizeImagePath(body.icon) const onboardingLock = shouldSerializeOnboardingCompletion ? await lockOnboardingApp(c, appId) : null - const { data, error: dbError } = await (async () => { - try { - return await supabaseApikey(c, apikey.key) + let data: Record | undefined + let dbError: { message?: string } | null = null + let completedPendingOnboarding = false + + try { + if (!canUpdateSettings && canCompleteOnboarding) { + // Bypass RLS for the narrow onboarding-completion path after explicit authz. + // Reuse the advisory-lock session so the update is serialized with the lock. + const pgClient = onboardingLock ?? getPgClient(c) + try { + const result = await pgClient.query( + `UPDATE public.apps + SET need_onboarding = false + WHERE app_id = $1 + AND need_onboarding = true + RETURNING *`, + [appId], + ) + data = result.rows[0] + if (data) { + completedPendingOnboarding = true + } + else { + // Already completed under the lock; return current row without re-firing side effects. + const current = await pgClient.query( + `SELECT * FROM public.apps WHERE app_id = $1`, + [appId], + ) + data = current.rows[0] + if (!data) + dbError = { message: 'App not found during onboarding completion' } + } + } + catch (error) { + dbError = { message: (error as Error)?.message } + } + finally { + // Only close a client we opened here; unlockOnboardingApp owns the lock session. + if (!onboardingLock) + await closeClient(c, pgClient) + } + } + else { + const updateResult = await supabaseApikey(c, apikey.key) .from('apps') .update({ name: body.name, @@ -79,13 +154,17 @@ export async function put(c: Context, appId: string, bod .eq('app_id', appId) .select() .single() + data = updateResult.data ?? undefined + dbError = updateResult.error + if (data) + completedPendingOnboarding = previousApp.need_onboarding === true && data.need_onboarding === false } - finally { - if (onboardingLock) { - await unlockOnboardingApp(c, onboardingLock, appId) - } + } + finally { + if (onboardingLock) { + await unlockOnboardingApp(c, onboardingLock, appId) } - })() + } if (dbError || !data) { throw simpleError('cannot_update_app', 'Cannot update app', { supabaseError: dbError }) @@ -101,7 +180,6 @@ export async function put(c: Context, appId: string, bod const signedIcon = await createSignedImageUrl(c, data.icon_url) data.icon_url = signedIcon ?? '' } - const completedPendingOnboarding = previousApp.need_onboarding === true && data.need_onboarding === false if (completedPendingOnboarding) { const { data: orgData, error: orgError } = await supabaseAdmin(c) diff --git a/supabase/migrations/20260723120547_fix_app_create_storage_rls.sql b/supabase/migrations/20260723120547_fix_app_create_storage_rls.sql new file mode 100644 index 0000000000..e8a20b29d0 --- /dev/null +++ b/supabase/migrations/20260723120547_fix_app_create_storage_rls.sql @@ -0,0 +1,187 @@ +-- Fix images bucket INSERT/UPDATE policies left behind during RBAC consolidation. +-- Bare `name` inside EXISTS (SELECT ... FROM public.apps ...) resolves to +-- apps.name (display name), not storage.objects.name (object path). Always +-- qualify as objects.name in those subqueries. +-- +-- Allow org.create_app for icon INSERT/UPDATE when the target app is missing or +-- still pending onboarding, so upsert:true during app add/init does not fail +-- RLS for keys that can create apps but lack app.update_settings. + +DROP POLICY IF EXISTS "Allow user or apikey to insert they own folder in images" ON storage.objects; +CREATE POLICY "Allow user or apikey to insert they own folder in images" +ON storage.objects +FOR INSERT +TO anon, authenticated +WITH CHECK ( + bucket_id = 'images' + AND ( + ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] IS NOT NULL + AND (storage.foldername(objects.name))[3] <> 'logo' + AND ( + public.rbac_check_permission_request( + public.rbac_perm_app_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + ((storage.foldername(objects.name))[3])::character varying, + NULL::bigint + ) + OR ( + -- create-only keys may upsert the onboarding icon only + -- (org/{org_id}/{app_id}/icon), not arbitrary image assets + storage.filename(objects.name) = 'icon' + AND public.rbac_check_permission_request( + public.rbac_perm_org_create_app(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + ) + OR EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + AND apps.need_onboarding IS TRUE + ) + ) + ) + ) + ) + OR ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] = 'logo' + AND public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + ) + OR ( + (SELECT auth.uid())::text = (storage.foldername(objects.name))[1] + ) + ) +); + +DROP POLICY IF EXISTS "Allow user or apikey to update they own folder in images" ON storage.objects; +CREATE POLICY "Allow user or apikey to update they own folder in images" +ON storage.objects +FOR UPDATE +TO anon, authenticated +USING ( + bucket_id = 'images' + AND ( + ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] IS NOT NULL + AND (storage.foldername(objects.name))[3] <> 'logo' + AND ( + public.rbac_check_permission_request( + public.rbac_perm_app_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + ((storage.foldername(objects.name))[3])::character varying, + NULL::bigint + ) + OR ( + storage.filename(objects.name) = 'icon' + AND public.rbac_check_permission_request( + public.rbac_perm_org_create_app(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + ) + OR EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + AND apps.need_onboarding IS TRUE + ) + ) + ) + ) + ) + OR ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] = 'logo' + AND public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + ) + OR ( + (SELECT auth.uid())::text = (storage.foldername(objects.name))[1] + ) + ) +) +WITH CHECK ( + bucket_id = 'images' + AND ( + ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] IS NOT NULL + AND (storage.foldername(objects.name))[3] <> 'logo' + AND ( + public.rbac_check_permission_request( + public.rbac_perm_app_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + ((storage.foldername(objects.name))[3])::character varying, + NULL::bigint + ) + OR ( + storage.filename(objects.name) = 'icon' + AND public.rbac_check_permission_request( + public.rbac_perm_org_create_app(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + ) + OR EXISTS ( + SELECT 1 + FROM public.apps + WHERE apps.owner_org = ((storage.foldername(objects.name))[2])::uuid + AND apps.app_id = ((storage.foldername(objects.name))[3])::character varying + AND apps.need_onboarding IS TRUE + ) + ) + ) + ) + ) + OR ( + (storage.foldername(objects.name))[1] = 'org' + AND (storage.foldername(objects.name))[3] = 'logo' + AND public.rbac_check_permission_request( + public.rbac_perm_org_update_settings(), + ((storage.foldername(objects.name))[2])::uuid, + NULL::character varying, + NULL::bigint + ) + ) + OR ( + (SELECT auth.uid())::text = (storage.foldername(objects.name))[1] + ) + ) +); diff --git a/supabase/tests/62_test_images_storage_insert_policy.sql b/supabase/tests/62_test_images_storage_insert_policy.sql new file mode 100644 index 0000000000..4f94be911e --- /dev/null +++ b/supabase/tests/62_test_images_storage_insert_policy.sql @@ -0,0 +1,52 @@ +BEGIN; + +SELECT plan(2); + +SELECT ok( + EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'storage' + AND tablename = 'objects' + AND policyname = 'Allow user or apikey to insert they own folder in images' + -- Bare name inside FROM apps resolves to apps.name; policy must use objects.name. + AND with_check NOT LIKE '%foldername((apps.name%' + AND with_check NOT LIKE '%foldername(("apps"."name"%' + AND with_check NOT LIKE '%foldername(apps.name%' + AND with_check LIKE '%foldername%objects.name%' + AND with_check LIKE '%rbac_perm_org_create_app%' + AND with_check LIKE '%need_onboarding%' + AND with_check LIKE '%filename%objects.name%' + AND with_check LIKE '%''icon''%' + ), + 'images insert policy uses storage objects.name path and org.create_app for new/pending app icons' +); + +SELECT ok( + EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'storage' + AND tablename = 'objects' + AND policyname = 'Allow user or apikey to update they own folder in images' + AND qual LIKE '%need_onboarding%' + AND with_check LIKE '%need_onboarding%' + AND qual LIKE '%rbac_perm_org_create_app%' + AND with_check LIKE '%rbac_perm_org_create_app%' + AND qual LIKE '%foldername%objects.name%' + AND with_check LIKE '%foldername%objects.name%' + AND qual LIKE '%filename%objects.name%' + AND with_check LIKE '%filename%objects.name%' + AND qual LIKE '%''icon''%' + AND with_check LIKE '%''icon''%' + AND coalesce(qual, '') NOT LIKE '%foldername((apps.name%' + AND coalesce(with_check, '') NOT LIKE '%foldername((apps.name%' + AND coalesce(qual, '') NOT LIKE '%foldername(apps.name%' + AND coalesce(with_check, '') NOT LIKE '%foldername(apps.name%' + ), + 'images update policy allows org.create_app for missing or pending onboarding app icons' +); + +SELECT * FROM finish(); -- noqa: AM04 + +ROLLBACK; diff --git a/tests/app-create-pending-onboarding.test.ts b/tests/app-create-pending-onboarding.test.ts new file mode 100644 index 0000000000..b16b296bb8 --- /dev/null +++ b/tests/app-create-pending-onboarding.test.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, describe, expect, it } from 'vitest' +import { + APIKEY_TEST_ALL, + BASE_URL, + getSupabaseClient, + ORG_ID, + resetAppData, + USER_ID, +} from './test-utils.ts' + +const USE_CLOUDFLARE = process.env.USE_CLOUDFLARE_WORKERS === 'true' +// Cloudflare workers can resolve freshly created API keys against a lagging replica. +// This coverage targets the Supabase edge create/complete path used by the CLI. +const describeBackend = describe.skipIf(USE_CLOUDFLARE) + +describeBackend('app create with leftover pending onboarding app', () => { + const pendingAppId = `com.test.pending.onboarding.${randomUUID()}` + const newAppId = `com.test.create.with.pending.${randomUUID()}` + + afterAll(async () => { + await resetAppData(pendingAppId) + await resetAppData(newAppId) + }) + + it('allows API keys to create a new app while a pending onboarding app exists', async () => { + const supabase = getSupabaseClient() + + const { error: pendingError } = await supabase.from('apps').insert({ + app_id: pendingAppId, + name: 'Pending onboarding leftover', + icon_url: '', + owner_org: ORG_ID, + user_id: USER_ID, + need_onboarding: true, + created_from_onboarding: true, + }) + expect(pendingError, JSON.stringify(pendingError)).toBeNull() + + const createResponse = await fetch(`${BASE_URL}/app`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': APIKEY_TEST_ALL, + }, + body: JSON.stringify({ + owner_org: ORG_ID, + app_id: newAppId, + name: `App ${newAppId}`, + icon: '', + need_onboarding: false, + created_from_onboarding: true, + }), + }) + const createBody = await createResponse.json().catch(() => null) + expect(createResponse.status, JSON.stringify(createBody)).toBe(200) + expect((createBody as { app_id?: string }).app_id).toBe(newAppId) + + const completeResponse = await fetch(`${BASE_URL}/app/${pendingAppId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': APIKEY_TEST_ALL, + }, + body: JSON.stringify({ + need_onboarding: false, + }), + }) + const completeBody = await completeResponse.json().catch(() => null) + expect(completeResponse.status, JSON.stringify(completeBody)).toBe(200) + expect((completeBody as { need_onboarding?: boolean }).need_onboarding).toBe(false) + }) +})