From 0654d05925f6b62978c9cbea9486f6af0333c379 Mon Sep 17 00:00:00 2001 From: hutao_clinic Date: Fri, 9 Oct 2026 13:53:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(knowledge):=20=E6=94=AF=E6=8C=81=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E6=88=90=E5=91=98=E6=8E=88=E6=9D=83=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E4=B8=8E=E6=96=87=E6=A1=A3=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 2 +- .../knowledge/graphs/milvus_graph_service.py | 14 +- backend/package/yuxi/knowledge/manager.py | 4 +- backend/package/yuxi/knowledge/read_models.py | 5 +- .../package/yuxi/knowledge/utils/security.py | 25 +- .../yuxi/permissions/resource_permission.py | 4 +- .../repositories/knowledge_file_repository.py | 3 + .../server/routers/knowledge_eval_router.py | 6 +- backend/server/routers/knowledge_router.py | 115 ++-- backend/server/utils/knowledge_permissions.py | 16 +- backend/server/utils/knowledge_response.py | 4 +- ...st_knowledge_document_storage_isolation.py | 151 +++++ .../integration/api/test_knowledge_router.py | 284 +++++++-- .../test_knowledge_file_upsert.py | 131 ++++ .../knowledge/test_file_listing_scaling.py | 14 +- .../permissions/test_resource_permission.py | 6 +- .../test_evaluation_resource_permission.py | 39 +- .../test_knowledge_content_delegation.py | 291 +++++++++ ...st_knowledge_document_storage_isolation.py | 93 +++ .../test_knowledge_resource_permission.py | 2 +- .../routers/test_knowledge_router_cleanup.py | 47 +- .../test_knowledge_workspace_import.py | 14 +- docs/advanced/knowledge-base-api.md | 16 +- ...-10-09-knowledge-delegated-content-edit.md | 53 ++ docs/intro/knowledge-base.md | 4 +- docs/mechanisms/knowledge-base.md | 16 +- .../images/knowledge-content-editor.jpg | Bin 0 -> 48693 bytes .../knowledge-content-reader-dark-900.jpg | Bin 0 -> 45399 bytes .../images/knowledge-content-reader.jpg | Bin 0 -> 47183 bytes .../images/knowledge-content-upload.jpg | Bin 0 -> 60434 bytes web/src/apis/knowledge_api.js | 67 +- web/src/components/FileTable.vue | 81 ++- web/src/components/FileUploadModal.vue | 285 ++++++--- web/src/components/ShareConfigForm.vue | 10 + .../knowledge/DatabaseCreateFlowModal.vue | 1 + web/src/components/modals/FileSearchModal.vue | 40 +- web/src/router/index.js | 3 +- web/src/stores/database.js | 229 ++++++- web/src/views/DataBaseInfoView.vue | 78 ++- web/src/views/DataBaseView.vue | 19 +- web/src/views/ExtensionsView.vue | 7 +- web/test/unit/database_store.test.js | 16 +- web/test/unit/knowledge_access.test.js | 307 +++++++++ web/test/unit/knowledge_access_ui.test.js | 199 ++++++ .../knowledge_file_search_runtime.test.js | 177 ++++++ .../unit/knowledge_file_table_runtime.test.js | 174 ++++++ ...owledge_upload_permissions_runtime.test.js | 588 ++++++++++++++++++ 47 files changed, 3322 insertions(+), 318 deletions(-) create mode 100644 backend/test/integration/api/test_knowledge_document_storage_isolation.py create mode 100644 backend/test/integration/repositories/test_knowledge_file_upsert.py create mode 100644 backend/test/unit/routers/test_knowledge_content_delegation.py create mode 100644 backend/test/unit/routers/test_knowledge_document_storage_isolation.py create mode 100644 docs/develop-guides/decisions/implemented/2026-10-09-knowledge-delegated-content-edit.md create mode 100644 docs/public/images/knowledge-content-editor.jpg create mode 100644 docs/public/images/knowledge-content-reader-dark-900.jpg create mode 100644 docs/public/images/knowledge-content-reader.jpg create mode 100644 docs/public/images/knowledge-content-upload.jpg create mode 100644 web/test/unit/knowledge_access.test.js create mode 100644 web/test/unit/knowledge_access_ui.test.js create mode 100644 web/test/unit/knowledge_file_search_runtime.test.js create mode 100644 web/test/unit/knowledge_file_table_runtime.test.js create mode 100644 web/test/unit/knowledge_upload_permissions_runtime.test.js 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 0000000000000000000000000000000000000000..63f5dbd754f6ece6ad85d3b1b53c8d76e771f0f6 GIT binary patch literal 48693 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 Dtn8vx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a6f8e15434b7a71bf10aa42822299cf18ae626c GIT binary patch literal 45399 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 vdg?gfoAxq4!CJgP9Q{7;&Chhl-}U(K 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 P>Bs#_Q2gJv-Rk~7As<4S literal 0 HcmV?d00001 diff --git a/docs/public/images/knowledge-content-upload.jpg b/docs/public/images/knowledge-content-upload.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4bdf901f4a769f0d2776a02b4c36f24d0af90e62 GIT binary patch literal 60434 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 { - 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}。`" />
OCR 引擎(仅应用于 PDF/图片文件)
@@ -52,7 +52,7 @@ class="index-pending-alert" type="info" show-icon - :message="`将提交 ${pendingIndexTotalText} 个待入库文件,任务会在后台按批处理,可在任务中心查看进度。`" + :message="`将提交 ${pendingIndexTotalText} 个待入库文件,任务会在后台按批处理,${taskProgressHint}。`" /> diff --git a/web/src/components/ShareConfigForm.vue b/web/src/components/ShareConfigForm.vue index 0bc174df63..f0cf74214a 100644 --- a/web/src/components/ShareConfigForm.vue +++ b/web/src/components/ShareConfigForm.vue @@ -19,6 +19,9 @@ @change="(enabled) => toggleScope(scope.key, enabled)" />
+

+ {{ manageScopeDescription }} +