fix: validate plugin installation scope (#39669)

This commit is contained in:
yyh 2026-07-28 12:13:20 +08:00 committed by GitHub
parent dd28b0d165
commit 1e5e47b889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 289 additions and 171 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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)
})

View File

@ -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)