From d349e892f4ccf2764d1a4461b3cff7759c1ea9a2 Mon Sep 17 00:00:00 2001 From: Jingyi Date: Wed, 24 Jun 2026 18:18:40 -0700 Subject: [PATCH] fix: respect legacy plugin permissions without RBAC (#37903) --- ...use-workspace-plugin-install-permission.ts | 5 +- .../__tests__/use-reference-setting.spec.ts | 55 +++++++++++++++++-- .../plugin-page/use-reference-setting.ts | 42 ++++++++++---- .../components/plugins/plugin-permissions.ts | 27 +++++++++ .../marketplace/__tests__/index.spec.tsx | 33 +++++++++++ .../components/tools/marketplace/index.tsx | 4 +- 6 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 web/app/components/plugins/plugin-permissions.ts diff --git a/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts b/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts index 59e7c1ad7f8..de1a2d09870 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts +++ b/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts @@ -5,7 +5,10 @@ import { hasPermission } from '@/utils/permission' const pluginReadAndUpdatePermissionKeys = ['plugin.install', 'plugin.manage'] const useWorkspacePluginInstallPermission = () => { - const { langGeniusVersionInfo, workspacePermissionKeys } = useAppContext() + const { + langGeniusVersionInfo, + workspacePermissionKeys, + } = useAppContext() const canInstallPlugin = useMemo(() => { return hasPermission(workspacePermissionKeys, 'plugin.install') diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index aec8390ec15..dc81db854a7 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -164,7 +164,7 @@ describe('useReferenceSetting Hook', () => { expect(result.current.canDebugger).toBe(false) }) - it('should use plugin keys even when legacy admin permission is configured', () => { + it('should use plugin keys even when legacy admin permission is configured and RBAC is enabled', () => { vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, @@ -179,11 +179,40 @@ describe('useReferenceSetting Hook', () => { }, } as ReturnType) - const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool)) + const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), { + systemFeatures: { rbac_enabled: true }, + }) expect(result.current.canManagement).toBe(true) expect(result.current.canDebugger).toBe(true) }) + + it('should apply legacy noOne plugin permissions when RBAC is disabled', () => { + vi.mocked(useAppContext).mockReturnValue({ + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' }, + workspacePermissionKeys: ['plugin.install', 'plugin.manage', 'plugin.debug'], + } as ReturnType) + vi.mocked(usePluginPermissionSettings).mockReturnValue({ + data: { + install_permission: PermissionType.noOne, + debug_permission: PermissionType.noOne, + }, + } as ReturnType) + + const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), { + systemFeatures: { rbac_enabled: false }, + }) + + expect(result.current.canInstallPlugin).toBe(false) + expect(result.current.canManagement).toBe(false) + expect(result.current.canUpdatePlugin).toBe(false) + expect(result.current.canViewInstalledPlugins).toBe(true) + expect(result.current.canManagePlugin).toBe(false) + expect(result.current.canDebugPlugin).toBe(false) + expect(result.current.canDebugger).toBe(false) + }) }) describe('canSetPermissions', () => { @@ -446,15 +475,33 @@ describe('useCanInstallPluginFromMarketplace Hook', () => { expect(result.current.canInstallPluginFromMarketplace).toBe(false) }) - it('should not fetch legacy plugin permissions or category auto-upgrade settings', () => { + it('should fetch legacy plugin permissions but not category auto-upgrade settings', () => { renderHook(() => useCanInstallPluginFromMarketplace(), { systemFeatures: { enable_marketplace: true }, }) - expect(usePluginPermissionSettings).not.toHaveBeenCalled() + expect(usePluginPermissionSettings).toHaveBeenCalled() expect(usePluginAutoUpgradeSettings).not.toHaveBeenCalled() }) + it('should return false when legacy install permission is noOne and RBAC is disabled', () => { + vi.mocked(usePluginPermissionSettings).mockReturnValue({ + data: { + install_permission: PermissionType.noOne, + debug_permission: PermissionType.everyone, + }, + } as ReturnType) + + const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), { + systemFeatures: { + enable_marketplace: true, + rbac_enabled: false, + }, + }) + + expect(result.current.canInstallPluginFromMarketplace).toBe(false) + }) + it('should use plugin.install when marketplace and RBAC are enabled', () => { vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: false, diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.ts b/web/app/components/plugins/plugin-page/use-reference-setting.ts index 0b779ccdd96..0d9d6900b58 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.ts +++ b/web/app/components/plugins/plugin-page/use-reference-setting.ts @@ -7,6 +7,7 @@ import { useAppContext } from '@/context/app-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins' import { hasPermission } from '@/utils/permission' +import { hasLegacyPluginPermissionAccess } from '../plugin-permissions' const pluginReadAndUpdatePermissionKeys = ['plugin.install', 'plugin.manage'] @@ -26,7 +27,11 @@ const useCanSetPluginSettings = () => { export const usePluginSettingsAccess = () => { const { t } = useTranslation() - const { workspacePermissionKeys, langGeniusVersionInfo } = useAppContext() + const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys, langGeniusVersionInfo } = useAppContext() + const { data: rbacEnabled } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: s => s.rbac_enabled, + }) const { canSetPermissions, canSetPluginPreferences } = useCanSetPluginSettings() const permissionQuery = usePluginPermissionSettings() const { data: permissions } = permissionQuery @@ -35,11 +40,22 @@ export const usePluginSettingsAccess = () => { toast.success(t('api.actionSuccess', { ns: 'common' })) }, }) - const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') - const canUpdatePlugin = hasPermission(workspacePermissionKeys, pluginReadAndUpdatePermissionKeys) + const isAdminOrOwner = isCurrentWorkspaceManager || isCurrentWorkspaceOwner + const legacyCanInstallPlugin = hasLegacyPluginPermissionAccess({ + isAdminOrOwner, + permission: permissions?.install_permission, + rbacEnabled, + }) + const legacyCanDebugPlugin = hasLegacyPluginPermissionAccess({ + isAdminOrOwner, + permission: permissions?.debug_permission, + rbacEnabled, + }) + const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin + const canUpdatePlugin = hasPermission(workspacePermissionKeys, pluginReadAndUpdatePermissionKeys) && legacyCanInstallPlugin const canViewInstalledPlugins = hasPermission(workspacePermissionKeys, pluginReadAndUpdatePermissionKeys) - const canManagePlugin = hasPermission(workspacePermissionKeys, 'plugin.manage') - const canDebugPlugin = hasPermission(workspacePermissionKeys, 'plugin.debug') + const canManagePlugin = hasPermission(workspacePermissionKeys, 'plugin.manage') && legacyCanInstallPlugin + const canDebugPlugin = hasPermission(workspacePermissionKeys, 'plugin.debug') && legacyCanDebugPlugin return { permission: permissions, @@ -102,12 +118,18 @@ const useReferenceSetting = (category: PluginCategoryEnum) => { } export const useCanInstallPluginFromMarketplace = () => { - const { data: marketplaceAccess } = useSuspenseQuery({ - ...systemFeaturesQueryOptions(), - select: s => s.enable_marketplace, + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const marketplaceAccess = systemFeatures.enable_marketplace + const rbacEnabled = systemFeatures.rbac_enabled + const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys } = useAppContext() + const permissionQuery = usePluginPermissionSettings() + const { data: permissions } = permissionQuery + const legacyCanInstallPlugin = hasLegacyPluginPermissionAccess({ + isAdminOrOwner: isCurrentWorkspaceManager || isCurrentWorkspaceOwner, + permission: permissions?.install_permission, + rbacEnabled, }) - const { workspacePermissionKeys } = useAppContext() - const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') + const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin const canInstallPluginFromMarketplace = useMemo(() => { return Boolean(marketplaceAccess && canInstallPlugin) diff --git a/web/app/components/plugins/plugin-permissions.ts b/web/app/components/plugins/plugin-permissions.ts new file mode 100644 index 00000000000..4548e73df26 --- /dev/null +++ b/web/app/components/plugins/plugin-permissions.ts @@ -0,0 +1,27 @@ +import { PermissionType } from './types' + +type LegacyPluginPermissionAccessOptions = { + isAdminOrOwner: boolean + permission?: PermissionType + rbacEnabled?: boolean +} + +export const hasLegacyPluginPermissionAccess = ({ + isAdminOrOwner, + permission, + rbacEnabled, +}: LegacyPluginPermissionAccessOptions) => { + if (rbacEnabled !== false) + return true + + if (!permission) + return false + + if (permission === PermissionType.everyone) + return true + + if (permission === PermissionType.admin) + return isAdminOrOwner + + return false +} diff --git a/web/app/components/tools/marketplace/__tests__/index.spec.tsx b/web/app/components/tools/marketplace/__tests__/index.spec.tsx index 8c71da63680..dea3e52dc3d 100644 --- a/web/app/components/tools/marketplace/__tests__/index.spec.tsx +++ b/web/app/components/tools/marketplace/__tests__/index.spec.tsx @@ -15,6 +15,10 @@ const { mockRouterPush } = vi.hoisted(() => ({ mockRouterPush: vi.fn(), })) +const { mockCanInstallPlugin } = vi.hoisted(() => ({ + mockCanInstallPlugin: vi.fn(() => true), +})) + const listRenderSpy = vi.fn() vi.mock('@/app/components/plugins/marketplace/list', () => ({ default: (props: { @@ -30,6 +34,12 @@ vi.mock('@/app/components/plugins/marketplace/list', () => ({ }, })) +vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ + usePluginSettingsAccess: () => ({ + canInstallPlugin: mockCanInstallPlugin(), + }), +})) + const mockUseMarketplaceCollectionsAndPlugins = vi.fn() const mockUseMarketplacePlugins = vi.fn() vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ @@ -108,6 +118,7 @@ const createMarketplaceContext = (overrides: Partial { beforeEach(() => { vi.clearAllMocks() + mockCanInstallPlugin.mockReturnValue(true) }) // Rendering the marketplace panel based on loading and visibility state. @@ -154,6 +165,28 @@ describe('Marketplace', () => { showInstallButton: true, })) }) + + it('should hide install actions when plugin install permission is missing', () => { + mockCanInstallPlugin.mockReturnValue(false) + const marketplaceContext = createMarketplaceContext({ + isLoading: false, + plugins: [createPlugin()], + }) + + render( + , + ) + + expect(listRenderSpy).toHaveBeenCalledWith(expect.objectContaining({ + showInstallButton: false, + })) + }) }) // Prop-driven UI output such as links and action triggers. diff --git a/web/app/components/tools/marketplace/index.tsx b/web/app/components/tools/marketplace/index.tsx index aadd71b649b..f06b2cdceb1 100644 --- a/web/app/components/tools/marketplace/index.tsx +++ b/web/app/components/tools/marketplace/index.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' import Loading from '@/app/components/base/loading' import List from '@/app/components/plugins/marketplace/list' +import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting' import { useRouter } from '@/next/navigation' import { getMarketplaceUrl } from '@/utils/var' import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset' @@ -35,6 +36,7 @@ const Marketplace = ({ const { t } = useTranslation() const { theme } = useTheme() const router = useRouter() + const { canInstallPlugin } = usePluginSettingsAccess() const { isLoading, marketplaceCollections, @@ -127,7 +129,7 @@ const Marketplace = ({ marketplaceCollections={marketplaceCollections || []} marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}} plugins={plugins} - showInstallButton + showInstallButton={canInstallPlugin} cardContainerClassName={cardContainerClassName} onCollectionMoreClick={handleCollectionMoreClick} />