Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions supabase/functions/_backend/public/channel/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,15 @@ export async function post(c: Context<MiddlewareKeyVariables>, 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) {
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"
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
)
)
);
20 changes: 20 additions & 0 deletions tests/channel-post.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
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