From 749e0f275e16671665327b9be45f7703f50c2f91 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 17 Jul 2026 15:43:31 +0200 Subject: [PATCH 01/11] fix(rbac): block preview public channels --- .../functions/_backend/public/channel/post.ts | 11 ++++-- ...33500_app_preview_public_channel_guard.sql | 36 +++++++++++++++++++ tests/channel-post.unit.test.ts | 20 +++++++++++ tests/cli-preview-lifecycle.test.ts | 30 ++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260717133500_app_preview_public_channel_guard.sql diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 9cb56294a5..4a0ebf86c0 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -342,8 +342,15 @@ export async function post(c: Context, body: ChannelSet, throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) } } - else if (!(await checkPermission(c, 'app.create_channel', { appId: body.app_id }))) { - throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) + else { + if (!(await checkPermission(c, 'app.create_channel', { appId: body.app_id }))) { + throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) + } + // A public/default channel changes the app's delivery configuration. Preview + // keys may bootstrap private channels only, so they cannot make one public. + if (body.public === true && !(await checkPermission(c, 'app.update_settings', { appId: body.app_id }))) { + throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) + } } const { data: org, error } = await supabaseAdmin(c).from('apps').select('owner_org').eq('app_id', body.app_id).single() if (error || !org) { diff --git a/supabase/migrations/20260717133500_app_preview_public_channel_guard.sql b/supabase/migrations/20260717133500_app_preview_public_channel_guard.sql new file mode 100644 index 0000000000..c514d6aa8c --- /dev/null +++ b/supabase/migrations/20260717133500_app_preview_public_channel_guard.sql @@ -0,0 +1,36 @@ +-- A public/default channel changes app delivery settings. App-preview keys may +-- bootstrap private channels, but must not make a newly created channel public. +-- Keep this in INSERT RLS because `channel add --default` writes the table +-- directly; the channel endpoint performs the matching guard for its raw SQL +-- create-and-promote transaction. +DROP POLICY IF EXISTS "Allow RBAC channels insert" ON public.channels; +CREATE POLICY "Allow RBAC channels insert" +ON public.channels +FOR INSERT +TO anon, authenticated +WITH CHECK ( + public.rbac_check_permission_request( + public.rbac_perm_app_create_channel(), + owner_org, + app_id, + NULL::bigint + ) + AND ( + "public" IS FALSE + OR public.rbac_check_permission_request( + public.rbac_perm_app_update_settings(), + owner_org, + app_id, + NULL::bigint + ) + ) + AND ( + (version IS NULL AND rollout_version IS NULL) + OR public.rbac_check_permission_request( + public.rbac_perm_channel_promote_bundle(), + owner_org, + app_id, + NULL::bigint + ) + ) +); diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index 79879f5fbb..652362b72b 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -256,6 +256,26 @@ describe('public channel post', () => { expect(updateOrCreateChannel).toHaveBeenCalledWith(c, expect.not.objectContaining({ electron: false }), null, true) }) + it('requires app settings permission to create a public channel', async () => { + checkPermission + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + const c = context() + + await expect(post(c, { + app_id: 'com.test.preview', + channel: 'preview-default', + public: true, + }, apiKey())).rejects.toMatchObject({ + cause: expect.objectContaining({ error: 'cannot_access_app' }), + }) + + expect(checkPermission).toHaveBeenNthCalledWith(1, c, 'app.create_channel', { appId: 'com.test.preview' }) + expect(checkPermission).toHaveBeenNthCalledWith(2, c, 'app.update_settings', { appId: 'com.test.preview' }) + expect(updateOrCreateChannel).not.toHaveBeenCalled() + }) + it('preserves the stable version for a settings-only update without channel.read or bundle lookup', async () => { const fromCalls: string[] = [] supabaseAdmin.mockImplementation(() => buildAdminChain({ diff --git a/tests/cli-preview-lifecycle.test.ts b/tests/cli-preview-lifecycle.test.ts index 2fef9a7984..1fc986adae 100644 --- a/tests/cli-preview-lifecycle.test.ts +++ b/tests/cli-preview-lifecycle.test.ts @@ -76,6 +76,8 @@ const APPNAME = `com.cli.preview.lifecycle.${id}` const CHANNEL_NAME = `preview-${id.slice(0, 8)}` const SECOND_CHANNEL_NAME = `preview-other-${id.slice(0, 8)}` const MAIN_CHANNEL_NAME = `main-${id.slice(0, 8)}` +const DEFAULT_CHANNEL_NAME = `preview-default-${id.slice(0, 8)}` +const PUBLIC_POST_CHANNEL_NAME = `preview-public-post-${id.slice(0, 8)}` const BUNDLE_NAME = `1.0.0-preview-${id.slice(0, 8)}` const LEGACY_CHANNEL_NAME = `preview-legacy-${id.slice(0, 8)}` const LEGACY_BUNDLE_NAME = `1.0.0-legacy-${id.slice(0, 8)}` @@ -248,6 +250,34 @@ describe('cli app preview lifecycle', () => { supaAnon: SUPABASE_ANON_KEY, } + await expect(addChannelInternal(DEFAULT_CHANNEL_NAME, APPNAME, { + ...cliOptions, + default: true, + }, true)).rejects.toThrow('Cannot create channel') + + const publicChannelResponse = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'capgkey': apiKey.key, + }, + body: JSON.stringify({ + app_id: APPNAME, + channel: PUBLIC_POST_CHANNEL_NAME, + public: true, + }), + }) + expect(publicChannelResponse.status).toBe(400) + await expect(publicChannelResponse.json()).resolves.toMatchObject({ error: 'cannot_access_app' }) + + const blockedPublicChannels = await executeSQL( + `SELECT COUNT(*)::integer AS count + FROM public.channels + WHERE app_id = $1 AND name = ANY($2::varchar[])`, + [APPNAME, [DEFAULT_CHANNEL_NAME, PUBLIC_POST_CHANNEL_NAME]], + ) + expect(Number(blockedPublicChannels[0]?.count ?? 0)).toBe(0) + const { upload, requests } = await (async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch') try { From cfe3ea5073665782ddd0525ebaf6bded745c2307 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Jul 2026 14:15:42 +0300 Subject: [PATCH 02/11] fix(rbac): restamp preview public channel guard and block updates Co-authored-by: Cursor --- .../functions/_backend/public/channel/post.ts | 17 +++++++------ ...1351_app_preview_public_channel_guard.sql} | 0 tests/channel-post.unit.test.ts | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) rename supabase/migrations/{20260717133500_app_preview_public_channel_guard.sql => 20260728111351_app_preview_public_channel_guard.sql} (100%) diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 4a0ebf86c0..cf1d52fd66 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -342,15 +342,14 @@ export async function post(c: Context, body: ChannelSet, throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) } } - else { - if (!(await checkPermission(c, 'app.create_channel', { appId: body.app_id }))) { - throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) - } - // A public/default channel changes the app's delivery configuration. Preview - // keys may bootstrap private channels only, so they cannot make one public. - if (body.public === true && !(await checkPermission(c, 'app.update_settings', { appId: body.app_id }))) { - throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) - } + else if (!(await checkPermission(c, 'app.create_channel', { appId: body.app_id }))) { + throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) + } + // A public/default channel changes the app's delivery configuration. Preview + // keys may bootstrap private channels only, so they cannot create or flip one + // to public without app.update_settings. + if (body.public === true && !(await checkPermission(c, 'app.update_settings', { appId: body.app_id }))) { + throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) } const { data: org, error } = await supabaseAdmin(c).from('apps').select('owner_org').eq('app_id', body.app_id).single() if (error || !org) { diff --git a/supabase/migrations/20260717133500_app_preview_public_channel_guard.sql b/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql similarity index 100% rename from supabase/migrations/20260717133500_app_preview_public_channel_guard.sql rename to supabase/migrations/20260728111351_app_preview_public_channel_guard.sql diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index 652362b72b..9617b55726 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -276,6 +276,30 @@ describe('public channel post', () => { expect(updateOrCreateChannel).not.toHaveBeenCalled() }) + it('requires app settings permission to make an existing channel public', async () => { + supabaseAdmin.mockImplementation(() => buildAdminChain({ + existingChannelId: 42, + existingChannelVersion: 123, + })) + checkPermission + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + const c = context() + + await expect(post(c, { + app_id: 'com.test.preview', + channel: 'preview-existing', + public: true, + }, apiKey())).rejects.toMatchObject({ + cause: expect.objectContaining({ error: 'cannot_access_app' }), + }) + + expect(checkPermission).toHaveBeenNthCalledWith(1, c, 'channel.update_settings', { appId: 'com.test.preview', channelId: 42 }) + expect(checkPermission).toHaveBeenNthCalledWith(2, c, 'app.update_settings', { appId: 'com.test.preview' }) + expect(updateOrCreateChannel).not.toHaveBeenCalled() + }) + it('preserves the stable version for a settings-only update without channel.read or bundle lookup', async () => { const fromCalls: string[] = [] supabaseAdmin.mockImplementation(() => buildAdminChain({ From 1ee73306d1edf88e0c66a2010bce605874247a4e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 28 Jul 2026 14:36:18 +0300 Subject: [PATCH 03/11] fix(rbac): require app settings to publicize via UPDATE RLS Co-authored-by: Cursor --- ...11351_app_preview_public_channel_guard.sql | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql b/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql index c514d6aa8c..4ad8ddda9e 100644 --- a/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql +++ b/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql @@ -1,8 +1,8 @@ -- A public/default channel changes app delivery settings. App-preview keys may --- bootstrap private channels, but must not make a newly created channel public. --- Keep this in INSERT RLS because `channel add --default` writes the table --- directly; the channel endpoint performs the matching guard for its raw SQL --- create-and-promote transaction. +-- bootstrap private channels, but must not create or flip a channel to public. +-- Keep this in INSERT/UPDATE RLS because CLI/PostgREST can write the table +-- directly; the channel endpoint performs the matching guard for its admin and +-- raw-SQL paths. DROP POLICY IF EXISTS "Allow RBAC channels insert" ON public.channels; CREATE POLICY "Allow RBAC channels insert" ON public.channels @@ -34,3 +34,34 @@ WITH CHECK ( ) ) ); + +DROP POLICY IF EXISTS "Allow RBAC channels update" ON public.channels; +CREATE POLICY "Allow RBAC channels update" +ON public.channels +FOR UPDATE +TO anon, authenticated +USING ( + public.rbac_check_permission_request( + public.rbac_perm_channel_update_settings(), + owner_org, + app_id, + id + ) +) +WITH CHECK ( + public.rbac_check_permission_request( + public.rbac_perm_channel_update_settings(), + owner_org, + app_id, + id + ) + AND ( + "public" IS FALSE + OR public.rbac_check_permission_request( + public.rbac_perm_app_update_settings(), + owner_org, + app_id, + NULL::bigint + ) + ) +); From fc43c48c23fb8cb7d4f67239709f32df9c433529 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 17:47:01 +0000 Subject: [PATCH 04/11] fix(rbac): gate public channel flips with transition trigger Keep channel UPDATE RLS channel-scoped so already-public edits still work. Require app.update_settings only on private-to-public transitions via a BEFORE UPDATE trigger, and align SQL/CLI isolation tests with that boundary. Co-authored-by: Martin DONADIEU --- ...11351_app_preview_public_channel_guard.sql | 63 +++++++++++++++---- supabase/tests/26_test_rls_policies.sql | 24 +++++++ tests/cli-channel.test.ts | 24 +++++-- 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql b/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql index 4ad8ddda9e..39be76a5ec 100644 --- a/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql +++ b/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql @@ -1,8 +1,8 @@ -- A public/default channel changes app delivery settings. App-preview keys may -- bootstrap private channels, but must not create or flip a channel to public. --- Keep this in INSERT/UPDATE RLS because CLI/PostgREST can write the table --- directly; the channel endpoint performs the matching guard for its admin and --- raw-SQL paths. +-- INSERT RLS blocks public creates. UPDATE uses a BEFORE trigger so the check +-- only applies on private -> public transitions; channel-scoped admins can still +-- edit already-public channels. The channel endpoint mirrors the same guard. DROP POLICY IF EXISTS "Allow RBAC channels insert" ON public.channels; CREATE POLICY "Allow RBAC channels insert" ON public.channels @@ -35,6 +35,8 @@ WITH CHECK ( ) ); +-- Keep UPDATE RLS channel-scoped. Requiring app.update_settings whenever the +-- NEW row is public would block legitimate edits to already-public channels. DROP POLICY IF EXISTS "Allow RBAC channels update" ON public.channels; CREATE POLICY "Allow RBAC channels update" ON public.channels @@ -55,13 +57,52 @@ WITH CHECK ( app_id, id ) - AND ( - "public" IS FALSE - OR public.rbac_check_permission_request( +); + +CREATE OR REPLACE FUNCTION public.enforce_public_channel_app_settings_permission() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_request_role text := COALESCE(auth.role(), session_user); +BEGIN + -- Only gate private -> public transitions. Service-role/admin paths enforce + -- the matching app.update_settings check in application code. + IF NEW.public IS TRUE + AND OLD.public IS NOT TRUE + AND v_request_role NOT IN ('service_role', 'postgres') + THEN + IF v_request_role IS DISTINCT FROM 'anon' AND v_request_role IS DISTINCT FROM 'authenticated' THEN + RAISE EXCEPTION 'PERMISSION_DENIED_APP_UPDATE_SETTINGS' + USING ERRCODE = '42501'; + END IF; + + IF NOT public.rbac_check_permission_request( public.rbac_perm_app_update_settings(), - owner_org, - app_id, + NEW.owner_org, + NEW.app_id, NULL::bigint - ) - ) -); + ) THEN + RAISE EXCEPTION 'PERMISSION_DENIED_APP_UPDATE_SETTINGS' + USING ERRCODE = '42501'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.enforce_public_channel_app_settings_permission() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.enforce_public_channel_app_settings_permission() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.enforce_public_channel_app_settings_permission() TO service_role; + +DROP TRIGGER IF EXISTS enforce_public_channel_app_settings_permission ON public.channels; +CREATE TRIGGER enforce_public_channel_app_settings_permission +BEFORE UPDATE OF "public" ON public.channels +FOR EACH ROW +EXECUTE FUNCTION public.enforce_public_channel_app_settings_permission(); + +COMMENT ON FUNCTION public.enforce_public_channel_app_settings_permission() IS + 'Requires app.update_settings when a user-context write flips a channel from private to public.'; diff --git a/supabase/tests/26_test_rls_policies.sql b/supabase/tests/26_test_rls_policies.sql index cf020b8096..d8b5d56590 100644 --- a/supabase/tests/26_test_rls_policies.sql +++ b/supabase/tests/26_test_rls_policies.sql @@ -410,6 +410,30 @@ SELECT 'channels update policy should honor channel-scoped update permission' ); +SELECT + ok( + ( + SELECT COALESCE(with_check, '') ~ 'rbac_perm_app_update_settings' + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'channels' + AND policyname = 'Allow RBAC channels insert' + ), + 'channels insert policy should require app.update_settings for public channels' + ); + +SELECT + ok( + EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'enforce_public_channel_app_settings_permission' + AND tgrelid = 'public.channels'::regclass + AND NOT tgisinternal + ), + 'channels should enforce app.update_settings on private-to-public updates' + ); + SELECT ok( NOT EXISTS ( diff --git a/tests/cli-channel.test.ts b/tests/cli-channel.test.ts index a220039f1b..be9eb030cd 100644 --- a/tests/cli-channel.test.ts +++ b/tests/cli-channel.test.ts @@ -955,25 +955,41 @@ describe('tests CLI channel commands', () => { expect(scopedChannelsError).toBeNull() expect(scopedChannels).toEqual([{ id: target!.id, name: targetChannelName }]) - const { error: targetUpdateError } = await scopedSupabase + // Channel-scoped admins may edit settings, but promoting to public + // requires app.update_settings and must be denied here. + const { error: targetPublicUpdateError } = await scopedSupabase .from('channels') .update({ public: true }) .eq('id', target!.id) + expect(targetPublicUpdateError?.code).toBe('42501') + + const { error: targetUpdateError } = await scopedSupabase + .from('channels') + .update({ allow_emulator: true }) + .eq('id', target!.id) expect(targetUpdateError).toBeNull() const { error: siblingUpdateError } = await scopedSupabase .from('channels') - .update({ public: true }) + .update({ allow_emulator: true }) .eq('id', sibling!.id) expect(siblingUpdateError).toBeNull() const { data: siblingAfterDirectUpdate, error: siblingAfterDirectUpdateError } = await supabase .from('channels') - .select('public') + .select('allow_emulator') .eq('id', sibling!.id) .single() expect(siblingAfterDirectUpdateError).toBeNull() - expect(siblingAfterDirectUpdate?.public).toBe(false) + expect(siblingAfterDirectUpdate?.allow_emulator).toBe(false) + + const { data: targetAfterDirectUpdate, error: targetAfterDirectUpdateError } = await supabase + .from('channels') + .select('public, allow_emulator') + .eq('id', target!.id) + .single() + expect(targetAfterDirectUpdateError).toBeNull() + expect(targetAfterDirectUpdate).toEqual({ public: false, allow_emulator: true }) const postResponse = await fetch(`${BASE_URL}/channel`, { method: 'POST', From 4104e2f4ddae342ef92a9a6a1301d959cbebfec6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 17:56:06 +0000 Subject: [PATCH 05/11] fix(rbac): restamp public channel guard past main migrations Co-authored-by: Martin DONADIEU --- ...rd.sql => 20260808175552_app_preview_public_channel_guard.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260728111351_app_preview_public_channel_guard.sql => 20260808175552_app_preview_public_channel_guard.sql} (100%) diff --git a/supabase/migrations/20260728111351_app_preview_public_channel_guard.sql b/supabase/migrations/20260808175552_app_preview_public_channel_guard.sql similarity index 100% rename from supabase/migrations/20260728111351_app_preview_public_channel_guard.sql rename to supabase/migrations/20260808175552_app_preview_public_channel_guard.sql From bac5bf4d46123c3bf8058c258755e76b85fca7c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 17:59:04 +0000 Subject: [PATCH 06/11] fix(admin): satisfy drizzle execute row constraint for onboarding metrics Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/triggers/logsnag_insights.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/functions/_backend/triggers/logsnag_insights.ts b/supabase/functions/_backend/triggers/logsnag_insights.ts index e29445ecf0..30e05db40e 100644 --- a/supabase/functions/_backend/triggers/logsnag_insights.ts +++ b/supabase/functions/_backend/triggers/logsnag_insights.ts @@ -44,6 +44,7 @@ type AppBuildOnboardingMetrics = Record & { apps_with_manual_builds_24h: number } interface AppBuildOnboardingMetricRow { + [key: string]: unknown created_at: string | Date | null created_from_onboarding: boolean | null onboarding_completed_at: string | Date | null From 948d40376f4c66e7f6c40d8c999a78ac4a52f067 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 18:06:01 +0000 Subject: [PATCH 07/11] test(db): bump channels RLS pgTAP plan for public guard asserts Co-authored-by: Martin DONADIEU --- supabase/tests/26_test_rls_policies.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/tests/26_test_rls_policies.sql b/supabase/tests/26_test_rls_policies.sql index d8b5d56590..051c5e58f7 100644 --- a/supabase/tests/26_test_rls_policies.sql +++ b/supabase/tests/26_test_rls_policies.sql @@ -1,7 +1,7 @@ -- Test RLS Policies -- This file tests all Row Level Security policies in the database BEGIN; -SELECT plan(70); +SELECT plan(72); SELECT policies_are( 'public', From 445c4374c7cedec9673751871a836c775e4e2b3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 18:14:43 +0000 Subject: [PATCH 08/11] fix(rbac): only require app settings when publicizing a channel Match the UPDATE trigger: retain already-public channels with channel.update_settings alone. Co-authored-by: Martin DONADIEU --- .../functions/_backend/public/channel/post.ts | 8 +++-- tests/channel-post.unit.test.ts | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 6f3408bdf3..86d356a9b8 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -331,7 +331,7 @@ export async function post(c: Context, body: ChannelSet, } const { data: existingChannel } = await supabaseAdmin(c) .from('channels') - .select('id, version, rollout_version') + .select('id, version, rollout_version, public') .eq('app_id', body.app_id) .eq('name', body.channel) .maybeSingle() @@ -347,8 +347,10 @@ export async function post(c: Context, body: ChannelSet, } // A public/default channel changes the app's delivery configuration. Preview // keys may bootstrap private channels only, so they cannot create or flip one - // to public without app.update_settings. - if (body.public === true && !(await checkPermission(c, 'app.update_settings', { appId: body.app_id }))) { + // to public without app.update_settings. Retaining an already-public channel + // stays channel-scoped, matching the UPDATE trigger boundary. + const isPublicizing = body.public === true && (existingChannel == null || existingChannel.public !== true) + if (isPublicizing && !(await checkPermission(c, 'app.update_settings', { appId: body.app_id }))) { throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id, channel: body.channel }) } const { data: org, error } = await supabaseAdmin(c).from('apps').select('owner_org').eq('app_id', body.app_id).single() diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index 02b02b6f0d..e0fb91a636 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -66,6 +66,7 @@ function buildAdminChain(body: { existingChannelId?: number | null existingChannelVersion?: number | null existingRolloutVersion?: number | null + existingChannelPublic?: boolean ownerOrg?: string versionId?: number versionError?: { message: string } | null @@ -98,6 +99,7 @@ function buildAdminChain(body: { id: body.existingChannelId, version: body.existingChannelVersion ?? null, rollout_version: body.existingRolloutVersion ?? null, + public: body.existingChannelPublic ?? false, }, error: null, }), @@ -276,10 +278,11 @@ describe('public channel post', () => { expect(updateOrCreateChannel).not.toHaveBeenCalled() }) - it('requires app settings permission to make an existing channel public', async () => { + it('requires app settings permission to make an existing private channel public', async () => { supabaseAdmin.mockImplementation(() => buildAdminChain({ existingChannelId: 42, existingChannelVersion: 123, + existingChannelPublic: false, })) checkPermission .mockResolvedValueOnce(true) @@ -300,6 +303,32 @@ describe('public channel post', () => { expect(updateOrCreateChannel).not.toHaveBeenCalled() }) + it('allows channel settings updates that retain an already-public channel', async () => { + supabaseAdmin.mockImplementation(() => buildAdminChain({ + existingChannelId: 42, + existingChannelVersion: 123, + existingChannelPublic: true, + })) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + const c = context() + + await post(c, { + app_id: 'com.test.already-public', + channel: 'production', + public: true, + allow_emulator: true, + }, apiKey()) + + expect(checkPermission).toHaveBeenCalledTimes(1) + expect(checkPermission).toHaveBeenCalledWith(c, 'channel.update_settings', { appId: 'com.test.already-public', channelId: 42 }) + expect(updateOrCreateChannel).toHaveBeenCalledWith( + c, + expect.objectContaining({ version: 123, public: true, allow_emulator: true }), + 42, + true, + ) + }) + it('preserves the stable version for a settings-only update without channel.read or bundle lookup', async () => { const fromCalls: string[] = [] supabaseAdmin.mockImplementation(() => buildAdminChain({ From bc2f9f8705e7ded32ddd4ad10cb314e2777a06e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 18:43:07 +0000 Subject: [PATCH 09/11] test(db): add behavioral coverage for public channel guard Assert app_preview cannot create public channels, channel-admins cannot flip private to public, and already-public channel edits stay allowed. Co-authored-by: Martin DONADIEU --- .../tests/67_test_public_channel_guard.sql | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 supabase/tests/67_test_public_channel_guard.sql diff --git a/supabase/tests/67_test_public_channel_guard.sql b/supabase/tests/67_test_public_channel_guard.sql new file mode 100644 index 0000000000..c5ada6ef6f --- /dev/null +++ b/supabase/tests/67_test_public_channel_guard.sql @@ -0,0 +1,319 @@ +-- Behavioral RLS/trigger coverage for the public/default channel guard. +-- app_preview may create private channels, but public create/flip needs +-- app.update_settings. Channel-scoped admins keep editing already-public rows. +BEGIN; + +SELECT plan(6); + +SELECT tests.authenticate_as_service_role(); +SELECT tests.create_supabase_user('public_channel_guard_owner', 'public_channel_guard_owner@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES ( + tests.get_supabase_uid('public_channel_guard_owner'), + 'public_channel_guard_owner@test.local', + NOW(), + NOW() +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email) +VALUES ( + '67000000-0000-4000-8000-000000000067', + tests.get_supabase_uid('public_channel_guard_owner'), + 'Public channel guard org', + 'public-channel-guard@test.local' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.apps (app_id, icon_url, user_id, name, owner_org) +VALUES ( + 'com.test.public.channel.guard', + '', + tests.get_supabase_uid('public_channel_guard_owner'), + 'Public channel guard app', + '67000000-0000-4000-8000-000000000067' +) +ON CONFLICT (app_id) DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + tests.get_supabase_uid('public_channel_guard_owner'), + roles.id, + public.rbac_scope_org(), + '67000000-0000-4000-8000-000000000067'::uuid, + tests.get_supabase_uid('public_channel_guard_owner'), + 'pgTAP public channel guard owner', + true +FROM public.roles +WHERE roles.name = public.rbac_role_org_super_admin() +ON CONFLICT DO NOTHING; + +-- Preview key: can create private channels, cannot publicize. +SELECT tests.create_v2_apikey( + 67001, + tests.get_supabase_uid('public_channel_guard_owner'), + 'public-channel-guard-preview-key', + 'public-channel-guard-preview-key', + '67000000-0000-4000-8000-000000000067'::uuid, + public.rbac_role_org_member(), + 'com.test.public.channel.guard', + 'app_preview' +); + +-- App admin key: has app.update_settings for intentional public flips. +SELECT tests.create_v2_apikey( + 67002, + tests.get_supabase_uid('public_channel_guard_owner'), + 'public-channel-guard-admin-key', + 'public-channel-guard-admin-key', + '67000000-0000-4000-8000-000000000067'::uuid, + public.rbac_role_org_member(), + 'com.test.public.channel.guard', + public.rbac_role_app_admin() +); + +-- Private channel owned by the fixture app for channel-admin flip tests. +INSERT INTO public.channels ( + id, + name, + app_id, + version, + public, + disable_auto_update_under_native, + disable_auto_update, + ios, + android, + electron, + allow_device_self_set, + allow_emulator, + allow_device, + allow_dev, + allow_prod, + owner_org, + created_by +) +VALUES ( + 6700401, + 'guard-private', + 'com.test.public.channel.guard', + NULL, + false, + true, + 'major'::public.disable_update, + true, + true, + false, + false, + false, + false, + false, + true, + '67000000-0000-4000-8000-000000000067'::uuid, + tests.get_supabase_uid('public_channel_guard_owner') +) +ON CONFLICT (id) DO UPDATE +SET + public = false, + allow_emulator = false; + +-- Channel-admin key: channel.update_settings only, no app.update_settings. +SELECT tests.create_v2_apikey( + 67003, + tests.get_supabase_uid('public_channel_guard_owner'), + 'public-channel-guard-channel-admin-key', + 'public-channel-guard-channel-admin-key', + '67000000-0000-4000-8000-000000000067'::uuid, + public.rbac_role_org_billing_admin() +); + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_apikey(), + apikeys.rbac_id, + roles.id, + public.rbac_scope_channel(), + '67000000-0000-4000-8000-000000000067'::uuid, + apps.id, + channels.rbac_id, + tests.get_supabase_uid('public_channel_guard_owner'), + 'pgTAP channel-admin public flip denial', + true +FROM public.apikeys +CROSS JOIN public.roles +CROSS JOIN public.apps +CROSS JOIN public.channels +WHERE apikeys.id = 67003 + AND roles.name = public.rbac_role_channel_admin() + AND apps.app_id = 'com.test.public.channel.guard' + AND channels.id = 6700401 +ON CONFLICT DO NOTHING; + +-- 1) Preview key cannot INSERT a public channel. +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-preview-key"}', true); + +SELECT throws_ok( + $$ + INSERT INTO public.channels ( + name, + app_id, + version, + public, + disable_auto_update_under_native, + disable_auto_update, + ios, + android, + electron, + allow_device_self_set, + allow_emulator, + allow_device, + allow_dev, + allow_prod, + owner_org, + created_by + ) + VALUES ( + 'guard-preview-public', + 'com.test.public.channel.guard', + NULL, + true, + true, + 'major'::public.disable_update, + true, + true, + false, + false, + false, + false, + false, + true, + '67000000-0000-4000-8000-000000000067'::uuid, + tests.get_supabase_uid('public_channel_guard_owner') + ) + $$, + '42501', + 'new row violates row-level security policy for table "channels"', + 'app_preview key cannot insert a public channel without app.update_settings' +); + +-- 2) Preview key can INSERT a private channel. +SELECT lives_ok( + $$ + INSERT INTO public.channels ( + name, + app_id, + version, + public, + disable_auto_update_under_native, + disable_auto_update, + ios, + android, + electron, + allow_device_self_set, + allow_emulator, + allow_device, + allow_dev, + allow_prod, + owner_org, + created_by + ) + VALUES ( + 'guard-preview-private', + 'com.test.public.channel.guard', + NULL, + false, + true, + 'major'::public.disable_update, + true, + true, + false, + false, + false, + false, + false, + true, + '67000000-0000-4000-8000-000000000067'::uuid, + tests.get_supabase_uid('public_channel_guard_owner') + ) + $$, + 'app_preview key can insert a private channel' +); + +-- 3) Channel-admin cannot flip private -> public. +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-channel-admin-key"}', true); + +SELECT throws_ok( + $$ + UPDATE public.channels + SET public = true + WHERE id = 6700401 + $$, + '42501', + 'PERMISSION_DENIED_APP_UPDATE_SETTINGS', + 'channel-admin cannot promote a private channel to public' +); + +-- 4) Channel-admin can still edit non-public settings on a private channel. +SELECT lives_ok( + $$ + UPDATE public.channels + SET allow_emulator = true + WHERE id = 6700401 + $$, + 'channel-admin can update non-public settings on a private channel' +); + +-- 5) App admin can flip private -> public. +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-admin-key"}', true); + +SELECT lives_ok( + $$ + UPDATE public.channels + SET public = true + WHERE id = 6700401 + $$, + 'app admin can promote a private channel to public' +); + +-- 6) Channel-admin can still edit an already-public channel. +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-channel-admin-key"}', true); + +SELECT lives_ok( + $$ + UPDATE public.channels + SET allow_device = true + WHERE id = 6700401 + $$, + 'channel-admin can update settings on an already-public channel' +); + +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{}', true); + +SELECT * FROM finish(); +ROLLBACK; From 70f8ddb030097c9b54dc034cae7de2eb5eddde8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 18:50:26 +0000 Subject: [PATCH 10/11] fix(db): detect request role for public channel guard Use current_request_role()/is_internal_request_role() so anon API-key and pgTAP callers are not skipped when session_user is postgres. Co-authored-by: Martin DONADIEU --- ...260808175552_app_preview_public_channel_guard.sql | 12 ++++++++---- supabase/tests/67_test_public_channel_guard.sql | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/20260808175552_app_preview_public_channel_guard.sql b/supabase/migrations/20260808175552_app_preview_public_channel_guard.sql index 39be76a5ec..e6d46cbc18 100644 --- a/supabase/migrations/20260808175552_app_preview_public_channel_guard.sql +++ b/supabase/migrations/20260808175552_app_preview_public_channel_guard.sql @@ -66,13 +66,17 @@ SECURITY DEFINER SET search_path = '' AS $$ DECLARE - v_request_role text := COALESCE(auth.role(), session_user); + -- Prefer current_request_role over auth.role()/session_user: pgTAP and + -- PostgREST API-key traffic set the role GUC (and/or JWT role) to anon while + -- session_user stays postgres. Falling back to session_user would skip the + -- private -> public guard for those callers. + v_request_role text := public.current_request_role(); BEGIN - -- Only gate private -> public transitions. Service-role/admin paths enforce - -- the matching app.update_settings check in application code. + -- Only gate private -> public transitions. Internal/service-role paths + -- enforce the matching app.update_settings check in application code. IF NEW.public IS TRUE AND OLD.public IS NOT TRUE - AND v_request_role NOT IN ('service_role', 'postgres') + AND NOT public.is_internal_request_role(v_request_role) THEN IF v_request_role IS DISTINCT FROM 'anon' AND v_request_role IS DISTINCT FROM 'authenticated' THEN RAISE EXCEPTION 'PERMISSION_DENIED_APP_UPDATE_SETTINGS' diff --git a/supabase/tests/67_test_public_channel_guard.sql b/supabase/tests/67_test_public_channel_guard.sql index c5ada6ef6f..54c3ced56b 100644 --- a/supabase/tests/67_test_public_channel_guard.sql +++ b/supabase/tests/67_test_public_channel_guard.sql @@ -172,6 +172,7 @@ ON CONFLICT DO NOTHING; -- 1) Preview key cannot INSERT a public channel. SELECT tests.clear_authentication(); +SELECT set_config('request.jwt.claim.role', 'anon', true); SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-preview-key"}', true); SELECT throws_ok( @@ -263,6 +264,7 @@ SELECT lives_ok( -- 3) Channel-admin cannot flip private -> public. SELECT tests.clear_authentication(); +SELECT set_config('request.jwt.claim.role', 'anon', true); SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-channel-admin-key"}', true); SELECT throws_ok( @@ -288,6 +290,7 @@ SELECT lives_ok( -- 5) App admin can flip private -> public. SELECT tests.clear_authentication(); +SELECT set_config('request.jwt.claim.role', 'anon', true); SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-admin-key"}', true); SELECT lives_ok( @@ -301,6 +304,7 @@ SELECT lives_ok( -- 6) Channel-admin can still edit an already-public channel. SELECT tests.clear_authentication(); +SELECT set_config('request.jwt.claim.role', 'anon', true); SELECT set_config('request.headers', '{"capgkey":"public-channel-guard-channel-admin-key"}', true); SELECT lives_ok( From 3684d361b4187384a5a040229d9e9dbf8007084f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 18:56:16 +0000 Subject: [PATCH 11/11] ci: retrigger tests after unrelated Cloudflare flake Co-authored-by: Martin DONADIEU