mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
Controllers must not query the database directly, and new tests must not introduce mocks or namespaces as test doubles. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""Console routes for file malware scanning.
|
|
|
|
Providers are workspace-wide and managed by owners and admins. A policy belongs to one product
|
|
resource, so each resource type has its own route guarded by that resource's own permissions;
|
|
they all delegate to ``FileScanPolicyService``.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound
|
|
|
|
from configs import dify_config
|
|
from controllers.common.rbac import DatasetId, RBACCheck
|
|
from controllers.common.schema import register_response_schema_models, register_schema_models
|
|
from controllers.console.flask_admission import console_account_admission
|
|
from core.db.session_factory import session_factory
|
|
from fields.base import ResponseModel
|
|
from libs.helper import to_timestamp
|
|
from machinery.context import RequestContext
|
|
from models.account import TenantAccountRole
|
|
from models.file_scan import FileScanFallbackAction
|
|
from services.file_scan import (
|
|
FileScanPolicyService,
|
|
FileScanProviderInUseError,
|
|
FileScanProviderNotFoundError,
|
|
FileScanProviderService,
|
|
FileScanValidationError,
|
|
ScanPolicy,
|
|
ScanScope,
|
|
)
|
|
from services.file_scan.entities import SCOPE_KNOWLEDGE_FS_SPACE
|
|
from services.file_scan.provider_service import FileScanProviderView
|
|
from services.knowledge_fs.feature_service import get_knowledge_fs_features
|
|
from services.knowledge_fs.product_operations import KnowledgeFSProductPermission
|
|
from services.knowledge_fs.product_service import KnowledgeFSProductNotFoundError
|
|
from services.knowledge_fs.runtime import get_knowledge_fs_runtime
|
|
|
|
from . import console_ns
|
|
from .wraps import RBACPermission, model_validate
|
|
|
|
_PROVIDER_ADMIN_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
|
_DATASET_EDIT_ROLES = frozenset(
|
|
{TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.DATASET_OPERATOR}
|
|
)
|
|
|
|
|
|
class FileScanProviderCreatePayload(BaseModel):
|
|
name: str = Field(description="Display name")
|
|
provider_type: str = Field(description="clamav, cloudmersive, scanii, metadefender or api")
|
|
config: dict[str, Any] = Field(default_factory=dict, description="Non-secret settings")
|
|
credentials: dict[str, str] = Field(default_factory=dict, description="Secret settings; write-only")
|
|
|
|
|
|
class FileScanProviderUpdatePayload(BaseModel):
|
|
name: str | None = None
|
|
config: dict[str, Any] | None = None
|
|
credentials: dict[str, str] | None = Field(
|
|
default=None, description="Secret settings to replace; omitted or empty fields keep their value"
|
|
)
|
|
|
|
|
|
class FileScanPolicyPayload(BaseModel):
|
|
enabled: bool
|
|
provider_id: str | None = None
|
|
on_unavailable: FileScanFallbackAction = FileScanFallbackAction.BLOCK
|
|
on_unscannable: FileScanFallbackAction = FileScanFallbackAction.BLOCK
|
|
|
|
|
|
class FileScanProviderTypeResponse(ResponseModel):
|
|
provider_type: str
|
|
available: bool
|
|
credential_fields: list[str]
|
|
|
|
|
|
class FileScanProviderTypeListResponse(ResponseModel):
|
|
data: list[FileScanProviderTypeResponse]
|
|
|
|
|
|
class FileScanProviderResponse(ResponseModel):
|
|
id: str
|
|
name: str
|
|
provider_type: str
|
|
config: dict[str, Any]
|
|
credentials_set: dict[str, bool]
|
|
status: str
|
|
last_error: str | None = None
|
|
last_tested_at: int | None = None
|
|
created_at: int | None = None
|
|
policy_count: int
|
|
|
|
@field_validator("last_tested_at", "created_at", mode="before")
|
|
@classmethod
|
|
def _timestamp(cls, value: datetime | int | None) -> int | None:
|
|
return to_timestamp(value)
|
|
|
|
|
|
class FileScanProviderListResponse(ResponseModel):
|
|
data: list[FileScanProviderResponse]
|
|
|
|
|
|
class FileScanPolicyResponse(ResponseModel):
|
|
enabled: bool
|
|
provider_id: str | None = None
|
|
on_unavailable: str
|
|
on_unscannable: str
|
|
|
|
|
|
register_schema_models(
|
|
console_ns,
|
|
FileScanProviderCreatePayload,
|
|
FileScanProviderUpdatePayload,
|
|
FileScanPolicyPayload,
|
|
)
|
|
register_response_schema_models(
|
|
console_ns,
|
|
FileScanProviderTypeListResponse,
|
|
FileScanProviderResponse,
|
|
FileScanProviderListResponse,
|
|
FileScanPolicyResponse,
|
|
)
|
|
|
|
|
|
def _require_enabled() -> None:
|
|
if not dify_config.FILE_SCAN_ENABLED:
|
|
raise NotFound()
|
|
|
|
|
|
def _provider_response(view: FileScanProviderView) -> dict[str, Any]:
|
|
return FileScanProviderResponse.model_validate(view).model_dump(mode="json")
|
|
|
|
|
|
def _policy_response(policy: ScanPolicy) -> dict[str, Any]:
|
|
return FileScanPolicyResponse(
|
|
enabled=policy.enabled,
|
|
provider_id=policy.provider_id,
|
|
on_unavailable=policy.on_unavailable.value,
|
|
on_unscannable=policy.on_unscannable.value,
|
|
).model_dump(mode="json")
|
|
|
|
|
|
def _set_policy(request_context: RequestContext, scope: ScanScope, payload: FileScanPolicyPayload) -> dict[str, Any]:
|
|
try:
|
|
policy = FileScanPolicyService().set(
|
|
tenant_id=request_context.active_workspace_id,
|
|
scope=scope,
|
|
policy=ScanPolicy(
|
|
enabled=payload.enabled,
|
|
provider_id=payload.provider_id,
|
|
on_unavailable=payload.on_unavailable,
|
|
on_unscannable=payload.on_unscannable,
|
|
),
|
|
account_id=request_context.account_id,
|
|
)
|
|
except FileScanValidationError as error:
|
|
raise BadRequest(str(error)) from error
|
|
return _policy_response(policy)
|
|
|
|
|
|
@console_ns.route("/file-scan/provider-types")
|
|
class FileScanProviderTypesApi(Resource):
|
|
@console_ns.doc(description="List the malware scanners this deployment offers")
|
|
@console_ns.response(200, "Success", console_ns.models[FileScanProviderTypeListResponse.__name__])
|
|
@console_account_admission()
|
|
def get(self, request_context: RequestContext):
|
|
_require_enabled()
|
|
return FileScanProviderTypeListResponse(
|
|
data=[
|
|
FileScanProviderTypeResponse(
|
|
provider_type=item.provider_type,
|
|
available=item.available,
|
|
credential_fields=list(item.credential_fields),
|
|
)
|
|
for item in FileScanProviderService.provider_types()
|
|
]
|
|
).model_dump(mode="json")
|
|
|
|
|
|
@console_ns.route("/file-scan/providers")
|
|
class FileScanProvidersApi(Resource):
|
|
@console_ns.doc(description="List the workspace's malware scanners. Secrets are never returned.")
|
|
@console_ns.response(200, "Success", console_ns.models[FileScanProviderListResponse.__name__])
|
|
@console_account_admission()
|
|
def get(self, request_context: RequestContext):
|
|
_require_enabled()
|
|
views = FileScanProviderService().list(tenant_id=request_context.active_workspace_id)
|
|
return {"data": [_provider_response(view) for view in views]}
|
|
|
|
@console_ns.doc(description="Add a malware scanner. It is verified with a live check before it is saved.")
|
|
@console_ns.expect(console_ns.models[FileScanProviderCreatePayload.__name__])
|
|
@console_ns.response(201, "Scanner added", console_ns.models[FileScanProviderResponse.__name__])
|
|
@console_account_admission(allowed_roles=_PROVIDER_ADMIN_ROLES)
|
|
@model_validate(FileScanProviderCreatePayload)
|
|
def post(self, payload: FileScanProviderCreatePayload, request_context: RequestContext):
|
|
_require_enabled()
|
|
try:
|
|
view = FileScanProviderService().create(
|
|
tenant_id=request_context.active_workspace_id,
|
|
account_id=request_context.account_id,
|
|
name=payload.name,
|
|
provider_type=payload.provider_type,
|
|
config=payload.config,
|
|
credentials=payload.credentials,
|
|
)
|
|
except FileScanValidationError as error:
|
|
raise BadRequest(str(error)) from error
|
|
return _provider_response(view), 201
|
|
|
|
|
|
@console_ns.route("/file-scan/providers/<uuid:provider_id>")
|
|
class FileScanProviderApi(Resource):
|
|
@console_ns.doc(description="Update a malware scanner. It is verified with a live check before it is saved.")
|
|
@console_ns.expect(console_ns.models[FileScanProviderUpdatePayload.__name__])
|
|
@console_ns.response(200, "Scanner updated", console_ns.models[FileScanProviderResponse.__name__])
|
|
@console_account_admission(allowed_roles=_PROVIDER_ADMIN_ROLES)
|
|
@model_validate(FileScanProviderUpdatePayload)
|
|
def patch(self, payload: FileScanProviderUpdatePayload, request_context: RequestContext, provider_id: UUID):
|
|
_require_enabled()
|
|
try:
|
|
view = FileScanProviderService().update(
|
|
tenant_id=request_context.active_workspace_id,
|
|
provider_id=str(provider_id),
|
|
name=payload.name,
|
|
config=payload.config,
|
|
credentials=payload.credentials,
|
|
)
|
|
except FileScanProviderNotFoundError as error:
|
|
raise NotFound() from error
|
|
except FileScanValidationError as error:
|
|
raise BadRequest(str(error)) from error
|
|
return _provider_response(view)
|
|
|
|
@console_ns.doc(description="Delete a malware scanner that no policy selects")
|
|
@console_ns.response(204, "Scanner deleted")
|
|
@console_account_admission(allowed_roles=_PROVIDER_ADMIN_ROLES)
|
|
def delete(self, request_context: RequestContext, provider_id: UUID):
|
|
_require_enabled()
|
|
try:
|
|
FileScanProviderService().delete(
|
|
tenant_id=request_context.active_workspace_id, provider_id=str(provider_id)
|
|
)
|
|
except FileScanProviderNotFoundError as error:
|
|
raise NotFound() from error
|
|
except FileScanProviderInUseError as error:
|
|
raise Conflict(str(error)) from error
|
|
return "", 204
|
|
|
|
|
|
@console_ns.route("/file-scan/providers/<uuid:provider_id>/test")
|
|
class FileScanProviderTestApi(Resource):
|
|
@console_ns.doc(description="Run the scanner's connectivity check again and store the result")
|
|
@console_ns.response(200, "Check finished", console_ns.models[FileScanProviderResponse.__name__])
|
|
@console_account_admission(allowed_roles=_PROVIDER_ADMIN_ROLES)
|
|
def post(self, request_context: RequestContext, provider_id: UUID):
|
|
_require_enabled()
|
|
try:
|
|
view = FileScanProviderService().test(
|
|
tenant_id=request_context.active_workspace_id, provider_id=str(provider_id)
|
|
)
|
|
except FileScanProviderNotFoundError as error:
|
|
raise NotFound() from error
|
|
return _provider_response(view)
|
|
|
|
|
|
def _dataset_scope(request_context: RequestContext, dataset_id: UUID) -> ScanScope:
|
|
scope = FileScanPolicyService().dataset_scope(
|
|
tenant_id=request_context.active_workspace_id, dataset_id=str(dataset_id)
|
|
)
|
|
if scope is None:
|
|
raise NotFound()
|
|
return scope
|
|
|
|
|
|
@console_ns.route("/datasets/<uuid:dataset_id>/file-scan-policy")
|
|
class DatasetFileScanPolicyApi(Resource):
|
|
@console_ns.doc(description="Get the malware scan policy of a Classic Knowledge Base")
|
|
@console_ns.response(200, "Success", console_ns.models[FileScanPolicyResponse.__name__])
|
|
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.DATASET_READONLY, DatasetId()),))
|
|
def get(self, request_context: RequestContext, dataset_id: UUID):
|
|
_require_enabled()
|
|
scope = _dataset_scope(request_context, dataset_id)
|
|
return _policy_response(FileScanPolicyService().get(tenant_id=request_context.active_workspace_id, scope=scope))
|
|
|
|
@console_ns.doc(description="Set the malware scan policy of a Classic Knowledge Base")
|
|
@console_ns.expect(console_ns.models[FileScanPolicyPayload.__name__])
|
|
@console_ns.response(200, "Policy saved", console_ns.models[FileScanPolicyResponse.__name__])
|
|
@console_account_admission(
|
|
allowed_roles=_DATASET_EDIT_ROLES, rbac_checks=(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()),)
|
|
)
|
|
@model_validate(FileScanPolicyPayload)
|
|
def put(self, payload: FileScanPolicyPayload, request_context: RequestContext, dataset_id: UUID):
|
|
_require_enabled()
|
|
return _set_policy(request_context, _dataset_scope(request_context, dataset_id), payload)
|
|
|
|
|
|
def _knowledge_fs_scope(
|
|
request_context: RequestContext, control_space_id: str, permission: KnowledgeFSProductPermission
|
|
) -> ScanScope:
|
|
if not get_knowledge_fs_features().enabled:
|
|
raise NotFound()
|
|
runtime = get_knowledge_fs_runtime(session_factory.get_session_maker())
|
|
try:
|
|
runtime.control_plane.authorize_space(
|
|
tenant_id=request_context.active_workspace_id,
|
|
account_id=request_context.account_id,
|
|
control_space_id=control_space_id,
|
|
permission=permission,
|
|
)
|
|
except KnowledgeFSProductNotFoundError as error:
|
|
raise NotFound() from error
|
|
except PermissionError as error:
|
|
raise Forbidden() from error
|
|
return ScanScope(SCOPE_KNOWLEDGE_FS_SPACE, control_space_id)
|
|
|
|
|
|
@console_ns.route("/knowledge-fs/spaces/<string:control_space_id>/file-scan-policy")
|
|
class KnowledgeFSFileScanPolicyApi(Resource):
|
|
@console_ns.doc(description="Get the malware scan policy of an Agent Knowledge Base")
|
|
@console_ns.response(200, "Success", console_ns.models[FileScanPolicyResponse.__name__])
|
|
@console_account_admission()
|
|
def get(self, request_context: RequestContext, control_space_id: str):
|
|
_require_enabled()
|
|
scope = _knowledge_fs_scope(request_context, control_space_id, KnowledgeFSProductPermission.READ)
|
|
return _policy_response(FileScanPolicyService().get(tenant_id=request_context.active_workspace_id, scope=scope))
|
|
|
|
@console_ns.doc(description="Set the malware scan policy of an Agent Knowledge Base")
|
|
@console_ns.expect(console_ns.models[FileScanPolicyPayload.__name__])
|
|
@console_ns.response(200, "Policy saved", console_ns.models[FileScanPolicyResponse.__name__])
|
|
@console_account_admission()
|
|
@model_validate(FileScanPolicyPayload)
|
|
def put(self, payload: FileScanPolicyPayload, request_context: RequestContext, control_space_id: str):
|
|
_require_enabled()
|
|
scope = _knowledge_fs_scope(request_context, control_space_id, KnowledgeFSProductPermission.EDIT)
|
|
return _set_policy(request_context, scope, payload)
|