Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions supabase/functions/_backend/public/channel/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ export async function post(c: Context<MiddlewareKeyVariables>, body: ChannelSet,
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) {
throw simpleError('invalid_app_id', 'You can\'t access this app', { app_id: body.app_id })
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
riderx marked this conversation as resolved.
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
)
)
);
44 changes: 44 additions & 0 deletions tests/channel-post.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,50 @@ 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('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({
Expand Down
30 changes: 30 additions & 0 deletions tests/cli-preview-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down Expand Up @@ -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 {
Expand Down
Loading