From 1e5e47b88923d76054f8414c1105fbe96a065459 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:13:20 +0800 Subject: [PATCH] fix: validate plugin installation scope (#39669) --- api/core/plugin/plugin_service.py | 46 ++++--- api/services/feature_service.py | 57 ++++++-- .../test_plugin_service_installation.py | 64 +++++---- ..._service_plugin_installation_permission.py | 92 +++++++++++++ .../use-install-plugin-limit.spec.ts | 124 +++++++----------- .../hooks/use-install-plugin-limit.tsx | 77 +++++------ 6 files changed, 289 insertions(+), 171 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index e2cf702c5bd..55738631891 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import ( PreUninstallPluginRequest, ) from services.errors.plugin import PluginInstallationForbiddenError -from services.feature_service import FeatureService, PluginInstallationScope +from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope logger = logging.getLogger(__name__) _provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity]) @@ -604,22 +604,30 @@ class PluginService: return result @staticmethod - def _check_marketplace_only_permission(): + def _check_marketplace_only_permission() -> None: """ Check if the marketplace only permission is enabled """ - features = FeatureService.get_system_features() - if features.plugin_installation_permission.restrict_to_marketplace_only: + permission = PluginService._get_plugin_installation_permission() + if permission.restrict_to_marketplace_only: raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only") @staticmethod - def _check_plugin_installation_scope(plugin_verification: PluginVerification | None): + def _get_plugin_installation_permission() -> PluginInstallationPermissionModel: + """Resolve the validated policy and reject deny-all before any installation side effect.""" + permission = FeatureService.get_plugin_installation_permission() + if permission.plugin_installation_scope == PluginInstallationScope.NONE: + raise PluginInstallationForbiddenError("Installing plugins is not allowed") + return permission + + @staticmethod + def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None: """ Check the plugin installation scope """ - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() - match features.plugin_installation_permission.plugin_installation_scope: + match permission.plugin_installation_scope: case PluginInstallationScope.OFFICIAL_ONLY: if ( plugin_verification is None @@ -634,10 +642,10 @@ class PluginService: raise PluginInstallationForbiddenError( "Plugin installation is restricted to official and specific partners" ) - case PluginInstallationScope.NONE: - raise PluginInstallationForbiddenError("Installing plugins is not allowed") case PluginInstallationScope.ALL: pass + case _: + raise PluginInstallationForbiddenError("Plugin installation policy is invalid") @staticmethod def get_debugging_key(tenant_id: str) -> str: @@ -907,7 +915,7 @@ class PluginService: # check if plugin pkg is already downloaded manager = PluginInstaller() - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() try: manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier) @@ -919,7 +927,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install @@ -974,11 +982,11 @@ class PluginService: """ PluginService._check_marketplace_only_permission() manager = PluginInstaller() - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) PluginService._check_plugin_installation_scope(response.verification) @@ -996,13 +1004,13 @@ class PluginService: pkg = download_with_size_limit( f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE ) - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() manager = PluginInstaller() response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) PluginService._check_plugin_installation_scope(response.verification) @@ -1076,7 +1084,7 @@ class PluginService: if not dify_config.MARKETPLACE_ENABLED: raise ValueError("marketplace is not enabled") - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() manager = PluginInstaller() try: @@ -1086,7 +1094,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install PluginService._check_plugin_installation_scope(response.verification) @@ -1108,7 +1116,7 @@ class PluginService: # collect actual plugin_unique_identifiers actual_plugin_unique_identifiers = [] metas = [] - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() # check if already downloaded for plugin_unique_identifier in plugin_unique_identifiers: @@ -1126,7 +1134,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install PluginService._check_plugin_installation_scope(response.verification) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index d80d0344788..954d182d74e 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -1,6 +1,8 @@ +import logging +from collections.abc import Mapping from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, ValidationError from configs import dify_config from constants.dsl_version import CURRENT_APP_DSL_VERSION @@ -10,6 +12,8 @@ from enums.hosted_provider import HostedTrialProvider from services.billing_service import BillingInfo, BillingService from services.enterprise.enterprise_service import EnterpriseService +logger = logging.getLogger(__name__) + class FeatureResponseModel(BaseModel): model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=()) @@ -131,6 +135,13 @@ class PluginInstallationPermissionModel(FeatureResponseModel): restrict_to_marketplace_only: bool = False +class _EnterprisePluginInstallationPermission(BaseModel): + model_config = ConfigDict(extra="ignore") + + plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope") + restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True) + + class FeatureModel(FeatureResponseModel): billing: BillingModel = BillingModel() education: EducationModel = EducationModel() @@ -285,6 +296,14 @@ class FeatureService: """Return whether Enterprise plugin credential policies must be enforced.""" return dify_config.ENTERPRISE_ENABLED + @classmethod + def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel: + """Resolve the validated deployment-wide plugin installation policy.""" + if not dify_config.ENTERPRISE_ENABLED: + return PluginInstallationPermissionModel() + + return cls._resolve_plugin_installation_permission(EnterpriseService.get_info()) + @classmethod def get_license(cls) -> LicenseModel: """Return full license detail. Enterprise-only; requires an authenticated caller. @@ -452,6 +471,33 @@ class FeatureService: ) return license_model + @classmethod + def _resolve_plugin_installation_permission( + cls, enterprise_info: Mapping[str, object] + ) -> PluginInstallationPermissionModel: + if "PluginInstallationPermission" not in enterprise_info: + return PluginInstallationPermissionModel() + + try: + permission = _EnterprisePluginInstallationPermission.model_validate( + enterprise_info["PluginInstallationPermission"] + ) + except ValidationError as exc: + # Do not attach the exception because it may contain raw Enterprise configuration values. + logger.error( # noqa: TRY400 + "Invalid Enterprise plugin installation permission; denying all plugin installations: %s", + exc.errors(include_input=False), + ) + return PluginInstallationPermissionModel( + plugin_installation_scope=PluginInstallationScope.NONE, + restrict_to_marketplace_only=True, + ) + + return PluginInstallationPermissionModel( + plugin_installation_scope=permission.plugin_installation_scope, + restrict_to_marketplace_only=permission.restrict_to_marketplace_only, + ) + @classmethod def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel): enterprise_info = EnterpriseService.get_info() @@ -499,11 +545,4 @@ class FeatureService: status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) ) - if "PluginInstallationPermission" in enterprise_info: - plugin_installation_info = enterprise_info["PluginInstallationPermission"] - features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[ - "pluginInstallationScope" - ] - features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[ - "restrictToMarketplaceOnly" - ] + features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py index ca1a227b010..9f6cf4f36f9 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py @@ -8,6 +8,7 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup. from __future__ import annotations from collections.abc import Iterator +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,6 @@ from sqlalchemy.orm import Session from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginVerification from core.plugin.plugin_service import PluginService -from enums.deployment_edition import DeploymentEdition from models import ProviderType from models.engine import db from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider @@ -27,20 +27,16 @@ from services.errors.plugin import PluginInstallationForbiddenError from services.feature_service import ( PluginInstallationPermissionModel, PluginInstallationScope, - SystemFeatureModel, ) -def _make_features( +def _make_permission( restrict_to_marketplace: bool = False, scope: PluginInstallationScope = PluginInstallationScope.ALL, -) -> SystemFeatureModel: - return SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - plugin_installation_permission=PluginInstallationPermissionModel( - restrict_to_marketplace_only=restrict_to_marketplace, - plugin_installation_scope=scope, - ), +) -> PluginInstallationPermissionModel: + return PluginInstallationPermissionModel( + restrict_to_marketplace_only=restrict_to_marketplace, + plugin_installation_scope=scope, ) @@ -119,22 +115,31 @@ class TestFetchLatestPluginVersion: class TestCheckMarketplaceOnlyPermission: @patch("core.plugin.plugin_service.FeatureService") def test_raises_when_restricted(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True) with pytest.raises(PluginInstallationForbiddenError): PluginService._check_marketplace_only_permission() @patch("core.plugin.plugin_service.FeatureService") def test_passes_when_not_restricted(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False) PluginService._check_marketplace_only_permission() # should not raise + @patch("core.plugin.plugin_service.FeatureService") + def test_raises_when_scope_denies_all(self, mock_fs): + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) + + with pytest.raises(PluginInstallationForbiddenError, match="not allowed"): + PluginService._check_marketplace_only_permission() + class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_only_allows_langgenius(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY) + mock_fs.get_plugin_installation_permission.return_value = _make_permission( + scope=PluginInstallationScope.OFFICIAL_ONLY + ) verification = MagicMock() verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius @@ -142,14 +147,16 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_only_rejects_third_party(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY) + mock_fs.get_plugin_installation_permission.return_value = _make_permission( + scope=PluginInstallationScope.OFFICIAL_ONLY + ) with pytest.raises(PluginInstallationForbiddenError): PluginService._check_plugin_installation_scope(None) @patch("core.plugin.plugin_service.FeatureService") def test_official_and_partners_allows_partner(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features( + mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS ) verification = MagicMock() @@ -159,7 +166,7 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_and_partners_rejects_none(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features( + mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS ) @@ -168,7 +175,7 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_none_scope_always_raises(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) verification = MagicMock() verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius @@ -177,10 +184,19 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_all_scope_passes_any(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL) PluginService._check_plugin_installation_scope(None) # should not raise + @patch("core.plugin.plugin_service.FeatureService") + def test_unknown_scope_always_raises(self, mock_fs): + permission = _make_permission() + permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope") + mock_fs.get_plugin_installation_permission.return_value = permission + + with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"): + PluginService._check_plugin_installation_scope(None) + class TestGetPluginIconUrl: @patch("core.plugin.plugin_service.dify_config") @@ -248,7 +264,7 @@ class TestUpgradePluginWithMarketplace: @patch("core.plugin.plugin_service.dify_config") def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.return_value = MagicMock() installer.upgrade_plugin.return_value = MagicMock() @@ -264,7 +280,7 @@ class TestUpgradePluginWithMarketplace: @patch("core.plugin.plugin_service.dify_config") def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.side_effect = RuntimeError("not found") mock_download.return_value = b"pkg-bytes" @@ -283,7 +299,7 @@ class TestUpgradePluginWithGithub: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.upgrade_plugin.return_value = MagicMock() @@ -298,7 +314,7 @@ class TestUploadPkg: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() upload_resp = MagicMock() upload_resp.verification = None mock_installer_cls.return_value.upload_pkg.return_value = upload_resp @@ -322,7 +338,7 @@ class TestInstallFromMarketplacePkg: @patch("core.plugin.plugin_service.dify_config") def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.side_effect = RuntimeError("not found") mock_download.return_value = b"pkg" @@ -344,7 +360,7 @@ class TestInstallFromMarketplacePkg: @patch("core.plugin.plugin_service.dify_config") def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.return_value = MagicMock() decode_resp = MagicMock() diff --git a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py new file mode 100644 index 00000000000..a1ab95095f6 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py @@ -0,0 +1,92 @@ +import logging + +import pytest + +from enums.deployment_edition import DeploymentEdition +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel + + +def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + + permission = FeatureService.get_plugin_installation_permission() + + assert permission.plugin_installation_scope is PluginInstallationScope.ALL + assert permission.restrict_to_marketplace_only is False + + +def test_get_plugin_installation_permission_parses_enterprise_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod( + lambda: { + "PluginInstallationPermission": { + "pluginInstallationScope": "official_only", + "restrictToMarketplaceOnly": True, + } + } + ), + ) + + permission = FeatureService.get_plugin_installation_permission() + + assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY + assert permission.restrict_to_marketplace_only is True + + +@pytest.mark.parametrize( + "invalid_permission", + [ + { + "pluginInstallationScope": "unknown-scope", + "restrictToMarketplaceOnly": False, + }, + { + "pluginInstallationScope": "all", + "restrictToMarketplaceOnly": "false", + }, + ], + ids=["unknown_scope", "non_boolean_marketplace_restriction"], +) +def test_invalid_enterprise_policy_denies_all_plugin_installations( + caplog: pytest.LogCaptureFixture, + invalid_permission: dict[str, object], +) -> None: + with caplog.at_level(logging.ERROR, logger="services.feature_service"): + permission = FeatureService._resolve_plugin_installation_permission( + {"PluginInstallationPermission": invalid_permission} + ) + + assert permission.plugin_installation_scope is PluginInstallationScope.NONE + assert permission.restrict_to_marketplace_only is True + assert "denying all plugin installations" in caplog.text + + +def test_system_features_exposes_only_validated_plugin_installation_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod( + lambda: { + "PluginInstallationPermission": { + "pluginInstallationScope": "unknown-scope", + "restrictToMarketplaceOnly": False, + } + } + ), + ) + features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) + + FeatureService._fulfill_params_from_enterprise(features) + + assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE + assert features.plugin_installation_permission.restrict_to_marketplace_only is True diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts index c1c5418578b..2b0dd1e39b1 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts @@ -4,125 +4,101 @@ import { InstallationScope } from '@/features/system-features/constants' import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { pluginInstallLimit } from '../use-install-plugin-limit' +type PluginInstallCandidate = Parameters[0] +type SystemFeatures = Parameters[1] + const basePlugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' }, +} satisfies PluginInstallCandidate + +function makeSystemFeatures( + scope: PluginInstallationScope, + restrictToMarketplaceOnly = false, +): SystemFeatures { + return { + plugin_installation_permission: { + restrict_to_marketplace_only: restrictToMarketplaceOnly, + plugin_installation_scope: scope, + }, + } } describe('pluginInstallLimit', () => { it('should allow all plugins when scope is ALL', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.ALL, - }, - } + const features = makeSystemFeatures(InstallationScope.ALL) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should deny all plugins when scope is NONE', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.NONE, - }, - } + const features = makeSystemFeatures(InstallationScope.NONE) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false) }) it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } - const plugin = { ...basePlugin, verification: { authorized_category: 'community' } } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) + const plugin = { + ...basePlugin, + verification: { authorized_category: 'community' as const }, + } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER, - }, - } - const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_AND_PARTNER) + const plugin = { + ...basePlugin, + verification: { authorized_category: 'partner' as const }, + } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(true) }) it('should deny github plugins when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - const plugin = { ...basePlugin, from: 'github' as const } + const features = makeSystemFeatures(InstallationScope.ALL, true) + const plugin = { ...basePlugin, from: 'github' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should deny package plugins when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - const plugin = { ...basePlugin, from: 'package' as const } + const features = makeSystemFeatures(InstallationScope.ALL, true) + const plugin = { ...basePlugin, from: 'package' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } + const features = makeSystemFeatures(InstallationScope.ALL, true) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should default to langgenius when no verification info', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } - const plugin = { from: 'marketplace' as const } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) + const plugin = { from: 'marketplace' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(true) }) - it('should fallback to canInstall true for unrecognized scope', () => { + it('should deny installation for an unrecognized runtime scope', () => { const features = { plugin_installation_permission: { restrict_to_marketplace_only: false, - plugin_installation_scope: 'unknown-scope' as unknown as PluginInstallationScope, + plugin_installation_scope: 'unknown-scope', }, - } + } as unknown as SystemFeatures - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false) }) }) @@ -132,9 +108,9 @@ describe('usePluginInstallLimit', () => { const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' }, - } + } satisfies PluginInstallCandidate - const { result } = renderHook(() => usePluginInstallLimit(plugin as never)) + const { result } = renderHook(() => usePluginInstallLimit(plugin)) expect(result.current.canInstall).toBe(true) }) diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx index 933f065d80e..7c498f00ecc 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx @@ -1,68 +1,55 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' -import type { Plugin, PluginManifestInMarket } from '../../types' +import type { + PluginBundleDependencyType, + PluginVerification, +} from '@dify/contracts/api/console/workspaces/types.gen' import { useSuspenseQuery } from '@tanstack/react-query' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { InstallationScope } from '@/features/system-features/constants' -type PluginProps = (Plugin | PluginManifestInMarket) & { - from: 'github' | 'marketplace' | 'package' +type PluginInstallCandidate = { + from: PluginBundleDependencyType + verification?: PluginVerification | null } type PluginInstallLimitResult = { canInstall: boolean } +function denyUnsupportedInstallationScope(_scope: never): PluginInstallLimitResult { + return { canInstall: false } +} + export function pluginInstallLimit( - plugin: PluginProps, + plugin: PluginInstallCandidate, systemFeatures: Pick, ) { - if (systemFeatures.plugin_installation_permission.restrict_to_marketplace_only) { + const permission = systemFeatures.plugin_installation_permission + if (permission.restrict_to_marketplace_only) { if (plugin.from === 'github' || plugin.from === 'package') return { canInstall: false } } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.ALL - ) { - return { - canInstall: true, - } - } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.NONE - ) { - return { - canInstall: false, - } - } - const verification = plugin.verification || {} - if (!plugin.verification || !plugin.verification.authorized_category) - verification.authorized_category = 'langgenius' + const authorizedCategory = plugin.verification?.authorized_category ?? 'langgenius' + const scope = permission.plugin_installation_scope - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.OFFICIAL_ONLY - ) { - return { - canInstall: verification.authorized_category === 'langgenius', - } - } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.OFFICIAL_AND_PARTNER - ) { - return { - canInstall: - verification.authorized_category === 'langgenius' || - verification.authorized_category === 'partner', - } - } - return { - canInstall: true, + switch (scope) { + case InstallationScope.ALL: + return { canInstall: true } + case InstallationScope.NONE: + return { canInstall: false } + case InstallationScope.OFFICIAL_ONLY: + return { canInstall: authorizedCategory === 'langgenius' } + case InstallationScope.OFFICIAL_AND_PARTNER: + return { + canInstall: authorizedCategory === 'langgenius' || authorizedCategory === 'partner', + } + default: + return denyUnsupportedInstallationScope(scope) } } -export default function usePluginInstallLimit(plugin: PluginProps): PluginInstallLimitResult { +export default function usePluginInstallLimit( + plugin: PluginInstallCandidate, +): PluginInstallLimitResult { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return pluginInstallLimit(plugin, systemFeatures)