From 749e0f275e16671665327b9be45f7703f50c2f91 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 17 Jul 2026 15:43:31 +0200 Subject: [PATCH] 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 {