diff --git a/backend/apps/file_management_app.py b/backend/apps/file_management_app.py index 427bde6f3..e30422b0e 100644 --- a/backend/apps/file_management_app.py +++ b/backend/apps/file_management_app.py @@ -12,6 +12,7 @@ from consts.exceptions import FileTooLargeException, NotFoundException, UnsupportedFileTypeException from consts.model import ProcessParams +from apps.permission_utils import require_knowledge_base_edit_permission from services.file_management_service import upload_to_minio, upload_files_impl, \ get_file_url_impl, get_file_stream_impl, delete_file_impl, list_files_impl, \ resolve_preview_file, get_preview_stream, check_file_access, check_file_access_batch, \ @@ -102,6 +103,8 @@ async def upload_files( detail="No files in the request") user_id, tenant_id = get_current_user_id(authorization) + if index_name: + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) errors, uploaded_file_paths, uploaded_filenames = await upload_files_impl( destination, file, folder, index_name, user_id, uploader_tenant_id=tenant_id ) @@ -144,6 +147,9 @@ async def process_files( index_name: index name in elasticsearch destination: 'local' or 'minio' """ + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) + process_params = ProcessParams( chunking_strategy=chunking_strategy, source_type=destination, diff --git a/backend/apps/knowledge_summary_app.py b/backend/apps/knowledge_summary_app.py index ab45170fb..6f664f5ea 100644 --- a/backend/apps/knowledge_summary_app.py +++ b/backend/apps/knowledge_summary_app.py @@ -6,6 +6,7 @@ from nexent.vector_database.base import VectorDatabaseCore from consts.model import ChangeSummaryRequest +from apps.permission_utils import require_knowledge_base_edit_permission from services.vectordatabase_service import ElasticSearchService, get_vector_db_core from utils.auth_utils import get_current_user_id, get_current_user_info from utils.config_utils import tenant_config_manager @@ -28,8 +29,9 @@ async def auto_summary( ): """Summary Elasticsearch index_name by model""" try: - _, tenant_id, language = get_current_user_info( + user_id, tenant_id, language = get_current_user_info( authorization, http_request) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) service = ElasticSearchService() # Get model_id from tenant config if not provided @@ -53,6 +55,8 @@ async def auto_summary( language=language, model_id=model_id ) + except HTTPException: + raise except Exception as e: logger.error( f"Knowledge base summary generation failed: {e}", exc_info=True) @@ -73,9 +77,12 @@ def change_summary( ): """Summary Elasticsearch index_name by user""" try: - user_id = get_current_user_id(authorization)[0] + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) summary_result = change_summary_request.summary_result return ElasticSearchService().change_summary(index_name=index_name, summary_result=summary_result, user_id=user_id) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=500, detail=f"Knowledge base summary update failed: {str(e)}") diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index edc64ef14..ce448af6d 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -517,7 +517,8 @@ async def manage_check_model_health( result = await check_model_connectivity( request.display_name, - request.tenant_id + request.tenant_id, + request.model_type ) return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Successfully checked model connectivity", diff --git a/backend/apps/permission_utils.py b/backend/apps/permission_utils.py new file mode 100644 index 000000000..9342f5f18 --- /dev/null +++ b/backend/apps/permission_utils.py @@ -0,0 +1,18 @@ +from http import HTTPStatus + +from fastapi import HTTPException + +from services.vectordatabase_service import ElasticSearchService + + +def require_knowledge_base_edit_permission(index_name: str, user_id: str, tenant_id: str) -> None: + try: + ElasticSearchService.require_knowledge_base_edit_permission( + index_name=index_name, + user_id=user_id, + tenant_id=tenant_id, + ) + except ValueError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index 505c39559..92023aa44 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -24,6 +24,7 @@ from utils.file_management_utils import get_all_files_status from database.knowledge_db import get_index_name_by_knowledge_name, get_knowledge_record from database.model_management_db import get_model_by_model_id +from apps.permission_utils import require_knowledge_base_edit_permission router = APIRouter(prefix="/indices") service = ElasticSearchService() @@ -125,9 +126,12 @@ async def delete_index( logger.debug(f"Received request to delete knowledge base: {index_name}") try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) # Call the centralized full deletion service result = await ElasticSearchService.full_delete_knowledge_base(index_name, vdb_core, user_id) return result + except HTTPException: + raise except Exception as e: logger.error( f"Error during API call to delete index '{index_name}': {str(e)}", exc_info=True) @@ -147,6 +151,7 @@ async def update_index( user_id, auth_tenant_id = get_current_user_id(authorization) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id tenant_id = request.get("tenant_id") or auth_tenant_id + require_knowledge_base_edit_permission(index_name, user_id, auth_tenant_id) # Extract update fields knowledge_name = request.get("knowledge_name") @@ -197,6 +202,7 @@ async def update_summary_frequency_endpoint( """Update the auto-summary frequency for a knowledge base.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) summary_frequency = request.get("summary_frequency") valid_frequencies = VALID_SUMMARY_FREQUENCIES @@ -337,6 +343,7 @@ def update_embedding_model( """ try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) model_id = request.get("model_id") if not model_id: @@ -456,6 +463,7 @@ def create_index_documents( """ try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) # Get the knowledge base record to retrieve the saved embedding model knowledge_record = get_knowledge_record({'index_name': index_name}) @@ -477,6 +485,8 @@ def create_index_documents( large_mode=large_mode, model_id=saved_embedding_model_id, ) + except HTTPException: + raise except Exception as e: error_msg = str(e) logger.error(f"Error indexing documents: {error_msg}") @@ -519,10 +529,13 @@ async def delete_documents( "full: delete ES documents, MinIO source, and Redis task records" ), ), - vdb_core: VectorDatabaseCore = Depends(get_vector_db_core) + vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), + authorization: Optional[str] = Header(None), ): """Delete a document by scope: source file only or full removal from the index.""" try: + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = await ElasticSearchService.delete_document_by_scope( index_name, path_or_url, scope, vdb_core ) @@ -567,6 +580,8 @@ async def delete_documents( raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -701,6 +716,7 @@ def create_chunk( """Create a manual chunk.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.create_chunk( index_name=index_name, chunk_request=payload, @@ -714,6 +730,8 @@ def create_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error creating chunk for index %s: %s", index_name, exc, exc_info=True @@ -736,6 +754,7 @@ def update_chunk( """Update an existing chunk.""" try: user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.update_chunk( index_name=index_name, chunk_id=chunk_id, @@ -750,6 +769,8 @@ def update_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error updating chunk %s for index %s: %s", @@ -773,7 +794,8 @@ def delete_chunk( ): """Delete a chunk.""" try: - get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) + require_knowledge_base_edit_permission(index_name, user_id, tenant_id) result = ElasticSearchService.delete_chunk( index_name=index_name, chunk_id=chunk_id, @@ -785,6 +807,8 @@ def delete_chunk( status_code=HTTPStatus.NOT_FOUND, detail=str(e) ) + except HTTPException: + raise except Exception as exc: logger.error( "Error deleting chunk %s for index %s: %s", diff --git a/backend/consts/model.py b/backend/consts/model.py index 0ec8ee407..32df76fbc 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1156,6 +1156,10 @@ class ManageTenantModelHealthcheckRequest(BaseModel): """Request model for checking model connectivity in a specific tenant (admin/manage operation)""" tenant_id: str = Field(..., min_length=1, description="Target tenant ID to check model connectivity") display_name: str = Field(..., description="Display name of the model to check") + model_type: Optional[str] = Field( + None, + description="Model type to disambiguate models with the same display name", + ) class ManageBatchCreateModelsRequest(BaseModel): diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 64b00b9f3..16c76bd2d 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -183,10 +183,12 @@ def get_model_by_display_name(display_name: str, tenant_id: str, model_type: str """ filters = {'display_name': display_name} - if model_type in ["multiEmbedding", "multi_embedding"]: - filters['model_type'] = "multi_embedding" - elif model_type == "embedding": - filters['model_type'] = "embedding" + if model_type: + filters['model_type'] = ( + "multi_embedding" + if model_type in ["multiEmbedding", "multi_embedding"] + else model_type + ) records = get_model_records(filters, tenant_id) if not records: @@ -358,5 +360,3 @@ def get_model_by_name_factory(model_name: str, model_factory: str, tenant_id: st } records = get_model_records(filters, tenant_id) return records[0] if records else None - - diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 5d472799d..33a24fca7 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -354,7 +354,13 @@ async def check_model_connectivity(display_name: str, tenant_id: str, model_type "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} logger.error(f"Error checking model connectivity: {str(e)}") update_model_record(model["model_id"], update_data) - raise e + if isinstance(e, ValueError): + raise e + return { + "connectivity": False, + "model_name": model_name, + "error": str(e), + } if connectivity: logger.info( @@ -367,10 +373,11 @@ async def check_model_connectivity(display_name: str, tenant_id: str, model_type if ssl_verify_fallback: update_data["ssl_verify"] = False update_model_record(model["model_id"], update_data) - return { + result = { "connectivity": connectivity, "model_name": model_name, } + return result except Exception as e: logger.error(f"Error checking model connectivity: {str(e)}") if 'model' in locals() and model: diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index dd2f6e51a..fcd545b6e 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -27,7 +27,18 @@ from nexent.vector_database.elasticsearch_core import ElasticSearchCore from nexent.vector_database.datamate_core import DataMateCore -from consts.const import DATAMATE_URL, ES_API_KEY, ES_HOST, LANGUAGE, VectorDatabaseType, IS_SPEED_MODE, PERMISSION_EDIT, PERMISSION_READ, ASSET_OWNER_TENANT_ID +from consts.const import ( + ASSET_OWNER_TENANT_ID, + CAN_EDIT_ALL_USER_ROLES, + DATAMATE_URL, + ES_API_KEY, + ES_HOST, + IS_SPEED_MODE, + LANGUAGE, + PERMISSION_EDIT, + PERMISSION_READ, + VectorDatabaseType, +) from consts.model import ChunkCreateRequest, ChunkUpdateRequest from database.attachment_db import delete_file, file_exists, get_file_stream from database.knowledge_db import ( @@ -493,6 +504,102 @@ def get_rerank_model(tenant_id: str, model_name: Optional[str] = None): class ElasticSearchService: + CREATOR_PERMISSION = "CREATOR" + + @staticmethod + def resolve_knowledge_base_permission( + index_name: str, + user_id: str, + tenant_id: Optional[str] = None, + ) -> Optional[str]: + """Resolve the current user's permission for one knowledge base.""" + record = get_knowledge_record({"index_name": index_name}) + if not record: + raise ValueError(f"Knowledge base '{index_name}' not found") + + if record.get("knowledge_sources") == "datamate": + return PERMISSION_READ + + user_tenant = get_user_tenant_by_user_id(user_id) + if not user_tenant and not IS_SPEED_MODE: + return None + + user_role = (user_tenant or {}).get("user_role") + user_tenant_id = str((user_tenant or {}).get("tenant_id") or tenant_id or "") + effective_user_role = user_role + if user_id == user_tenant_id: + effective_user_role = "ADMIN" + logger.info(f"User {user_id} identified as legacy admin") + elif IS_SPEED_MODE: + effective_user_role = "SPEED" + logger.info("User under SPEED version is treated as admin") + + role = (effective_user_role or "").upper() + record_tenant_id = str(record.get("tenant_id") or "") + is_asset_owner_record = record_tenant_id == ASSET_OWNER_TENANT_ID + + if is_asset_owner_record: + if role == "ASSET_OWNER": + return PERMISSION_EDIT + if role in {"SU", "ADMIN", "SPEED", "DEV"}: + return PERMISSION_READ + return None + + if record_tenant_id and user_tenant_id and record_tenant_id != user_tenant_id: + return None + + if role in CAN_EDIT_ALL_USER_ROLES: + return PERMISSION_EDIT + + if role in {"USER", "DEV"}: + kb_group_ids_str = record.get("group_ids") + kb_group_ids = convert_string_to_list(kb_group_ids_str or "") + user_group_ids = query_group_ids_by_user(user_id) + + kb_groups_empty = ( + kb_group_ids_str is None + or (isinstance(kb_group_ids_str, str) and kb_group_ids_str.strip() == "") + or len(kb_group_ids) == 0 + ) + user_groups_empty = len(user_group_ids) == 0 + + has_group_intersection = ( + True + if kb_groups_empty and user_groups_empty + else bool(set(user_group_ids) & set(kb_group_ids)) + ) + if not has_group_intersection: + return None + + if str(record.get("created_by")) == str(user_id): + return ElasticSearchService.CREATOR_PERMISSION + + ingroup_permission = record.get("ingroup_permission") or PERMISSION_READ + if ingroup_permission == PERMISSION_EDIT: + return PERMISSION_EDIT + if ingroup_permission == PERMISSION_READ: + return PERMISSION_READ + if ingroup_permission == "PRIVATE": + return None + + return None + + @staticmethod + def require_knowledge_base_edit_permission( + index_name: str, + user_id: str, + tenant_id: Optional[str] = None, + ) -> str: + """Raise when the current user cannot modify the knowledge base.""" + permission = ElasticSearchService.resolve_knowledge_base_permission( + index_name=index_name, + user_id=user_id, + tenant_id=tenant_id, + ) + if permission not in {PERMISSION_EDIT, ElasticSearchService.CREATOR_PERMISSION}: + raise PermissionError("No permission to modify this knowledge base") + return permission + @staticmethod async def full_delete_knowledge_base(index_name: str, vdb_core: VectorDatabaseCore, user_id: str): """ diff --git a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx index dd3a427e0..651b44906 100644 --- a/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx +++ b/frontend/app/[locale]/knowledges/KnowledgeBaseConfiguration.tsx @@ -756,6 +756,10 @@ function DataConfig({ isActive }: DataConfigProps) { const handleDeleteDocument = (docId: string) => { const kbId = kbState.activeKnowledgeBase?.id; if (!kbId) return; + if (kbState.activeKnowledgeBase?.permission === "READ_ONLY") { + message.error(t("errorCode.000202", "Access forbidden.")); + return; + } confirm({ title: t("document.modal.deleteConfirm.title"), @@ -776,6 +780,10 @@ function DataConfig({ isActive }: DataConfigProps) { // Handle file upload - in creation mode create knowledge base first then upload, in normal mode upload directly const handleFileUpload = async () => { + if (!isCreatingMode && kbState.activeKnowledgeBase?.permission === "READ_ONLY") { + message.error(t("errorCode.000202", "Access forbidden.")); + return; + } if (!uploadFiles.length) { message.warning(t("document.message.noFiles")); return; diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx index b96a3b59b..58e394be8 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx @@ -1052,18 +1052,20 @@ const DocumentListContainer = forwardRef( > {t("common.preview")} - + {!isReadOnlyMode && ( + + )} )} @@ -1103,7 +1105,7 @@ const DocumentListContainer = forwardRef( onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} - disabled={!isCreatingMode && !knowledgeBaseId} + disabled={isReadOnlyMode || (!isCreatingMode && !knowledgeBaseId)} componentHeight={uploadHeight} isCreatingMode={isCreatingMode} // Use internal ID for backend operations; fall back to name in creation mode diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index bbe404d89..80ec12b48 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -143,11 +143,11 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { setCheckingConnectivity((prev) => new Set(prev).add(displayName)); try { - const isConnected = await modelService.verifyCustomModel(displayName, modelType); + const isConnected = await modelService.checkManageTenantModelConnectivity(tenantId, displayName, modelType); if (isConnected) { message.success(t("tenantResources.models.connectivitySuccess")); } else { - message.warning(t("tenantResources.models.connectivityFailed")); + message.error(t("tenantResources.models.connectivityFailed")); } refetch(); } catch (error) { diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 3d1e16071..e9e10cbb2 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2035,7 +2035,7 @@ "tenantResources.models.deleteFailed": "Delete failed", "tenantResources.models.checkConnectivity": "Check Connectivity", "tenantResources.models.connectivitySuccess": "Model connectivity is healthy", - "tenantResources.models.connectivityFailed": "Model cannot connect", + "tenantResources.models.connectivityFailed": "Model unavailable", "tenantResources.models.connectivityError": "Error checking connectivity", "tenantResources.models.enterDescription": "Enter model description", "tenantResources.models.enterName": "Enter model name", @@ -3364,5 +3364,102 @@ "skillSpace.comingSoon.feature1": "Browse and install community-built skills", "skillSpace.comingSoon.feature2": "Create and publish your own skill packs", "skillSpace.comingSoon.feature3": "Version control and skill dependency management", - "skillSpace.comingSoon.badge": "Coming Soon" + "skillSpace.comingSoon.badge": "Coming Soon", + "a2a.discovery.addToLocalAgent": "Add to Local Agent", + "a2a.discovery.addToLocalAgentFailed": "Failed to add to local agent", + "a2a.discovery.addToLocalAgentSuccess": "Added to local agent", + "a2a.discovery.noDescription": "No description", + "a2a.discovery.scanFailed": "Failed to scan agents", + "a2a.service.addRelationSuccess": "Relation added successfully", + "a2a.service.deleteSuccess": "Deleted successfully", + "a2a.service.updateProtocolFailed": "Failed to update protocol", + "agent.validation.nameRequired": "Please enter agent name", + "agentEvaluation.history.caseCount": "{{count}} cases", + "agentEvaluation.step1.runningProgress": "Running {{current}}/{{total}}", + "chatHeader.share": "Share", + "chatInterface.errorUpdatingTitle": "Failed to update title", + "chatInterface.resumeStreamFailed": "Failed to resume stream", + "chatInterface.shareLoadFailed": "Failed to load shared conversation", + "chatInterface.shareReadOnly": "Shared conversation is read-only", + "chatInterface.sharedConversation": "Shared Conversation", + "chatRightPanel.source.aidp": "Source: AIDP", + "chatStreamMain.noHistory": "No history", + "chatStreamMain.noMessages": "No messages", + "copyButton.copied": "Copied", + "copyButton.copy": "Copy", + "document.chunk.source.datamate": "DataMate", + "document.chunk.source.nexent": "Nexent", + "document.delete.terminateTask": "Terminate task", + "document.message.deleteError": "Failed to delete document", + "document.message.uploadDisabledForDataMate": "DataMate knowledge bases do not support document uploads", + "document.summary.placeholder": "Enter knowledge base summary", + "filePreview.markdownOutline": "Markdown Outline", + "knowledgeBase.create.embeddingModelPlaceholder": "Please select an embedding model", + "knowledgeBase.empty": "No knowledge bases", + "knowledgeBase.total": "{{count}} knowledge bases", + "market.detail.inputParameters": "Input Parameters", + "market.install.mcp.defaultConfigHint": "The MCP service will be installed with default configuration", + "mcpConfig.message.getMcpRecordFailed": "Failed to get MCP record", + "mcpConfig.message.uploadImageFailed": "Failed to upload image", + "mcpService.debug.addFromConfigFailed": "Failed to add MCP service from config:", + "mcpService.debug.deleteContainerFailed": "Failed to delete container:", + "mcpService.debug.getContainerLogsFailed": "Failed to get container logs:", + "mcpService.debug.getContainersFailed": "Failed to get containers:", + "mcpService.debug.getMcpRecordFailed": "Failed to get MCP record:", + "mcpService.debug.healthCheckFailed": "Health check failed:", + "mcpService.debug.streamContainerLogsFailed": "Failed to stream container logs:", + "mcpService.message.addFromConfigFailed": "Failed to add MCP service from config", + "mcpService.message.addFromConfigSuccess": "MCP service added from config", + "mcpService.message.containerNotFound": "Container not found", + "mcpService.message.deleteContainerFailed": "Failed to delete container", + "mcpService.message.dockerServiceUnavailable": "Docker service is unavailable. Please check whether Docker is running.", + "mcpService.message.getContainerLogsFailed": "Failed to get container logs", + "mcpService.message.getContainersFailed": "Failed to get containers", + "mcpService.message.getMcpRecordFailed": "Failed to get MCP record", + "mcpService.message.healthCheckFailed": "Health check failed", + "mcpService.message.healthCheckSuccess": "Health check passed", + "mcpService.message.invalidConfig": "Invalid configuration", + "mcpService.message.mcpRecordNotFound": "MCP record not found", + "mcpService.message.mcpServerNotFound": "MCP service not found", + "mcpTools.mine.reviewProgressService": "MCP Service", + "mcpTools.mine.reviewProgressStepApproved": "Approved", + "mcpTools.mine.reviewProgressStepRejected": "Rejected", + "mcpTools.mine.reviewProgressStepReviewing": "Waiting for admin review", + "mcpTools.mine.reviewProgressStepSubmitted": "Application submitted", + "mcpTools.mine.reviewProgressTitle": "Version Update Review Progress", + "mcpTools.mine.reviewProgressVersion": "Version", + "mcpTools.page.importService": "Import", + "mcpTools.page.role.admin": "Admin", + "mcpTools.page.role.adminHint": "Can manage the repository and review center", + "mcpTools.page.role.user": "User", + "mcpTools.page.role.userHint": "Can install, import, and manage personal MCP tools", + "mcpTools.repository.downloads": "Downloads", + "mcpTools.repository.installCount": "Installs", + "mcpTools.repository.noRating": "No rating", + "mcpTools.repository.offlinePending": "Pending offline", + "mcpTools.repository.rating": "Rating", + "mcpTools.repository.version": "Version", + "mcpTools.review.initialListing": "Initial listing v{{version}}", + "mcpTools.review.status.offline": "Offline", + "mcpTools.review.submitter": "Submitter: {{name}}", + "mcpTools.review.versionUpdate": "Version update v{{oldVersion}} → v{{newVersion}}", + "mcpTools.service.toggle.missingId": "Missing MCP service ID", + "model.dialog.error.apiConnectionFailed": "Model API connection failed", + "model.dialog.error.provider.accessDenied": "Access denied. Please check permissions.", + "model.dialog.error.provider.authenticationFailed": "Authentication failed. Please check the API key.", + "model.dialog.error.provider.connectionFailed": "Failed to connect to model service", + "model.dialog.error.provider.endpointNotFound": "Model service endpoint not found", + "model.dialog.error.provider.noModels": "No available models found", + "model.dialog.error.provider.serverError": "Model service error. Please try again later.", + "model.dialog.error.provider.sslError": "SSL verification failed. Please check certificate configuration.", + "model.dialog.error.provider.timeout": "Connection to model service timed out", + "model.message.updateFailed": "Failed to update model", + "model.message.updateSuccess": "Model updated successfully", + "page.adminPrompt.githubSupport": "Star us on GitHub to support Nexent", + "page.adminPrompt.intro": "You need administrator permissions to continue.", + "page.adminPrompt.title": "Administrator Required", + "setup.navigation.button.saving": "Saving...", + "space.new": "New Space", + "toolConfig.validation.array.invalid": "Please enter valid array JSON", + "toolConfig.validation.object.invalid": "Please enter valid object JSON" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 168c75a76..9ab2e7693 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -2004,7 +2004,7 @@ "tenantResources.models.deleteFailed": "删除模型失败", "tenantResources.models.checkConnectivity": "检查连通性", "tenantResources.models.connectivitySuccess": "模型连通性正常", - "tenantResources.models.connectivityFailed": "模型无法连接", + "tenantResources.models.connectivityFailed": "模型不可用", "tenantResources.models.connectivityError": "检查连通性时发生错误", "tenantResources.models.enterDescription": "输入模型描述", "tenantResources.models.enterName": "输入模型名称", @@ -3375,5 +3375,91 @@ "mcpTools.repository.rating": "评分", "mcpTools.repository.downloads": "下载量", "mcpTools.repository.downloadCount": "下载次数", - "mcpTools.repository.installed": "已安装" + "mcpTools.repository.installed": "已安装", + "a2a.discovery.addToLocalAgent": "添加到本地 Agent", + "a2a.discovery.addToLocalAgentFailed": "添加到本地 Agent 失败", + "a2a.discovery.addToLocalAgentSuccess": "已添加到本地 Agent", + "a2a.discovery.noDescription": "暂无描述", + "a2a.discovery.scanFailed": "扫描 Agent 失败", + "a2a.service.addRelationSuccess": "关联添加成功", + "a2a.service.deleteSuccess": "删除成功", + "agent.validation.nameRequired": "请输入 Agent 名称", + "chatHeader.share": "分享", + "chatInterface.errorUpdatingTitle": "更新标题失败", + "chatInterface.resumeStreamFailed": "恢复流式响应失败", + "chatInterface.shareLoadFailed": "加载分享内容失败", + "chatInterface.shareReadOnly": "分享对话仅可查看", + "chatInterface.sharedConversation": "分享的对话", + "chatRightPanel.source.aidp": "来源: AIDP", + "chatStreamFinalMessage.maxStepsMessage": "已达到最大执行步数,请调整配置后重试", + "chatStreamMain.noHistory": "暂无历史消息", + "chatStreamMain.noMessages": "暂无消息", + "copyButton.copied": "已复制", + "copyButton.copy": "复制", + "document.chunk.source.datamate": "DataMate", + "document.chunk.source.nexent": "Nexent", + "document.delete.terminateTask": "终止任务", + "document.message.deleteError": "删除文档失败", + "document.message.uploadDisabledForDataMate": "DataMate 知识库不支持上传文档", + "document.summary.placeholder": "请输入知识库总结", + "filePreview.markdownOutline": "Markdown 大纲", + "knowledgeBase.create.embeddingModelPlaceholder": "请选择嵌入模型", + "knowledgeBase.empty": "暂无知识库", + "knowledgeBase.filter.clear": "清空筛选", + "knowledgeBase.message.embeddingModelRequired": "请选择嵌入模型", + "knowledgeBase.total": "共 {{count}} 个知识库", + "market.detail.inputParameters": "输入参数", + "market.install.mcp.defaultConfigHint": "将使用默认配置安装 MCP 服务", + "mcpConfig.message.getMcpRecordFailed": "获取 MCP 记录失败", + "mcpConfig.message.uploadImageFailed": "上传镜像失败", + "mcpService.debug.addFromConfigFailed": "从配置添加 MCP 服务失败:", + "mcpService.debug.deleteContainerFailed": "删除容器失败:", + "mcpService.debug.getContainerLogsFailed": "获取容器日志失败:", + "mcpService.debug.getContainersFailed": "获取容器列表失败:", + "mcpService.debug.getMcpRecordFailed": "获取 MCP 记录失败:", + "mcpService.debug.healthCheckFailed": "健康检查失败:", + "mcpService.debug.streamContainerLogsFailed": "拉取容器日志流失败:", + "mcpService.message.addFromConfigFailed": "从配置添加 MCP 服务失败", + "mcpService.message.addFromConfigSuccess": "已从配置添加 MCP 服务", + "mcpService.message.containerNotFound": "容器不存在", + "mcpService.message.deleteContainerFailed": "删除容器失败", + "mcpService.message.dockerServiceUnavailable": "Docker 服务不可用,请检查 Docker 是否正在运行", + "mcpService.message.getContainerLogsFailed": "获取容器日志失败", + "mcpService.message.getContainersFailed": "获取容器列表失败", + "mcpService.message.getMcpRecordFailed": "获取 MCP 记录失败", + "mcpService.message.healthCheckFailed": "健康检查失败", + "mcpService.message.healthCheckSuccess": "健康检查通过", + "mcpService.message.invalidConfig": "配置无效", + "mcpService.message.mcpRecordNotFound": "MCP 记录不存在", + "mcpService.message.mcpServerNotFound": "MCP 服务不存在", + "mcpTools.detail.saving": "保存中...", + "mcpTools.service.toggle.missingId": "缺少 MCP 服务 ID", + "model.dialog.error.provider.accessDenied": "访问被拒绝,请检查权限", + "model.dialog.error.provider.authenticationFailed": "认证失败,请检查 API Key", + "model.dialog.error.provider.connectionFailed": "连接模型服务失败", + "model.dialog.error.provider.endpointNotFound": "模型服务地址不存在", + "model.dialog.error.provider.noModels": "未获取到可用模型", + "model.dialog.error.provider.serverError": "模型服务异常,请稍后重试", + "model.dialog.error.provider.sslError": "SSL 验证失败,请检查证书配置", + "model.dialog.error.provider.timeout": "连接模型服务超时", + "model.dialog.success.connectivityVerified": "连通性验证成功", + "model.message.updateFailed": "更新模型失败", + "model.message.updateSuccess": "模型更新成功", + "page.loginPrompt.header": "登录后继续使用", + "sidebar.knowledgeBase": "知识库", + "sidebar.memoryManagement": "记忆管理", + "sidebar.modelManagement": "模型管理", + "space.new": "新建空间", + "systemPrompt.button.badcase": "Badcase 优化", + "systemPrompt.finetune.title": "微调", + "systemPrompt.finetune.insertPositionLabel": "插入位置(字符索引)", + "systemPrompt.finetune.insertPositionPlaceholder": "例如:50", + "systemPrompt.finetune.positionError": "请输入有效的位置数字", + "systemPrompt.finetune.selectEndLabel": "选择结束位置(字符索引)", + "systemPrompt.finetune.selectEndPlaceholder": "例如:100", + "systemPrompt.finetune.selectStartLabel": "选择开始位置(字符索引)", + "systemPrompt.finetune.selectStartPlaceholder": "例如:10", + "systemPrompt.finetune.selectTip": "在下方编辑器中选择文本以获取位置", + "toolConfig.validation.array.invalid": "请输入有效的数组 JSON", + "toolConfig.validation.object.invalid": "请输入有效的对象 JSON" } diff --git a/frontend/services/a2aService.ts b/frontend/services/a2aService.ts index f2909fa8e..8e2923d4a 100644 --- a/frontend/services/a2aService.ts +++ b/frontend/services/a2aService.ts @@ -532,9 +532,14 @@ export const a2aClientService = { const data = await response.json(); if (response.ok && data.status === 'success') { + const testResult = data.data; return { - success: true, - message: data.data?.message || t('a2a.service.testConnectionSuccess') + success: testResult?.success === true, + message: testResult?.message || ( + testResult?.success === true + ? t('a2a.service.testConnectionSuccess') + : t('a2a.service.testConnectionFailed') + ) }; } diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 66246cb81..a2f13d296 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -116,6 +116,12 @@ const mapCapacityCoverageFromApi = (coverage: any): CapacityCoverage => ({ })), }); +type ModelConnectivityResult = { + connectivity: boolean; + modelName?: string; + error?: string; +}; + // Error class export class ModelError extends Error { constructor( @@ -626,6 +632,50 @@ export const modelService = { } }, + checkManageTenantModelConnectivityDetail: async ( + tenantId: string, + displayName: string, + modelType: string, + signal?: AbortSignal + ): Promise => { + try { + if (!displayName) return { connectivity: false }; + const response = await fetch(API_ENDPOINTS.model.manageModelHealthcheck, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tenant_id: tenantId, + display_name: displayName, + model_type: modelType, + }), + signal, + }); + const result = await response.json(); + if (response.status === 200 && result.data) { + return { + connectivity: Boolean(result.data.connectivity), + modelName: result.data.model_name, + error: result.data.error, + }; + } + return { + connectivity: false, + error: result.detail || result.message, + }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw error; + } + return { + connectivity: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + // Check model connectivity for a specific tenant (admin/manage operation) checkManageTenantModelConnectivity: async ( tenantId: string, diff --git a/test/backend/app/test_file_management_app.py b/test/backend/app/test_file_management_app.py index 81c4efd4e..e4d7107e4 100644 --- a/test/backend/app/test_file_management_app.py +++ b/test/backend/app/test_file_management_app.py @@ -116,6 +116,19 @@ def _stub_get_preview_stream(actual_object_name, start=None, end=None): sys.modules["services.file_management_service"] = sfms_stub setattr(services_pkg, "file_management_service", sfms_stub) +vdb_service_stub = types.ModuleType("services.vectordatabase_service") + + +class _StubElasticSearchService: + @staticmethod + def require_knowledge_base_edit_permission(index_name, user_id, tenant_id=None): + return "EDIT" + + +vdb_service_stub.ElasticSearchService = _StubElasticSearchService +sys.modules["services.vectordatabase_service"] = vdb_service_stub +setattr(services_pkg, "vectordatabase_service", vdb_service_stub) + # Stub utils.auth_utils.get_current_user_id (the function actually used in the app) utils_pkg = types.ModuleType("utils") @@ -222,6 +235,41 @@ async def fake_upload_impl(dest, files, folder, index_name, user_id=None, upload assert "a.txt" in content and "/abs/path1" in content +def test_upload_files_forbidden_for_read_only(monkeypatch): + from fastapi import FastAPI, HTTPException + from fastapi.testclient import TestClient + + mock_require_permission = MagicMock( + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ) + ) + mock_upload_impl = AsyncMock() + monkeypatch.setattr(file_management_app, "require_knowledge_base_edit_permission", mock_require_permission) + monkeypatch.setattr(file_management_app, "upload_files_impl", mock_upload_impl) + + app = FastAPI() + app.include_router(file_management_app.file_management_config_router) + client = TestClient(app) + + response = client.post( + "/file/upload", + data={ + "destination": "minio", + "folder": "knowledge_base", + "index_name": "test_index", + }, + files=[("file", ("read-only.txt", b"data", "text/plain"))], + headers={"Authorization": MOCK_AUTH}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with("test_index", "user1", "tenant1") + mock_upload_impl.assert_not_called() + + @pytest.mark.asyncio async def test_upload_files_no_files_bad_request(): with pytest.raises(Exception) as ei: diff --git a/test/backend/app/test_knowledge_summary_app.py b/test/backend/app/test_knowledge_summary_app.py index fcbad52db..b50899918 100644 --- a/test/backend/app/test_knowledge_summary_app.py +++ b/test/backend/app/test_knowledge_summary_app.py @@ -70,6 +70,10 @@ class MockElasticSearchService: def __init__(self, *args, **kwargs): pass + @staticmethod + def require_knowledge_base_edit_permission(index_name, user_id, tenant_id=None): + return "EDIT" + def mock_get_vector_db_core(): return MagicMock() @@ -112,6 +116,8 @@ class ChangeSummaryRequest(BaseModel): # Import the modules we need from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi import HTTPException +from apps import permission_utils from apps.knowledge_summary_app import router # Create a test app and client @@ -133,6 +139,44 @@ def test_data(): return data +def test_permission_utils_maps_missing_knowledge_base_to_404(monkeypatch): + def raise_missing(**_kwargs): + raise ValueError("Knowledge base 'missing' not found") + + monkeypatch.setattr( + permission_utils.ElasticSearchService, + "require_knowledge_base_edit_permission", + raise_missing, + ) + + with pytest.raises(HTTPException) as exc_info: + permission_utils.require_knowledge_base_edit_permission( + "missing", "user-1", "tenant-1" + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Knowledge base 'missing' not found" + + +def test_permission_utils_maps_permission_error_to_403(monkeypatch): + def raise_forbidden(**_kwargs): + raise PermissionError("No permission to modify this knowledge base") + + monkeypatch.setattr( + permission_utils.ElasticSearchService, + "require_knowledge_base_edit_permission", + raise_forbidden, + ) + + with pytest.raises(HTTPException) as exc_info: + permission_utils.require_knowledge_base_edit_permission( + "kb", "user-1", "tenant-1" + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "No permission to modify this knowledge base" + + class TestAutoSummary: """Test auto summary generation endpoint""" @@ -166,6 +210,40 @@ def test_auto_summary_success(self, mock_user_info, mock_vdb_core, mock_service_ assert call_kwargs['language'] == mock_user_info_value[2] assert call_kwargs['model_id'] == 1 + @patch('apps.knowledge_summary_app.ElasticSearchService') + @patch('apps.knowledge_summary_app.get_vector_db_core') + @patch('apps.knowledge_summary_app.get_current_user_info') + @patch('apps.knowledge_summary_app.require_knowledge_base_edit_permission') + def test_auto_summary_forbidden_for_read_only( + self, + mock_require_permission, + mock_user_info, + mock_vdb_core, + mock_service_class, + test_data, + ): + """Read-only users must not be able to regenerate knowledge base summary.""" + mock_vdb_core.return_value = MagicMock() + mock_user_info.return_value = test_data["user_info"] + mock_require_permission.side_effect = HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ) + + response = client.post( + f"/summary/{test_data['index_name']}/auto_summary", + headers=test_data["auth_header"] + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + test_data["index_name"], + test_data["user_info"][0], + test_data["user_info"][1], + ) + mock_service_class.assert_not_called() + @patch('apps.knowledge_summary_app.ElasticSearchService') @patch('apps.knowledge_summary_app.get_vector_db_core') @patch('apps.knowledge_summary_app.get_current_user_info') @@ -352,6 +430,38 @@ def test_change_summary_success(self, mock_get_user_id, mock_service_class, test user_id=test_data["user_id"][0] ) + @patch('apps.knowledge_summary_app.ElasticSearchService') + @patch('apps.knowledge_summary_app.get_current_user_id') + @patch('apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission') + def test_change_summary_forbidden_for_read_only( + self, + mock_require_permission, + mock_get_user_id, + mock_service_class, + test_data, + ): + """Read-only users must not be able to update knowledge base summary.""" + mock_get_user_id.return_value = test_data["user_id"] + mock_require_permission.side_effect = PermissionError( + "No permission to modify this knowledge base" + ) + + request_data = {"summary_result": test_data["summary_result"]} + response = client.post( + f"/summary/{test_data['index_name']}/summary", + json=request_data, + headers=test_data["auth_header"] + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=test_data["index_name"], + user_id=test_data["user_id"][0], + tenant_id=test_data["user_id"][1], + ) + mock_service_class.return_value.change_summary.assert_not_called() + @patch('apps.knowledge_summary_app.ElasticSearchService') @patch('apps.knowledge_summary_app.get_current_user_id') def test_change_summary_exception(self, mock_get_user_id, mock_service_class, test_data): diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index 8def4d3e2..619586da3 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -1533,7 +1533,8 @@ async def test_manage_healthcheck_success(client, auth_header, user_credentials, request_data = { "tenant_id": "target_tenant", - "display_name": "test-model" + "display_name": "test-model", + "model_type": "llm" } response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) @@ -1541,7 +1542,29 @@ async def test_manage_healthcheck_success(client, auth_header, user_credentials, data = response.json() assert "Successfully checked model connectivity" in data["message"] assert data["data"]["connectivity"] is True - mock_check.assert_called_once_with("test-model", "target_tenant") + mock_check.assert_called_once_with("test-model", "target_tenant", "llm") + + +@pytest.mark.asyncio +async def test_manage_healthcheck_without_model_type_is_backward_compatible( + client, auth_header, user_credentials, mocker +): + """Test model connectivity check still accepts requests without model_type.""" + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + + mock_check = mocker.patch( + 'backend.apps.model_managment_app.check_model_connectivity', + return_value={"connectivity": True, "connect_status": "available"} + ) + + request_data = { + "tenant_id": "target_tenant", + "display_name": "test-model" + } + response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + + assert response.status_code == HTTPStatus.OK + mock_check.assert_called_once_with("test-model", "target_tenant", None) @pytest.mark.asyncio diff --git a/test/backend/app/test_vectordatabase_app.py b/test/backend/app/test_vectordatabase_app.py index cd684512f..998316e10 100644 --- a/test/backend/app/test_vectordatabase_app.py +++ b/test/backend/app/test_vectordatabase_app.py @@ -10,7 +10,7 @@ import importlib.machinery from unittest.mock import patch, MagicMock, ANY, AsyncMock from fastapi.testclient import TestClient -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from typing import List, Optional, Any, Dict from pydantic import BaseModel @@ -142,6 +142,15 @@ def auth_data(): "auth_header": {"Authorization": "Bearer test_token"} } + +@pytest.fixture(autouse=True) +def mock_knowledge_base_edit_permission(): + with patch( + "backend.apps.vectordatabase_app.ElasticSearchService.require_knowledge_base_edit_permission", + return_value="EDIT", + ): + yield + # Test cases using pytest-asyncio @@ -358,6 +367,36 @@ async def test_delete_index_success(vdb_core_mock, redis_service_mock, auth_data ) +@pytest.mark.asyncio +async def test_delete_index_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete a knowledge base.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ) as mock_require_permission, \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.full_delete_knowledge_base", + new_callable=AsyncMock, + ) as mock_full_delete: + + response = client.delete( + f"/indices/{auth_data['index_name']}", + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=auth_data["index_name"], + user_id=auth_data["user_id"], + tenant_id=auth_data["tenant_id"], + ) + mock_full_delete.assert_not_called() + + @pytest.mark.asyncio async def test_delete_index_redis_error(vdb_core_mock, redis_service_mock, auth_data): """ @@ -687,6 +726,37 @@ async def test_create_index_documents_success(vdb_core_mock, auth_data): mock_index.assert_called_once() +@pytest.mark.asyncio +async def test_create_index_documents_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to index documents.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.index_documents") as mock_index: + + response = client.post( + f"/indices/{auth_data['index_name']}/documents", + json=[{"id": 1, "text": "test doc"}], + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_index.assert_not_called() + + @pytest.mark.asyncio async def test_create_index_documents_uses_multimodal_embedding(vdb_core_mock, auth_data): with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ @@ -1085,6 +1155,37 @@ async def test_create_chunk_success(vdb_core_mock, auth_data): assert call_kwargs["tenant_id"] == auth_data["tenant_id"] +@pytest.mark.asyncio +async def test_create_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to create chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.create_chunk") as mock_create: + + response = client.post( + f"/indices/{auth_data['index_name']}/chunk", + json={"content": "Hello world", "path_or_url": "doc-1"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_create.assert_not_called() + + @pytest.mark.asyncio async def test_create_chunk_passes_tenant_id_to_service(vdb_core_mock, auth_data): """ @@ -1180,6 +1281,37 @@ async def test_update_chunk_success(vdb_core_mock, auth_data): mock_update.assert_called_once() +@pytest.mark.asyncio +async def test_update_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to update chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.require_knowledge_base_edit_permission", + side_effect=HTTPException( + status_code=403, + detail="No permission to modify this knowledge base", + ), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.update_chunk") as mock_update: + + response = client.put( + f"/indices/{auth_data['index_name']}/chunk/chunk-1", + json={"content": "Updated content"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + auth_data["index_name"], + auth_data["user_id"], + auth_data["tenant_id"], + ) + mock_update.assert_not_called() + + @pytest.mark.asyncio async def test_update_chunk_value_error(vdb_core_mock, auth_data): """ @@ -1261,6 +1393,33 @@ async def test_delete_chunk_success(vdb_core_mock, auth_data): mock_delete.assert_called_once() +@pytest.mark.asyncio +async def test_delete_chunk_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete chunks.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "apps.permission_utils.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ) as mock_require_permission, \ + patch("backend.apps.vectordatabase_app.ElasticSearchService.delete_chunk") as mock_delete: + + response = client.delete( + f"/indices/{auth_data['index_name']}/chunk/chunk-1", + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_require_permission.assert_called_once_with( + index_name=auth_data["index_name"], + user_id=auth_data["user_id"], + tenant_id=auth_data["tenant_id"], + ) + mock_delete.assert_not_called() + + @pytest.mark.asyncio async def test_delete_chunk_not_found(vdb_core_mock, auth_data): """ @@ -1679,6 +1838,32 @@ async def test_delete_documents_success(vdb_core_mock, redis_service_mock): index_name, path_or_url) +@pytest.mark.asyncio +async def test_delete_documents_forbidden_for_read_only(vdb_core_mock, auth_data): + """Read-only users must not be able to delete files from a knowledge base.""" + with patch("backend.apps.vectordatabase_app.get_vector_db_core", return_value=vdb_core_mock), \ + patch("backend.apps.vectordatabase_app.get_current_user_id", + return_value=(auth_data["user_id"], auth_data["tenant_id"])), \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.require_knowledge_base_edit_permission", + side_effect=PermissionError("No permission to modify this knowledge base"), + ), \ + patch( + "backend.apps.vectordatabase_app.ElasticSearchService.delete_document_by_scope", + new_callable=AsyncMock, + ) as mock_delete_by_scope: + + response = client.delete( + f"/indices/{auth_data['index_name']}/documents", + params={"path_or_url": "test_document.pdf", "scope": "full"}, + headers=auth_data["auth_header"], + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "No permission to modify this knowledge base" + mock_delete_by_scope.assert_not_called() + + @pytest.mark.asyncio async def test_delete_documents_source_only_skips_redis(vdb_core_mock, redis_service_mock): """source_only scope must not trigger Redis document cleanup.""" diff --git a/test/backend/database/test_model_managment_db.py b/test/backend/database/test_model_managment_db.py index 25692c1ca..ad2583b43 100644 --- a/test/backend/database/test_model_managment_db.py +++ b/test/backend/database/test_model_managment_db.py @@ -437,6 +437,22 @@ def fake_get_model_records(filters, tenant_id): assert captured["model_type"] == "embedding" +def test_get_model_by_display_name_llm_filter(monkeypatch): + captured = {} + + def fake_get_model_records(filters, tenant_id): + captured.update(filters) + return [{"model_id": 13, "display_name": "Shared Name", "model_type": "llm"}] + + monkeypatch.setattr(model_mgmt_db, "get_model_records", fake_get_model_records) + + result = model_mgmt_db.get_model_by_display_name("Shared Name", "tenant13", model_type="llm") + + assert result["model_id"] == 13 + assert captured["display_name"] == "Shared Name" + assert captured["model_type"] == "llm" + + def test_get_model_by_model_id_not_found(monkeypatch): mock_scalars = MagicMock() mock_scalars.first.return_value = None diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 0411a6f30..cedf0e57e 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -525,6 +525,35 @@ async def test_check_model_connectivity_exception(): "model123", {"connect_status": "unavailable"}) +@pytest.mark.asyncio +async def test_check_model_connectivity_probe_exception_returns_unavailable(): + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ + mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ + mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: + + mock_enum.AVAILABLE.value = "available" + mock_enum.UNAVAILABLE.value = "unavailable" + mock_enum.DETECTING.value = "detecting" + + mock_get_model.return_value = { + "model_id": "model123", + "model_name": "gpt-4", + "model_type": "llm", + "base_url": "https://api.openai.com", + "api_key": "test-key" + } + mock_connectivity_check.side_effect = RuntimeError("connection refused") + + response = await check_model_connectivity("GPT-4", "tenant456") + + assert response["connectivity"] is False + assert response["model_name"] == "gpt-4" + assert response["error"] == "connection refused" + mock_update_model.assert_any_call( + "model123", {"connect_status": "unavailable"}) + + @pytest.mark.asyncio async def test_check_model_connectivity_general_exception(): # Setup diff --git a/test/backend/services/test_vectordatabase_service.py b/test/backend/services/test_vectordatabase_service.py index c6d2ea3e6..05660d92d 100644 --- a/test/backend/services/test_vectordatabase_service.py +++ b/test/backend/services/test_vectordatabase_service.py @@ -7383,5 +7383,222 @@ def test_search_hybrid_needs_config_raises(self, mock_get_model): self.assertIn("embedding model", str(ctx.exception).lower()) +def _patch_kb_permission_context( + monkeypatch, + record, + user_tenant=None, + user_group_ids=None, + speed_mode=False, +): + import backend.services.vectordatabase_service as vdb_service + + monkeypatch.setattr(vdb_service, "get_knowledge_record", lambda _filters: record) + monkeypatch.setattr( + vdb_service, + "get_user_tenant_by_user_id", + lambda _user_id: user_tenant, + ) + monkeypatch.setattr( + vdb_service, + "query_group_ids_by_user", + lambda _user_id: user_group_ids or [], + ) + monkeypatch.setattr(vdb_service, "IS_SPEED_MODE", speed_mode) + monkeypatch.setattr(vdb_service, "ASSET_OWNER_TENANT_ID", "asset-owner") + + +def test_resolve_knowledge_base_permission_not_found(monkeypatch): + _patch_kb_permission_context(monkeypatch, record=None) + + with pytest.raises(ValueError, match="not found"): + ElasticSearchService.resolve_knowledge_base_permission( + "missing-kb", "user-1", "tenant-1" + ) + + +def test_resolve_knowledge_base_permission_datamate_is_read_only(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={"index_name": "datamate-kb", "knowledge_sources": "datamate"}, + ) + + permission = ElasticSearchService.resolve_knowledge_base_permission( + "datamate-kb", "user-1", "tenant-1" + ) + + assert permission == "READ_ONLY" + + +def test_resolve_knowledge_base_permission_without_user_tenant_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant=None, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "user-1", "tenant-1") + is None + ) + + +@pytest.mark.parametrize( + "user_id,user_tenant,speed_mode,expected", + [ + ("tenant-1", {"tenant_id": "tenant-1"}, False, "EDIT"), + ("user-speed", None, True, "EDIT"), + ("admin-user", {"user_role": "ADMIN", "tenant_id": "tenant-1"}, False, "EDIT"), + ], +) +def test_resolve_knowledge_base_permission_admin_like_roles_edit( + monkeypatch, + user_id, + user_tenant, + speed_mode, + expected, +): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant=user_tenant, + speed_mode=speed_mode, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", user_id, "tenant-1") + == expected + ) + + +@pytest.mark.parametrize( + "role,expected", + [ + ("ASSET_OWNER", "EDIT"), + ("SU", "READ_ONLY"), + ("DEV", "READ_ONLY"), + ("USER", None), + ], +) +def test_resolve_knowledge_base_permission_asset_owner_record(monkeypatch, role, expected): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "asset-kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "asset-owner", + }, + user_tenant={"user_role": role, "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("asset-kb", "user-1", "tenant-1") + == expected + ) + + +def test_resolve_knowledge_base_permission_cross_tenant_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-2", + }, + user_tenant={"user_role": "ADMIN", "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "admin-user", "tenant-1") + is None + ) + + +def test_resolve_knowledge_base_permission_unknown_role_returns_none(monkeypatch): + _patch_kb_permission_context( + monkeypatch, + record={ + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + }, + user_tenant={"user_role": "GUEST", "tenant_id": "tenant-1"}, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "guest-user", "tenant-1") + is None + ) + + +@pytest.mark.parametrize( + "record,user_group_ids,expected", + [ + ({"group_ids": "1,2", "created_by": "other", "ingroup_permission": "EDIT"}, [2], "EDIT"), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "READ_ONLY"}, [1], "READ_ONLY"), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "PRIVATE"}, [1], None), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "UNKNOWN"}, [1], None), + ({"group_ids": "1", "created_by": "other", "ingroup_permission": "EDIT"}, [3], None), + ({"group_ids": "", "created_by": "user-1", "ingroup_permission": "READ_ONLY"}, [], "CREATOR"), + ({"group_ids": None, "created_by": "other"}, [], "READ_ONLY"), + ], +) +def test_resolve_knowledge_base_permission_user_group_rules( + monkeypatch, + record, + user_group_ids, + expected, +): + record = { + "index_name": "kb", + "knowledge_sources": "elasticsearch", + "tenant_id": "tenant-1", + **record, + } + _patch_kb_permission_context( + monkeypatch, + record=record, + user_tenant={"user_role": "USER", "tenant_id": "tenant-1"}, + user_group_ids=user_group_ids, + ) + + assert ( + ElasticSearchService.resolve_knowledge_base_permission("kb", "user-1", "tenant-1") + == expected + ) + + +@pytest.mark.parametrize("permission", ["EDIT", "CREATOR"]) +def test_require_knowledge_base_edit_permission_allows_editors(monkeypatch, permission): + monkeypatch.setattr( + ElasticSearchService, + "resolve_knowledge_base_permission", + staticmethod(lambda **_kwargs: permission), + ) + + assert ( + ElasticSearchService.require_knowledge_base_edit_permission("kb", "user-1", "tenant-1") + == permission + ) + + +def test_require_knowledge_base_edit_permission_rejects_read_only(monkeypatch): + monkeypatch.setattr( + ElasticSearchService, + "resolve_knowledge_base_permission", + staticmethod(lambda **_kwargs: "READ_ONLY"), + ) + + with pytest.raises(PermissionError, match="No permission"): + ElasticSearchService.require_knowledge_base_edit_permission("kb", "user-1", "tenant-1") + + if __name__ == '__main__': unittest.main()