From b6ede5cd6aebb7d9039c9bb294fe895b6d72c1c0 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 15 Jul 2026 18:44:47 +0200 Subject: [PATCH 01/26] fix(rbac): add app preview API key role --- messages/en.json | 6 + playwright/e2e/apikeys.spec.ts | 74 +++++++++ src/components/connect/ConnectAppPicker.vue | 1 + .../ChannelPermissionOverridesPanel.vue | 5 + src/pages/ApiKeys.vue | 155 +++++++++++------- src/services/apikeys.ts | 10 +- src/stores/organization.ts | 1 + .../_backend/private/role_bindings.ts | 10 +- .../functions/_backend/public/apikey/post.ts | 25 +-- .../functions/_backend/public/apikey/put.ts | 30 +--- .../functions/_backend/public/apikey/scope.ts | 7 +- .../20260715155032_add_app_preview_role.sql | 49 ++++++ tests/apikey-scope.unit.test.ts | 71 ++++++++ tests/apikeys-service.unit.test.ts | 58 +++++++ tests/apikeys.test.ts | 46 +++++- tests/cli-preview-lifecycle.test.ts | 140 ++++++++++++++++ tests/frontend-channel-rbac-scope.test.ts | 6 + tests/rbac-permissions.test.ts | 105 ++++++++++++ 18 files changed, 666 insertions(+), 133 deletions(-) create mode 100644 supabase/migrations/20260715155032_add_app_preview_role.sql create mode 100644 tests/apikey-scope.unit.test.ts create mode 100644 tests/apikeys-service.unit.test.ts create mode 100644 tests/cli-preview-lifecycle.test.ts diff --git a/messages/en.json b/messages/en.json index 5fc40500e2..3fc3813e3f 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. This key cannot access other apps in the selected organizations.", + "api-key-selected-apps-only-description": "The selected organizations only filter the app list. This key receives access only to the selected apps, not organization-wide access.", + "api-key-selected-apps-only-org-filter": "Organizations to filter apps", + "api-key-selected-apps-only-org-filter-description": "These organizations only determine which apps you can select; they do not grant organization-wide access.", "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", diff --git a/playwright/e2e/apikeys.spec.ts b/playwright/e2e/apikeys.spec.ts index cb6d18261f..1503db7abc 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,74 @@ 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(3)).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/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..550a41bc2d 100644 --- a/src/components/permissions/ChannelPermissionOverridesPanel.vue +++ b/src/components/permissions/ChannelPermissionOverridesPanel.vue @@ -63,6 +63,11 @@ 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,7 @@ 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) @@ -452,9 +458,9 @@ 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) }) return orgIds @@ -679,11 +685,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 +741,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 +975,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-role')) + 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 +999,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 +1081,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 +1109,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 +1128,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 +1157,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 +1170,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 +1198,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 +1215,8 @@ async function updateApiKey() { if (!key) return false - 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')) + if (!validateApiKeyScope()) 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 +1587,11 @@ watch(orgRoleOptions, () => { ensureSelectedOrgRoleAllowed() }) +watch(appOnlyScope, (isAppOnly) => { + if (isAppOnly) + allowOrgCreation.value = false +}) + watch(canEnableOrgCreation, (canEnable) => { if (!canEnable) allowOrgCreation.value = false @@ -1583,9 +1605,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 +1770,32 @@ getKeys() - +
+ +

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

+

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