mirror of
https://github.com/langgenius/dify.git
synced 2026-07-28 23:59:34 +08:00
fix: validate plugin installation scope (#39669)
This commit is contained in:
parent
dd28b0d165
commit
1e5e47b889
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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<typeof pluginInstallLimit>[0]
|
||||
type SystemFeatures = Parameters<typeof pluginInstallLimit>[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)
|
||||
})
|
||||
|
||||
@ -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<GetSystemFeaturesResponse, 'plugin_installation_permission'>,
|
||||
) {
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user