diff --git a/.github/pr-screenshots/app-preview-api-key.webp b/.github/pr-screenshots/app-preview-api-key.webp new file mode 100644 index 0000000000..384a024331 Binary files /dev/null and b/.github/pr-screenshots/app-preview-api-key.webp differ diff --git a/.github/workflows/visual-diff.yml b/.github/workflows/visual-diff.yml index da2f093df9..27849b5bee 100644 --- a/.github/workflows/visual-diff.yml +++ b/.github/workflows/visual-diff.yml @@ -86,7 +86,9 @@ jobs: uses: actions/upload-artifact@v6 with: name: visual-diff-report-${{ github.event.pull_request.head.sha }} - path: .context/visual-diff/report/ + path: | + .context/visual-diff/report/ + .context/visual-diff/logs/ if-no-files-found: ignore retention-days: 14 diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index 42b0d85afd..b9781612e2 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -789,7 +789,12 @@ async function deleteLinkedBundleOnUpload(supabase: SupabaseType, version: Linke log.info(`Linked bundle ${version.name} deleted`) } -async function findUploadTargetChannel(supabase: SupabaseType, appid: string, channel: string): Promise { +async function findUploadTargetChannel( + supabase: SupabaseType, + appid: string, + channel: string, + failOnError = true, +): Promise { const { data, error } = await supabase .from('channels') .select('id, public, version, rollout_version') @@ -797,7 +802,7 @@ async function findUploadTargetChannel(supabase: SupabaseType, appid: string, ch .eq('name', channel) .maybeSingle() - if (error) + if (error && failOnError) uploadFail(`Cannot check channel ${channel}: ${formatError(error)}`) return data @@ -845,10 +850,6 @@ async function preflightRequiredChannelAssignments( if (!canCreateChannel) uploadFail('Cannot create target channel because this API key lacks app.create_channel') - const canPromoteCreatedChannel = await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid }) - if (!canPromoteCreatedChannel) - uploadFail('Cannot create target channel with a bundle because this API key lacks channel.promote_bundle') - uploadTargetChannels.set(channel, null) } @@ -914,12 +915,6 @@ async function setVersionInChannel( requireChannelAssignment = false, selfAssign?: boolean, ): Promise { - const { data: versionId } = await supabase - .rpc('get_app_versions', { apikey, name_version: bundle, appid }) - .single() - - if (!versionId) - uploadFail('Cannot get version id, cannot set channel') const canPromoteTargetChannel = targetChannel !== null && await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id }) @@ -934,18 +929,19 @@ async function setVersionInChannel( return false } - if (targetChannel && canPromoteTargetChannel && selfAssign) { - const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id }) - if (!canUpdateChannelSettings) { - log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings') - return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl) + if (targetChannel && canPromoteTargetChannel) { + const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle) + if (selfAssign) { + const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id }) + if (!canUpdateChannelSettings) { + log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings') + return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl) + } } - } - if (targetChannel && canPromoteTargetChannel && !selfAssign) - return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl) + if (!selfAssign) + return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl) - if ((targetChannel && canPromoteTargetChannel) || canCreateChannel) { const { error: dbError3, data } = await updateOrCreateChannel(supabase, { name: channel, app_id: appid, @@ -967,6 +963,52 @@ async function setVersionInChannel( return true } + // The channel endpoint creates the preview channel, receives its scoped + // lifecycle binding, and promotes this bundle in one transaction. + if (canCreateChannel) { + const { error, data } = await supabase.functions.invoke('channel', { + method: 'POST', + body: JSON.stringify({ + app_id: appid, + channel, + version: bundle, + ...(selfAssign ? { allow_device_self_set: true } : {}), + }), + }) + if (error) { + uploadFail(`Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) + } + + const createdChannel = data as { id?: unknown, public?: unknown } | null + let createdChannelId = Number(createdChannel?.id) + let createdChannelPublic = createdChannel?.public === true + if (!Number.isSafeInteger(createdChannelId) || typeof createdChannel?.public !== 'boolean') { + // Older channel endpoints do not return metadata, so only their fallback reads the new channel. + const fallbackChannel = await findUploadTargetChannel(supabase, appid, channel, false) + const fallbackChannelId = Number(fallbackChannel?.id) + if (!Number.isSafeInteger(fallbackChannelId)) { + if (!Number.isSafeInteger(createdChannelId)) { + log.info('Your update is now available 🎉') + return true + } + } + else { + createdChannelId = fallbackChannelId + createdChannelPublic = fallbackChannel?.public === true + } + } + + const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${createdChannelId}` + if (createdChannelPublic) + log.info('Your update is now available in your public channel 🎉') + else + log.info(`Link device to this bundle to try it: ${bundleUrl}`) + + if (displayBundleUrl) + log.info(`Bundle url: ${bundleUrl}`) + return true + } + const message = 'Cannot create target channel because this API key lacks app.create_channel' if (requireChannelAssignment) uploadFail(message) diff --git a/cli/src/channel/delete.ts b/cli/src/channel/delete.ts index 27655e1749..3041d7ad66 100644 --- a/cli/src/channel/delete.ts +++ b/cli/src/channel/delete.ts @@ -9,6 +9,7 @@ import { formatError, getAppId, getConfig, + hasCliPermission, sendEvent, } from '../utils' @@ -48,10 +49,10 @@ export async function deleteChannelInternal(channelId: string, appId: string, op throw new Error(`Channel ${channelId} not found`) } - await checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'channel.delete', silent, true, channel.id) - if (options.deleteBundle) - await checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'bundle.delete', silent, true) + const canDeleteBundle = options.deleteBundle + ? await hasCliPermission(supabase, options.apikey, 'bundle.delete', { appId }) + : false const orgId = channel.owner_org if (!orgId) { @@ -60,25 +61,46 @@ export async function deleteChannelInternal(channelId: string, appId: string, op throw new Error(`Channel ${channelId} has no owner organization`) } - if (options.deleteBundle && !silent) - log.info(`Deleting bundle ${appId}#${channelId} from Capgo`) - - if (options.deleteBundle) { - const bundle = await findBundleIdByChannelName(supabase, appId, channelId) - if (bundle?.name && !silent) - log.info(`Deleting bundle ${bundle.name} from Capgo`) - if (bundle?.name) - await deleteAppVersion(supabase, appId, bundle.name) + if (options.deleteBundle && !canDeleteBundle) { + if (!silent) + log.info(`Deleting preview channel ${appId}#${channelId} and its bundle from Capgo`) + + const { error } = await supabase.functions.invoke('channel', { + method: 'DELETE', + body: JSON.stringify({ + app_id: appId, + channel: channelId, + delete_bundle: true, + }), + }) + if (error) { + const message = `Cannot delete preview channel and bundle: ${formatError(error)}` + if (!silent) + log.error(message) + throw new Error(message) + } } + else { + if (options.deleteBundle && !silent) + log.info(`Deleting bundle ${appId}#${channelId} from Capgo`) + + if (options.deleteBundle) { + const bundle = await findBundleIdByChannelName(supabase, appId, channelId) + if (bundle?.name && !silent) + log.info(`Deleting bundle ${bundle.name} from Capgo`) + if (bundle?.name) + await deleteAppVersion(supabase, appId, bundle.name) + } - if (!silent) - log.info(`Deleting channel ${appId}#${channelId} from Capgo`) - - const deleteStatus = await delChannel(supabase, channelId, appId) - if (deleteStatus.error) { if (!silent) - log.error(`Cannot delete Channel 🙀 ${formatError(deleteStatus.error)}`) - throw new Error(`Cannot delete channel: ${formatError(deleteStatus.error)}`) + log.info(`Deleting channel ${appId}#${channelId} from Capgo`) + + const deleteStatus = await delChannel(supabase, channelId, appId) + if (deleteStatus.error) { + if (!silent) + log.error(`Cannot delete Channel 🙀 ${formatError(deleteStatus.error)}`) + throw new Error(`Cannot delete channel: ${formatError(deleteStatus.error)}`) + } } await sendEvent(options.apikey, { diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 2bed4fdacc..a05c6eb07f 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -167,6 +167,7 @@ export type Database = { cli_version: string | null comment: string | null created_at: string | null + created_by_apikey_rbac_id: string | null deleted: boolean deleted_at: string | null external_url: string | null @@ -193,6 +194,7 @@ export type Database = { cli_version?: string | null comment?: string | null created_at?: string | null + created_by_apikey_rbac_id?: string | null deleted?: boolean deleted_at?: string | null external_url?: string | null @@ -219,6 +221,7 @@ export type Database = { cli_version?: string | null comment?: string | null created_at?: string | null + created_by_apikey_rbac_id?: string | null deleted?: boolean deleted_at?: string | null external_url?: string | null @@ -2323,6 +2326,7 @@ export type Database = { id: string is_direct: boolean org_id: string | null + parent_binding_id: string | null principal_id: string principal_type: string reason: string | null @@ -2339,6 +2343,7 @@ export type Database = { id?: string is_direct?: boolean org_id?: string | null + parent_binding_id?: string | null principal_id: string principal_type: string reason?: string | null @@ -2355,6 +2360,7 @@ export type Database = { id?: string is_direct?: boolean org_id?: string | null + parent_binding_id?: string | null principal_id?: string principal_type?: string reason?: string | null @@ -2390,6 +2396,13 @@ export type Database = { referencedRelation: "orgs" referencedColumns: ["id"] }, + { + foreignKeyName: "role_bindings_parent_binding_id_fkey" + columns: ["parent_binding_id"] + isOneToOne: false + referencedRelation: "role_bindings" + referencedColumns: ["id"] + }, { foreignKeyName: "role_bindings_role_id_fkey" columns: ["role_id"] @@ -3087,10 +3100,12 @@ export type Database = { country: string | null created_at: string | null created_via_invite: boolean + discord_username: string | null email: string email_preferences: Json enable_notifications: boolean first_name: string | null + github_username: string | null id: string image_url: string | null last_name: string | null @@ -3102,10 +3117,12 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null + github_username?: string | null id: string image_url?: string | null last_name?: string | null @@ -3117,10 +3134,12 @@ export type Database = { country?: string | null created_at?: string | null created_via_invite?: boolean + discord_username?: string | null email?: string email_preferences?: Json enable_notifications?: boolean first_name?: string | null + github_username?: string | null id?: string image_url?: string | null last_name?: string | null diff --git a/messages/en.json b/messages/en.json index 5fc40500e2..815396fee0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -330,6 +330,11 @@ "api-key-not-found": "API key not found", "api-key-policy": "API Key Policy", "api-key-policy-description": "Configure policies for API keys used with this organization.", + "api-key-selected-apps-only": "Limit this key to selected apps", + "api-key-selected-apps-only-app-access": "Choose one role for each app. Each app binding is limited to that app and its owning organization.", + "api-key-selected-apps-only-description": "Each selected app is bound to its owning organization. This key can access only the selected apps and does not receive organization-wide permissions.", + "api-key-selected-apps-only-org-filter": "Organizations for selected apps", + "api-key-selected-apps-only-org-filter-description": "Each selected app stays bound to its owning organization. Choosing an organization does not grant organization-wide permissions.", "api-key-updated": "API key updated", "api-key-policy-updated": "API key policy updated successfully", "api-keys": "API Keys", @@ -2029,6 +2034,7 @@ "role": "Role", "role-per-org": "Role per organization", "role-app-admin": "App Admin", + "role-app-preview": "App Preview", "role-app-developer": "App Developer", "role-app-notifications": "App Notifications", "role-app-reader": "App Reader", @@ -2105,6 +2111,7 @@ "select-user-perms": "Select user's permissions", "select-user-perms-expanded": "Select which permission should the invited user have", "select-user-role": "Select a role", + "select-at-least-one-app": "Select at least one app", "select-at-least-one-role": "Select at least one org role or app role", "select-role-for-each-app": "Select a role for each app", "select-user-role-expanded": "Choose the RBAC role to assign. Legacy roles remain visible during migration.", diff --git a/playwright/e2e/apikeys.spec.ts b/playwright/e2e/apikeys.spec.ts index cb6d18261f..0ae9997e86 100644 --- a/playwright/e2e/apikeys.spec.ts +++ b/playwright/e2e/apikeys.spec.ts @@ -9,6 +9,12 @@ const INHERITED_ORG_ID = randomUUID() const INHERITED_APP_ID = `com.apikeys.e2e.${randomUUID().replaceAll('-', '').slice(0, 12)}` const INHERITED_CUSTOMER_ID = `cus_apikeys_e2e_${randomUUID().replaceAll('-', '').slice(0, 12)}` +interface RoleBindingWithRole { + scope_type: string + app_id: string | null + roles: { name: string } | { name: string }[] | null +} + function uniqueKeyName(prefix: string) { return `${prefix} ${Date.now().toString(36)}` } @@ -250,6 +256,79 @@ test.describe('API Key Management', () => { await page.getByRole('button', { name: 'Cancel' }).click() }) + test('should create and preserve an app-only preview key', async ({ page }) => { + const keyName = uniqueKeyName('App Preview') + const dialog = await openCreateKeyDialog(page) + await selectOnlyOrgForCreation(page, dialog, INHERITED_ORG_ID) + + const appOnlyScope = dialog.locator('[data-test="create-key-app-only-scope"]') + await appOnlyScope.check() + await expect(appOnlyScope).toBeChecked() + await expect(dialog.locator('[data-test^="create-key-org-role-"]')).toHaveCount(0) + + await fillApiKeyName(page, dialog, keyName) + await dialog.locator('[data-test="create-key-add-app"]').click() + const seededAppOption = dialog.locator('label', { hasText: INHERITED_APP_ID }).first() + await expect(seededAppOption).toBeVisible() + await seededAppOption.locator('[data-test="create-key-app-checkbox"]').check() + + const selectedApp = dialog.locator('[data-test="create-key-selected-app"]', { hasText: 'Seeded App' }).first() + await expect(selectedApp).toBeVisible() + const roleSelect = selectedApp.locator('[data-test="create-key-app-role-select"]') + await roleSelect.selectOption('app_preview') + await page.mouse.click(5, 5) + + await page.getByRole('button', { name: 'Create' }).click() + await expect(page.getByText('Added new API key successfully').first()).toBeVisible() + const keyRow = await expectApiKeyRow(page, keyName) + await expect(keyRow).toContainText('App Preview') + await expect(keyRow.locator('td').nth(1)).toContainText('-') + + await page.reload() + const reloadedKeyRow = await expectApiKeyRow(page, keyName) + await expect(reloadedKeyRow).toContainText('App Preview') + await expect(reloadedKeyRow.locator('td').nth(1)).toContainText('-') + + const supabase = getSupabaseClient() + const { data: key, error: keyError } = await supabase + .from('apikeys') + .select('id, rbac_id') + .eq('name', keyName) + .single() + expect(keyError).toBeNull() + expect(key?.rbac_id).toBeTruthy() + + const assertAppOnlyBindings = async () => { + const { data: bindings, error: bindingsError } = await supabase + .from('role_bindings') + .select('scope_type, app_id, roles(name)') + .eq('principal_type', 'apikey') + .eq('principal_id', key!.rbac_id) + expect(bindingsError).toBeNull() + + const normalized = ((bindings ?? []) as unknown as RoleBindingWithRole[]).map(binding => ({ + scope_type: binding.scope_type, + app_id: binding.app_id, + role_name: Array.isArray(binding.roles) ? binding.roles[0]?.name : binding.roles?.name, + })) + expect(normalized).toEqual([ + expect.objectContaining({ scope_type: 'app', role_name: 'app_preview' }), + ]) + } + + await assertAppOnlyBindings() + + await keyRow.locator('[data-test^="edit-key-"]').click() + const editDialog = page.locator('#dialog-v2-content') + await expect(editDialog.locator('[data-test="create-key-app-only-scope"]')).toBeChecked() + await expect(editDialog.locator('[data-test^="create-key-org-role-"]')).toHaveCount(0) + await expect(editDialog.locator('[data-test="create-key-app-role-select"]')).toHaveValue('app_preview') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.locator('[data-test="toast"]')).toContainText('API key updated') + + await assertAppOnlyBindings() + }) + test('should edit API key rights', async ({ page }) => { const keyName = `Playwright Edit Rights ${Date.now()}` diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 8f5f5e23e5..018ff6372c 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -1,8 +1,12 @@ +import type { Page } from '@playwright/test' + export interface VisualDiffRoute { slug: string path: string /** When true, logs in as test@capgo.app before visiting the route. */ auth?: boolean + /** Optional deterministic UI setup before the screenshot is captured. */ + prepare?: (page: Page) => Promise } /** @@ -18,6 +22,17 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'devices', path: '/app/com.demo.app/devices', auth: true }, { slug: 'observe', path: '/app/com.demo.app/observe', auth: true }, { slug: 'observe-plugins', path: '/app/com.demo.app/observe/plugins', auth: true }, + { + slug: 'api-keys-app-preview', + path: '/apikeys', + auth: true, + prepare: async (page) => { + await page.locator('[data-test="create-key"]').click() + const appOnlyScope = page.locator('[data-test="create-key-app-only-scope"]') + if (await appOnlyScope.count()) + await appOnlyScope.check() + }, + }, ] export const visualDiffViewport = { diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index ca56386632..4857f5c5bb 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -220,6 +220,16 @@ "table": "app_versions", "type": "timestamp with time zone" }, + { + "default": null, + "generated": "", + "identity": "", + "name": "created_by_apikey_rbac_id", + "notNull": false, + "position": 23, + "table": "app_versions", + "type": "uuid" + }, { "default": "now()", "generated": "", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index 6789ce45ab..94e69f1dc1 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -136,7 +136,8 @@ CREATE TABLE public.app_versions ( manifest_count integer DEFAULT 0 NOT NULL, key_id character varying(20), cli_version character varying, - deleted_at timestamp with time zone + deleted_at timestamp with time zone, + created_by_apikey_rbac_id uuid ) WITH (autovacuum_vacuum_scale_factor='0.05', autovacuum_analyze_scale_factor='0.02'); diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts index 1c58f5623e..f7324811da 100644 --- a/scripts/visual-diff.ts +++ b/scripts/visual-diff.ts @@ -70,6 +70,8 @@ const stripePort = getStripeEmulatorPort(process.env) const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(process.env) const managedProcesses: ManagedProcess[] = [] const isWindows = process.platform === 'win32' +const httpReadyTimeoutMs = 5_000 +const visualDiffActionTimeoutMs = 60_000 function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) @@ -173,13 +175,27 @@ function diffDir() { } async function isHttpReady(url: string, options: { requireSuccess?: boolean } = {}): Promise { + const controller = new AbortController() + let timeout: ReturnType | undefined try { - const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }) + const response = await Promise.race([ + fetch(url, { signal: controller.signal }), + new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort() + reject(new Error(`Timed out waiting for ${url}`)) + }, httpReadyTimeoutMs) + }), + ]) return options.requireSuccess ? response.ok : response.status < 500 } catch { return false } + finally { + if (timeout) + clearTimeout(timeout) + } } function isTcpReady(port: number, host = '127.0.0.1'): Promise { @@ -476,30 +492,42 @@ async function captureScreenshots( const targetDir = phaseDir(phase) mkdirSync(targetDir, { recursive: true }) + console.log(`[visual-diff] preparing ${phase} capture stack`) const { supabaseUrl, supabaseAnon, apiDomain } = await ensureBackendStack({ force: options.forceBackend }) await prepareScreenshotData(supabaseUrl, supabaseAnon) await ensureFrontendStack(supabaseUrl, supabaseAnon, apiDomain, { force: options.forceFrontend }) + console.log(`[visual-diff] ${phase} frontend ready`) - const browser = await chromium.launch({ headless: true }) + console.log(`[visual-diff] launching browser for ${phase}`) + const browser = await chromium.launch({ headless: true, timeout: visualDiffActionTimeoutMs }) try { + console.log(`[visual-diff] creating browser context for ${phase}`) const context = await browser.newContext({ baseURL: frontendBaseUrl, viewport: visualDiffViewport, deviceScaleFactor: 1, }) + console.log(`[visual-diff] creating page for ${phase}`) const page = await context.newPage() + page.setDefaultTimeout(visualDiffActionTimeoutMs) + page.setDefaultNavigationTimeout(visualDiffActionTimeoutMs) let authenticated = false for (const route of routes) { + console.log(`[visual-diff] capturing ${phase} ${route.slug}`) if (route.auth && !authenticated) { await login(page) authenticated = true } - await page.goto(route.path, { waitUntil: 'domcontentloaded' }) + await page.goto(route.path, { waitUntil: 'domcontentloaded', timeout: visualDiffActionTimeoutMs }) await settlePage(page) + if (route.prepare) { + await route.prepare(page) + await settlePage(page) + } const outputPath = resolve(targetDir, `${route.slug}.png`) - await page.screenshot({ path: outputPath, fullPage: false }) + await page.screenshot({ path: outputPath, fullPage: false, timeout: visualDiffActionTimeoutMs }) console.log(`[visual-diff] captured ${phase} ${route.slug} -> ${outputPath}`) } diff --git a/src/components/connect/ConnectAppPicker.vue b/src/components/connect/ConnectAppPicker.vue index 4302fb08be..b44846a490 100644 --- a/src/components/connect/ConnectAppPicker.vue +++ b/src/components/connect/ConnectAppPicker.vue @@ -35,6 +35,7 @@ const failedIcons = ref>(new Set()) // App-level roles the key can grant per app (app_admin is the default / recommended). const APP_ROLES = [ { value: 'app_admin', i18n: 'role-app-admin' }, + { value: 'app_preview', i18n: 'role-app-preview' }, { value: 'app_developer', i18n: 'role-app-developer' }, { value: 'app_uploader', i18n: 'role-app-uploader' }, { value: 'app_reader', i18n: 'role-app-reader' }, diff --git a/src/components/permissions/ChannelPermissionOverridesPanel.vue b/src/components/permissions/ChannelPermissionOverridesPanel.vue index 9bc755e585..020e220003 100644 --- a/src/components/permissions/ChannelPermissionOverridesPanel.vue +++ b/src/components/permissions/ChannelPermissionOverridesPanel.vue @@ -63,6 +63,16 @@ const roleDefaultChannelPermissions: Record(null) const selectedOrgRole = ref('org_member') const allowOrgCreation = ref(false) const hideOrgCreationPermission = isNativeAppStoreContext() +const appOnlyScope = ref(false) const selectedOrgsForCreation = ref([]) const selectedOrgRolesById = ref>({}) const isHydratingApiKeyEdit = ref(false) @@ -168,22 +169,27 @@ function getRoleDisplayName(roleName: string): string { const i18nKey = getRbacRoleI18nKey(normalized) return i18nKey ? t(i18nKey) : normalized.replaceAll('_', ' ') } +const systemApiKeyOrgReaderRole = 'apikey_org_reader' // Get bindings for a specific key function getBindingsForKey(key: Database['public']['Tables']['apikeys']['Row']): RoleBindingRow[] { return allBindings.value.filter(b => b.principal_id === key.rbac_id) } -// Get the highest role for a key (by priority_rank) +// Older app-only keys can retain apikey_org_reader for compatibility; show +// their actual app role instead. function getHighestRole(key: Database['public']['Tables']['apikeys']['Row']): string | null { - const keyBindings = getBindingsForKey(key) - .filter(binding => binding.scope_type === 'org') - if (keyBindings.length === 0) - return null + const bindings = getBindingsForKey(key) + const directOrgBindings = bindings.filter(binding => + binding.scope_type === 'org' && binding.role_name !== systemApiKeyOrgReaderRole, + ) + const candidateBindings = directOrgBindings.length > 0 + ? directOrgBindings + : bindings.filter(binding => binding.scope_type === 'app') let highest: RoleBindingRow | null = null let highestRank = -1 - for (const binding of keyBindings) { + for (const binding of candidateBindings) { const role = roles.value.find(r => r.name === binding.role_name) const rank = role?.priority_rank ?? 0 if (rank > highestRank) { @@ -203,7 +209,18 @@ function getRbacAppBindingIds(key: Database['public']['Tables']['apikeys']['Row' function getDisplayOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] { const orgIds = new Set() getBindingsForKey(key).forEach((binding) => { - if (binding.org_id) + if (binding.scope_type === 'org' && binding.org_id && binding.role_name !== systemApiKeyOrgReaderRole) + orgIds.add(binding.org_id) + }) + return Array.from(orgIds) +} + +// App bindings retain their validated owning organization while remaining +// restricted to the selected app rather than receiving an organization role. +function getFilterOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] { + const orgIds = new Set(getDisplayOrgIds(key)) + getBindingsForKey(key).forEach((binding) => { + if (binding.scope_type === 'app' && binding.org_id) orgIds.add(binding.org_id) }) return Array.from(orgIds) @@ -452,9 +469,12 @@ const uniqueOrgIds = computed(() => { if (currentOrganizationId.value) orgIds.add(currentOrganizationId.value) - allBindings.value.forEach((b) => { - if (b.org_id) - orgIds.add(b.org_id) + allBindings.value.forEach((binding) => { + if (binding.scope_type === 'org' && binding.org_id && binding.role_name !== systemApiKeyOrgReaderRole) + orgIds.add(binding.org_id) + + if (binding.scope_type === 'app' && binding.org_id) + orgIds.add(binding.org_id) }) return orgIds @@ -602,7 +622,7 @@ const filteredAndSortedKeys = computed(() => { const orgFilterIds = selectedScopeFilterIds('org') if (orgFilterIds.length > 0) { result = result.filter((key) => { - const orgIds = getDisplayOrgIds(key) + const orgIds = getFilterOrgIds(key) return orgFilterIds.some(orgId => orgIds.includes(orgId)) }) } @@ -679,11 +699,10 @@ const appRoleOptions = computed(() => const rolesWithInheritedAppAccess = new Set(['org_admin', 'org_super_admin']) const rolesWithOrgCreateAccess = new Set(['org_admin', 'org_super_admin']) -const systemApiKeyOrgReaderRole = 'apikey_org_reader' const apiKeyOrgCreatePermission = 'org.create' const isEditingApiKey = computed(() => editingApiKey.value !== null) const showAppAccessInModal = computed(() => - !!selectedOrgRole.value && !rolesWithInheritedAppAccess.has(selectedOrgRole.value), + appOnlyScope.value || (!!selectedOrgRole.value && !rolesWithInheritedAppAccess.has(selectedOrgRole.value)), ) // Filtered apps based on selected orgs for creation @@ -736,7 +755,9 @@ function getOrgRoleForBinding(orgId: string) { } const canEnableOrgCreation = computed(() => - !hideOrgCreationPermission && selectedOrgsForCreation.value.some(orgId => rolesWithOrgCreateAccess.has(getOrgRoleForBinding(orgId))), + !appOnlyScope.value + && !hideOrgCreationPermission + && selectedOrgsForCreation.value.some(orgId => rolesWithOrgCreateAccess.has(getOrgRoleForBinding(orgId))), ) const selectedAppIds = computed(() => Object.keys(pendingAppBindings.value)) const selectedChannelPermissionApp = computed(() => @@ -968,17 +989,23 @@ async function loadManageableOrganizations() { manageableOrgIds.value = new Set(checks.filter((orgId): orgId is string => !!orgId)) } -async function createApiKey() { - const isHashed = createAsHashed.value - - if (selectedOrgsForCreation.value.length === 0) { - toast.error(t('alert-no-org-selected')) - return false +function validateApiKeyScope() { + if (appOnlyScope.value) { + if (selectedAppIds.value.length === 0) { + toast.error(t('select-at-least-one-app')) + return false + } } + else { + if (selectedOrgsForCreation.value.length === 0) { + toast.error(t('alert-no-org-selected')) + return false + } - if (!selectedOrgRole.value) { - toast.error(t('select-at-least-one-role')) - return false + if (!selectedOrgRole.value) { + toast.error(t('select-at-least-one-role')) + return false + } } if (hasIncompleteAppBindings()) { @@ -986,6 +1013,15 @@ async function createApiKey() { return false } + return true +} + +async function createApiKey() { + const isHashed = createAsHashed.value + + if (!validateApiKeyScope()) + return false + // Get expiration date if set let expiresAt: string | null = null if (setExpirationCheckbox.value && expirationDate.value) { @@ -1059,12 +1095,14 @@ async function showOneTimeKeyModal(plainKey: string) { function buildApiKeyBindingsFromForm(): ApiKeyBindingInput[] { const bindings: ApiKeyBindingInput[] = [] - for (const orgId of selectedOrgsForCreation.value) { - bindings.push({ - role_name: getOrgRoleForBinding(orgId), - scope_type: 'org', - org_id: orgId, - }) + if (!appOnlyScope.value) { + for (const orgId of selectedOrgsForCreation.value) { + bindings.push({ + role_name: getOrgRoleForBinding(orgId), + scope_type: 'org', + org_id: orgId, + }) + } } for (const [appId, roleName] of Object.entries(pendingAppBindings.value)) { @@ -1085,10 +1123,10 @@ function buildApiKeyBindingsFromForm(): ApiKeyBindingInput[] { } function buildApiKeyGlobalPermissionsFromForm(currentKey?: ApiKeyRow | null) { - if (allowOrgCreation.value && canEnableOrgCreation.value) + if (!appOnlyScope.value && allowOrgCreation.value && canEnableOrgCreation.value) return [apiKeyOrgCreatePermission] - if (hideOrgCreationPermission && currentKey && hasOrgCreatePermission(currentKey)) + if (!appOnlyScope.value && hideOrgCreationPermission && currentKey && hasOrgCreatePermission(currentKey)) return [apiKeyOrgCreatePermission] return [] @@ -1104,6 +1142,7 @@ async function addNewApiKey() { newApiKeyName.value = '' createAsHashed.value = false allowOrgCreation.value = false + appOnlyScope.value = false setExpirationCheckbox.value = false expirationDate.value = null selectedOrgRole.value = 'org_member' @@ -1132,6 +1171,7 @@ async function editApiKey(key: Database['public']['Tables']['apikeys']['Row']) { newApiKeyName.value = key.name || '' createAsHashed.value = isHashedKey(key) allowOrgCreation.value = hasOrgCreatePermission(key as ApiKeyRow) + appOnlyScope.value = false setExpirationCheckbox.value = !!key.expires_at expirationDate.value = key.expires_at ? new Date(key.expires_at) : null selectedOrgRolesById.value = {} @@ -1144,8 +1184,13 @@ async function editApiKey(key: Database['public']['Tables']['apikeys']['Row']) { const keyBindings = getBindingsForKey(key) const editableOrgBindings = keyBindings .filter(binding => binding.scope_type === 'org' && !!binding.org_id && binding.role_name !== systemApiKeyOrgReaderRole) - const appBindingOrgIds = keyBindings - .filter(binding => binding.scope_type === 'app' && !!binding.app_id) + const appBindings = keyBindings + .filter(binding => binding.scope_type === 'app' && !!binding.app_id && !!binding.role_name) + appOnlyScope.value = editableOrgBindings.length === 0 && appBindings.length > 0 + if (appOnlyScope.value) + allowOrgCreation.value = false + + const appBindingOrgIds = appBindings .map(binding => availableApps.value.find(app => app.id === binding.app_id)?.owner_org) .filter((orgId): orgId is string => !!orgId) @@ -1167,9 +1212,7 @@ async function editApiKey(key: Database['public']['Tables']['apikeys']['Row']) { syncSelectedOrgRolesById(selectedOrgRole.value) pendingAppBindings.value = Object.fromEntries( - keyBindings - .filter(binding => binding.scope_type === 'app' && !!binding.app_id && !!binding.role_name) - .map(binding => [binding.app_id!, binding.role_name]), + appBindings.map(binding => [binding.app_id!, binding.role_name]), ) await nextTick() @@ -1186,20 +1229,8 @@ async function updateApiKey() { if (!key) return false - if (selectedOrgsForCreation.value.length === 0) { - toast.error(t('alert-no-org-selected')) + if (!validateApiKeyScope()) return false - } - - if (!selectedOrgRole.value) { - toast.error(t('select-at-least-one-role')) - return false - } - - if (hasIncompleteAppBindings()) { - toast.error(t('select-role-for-each-app')) - return false - } const currentName = key.name || '' const trimmedName = newApiKeyName.value.trim() @@ -1570,6 +1601,11 @@ watch(orgRoleOptions, () => { ensureSelectedOrgRoleAllowed() }) +watch(appOnlyScope, (isAppOnly) => { + if (isAppOnly) + allowOrgCreation.value = false +}) + watch(canEnableOrgCreation, (canEnable) => { if (!canEnable) allowOrgCreation.value = false @@ -1583,9 +1619,8 @@ watch(selectedOrgRole, (newRole) => { if (isEditingApiKey.value) syncSelectedOrgRolesById(newRole, true) - if (rolesWithInheritedAppAccess.has(newRole)) { + if (!appOnlyScope.value && rolesWithInheritedAppAccess.has(newRole)) pendingAppBindings.value = {} - } }) displayStore.NavTitle = t('api-keys') @@ -1749,12 +1784,32 @@ getKeys() - +
+ +

- {{ t('organizations') }} + {{ t(appOnlyScope ? 'api-key-selected-apps-only-org-filter' : 'organizations') }}

+

+ {{ t('api-key-selected-apps-only-org-filter-description') }} +