diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e94304b06..1e117246fc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,7 +74,7 @@ Yuxi 只交付完整知识能力路径。API 始终注册 `external_kb`、`knowl - `composables` 封装请求排队、Run SSE、流式消息、审批、线程状态、提及和其他可组合逻辑。 - `utils` 放轻量转换和展示辅助;全局样式集中在 `assets/css`,颜色和基础规范优先复用 `base.css`。 -`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 对普通用户可见,知识库、工具和 MCP 管理能力仅管理员可见;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 +`/` 是公开首页;登录后的核心工作区是 `/agent`。`/extensions` 对所有登录用户开放,其中 Skills 和已授权知识库对普通用户可见;知识库文档维护按单库授权开放,整库配置、工具和 MCP 管理要求管理员;Dashboard 仅超级管理员可访问。后端权限检查始终是最终边界,前端守卫只负责页面体验。 ## 智能体运行链路 diff --git a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py index 5f41579bbf..711534b305 100644 --- a/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py +++ b/backend/package/yuxi/knowledge/graphs/milvus_graph_service.py @@ -17,6 +17,7 @@ normalize_entity_name, ) from yuxi.knowledge.graphs.milvus_graph_vector_store import MilvusGraphVectorStore +from yuxi.knowledge.utils.security import redact_sensitive_params from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository from yuxi.repositories.knowledge_chunk_repository import KnowledgeChunkRepository from yuxi.repositories.knowledge_graph_repository import KnowledgeGraphRepository @@ -129,7 +130,8 @@ async def get_graph_vector_store(self) -> MilvusGraphVectorStore: def driver(self): return self.connection.driver - async def get_status(self, kb_id: str, *, tasker: Any = None) -> dict[str, Any]: + async def get_status(self, kb_id: str, *, tasker: Any = None, redact_secrets: bool = True) -> dict[str, Any]: + """返回图谱构建状态,默认隐藏配置中的凭据。""" kb = await self._get_milvus_kb(kb_id) params = dict(kb.additional_params or {}) config = params.get(GRAPH_CONFIG_KEY) or {} @@ -175,7 +177,7 @@ async def get_status(self, kb_id: str, *, tasker: Any = None) -> dict[str, Any]: "kb_type": kb.kb_type, "configured": bool(config), "locked": bool(config.get("locked")), - "config": self._public_config(config), + "config": self._public_config(config, redact_secrets=redact_secrets), "total_chunks": total_chunks, "pending_chunks": pending_chunks, "indexed_chunks": indexed_chunks, @@ -1043,13 +1045,17 @@ def _get_locked_config(self, additional_params: dict[str, Any]) -> dict[str, Any raise ValueError("图谱抽取配置缺少 extractor_type") return config - def _public_config(self, config: dict[str, Any]) -> dict[str, Any] | None: + def _public_config(self, config: dict[str, Any], *, redact_secrets: bool = True) -> dict[str, Any] | None: + """组装状态响应配置,保留运行时使用的原始参数。""" if not config: return None + extractor_options = self._runtime_extractor_options(config) + if redact_secrets: + extractor_options = redact_sensitive_params(extractor_options) return { "locked": bool(config.get("locked")), "extractor_type": config.get("extractor_type"), - "extractor_options": self._runtime_extractor_options(config), + "extractor_options": extractor_options, "created_at": config.get("created_at"), "created_by": config.get("created_by"), "updated_at": config.get("updated_at"), diff --git a/backend/package/yuxi/knowledge/manager.py b/backend/package/yuxi/knowledge/manager.py index 7950420908..9ba309446f 100644 --- a/backend/package/yuxi/knowledge/manager.py +++ b/backend/package/yuxi/knowledge/manager.py @@ -412,13 +412,15 @@ async def get_databases_by_user(self, user: User | dict) -> list[KnowledgeBaseSu if permission == ResourcePermission.NONE: continue additional_params = database.additional_params - if permission == ResourcePermission.READ: + can_manage = user_role in {"admin", "superadmin"} and permission == ResourcePermission.MANAGE + if not can_manage: additional_params = redact_sensitive_params(additional_params) filtered_databases.append( replace( database, additional_params=additional_params, effective_permission=permission, + can_manage=can_manage, ) ) diff --git a/backend/package/yuxi/knowledge/read_models.py b/backend/package/yuxi/knowledge/read_models.py index d3e3cd7d48..a0b092841c 100644 --- a/backend/package/yuxi/knowledge/read_models.py +++ b/backend/package/yuxi/knowledge/read_models.py @@ -51,10 +51,11 @@ class KnowledgeBaseSummary: pending_index_count: int = 0 processing_count: int = 0 effective_permission: ResourcePermission | None = None + can_manage: bool = False @property - def can_manage(self) -> bool: - """返回当前调用者是否拥有管理权限。""" + def can_edit_documents(self) -> bool: + """返回当前调用者是否拥有知识库内容编辑权限。""" return self.effective_permission == ResourcePermission.MANAGE diff --git a/backend/package/yuxi/knowledge/utils/security.py b/backend/package/yuxi/knowledge/utils/security.py index 6087f277bb..299a9dd49d 100644 --- a/backend/package/yuxi/knowledge/utils/security.py +++ b/backend/package/yuxi/knowledge/utils/security.py @@ -2,15 +2,30 @@ from typing import Any -_SENSITIVE_PARAMETER_MARKERS = ("token", "secret", "password", "api_key") +_SENSITIVE_PARAMETER_MARKERS = ("token", "secret", "password", "api_key", "authorization", "authentication", "cookie") +_NON_SECRET_TOKEN_PARAMETERS = {"max_tokens", "max_completion_tokens", "max_output_tokens", "chunk_token_num"} +_AUTHENTICATION_HEADER_PARAMETERS = {"default_headers", "extra_headers"} def redact_sensitive_params(params: dict[str, Any]) -> dict[str, Any]: - """移除知识库类型参数中的敏感凭据。""" + """复制知识库配置并移除各层凭据和可携带任意网关认证的请求头。""" redacted = {} for key, value in params.items(): - normalized_key = key.lower() - if any(marker in normalized_key for marker in _SENSITIVE_PARAMETER_MARKERS): + normalized_key = key.lower().replace("-", "_") + if normalized_key in _AUTHENTICATION_HEADER_PARAMETERS: continue - redacted[key] = value + if normalized_key not in _NON_SECRET_TOKEN_PARAMETERS and any( + marker in normalized_key for marker in _SENSITIVE_PARAMETER_MARKERS + ): + continue + redacted[key] = _redact_parameter_value(value) return redacted + + +def _redact_parameter_value(value: Any) -> Any: + """复制配置中的对象与数组,保留非凭据参数值。""" + if isinstance(value, dict): + return redact_sensitive_params(value) + if isinstance(value, list): + return [_redact_parameter_value(item) for item in value] + return value diff --git a/backend/package/yuxi/permissions/resource_permission.py b/backend/package/yuxi/permissions/resource_permission.py index 6da0f8ae7e..adb0b5a6dc 100644 --- a/backend/package/yuxi/permissions/resource_permission.py +++ b/backend/package/yuxi/permissions/resource_permission.py @@ -43,7 +43,7 @@ class ResourcePermissionPolicy: DEFAULT_SCOPE = {"access_level": "global", "department_ids": [], "user_uids": []} KNOWLEDGE_BASE_PERMISSION_POLICY = ResourcePermissionPolicy( role_ceiling={ - "user": ResourcePermission.READ, + "user": ResourcePermission.MANAGE, "admin": ResourcePermission.MANAGE, "superadmin": ResourcePermission.MANAGE, } @@ -207,7 +207,7 @@ def require_resource_permission( def resolve_knowledge_base_permission(user: Any, resource: ShareableResource) -> ResourcePermission: - """解析知识库权限,普通用户最多只能获得只读权限。""" + """解析知识库共享权限;整库管理另由管理员身份约束。""" return resolve_resource_permission( user, diff --git a/backend/package/yuxi/repositories/knowledge_file_repository.py b/backend/package/yuxi/repositories/knowledge_file_repository.py index b86bf26776..75c47a0a51 100644 --- a/backend/package/yuxi/repositories/knowledge_file_repository.py +++ b/backend/package/yuxi/repositories/knowledge_file_repository.py @@ -717,6 +717,7 @@ async def query_kb_file_stats(self, kb_id: str, *, session: AsyncSession) -> dic } async def upsert(self, file_id: str, data: dict[str, Any]) -> KnowledgeFile: + """新增或更新同库文件,拒绝跨库文件 ID 碰撞。""" sanitized_data = self._sanitize_data(data) async with pg_manager.get_async_session_context() as session: result = await session.execute(select(KnowledgeFile).where(KnowledgeFile.file_id == file_id)) @@ -725,6 +726,8 @@ async def upsert(self, file_id: str, data: dict[str, Any]) -> KnowledgeFile: record = KnowledgeFile(file_id=file_id, **sanitized_data) session.add(record) return record + if existing.kb_id != sanitized_data.get("kb_id"): + raise ValueError("File ID already belongs to another knowledge base") for key, value in sanitized_data.items(): setattr(existing, key, value) return existing diff --git a/backend/server/routers/knowledge_eval_router.py b/backend/server/routers/knowledge_eval_router.py index 1bbc09d30b..31ecbed9b4 100644 --- a/backend/server/routers/knowledge_eval_router.py +++ b/backend/server/routers/knowledge_eval_router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import Response from pydantic import BaseModel, Field -from server.utils.auth_middleware import get_admin_user +from server.utils.auth_middleware import get_admin_user, get_required_user from server.utils.knowledge_permissions import ( ensure_knowledge_base_permission, require_knowledge_base_manage, @@ -56,9 +56,9 @@ async def _get_evaluation_dataset_or_raise(dataset_id: str) -> Any: async def require_evaluation_dataset_read( dataset_id: str, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), ) -> User: - """校验管理员对评估数据集所属知识库的读取权限。""" + """校验登录用户对评估数据集所属知识库的读取权限。""" dataset = await _get_evaluation_dataset_or_raise(dataset_id) await ensure_knowledge_base_permission(str(dataset.kb_id), current_user, ResourcePermission.READ) diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 68b5090f9e..15b46685c0 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -50,6 +50,7 @@ from server.utils.knowledge_response import serialize_knowledge_base, serialize_knowledge_base_list from server.utils.knowledge_permissions import ( ensure_knowledge_base_permission as _ensure_database_permission, + require_knowledge_base_content_edit, require_knowledge_base_manage, require_knowledge_base_read, ) @@ -135,12 +136,21 @@ class PendingIndexDocumentsRequest(BaseModel): async def _delete_document_storage_objects(kb_id: str, doc_id: str, file_path: str) -> None: + """仅清理本库拥有的对象,跨库原件引用不授予删除权。""" minio_client = get_minio_client() if is_minio_url(file_path): try: bucket_name, object_name = parse_minio_url(file_path) - await minio_client.adelete_file(bucket_name, object_name) + if ( + bucket_name == minio_client.KB_BUCKETS["documents"] + and object_name.startswith(f"{kb_id}/") + and "\\" not in object_name + and not any(part in {".", ".."} for part in object_name.split("/")) + ): + await minio_client.adelete_file(bucket_name, object_name) + else: + logger.warning(f"保留不属于本知识库的文档原件: {kb_id=}, {doc_id=}") except Exception as minio_error: logger.warning(f"从MinIO删除原始文件失败: {minio_error}") @@ -155,10 +165,14 @@ async def _delete_document_storage_objects(kb_id: str, doc_id: str, file_path: s logger.warning(f"从MinIO删除预览 PDF 失败: {minio_error}") -async def _require_manage_permission_if_kb_id(kb_id: str | None, current_user: User) -> None: - """当请求携带 kb_id 时,校验当前用户对该知识库的管理权限。""" - if kb_id and getattr(current_user, "role", None): - await _ensure_database_permission(kb_id, current_user, ResourcePermission.MANAGE) +async def _require_upload_permission(kb_id: str | None, current_user: User) -> None: + """上传前校验单库内容权限,未指定库的上传仅允许管理员。""" + if not kb_id: + if current_user.role not in {"admin", "superadmin"}: + raise HTTPException(status_code=403, detail="普通用户上传必须指定知识库") + return + + await _ensure_database_permission(kb_id, current_user, ResourcePermission.MANAGE) async def _ensure_database_supports_documents(kb_id: str, operation: str) -> dict: @@ -179,7 +193,9 @@ def _ensure_document_params(params: dict | None) -> dict: return params -def _validate_uploaded_document_items(items: list[str], params: dict) -> None: +def _validate_uploaded_document_items( + items: list[str], params: dict, *, kb_id: str, restrict_to_database: bool +) -> None: if not items: raise HTTPException(status_code=400, detail="items must not be empty") @@ -207,6 +223,23 @@ def _validate_uploaded_document_items(items: list[str], params: dict) -> None: if not has_content_hash and not has_preprocessed_hash: raise HTTPException(status_code=400, detail=f"Missing content_hash for file: {item}") + if restrict_to_database: + sources = [item] + if isinstance(preprocessed, dict): + sources.append(preprocessed.get("path", "")) + for source in sources: + if not isinstance(source, str) or not is_minio_url(source): + raise HTTPException(status_code=403, detail="只允许导入该知识库已上传的文件") + bucket_name, object_name = parse_minio_url(source) + if ( + bucket_name != MinIOClient.KB_BUCKETS["documents"] + or not object_name.startswith(f"{kb_id}/upload/") + or not object_name.removeprefix(f"{kb_id}/upload/") + or "\\" in object_name + or any(part in {".", ".."} for part in object_name.split("/")) + ): + raise HTTPException(status_code=403, detail="只允许导入该知识库已上传的文件") + async def _has_running_graph_build_task(kb_id: str) -> bool: return ( @@ -266,7 +299,7 @@ async def create_database( **(additional_params or {}), ) - response = serialize_knowledge_base(database_info) + response = serialize_knowledge_base(database_info, permission=ResourcePermission.MANAGE, can_manage=True) response["files"] = {} return response except KBNameConflictError as e: @@ -280,17 +313,13 @@ async def create_database( @knowledge.get("/databases/accessible") async def get_accessible_databases(current_user: User = Depends(get_required_user)): - """获取当前用户有权访问的知识库列表(用于智能体配置)""" + """获取当前用户有权访问的知识库摘要,供页面和智能体配置使用。""" try: databases = await knowledge_base.get_databases_by_uid(current_user.uid) accessible = [ { - "name": db.name, - "kb_id": db.kb_id, - "description": db.description or "", - "created_by": db.created_by, - "kb_type": db.kb_type, + **serialize_knowledge_base(db, redact_secrets=True, row_count_fallback=True), "supports_documents": knowledge_base.database_type_supports_documents(db.kb_type), } for db in databases @@ -381,10 +410,12 @@ async def get_database_info( if database is None: raise HTTPException(status_code=404, detail="Database not found") permission = resolve_knowledge_base_permission(current_user, database) + can_manage = current_user.role in {"admin", "superadmin"} and permission == ResourcePermission.MANAGE return serialize_knowledge_base( database, permission=permission, - redact_secrets=permission != ResourcePermission.MANAGE, + can_manage=can_manage, + redact_secrets=not can_manage, ) @@ -428,7 +459,10 @@ async def update_database_info( operator_uid=current_user.uid, operator_department_id=current_user.department_id, ) - return {"message": "更新成功", "database": serialize_knowledge_base(database)} + return { + "message": "更新成功", + "database": serialize_knowledge_base(database, permission=ResourcePermission.MANAGE, can_manage=True), + } except HTTPException: raise except Exception as e: @@ -454,7 +488,10 @@ async def delete_database(kb_id: str, current_user: User = Depends(require_knowl @knowledge.get("/databases/{kb_id}/graph-build/status") async def get_graph_build_status(kb_id: str, current_user: User = Depends(require_knowledge_base_read)): try: - return await MilvusGraphService().get_status(kb_id, tasker=tasker) + database = await _ensure_database_permission(kb_id, current_user, ResourcePermission.READ) + permission = resolve_knowledge_base_permission(current_user, database) + can_manage = current_user.role in {"admin", "superadmin"} and permission == ResourcePermission.MANAGE + return await MilvusGraphService().get_status(kb_id, tasker=tasker, redact_secrets=not can_manage) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: @@ -717,7 +754,7 @@ async def add_documents( kb_id: str, items: list[str] = Body(...), params: dict = Body(...), - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """添加文档到知识库(上传 -> 解析 -> 可选入库)""" logger.debug(f"Add documents for kb_id {kb_id}: {items} {params=}") @@ -730,7 +767,9 @@ async def add_documents( if content_type != "file": raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}") - _validate_uploaded_document_items(items, params) + _validate_uploaded_document_items( + items, params, kb_id=kb_id, restrict_to_database=current_user.role not in {"admin", "superadmin"} + ) try: database = await knowledge_base.get_database_info(kb_id) @@ -761,7 +800,7 @@ async def add_documents( async def add_uploaded_documents( kb_id: str, payload: AddUploadedDocumentsRequest, - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """将已上传的 MinIO 文件同步添加为知识库文档记录,不解析、不入库。""" logger.debug(f"Add uploaded documents for kb_id {kb_id}: {payload.items} params={payload.params}") @@ -774,7 +813,9 @@ async def add_uploaded_documents( if content_type != "file": raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}") - _validate_uploaded_document_items(payload.items, params) + _validate_uploaded_document_items( + payload.items, params, kb_id=kb_id, restrict_to_database=current_user.role not in {"admin", "superadmin"} + ) added_items: list[dict] = [] failed_items: list[dict] = [] @@ -922,7 +963,7 @@ async def _enqueue_pending_document_action_task( async def parse_documents( kb_id: str, payload: ParseDocumentsRequest | list[str] = Body(...), - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """手动触发文档解析""" if isinstance(payload, list): @@ -948,7 +989,7 @@ async def parse_documents( async def parse_pending_documents( kb_id: str, payload: PendingParseDocumentsRequest | None = None, - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """按状态手动触发全部待解析文档解析。""" params = (payload.params if payload else None) or {} @@ -968,7 +1009,7 @@ async def index_documents( kb_id: str, file_ids: list[str] = Body(...), params: dict | None = Body(None), - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """手动触发文档入库(Indexing),支持更新参数""" file_ids = _validate_direct_document_action_file_ids(file_ids) @@ -989,7 +1030,7 @@ async def index_documents( async def index_pending_documents( kb_id: str, payload: PendingIndexDocumentsRequest | None = None, - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """按状态手动触发全部待入库文档入库。""" params = (payload.params if payload else None) or {} @@ -1059,7 +1100,7 @@ async def get_document_content(kb_id: str, doc_id: str, current_user: User = Dep @knowledge.delete("/databases/{kb_id}/documents/batch") async def batch_delete_documents( - kb_id: str, file_ids: list[str] = Body(...), current_user: User = Depends(require_knowledge_base_manage) + kb_id: str, file_ids: list[str] = Body(...), current_user: User = Depends(require_knowledge_base_content_edit) ): """批量删除文档或文件夹""" logger.debug(f"BATCH DELETE documents {file_ids} in {kb_id}") @@ -1112,7 +1153,7 @@ async def batch_delete_documents( @knowledge.delete("/databases/{kb_id}/documents/{doc_id}") -async def delete_document(kb_id: str, doc_id: str, current_user: User = Depends(require_knowledge_base_manage)): +async def delete_document(kb_id: str, doc_id: str, current_user: User = Depends(require_knowledge_base_content_edit)): """删除文档或文件夹""" logger.debug(f"DELETE document {doc_id} info in {kb_id}") await _ensure_database_supports_documents(kb_id, "文档删除") @@ -1391,7 +1432,7 @@ async def create_folder( kb_id: str, folder_name: str = Body(..., embed=True), parent_id: str | None = Body(None, embed=True), - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """创建文件夹""" try: @@ -1465,7 +1506,7 @@ async def rename_folder( kb_id: str, folder_id: str, folder_name: str = Body(..., embed=True), - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """重命名真实文件夹。""" try: @@ -1485,7 +1526,7 @@ async def move_document( kb_id: str, doc_id: str, request: MoveDocumentRequest, - current_user: User = Depends(require_knowledge_base_manage), + current_user: User = Depends(require_knowledge_base_content_edit), ): """移动文件或文件夹""" logger.debug(f"Move document {doc_id} to {request.new_parent_id} in {kb_id}") @@ -1512,7 +1553,7 @@ async def fetch_url( """ logger.debug(f"Fetching URL: {url} for kb_id: {kb_id}") try: - await _require_manage_permission_if_kb_id(kb_id, current_user) + await _require_upload_permission(kb_id, current_user) # 1. 下载内容 (包含白名单校验、大小限制、类型检查) content_bytes, final_url = await fetch_url_content(url) @@ -1575,7 +1616,7 @@ async def fetch_url( @knowledge.post("/files/import-workspace") async def import_workspace_files( payload: WorkspaceImportRequest, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), ): """将当前用户工作区文件导入 MinIO,返回与普通文件上传一致的预处理结果。""" kb_id = payload.kb_id.strip() @@ -1585,7 +1626,7 @@ async def import_workspace_files( if not paths: raise HTTPException(status_code=400, detail="请选择至少一个工作区文件") - await _require_manage_permission_if_kb_id(kb_id, current_user) + await _require_upload_permission(kb_id, current_user) await _ensure_database_supports_documents(kb_id, "文档添加/解析/入库") bucket_name = MinIOClient.KB_BUCKETS["documents"] @@ -1639,14 +1680,16 @@ async def import_workspace_files( async def upload_file( file: UploadFile = File(...), kb_id: str | None = Query(None), - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), ): """上传文件""" if not file.filename: raise HTTPException(status_code=400, detail="No selected file") + if current_user.role not in {"admin", "superadmin"} and ("/" in file.filename or "\\" in file.filename): + raise HTTPException(status_code=400, detail="上传文件名不能包含目录路径") + await _require_upload_permission(kb_id, current_user) if kb_id: - await _require_manage_permission_if_kb_id(kb_id, current_user) await _ensure_database_supports_documents(kb_id, "文档上传") logger.debug(f"Received upload file with filename: {file.filename}") @@ -1711,7 +1754,7 @@ async def upload_file( @knowledge.get("/files/supported-types") -async def get_supported_file_types(current_user: User = Depends(get_admin_user)): +async def get_supported_file_types(current_user: User = Depends(get_required_user)): """获取当前支持的文件类型""" return {"message": "success", "file_types": sorted(SUPPORTED_FILE_EXTENSIONS)} @@ -1774,7 +1817,7 @@ async def get_knowledge_base_types(current_user: User = Depends(get_admin_user)) @knowledge.get("/chunk-presets") -async def get_knowledge_chunk_presets(current_user: User = Depends(get_admin_user)): +async def get_knowledge_chunk_presets(current_user: User = Depends(get_required_user)): """获取支持的知识库分块策略""" return {"chunk_presets": get_chunk_preset_options(), "message": "success"} diff --git a/backend/server/utils/knowledge_permissions.py b/backend/server/utils/knowledge_permissions.py index 3c7ac418ce..33132daa33 100644 --- a/backend/server/utils/knowledge_permissions.py +++ b/backend/server/utils/knowledge_permissions.py @@ -2,7 +2,7 @@ from fastapi import Depends, HTTPException -from server.utils.auth_middleware import get_admin_user +from server.utils.auth_middleware import get_admin_user, get_required_user from yuxi.knowledge.read_models import KnowledgeBaseDetail from yuxi.knowledge.runtime import knowledge_base from yuxi.permissions import ( @@ -33,14 +33,24 @@ async def ensure_knowledge_base_permission( async def require_knowledge_base_read( kb_id: str, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), ) -> User: - """校验管理员对指定知识库的读取权限。""" + """校验登录用户对指定知识库的读取权限。""" await ensure_knowledge_base_permission(kb_id, current_user, ResourcePermission.READ) return current_user +async def require_knowledge_base_content_edit( + kb_id: str, + current_user: User = Depends(get_required_user), +) -> User: + """校验登录用户对指定知识库的文档与文件夹编辑权限。""" + + await ensure_knowledge_base_permission(kb_id, current_user, ResourcePermission.MANAGE) + return current_user + + async def require_knowledge_base_manage( kb_id: str, current_user: User = Depends(get_admin_user), diff --git a/backend/server/utils/knowledge_response.py b/backend/server/utils/knowledge_response.py index 4236ac1b94..6c198aec21 100644 --- a/backend/server/utils/knowledge_response.py +++ b/backend/server/utils/knowledge_response.py @@ -29,6 +29,7 @@ def serialize_knowledge_base( database: KnowledgeBaseSummary, *, permission: ResourcePermission | None = None, + can_manage: bool | None = None, redact_secrets: bool = False, row_count_fallback: bool = False, ) -> dict[str, Any]: @@ -60,7 +61,8 @@ def serialize_knowledge_base( effective_permission = permission or database.effective_permission if effective_permission is not None: response["effective_permission"] = effective_permission.value - response["can_manage"] = effective_permission == ResourcePermission.MANAGE + response["can_manage"] = database.can_manage if can_manage is None else can_manage + response["can_edit_documents"] = effective_permission == ResourcePermission.MANAGE if isinstance(database, KnowledgeBaseDetail): response["mindmap"] = database.mindmap diff --git a/backend/test/integration/api/test_knowledge_document_storage_isolation.py b/backend/test/integration/api/test_knowledge_document_storage_isolation.py new file mode 100644 index 0000000000..f9f0f5dd49 --- /dev/null +++ b/backend/test/integration/api/test_knowledge_document_storage_isolation.py @@ -0,0 +1,151 @@ +import os +import uuid + +import pytest + +from yuxi.knowledge.utils import parse_minio_url +from yuxi.models.providers.cache import model_cache +from yuxi.storage.minio.client import get_minio_client + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_test_knowledge_resources(): + """本文件仅清理自己创建的资源,关闭父层的共享资源扫描。""" + yield + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_test_sandboxes(): + """文档对象测试不创建或清理沙盒。""" + yield + + +async def _add_document(client, headers, kb_id, upload): + """通过真实接口将上传对象登记为文档。""" + path = upload["file_path"] + response = await client.post( + f"/api/knowledge/databases/{kb_id}/documents/add", + headers=headers, + json={ + "items": [path], + "params": { + "content_hashes": {path: upload["content_hash"]}, + "file_sizes": {path: upload["size"]}, + }, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["items"], response.text + return response.json()["items"][0]["file_id"] + + +@pytest.mark.parametrize("batch", [False, True]) +@pytest.mark.parametrize("actor", ["editor", "admin"]) +async def test_delete_cross_kb_reference_preserves_foreign_source_and_cleans_owned_objects( + test_client, admin_headers, standard_user, batch, actor +): + """真实 HTTP 删除跨库引用后回读原件,且本库原件仍可删除。""" + models = model_cache.get_all_specs("embedding") + embedding_spec = os.getenv("TEST_KB_EMBEDDING_MODEL") + if not embedding_spec: + if not models: + pytest.skip("No embedding model is configured; set TEST_KB_EMBEDDING_MODEL.") + embedding_spec = models[0].spec + current_user = await test_client.get("/api/auth/me", headers=admin_headers) + assert current_user.status_code == 200, current_user.text + owner_scope = {"access_level": "user", "user_uids": [current_user.json()["uid"]]} + editor_scope = {"access_level": "user", "user_uids": [standard_user["user"]["uid"]]} + storage = get_minio_client() + database_ids = [] + fixture_objects = [] + + try: + for scope in (owner_scope, editor_scope): + response = await test_client.post( + "/api/knowledge/databases", + headers=admin_headers, + json={ + "database_name": f"pytest_storage_isolation_{uuid.uuid4().hex}", + "description": "Scoped object deletion fixture", + "embedding_model_spec": embedding_spec, + "kb_type": "milvus", + "additional_params": {}, + "share_config": {"version": 2, "read_scope": scope, "manage_scope": scope}, + }, + ) + assert response.status_code == 200, response.text + database_ids.append(response.json()["kb_id"]) + source_kb, target_kb = database_ids + editor_headers = standard_user["headers"] + foreign_content = b"Original owned by knowledge base A; B holds only a reference." + owned_content = b"Original owned by knowledge base B." + document_ids = [] + + for kb_id, headers, content in ( + (source_kb, admin_headers, foreign_content), + (target_kb, editor_headers, owned_content), + ): + response = await test_client.post( + "/api/knowledge/files/upload", + headers=headers, + params={"kb_id": kb_id}, + files={"file": (f"source-{uuid.uuid4().hex}.txt", content, "text/plain")}, + ) + assert response.status_code == 200, response.text + upload = response.json() + fixture_objects.append(parse_minio_url(upload["file_path"])) + doc_id = await _add_document(test_client, headers, kb_id, upload) + document_ids.append(doc_id) + if kb_id == source_kb: + foreign_upload = upload + + reference_id = await _add_document(test_client, admin_headers, target_kb, foreign_upload) + reference = await test_client.get( + f"/api/knowledge/databases/{target_kb}/documents/{reference_id}/basic", headers=editor_headers + ) + assert reference.status_code == 200, reference.text + assert reference.json()["meta"]["path"] == foreign_upload["file_path"] + derived_objects = [ + (storage.KB_BUCKETS["parsed"], f"{target_kb}/parsed/{reference_id}.md"), + (storage.KB_BUCKETS["parsed"], f"{target_kb}/preview/{reference_id}.pdf"), + ] + for bucket_name, object_name in derived_objects: + fixture_objects.append((bucket_name, object_name)) + storage.upload_file(bucket_name, object_name, b"own derived fixture") + delete_headers = editor_headers if actor == "editor" else admin_headers + + for doc_id in (reference_id, document_ids[1]): + if batch: + deleted = await test_client.request( + "DELETE", + f"/api/knowledge/databases/{target_kb}/documents/batch", + headers=delete_headers, + json=[doc_id], + ) + assert deleted.status_code == 200, deleted.text + assert deleted.json()["deleted_count"] == 1, deleted.text + else: + deleted = await test_client.delete( + f"/api/knowledge/databases/{target_kb}/documents/{doc_id}", headers=delete_headers + ) + assert deleted.status_code == 200, deleted.text + + remaining = await test_client.get(f"/api/knowledge/databases/{target_kb}/documents", headers=editor_headers) + assert remaining.status_code == 200, remaining.text + assert remaining.json()["items"] == [] + original = await test_client.get( + f"/api/knowledge/databases/{source_kb}/documents/{document_ids[0]}/download", headers=admin_headers + ) + assert original.status_code == 200, original.text + assert original.content == foreign_content + assert storage.download_file(*fixture_objects[0]) == foreign_content + assert not storage.file_exists(*fixture_objects[1]) + assert all(not storage.file_exists(*obj) for obj in derived_objects) + finally: + for kb_id in reversed(database_ids): + response = await test_client.delete(f"/api/knowledge/databases/{kb_id}", headers=admin_headers) + assert response.status_code in {200, 404}, response.text + for bucket_name, object_name in fixture_objects: + storage.delete_file(bucket_name, object_name) diff --git a/backend/test/integration/api/test_knowledge_router.py b/backend/test/integration/api/test_knowledge_router.py index 6b59fe2b9e..59aaad2c97 100644 --- a/backend/test/integration/api/test_knowledge_router.py +++ b/backend/test/integration/api/test_knowledge_router.py @@ -90,13 +90,15 @@ async def _delete_department_with_admin(test_client, admin_headers, department): assert response.status_code in (200, 404), response.text -async def _create_test_database(test_client, admin_headers, share_config=None): +async def _create_test_database( + test_client, admin_headers, share_config=None, embedding_model_spec="siliconflow-cn:Pro/BAAI/bge-m3" +): response = await test_client.post( "/api/knowledge/databases", json={ "database_name": f"pytest_acl_{uuid.uuid4().hex[:8]}", "description": "Knowledge permission test", - "embedding_model_spec": "siliconflow-cn:Pro/BAAI/bge-m3", + "embedding_model_spec": embedding_model_spec, "kb_type": "milvus", "additional_params": {}, "share_config": share_config, @@ -301,14 +303,18 @@ async def test_knowledge_virtual_folder_migration_runs_without_sse_and_is_resuma assert final_detection.json()["has_virtual_folders"] is False async with engine.connect() as connection: folder_creators = ( - await connection.execute( - text( - "SELECT created_by FROM knowledge_files WHERE kb_id = :kb " - "AND is_folder IS TRUE AND filename IN (:root, 'shared', 'other')" - ), - {"kb": kb_id, "root": f"history-{prefix}"}, + ( + await connection.execute( + text( + "SELECT created_by FROM knowledge_files WHERE kb_id = :kb " + "AND is_folder IS TRUE AND filename IN (:root, 'shared', 'other')" + ), + {"kb": kb_id, "root": f"history-{prefix}"}, + ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(folder_creators) == 3 assert all(folder_creators) finally: @@ -362,26 +368,28 @@ async def test_virtual_folder_migration_keeps_conflicts_and_commits_other_paths( async with engine.connect() as connection: rows = ( - await connection.execute( - text( - "SELECT filename, parent_id FROM knowledge_files WHERE file_id IN " - "(:blocked_file, :movable_file) ORDER BY file_id" - ), - { - "blocked_file": f"file_{suffix}_blocked", - "movable_file": f"file_{suffix}_movable", - }, + ( + await connection.execute( + text( + "SELECT filename, parent_id FROM knowledge_files WHERE file_id IN " + "(:blocked_file, :movable_file) ORDER BY file_id" + ), + { + "blocked_file": f"file_{suffix}_blocked", + "movable_file": f"file_{suffix}_movable", + }, + ) ) - ).mappings().all() + .mappings() + .all() + ) assert {row["filename"] for row in rows} == {f"{blocked}/a.txt", "b.txt"} assert sum(row["parent_id"] is not None for row in rows) == 1 finally: await engine.dispose() -async def test_folder_mutations_reject_invalid_name_and_directory_cycle( - test_client, admin_headers, knowledge_database -): +async def test_folder_mutations_reject_invalid_name_and_directory_cycle(test_client, admin_headers, knowledge_database): kb_id = knowledge_database["kb_id"] parent_response = await test_client.post( @@ -590,23 +598,23 @@ async def test_knowledge_routes_enforce_permissions(test_client, standard_user, forbidden_list = await test_client.get("/api/knowledge/databases", headers=standard_user["headers"]) _assert_forbidden_response(forbidden_list) - forbidden_chunk_presets = await test_client.get("/api/knowledge/chunk-presets", headers=standard_user["headers"]) - _assert_forbidden_response(forbidden_chunk_presets) + chunk_presets = await test_client.get("/api/knowledge/chunk-presets", headers=standard_user["headers"]) + assert chunk_presets.status_code == 200, chunk_presets.text - forbidden_get = await test_client.get(f"/api/knowledge/databases/{kb_id}", headers=standard_user["headers"]) - _assert_forbidden_response(forbidden_get) + readable = await test_client.get(f"/api/knowledge/databases/{kb_id}", headers=standard_user["headers"]) + assert readable.status_code == 200, readable.text + assert readable.json()["can_manage"] is False + assert readable.json()["can_edit_documents"] is False - forbidden_exists = await test_client.get( + exists = await test_client.get( f"/api/knowledge/databases/{kb_id}/documents/exists", params={"filename": "demo.txt"}, headers=standard_user["headers"], ) - _assert_forbidden_response(forbidden_exists) + assert exists.status_code == 200, exists.text -async def test_kb_image_proxy_requires_auth_and_streams_private_image( - test_client, admin_headers, knowledge_database -): +async def test_kb_image_proxy_requires_auth_and_streams_private_image(test_client, admin_headers, knowledge_database): """知识库图片代理:未登录不可访问,鉴权后可读取私有 bucket 图片""" from yuxi.storage.minio.client import MinIOClient, get_minio_client @@ -1147,14 +1155,14 @@ async def test_mindmap_permissions(test_client, standard_user, knowledge_databas """测试思维导图接口的权限控制""" kb_id = knowledge_database["kb_id"] - # 普通用户应该无法访问 + # 普通用户不能使用管理员概览,但可以读取已共享库。 forbidden_list = await test_client.get("/api/knowledge/mindmap/databases", headers=standard_user["headers"]) _assert_forbidden_response(forbidden_list) - forbidden_files = await test_client.get( + readable_files = await test_client.get( f"/api/knowledge/databases/{kb_id}/mindmap/files", headers=standard_user["headers"] ) - _assert_forbidden_response(forbidden_files) + assert readable_files.status_code == 200, readable_files.text forbidden_generate = await test_client.post( f"/api/knowledge/databases/{kb_id}/mindmap/generate", @@ -1189,12 +1197,214 @@ async def test_document_search_returns_empty_results(test_client, admin_headers, assert payload["limit"] == search_params["limit"] -async def test_document_search_requires_admin(test_client, standard_user, knowledge_database): - """普通用户不能访问管理端搜索接口。""" +async def test_document_search_allows_authorized_reader(test_client, standard_user, knowledge_database): + """普通用户可以搜索已有读取授权的知识库。""" kb_id = knowledge_database["kb_id"] response = await test_client.get( f"/api/knowledge/databases/{kb_id}/documents/search", params={"query": "x"}, headers=standard_user["headers"], ) - _assert_forbidden_response(response) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("access", ["manage", "read", "none"]) +async def test_delegated_knowledge_content_permissions(test_client, admin_headers, standard_user, access): + """通过真实 HTTP 与持久化数据验证普通编辑者、读取者和未授权用户的边界。""" + from yuxi.models.providers.cache import model_cache + + embedding_models = model_cache.get_all_specs("embedding") + embedding_spec = os.getenv("TEST_KB_EMBEDDING_MODEL") + if not embedding_spec: + if not embedding_models: + pytest.skip("No embedding model is configured; set TEST_KB_EMBEDDING_MODEL or configure a provider.") + embedding_spec = embedding_models[0].spec + scope = {"access_level": "user", "user_uids": [standard_user["user"]["uid"]]} + owner = await test_client.get("/api/auth/me", headers=admin_headers) + assert owner.status_code == 200, owner.text + private_scope = {"access_level": "user", "user_uids": [owner.json()["uid"]]} + database = await _create_test_database( + test_client, + admin_headers, + { + "version": 2, + "read_scope": scope if access != "none" else private_scope, + "manage_scope": scope if access == "manage" else None, + }, + embedding_model_spec=embedding_spec, + ) + kb_id = database["kb_id"] + user_headers = standard_user["headers"] + try: + configuration = await test_client.put( + f"/api/knowledge/databases/{kb_id}", + headers=admin_headers, + json={ + "name": database["name"], + "description": "Delegated knowledge content test", + "additional_params": {"api_key": "delegation-test-secret"}, + }, + ) + assert configuration.status_code == 200, configuration.text + + listing = await test_client.get("/api/knowledge/databases/accessible", headers=user_headers) + assert listing.status_code == 200, listing.text + visible = {item["kb_id"]: item for item in listing.json()["databases"]} + assert (kb_id in visible) is (access != "none") + detail = await test_client.get(f"/api/knowledge/databases/{kb_id}", headers=user_headers) + assert detail.status_code == (403 if access == "none" else 200), detail.text + if access != "none": + for response in (visible[kb_id], detail.json()): + assert response["can_edit_documents"] is (access == "manage") + assert response["can_manage"] is False + assert "api_key" not in response["metadata"] + assert "api_key" not in response["additional_params"] + + for method, route, payload in [ + ("put", f"/api/knowledge/databases/{kb_id}", {"name": "forbidden", "description": "forbidden"}), + ("delete", f"/api/knowledge/databases/{kb_id}", None), + ("post", f"/api/knowledge/databases/{kb_id}/graph-build/reset", {}), + ("put", f"/api/knowledge/databases/{kb_id}/query-params", {}), + ("post", f"/api/evaluation/databases/{kb_id}/datasets/generate", {"llm_model_spec": "unused"}), + ]: + forbidden = await test_client.request(method, route, json=payload, headers=user_headers) + _assert_forbidden_response(forbidden) + + folder = await test_client.post( + f"/api/knowledge/databases/{kb_id}/folders", + json={"folder_name": "delegated", "parent_id": None}, + headers=user_headers, + ) + assert folder.status_code == (200 if access == "manage" else 403), folder.text + text_bytes = b"Delegated editors can maintain documents inside their assigned knowledge base." + upload = await test_client.post( + "/api/knowledge/files/upload", + params={"kb_id": kb_id}, + files={"file": ("delegated.txt", text_bytes, "text/plain")}, + headers=user_headers, + ) + assert upload.status_code == (200 if access == "manage" else 403), upload.text + without_database = await test_client.post( + "/api/knowledge/files/upload", + files={"file": ("unscoped.txt", text_bytes, "text/plain")}, + headers=user_headers, + ) + _assert_forbidden_response(without_database) + if access != "manage": + forbidden_parse = await test_client.post( + f"/api/knowledge/databases/{kb_id}/documents/parse", + json={"file_ids": ["unavailable"]}, + headers=user_headers, + ) + _assert_forbidden_response(forbidden_parse) + return + + folder_id = folder.json()["file_id"] + uploaded = upload.json() + item = uploaded["file_path"] + content_params = { + "content_hashes": {item: uploaded["content_hash"]}, + "file_sizes": {item: uploaded["size"]}, + "parent_id": folder_id, + } + for route in ("documents", "documents/add"): + foreign = "minio://knowledgebases/other-kb/upload/private.txt" + for source, params in [ + (foreign, {"content_hashes": {foreign: "hash"}}), + (item, {"_preprocessed_map": {item: {"path": foreign, "content_hash": "hash"}}}), + ]: + denied_import = await test_client.post( + f"/api/knowledge/databases/{kb_id}/{route}", + json={"items": [source], "params": params}, + headers=user_headers, + ) + _assert_forbidden_response(denied_import) + assert "该知识库" in denied_import.json()["detail"] + + added = await test_client.post( + f"/api/knowledge/databases/{kb_id}/documents/add", + json={"items": [item], "params": content_params}, + headers=user_headers, + ) + assert added.status_code == 200, added.text + assert added.json()["added"] == 1 + doc_id = added.json()["items"][0]["file_id"] + original = await test_client.get( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/download", + headers=user_headers, + ) + assert original.status_code == 200, original.text + assert original.content == text_bytes + + parsed = await test_client.post( + f"/api/knowledge/databases/{kb_id}/documents/parse", + json={"file_ids": [doc_id]}, + headers=user_headers, + ) + assert parsed.status_code == 200, parsed.text + for _ in range(120): + persisted = await test_client.get( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/basic", headers=user_headers + ) + assert persisted.status_code == 200, persisted.text + status = persisted.json()["meta"]["status"] + if status in {"parsed", "indexed", "error_parsing"}: + break + await asyncio.sleep(0.5) + assert status == "parsed", persisted.text + parsed_content = await test_client.get( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/content", headers=user_headers + ) + assert parsed_content.status_code == 200, parsed_content.text + assert "Delegated editors" in str(parsed_content.json()) + + indexed = await test_client.post( + f"/api/knowledge/databases/{kb_id}/documents/index", + json={"file_ids": [doc_id]}, + headers=user_headers, + ) + assert indexed.status_code == 200, indexed.text + for _ in range(120): + persisted = await test_client.get( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/basic", headers=user_headers + ) + assert persisted.status_code == 200, persisted.text + status = persisted.json()["meta"]["status"] + if status in {"indexed", "error_indexing"}: + break + await asyncio.sleep(0.5) + assert status == "indexed", persisted.text + indexed_content = await test_client.get( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/content", headers=user_headers + ) + assert indexed_content.status_code == 200, indexed_content.text + indexed_chunks = indexed_content.json()["lines"] + assert indexed_chunks + assert "Delegated editors" in indexed_chunks[0]["content"] + + renamed = await test_client.put( + f"/api/knowledge/databases/{kb_id}/folders/{folder_id}/rename", + json={"folder_name": "renamed"}, + headers=user_headers, + ) + assert renamed.status_code == 200, renamed.text + moved = await test_client.put( + f"/api/knowledge/databases/{kb_id}/documents/{doc_id}/move", + json={"new_parent_id": None}, + headers=user_headers, + ) + assert moved.status_code == 200, moved.text + root_listing = await test_client.get(f"/api/knowledge/databases/{kb_id}/documents", headers=user_headers) + assert root_listing.status_code == 200, root_listing.text + assert doc_id in {row["file_id"] for row in root_listing.json()["items"]} + deleted = await test_client.delete(f"/api/knowledge/databases/{kb_id}/documents/{doc_id}", headers=user_headers) + assert deleted.status_code == 200, deleted.text + final_listing = await test_client.get(f"/api/knowledge/databases/{kb_id}/documents", headers=user_headers) + assert final_listing.status_code == 200, final_listing.text + assert doc_id not in {row["file_id"] for row in final_listing.json()["items"]} + persisted_database = await test_client.get(f"/api/knowledge/databases/{kb_id}", headers=admin_headers) + assert persisted_database.json()["can_manage"] is True + assert persisted_database.json()["additional_params"]["api_key"] == "delegation-test-secret" + finally: + deleted_database = await test_client.delete(f"/api/knowledge/databases/{kb_id}", headers=admin_headers) + assert deleted_database.status_code in {200, 404}, deleted_database.text diff --git a/backend/test/integration/repositories/test_knowledge_file_upsert.py b/backend/test/integration/repositories/test_knowledge_file_upsert.py new file mode 100644 index 0000000000..2df619d573 --- /dev/null +++ b/backend/test/integration/repositories/test_knowledge_file_upsert.py @@ -0,0 +1,131 @@ +"""真实 PostgreSQL 中的文件 ID 碰撞与同库写入回归。""" + +import os +import uuid + +import pytest +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from yuxi.repositories import knowledge_file_repository as repository_module +from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository +from yuxi.storage.postgres.manager import PostgresManager +from yuxi.storage.postgres.models_knowledge import KnowledgeBase, KnowledgeFile + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest.fixture(scope="session", autouse=True) +def ensure_live_api_schema(): + """本测试只使用独立 Schema。""" + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_test_knowledge_resources(): + """资源由局部 fixture 清理。""" + yield + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_test_sandboxes(): + """本测试不创建沙盒。""" + yield + + +@pytest.fixture +async def file_store(monkeypatch): + """使用独立表和真实事务,避免触及运行实例的文件记录。""" + schema = f"pytest_file_upsert_{uuid.uuid4().hex[:16]}" + admin_engine = create_async_engine(os.environ["POSTGRES_URL"]) + async with admin_engine.begin() as connection: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + + engine = create_async_engine(os.environ["POSTGRES_URL"], connect_args={"server_settings": {"search_path": schema}}) + manager = object.__new__(PostgresManager) + PostgresManager.__init__(manager) + manager.async_engine = engine + manager.AsyncSession = async_sessionmaker(engine, expire_on_commit=False) + manager._initialized = True + monkeypatch.setattr(repository_module, "pg_manager", manager) + + try: + async with engine.begin() as connection: + await connection.run_sync(KnowledgeBase.__table__.create) + await connection.run_sync(KnowledgeFile.__table__.create) + async with manager.get_async_session_context() as session: + session.add_all( + [ + KnowledgeBase(kb_id="owner-kb", name="Owner", kb_type="milvus"), + KnowledgeBase(kb_id="editor-kb", name="Editor", kb_type="milvus"), + ] + ) + yield manager + finally: + await engine.dispose() + async with admin_engine.begin() as connection: + await connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + await admin_engine.dispose() + + +async def _read_file_row(manager: PostgresManager, file_id: str) -> dict: + """通过新事务回读整行,包含更新时刻等隐式字段。""" + async with manager.get_async_session_context() as session: + result = await session.execute(select(KnowledgeFile.__table__).where(KnowledgeFile.file_id == file_id)) + return dict(result.mappings().one()) + + +async def test_upsert_rejects_cross_kb_collision_and_preserves_foreign_row(file_store): + """跨库碰撞必须报错,其他库的整行保持原值。""" + repository = KnowledgeFileRepository() + file_id = "file_deadbe" + await repository.upsert( + file_id, + { + "kb_id": "owner-kb", + "filename": "owner.md", + "path": "owner/original.md", + "status": "indexed", + "content_hash": "original-hash", + "processing_params": {"keep": True}, + "created_by": "owner", + }, + ) + before = await _read_file_row(file_store, file_id) + + with pytest.raises(ValueError, match="another knowledge base"): + await repository.upsert( + file_id, + { + "kb_id": "editor-kb", + "filename": "replacement.md", + "path": "editor/replacement.md", + "status": "uploaded", + "content_hash": "replacement-hash", + "processing_params": {"replace": True}, + "created_by": "editor", + }, + ) + + assert await _read_file_row(file_store, file_id) == before + + +async def test_upsert_updates_existing_file_in_same_kb(file_store): + """同库文件继续允许更新,创建信息和主键保持原值。""" + repository = KnowledgeFileRepository() + file_id = "file_deadbe" + await repository.upsert(file_id, {"kb_id": "editor-kb", "filename": "original.md", "created_by": "creator"}) + before = await _read_file_row(file_store, file_id) + + await repository.upsert( + file_id, + {"kb_id": "editor-kb", "filename": "updated.md", "status": "parsed", "updated_by": "editor"}, + ) + + after = await _read_file_row(file_store, file_id) + assert after["id"] == before["id"] + assert after["kb_id"] == "editor-kb" + assert after["filename"] == "updated.md" + assert after["status"] == "parsed" + assert after["created_by"] == "creator" + assert after["created_at"] == before["created_at"] + assert after["updated_by"] == "editor" diff --git a/backend/test/unit/knowledge/test_file_listing_scaling.py b/backend/test/unit/knowledge/test_file_listing_scaling.py index b4aba7104f..e9bb193fd7 100644 --- a/backend/test/unit/knowledge/test_file_listing_scaling.py +++ b/backend/test/unit/knowledge/test_file_listing_scaling.py @@ -244,12 +244,17 @@ async def get_all(self): assert [db.kb_id for db in result] == ["kb_good"] -async def test_get_databases_by_user_sets_permission_and_redacts_readonly_secrets(monkeypatch): +async def test_get_databases_by_user_redacts_secrets_except_for_admin_managers(monkeypatch): class SecretKnowledgeBaseRepository(FakeKnowledgeBaseRepository): async def get_all(self): record = await self.get_by_kb_id("kb_1") record.additional_params = {"dify_token": "secret", "chunk_preset_id": "general"} record.created_by = "user_1" + record.share_config = { + "version": 2, + "read_scope": {"access_level": "global"}, + "manage_scope": {"access_level": "user", "user_uids": ["editor"]}, + } return [record] monkeypatch.setattr( @@ -260,6 +265,8 @@ async def get_all(self): readonly = await manager.get_databases_by_user({"uid": "user_2", "role": "admin", "department_id": None}) owner = await manager.get_databases_by_user({"uid": "user_1", "role": "admin", "department_id": None}) + editor = await manager.get_databases_by_user({"uid": "editor", "role": "user", "department_id": None}) + ordinary_owner = await manager.get_databases_by_user({"uid": "user_1", "role": "user", "department_id": None}) assert readonly[0].effective_permission == ResourcePermission.READ assert readonly[0].can_manage is False @@ -267,6 +274,11 @@ async def get_all(self): assert owner[0].effective_permission == ResourcePermission.MANAGE assert owner[0].can_manage is True assert owner[0].additional_params["dify_token"] == "secret" + for content_editor in (editor[0], ordinary_owner[0]): + assert content_editor.effective_permission == ResourcePermission.MANAGE + assert content_editor.can_edit_documents is True + assert content_editor.can_manage is False + assert "dify_token" not in content_editor.additional_params @pytest.mark.parametrize( diff --git a/backend/test/unit/permissions/test_resource_permission.py b/backend/test/unit/permissions/test_resource_permission.py index ccf219ba33..966eb918de 100644 --- a/backend/test/unit/permissions/test_resource_permission.py +++ b/backend/test/unit/permissions/test_resource_permission.py @@ -28,7 +28,7 @@ def test_knowledge_base_global_read_and_department_manage(): } resource = _resource(share_config=config) - assert resolve_knowledge_base_permission(_user(department_id=1), resource) == ResourcePermission.READ + assert resolve_knowledge_base_permission(_user(department_id=1), resource) == ResourcePermission.MANAGE managing_admin = _user(uid="admin-1", role="admin", department_id=1) readonly_admin = _user(uid="other", role="admin", department_id=2) assert resolve_knowledge_base_permission(managing_admin, resource) == ResourcePermission.MANAGE @@ -118,7 +118,7 @@ def test_global_knowledge_base_share_remains_manage_for_admin(): ) assert resolve_knowledge_base_permission(_user(role="admin"), resource) == ResourcePermission.MANAGE - assert resolve_knowledge_base_permission(_user(role="user"), resource) == ResourcePermission.READ + assert resolve_knowledge_base_permission(_user(role="user"), resource) == ResourcePermission.MANAGE def test_legacy_permission_config_is_rejected_at_runtime(): @@ -154,7 +154,7 @@ def test_manage_only_scope_also_grants_read_to_matching_users(): assert ( resolve_knowledge_base_permission(_user(role="admin", department_id=1), resource) == ResourcePermission.MANAGE ) - assert resolve_knowledge_base_permission(_user(department_id=1), resource) == ResourcePermission.READ + assert resolve_knowledge_base_permission(_user(department_id=1), resource) == ResourcePermission.MANAGE assert resolve_knowledge_base_permission(_user(role="admin", department_id=2), resource) == ResourcePermission.NONE diff --git a/backend/test/unit/routers/test_evaluation_resource_permission.py b/backend/test/unit/routers/test_evaluation_resource_permission.py index e625a37999..84f21b0933 100644 --- a/backend/test/unit/routers/test_evaluation_resource_permission.py +++ b/backend/test/unit/routers/test_evaluation_resource_permission.py @@ -56,7 +56,7 @@ async def fake_get_database_info(_kb_id): assert exc_info.value.status_code == 403 -def test_evaluation_routes_require_admin_role(): +def test_evaluation_mutation_routes_require_admin_role(): app = FastAPI() app.include_router(knowledge_eval_router.evaluation) @@ -65,7 +65,42 @@ async def fake_required_user(): app.dependency_overrides[get_required_user] = fake_required_user - response = TestClient(app).get("/evaluation/databases/kb-1/datasets") + response = TestClient(app).delete("/evaluation/datasets/dataset-1") assert response.status_code == 403 assert response.json()["detail"] == "需要管理员权限" + + +@pytest.mark.parametrize(("uid", "expected_status"), [("reader", 200), ("unshared", 403)]) +def test_dataset_download_uses_the_owning_database_read_scope(monkeypatch, uid, expected_status): + app = FastAPI() + app.include_router(knowledge_eval_router.evaluation) + + async def fake_required_user(): + return SimpleNamespace(uid=uid, role="user", department_id=1) + + async def fake_get_dataset(_repository, _dataset_id): + return SimpleNamespace(kb_id="kb-1") + + async def fake_get_database_info(_kb_id): + return { + "created_by": "owner", + "share_config": { + "version": 2, + "read_scope": {"access_level": "user", "user_uids": ["reader"]}, + "manage_scope": None, + }, + } + + async def fake_export(_service, _dataset_id): + return {"filename": "dataset.jsonl", "content": '{"query":"authorized"}\n'} + + app.dependency_overrides[get_required_user] = fake_required_user + monkeypatch.setattr(knowledge_eval_router.EvaluationRepository, "get_dataset", fake_get_dataset) + monkeypatch.setattr(knowledge_permissions.knowledge_base, "get_database_info", fake_get_database_info) + monkeypatch.setattr(knowledge_eval_router.EvaluationService, "export_dataset_jsonl", fake_export) + response = TestClient(app).get("/evaluation/datasets/dataset-1/download") + + assert response.status_code == expected_status + if expected_status == 200: + assert response.content == b'{"query":"authorized"}\n' diff --git a/backend/test/unit/routers/test_knowledge_content_delegation.py b/backend/test/unit/routers/test_knowledge_content_delegation.py new file mode 100644 index 0000000000..c9d32bf82b --- /dev/null +++ b/backend/test/unit/routers/test_knowledge_content_delegation.py @@ -0,0 +1,291 @@ +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from server.routers import knowledge_router +from server.utils.auth_middleware import get_required_user +from yuxi.knowledge.graphs.milvus_graph_service import MilvusGraphService +from yuxi.knowledge.read_models import KnowledgeBaseDetail + + +def _database(): + return KnowledgeBaseDetail( + kb_id="kb-1", + name="授权库", + description="", + kb_type="milvus", + embedding_model_spec=None, + llm_model_spec=None, + query_params={}, + additional_params={ + "dify_token": "secret", + "chunk_size": 100, + "chunk_parser_config": {"chunk_token_num": 2048}, + "graph_build_config": { + "locked": True, + "extractor_type": "llm", + "extractor_options": { + "model_spec": "test/model", + "model_params": { + "temperature": 0.2, + "max_tokens": 1000, + "max_completion_tokens": 800, + "max_output_tokens": 600, + "api_key": "nested-model-secret", + "default_headers": { + "Authorization": "Bearer default-secret", + "X-Gateway-Credential": "custom-default-secret", + }, + "extra_headers": {"X-Gateway-Credential": "custom-extra-secret"}, + "extra_body": {"routing": [{"accessToken": "routing-secret", "region": "example"}]}, + }, + }, + }, + }, + created_by="owner", + created_at=None, + share_config={ + "version": 2, + "read_scope": {"access_level": "user", "user_uids": ["editor", "reader"]}, + "manage_scope": {"access_level": "user", "user_uids": ["editor"]}, + }, + ) + + +def _client(monkeypatch, uid="editor", role="user", database=None): + database = database or _database() + + async def fake_database_info(*_args, **_kwargs): + return database + + async def fake_support(*_args): + return database, True + + async def fake_required_user(): + return SimpleNamespace(uid=uid, role=role, department_id=1) + + app = FastAPI() + app.include_router(knowledge_router.knowledge) + app.dependency_overrides[get_required_user] = fake_required_user + monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_database_info) + monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_document_support", fake_support) + return TestClient(app) + + +@pytest.mark.parametrize("endpoint", ["detail", "accessible", "graph-status"]) +@pytest.mark.parametrize( + ("uid", "role", "can_manage"), + [("reader", "user", False), ("editor", "user", False), ("reader", "admin", False), ("editor", "admin", True)], +) +def test_read_projections_remove_nested_credentials_without_changing_stored_configuration( + monkeypatch, endpoint, uid, role, can_manage +): + """三个读取出口保护嵌套模型凭据,管理员仍可编辑原始配置。""" + database = _database() + original_params = deepcopy(database.additional_params) + client = _client(monkeypatch, uid, role, database) + + async def list_databases(_uid): + return [database] + + monkeypatch.setattr(knowledge_router.knowledge_base, "get_databases_by_uid", list_databases) + service = MilvusGraphService( + kb_repo=SimpleNamespace(get_by_kb_id=AsyncMock(return_value=database)), + chunk_repo=SimpleNamespace( + count_by_kb_id=AsyncMock(return_value=0), + count_graph_pending_by_kb_id=AsyncMock(return_value=0), + count_graph_indexed_by_kb_id=AsyncMock(return_value=0), + count_graph_structure_indexed_by_kb_id=AsyncMock(return_value=0), + count_graph_extraction_statuses_by_kb_id=AsyncMock(return_value={}), + ), + graph_repo=SimpleNamespace( + count_by_kb_id=AsyncMock(return_value=(0, 0)), + count_vector_statuses_by_kb_id=AsyncMock(return_value={}), + ), + ) + monkeypatch.setattr(knowledge_router, "MilvusGraphService", lambda: service) + monkeypatch.setattr(knowledge_router, "tasker", SimpleNamespace(find_task_by_payload=AsyncMock(return_value=None))) + path = { + "detail": "/knowledge/databases/kb-1", + "accessible": "/knowledge/databases/accessible", + "graph-status": "/knowledge/databases/kb-1/graph-build/status", + }[endpoint] + + response = client.get(path) + + assert response.status_code == 200, response.text + body = response.json() + if endpoint == "graph-status": + projections = [body["config"]] + else: + if endpoint == "accessible": + body = body["databases"][0] + for field in ("additional_params", "metadata"): + assert body[field]["chunk_parser_config"] == {"chunk_token_num": 2048} + projections = [body[field]["graph_build_config"] for field in ("additional_params", "metadata")] + for projection in projections: + model_params = projection["extractor_options"]["model_params"] + assert model_params["temperature"] == 0.2 + assert model_params["max_tokens"] == 1000 + assert model_params["max_completion_tokens"] == 800 + assert model_params["max_output_tokens"] == 600 + if can_manage and endpoint != "accessible": + assert model_params == original_params["graph_build_config"]["extractor_options"]["model_params"] + else: + assert "api_key" not in model_params + assert "default_headers" not in model_params + assert "extra_headers" not in model_params + assert model_params["extra_body"]["routing"] == [{"region": "example"}] + assert database.additional_params == original_params + runtime_options = service._runtime_extractor_options(database.additional_params["graph_build_config"]) + assert runtime_options["model_params"] == original_params["graph_build_config"]["extractor_options"]["model_params"] + + +@pytest.mark.parametrize(("uid", "can_edit"), [("editor", True), ("reader", False)]) +def test_delegated_users_read_detail_without_configuration_credentials(monkeypatch, uid, can_edit): + response = _client(monkeypatch, uid).get("/knowledge/databases/kb-1") + + assert response.status_code == 200, response.text + body = response.json() + assert body["can_edit_documents"] is can_edit + assert body["can_manage"] is False + assert "dify_token" not in body["additional_params"] + assert "dify_token" not in body["metadata"] + + +@pytest.mark.parametrize(("uid", "status"), [("editor", 200), ("reader", 403), ("unshared", 403)]) +def test_only_delegated_editor_can_create_folder(monkeypatch, uid, status): + calls = [] + + async def fake_create_folder(kb_id, name, parent_id, operator_id): + calls.append((kb_id, name, parent_id, operator_id)) + return {"file_id": "folder-1"} + + monkeypatch.setattr(knowledge_router.knowledge_base, "create_folder", fake_create_folder) + response = _client(monkeypatch, uid).post( + "/knowledge/databases/kb-1/folders", json={"folder_name": "测试", "parent_id": None} + ) + + assert response.status_code == status, response.text + assert calls == ([("kb-1", "测试", None, "editor")] if status == 200 else []) + + +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ("delete", "/knowledge/databases/kb-1", None), + ("put", "/knowledge/databases/kb-1", {"name": "changed", "description": "changed"}), + ("post", "/knowledge/databases/kb-1/graph-build/reset", {}), + ("put", "/knowledge/databases/kb-1/query-params", {}), + ("post", "/knowledge/databases/kb-1/mindmap/generate", {"file_ids": []}), + ], +) +def test_delegated_editor_cannot_perform_database_administration(monkeypatch, method, path, body): + response = _client(monkeypatch).request(method, path, json=body) + + assert response.status_code == 403 + assert response.json()["detail"] == "需要管理员权限" + + +def test_unshared_user_cannot_read_database(monkeypatch): + response = _client(monkeypatch, "unshared").get("/knowledge/databases/kb-1") + + assert response.status_code == 403 + + +@pytest.mark.parametrize("route", ["/documents", "/documents/add"]) +@pytest.mark.parametrize("override_path", [False, True]) +def test_delegated_editor_cannot_import_another_database_object(monkeypatch, route, override_path): + item = "minio://knowledgebases/kb-1/upload/document.txt" + source = "minio://knowledgebases/other-kb/upload/private.txt" + params = {"content_hashes": {item: "hash"}} + if override_path: + params["_preprocessed_map"] = {item: {"content_hash": "hash", "path": source}} + else: + item = source + params = {"content_hashes": {item: "hash"}} + + response = _client(monkeypatch).post(f"/knowledge/databases/kb-1{route}", json={"items": [item], "params": params}) + + assert response.status_code == 403, response.text + assert "该知识库" in response.json()["detail"] + + +@pytest.mark.parametrize("route", ["/files/upload", "/files/fetch-url"]) +def test_ordinary_user_cannot_upload_without_a_database(monkeypatch, route): + client = _client(monkeypatch) + if route.endswith("upload"): + response = client.post(f"/knowledge{route}", files={"file": ("note.txt", b"note", "text/plain")}) + else: + response = client.post(f"/knowledge{route}", json={"url": "https://example.com"}) + + assert response.status_code == 403 + assert ("知识库" if route.endswith("upload") else "管理员") in response.json()["detail"] + + +@pytest.mark.parametrize( + "source", + [ + "minio://other-bucket/kb-1/upload/private.txt", + "minio://knowledgebases/kb-1/upload/../private.txt", + "minio://knowledgebases/kb-1/upload/%2e%2e/private.txt", + "minio://knowledgebases/kb-1/upload/..%5cprivate.txt", + ], +) +def test_delegated_editor_cannot_import_outside_the_upload_prefix(monkeypatch, source): + response = _client(monkeypatch).post( + "/knowledge/databases/kb-1/documents/add", + json={"items": [source], "params": {"content_hashes": {source: "hash"}}}, + ) + + assert response.status_code == 403 + assert "该知识库" in response.json()["detail"] + + +@pytest.mark.parametrize("filename", ["../../private.txt", "..\\..\\private.txt"]) +def test_delegated_upload_rejects_directory_filename_before_file_read_or_storage(monkeypatch, filename): + calls = [] + + async def unexpected_side_effect(*_args, **_kwargs): + calls.append("side effect") + raise AssertionError("非法文件名不能读取或上传内容") + + monkeypatch.setattr(knowledge_router, "read_upload_with_limit", unexpected_side_effect) + monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", unexpected_side_effect) + response = _client(monkeypatch).post( + "/knowledge/files/upload?kb_id=kb-1", files={"file": (filename, b"private", "text/plain")} + ) + + assert response.status_code == 400 + assert "目录路径" in response.json()["detail"] + assert not calls + + +def test_delegated_url_import_requires_admin_before_fetch_or_storage(monkeypatch): + client = _client(monkeypatch) + calls = [] + + async def unexpected_side_effect(*_args, **_kwargs): + calls.append("side effect") + raise AssertionError("普通内容编辑者不能抓取或上传 URL 文档") + + monkeypatch.setattr(knowledge_router, "fetch_url_content", unexpected_side_effect) + monkeypatch.setattr(knowledge_router, "get_minio_client", lambda: calls.append("storage")) + response = client.post("/knowledge/files/fetch-url", json={"url": "https://example.com", "kb_id": "kb-1"}) + + assert response.status_code == 403 + assert "管理员" in response.json()["detail"] + assert not calls + + +def test_administrator_still_receives_database_management_permission(monkeypatch): + response = _client(monkeypatch, "editor", "admin").get("/knowledge/databases/kb-1") + + assert response.status_code == 200 + assert response.json()["can_manage"] is True + assert response.json()["can_edit_documents"] is True + assert response.json()["additional_params"]["dify_token"] == "secret" diff --git a/backend/test/unit/routers/test_knowledge_document_storage_isolation.py b/backend/test/unit/routers/test_knowledge_document_storage_isolation.py new file mode 100644 index 0000000000..bcc7f3d9a1 --- /dev/null +++ b/backend/test/unit/routers/test_knowledge_document_storage_isolation.py @@ -0,0 +1,93 @@ +from types import SimpleNamespace + +import pytest + +from server.routers import knowledge_router +from yuxi.storage.minio.client import MinIOClient + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("batch", [False, True]) +@pytest.mark.parametrize( + "source", + [ + "minio://knowledgebases/kb-a/upload/source.txt", + "http://minio:9000/knowledgebases/kb-a/upload/source.txt", + "minio://knowledgebases/kb-b-other/upload/source.txt", + "minio://public/kb-b/upload/source.txt", + "minio://knowledgebases/kb-b/upload/%2e%2e/%2e%2e/kb-a/upload/source.txt", + ], +) +async def test_delete_foreign_source_preserves_original_and_removes_current_document(monkeypatch, batch, source): + """跨库引用不授予原件删除权,本库记录和派生物仍正常清理。""" + source_object = knowledge_router.parse_minio_url(source) + parsed = ("knowledgebases", "kb-b/parsed/file-b.md") + preview = ("knowledgebases", "kb-b/preview/file-b.pdf") + objects = {source_object: b"foreign original", parsed: b"derived markdown", preview: b"derived preview"} + documents = {"file-b": {"meta": {"path": source, "filename": "source.txt"}}} + + class ObjectStorage: + KB_BUCKETS = MinIOClient.KB_BUCKETS + + async def adelete_file(self, bucket_name, object_name): + objects.pop((bucket_name, object_name), None) + + async def support(kb_id, operation): + assert kb_id == "kb-b" + + async def basic_info(kb_id, doc_id): + assert kb_id == "kb-b" + return documents[doc_id] + + async def delete_file(kb_id, doc_id): + assert kb_id == "kb-b" + del documents[doc_id] + + async def remove_mindmap(*args): + return None + + monkeypatch.setattr(knowledge_router, "get_minio_client", ObjectStorage) + monkeypatch.setattr(knowledge_router, "_ensure_database_supports_documents", support) + monkeypatch.setattr(knowledge_router.knowledge_base, "get_file_basic_info", basic_info) + monkeypatch.setattr(knowledge_router.knowledge_base, "delete_file", delete_file) + monkeypatch.setattr(knowledge_router, "remove_file_from_mindmap", remove_mindmap) + monkeypatch.setattr(knowledge_router, "batch_remove_files_from_mindmap", remove_mindmap) + + editor = SimpleNamespace(uid="editor-b", role="user") + if batch: + result = await knowledge_router.batch_delete_documents("kb-b", ["file-b"], editor) + assert result["deleted_count"] == 1 + else: + result = await knowledge_router.delete_document("kb-b", "file-b", editor) + assert result == {"message": "删除成功"} + + assert documents == {} + assert objects == {source_object: b"foreign original"} + + +@pytest.mark.parametrize( + "source", + [ + "minio://knowledgebases/kb-b/upload/source.txt", + "http://minio:9000/knowledgebases/kb-b/upload/folder/source%20name.txt", + ], +) +async def test_delete_owned_source_removes_original_and_derived_objects(monkeypatch, source): + """本库原件及派生物按原有语义删除。""" + objects = { + knowledge_router.parse_minio_url(source): b"original", + ("knowledgebases", "kb-b/parsed/file-b.md"): b"markdown", + ("knowledgebases", "kb-b/preview/file-b.pdf"): b"preview", + } + + class ObjectStorage: + KB_BUCKETS = MinIOClient.KB_BUCKETS + + async def adelete_file(self, bucket_name, object_name): + objects.pop((bucket_name, object_name), None) + + monkeypatch.setattr(knowledge_router, "get_minio_client", ObjectStorage) + await knowledge_router._delete_document_storage_objects("kb-b", "file-b", source) + + assert objects == {} diff --git a/backend/test/unit/routers/test_knowledge_resource_permission.py b/backend/test/unit/routers/test_knowledge_resource_permission.py index e9e8f9838b..9ba45b57a6 100644 --- a/backend/test/unit/routers/test_knowledge_resource_permission.py +++ b/backend/test/unit/routers/test_knowledge_resource_permission.py @@ -31,7 +31,7 @@ def test_serialize_knowledge_base_redacts_credentials_from_compatibility_fields( assert "dify_token" not in response["metadata"] -@pytest.mark.parametrize(("uid", "role", "can_read"), [("admin-1", "admin", True), ("other-user", "user", False)]) +@pytest.mark.parametrize(("uid", "role", "can_read"), [("admin-1", "admin", True), ("other-user", "user", True)]) @pytest.mark.asyncio async def test_non_manager_cannot_manage_global_read_knowledge_base(monkeypatch, uid, role, can_read): database = { diff --git a/backend/test/unit/routers/test_knowledge_router_cleanup.py b/backend/test/unit/routers/test_knowledge_router_cleanup.py index 2ead392626..cf59dd3e26 100644 --- a/backend/test/unit/routers/test_knowledge_router_cleanup.py +++ b/backend/test/unit/routers/test_knowledge_router_cleanup.py @@ -12,6 +12,15 @@ pytestmark = pytest.mark.asyncio +@pytest.fixture(autouse=True) +def allow_authorized_upload(monkeypatch): + async def fake_permission(kb_id, current_user, required): + assert current_user.role == "admin" + assert required == knowledge_router.ResourcePermission.MANAGE + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", fake_permission) + + def _database_detail(**stats) -> KnowledgeBaseDetail: return KnowledgeBaseDetail( kb_id="kb_1", @@ -69,7 +78,7 @@ async def fake_document_file_exists(kb_id: str, filename: str) -> bool: result = await knowledge_router.document_file_exists( "kb_1", filename=" google_drive/shared_drives/engineering/playbook.txt ", - current_user=SimpleNamespace(uid="user_1"), + current_user=SimpleNamespace(uid="user_1", role="admin"), ) assert result == { @@ -97,7 +106,7 @@ async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> await knowledge_router.document_file_exists( "kb_1", filename=" ", - current_user=SimpleNamespace(uid="user_1"), + current_user=SimpleNamespace(uid="user_1", role="admin"), ) assert exc_info.value.status_code == 400 @@ -108,7 +117,7 @@ async def test_upload_file_rejects_jsonl_uploads(): upload = UploadFile(filename="dataset.jsonl", file=BytesIO(b'{"query":"hello"}\n')) with pytest.raises(HTTPException) as exc_info: - await knowledge_router.upload_file(upload, kb_id=None, current_user=SimpleNamespace(uid="user_1")) + await knowledge_router.upload_file(upload, kb_id=None, current_user=SimpleNamespace(uid="user_1", role="admin")) assert exc_info.value.status_code == 400 assert exc_info.value.detail == "Unsupported file type: .jsonl" @@ -117,8 +126,10 @@ async def test_upload_file_rejects_jsonl_uploads(): @pytest.mark.parametrize( "call_upload", [ - lambda upload: knowledge_router.upload_file(upload, kb_id="kb_1", current_user=SimpleNamespace(uid="user_1")), - lambda upload: knowledge_router.mark_it_down(upload, current_user=SimpleNamespace(uid="user_1")), + lambda upload: knowledge_router.upload_file( + upload, kb_id="kb_1", current_user=SimpleNamespace(uid="user_1", role="admin") + ), + lambda upload: knowledge_router.mark_it_down(upload, current_user=SimpleNamespace(uid="user_1", role="admin")), ], ids=["upload_file", "mark_it_down"], ) @@ -175,7 +186,9 @@ async def fake_upload_to_minio(*_args, **_kwargs) -> str: upload = UploadFile(filename="demo.txt", file=BytesIO(b"demo")) with pytest.raises(HTTPException) as exc_info: - await knowledge_router.upload_file(upload, kb_id=kb_id, current_user=SimpleNamespace(uid="user_1")) + await knowledge_router.upload_file( + upload, kb_id=kb_id, current_user=SimpleNamespace(uid="user_1", role="admin") + ) assert exc_info.value.status_code == status_code assert calls == {"read": 0, "upload": 0} @@ -227,7 +240,7 @@ async def test_parse_documents_rejects_oversized_direct_batch(): await knowledge_router.parse_documents( "kb_1", file_ids, - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert exc_info.value.status_code == 400 @@ -276,7 +289,7 @@ async def fake_enqueue_unique_by_payload(**kwargs): result = await knowledge_router.parse_pending_documents( "kb_1", - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "queued" @@ -336,7 +349,7 @@ async def fake_enqueue_unique_by_payload(**kwargs): result = await knowledge_router.reconcile_graph_build( "kb_1", data={"mode": "all_vectors"}, - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result == { @@ -406,7 +419,7 @@ async def fake_enqueue_unique_by_payload(**kwargs): result = await knowledge_router.index_pending_documents( "kb_1", payload=knowledge_router.PendingIndexDocumentsRequest(params=params), - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "queued" @@ -471,7 +484,7 @@ async def fake_enqueue(name: str, task_type: str, payload: dict): "kb_1", [item], params={"content_type": "file", "auto_index": True, "content_hashes": {item: "hash_1"}}, - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "queued" @@ -523,7 +536,7 @@ async def fake_enqueue(name: str, task_type: str, payload: dict): "content_hashes": {item1: "hash_1", item2: "hash_2"}, "source_paths": {item1: "folder_a/sub/doc1.txt", item2: "folder_a/doc2.txt"}, }, - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert len(captured_records) == 2 @@ -567,7 +580,7 @@ async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> await knowledge_router.add_uploaded_documents( "kb_1", payload, - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert exc_info.value.status_code == 400 @@ -609,7 +622,7 @@ async def fail_enqueue(*_args, **_kwargs): "source_paths": {item: "docs/demo.txt"}, }, ), - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "success" @@ -652,7 +665,7 @@ async def fake_migrate(context, *, kb_id: str, operator_id: str): response = await knowledge_router.start_virtual_folder_migration( "kb_1", - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert response == {"task_id": "task_migration_1", "created": True} @@ -711,7 +724,7 @@ async def fake_enqueue(name: str, task_type: str, payload: dict): result = await knowledge_router.parse_documents( "kb_1", payload=knowledge_router.ParseDocumentsRequest(file_ids=["file_1"], params=params), - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "queued" @@ -764,7 +777,7 @@ async def fake_enqueue_unique_by_payload(**kwargs): result = await knowledge_router.parse_pending_documents( "kb_1", payload=knowledge_router.PendingParseDocumentsRequest(params=params), - current_user=SimpleNamespace(uid="uid-user"), + current_user=SimpleNamespace(uid="uid-user", role="admin"), ) assert result["status"] == "queued" diff --git a/backend/test/unit/routers/test_knowledge_workspace_import.py b/backend/test/unit/routers/test_knowledge_workspace_import.py index bf2e1596b9..50f7ff7593 100644 --- a/backend/test/unit/routers/test_knowledge_workspace_import.py +++ b/backend/test/unit/routers/test_knowledge_workspace_import.py @@ -10,6 +10,16 @@ pytestmark = pytest.mark.asyncio +@pytest.fixture(autouse=True) +def allow_authorized_upload(monkeypatch): + async def fake_permission(kb_id, current_user, required): + assert kb_id == "db_1" + assert current_user.role == "admin" + assert required == knowledge_router.ResourcePermission.MANAGE + + monkeypatch.setattr(knowledge_router, "_ensure_database_permission", fake_permission) + + async def test_import_workspace_files_uploads_workspace_file_to_minio(monkeypatch): async def fake_ensure_database_supports_documents(slug: str, operation: str) -> None: assert slug == "db_1" @@ -37,7 +47,7 @@ async def fake_upload(bucket_name: str, file_name: str, data: bytes) -> str: fake_ensure_database_supports_documents, ) - user = SimpleNamespace(id="user_1") + user = SimpleNamespace(uid="user_1", role="admin") async def fake_read_workspace_file_bytes(*, path, current_user): assert path == "/note.md" @@ -83,7 +93,7 @@ async def fake_read_workspace_file_bytes(**_kwargs): with pytest.raises(HTTPException) as exc_info: await knowledge_router.import_workspace_files( knowledge_router.WorkspaceImportRequest(kb_id="db_1", paths=["/folder"]), - current_user=SimpleNamespace(id="user_1"), + current_user=SimpleNamespace(uid="user_1", role="admin"), ) assert exc_info.value.status_code == 400 diff --git a/docs/advanced/knowledge-base-api.md b/docs/advanced/knowledge-base-api.md index e89a006103..ad5f35c5a9 100644 --- a/docs/advanced/knowledge-base-api.md +++ b/docs/advanced/knowledge-base-api.md @@ -4,16 +4,18 @@ ## 权限 -文档管理接口要求知识库管理权限;原始文件上传接口还要求管理员身份。读取和外部查询接口要求知识库读取权限。 +文档维护接口要求目标知识库的 manage 权限,普通成员也可以获得该授权;整库配置、共享、删除、图谱与评估管理同时要求管理员角色。读取和外部查询接口要求知识库 read 权限。 知识库的 `share_config` 使用 version 2,分别保存 `read_scope` 和 `manage_scope`。范围的 `access_level` 可以是 `global`、`department` 或 `user`;管理范围必须包含在读取范围内。 -| 用户 | 读取 | 管理 | -| --- | --- | --- | -| 创建者 | 有 | 有 | -| `superadmin` | 有 | 有 | -| `admin` | 命中读取范围时有 | 命中管理范围时有 | -| `user` | 命中读取范围时有 | 无 | +| 用户 | 读取 | 文档维护 | 整库管理 | +| --- | --- | --- | --- | +| 创建者 | 有 | 有 | 管理员角色时有 | +| `superadmin` | 有 | 有 | 有 | +| `admin` | 命中读取范围时有 | 命中管理范围时有 | 命中管理范围时有 | +| `user` | 命中读取范围时有 | 命中管理范围时有 | 无 | + +普通成员上传和工作区导入必须指定目标库;URL 抓取及未指定库的上传入口只供管理员使用。列表和详情通过 `can_edit_documents`、`can_manage` 区分两种维护能力,普通编辑成员不会收到连接器凭据。 前端显示和 Agent 配置只会缩小可见范围,最终授权由后端依赖和 repository/manager 查询执行。 diff --git a/docs/develop-guides/decisions/implemented/2026-10-09-knowledge-delegated-content-edit.md b/docs/develop-guides/decisions/implemented/2026-10-09-knowledge-delegated-content-edit.md new file mode 100644 index 0000000000..1ab5a3a916 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-10-09-knowledge-delegated-content-edit.md @@ -0,0 +1,53 @@ +# 知识库普通成员的授权访问与内容维护 + +状态:implemented +类型:feature +Owner:backend/package/yuxi/permissions/resource_permission.py + +## 问题 + +普通成员被加入知识库共享管理范围后,仍看不到知识库页面,后端角色上限与管理员依赖也阻止访问。团队需要让成员维护获授权知识库内的文档,同时由管理员负责知识库配置与授权。相关上游功能建议见 [Issue #1094](https://github.com/xerrors/Yuxi/issues/1094)。 + +## 决策 + +复用 version 2 的读取和管理范围,不新增权限枚举或数据库结构。命中管理范围的普通成员获得文档及文件夹维护能力;知识库配置、共享权限、连接凭据、整库删除、图谱和评估管理仍要求管理员角色及该库管理权限。读取范围允许普通成员查看该库文档与现有读取功能。创建知识库和全局管理功能仍由管理员执行。 + +### 实现方案 + +资源权限解析拥有单库范围判断,FastAPI 依赖拥有操作级授权,Manager 与 HTTP 响应拥有过滤与凭据脱敏。响应区分 `can_edit_documents` 与 `can_manage`,前端按这两个能力展示操作;路由只要求登录,单库授权由后端裁决。上传和工作区导入要求普通成员提供已授权的知识库;文档提交校验对象属于目标库,不能引用另一库的原始或预处理对象。前端切换账户或目标库、权限请求失败时清理旧库状态与弹窗载荷,迟到响应不能覆盖当前会话。 + +非整库管理者的响应递归移除配置中的凭据及任意模型认证请求头,保留模型 token 预算和文档分块参数。可访问摘要始终脱敏;管理员且具有该库管理权限时,详情和图谱状态保留可编辑配置。脱敏生成副本,持久配置与模型运行参数保持原值。 + +文件仓储拥有跨库文件 ID 隔离。新文件 ID 使用现有短随机值;upsert 命中其他知识库的记录时抛出 `ValueError`,原记录不变。ID 算法和数据库结构保持现状,碰撞产生可观察的导入失败,调用方可以重试。 + +文档删除的存储清理拥有原件归属判断:只有 documents bucket 内当前库前缀的对象可以删除。管理员或历史跨库引用不赋予原件删除所有权,删除引用记录时保留外库原件,继续清理本库派生物与记录。这一归属约束同时适用于管理员和普通编辑成员。 + +任务中心和网页 URL 导入保持管理员能力,普通成员通过文档状态查看解析和索引结果。URL 抓取授权、整库所有者转移、新角色、权限迁移和任务授权体系超出文档委托范围。 + +## 替代方案 + +- 将成员升级为管理员:增加系统管理能力,超出文档维护任务。 +- 仅开放菜单:路由和 API 仍拒绝授权用户,不能完成维护流程。 +- 普通成员获得完整管理能力:共享配置、连接凭据与整库删除的权限范围超过团队当前需求。 +- 新增独立编辑权限等级和共享字段:需要迁移与更多兼容逻辑;第一版复用已有授权意图,在操作边界区分角色。 + +## 后果 + +已有管理范围立即授予其中普通成员内容维护能力,包括全局或部门范围;上线前管理员需要确认现有范围确实表达编辑授权。文件删除属于内容维护授权,整库删除仍由管理员执行。解析和索引沿用后台任务,普通成员在文件列表查看最终状态。权限字段和数据库结构无需迁移。 + +普通文件夹上传使用叶子文件名作为 multipart 名称,通过既有 `source_paths` 保留目录结构,避免目录路径进入原文件上传名称。管理员原有上传流程保持可用。 + +## 验证 + +验证基于上游 main 的独立工作树和全新 Compose 状态,直接回读 schema 为 business 9 / knowledge 2。合成数据经过真实 HTTP、JWT、PostgreSQL、MinIO、Milvus 与 worker;Embedding 使用本地确定性 HTTP stub。该环境不证明真实模型质量、第三方知识库服务或生产部署结果。 + +| 验收主张 | 失败面 | 语义 Owner | 直接证据 / 命令 | 负向案例 | 当前结果 | +|---|---|---|---|---|---| +| 授权成员可维护文档,整库管理保持管理员边界 | 角色上限、操作授权及可见性 | `resource_permission.py`、`knowledge_permissions.py` 与知识库路由 | 权限与图谱相关 unit 最终集合 170 项;真实 HTTP 授权流程 3 项 | 未授权拒绝读取、只读拒绝写入;普通编辑拒绝整库配置、删除、图谱管理和评估写入 | Passed | +| 新开放读取出口保护嵌套模型凭据且保留运行配置 | 详情、可访问摘要及图谱状态响应 | `knowledge/utils/security.py`、`knowledge_response.py`、`MilvusGraphService` | 三出口与四身份 12 个 unit 案例;补丁后的真实 HTTP 探针验证编辑者、读取者和管理者的九种出口,PostgreSQL 原配置回读不变 | 仅绑定上游旧脱敏实现时,10 项因嵌套凭据未移除失败,管理员管理详情和状态 2 项仍成功 | Passed | +| 上传和删除不能损坏其他库的对象,文件 ID 碰撞保持原行 | 对象归属、删除清理与跨库 upsert | 知识库文档 executor 与 `KnowledgeFileRepository` | 真实 HTTP/MinIO 删除 4 项;真实 PostgreSQL upsert 2 项 | 编辑者和管理员单删、批删保留外库原件;跨库 ID 碰撞被拒绝且原行及更新时间保持不变 | Passed | +| 前端按单库能力展示操作并隔离旧上下文 | 切库、身份变化、权限失效及迟到响应 | `database.js`、文件表格、上传与搜索组件 | `pnpm run test:unit` 445 项;实际浏览器编辑用户、只读库、切身份、上传模式与深色/900px 页面 | 切库和会话失效后旧参数、上传队列、工作区导入和迟到写响应不能继续原操作 | Passed | +| 相关改动满足静态与构建约束 | lint、格式、链接及工程 gate | 仓库信任检查、Ruff、ESLint、Vite 与 VitePress | 工程信任检查及其 unit 63 项;Web lint/build、docs build、受影响 Python Ruff 与 `git diff --check` | 前端 SSR 替身必须转发上游按钮属性,配置入口的权限断言保持有效 | Passed | +| 外部 provider 与第三方知识库集成保持行为 | Dify、RAGFlow、真实模型与外部部署 | 各知识库实现与外部服务 | 本地独立环境没有这些服务或真实 provider | 不适用 | Not run | + +后端通用回归在 Linux 容器运行 `pytest test/unit -m 'not slow' --timeout=90 -q -p no:cacheprovider`,2,551 项通过。该集合执行在补充嵌套凭据案例前;补丁后的权限、响应及图谱回归为上述 170 项,不能将两集合相加作为全量计数。Windows 本地解释器因缺少 POSIX `os.O_DIRECTORY` 无法收集相关后端测试,最终证据采用 Linux 容器结果。 diff --git a/docs/intro/knowledge-base.md b/docs/intro/knowledge-base.md index 5bdc495f86..b3f9e8da13 100644 --- a/docs/intro/knowledge-base.md +++ b/docs/intro/knowledge-base.md @@ -42,7 +42,7 @@ Dify 和 Notion 是只读连接器,不能在 Yuxi 中上传、解析、索引 在知识库详情页点击“上传”,选择测试文档并设置 OCR、解析器和分块参数。勾选“上传后自动入库”可以在解析完成后继续建立索引;不勾选时,解析和索引分开操作。 -上传完成后点击“添加到知识库”。系统会创建文件记录并提交解析任务,在任务中心可以看到进度。文件状态表示具体阶段: +上传完成后点击“添加到知识库”。系统会创建文件记录并提交解析任务,在文件列表可以看到状态;管理员还可以在任务中心查看进度。文件状态表示具体阶段: | 状态 | 含义 | 下一步 | | --- | --- | --- | @@ -72,6 +72,8 @@ Dify 和 Notion 是只读连接器,不能在 Yuxi 中上传、解析、索引 在“访问权限”中分别设置读取范围和管理范围。范围可以是全局、指定部门或指定用户。创建者和 `superadmin` 具有管理权限;其他用户必须同时命中相应范围,`admin` 角色也不会自动获得所有部门或指定用户知识库的权限。 +普通成员命中读取范围后,可以进入知识库页面查看文档;命中管理范围后,可以上传、解析、入库、整理和删除文档。整库配置、共享范围及整库删除由具有该库管理权限的管理员操作。已有管理范围对其中的普通成员同样生效,授权前请确认全局或部门范围符合团队的编辑范围。 + 保存后,用一个不在读取范围内的账号确认知识库不会出现在列表中,再用范围内账号确认可以检索。前端隐藏入口不是权限证明,最终结果以后端列表和检索请求为准。 ## 5. 让智能体使用知识库 diff --git a/docs/mechanisms/knowledge-base.md b/docs/mechanisms/knowledge-base.md index 4ff0cf66c6..82af29c717 100644 --- a/docs/mechanisms/knowledge-base.md +++ b/docs/mechanisms/knowledge-base.md @@ -102,11 +102,21 @@ get_mindmap、search_file、download_kb_file 知识库的最终授权由后端依赖、Manager 可见性查询和具体工具目标校验共同完成: -- 读取、检索、打开和下载需要 read 权限; -- 创建、更新、添加文件、解析、索引、删除和图谱写操作需要 manage 权限; -- 原文件上传入口要求管理员,并在传入 `kb_id` 时继续检查该知识库的 manage 权限; +- 读取、检索、打开和下载需要 read 权限,普通成员也能进入已授权知识库页面; +- 添加和删除文档、文件夹维护、解析和索引需要 manage 权限,普通成员命中管理范围时可以执行这些内容操作; +- 创建知识库要求管理员;修改整库配置、共享范围、整库删除、图谱和评估写操作要求管理员角色及该库 manage 权限; +- 普通成员上传原文件或导入工作区文件必须指定有 manage 权限的目标库;文档提交校验原始和预处理对象属于目标库。网页 URL 抓取及未指定库的上传入口要求管理员; +- 列表和详情区分 `can_edit_documents` 与 `can_manage`,普通编辑成员的连接器凭据始终脱敏; - 前端守卫、按钮隐藏、Agent 配置和提示词只控制呈现或缩小范围,不能授予权限。 +管理范围可以是指定用户、指定部门或全局;其中的普通成员获得内容维护权限,管理员同时获得整库管理权限。只有读取权限的成员不能修改文档。任务中心要求管理员,普通成员在文档列表查看解析和索引的状态及错误。 + +非整库管理者的列表、详情及图谱状态响应移除各层配置中的凭据和模型请求头;可访问摘要始终脱敏。具有该库 manage 权限的管理员在详情和图谱状态中保留可编辑的配置。脱敏只生成响应副本,持久配置和图谱运行参数保持原值。 + +文件元数据的 `file_id` 在所有知识库间共享主键空间。仓储 upsert 命中现有记录时要求新旧 `kb_id` 一致;跨库 ID 碰撞显式失败,原库记录保持不变。 + +删除文档时,存储清理只删除 documents bucket 中归属当前知识库的原件。管理员或历史记录引用另一库对象时,该原件保留;当前库文档记录和本库解析、预览产物仍按当前库归属清理。 + Agent 的 `knowledges` 只能缩小用户已有权限。子智能体使用自己的配置,但仍沿用发起用户的身份。私有解析图片通过带知识库权限校验的 API 读取,MinIO 对象 URL 不是授权凭证。 ## 失败和重试 diff --git a/docs/public/images/knowledge-content-editor.jpg b/docs/public/images/knowledge-content-editor.jpg new file mode 100644 index 0000000000..63f5dbd754 Binary files /dev/null and b/docs/public/images/knowledge-content-editor.jpg differ diff --git a/docs/public/images/knowledge-content-reader-dark-900.jpg b/docs/public/images/knowledge-content-reader-dark-900.jpg new file mode 100644 index 0000000000..7a6f8e1543 Binary files /dev/null and b/docs/public/images/knowledge-content-reader-dark-900.jpg differ diff --git a/docs/public/images/knowledge-content-reader.jpg b/docs/public/images/knowledge-content-reader.jpg new file mode 100644 index 0000000000..55a0c068b2 Binary files /dev/null and b/docs/public/images/knowledge-content-reader.jpg differ diff --git a/docs/public/images/knowledge-content-upload.jpg b/docs/public/images/knowledge-content-upload.jpg new file mode 100644 index 0000000000..4bdf901f4a Binary files /dev/null and b/docs/public/images/knowledge-content-upload.jpg differ diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 08ea9c0584..44a2bee057 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -1,5 +1,8 @@ import { apiGet, + apiPost, + apiPut, + apiDelete, apiAdminGet, apiAdminPost, apiAdminPut, @@ -41,7 +44,7 @@ export const databaseApi = { * @returns {Promise} - 知识库信息 */ getDatabaseInfo: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}`) + return apiGet(`/api/knowledge/databases/${kbId}`) }, /** @@ -125,14 +128,12 @@ export const documentApi = { */ listDocuments: async (kbId, params = {}) => { const query = buildQuery(params) - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents${query ? `?${query}` : ''}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents${query ? `?${query}` : ''}`) }, searchDocuments: async (kbId, params = {}) => { const query = buildQuery(params) - return apiAdminGet( - `/api/knowledge/databases/${kbId}/documents/search${query ? `?${query}` : ''}` - ) + return apiGet(`/api/knowledge/databases/${kbId}/documents/search${query ? `?${query}` : ''}`) }, /** @@ -143,7 +144,7 @@ export const documentApi = { */ documentExists: async (kbId, filename) => { const query = buildQuery({ filename }) - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/exists?${query}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/exists?${query}`) }, /** @@ -154,20 +155,20 @@ export const documentApi = { * @returns {Promise} - 创建结果 */ createFolder: async (kbId, folderName, parentId = null) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/folders`, { + return apiPost(`/api/knowledge/databases/${kbId}/folders`, { folder_name: folderName, parent_id: parentId }) }, renameFolder: async (kbId, folderId, folderName) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}/folders/${folderId}/rename`, { + return apiPut(`/api/knowledge/databases/${kbId}/folders/${folderId}/rename`, { folder_name: folderName }) }, moveDocument: async (kbId, documentId, newParentId) => { - return apiAdminPut(`/api/knowledge/databases/${kbId}/documents/${documentId}/move`, { + return apiPut(`/api/knowledge/databases/${kbId}/documents/${documentId}/move`, { new_parent_id: newParentId }) }, @@ -180,7 +181,7 @@ export const documentApi = { * @returns {Promise} - 添加结果 */ addDocuments: async (kbId, items, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents`, { items, params }) @@ -194,7 +195,7 @@ export const documentApi = { * @returns {Promise} - 添加结果 */ addUploadedDocuments: async (kbId, items, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/add`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/add`, { items, params }) @@ -207,7 +208,7 @@ export const documentApi = { * @returns {Promise} - 文档信息 */ getDocumentInfo: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}`) }, /** @@ -217,7 +218,7 @@ export const documentApi = { * @returns {Promise} - 文档基本信息 */ getDocumentBasicInfo: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/basic`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/basic`) }, /** @@ -227,7 +228,7 @@ export const documentApi = { * @returns {Promise} - 文档内容信息 */ getDocumentContent: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/content`) + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/content`) }, /** @@ -237,7 +238,7 @@ export const documentApi = { * @returns {Promise} - 删除结果 */ deleteDocument: async (kbId, docId) => { - return apiAdminDelete(`/api/knowledge/databases/${kbId}/documents/${docId}`) + return apiDelete(`/api/knowledge/databases/${kbId}/documents/${docId}`) }, /** @@ -268,7 +269,7 @@ export const documentApi = { * @returns {Promise} - Response对象 */ downloadDocument: async (kbId, docId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/documents/${docId}/download`, {}, 'blob') + return apiGet(`/api/knowledge/databases/${kbId}/documents/${docId}/download`, {}, true, 'blob') }, /** @@ -279,7 +280,7 @@ export const documentApi = { * @returns {Promise} - 解析任务结果 */ parseDocuments: async (kbId, fileIds, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/parse`, { file_ids: fileIds, params }) @@ -292,7 +293,7 @@ export const documentApi = { * @returns {Promise} - 解析任务结果 */ parsePendingDocuments: async (kbId, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/parse-pending`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/parse-pending`, { params }) }, @@ -305,7 +306,7 @@ export const documentApi = { * @returns {Promise} - 入库任务结果 */ indexDocuments: async (kbId, fileIds, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/index`, { file_ids: fileIds, params }) @@ -318,7 +319,7 @@ export const documentApi = { * @returns {Promise} - 入库任务结果 */ indexPendingDocuments: async (kbId, params = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/documents/index-pending`, { + return apiPost(`/api/knowledge/databases/${kbId}/documents/index-pending`, { params }) } @@ -334,11 +335,11 @@ function graphBuildUrl(kbId, action) { export const graphBuildApi = { getStatus: async (kbId) => { - return apiAdminGet(graphBuildUrl(kbId, 'status')) + return apiGet(graphBuildUrl(kbId, 'status')) }, getFailedChunks: async (kbId, limit = 10) => { - return apiAdminGet(`${graphBuildUrl(kbId, 'failed-chunks')}?limit=${limit}`) + return apiGet(`${graphBuildUrl(kbId, 'failed-chunks')}?limit=${limit}`) }, configure: async (kbId, data) => { @@ -368,7 +369,7 @@ export const mindmapApi = { }, getDatabaseFiles: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/files`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap/files`) }, generateMindmap: async (kbId, fileIds = [], userPrompt = '', incremental = false) => { @@ -380,11 +381,11 @@ export const mindmapApi = { }, getByDatabase: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap`) }, getDiff: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/mindmap/diff`) + return apiGet(`/api/knowledge/databases/${kbId}/mindmap/diff`) } } @@ -401,7 +402,7 @@ export const queryApi = { * @returns {Promise} - 查询结果 */ queryKnowledgeBase: async (kbId, query, meta = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/query`, { + return apiPost(`/api/knowledge/databases/${kbId}/query`, { query, meta }) @@ -415,7 +416,7 @@ export const queryApi = { * @returns {Promise} - 测试结果 */ queryTest: async (kbId, query, meta = {}) => { - return apiAdminPost(`/api/knowledge/databases/${kbId}/query-test`, { + return apiPost(`/api/knowledge/databases/${kbId}/query-test`, { query, meta }) @@ -427,7 +428,7 @@ export const queryApi = { * @returns {Promise} - 查询参数 */ getKnowledgeBaseQueryParams: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/query-params`) + return apiGet(`/api/knowledge/databases/${kbId}/query-params`) }, /** @@ -458,7 +459,7 @@ export const queryApi = { * @returns {Promise} - 问题列表 */ getSampleQuestions: async (kbId) => { - return apiAdminGet(`/api/knowledge/databases/${kbId}/sample-questions`) + return apiGet(`/api/knowledge/databases/${kbId}/sample-questions`) } } @@ -499,7 +500,7 @@ export const fileApi = { * @returns {Promise} - 导入结果 */ importWorkspaceFiles: async (kbId, paths) => { - return apiAdminPost('/api/knowledge/files/import-workspace', { + return apiPost('/api/knowledge/files/import-workspace', { kb_id: kbId, paths }) @@ -515,7 +516,7 @@ export const fileApi = { const formData = new FormData() formData.append('file', file) - return apiAdminPost(fileApi.getUploadUrl(kbId), formData) + return apiPost(fileApi.getUploadUrl(kbId), formData) }, /** @@ -523,7 +524,7 @@ export const fileApi = { * @returns {Promise} - 文件类型列表 */ getSupportedFileTypes: async () => { - return apiAdminGet('/api/knowledge/files/supported-types') + return apiGet('/api/knowledge/files/supported-types') }, /** @@ -584,7 +585,7 @@ export const typeApi = { * @returns {Promise} - 分块策略列表 */ getChunkPresets: async () => { - return apiAdminGet('/api/knowledge/chunk-presets') + return apiGet('/api/knowledge/chunk-presets') }, /** diff --git a/web/src/components/FileTable.vue b/web/src/components/FileTable.vue index eccb10d413..4e3b85d904 100644 --- a/web/src/components/FileTable.vue +++ b/web/src/components/FileTable.vue @@ -18,7 +18,7 @@ class="parse-pending-alert" type="info" show-icon - :message="`将提交 ${pendingParseTotalText} 个待解析文件,任务会在后台按批处理,可在任务中心查看进度。`" + :message="`将提交 ${pendingParseTotalText} 个待解析文件,任务会在后台按批处理,${taskProgressHint}。`" />
+ {{ manageScopeDescription }} +