From e260d484e507f638415b4504a5d6a399b460d1bd Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Wed, 27 May 2026 17:47:13 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Feat:=20add=20ASSET=5FOWNER=20r?= =?UTF-8?q?ole,=20enforce=20asset=20visibility,=20and=20refine=20northboun?= =?UTF-8?q?d=20knowledge=20APIs=20*=20Introduce=20ASSET=5FOWNER=20role=20w?= =?UTF-8?q?ith=20virtual=20tenant=20scope=20(asset=5Fowner=5Ftenant=5Fid)?= =?UTF-8?q?=20and=20invitation=20bootstrap=20flow=20*=20Add/adjust=20role?= =?UTF-8?q?=20permissions=20and=20tenant=20migrations=20for=20ASSET=5FOWNE?= =?UTF-8?q?R-scoped=20resources=20(agents,=20skills,=20models,=20tools,=20?= =?UTF-8?q?invitations)=20*=20Enforce=20visibility=20rules:=20hide=20ASSET?= =?UTF-8?q?=5FOWNER=20agent=20prompts=20for=20non-ASSET=5FOWNER=20callers?= =?UTF-8?q?=20(prompts=5Fhidden)=20and=20restrict=20ASSET=5FOWNER=20skills?= =?UTF-8?q?/docs/files=20to=20asset-owner=20scope=20*=20Tighten=20attachme?= =?UTF-8?q?nt=20access=20control=20for=20attachments/asset=5Fowner/{user?= =?UTF-8?q?=5Fid}=20while=20keeping=20knowledge=5Fbase=20files=20readable?= =?UTF-8?q?=20for=20authenticated=20users=20*=20Refine=20/nb/v1/knowledge?= =?UTF-8?q?=20endpoints=20and=20parameters=20for=20index=20and=20file=20op?= =?UTF-8?q?erations=20(list/create/delete=20indices,=20list=20files,=20del?= =?UTF-8?q?ete=20documents,=20upload/download)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/agent_app.py | 65 ++- backend/apps/file_management_app.py | 29 +- backend/apps/invitation_app.py | 12 + backend/apps/model_managment_app.py | 1 + backend/apps/northbound_base_app.py | 2 + backend/apps/northbound_knowledge_app.py | 504 ++++++++++++++++++ backend/apps/skill_app.py | 96 +++- backend/apps/user_management_app.py | 41 +- backend/apps/vectordatabase_app.py | 96 +++- backend/consts/const.py | 40 +- backend/database/agent_db.py | 1 - backend/database/agent_version_db.py | 6 +- backend/database/invitation_db.py | 4 +- backend/services/agent_service.py | 136 +++-- backend/services/asset_owner_visibility.py | 202 +++++++ backend/services/file_management_service.py | 144 ++--- backend/services/invitation_service.py | 108 +++- backend/services/tenant_service.py | 123 +++-- backend/services/user_management_service.py | 78 ++- backend/services/vectordatabase_service.py | 28 +- backend/utils/auth_utils.py | 46 +- docker/.env.example | 3 + docker/init.sql | 40 +- .../app/[locale]/agents/AgentVersionCard.tsx | 3 +- .../agents/components/AgentManageComp.tsx | 211 ++++++++ .../agentConfig/SkillBuildModal.tsx | 5 + .../agentConfig/SkillDetailModal.tsx | 27 +- .../agentInfo/AgentGenerateDetail.tsx | 20 +- .../[locale]/asset-owner-resources/page.tsx | 18 + .../components/document/DocumentChunk.tsx | 19 +- .../components/MarketAgentDetailModal.tsx | 31 +- .../space/components/AgentDetailModal.tsx | 25 +- .../components/AssetOwnerResourcesComp.tsx | 110 ++++ .../components/UserManageComp.tsx | 230 +++++--- .../components/resources/GroupList.tsx | 14 +- .../components/resources/InvitationList.tsx | 329 ++++++++---- .../components/resources/SkillList.tsx | 135 +++-- .../components/resources/UserList.tsx | 4 +- .../users/components/UserProfileComp.tsx | 178 ++++--- frontend/components/auth/avatarDropdown.tsx | 21 +- .../components/navigation/SideNavigation.tsx | 1 + frontend/const/auth.ts | 8 +- frontend/const/modelConfig.ts | 1 + frontend/hooks/agent/useAgentList.ts | 12 +- .../hooks/invitation/useInvitationList.ts | 2 +- frontend/hooks/useAgentImport.ts | 3 + frontend/lib/agentListTenant.ts | 30 ++ frontend/lib/agentPromptVisibility.ts | 35 ++ frontend/lib/auth.ts | 3 +- frontend/lib/tenantScope.ts | 35 ++ frontend/public/locales/en/common.json | 10 + frontend/public/locales/zh/common.json | 10 + frontend/services/agentConfigService.ts | 163 ++++-- frontend/services/authService.ts | 20 +- frontend/services/skillService.ts | 2 +- frontend/stores/agentConfigStore.ts | 2 + frontend/types/agentConfig.ts | 2 + frontend/types/market.ts | 1 + .../charts/nexent-common/files/init.sql | 35 +- 59 files changed, 2871 insertions(+), 689 deletions(-) create mode 100644 backend/apps/northbound_knowledge_app.py create mode 100644 backend/services/asset_owner_visibility.py create mode 100644 frontend/app/[locale]/agents/components/AgentManageComp.tsx create mode 100644 frontend/app/[locale]/asset-owner-resources/page.tsx create mode 100644 frontend/app/[locale]/tenant-resources/components/AssetOwnerResourcesComp.tsx create mode 100644 frontend/lib/agentListTenant.ts create mode 100644 frontend/lib/agentPromptVisibility.ts create mode 100644 frontend/lib/tenantScope.ts diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 0a97f3845..923251906 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -7,8 +7,11 @@ from fastapi.encoders import jsonable_encoder from starlette.responses import JSONResponse, Response +from consts.const import ASSET_OWNER_TENANT_ID from consts.model import AgentRequest, AgentInfoRequest, AgentIDRequest, ConversationResponse, AgentImportRequest, AgentNameBatchCheckRequest, AgentNameBatchRegenerateRequest, VersionPublishRequest, VersionListResponse, VersionDetailResponse, VersionRollbackRequest, VersionStatusRequest, CurrentVersionResponse, VersionCompareRequest, VersionUpdateRequest from consts.exceptions import SkillDuplicateError +from services.asset_owner_visibility import apply_agent_detail_prompt_visibility + from services.agent_service import ( get_agent_info_impl, get_creating_sub_agent_info_impl, @@ -91,7 +94,8 @@ async def search_agent_info_api( user_id, auth_tenant_id = get_current_user_id(authorization) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id - return await get_agent_info_impl(agent_id, effective_tenant_id, version_no, user_id) + agent_info = await get_agent_info_impl(agent_id, effective_tenant_id, version_no, user_id) + return apply_agent_detail_prompt_visibility(auth_tenant_id, agent_info) except Exception as e: logger.error(f"Agent search info error: {str(e)}") raise HTTPException( @@ -158,7 +162,8 @@ async def delete_agent_api( Delete an agent """ try: - user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) + user_id, auth_tenant_id, _ = get_current_user_info( + authorization, http_request) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id await delete_agent_impl(request.agent_id, effective_tenant_id, user_id) @@ -287,10 +292,19 @@ async def list_all_agent_info_api( list all agent info """ try: - user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id - effective_tenant_id = tenant_id or auth_tenant_id - return await list_all_agent_info_impl(tenant_id=effective_tenant_id, user_id=user_id) + user_id, auth_tenant_id, _ = get_current_user_info( + authorization, request) + if tenant_id is None: + agent_list = await list_all_agent_info_impl( + tenant_id=auth_tenant_id, user_id=user_id + ) + if auth_tenant_id != ASSET_OWNER_TENANT_ID: + asset_agent_list = await list_all_agent_info_impl( + tenant_id=ASSET_OWNER_TENANT_ID, user_id=user_id + ) + return agent_list + asset_agent_list + return agent_list + return await list_all_agent_info_impl(tenant_id=tenant_id, user_id=user_id) except Exception as e: logger.error(f"Agent list error: {str(e)}") raise HTTPException( @@ -339,7 +353,8 @@ async def publish_version_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Publish version error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Publish version error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Publish version error.") @agent_config_router.post("/{agent_id}/versions/compare") @@ -364,7 +379,8 @@ async def compare_versions_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Compare versions error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Compare versions error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Compare versions error.") @agent_config_router.get("/{agent_id}/versions", response_model=VersionListResponse) @@ -375,14 +391,16 @@ async def get_version_list_api( authorization: Optional[str] = Header(None), request: Request = None ): - """ + """versions = session.query(AgentVersion) Get version list for an agent """ try: - user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) + user_id, auth_tenant_id, _ = get_current_user_info( + authorization, request) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id - logger.info(f"Get version list for agent_id: {agent_id}, tenant_id: {effective_tenant_id}") + logger.info( + f"Get version list for agent_id: {agent_id}, tenant_id: {effective_tenant_id}") result = get_version_list_impl( agent_id=agent_id, tenant_id=effective_tenant_id, @@ -391,7 +409,8 @@ async def get_version_list_api( return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) except Exception as e: logger.error(f"Get version list error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version list error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version list error.") @agent_config_router.get("/{agent_id}/versions/{version_no}", response_model=VersionDetailResponse) @@ -415,7 +434,9 @@ async def get_version_api( raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except Exception as e: logger.error(f"Get version detail error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") + @agent_config_router.get("/{agent_id}/versions/{version_no}/detail", response_model=VersionDetailResponse) async def get_version_detail_api( @@ -438,7 +459,8 @@ async def get_version_detail_api( raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except Exception as e: logger.error(f"Get version detail error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get version detail error.") @agent_config_router.post("/{agent_id}/versions/{version_no}/rollback") @@ -465,7 +487,8 @@ async def rollback_version_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Rollback version error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Rollback version error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Rollback version error.") @agent_config_router.patch("/{agent_id}/versions/{version_no}/status") @@ -492,7 +515,8 @@ async def update_version_status_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Update version status error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Update version status error.") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Update version status error.") @agent_config_router.put("/{agent_id}/versions/{version_no}") @@ -520,7 +544,8 @@ async def update_version_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Update version error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Update version error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Update version error.") @agent_config_router.delete("/{agent_id}/versions/{version_no}") @@ -545,7 +570,8 @@ async def delete_version_api( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logger.error(f"Delete version error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Delete version error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Delete version error.") @agent_config_router.get("/{agent_id}/current_version", response_model=CurrentVersionResponse) @@ -567,7 +593,8 @@ async def get_current_version_api( raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except Exception as e: logger.error(f"Get current version error: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get current version error.") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Get current version error.") @agent_config_router.get("/published_list") diff --git a/backend/apps/file_management_app.py b/backend/apps/file_management_app.py index 578277b6d..92dbeaf9a 100644 --- a/backend/apps/file_management_app.py +++ b/backend/apps/file_management_app.py @@ -14,7 +14,8 @@ from consts.model import ProcessParams 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 + resolve_preview_file, get_preview_stream, check_file_access, check_file_access_batch, \ + resolve_minio_upload_folder from utils.auth_utils import get_current_user_id from utils.file_management_utils import trigger_data_process @@ -101,7 +102,9 @@ async def upload_files( detail="No files in the request") user_id, tenant_id = get_current_user_id(authorization) - errors, uploaded_file_paths, uploaded_filenames = await upload_files_impl(destination, file, folder, index_name, user_id) + errors, uploaded_file_paths, uploaded_filenames = await upload_files_impl( + destination, file, folder, index_name, user_id, uploader_tenant_id=tenant_id + ) if uploaded_file_paths: return JSONResponse( @@ -199,7 +202,7 @@ async def get_storage_file( try: user_id, tenant_id = get_current_user_id(authorization) - if not check_file_access(object_name, user_id): + if not check_file_access(object_name, user_id, tenant_id): logger.warning(f"[get_storage_file] Access denied: object_name={object_name}, user_id={user_id}") raise HTTPException( status_code=HTTPStatus.FORBIDDEN, @@ -282,15 +285,8 @@ async def storage_upload_files( try: user_id, tenant_id = get_current_user_id(authorization) - if folder == "knowledge_base": - actual_folder = "knowledge_base" - else: - if user_id: - actual_folder = f"attachments/{user_id}" - else: - actual_folder = folder or "attachments" - - results = await upload_to_minio(files=files, folder=actual_folder, user_id=user_id) + actual_folder = resolve_minio_upload_folder(folder, user_id, tenant_id) + results = await upload_to_minio(files=files, folder=actual_folder) return { "message": f"Processed {len(results)} files", @@ -344,7 +340,7 @@ async def get_storage_files( if user_id: filtered_files = [ f for f in files - if f.get("key") and check_file_access(f.get("key"), user_id) + if f.get("key") and check_file_access(f.get("key"), user_id, tenant_id) ] else: filtered_files = [ @@ -592,7 +588,7 @@ async def remove_storage_file( try: user_id, tenant_id = get_current_user_id(authorization) - if not check_file_access(object_name, user_id): + if not check_file_access(object_name, user_id, tenant_id): logger.warning(f"[remove_storage_file] Access denied: object_name={object_name}, user_id={user_id}") raise HTTPException( status_code=HTTPStatus.FORBIDDEN, @@ -643,7 +639,7 @@ async def get_storage_file_batch_urls( results = [] for object_name in object_names: - if not check_file_access(object_name, user_id): + if not check_file_access(object_name, user_id, tenant_id): results.append({ "object_name": object_name, "success": False, @@ -693,6 +689,7 @@ async def preview_file( Access control: - knowledge_base/*: All authenticated users can access - attachments/{user_id}/*: Only the owner (user_id) can access + - attachments/asset_owner/{user_id}/*: ASSET_OWNER virtual tenant and owner only - **object_name**: File object name in storage - **filename**: Original filename for Content-Disposition header (optional) @@ -703,7 +700,7 @@ async def preview_file( try: user_id, tenant_id = get_current_user_id(authorization) - if not check_file_access(object_name, user_id): + if not check_file_access(object_name, user_id, tenant_id): logger.warning(f"[preview_file] Access denied: object_name={object_name}, user_id={user_id}") raise HTTPException( status_code=HTTPStatus.FORBIDDEN, diff --git a/backend/apps/invitation_app.py b/backend/apps/invitation_app.py index 2aa3edc9e..55bbac998 100644 --- a/backend/apps/invitation_app.py +++ b/backend/apps/invitation_app.py @@ -69,6 +69,12 @@ async def list_invitations_endpoint( status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc) ) + except ValidationError as exc: + logger.warning(f"Invitation list rejected by feature flag: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) except Exception as exc: logger.error(f"Unexpected error retrieving invitation list: {str(exc)}") raise HTTPException( @@ -131,6 +137,12 @@ async def create_invitation_endpoint( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) + except ValidationError as exc: + logger.warning(f"Invitation creation rejected by feature flag: {str(exc)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc) + ) except DuplicateError as exc: logger.warning(f"Duplicate invitation code: {str(exc)}") raise HTTPException( diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 278b729e8..46713b330 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -264,6 +264,7 @@ async def get_model_list(authorization: Optional[str] = Header(None)): Returns each model enriched with repo-qualified `model_name` and a normalized `connect_status` value. """ + try: user_id, tenant_id = get_current_user_id(authorization) logger.debug( diff --git a/backend/apps/northbound_base_app.py b/backend/apps/northbound_base_app.py index db303e00f..66d937b52 100644 --- a/backend/apps/northbound_base_app.py +++ b/backend/apps/northbound_base_app.py @@ -16,6 +16,7 @@ from apps.app_factory import create_app from .northbound_app import router as northbound_router +from .northbound_knowledge_app import router as northbound_knowledge_router class A2AServerSettings(BaseModel): @@ -49,6 +50,7 @@ class A2AServerSettings(BaseModel): ) northbound_app.include_router(northbound_router) +northbound_app.include_router(northbound_knowledge_router) # ============================================================================= diff --git a/backend/apps/northbound_knowledge_app.py b/backend/apps/northbound_knowledge_app.py new file mode 100644 index 000000000..6999315ba --- /dev/null +++ b/backend/apps/northbound_knowledge_app.py @@ -0,0 +1,504 @@ +import base64 +import logging +from http import HTTPStatus +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, Body, File, Form, Path, Path as PathParam, Query, Request, HTTPException, UploadFile +from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse + +from consts.const import ASSET_OWNER_TENANT_ID, VectorDatabaseType +from consts.exceptions import ( + LimitExceededError, + UnauthorizedError, +) +from consts.model import ProcessParams +from services.file_management_service import ( + upload_files_impl, + get_file_url_impl, + get_file_stream_impl, + check_file_access, +) +from services.northbound_service import NorthboundContext +from services.redis_service import get_redis_service +from services.vectordatabase_service import ElasticSearchService, get_vector_db_core +from utils.auth_utils import generate_session_jwt +from utils.file_management_utils import trigger_data_process + +from .file_management_app import build_content_disposition_header +from .northbound_app import _get_northbound_context + + +logger = logging.getLogger("northbound_knowledge_app") + +router = APIRouter(prefix="/nb/v1/knowledge", tags=["northbound"]) + +__all__ = ["router"] + + +async def _require_asset_owner_context(request: Request) -> NorthboundContext: + """Resolve northbound context and ensure the caller belongs to the asset-owner tenant.""" + ctx = await _get_northbound_context(request) + if ctx.tenant_id != ASSET_OWNER_TENANT_ID: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="This endpoint is restricted to asset administrators.", + ) + return ctx + + +@router.get("/indices") +async def get_list_indices( + request: Request, + pattern: str = Query("*", description="Pattern to match index names"), +): + """List knowledge bases visible to the asset-owner tenant. + + Restricted to asset administrators (same auth as create_new_index). + """ + try: + ctx = await _require_asset_owner_context(request) + vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) + return ElasticSearchService.list_indices( + pattern, True, ctx.tenant_id, ctx.user_id, vdb_core + ) + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing knowledge bases: {str(e)}", exc_info=True) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error listing knowledge bases: {str(e)}") + + +@router.post("/indices/{index_name}") +async def create_new_index( + request: Request, + index_name: str = Path(..., description="Name of the index to create"), + embedding_dim: Optional[int] = Query( + None, description="Dimension of the embedding vectors"), + body: Optional[Dict[str, Any]] = Body( + None, + description="Request body with optional fields (ingroup_permission, group_ids, embedding_model_name)"), +): + """Create a new vector index and store it in the knowledge table. + + Restricted to the asset-owner tenant: only callers whose access key resolves + to the asset-owner tenant are allowed to create knowledge bases through the + northbound API. + """ + try: + ctx = await _require_asset_owner_context(request) + vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) + + ingroup_permission = None + group_ids = None + embedding_model_name = None + if body: + ingroup_permission = body.get("ingroup_permission") + group_ids = body.get("group_ids") + embedding_model_name = body.get("embedding_model_name") + + return ElasticSearchService.create_knowledge_base( + knowledge_name=index_name, + embedding_dim=embedding_dim, + vdb_core=vdb_core, + user_id=ctx.user_id, + tenant_id=ctx.tenant_id, + ingroup_permission=ingroup_permission, + group_ids=group_ids, + embedding_model_name=embedding_model_name, + ) + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error creating index '{index_name}': {str(e)}", exc_info=True) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error creating index: {str(e)}") + + +@router.delete("/indices/{index_name}") +async def delete_index( + request: Request, + index_name: str = Path(..., description="Name of the index to delete"), +): + """Delete a knowledge base and all related data. + + Restricted to asset administrators (same auth as create_new_index). + """ + logger.debug(f"Received northbound request to delete knowledge base: {index_name}") + try: + ctx = await _require_asset_owner_context(request) + vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) + return await ElasticSearchService.full_delete_knowledge_base( + index_name, vdb_core, ctx.user_id + ) + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error deleting index '{index_name}': {str(e)}", exc_info=True) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error deleting index: {str(e)}") + + +@router.get("/indices/{index_name}/files") +async def get_index_files( + request: Request, + index_name: str = Path(..., description="Name of the index"), +): + """Get all files from an index, including those that are not yet stored in ES. + + Restricted to asset administrators (same auth as get_list_indices). + """ + try: + ctx = await _require_asset_owner_context(request) + vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) + logger.debug( + "Listing files for index %s, tenant_id=%s, user_id=%s", + index_name, + ctx.tenant_id, + ctx.user_id, + ) + result = await ElasticSearchService.list_files( + index_name, include_chunks=False, vdb_core=vdb_core + ) + return { + "status": "success", + "files": result.get("files", []), + } + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error getting files for index '{index_name}': {str(e)}", + exc_info=True, + ) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error getting index files: {str(e)}") + + +@router.delete("/indices/{index_name}/documents") +async def delete_documents( + request: Request, + index_name: str = Path(..., description="Name of the index"), + path_or_url: str = Query( + ..., description="Path or URL of documents to delete"), +): + """Delete documents by path or URL and clean up related Redis records. + + Restricted to asset administrators (same auth as get_list_indices). + """ + try: + ctx = await _require_asset_owner_context(request) + vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) + logger.debug( + "Deleting documents for index %s, path_or_url=%s, tenant_id=%s, user_id=%s", + index_name, + path_or_url, + ctx.tenant_id, + ctx.user_id, + ) + result = ElasticSearchService.delete_documents( + index_name, path_or_url, vdb_core) + + try: + redis_service = get_redis_service() + redis_cleanup_result = redis_service.delete_document_records( + index_name, path_or_url) + + result["redis_cleanup"] = redis_cleanup_result + + original_message = result.get( + "message", "Documents deleted successfully") + result["message"] = ( + f"{original_message}. " + f"Cleaned up {redis_cleanup_result['total_deleted']} Redis records " + f"({redis_cleanup_result['celery_tasks_deleted']} tasks, " + f"{redis_cleanup_result['cache_keys_deleted']} cache keys)." + ) + + if redis_cleanup_result.get("errors"): + result["redis_warnings"] = redis_cleanup_result["errors"] + + except Exception as redis_error: + logger.warning( + "Redis cleanup failed for document %s in index %s: %s", + path_or_url, + index_name, + str(redis_error), + ) + result["redis_cleanup_error"] = str(redis_error) + original_message = result.get( + "message", "Documents deleted successfully") + result["message"] = ( + f"{original_message}, but Redis cleanup encountered an error: " + f"{str(redis_error)}" + ) + + return result + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error deleting documents for index '{index_name}': {str(e)}", + exc_info=True, + ) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Error deleting documents: {str(e)}") + + +@router.post("/file/upload") +async def upload_files( + request: Request, + file: List[UploadFile] = File(..., alias="file"), + index_name: str = Form(..., description="Knowledge base index"), +): + """Upload files to MinIO and trigger knowledge base data processing. + + Uses chunking_strategy=basic. Restricted to asset administrators + (same auth as create_new_index). + """ + try: + ctx = await _require_asset_owner_context(request) + destination = "minio" + if not file: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No files in the request", + ) + + errors, uploaded_file_paths, uploaded_filenames = await upload_files_impl( + destination, file, None, index_name, ctx.user_id, uploader_tenant_id=ctx.tenant_id + ) + + if uploaded_file_paths: + files = [ + {"path_or_url": path, "filename": name} + for path, name in zip(uploaded_file_paths, uploaded_filenames) + ] + # Internal data-process / ES indexing expects JWT, not northbound API key + internal_jwt = generate_session_jwt(ctx.user_id) + process_params = ProcessParams( + chunking_strategy="basic", + source_type="minio", + index_name=index_name, + authorization=internal_jwt, + ) + process_result = await trigger_data_process(files, process_params) + + if process_result is None or ( + isinstance(process_result, dict) + and process_result.get("status") == "error" + ): + error_message = "Data process service failed" + if isinstance(process_result, dict) and "message" in process_result: + error_message = process_result["message"] + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=error_message, + ) + + return JSONResponse( + status_code=HTTPStatus.CREATED, + content={ + "message": ( + "Files uploaded and processing triggered successfully" + ), + "uploaded_filenames": uploaded_filenames, + "uploaded_file_paths": uploaded_file_paths, + "errors": errors, + "process_tasks": process_result, + }, + ) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No valid files uploaded", + ) + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error(f"File upload error: {str(e)}", exc_info=True) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="File upload error.") + + +@router.get("/file/download/{object_name:path}") +async def get_storage_file( + request: Request, + object_name: str = PathParam(..., description="File object name"), + download: str = Query( + "ignore", + description=( + "How to get the file: " + "'ignore' (default, return file info), " + "'stream' (return file stream), " + "'redirect' (redirect to download URL), " + "'base64' (return base64-encoded content for images)." + ), + ), + expires: int = Query(86400, description="URL validity period (seconds)"), + filename: Optional[str] = Query( + None, description="Original filename for download (optional)"), +): + """Get file information, download link, or file stream. + + Restricted to asset administrators (same auth as create_new_index). + """ + try: + ctx = await _require_asset_owner_context(request) + + if not check_file_access(object_name, ctx.user_id, ctx.tenant_id): + logger.warning( + "[get_storage_file] Access denied: object_name=%s, user_id=%s", + object_name, + ctx.user_id, + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="You don't have permission to access this file", + ) + + logger.info( + "[get_storage_file] object_name=%s, download=%s, filename=%s", + object_name, + download, + filename, + ) + if download == "redirect": + result = await get_file_url_impl( + object_name=object_name, expires=expires) + return RedirectResponse(url=result["url"]) + if download == "stream": + file_stream, content_type = await get_file_stream_impl( + object_name=object_name) + logger.info( + "Streaming file: object_name=%s, content_type=%s", + object_name, + content_type, + ) + + download_filename = filename + if not download_filename: + download_filename = ( + object_name.split("/")[-1] + if "/" in object_name + else object_name + ) + + content_disposition = build_content_disposition_header( + download_filename) + + return StreamingResponse( + file_stream, + media_type=content_type, + headers={ + "Content-Disposition": content_disposition, + "Cache-Control": "public, max-age=3600", + "ETag": f'"{object_name}"', + }, + ) + if download == "base64": + file_stream, content_type = await get_file_stream_impl( + object_name=object_name) + try: + data = file_stream.read() + except Exception as exc: + logger.error( + "Failed to read file stream for base64: %s", str(exc)) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to read file content for base64 encoding", + ) + + base64_content = base64.b64encode(data).decode("utf-8") + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "success": True, + "base64": base64_content, + "content_type": content_type, + "object_name": object_name, + }, + ) + return await get_file_url_impl( + object_name=object_name, expires=expires) + except LimitExceededError as e: + logger.error( + f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + detail="Too Many Requests: rate limit exceeded") + except UnauthorizedError as e: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to get file: object_name={object_name}, error={str(e)}", + exc_info=True, + ) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to get file.") + diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py index 40d3613f8..a2a3b38cf 100644 --- a/backend/apps/skill_app.py +++ b/backend/apps/skill_app.py @@ -1,5 +1,6 @@ """Skill management HTTP endpoints.""" +from nexent.core.agents.agent_model import ModelConfig import logging from typing import Any, Dict, List, Optional @@ -19,7 +20,9 @@ ) from consts.model import SkillInstanceInfoRequest, SkillCreateRequest, SkillCreateInteractiveRequest, SkillUpdateRequest, SkillResponse from utils.auth_utils import get_current_user_id, get_current_user_info -from nexent.core.agents.agent_model import ModelConfig +from services.asset_owner_visibility import can_view_skill + +ASSET_OWNER_SKILL_VIEW_DENIED = {"content": "您无权限查看"} logger = logging.getLogger(__name__) @@ -27,10 +30,18 @@ skill_creator_router = APIRouter(prefix="/skills", tags=["nl2skill"]) +def _asset_owner_skill_view_denied_response(skill: Optional[Dict[str, Any]], tenant_id: str): + """Return a denial JSONResponse when the caller cannot view an ASSET_OWNER-scoped skill.""" + if skill and not can_view_skill(tenant_id, skill.get("tenant_id")): + return JSONResponse(content=ASSET_OWNER_SKILL_VIEW_DENIED) + return None + + # List routes first (no path parameters) @router.get("") async def list_skills( - tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to query specific tenant's skills"), + tenant_id: Optional[str] = Query( + None, description="Tenant ID for super admin to query specific tenant's skills"), authorization: Optional[str] = Header(None) ) -> JSONResponse: """List all available skills for the current tenant (or a specific tenant for super admin).""" @@ -50,7 +61,8 @@ async def list_skills( @router.get("/official") async def list_official_skills( - tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to query specific tenant's skills"), + tenant_id: Optional[str] = Query( + None, description="Tenant ID for super admin to query specific tenant's skills"), authorization: Optional[str] = Header(None) ) -> JSONResponse: """List all official skills with installation status for the current tenant (or a specific tenant for super admin). @@ -70,14 +82,17 @@ async def list_official_skills( class InstallSkillsRequest(BaseModel): - skill_names: List[str] = Field(..., description="List of skill names to install") - locale: Optional[str] = Field(default="en", description="Frontend locale (zh or en)") + skill_names: List[str] = Field(..., + description="List of skill names to install") + locale: Optional[str] = Field( + default="en", description="Frontend locale (zh or en)") @router.post("/install") async def install_skills( request: InstallSkillsRequest, - tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to install skills for a specific tenant"), + tenant_id: Optional[str] = Query( + None, description="Tenant ID for super admin to install skills for a specific tenant"), authorization: Optional[str] = Header(None) ) -> JSONResponse: """Install official skills for the current tenant (or a specific tenant for super admin). @@ -120,7 +135,8 @@ async def create_skill( # Convert tool_names to tool_ids if provided tool_ids = request.tool_ids or [] if request.tool_names: - raise NotImplementedError("Tool names are not supported for skill creation") + raise NotImplementedError( + "Tool names are not supported for skill creation") skill_data = { "name": request.name, @@ -133,7 +149,8 @@ async def create_skill( "config_values": request.config_values, "files": request.files if request.files else [], } - skill = service.create_skill(skill_data, tenant_id=tenant_id, user_id=user_id) + skill = service.create_skill( + skill_data, tenant_id=tenant_id, user_id=user_id) return JSONResponse(content=skill, status_code=201) except UnauthorizedError as e: raise HTTPException(status_code=401, detail=str(e)) @@ -150,7 +167,8 @@ async def create_skill( @router.post("/upload") async def create_skill_from_file( file: UploadFile = File(..., description="SKILL.md file or ZIP archive"), - skill_name: Optional[str] = Form(None, description="Optional skill name override"), + skill_name: Optional[str] = Form( + None, description="Optional skill name override"), source: Optional[str] = Form("自定义", description="Skill source"), authorization: Optional[str] = Header(None) ) -> JSONResponse: @@ -191,7 +209,8 @@ async def create_skill_from_file( raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - logger.error(f"Unexpected error: {type(e).__name__}: {e}", exc_info=True) + logger.error( + f"Unexpected error: {type(e).__name__}: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @@ -205,12 +224,24 @@ async def get_skill_file_tree( try: _, tenant_id = get_current_user_id(authorization) service = SkillService(tenant_id=tenant_id) + skill = service.get_skill(skill_name) + if not skill: + raise HTTPException( + status_code=404, detail=f"Skill not found: {skill_name}") + + denied = _asset_owner_skill_view_denied_response(skill, tenant_id) + if denied: + return denied + tree = service.get_skill_file_tree(skill_name) if not tree: - raise HTTPException(status_code=404, detail=f"Skill not found: {skill_name}") + raise HTTPException( + status_code=404, detail=f"Skill not found: {skill_name}") return JSONResponse(content=tree) except HTTPException: raise + except UnauthorizedError as e: + raise HTTPException(status_code=401, detail=str(e)) except SkillException as e: raise HTTPException(status_code=500, detail=str(e)) except Exception as e: @@ -233,12 +264,24 @@ async def get_skill_file_content( try: _, tenant_id = get_current_user_id(authorization) service = SkillService(tenant_id=tenant_id) + skill = service.get_skill(skill_name) + if not skill: + raise HTTPException( + status_code=404, detail=f"Skill not found: {skill_name}") + + denied = _asset_owner_skill_view_denied_response(skill, tenant_id) + if denied: + return denied + content = service.get_skill_file_content(skill_name, file_path) if content is None: - raise HTTPException(status_code=404, detail=f"File not found: {file_path}") + raise HTTPException( + status_code=404, detail=f"File not found: {file_path}") return JSONResponse(content={"content": content}) except HTTPException: raise + except UnauthorizedError as e: + raise HTTPException(status_code=401, detail=str(e)) except SkillException as e: raise HTTPException(status_code=500, detail=str(e)) except Exception as e: @@ -359,7 +402,8 @@ async def update_skill_instance( service = SkillService(tenant_id=tenant_id) skill = service.get_skill_by_id(request.skill_id, tenant_id) if not skill: - raise HTTPException(status_code=404, detail=f"Skill with ID {request.skill_id} not found") + raise HTTPException( + status_code=404, detail=f"Skill with ID {request.skill_id} not found") # Create or update skill instance instance = service.create_or_update_skill_instance( @@ -395,7 +439,8 @@ async def update_skill_instance( @router.get("/instance/list") async def list_skill_instances( - agent_id: int = Query(..., description="Agent ID to query skill instances"), + agent_id: int = Query(..., + description="Agent ID to query skill instances"), version_no: int = Query(0, description="Version number (0 for draft)"), authorization: Optional[str] = Header(None) ) -> JSONResponse: @@ -415,7 +460,8 @@ async def list_skill_instances( # Also include config_schemas and config_values from the template (via YAML enrichment). # The instance's per-agent overrides (config_values) are used as-is for the frontend. for instance in instances: - skill = service.get_skill_by_id(instance.get("skill_id"), tenant_id) + skill = service.get_skill_by_id( + instance.get("skill_id"), tenant_id) if skill: instance["skill_name"] = skill.get("name") instance["skill_description"] = skill.get("description", "") @@ -423,7 +469,8 @@ async def list_skill_instances( # Template defaults from YAML-enriched skill instance["config_schemas"] = skill.get("config_schemas") or [] # Per-agent config_values from SkillInstance override template defaults - instance["config_values"] = instance.get("config_values") or skill.get("config_values") or {} + instance["config_values"] = instance.get( + "config_values") or skill.get("config_values") or {} return JSONResponse(content={"instances": instances}) except UnauthorizedError as e: @@ -445,7 +492,8 @@ async def scan_and_update_skill(authorization: Optional[str] = Header(None)): ) except Exception as e: logger.error(f"Failed to update skill: {e}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update skill") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update skill") @router.get("/{skill_name}") @@ -456,7 +504,8 @@ async def get_skill(skill_name: str, authorization: Optional[str] = Header(None) service = SkillService(tenant_id=tenant_id) skill = service.get_skill(skill_name, tenant_id=tenant_id) if not skill: - raise HTTPException(status_code=404, detail=f"Skill not found: {skill_name}") + raise HTTPException( + status_code=404, detail=f"Skill not found: {skill_name}") return JSONResponse(content=skill) except HTTPException: raise @@ -499,13 +548,12 @@ async def update_skill( if not update_data: raise HTTPException(status_code=400, detail="No fields to update") - print( - f"[DEBUG skill_app.update_skill] skill={skill_name} tenant={tenant_id} " - f"keys={list(update_data.keys())} has_cval={'config_values' in update_data}", - flush=True, + skill = service.update_skill( + skill_name, + update_data, + tenant_id=tenant_id, + user_id=user_id, ) - - skill = service.update_skill(skill_name, update_data, tenant_id=tenant_id, user_id=user_id) return JSONResponse(content=skill) except UnauthorizedError as e: raise HTTPException(status_code=401, detail=str(e)) diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index c8604d173..97ee75633 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -9,7 +9,14 @@ from supabase_auth.errors import AuthApiError, AuthWeakPasswordError from consts.model import UserSignInRequest, UserSignUpRequest, UpdatePasswordRequest -from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, AppException, UnauthorizedError +from consts.exceptions import ( + NoInviteCodeException, + IncorrectInviteCodeException, + UserRegistrationException, + AppException, + UnauthorizedError, + ValidationError, +) from consts.error_code import ErrorCode from services.user_management_service import get_authorized_client, validate_token, \ check_auth_service_health, signup_user_with_invitation, signin_user, refresh_user_token, \ @@ -35,10 +42,12 @@ async def service_health(): content={"message": "Auth service is available"}) except ConnectionError as e: logging.error(f"Auth service health check failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Auth service is unavailable") + raise HTTPException( + status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Auth service is unavailable") except Exception as e: logging.error(f"Auth service health check failed: {str(e)}") - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Auth service is unavailable") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Auth service is unavailable") @router.post("/signup") @@ -51,7 +60,7 @@ async def signup(request: UserSignUpRequest): auto_login=request.auto_login) success_message = "🎉 User account registered successfully! Please start experiencing the AI assistant service." return JSONResponse(status_code=HTTPStatus.OK, - content={"message":success_message, "data":user_data}) + content={"message": success_message, "data": user_data}) except NoInviteCodeException as e: logging.error(f"User registration failed by invite code: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -60,8 +69,13 @@ async def signup(request: UserSignUpRequest): logging.error(f"User registration failed by invite code: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="INVITE_CODE_INVALID") + except ValidationError as e: + logging.warning( + f"User registration rejected by feature flag: {str(e)}") + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except UserRegistrationException as e: - logging.error(f"User registration failed by registration service: {str(e)}") + logging.error( + f"User registration failed by registration service: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="REGISTRATION_SERVICE_ERROR") except AuthWeakPasswordError as e: @@ -83,13 +97,16 @@ async def signin(request: UserSignInRequest): """User login""" try: signin_content = await signin_user(email=request.email, - password=request.password) + password=request.password) return JSONResponse(status_code=HTTPStatus.OK, content=signin_content) except AuthApiError as e: logging.error(f"User login failed: {str(e)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Email or password error") + except ValidationError as e: + logging.warning(f"User login rejected by feature flag: {str(e)}") + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logging.error(f"User login failed, unknown error: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -110,7 +127,7 @@ async def user_refresh_token(request: Request): raise ValueError("No refresh token provided") session_info = await refresh_user_token(authorization, refresh_token) return JSONResponse(status_code=HTTPStatus.OK, - content={"message":"Token refresh successful", "data":{"session": session_info}}) + content={"message": "Token refresh successful", "data": {"session": session_info}}) except ValueError as e: logging.error(f"Refresh token failed: {str(e)}") raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, @@ -136,7 +153,7 @@ async def logout(request: Request): logging.warning( f"Sign out encountered an error but will be ignored: {str(signout_err)}") return JSONResponse(status_code=HTTPStatus.OK, - content={"message":"Logout successful"}) + content={"message": "Logout successful"}) except Exception as e: logging.error(f"User logout failed: {str(e)}") @@ -156,8 +173,8 @@ async def get_session(request: Request): try: data = await get_session_by_authorization(authorization) return JSONResponse(status_code=HTTPStatus.OK, - content={"message": "Session is valid", - "data": data}) + content={"message": "Session is valid", + "data": data}) except UnauthorizedError as e: logging.error(f"Get user session unauthorized: {str(e)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, @@ -278,6 +295,7 @@ async def revoke_user_account(request: Request): raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User revoke failed") + @router.post("/tokens") async def create_token_endpoint( authorization: Optional[str] = Header(None) @@ -420,7 +438,8 @@ async def update_password_endpoint( logger.warning(f"Password update unauthorized for user: {str(e)}") raise AppException(ErrorCode.PROFILE_INVALID_CREDENTIALS, str(e)) except AppException as e: - logger.warning(f"Password update business error: {e.error_code} - {str(e)}") + logger.warning( + f"Password update business error: {e.error_code} - {str(e)}") raise e # Let app_exception_handler format the response except Exception as e: logging.error(f"Failed to update password: {str(e)}", exc_info=e) diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index 6f4232afd..eeeccdbf6 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse import re +from consts.const import ASSET_OWNER_TENANT_ID, PERMISSION_READ from consts.model import ChunkCreateRequest, ChunkUpdateRequest, HybridSearchRequest, IndexingResponse from consts.scheduler import VALID_SUMMARY_FREQUENCIES, SUMMARY_FREQUENCY_OPTIONS_FOR_API from nexent.vector_database.base import VectorDatabaseCore @@ -17,10 +18,11 @@ check_knowledge_base_exist_impl, KnowledgeBaseNeedsModelConfigError, ) +from services.file_management_service import check_file_access from services.redis_service import get_redis_service from utils.auth_utils import get_current_user_id 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.knowledge_db import get_knowledge_record from database.model_management_db import get_model_by_model_id router = APIRouter(prefix="/indices") @@ -243,7 +245,8 @@ def get_embedding_model_status( # Get the knowledge base record by index_name knowledge_record = get_knowledge_record({ "index_name": index_name, - "tenant_id": tenant_id + "tenant_id": tenant_id, + "include_asset_owner_assets": True, }) if not knowledge_record: @@ -357,6 +360,35 @@ def update_embedding_model( ) +def _apply_read_only_to_asset_indices_info(asset_result: Dict[str, Any]) -> Dict[str, Any]: + """Force READ_ONLY permission on asset-owner indices_info before merge.""" + indices_info = asset_result.get("indices_info") + if not indices_info: + return asset_result + normalized = dict(asset_result) + normalized["indices_info"] = [ + {**info, "permission": PERMISSION_READ} for info in indices_info + ] + return normalized + + +def _merge_list_indices_results( + primary: Dict[str, Any], + asset_owner: Dict[str, Any], +) -> Dict[str, Any]: + """Merge tenant and ASSET_OWNER list_indices responses (concat, no dedup).""" + merged_indices = primary.get("indices", []) + asset_owner.get("indices", []) + merged: Dict[str, Any] = { + "indices": merged_indices, + "count": len(merged_indices), + } + if "indices_info" in primary or "indices_info" in asset_owner: + merged["indices_info"] = ( + primary.get("indices_info", []) + asset_owner.get("indices_info", []) + ) + return merged + + @router.get("") def get_list_indices( pattern: str = Query("*", description="Pattern to match index names"), @@ -370,9 +402,20 @@ def get_list_indices( """List all user indices with optional stats""" try: user_id, auth_tenant_id = get_current_user_id(authorization) - # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id - effective_tenant_id = tenant_id or auth_tenant_id - return ElasticSearchService.list_indices(pattern, include_stats, effective_tenant_id, user_id, vdb_core) + if tenant_id is None: + result = ElasticSearchService.list_indices( + pattern, include_stats, auth_tenant_id, user_id, vdb_core + ) + if auth_tenant_id != ASSET_OWNER_TENANT_ID: + asset_result = ElasticSearchService.list_indices( + pattern, include_stats, ASSET_OWNER_TENANT_ID, user_id, vdb_core + ) + asset_result = _apply_read_only_to_asset_indices_info(asset_result) + return _merge_list_indices_results(result, asset_result) + return result + return ElasticSearchService.list_indices( + pattern, include_stats, tenant_id, user_id, vdb_core + ) except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error get index: {str(e)}") @@ -571,7 +614,7 @@ def health_check(vdb_core: VectorDatabaseCore = Depends(get_vector_db_core)): @router.post("/{index_name}/chunks") def get_index_chunks( index_name: str = Path(..., - description="Name of the index (or knowledge_name) to get chunks from"), + description="Internal index_name from knowledge_record_t"), page: int = Query( None, description="Page number (1-based) for pagination"), page_size: int = Query( @@ -583,12 +626,23 @@ def get_index_chunks( ): """Get chunks from the specified index, with optional pagination support""" try: - _, tenant_id = get_current_user_id(authorization) - actual_index_name = get_index_name_by_knowledge_name( - index_name, tenant_id) + user_id, tenant_id = get_current_user_id(authorization) + + if path_or_url is not None and not check_file_access( + path_or_url, user_id, tenant_id + ): + logger.warning( + "[get_index_chunks] Access denied: path_or_url=%s, user_id=%s", + path_or_url, + user_id, + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="You don't have permission to access this file", + ) result = ElasticSearchService.get_index_chunks( - index_name=actual_index_name, + index_name=index_name, page=page, page_size=page_size, path_or_url=path_or_url, @@ -611,7 +665,7 @@ def get_index_chunks( @router.post("/{index_name}/chunk") def create_chunk( index_name: str = Path(..., - description="Name of the index (or knowledge_name)"), + description="Internal index_name from knowledge_record_t"), payload: ChunkCreateRequest = Body(..., description="Chunk data"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None), @@ -619,10 +673,8 @@ def create_chunk( """Create a manual chunk.""" try: user_id, tenant_id = get_current_user_id(authorization) - actual_index_name = get_index_name_by_knowledge_name( - index_name, tenant_id) result = ElasticSearchService.create_chunk( - index_name=actual_index_name, + index_name=index_name, chunk_request=payload, vdb_core=vdb_core, user_id=user_id, @@ -646,7 +698,7 @@ def create_chunk( @router.put("/{index_name}/chunk/{chunk_id}") def update_chunk( index_name: str = Path(..., - description="Name of the index (or knowledge_name)"), + description="Internal index_name from knowledge_record_t"), chunk_id: str = Path(..., description="Chunk identifier"), payload: ChunkUpdateRequest = Body(..., description="Chunk update payload"), @@ -655,11 +707,9 @@ def update_chunk( ): """Update an existing chunk.""" try: - user_id, tenant_id = get_current_user_id(authorization) - actual_index_name = get_index_name_by_knowledge_name( - index_name, tenant_id) + user_id, _ = get_current_user_id(authorization) result = ElasticSearchService.update_chunk( - index_name=actual_index_name, + index_name=index_name, chunk_id=chunk_id, chunk_request=payload, vdb_core=vdb_core, @@ -687,18 +737,16 @@ def update_chunk( @router.delete("/{index_name}/chunk/{chunk_id}") def delete_chunk( index_name: str = Path(..., - description="Name of the index (or knowledge_name)"), + description="Internal index_name from knowledge_record_t"), chunk_id: str = Path(..., description="Chunk identifier"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None), ): """Delete a chunk.""" try: - _, tenant_id = get_current_user_id(authorization) - actual_index_name = get_index_name_by_knowledge_name( - index_name, tenant_id) + get_current_user_id(authorization) result = ElasticSearchService.delete_chunk( - index_name=actual_index_name, + index_name=index_name, chunk_id=chunk_id, vdb_core=vdb_core, ) diff --git a/backend/consts/const.py b/backend/consts/const.py index bbfb1ce41..fac1ffb83 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -43,7 +43,6 @@ class VectorDatabaseType(str, Enum): MAX_TIMEOUT = int(os.getenv("DP_SPLIT_WAIT_TIMEOUT_MAX_S", "1800")) - # Container-internal skills storage path CONTAINER_SKILLS_PATH = os.getenv("SKILLS_PATH") @@ -77,7 +76,8 @@ class VectorDatabaseType(str, Enum): SERVICE_ROLE_KEY = os.getenv('SERVICE_ROLE_KEY', SUPABASE_KEY) # JWT secret for verifying Supabase-signed access tokens. # GoTrue uses GOTRUE_JWT_SECRET (= JWT_SECRET in docker setup) to sign tokens. -SUPABASE_JWT_SECRET = os.getenv('SUPABASE_JWT_SECRET') or os.getenv('JWT_SECRET', '') +SUPABASE_JWT_SECRET = os.getenv( + 'SUPABASE_JWT_SECRET') or os.getenv('JWT_SECRET', '') # OAuth Configuration @@ -108,15 +108,34 @@ class VectorDatabaseType(str, Enum): DEFAULT_USER_ID = "user_id" DEFAULT_TENANT_ID = "tenant_id" +# Invitation code type for asset administrator registration +ASSET_OWNER_INVITE_CODE_TYPE = "ASSET_OWNER_INVITE" + +# User role identifier for asset administrators +ASSET_OWNER_ROLE = "ASSET_OWNER" + +# Tenant ID for asset administrators (virtual tenant, not a real tenant) +ASSET_OWNER_TENANT_ID = "asset_owner_tenant_id" + +# MinIO prefix for ASSET_OWNER-scoped attachment uploads (attachments/asset_owner/{user_id}/...) +ASSET_OWNER_ATTACHMENTS_PREFIX = "attachments/asset_owner" + +# When false, block ASSET_OWNER invites, registrations, and sign-in. +ENABLE_ASSET_OWNER_ROLE = os.getenv( + "ENABLE_ASSET_OWNER_ROLE", "false").lower() == "true" + # Roles that can edit all resources within a tenant (permission = EDIT). # Keep this centralized to avoid drifting role logic across modules. -CAN_EDIT_ALL_USER_ROLES = {"SU", "ADMIN", "SPEED"} +CAN_EDIT_ALL_USER_ROLES = {"SU", "ADMIN", "SPEED", "ASSET_OWNER"} # Permission constants used by list endpoints (e.g., /agent/list, /mcp/list). PERMISSION_READ = "READ_ONLY" PERMISSION_EDIT = "EDIT" PERMISSION_PRIVATE = "PRIVATE" +# Response flag when system prompts are withheld from non-ASSET_OWNER callers. +AGENT_PROMPTS_HIDDEN_FLAG = "prompts_hidden" + # Deployment Version Configuration DEPLOYMENT_VERSION = os.getenv("DEPLOYMENT_VERSION", "speed") @@ -197,8 +216,10 @@ class VectorDatabaseType(str, Enum): # Will be dynamically set based on PID if not provided WORKER_NAME = os.getenv("WORKER_NAME") WORKER_CONCURRENCY = int(os.getenv("WORKER_CONCURRENCY", "4")) -RAY_WARM_ACTOR_POOL_SIZE_PART = int(os.getenv("RAY_WARM_ACTOR_POOL_SIZE_PART", "2")) -RAY_WARM_ACTOR_POOL_SIZE_PROCESS = int(os.getenv("RAY_WARM_ACTOR_POOL_SIZE_PROCESS", "1")) +RAY_WARM_ACTOR_POOL_SIZE_PART = int( + os.getenv("RAY_WARM_ACTOR_POOL_SIZE_PART", "2")) +RAY_WARM_ACTOR_POOL_SIZE_PROCESS = int( + os.getenv("RAY_WARM_ACTOR_POOL_SIZE_PROCESS", "1")) # Global Ray actor pool (shared by process_q/process_part_q workers) RAY_GLOBAL_ACTOR_POOL_SIZE = int(os.getenv("RAY_GLOBAL_ACTOR_POOL_SIZE", "3")) RAY_ACTOR_WARM_TIMEOUT_S = float(os.getenv("RAY_ACTOR_WARM_TIMEOUT_S", "60")) @@ -208,9 +229,6 @@ class VectorDatabaseType(str, Enum): "RAY_GLOBAL_ACTOR_POOL_NAMESPACE", "nexent-data-process") - - - # Voice Service Configuration APPID = os.getenv("APPID", "") TOKEN = os.getenv("TOKEN", "") @@ -412,11 +430,13 @@ def _parse_otlp_headers(headers_str: str) -> dict: # Container Platform Configuration -IS_DEPLOYED_BY_KUBERNETES = os.getenv("IS_DEPLOYED_BY_KUBERNETES", "false").lower() == "true" +IS_DEPLOYED_BY_KUBERNETES = os.getenv( + "IS_DEPLOYED_BY_KUBERNETES", "false").lower() == "true" KUBERNETES_NAMESPACE = os.getenv("KUBERNETES_NAMESPACE", "nexent") # Northbound API public base URL (used for A2A agent cards and external file proxy links) -NORTHBOUND_EXTERNAL_URL = os.getenv("NORTHBOUND_EXTERNAL_URL", "http://localhost:5013/api").rstrip("/") +NORTHBOUND_EXTERNAL_URL = os.getenv( + "NORTHBOUND_EXTERNAL_URL", "http://localhost:5013/api").rstrip("/") # APP Version diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index 90de64ca9..f1273a417 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -22,7 +22,6 @@ def search_agent_info_by_agent_id(agent_id: int, tenant_id: str, version_no: int with get_db_session() as session: agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, - AgentInfo.tenant_id == tenant_id, AgentInfo.version_no == version_no, AgentInfo.delete_flag != 'Y' ).first() diff --git a/backend/database/agent_version_db.py b/backend/database/agent_version_db.py index aea8c06dc..7a12aa80d 100644 --- a/backend/database/agent_version_db.py +++ b/backend/database/agent_version_db.py @@ -28,7 +28,6 @@ def search_version_by_version_no( with get_db_session() as session: version = session.query(AgentVersion).filter( AgentVersion.agent_id == agent_id, - AgentVersion.tenant_id == tenant_id, AgentVersion.version_no == version_no, AgentVersion.delete_flag == 'N', ).first() @@ -96,7 +95,6 @@ def query_agent_snapshot( # Query agent info snapshot agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, - AgentInfo.tenant_id == tenant_id, AgentInfo.version_no == version_no, AgentInfo.delete_flag == 'N', ).first() @@ -104,7 +102,7 @@ def query_agent_snapshot( # Query tool instances snapshot tools = session.query(ToolInstance).filter( ToolInstance.agent_id == agent_id, - ToolInstance.tenant_id == tenant_id, + ToolInstance.tenant_id == agent.tenant_id, ToolInstance.version_no == version_no, ToolInstance.delete_flag == 'N', ).all() @@ -112,7 +110,7 @@ def query_agent_snapshot( # Query relations snapshot relations = session.query(AgentRelation).filter( AgentRelation.parent_agent_id == agent_id, - AgentRelation.tenant_id == tenant_id, + AgentRelation.tenant_id == agent.tenant_id, AgentRelation.version_no == version_no, AgentRelation.delete_flag == 'N', ).all() diff --git a/backend/database/invitation_db.py b/backend/database/invitation_db.py index f7e27d005..32523cd06 100644 --- a/backend/database/invitation_db.py +++ b/backend/database/invitation_db.py @@ -300,8 +300,8 @@ def query_invitations_with_pagination( TenantInvitationCode.delete_flag == "N" ) - # Apply tenant filter if provided - if tenant_id: + # Apply tenant filter when tenant_id is specified (including ASSET_OWNER virtual tenant) + if tenant_id is not None: query = query.filter(TenantInvitationCode.tenant_id == tenant_id) # Apply sorting diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index c90d707e1..a5dd09914 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -23,7 +23,6 @@ from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \ LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, PERMISSION_PRIVATE from consts.exceptions import MemoryPreparationException, SkillDuplicateError -from consts.exceptions import MemoryPreparationException from consts.agent_unavailable_reasons import AgentUnavailableReason from consts.model import ( AgentInfoRequest, @@ -38,6 +37,7 @@ ToolInstanceInfoRequest, ToolSourceEnum, ModelConnectStatusEnum ) +from services.asset_owner_visibility import resolve_agent_list_permission from database.agent_db import ( create_agent, delete_agent_by_id, @@ -385,7 +385,8 @@ def _regenerate_agent_value_with_llm( callback=None, tenant_id=tenant_id ) - candidate = (regenerated_value or "").strip().splitlines()[0].strip() + candidate = (regenerated_value or "").strip().splitlines()[ + 0].strip() if candidate in value_set: raise ValueError(f"Generated duplicate value '{candidate}'") return candidate @@ -446,7 +447,6 @@ def _regenerate_agent_name_with_llm( ) - def _regenerate_agent_display_name_with_llm( original_display_name: str, existing_display_names: list[str], @@ -490,7 +490,6 @@ def _regenerate_agent_display_name_with_llm( ) - async def check_agent_name_conflict_batch_impl( request: AgentNameBatchCheckRequest, authorization: str @@ -548,17 +547,21 @@ async def regenerate_agent_name_batch_impl( _, tenant_id, _ = get_current_user_info(authorization) agents_cache = query_all_agent_info_by_tenant_id(tenant_id) - existing_names = [agent.get("name") for agent in agents_cache if agent.get("name")] - existing_display_names = [agent.get("display_name") for agent in agents_cache if agent.get("display_name")] + existing_names = [agent.get("name") + for agent in agents_cache if agent.get("name")] + existing_display_names = [agent.get( + "display_name") for agent in agents_cache if agent.get("display_name")] # Always use tenant quick-config LLM model quick_config_model = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id ) - resolved_model_id = quick_config_model.get("model_id") if quick_config_model else None + resolved_model_id = quick_config_model.get( + "model_id") if quick_config_model else None if not resolved_model_id: - raise ValueError("No available model for regeneration. Please configure an LLM model first.") + raise ValueError( + "No available model for regeneration. Please configure an LLM model first.") results: list[dict] = [] # Use local mutable caches to avoid regenerated duplicates in the same batch @@ -588,7 +591,8 @@ async def regenerate_agent_name_batch_impl( exclude_agent_id=exclude_agent_id ) except Exception as e: - logger.error(f"Failed to regenerate agent name with LLM: {str(e)}, using fallback") + logger.error( + f"Failed to regenerate agent name with LLM: {str(e)}, using fallback") agent_name = _generate_unique_agent_name_with_suffix( agent_name, tenant_id=tenant_id, @@ -613,7 +617,8 @@ async def regenerate_agent_name_batch_impl( exclude_agent_id=exclude_agent_id ) except Exception as e: - logger.error(f"Failed to regenerate agent display_name with LLM: {str(e)}, using fallback") + logger.error( + f"Failed to regenerate agent display_name with LLM: {str(e)}, using fallback") agent_display_name = _generate_unique_display_name_with_suffix( agent_display_name, tenant_id=tenant_id, @@ -726,7 +731,8 @@ async def _add_memory_background(): # Create and store the background task to avoid warnings background_task = asyncio.create_task(_add_memory_background()) # Add done callback to handle any exceptions that might occur - background_task.add_done_callback(lambda t: t.exception() if t.exception() else None) + background_task.add_done_callback( + lambda t: t.exception() if t.exception() else None) except Exception as schedule_err: logger.error( f"Failed to schedule background memory addition: {schedule_err}") @@ -756,7 +762,9 @@ async def get_creating_sub_agent_id_service(tenant_id: str, user_id: str = None) async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0, user_id: Optional[str] = None): try: - agent_info = search_agent_info_by_agent_id(agent_id, tenant_id, version_no) + agent_info = search_agent_info_by_agent_id( + agent_id, tenant_id, version_no) + tenant_id = agent_info.get("tenant_id") except Exception as e: logger.error(f"Failed to get agent info: {str(e)}") raise ValueError(f"Failed to get agent info: {str(e)}") @@ -819,14 +827,17 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0 if agent_info["model_id"] is not None: model_info = get_model_by_model_id(agent_info["model_id"]) - agent_info["model_name"] = model_info.get("display_name", None) if model_info is not None else None + agent_info["model_name"] = model_info.get( + "display_name", None) if model_info is not None else None else: agent_info["model_name"] = None # Get business logic model display name from model_id if agent_info.get("business_logic_model_id") is not None: - business_logic_model_info = get_model_by_model_id(agent_info["business_logic_model_id"]) - agent_info["business_logic_model_name"] = business_logic_model_info.get("display_name", None) if business_logic_model_info is not None else None + business_logic_model_info = get_model_by_model_id( + agent_info["business_logic_model_id"]) + agent_info["business_logic_model_name"] = business_logic_model_info.get( + "display_name", None) if business_logic_model_info is not None else None elif "business_logic_model_name" not in agent_info: agent_info["business_logic_model_name"] = None @@ -836,7 +847,8 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0 agent_info["prompt_template_name"] = SYSTEM_PROMPT_TEMPLATE_NAME if agent_info.get("group_ids") is not None: - agent_info["group_ids"] = convert_string_to_list(agent_info.get("group_ids")) + agent_info["group_ids"] = convert_string_to_list( + agent_info.get("group_ids")) # Check agent availability is_available, unavailable_reasons = check_agent_availability( @@ -996,7 +1008,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = skill_info=SkillInstanceInfoRequest( skill_id=inst_skill_id, agent_id=agent_id, - skill_description=instance.get("skill_description"), + skill_description=instance.get( + "skill_description"), skill_content=instance.get("skill_content"), enabled=False, config_values=instance.get("config_values"), @@ -1013,7 +1026,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = if inst.get("skill_id") == skill_id), None ) - skill_description = (existing_instance or {}).get("skill_description") + skill_description = (existing_instance or {}).get( + "skill_description") skill_content = (existing_instance or {}).get("skill_content") skill_db.create_or_update_skill_by_skill_info( skill_info=SkillInstanceInfoRequest( @@ -1022,7 +1036,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = skill_description=skill_description, skill_content=skill_content, enabled=True, - config_values=(existing_instance or {}).get("config_values"), + config_values=(existing_instance or {} + ).get("config_values"), ), tenant_id=tenant_id, user_id=user_id @@ -1042,7 +1057,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = while len(search_list): left_ele = search_list.popleft() if left_ele == agent_id: - raise ValueError("Circular dependency detected: Agent cannot be related to itself or create circular calls") + raise ValueError( + "Circular dependency detected: Agent cannot be related to itself or create circular calls") if left_ele in agent_id_set: continue else: @@ -1077,7 +1093,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str = current_external_ids = { rel["external_agent_id"] for rel in current_relations } - new_external_ids = set(related_external_agent_ids) if related_external_agent_ids else set() + new_external_ids = set( + related_external_agent_ids) if related_external_agent_ids else set() # Find IDs to delete (in current but not in new) ids_to_delete = current_external_ids - new_external_ids @@ -1261,7 +1278,8 @@ async def export_agent_by_agent_id(agent_id: int, tenant_id: str, user_id: str) if name: skill_names.append(name) except Exception as e: - logger.warning(f"Failed to collect skill instances for agent {agent_id}: {e}") + logger.warning( + f"Failed to collect skill instances for agent {agent_id}: {e}") # Check if any tool is KnowledgeBaseSearchTool and set its metadata to empty dict for tool in tool_list: @@ -1273,14 +1291,17 @@ async def export_agent_by_agent_id(agent_id: int, tenant_id: str, user_id: str) model_display_name = None if model_id is not None: model_info = get_model_by_model_id(model_id) - model_display_name = model_info.get("display_name") if model_info is not None else None + model_display_name = model_info.get( + "display_name") if model_info is not None else None # Get business_logic_model_id and business logic model display name business_logic_model_id = agent_info.get("business_logic_model_id") business_logic_model_display_name = None if business_logic_model_id is not None: - business_logic_model_info = get_model_by_model_id(business_logic_model_id) - business_logic_model_display_name = business_logic_model_info.get("display_name") if business_logic_model_info is not None else None + business_logic_model_info = get_model_by_model_id( + business_logic_model_id) + business_logic_model_display_name = business_logic_model_info.get( + "display_name") if business_logic_model_info is not None else None agent_info = ExportAndImportAgentInfo(agent_id=agent_id, name=agent_info["name"], @@ -1304,7 +1325,8 @@ async def export_agent_by_agent_id(agent_id: int, tenant_id: str, user_id: str) business_logic_model_id=business_logic_model_id, business_logic_model_name=business_logic_model_display_name, skill_names=skill_names, - prompt_template_id=agent_info.get("prompt_template_id"), + prompt_template_id=agent_info.get( + "prompt_template_id"), prompt_template_name=agent_info.get("prompt_template_name")) return agent_info @@ -1466,7 +1488,8 @@ async def import_agent_by_agent_id( release_note="Initial version from Agent Market" ) except Exception as e: - logger.warning(f"Failed to auto-publish version v1 for agent {new_agent_id}: {str(e)}") + logger.warning( + f"Failed to auto-publish version v1 for agent {new_agent_id}: {str(e)}") return new_agent_id @@ -1495,12 +1518,11 @@ async def clear_agent_new_mark_impl(agent_id: int, tenant_id: str, user_id: str) user_id (str): User ID (for audit purposes) """ rowcount = clear_agent_new_mark(agent_id, tenant_id, user_id) - logger.info(f"clear_agent_new_mark_impl called for agent_id={agent_id}, tenant_id={tenant_id}, user_id={user_id}, affected_rows={rowcount}") + logger.info( + f"clear_agent_new_mark_impl called for agent_id={agent_id}, tenant_id={tenant_id}, user_id={user_id}, affected_rows={rowcount}") return rowcount - - async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: """ list all agent info @@ -1546,7 +1568,8 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: # Apply visibility filter for DEV/USER based on group overlap if not can_edit_all: - agent_group_ids = set(convert_string_to_list(agent.get("group_ids"))) + agent_group_ids = set( + convert_string_to_list(agent.get("group_ids"))) ingroup_permission = agent.get("ingroup_permission") is_creator = str(agent.get("created_by")) == str(user_id) # Hide agent if: no group overlap OR (ingroup_permission is PRIVATE AND user is not creator) @@ -1574,23 +1597,24 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]: simple_agent_list: list[dict] = [] for entry in enriched_agents: agent = entry["raw_agent"] - unavailable_reasons = list(dict.fromkeys(entry["unavailable_reasons"])) + unavailable_reasons = list( + dict.fromkeys(entry["unavailable_reasons"])) model_id = agent.get("model_id") model_info = None if model_id is not None: if model_id not in model_cache: - model_cache[model_id] = get_model_by_model_id(model_id, tenant_id) + model_cache[model_id] = get_model_by_model_id( + model_id, tenant_id) model_info = model_cache.get(model_id) - # Permission logic: - # - If creator or can_edit_all: PERMISSION_EDIT - # - Otherwise: use ingroup_permission, default to PERMISSION_READ if None - if can_edit_all or str(agent.get("created_by")) == str(user_id): - permission = PERMISSION_EDIT - else: - ingroup_permission = agent.get("ingroup_permission") - permission = ingroup_permission if ingroup_permission is not None else PERMISSION_READ + # Permission logic (ASSET_OWNER-scoped + non-ASSET_OWNER role => READ_ONLY first): + permission = resolve_agent_list_permission( + user_role=user_role, + agent=agent, + user_id=user_id, + can_edit_all=can_edit_all, + ) simple_agent_list.append({ "agent_id": agent["agent_id"], @@ -1653,7 +1677,8 @@ def _mark_duplicates(groups: dict[str, list[dict]], reason_key: str) -> None: duplicate_entry["unavailable_reasons"].append(reason_key) _mark_duplicates(name_groups, AgentUnavailableReason.DUPLICATE_NAME) - _mark_duplicates(display_name_groups, AgentUnavailableReason.DUPLICATE_DISPLAY_NAME) + _mark_duplicates(display_name_groups, + AgentUnavailableReason.DUPLICATE_DISPLAY_NAME) def _collect_model_availability_reasons(agent: dict, tenant_id: str, model_cache: Dict[int, Optional[dict]]) -> list[str]: @@ -1726,8 +1751,10 @@ def check_agent_availability( return False, [AgentUnavailableReason.AGENT_NOT_FOUND] # Check tool availability - tool_info = search_tools_for_sub_agent(agent_id=agent_id, tenant_id=tenant_id) - tool_id_list = [tool["tool_id"] for tool in tool_info if tool.get("tool_id") is not None] + tool_info = search_tools_for_sub_agent( + agent_id=agent_id, tenant_id=tenant_id) + tool_id_list = [tool["tool_id"] + for tool in tool_info if tool.get("tool_id") is not None] if tool_id_list: tool_statuses = check_tool_is_available(tool_id_list) if not all(tool_statuses): @@ -1808,7 +1835,8 @@ async def prepare_agent_run( ) # Mount conversation-level reusable ContextManager if enabled - cm_config = getattr(agent_run_info.agent_config, 'context_manager_config', None) + cm_config = getattr(agent_run_info.agent_config, + 'context_manager_config', None) if cm_config and cm_config.enabled: cm = agent_run_manager.get_or_create_context_manager( conversation_id=str(agent_request.conversation_id), @@ -2009,8 +2037,10 @@ async def run_agent_stream( is_debug=agent_request.is_debug, language=language, memory_enabled=memory_enabled, - history_count=len(agent_request.history) if agent_request.history else 0, - minio_files_count=len(agent_request.minio_files) if agent_request.minio_files else 0, + history_count=len( + agent_request.history) if agent_request.history else 0, + minio_files_count=len( + agent_request.minio_files) if agent_request.minio_files else 0, extra_metadata={ "agent_share_option": getattr( memory_ctx_preview.user_config, @@ -2278,7 +2308,8 @@ async def export_agent_with_skills_impl(agent_id: int, authorization: str) -> di agent_json_str = await export_agent_impl(agent_id, authorization) skill_service = SkillService(tenant_id=tenant_id) - skill_zip_entries = skill_service.export_skills_by_names(skill_names, tenant_id) + skill_zip_entries = skill_service.export_skills_by_names( + skill_names, tenant_id) zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: @@ -2290,8 +2321,10 @@ async def export_agent_with_skills_impl(agent_id: int, authorization: str) -> di zip_buffer.seek(0) zip_data = zip_buffer.read() - agent_info = search_agent_info_by_agent_id(agent_id=agent_id, tenant_id=tenant_id) - agent_name = agent_info.get("name", "anonymous") if agent_info else "anonymous" + agent_info = search_agent_info_by_agent_id( + agent_id=agent_id, tenant_id=tenant_id) + agent_name = agent_info.get( + "name", "anonymous") if agent_info else "anonymous" filename = f"{agent_name}.zip" @@ -2322,7 +2355,8 @@ async def import_agent_with_skills_impl( user_id, tenant_id, _ = get_current_user_info(authorization) - skill_name_to_zip_base64 = {entry.skill_name: entry.skill_zip_base64 for entry in skills} + skill_name_to_zip_base64 = { + entry.skill_name: entry.skill_zip_base64 for entry in skills} existing_skills = skill_db.list_skills(tenant_id) existing_skill_names = {s.get("name") for s in existing_skills} diff --git a/backend/services/asset_owner_visibility.py b/backend/services/asset_owner_visibility.py new file mode 100644 index 000000000..5ccc90446 --- /dev/null +++ b/backend/services/asset_owner_visibility.py @@ -0,0 +1,202 @@ +"""ASSET_OWNER tenant visibility filters, feature flags, and response post-processing.""" + +import hashlib +import re +from typing import Any, Dict, List, Optional + +from sqlalchemy import or_ +from sqlalchemy.orm import Query + +from consts.const import ( + AGENT_PROMPTS_HIDDEN_FLAG, + ASSET_OWNER_ATTACHMENTS_PREFIX, + ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, + ENABLE_ASSET_OWNER_ROLE, + PERMISSION_EDIT, + PERMISSION_READ, +) +from consts.exceptions import ValidationError + + +_PROMPT_FIELDS = ("duty_prompt", "constraint_prompt", "few_shots_prompt") + + +_PREVIEW_CACHE_PREFIXES = ("preview/converted/", "preview/converting/") +_PREVIEW_HASH_PATTERN = re.compile(r"^(.+)_[0-9a-f]{8}(\.pdf|\.pdf\.tmp)?$") +_OFFICE_EXTENSIONS = (".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls") + +ASSET_OWNER_RESOURCES_ROUTE = "/asset-owner-resources" + + +def is_asset_owner_enabled() -> bool: + """Return whether the ASSET_OWNER feature flag is enabled.""" + return ENABLE_ASSET_OWNER_ROLE + + +def require_asset_owner_enabled() -> None: + """Raise ValidationError when the ASSET_OWNER feature is disabled.""" + if not ENABLE_ASSET_OWNER_ROLE: + raise ValidationError("ASSET_OWNER feature is not enabled") + + +def filter_accessible_routes_for_asset_owner_feature( + accessible_routes: List[str], +) -> List[str]: + """Remove asset-owner nav route when the ASSET_OWNER feature flag is disabled.""" + if ENABLE_ASSET_OWNER_ROLE: + return accessible_routes + return [r for r in accessible_routes if r != ASSET_OWNER_RESOURCES_ROUTE] + + +def _parse_preview_cache_object_name(preview_object_name: str) -> Optional[str]: + """ + Recover the source object_name from a preview cache key, if possible. + + Cache layout matches resolve_preview_file: + preview/converted/{source_without_ext}_{md5(source)[:8]}.pdf + """ + for prefix in _PREVIEW_CACHE_PREFIXES: + if not preview_object_name.startswith(prefix): + continue + remainder = preview_object_name[len(prefix):] + match = _PREVIEW_HASH_PATTERN.match(remainder) + if not match: + return None + source_without_ext = match.group(1) + hash_suffix = remainder.rsplit("_", 1)[-1][:8] + for ext in _OFFICE_EXTENSIONS: + candidate = f"{source_without_ext}{ext}" + expected_hash = hashlib.md5(candidate.encode()).hexdigest()[:8] + if expected_hash == hash_suffix: + return candidate + return None + return None + + +def _is_legacy_root_attachment(object_name: str) -> bool: + """True for attachments/filename (no user_id subdirectory).""" + if not object_name.startswith("attachments/"): + return False + return "/" not in object_name.replace("attachments/", "", 1) + + +def can_access_file( + object_name: str, + caller_user_id: Optional[str], + caller_tenant_id: Optional[str] = None, +) -> bool: + """ + Return True when the caller may read a MinIO object. + + Rules (in order): + - No caller_user_id -> False + - preview cache paths -> delegate to source object access + - attachments/asset_owner/{user_id}/* -> ASSET_OWNER tenant and matching user_id + - knowledge_base/* -> all authenticated users + - attachments/{caller_user_id}/* -> owner only + - legacy attachments/filename -> all authenticated users (backward compatible) + - otherwise -> False + """ + if not caller_user_id: + return False + + if any(object_name.startswith(prefix) for prefix in _PREVIEW_CACHE_PREFIXES): + source = _parse_preview_cache_object_name(object_name) + if source is None: + return False + return can_access_file(source, caller_user_id, caller_tenant_id) + + asset_owner_prefix = f"{ASSET_OWNER_ATTACHMENTS_PREFIX}/" + if object_name.startswith(asset_owner_prefix): + if caller_tenant_id != ASSET_OWNER_TENANT_ID: + return False + remainder = object_name[len(asset_owner_prefix):] + path_user_id = remainder.split("/", 1)[0] if remainder else "" + return path_user_id == str(caller_user_id) + + if object_name.startswith("knowledge_base/"): + return True + + if object_name.startswith(f"attachments/{caller_user_id}/"): + return True + + if _is_legacy_root_attachment(object_name): + return True + + return False + + +def can_view_skill(caller_tenant_id: Optional[str], skill_tenant_id: Optional[str]) -> bool: + """ + Return True when the caller may view a skill and its files. + + ASSET_OWNER-scoped skills (tenant_id asset_owner_tenant_id or legacy "") are + visible only to callers in the ASSET_OWNER virtual tenant. + """ + + if skill_tenant_id == ASSET_OWNER_TENANT_ID: + return caller_tenant_id == ASSET_OWNER_TENANT_ID + return True + + +def resolve_agent_list_permission( + user_role: str, + agent: Dict[str, Any], + user_id: str, + can_edit_all: bool, +) -> str: + """ + Resolve list-item permission for an agent. + + Highest priority: ASSET_OWNER-scoped agents are READ_ONLY for callers whose + user_role is not ASSET_OWNER (overrides can_edit_all, creator, ingroup_permission). + """ + role = (user_role or "").upper() + if agent.get("tenant_id") == ASSET_OWNER_TENANT_ID and role != ASSET_OWNER_ROLE: + return PERMISSION_READ + if can_edit_all or str(agent.get("created_by")) == str(user_id): + return PERMISSION_EDIT + ingroup_permission = agent.get("ingroup_permission") + return ingroup_permission if ingroup_permission is not None else PERMISSION_READ + + +def apply_agent_detail_prompt_visibility( + caller_tenant_id: Optional[str], + agent_info: Dict[str, Any], +) -> Dict[str, Any]: + """ + Mask system prompt fields when a non-ASSET_OWNER caller views an ASSET_OWNER-scoped agent. + + Sets duty_prompt, constraint_prompt, and few_shots_prompt to None and adds + prompts_hidden=True so clients can render a permission-denied state. + """ + result = dict(agent_info) + if caller_tenant_id == ASSET_OWNER_TENANT_ID: + return result + if result.get("tenant_id") != ASSET_OWNER_TENANT_ID: + return result + for field in _PROMPT_FIELDS: + result[field] = None + result[AGENT_PROMPTS_HIDDEN_FLAG] = True + return result + + +def postprocess_agent_visibility( + items: List[Dict[str, Any]], + caller_role: Optional[str], + caller_tenant_id: Optional[str], +) -> List[Dict[str, Any]]: + """Return agent records after visibility post-processing (no-op for now).""" + _ = (caller_role, caller_tenant_id) + return items + + +def postprocess_knowledge_visibility( + items: List[Dict[str, Any]], + caller_role: Optional[str], + caller_tenant_id: Optional[str], +) -> List[Dict[str, Any]]: + """Return knowledge records after visibility post-processing (no-op for now).""" + _ = (caller_role, caller_tenant_id) + return items diff --git a/backend/services/file_management_service.py b/backend/services/file_management_service.py index 7dad75a0a..8f074a413 100644 --- a/backend/services/file_management_service.py +++ b/backend/services/file_management_service.py @@ -10,6 +10,8 @@ from fastapi import UploadFile from consts.const import ( + ASSET_OWNER_ATTACHMENTS_PREFIX, + ASSET_OWNER_TENANT_ID, DATA_PROCESS_SERVICE, FILE_PREVIEW_SIZE_LIMIT, MAX_CONCURRENT_UPLOADS, @@ -17,6 +19,7 @@ OFFICE_MIME_TYPES, UPLOAD_FOLDER, ) +from services.asset_owner_visibility import can_access_file from consts.exceptions import FileTooLargeException, NotFoundException, OfficeConversionException, UnsupportedFileTypeException from database.attachment_db import ( copy_file, @@ -51,59 +54,82 @@ logger = logging.getLogger("file_management_service") -def check_file_access(object_name: str, user_id: Optional[str]) -> bool: - """ - Check if user has permission to access the file. +def resolve_minio_upload_folder( + folder: Optional[str], + user_id: Optional[str] = None, + uploader_tenant_id: Optional[str] = None, +) -> str: + """Map caller context to the MinIO object prefix used for uploads. + + Resolution order (first match wins): + 1. Asset-owner tenant → ``attachments/asset_owner/{user_id}`` + 2. ``folder == "knowledge_base"`` → shared ``knowledge_base`` prefix + 3. Otherwise → per-user ``attachments/{user_id}`` when ``user_id`` is set + 4. Legacy fallback → ``folder`` if provided, else ``attachments`` - Access rules: - - knowledge_base/*: All authenticated users can access - - attachments/{user_id}/*: Only the owner (user_id) can access - - preview/*: Accessible if the original file is accessible + Access control for reads is enforced separately; this function only + chooses the storage prefix. Args: - object_name: File object name in storage - user_id: Current user ID + folder: Requested folder hint (e.g. ``"knowledge_base"`` or a legacy path). + user_id: Uploader user ID; required for user-scoped attachment paths. + uploader_tenant_id: Uploader tenant ID; asset-owner tenants use a dedicated prefix. Returns: - True if access is allowed, False otherwise + Resolved MinIO folder prefix (no leading or trailing slash). """ - if not user_id: - return False + if uploader_tenant_id == ASSET_OWNER_TENANT_ID: + return f"{ASSET_OWNER_ATTACHMENTS_PREFIX}/{user_id}" + + if folder == "knowledge_base": + return "knowledge_base" + + if user_id: + return f"attachments/{user_id}" - if object_name.startswith("knowledge_base/"): - # Knowledge base files: all authenticated users can access - return True + return folder or "attachments" - # Check if file is in user's attachments folder - # Pattern: attachments/{user_id}/* - if object_name.startswith(f"attachments/{user_id}/"): - return True - # For backward compatibility, allow access to files in root attachments folder - # Pattern: attachments/{filename} (no user_id subfolder) - if object_name.startswith("attachments/") and "/" not in object_name.replace("attachments/", "", 1): - # Old format: attachments/filename (no subdirectory) - # Allow access for backward compatibility - return True +def check_file_access( + object_name: str, + user_id: Optional[str], + caller_tenant_id: Optional[str] = None, +) -> bool: + """ + Check if user has permission to access the file. - return False + Access rules are implemented in asset_owner_visibility.can_access_file. + """ + return can_access_file(object_name, user_id, caller_tenant_id) -def check_file_access_batch(object_names: List[str], user_id: Optional[str]) -> Dict[str, bool]: +def check_file_access_batch( + object_names: List[str], + user_id: Optional[str], + caller_tenant_id: Optional[str] = None, +) -> Dict[str, bool]: """ Batch check file access permissions. Args: object_names: List of file object names user_id: Current user ID + caller_tenant_id: Caller's tenant ID for ASSET_OWNER path checks Returns: Dict mapping object_name to access permission (True/False) """ - return {obj_name: check_file_access(obj_name, user_id) for obj_name in object_names} + return { + obj_name: check_file_access(obj_name, user_id, caller_tenant_id) + for obj_name in object_names + } -def validate_s3_url_access(object_name: str, user_id: Optional[str]) -> None: +def validate_s3_url_access( + object_name: str, + user_id: Optional[str], + caller_tenant_id: Optional[str] = None, +) -> None: """ Validate if user has permission to access the S3 URL. @@ -117,12 +143,16 @@ def validate_s3_url_access(object_name: str, user_id: Optional[str]) -> None: if not user_id: raise PermissionError("User authentication required to access files") - if not check_file_access(object_name, user_id): + if not check_file_access(object_name, user_id, caller_tenant_id): logger.warning(f"[validate_s3_url_access] Access denied: object_name={object_name}, user_id={user_id}") raise PermissionError(f"Access denied: You don't have permission to access this file ({object_name})") -def validate_urls_access(urls: List[str], user_id: Optional[str]) -> None: +def validate_urls_access( + urls: List[str], + user_id: Optional[str], + caller_tenant_id: Optional[str] = None, +) -> None: """ Validate if user has permission to access the given URLs. @@ -147,7 +177,7 @@ def validate_urls_access(urls: List[str], user_id: Optional[str]) -> None: if url.startswith("s3://"): try: _, object_name = parse_s3_url(url) - validate_s3_url_access(object_name, user_id) + validate_s3_url_access(object_name, user_id, caller_tenant_id) except ValueError as e: logger.warning(f"[validate_urls_access] Failed to parse S3 URL: {url}, error: {e}") raise PermissionError(f"Invalid S3 URL format: {url}") @@ -156,10 +186,17 @@ def validate_urls_access(urls: List[str], user_id: Optional[str]) -> None: parts = url.strip("/").split("/", 1) if len(parts) == 2: bucket, object_name = parts - validate_s3_url_access(object_name, user_id) + validate_s3_url_access(object_name, user_id, caller_tenant_id) -async def upload_files_impl(destination: str, file: List[UploadFile], folder: str = None, index_name: Optional[str] = None, user_id: Optional[str] = None) -> tuple: +async def upload_files_impl( + destination: str, + file: List[UploadFile], + folder: str = None, + index_name: Optional[str] = None, + user_id: Optional[str] = None, + uploader_tenant_id: Optional[str] = None, +) -> tuple: """ Upload files to local storage or MinIO based on destination. @@ -169,6 +206,7 @@ async def upload_files_impl(destination: str, file: List[UploadFile], folder: st folder: Folder name for MinIO uploads index_name: Knowledge base index for conflict resolution user_id: User ID for attachment path isolation + uploader_tenant_id: Uploader tenant ID (ASSET_OWNER uses dedicated prefix) Returns: tuple: (errors, uploaded_file_paths, uploaded_filenames) @@ -195,19 +233,7 @@ async def upload_files_impl(destination: str, file: List[UploadFile], folder: st errors.append(f"Failed to save file: {f.filename}") elif destination == "minio": - # Determine actual folder path based on file type - # knowledge_base: accessible by all authenticated users - # other folders (attachments): user-isolated path (attachments/{user_id}/...) - if folder == "knowledge_base": - actual_folder = "knowledge_base" - else: - # User isolation for personal attachments - if user_id: - actual_folder = f"attachments/{user_id}" - else: - # Fallback to old behavior if no user_id provided - actual_folder = folder or "attachments" - + actual_folder = resolve_minio_upload_folder(folder, user_id, uploader_tenant_id) minio_results = await upload_to_minio(files=file, folder=actual_folder) for result in minio_results: if result.get("success"): @@ -261,18 +287,25 @@ def make_unique_names(original_names: List[str], taken_lower: set) -> List[str]: return errors, uploaded_file_paths, uploaded_filenames -async def upload_to_minio(files: List[UploadFile], folder: str, user_id: Optional[str] = None) -> List[dict]: +async def upload_to_minio( + files: List[UploadFile], + folder: str, + user_id: Optional[str] = None, + uploader_tenant_id: Optional[str] = None, +) -> List[dict]: """ Helper function to upload files to MinIO and return results. Args: files: List of files to upload - folder: Storage folder path (will be prefixed with user_id if user_id is provided for attachments) - user_id: User ID for attachment path isolation + folder: Storage folder path or resolved MinIO prefix + user_id: User ID for attachment path isolation when folder is generic + uploader_tenant_id: Uploader tenant ID for ASSET_OWNER attachment prefix Returns: List of upload results """ + actual_folder = resolve_minio_upload_folder(folder, user_id, uploader_tenant_id) results = [] for f in files: try: @@ -282,17 +315,6 @@ async def upload_to_minio(files: List[UploadFile], folder: str, user_id: Optiona # Convert file content to BytesIO object file_obj = BytesIO(file_content) - # Determine actual folder path - # knowledge_base: no user isolation - # other folders: append user_id to path for isolation - if folder == "knowledge_base": - actual_folder = "knowledge_base" - else: - if user_id: - actual_folder = f"attachments/{user_id}" - else: - actual_folder = folder or "attachments" - # Upload file result = upload_fileobj( file_obj=file_obj, diff --git a/backend/services/invitation_service.py b/backend/services/invitation_service.py index 58a45d369..316df484c 100644 --- a/backend/services/invitation_service.py +++ b/backend/services/invitation_service.py @@ -19,8 +19,15 @@ ) from database.user_tenant_db import get_user_tenant_by_user_id from database.group_db import query_group_ids_by_user +from database.role_permission_db import check_role_permission +from consts.const import ( + ASSET_OWNER_TENANT_ID, + ASSET_OWNER_INVITE_CODE_TYPE, + ENABLE_ASSET_OWNER_ROLE, +) from consts.exceptions import NotFoundException, UnauthorizedError, DuplicateError from services.group_service import get_tenant_default_group_id +from services.asset_owner_visibility import require_asset_owner_enabled from utils.str_utils import convert_string_to_list logger = logging.getLogger(__name__) @@ -41,7 +48,7 @@ def create_invitation_code( Args: tenant_id (str): Tenant ID - code_type (str): Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE) + code_type (str): Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE, ASSET_OWNER_INVITE) invitation_code (Optional[str]): Invitation code, auto-generated if None group_ids (Optional[List[int]]): Associated group IDs capacity (int): Invitation code capacity @@ -58,9 +65,21 @@ def create_invitation_code( ValueError: When code_type is invalid """ # Validate code_type - valid_code_types = ["ADMIN_INVITE", "DEV_INVITE", "USER_INVITE"] + valid_code_types = [ + "ADMIN_INVITE", + "DEV_INVITE", + "USER_INVITE", + ASSET_OWNER_INVITE_CODE_TYPE, + ] + if ENABLE_ASSET_OWNER_ROLE: + valid_code_types.append(ASSET_OWNER_INVITE_CODE_TYPE) if code_type not in valid_code_types: - raise ValueError(f"Invalid code_type: {code_type}. Must be one of {valid_code_types}") + raise ValueError( + f"Invalid code_type: {code_type}. Must be one of {valid_code_types}") + + if code_type == ASSET_OWNER_INVITE_CODE_TYPE and not ENABLE_ASSET_OWNER_ROLE: + raise UnauthorizedError( + "ASSET_OWNER feature is not enabled") # Get user information user_info = get_user_tenant_by_user_id(user_id) @@ -70,10 +89,16 @@ def create_invitation_code( user_role = user_info.get("user_role", "USER") # Check permission based on code_type - if code_type == "ADMIN_INVITE" and user_role not in ["SU"]: - raise UnauthorizedError(f"User role {user_role} not authorized to create ADMIN_INVITE codes") + if code_type in ["ADMIN_INVITE", ASSET_OWNER_INVITE_CODE_TYPE] and user_role not in ["SU"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to create ADMIN_INVITE codes") elif code_type in ["DEV_INVITE", "USER_INVITE"] and user_role not in ["SU", "ADMIN"]: - raise UnauthorizedError(f"User role {user_role} not authorized to create {code_type} codes") + raise UnauthorizedError( + f"User role {user_role} not authorized to create {code_type} codes") + + if code_type == ASSET_OWNER_INVITE_CODE_TYPE: + tenant_id = ASSET_OWNER_TENANT_ID + group_ids = [] # Set default group_ids based on code_type if not provided if group_ids is None: @@ -95,7 +120,8 @@ def create_invitation_code( # Check if invitation code already exists if query_invitation_by_code(invitation_code): - raise DuplicateError(f"Invitation code '{invitation_code}' already exists") + raise DuplicateError( + f"Invitation code '{invitation_code}' already exists") # Create invitation (status will be set automatically) invitation_id = add_invitation( @@ -112,11 +138,13 @@ def create_invitation_code( # Automatically update status based on expiry date and capacity update_invitation_code_status(invitation_id) - logger.info(f"Created invitation code {invitation_code} (type: {code_type}) for tenant {tenant_id} by user {user_id}") + logger.info( + f"Created invitation code {invitation_code} (type: {code_type}) for tenant {tenant_id} by user {user_id}") # Get the final invitation info with correct status invitation_info = query_invitation_by_id(invitation_id) - normalized_info = _normalize_invitation_data(invitation_info) if invitation_info else None + normalized_info = _normalize_invitation_data( + invitation_info) if invitation_info else None return { "invitation_id": invitation_id, @@ -154,8 +182,18 @@ def update_invitation_code( raise UnauthorizedError(f"User {user_id} not found") user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: - raise UnauthorizedError(f"User role {user_role} not authorized to update invitation codes") + + invitation_info = query_invitation_by_id(invitation_id) + if not invitation_info: + raise NotFoundException(f"Invitation {invitation_id} not found") + + code_type = invitation_info.get("code_type") + if code_type == ASSET_OWNER_INVITE_CODE_TYPE and user_role not in ["SU"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to update invitation codes") + elif user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to update invitation codes") # Update invitation code success = modify_invitation( @@ -165,7 +203,8 @@ def update_invitation_code( ) if success: - logger.info(f"Updated invitation code {invitation_id} by user {user_id}") + logger.info( + f"Updated invitation code {invitation_id} by user {user_id}") # Automatically update status after successful update update_invitation_code_status(invitation_id) @@ -193,15 +232,19 @@ def delete_invitation_code(invitation_id: int, user_id: str) -> bool: raise UnauthorizedError(f"User {user_id} not found") user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: - raise UnauthorizedError( - f"User role {user_role} not authorized to delete invitation codes") - # Check if invitation exists invitation_info = query_invitation_by_id(invitation_id) if not invitation_info: raise NotFoundException(f"Invitation {invitation_id} not found") + code_type = invitation_info.get("code_type") + if code_type == ASSET_OWNER_INVITE_CODE_TYPE and user_role not in ["SU"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to update invitation codes") + elif user_role not in ["SU", "ADMIN"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to update invitation codes") + # Delete invitation code success = remove_invitation( invitation_id=invitation_id, updated_by=user_id) @@ -306,7 +349,8 @@ def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any] if current_time.date() > expiry_datetime.date(): new_status = "EXPIRE" except (ValueError, AttributeError, TypeError): - logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") + logger.warning( + f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") # Check capacity if usage_count >= capacity: @@ -346,7 +390,7 @@ def use_invitation_code( ) -> Dict[str, Any]: """ Use an invitation code by creating a usage record. - + Args: invitation_code (str): Invitation code to use user_id (str): User ID using the code @@ -359,7 +403,8 @@ def use_invitation_code( """ # Check if invitation is available if not check_invitation_available(invitation_code): - raise NotFoundException(f"Invitation code {invitation_code} is not available") + raise NotFoundException( + f"Invitation code {invitation_code} is not available") # Get invitation code details invitation_info = query_invitation_by_code(invitation_code) @@ -426,7 +471,8 @@ def update_invitation_code_status(invitation_id: int) -> bool: if current_time.date() > expiry_datetime.date(): new_status = "EXPIRE" except (ValueError, AttributeError, TypeError): - logger.warning(f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") + logger.warning( + f"Invalid expiry_date format for invitation {invitation_id}: {expiry_date}") # Check capacity if not expired if new_status == "IN_USE" and usage_count >= capacity: @@ -439,7 +485,8 @@ def update_invitation_code_status(invitation_id: int) -> bool: updates={"status": new_status}, updated_by="system" ) - logger.info(f"Updated invitation code {invitation_id} status to {new_status}") + logger.info( + f"Updated invitation code {invitation_id} status to {new_status}") return True return False @@ -460,7 +507,8 @@ def _generate_unique_invitation_code(length: int = 6) -> str: while attempts < max_attempts: # Generate random code with letters and digits - code = ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + code = ''.join(random.choices( + string.ascii_letters + string.digits, k=length)) # Check uniqueness if not query_invitation_by_code(code): @@ -468,7 +516,8 @@ def _generate_unique_invitation_code(length: int = 6) -> str: attempts += 1 - raise RuntimeError(f"Failed to generate unique invitation code after {max_attempts} attempts") + raise RuntimeError( + f"Failed to generate unique invitation code after {max_attempts} attempts") def get_invitations_list( @@ -506,9 +555,13 @@ def get_invitations_list( # Permission logic: # - If tenant_id is provided: ADMIN or SU can view that tenant's invitations # - If tenant_id is not provided: Only SU can view all invitations - if tenant_id: - # If tenant_id is specified, user must be ADMIN/SU - if user_role not in ["SU", "ADMIN"]: + if tenant_id is not None: + # ASSET_OWNER_TENANT_ID virtual tenant_id is used for asset-owner invites (SU only) + if tenant_id == ASSET_OWNER_TENANT_ID: + if user_role not in ["SU"]: + raise UnauthorizedError( + f"User role {user_role} not authorized to view asset owner invitations") + elif user_role not in ["SU", "ADMIN"]: raise UnauthorizedError( f"User role {user_role} not authorized to view invitation lists") else: @@ -531,6 +584,7 @@ def get_invitations_list( # Normalize each invitation item in the list if result and "items" in result: - result["items"] = [_normalize_invitation_data(item) for item in result["items"]] + result["items"] = [_normalize_invitation_data( + item) for item in result["items"]] return result diff --git a/backend/services/tenant_service.py b/backend/services/tenant_service.py index efc46b5da..6ed96a849 100644 --- a/backend/services/tenant_service.py +++ b/backend/services/tenant_service.py @@ -26,7 +26,7 @@ from database.remote_mcp_db import get_mcp_records_by_tenant, delete_mcp_record_by_name_and_url from database.invitation_db import query_invitations_by_tenant, remove_invitation from database.tool_db import delete_tools_by_agent_id -from consts.const import TENANT_NAME, TENANT_ID, DEFAULT_GROUP_ID, CONTAINER_SKILLS_PATH +from consts.const import ASSET_OWNER_TENANT_ID, TENANT_NAME, TENANT_ID, DEFAULT_GROUP_ID, CONTAINER_SKILLS_PATH from consts.exceptions import NotFoundException, ValidationError, UserRegistrationException from services.skill_service import install_skills_from_zip_for_tenant @@ -51,7 +51,8 @@ def get_tenant_info(tenant_id: str) -> Dict[str, Any]: # Get tenant name name_config = get_single_config_info(tenant_id, TENANT_NAME) if not name_config: - logger.warning(f"The name of tenant {tenant_id} not found, creating default config.") + logger.warning( + f"The name of tenant {tenant_id} not found, creating default config.") # Auto-create TENANT_NAME config with default name _ensure_tenant_name_config(tenant_id) # Re-fetch after creation @@ -96,7 +97,8 @@ def _ensure_tenant_name_config(tenant_id: str) -> bool: if success: logger.info(f"Auto-created TENANT_NAME config for tenant {tenant_id}") else: - logger.error(f"Failed to auto-create TENANT_NAME config for tenant {tenant_id}") + logger.error( + f"Failed to auto-create TENANT_NAME config for tenant {tenant_id}") return success @@ -137,8 +139,11 @@ def get_tenants_paginated(page: int = 1, page_size: int = 20) -> Dict[str, Any]: Returns: Dict[str, Any]: Dictionary containing paginated tenant data and pagination info """ - # Get all tenant IDs first - all_tenant_ids = get_all_tenant_ids() + # Exclude virtual ASSET_OWNER tenant from admin tenant listings + all_tenant_ids = [ + tid for tid in get_all_tenant_ids() + if tid != ASSET_OWNER_TENANT_ID + ] total = len(all_tenant_ids) # Calculate pagination @@ -155,7 +160,8 @@ def get_tenants_paginated(page: int = 1, page_size: int = 20) -> Dict[str, Any]: tenant_info = get_tenant_info(tenant_id) tenants.append(tenant_info) except NotFoundException: - logging.warning(f"Tenant info of {tenant_id} not found. Returning basic tenant structure.") + logging.warning( + f"Tenant info of {tenant_id} not found. Returning basic tenant structure.") tenant_info = { "tenant_id": tenant_id, "tenant_name": "", @@ -201,11 +207,13 @@ def create_tenant( # Check if tenant name already exists if check_tenant_name_exists(tenant_name.strip()): - raise ValidationError(f"Tenant with name '{tenant_name.strip()}' already exists") + raise ValidationError( + f"Tenant with name '{tenant_name.strip()}' already exists") try: # Create default group first - default_group_id = _create_default_group_for_tenant(tenant_id, created_by) + default_group_id = _create_default_group_for_tenant( + tenant_id, created_by) # Create tenant ID configuration tenant_id_data = { @@ -241,7 +249,8 @@ def create_tenant( } group_success = insert_config(group_config_data) if not group_success: - raise ValidationError("Failed to create tenant default group configuration") + raise ValidationError( + "Failed to create tenant default group configuration") # Install requested skills for the new tenant # Prefer skill_names (ZIP-based installation) over skill_ids (legacy record-copy) @@ -255,7 +264,8 @@ def create_tenant( locale=locale ) except Exception as e: - logger.warning(f"Failed to install skills from ZIP for tenant {tenant_id}: {e}") + logger.warning( + f"Failed to install skills from ZIP for tenant {tenant_id}: {e}") elif skill_ids: try: from services.skill_service import install_skills_for_tenant as install_by_ids @@ -269,7 +279,8 @@ def create_tenant( f"for tenant {tenant_id}" ) except Exception as e: - logger.warning(f"Failed to install skills by IDs for tenant {tenant_id}: {e}") + logger.warning( + f"Failed to install skills by IDs for tenant {tenant_id}: {e}") tenant_info = { "tenant_id": tenant_id, @@ -278,7 +289,8 @@ def create_tenant( "installed_skill_names": installed_skill_names, } - logger.info(f"Created tenant {tenant_id} with name '{tenant_name}' and default group {default_group_id}") + logger.info( + f"Created tenant {tenant_id} with name '{tenant_name}' and default group {default_group_id}") return tenant_info except Exception as e: @@ -309,13 +321,15 @@ def update_tenant_info(tenant_id: str, tenant_name: str, updated_by: Optional[st # Check if tenant name already exists (exclude current tenant) if check_tenant_name_exists(tenant_name.strip(), exclude_tenant_id=tenant_id): - raise ValidationError(f"Tenant with name '{tenant_name.strip()}' already exists") + raise ValidationError( + f"Tenant with name '{tenant_name.strip()}' already exists") # Check if tenant name config exists name_config = get_single_config_info(tenant_id, TENANT_NAME) if not name_config: # Tenant config doesn't exist, create it with the provided name - logger.info(f"TENANT_NAME config not found for {tenant_id}, creating new config.") + logger.info( + f"TENANT_NAME config not found for {tenant_id}, creating new config.") tenant_name_data = { "tenant_id": tenant_id, "config_key": TENANT_NAME, @@ -358,10 +372,13 @@ async def _delete_skills_for_tenant(tenant_id: str, actor: str) -> None: # 1. Soft-delete all skill instances for the tenant (regardless of skill source) try: - deleted_count = skill_db.delete_skill_instances_by_tenant(tenant_id, actor) - logger.info(f"Soft-deleted {deleted_count} skill instances for tenant {tenant_id}") + deleted_count = skill_db.delete_skill_instances_by_tenant( + tenant_id, actor) + logger.info( + f"Soft-deleted {deleted_count} skill instances for tenant {tenant_id}") except Exception as e: - logger.warning(f"Failed to soft-delete skill instances for tenant {tenant_id}: {str(e)}") + logger.warning( + f"Failed to soft-delete skill instances for tenant {tenant_id}: {str(e)}") # 2. Soft-delete all skills for the tenant skills = skill_db.list_skills(tenant_id) @@ -370,9 +387,11 @@ async def _delete_skills_for_tenant(tenant_id: str, actor: str) -> None: skill_name = skill.get("name") if skill_name: skill_db.delete_skill(skill_name, tenant_id, actor) - logger.info(f"Soft-deleted skill '{skill_name}' for tenant {tenant_id}") + logger.info( + f"Soft-deleted skill '{skill_name}' for tenant {tenant_id}") except Exception as e: - logger.warning(f"Failed to soft-delete skill {skill.get('name')}: {str(e)}") + logger.warning( + f"Failed to soft-delete skill {skill.get('name')}: {str(e)}") # 3. Delete the tenant's local skill directory and all its contents if CONTAINER_SKILLS_PATH: @@ -380,9 +399,11 @@ async def _delete_skills_for_tenant(tenant_id: str, actor: str) -> None: if os.path.exists(tenant_skill_root): try: shutil.rmtree(tenant_skill_root) - logger.info(f"Deleted tenant skill root directory: {tenant_skill_root}") + logger.info( + f"Deleted tenant skill root directory: {tenant_skill_root}") except Exception as e: - logger.warning(f"Failed to delete tenant skill root directory {tenant_skill_root}: {str(e)}") + logger.warning( + f"Failed to delete tenant skill root directory {tenant_skill_root}: {str(e)}") async def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> bool: @@ -416,12 +437,14 @@ async def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> boo if not name_config: raise NotFoundException(f"Tenant {tenant_id} does not exist") - logger.info(f"Starting cascade deletion for tenant {tenant_id} by {deleted_by}") + logger.info( + f"Starting cascade deletion for tenant {tenant_id} by {deleted_by}") try: # 1. Deactivate all users in the tenant (full cleanup including Supabase deletion) logger.info(f"Deactivating users for tenant {tenant_id}") - users_result = get_users_by_tenant_id(tenant_id, page=1, page_size=10000) + users_result = get_users_by_tenant_id( + tenant_id, page=1, page_size=10000) users = users_result.get("users", []) if users: @@ -430,9 +453,11 @@ async def delete_single_user(user: Dict[str, Any]) -> None: if user_id: try: await delete_user_and_cleanup(user_id, tenant_id) - logger.info(f"Deactivated user {user_id} for tenant {tenant_id}") + logger.info( + f"Deactivated user {user_id} for tenant {tenant_id}") except Exception as e: - logger.warning(f"Failed to deactivate user {user_id}: {str(e)}") + logger.warning( + f"Failed to deactivate user {user_id}: {str(e)}") # Concurrently delete all users await asyncio.gather(*[delete_single_user(user) for user in users]) @@ -444,16 +469,19 @@ async def delete_single_user(user: Dict[str, Any]) -> None: try: remove_group(group["group_id"], deleted_by) except Exception as e: - logger.warning(f"Failed to delete group {group.get('group_id')}: {str(e)}") + logger.warning( + f"Failed to delete group {group.get('group_id')}: {str(e)}") # 3. Delete all models in the tenant logger.info(f"Deleting models for tenant {tenant_id}") models = get_model_records({"tenant_id": tenant_id}, tenant_id) for model in models: try: - delete_model_record(model["model_id"], deleted_by or "system", tenant_id) + delete_model_record( + model["model_id"], deleted_by or "system", tenant_id) except Exception as e: - logger.warning(f"Failed to delete model {model.get('model_id')}: {str(e)}") + logger.warning( + f"Failed to delete model {model.get('model_id')}: {str(e)}") # 4. Delete all knowledge bases in the tenant logger.info(f"Deleting knowledge bases for tenant {tenant_id}") @@ -465,7 +493,8 @@ async def delete_single_user(user: Dict[str, Any]) -> None: "user_id": deleted_by or "system" }) except Exception as e: - logger.warning(f"Failed to delete knowledge base {kb.get('knowledge_id')}: {str(e)}") + logger.warning( + f"Failed to delete knowledge base {kb.get('knowledge_id')}: {str(e)}") # 5. Delete all agents in the tenant (including related data) logger.info(f"Deleting agents for tenant {tenant_id}") @@ -474,24 +503,31 @@ async def delete_single_user(user: Dict[str, Any]) -> None: try: agent_id = agent.get("agent_id") # Delete tool instances first - delete_tools_by_agent_id(agent_id, tenant_id, deleted_by or "system", version_no=0) + delete_tools_by_agent_id( + agent_id, tenant_id, deleted_by or "system", version_no=0) # Delete agent relationships - delete_agent_relationship(agent_id, tenant_id, deleted_by or "system", version_no=0) + delete_agent_relationship( + agent_id, tenant_id, deleted_by or "system", version_no=0) # Delete the agent delete_agent_by_id(agent_id, tenant_id, deleted_by or "system") except Exception as e: - logger.warning(f"Failed to delete agent {agent.get('agent_id')}: {str(e)}") + logger.warning( + f"Failed to delete agent {agent.get('agent_id')}: {str(e)}") # Also delete published agents (version_no >= 1) - agents_published = query_all_agent_info_by_tenant_id(tenant_id, version_no=1) + agents_published = query_all_agent_info_by_tenant_id( + tenant_id, version_no=1) for agent in agents_published: try: agent_id = agent.get("agent_id") - delete_tools_by_agent_id(agent_id, tenant_id, deleted_by or "system", version_no=1) - delete_agent_relationship(agent_id, tenant_id, deleted_by or "system", version_no=1) + delete_tools_by_agent_id( + agent_id, tenant_id, deleted_by or "system", version_no=1) + delete_agent_relationship( + agent_id, tenant_id, deleted_by or "system", version_no=1) delete_agent_by_id(agent_id, tenant_id, deleted_by or "system") except Exception as e: - logger.warning(f"Failed to delete published agent {agent.get('agent_id')}: {str(e)}") + logger.warning( + f"Failed to delete published agent {agent.get('agent_id')}: {str(e)}") # 5b. Delete all skills, skill instances, and local skill files for the tenant _delete_skills_for_tenant(tenant_id, deleted_by or "system") @@ -508,7 +544,8 @@ async def delete_single_user(user: Dict[str, Any]) -> None: deleted_by or "system" ) except Exception as e: - logger.warning(f"Failed to delete MCP {mcp.get('mcp_id')}: {str(e)}") + logger.warning( + f"Failed to delete MCP {mcp.get('mcp_id')}: {str(e)}") # 7. Delete all invitation codes in the tenant logger.info(f"Deleting invitations for tenant {tenant_id}") @@ -517,7 +554,8 @@ async def delete_single_user(user: Dict[str, Any]) -> None: try: remove_invitation(invitation["invitation_id"], deleted_by) except Exception as e: - logger.warning(f"Failed to delete invitation {invitation.get('invitation_id')}: {str(e)}") + logger.warning( + f"Failed to delete invitation {invitation.get('invitation_id')}: {str(e)}") # 8. Delete all tenant configurations (must be done last) logger.info(f"Deleting tenant configurations for tenant {tenant_id}") @@ -527,9 +565,11 @@ async def delete_single_user(user: Dict[str, Any]) -> None: try: delete_config_by_tenant_config_id(config["tenant_config_id"]) except Exception as e: - logger.warning(f"Failed to delete config {config.get('tenant_config_id')}: {str(e)}") + logger.warning( + f"Failed to delete config {config.get('tenant_config_id')}: {str(e)}") - logger.info(f"Successfully deleted tenant {tenant_id} and all associated resources") + logger.info( + f"Successfully deleted tenant {tenant_id} and all associated resources") return True except Exception as e: @@ -563,5 +603,6 @@ def _create_default_group_for_tenant(tenant_id: str, created_by: Optional[str] = return group_id except Exception as e: - logger.error(f"Failed to create default group for tenant {tenant_id}: {str(e)}") + logger.error( + f"Failed to create default group for tenant {tenant_id}: {str(e)}") raise ValidationError(f"Failed to create default group: {str(e)}") diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 13cb3bd0d..ab3a9288e 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -18,6 +18,21 @@ get_supabase_admin_client, calculate_expires_at, get_jwt_expiry_seconds, + resolve_tenant_id_from_user_tenant_record, +) +from consts.const import ( + INVITE_CODE, + SUPABASE_URL, + SUPABASE_KEY, + DEFAULT_TENANT_ID, + ASSET_OWNER_TENANT_ID, + ASSET_OWNER_INVITE_CODE_TYPE, + ASSET_OWNER_ROLE, +) + +from services.asset_owner_visibility import ( + filter_accessible_routes_for_asset_owner_feature, + require_asset_owner_enabled, ) from consts.const import INVITE_CODE, SUPABASE_URL, SUPABASE_KEY, DEFAULT_TENANT_ID from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError @@ -35,7 +50,6 @@ from services.skill_service import init_skill_list_for_tenant - logging.getLogger("user_management_service").setLevel(logging.DEBUG) @@ -173,12 +187,17 @@ async def signup_user_with_invitation(email: EmailStr, user_role = "ADMIN" elif code_type == "DEV_INVITE": user_role = "DEV" + elif code_type == ASSET_OWNER_INVITE_CODE_TYPE: + require_asset_owner_enabled() + user_role = ASSET_OWNER_ROLE logging.info( f"Invitation code {invite_code} validated successfully, will assign role: {user_role}") except IncorrectInviteCodeException: raise + except ValidationError: + raise except Exception as e: logging.error( f"Invitation code {invite_code} validation failed: {str(e)}") @@ -197,14 +216,20 @@ async def signup_user_with_invitation(email: EmailStr, # Determine tenant_id based on invitation code if invitation_info: tenant_id = invitation_info["tenant_id"] + if invitation_info.get("code_type") == ASSET_OWNER_INVITE_CODE_TYPE: + tenant_id = ASSET_OWNER_TENANT_ID else: tenant_id = DEFAULT_TENANT_ID + is_asset_owner_registration = user_role == ASSET_OWNER_ROLE + # Create user tenant relationship - logging.debug(f"Creating user tenant relationship: user_id={user_id}, tenant_id={tenant_id}, user_role={user_role}") + logging.debug( + f"Creating user tenant relationship: user_id={user_id}, tenant_id={tenant_id}, user_role={user_role}") insert_user_tenant( user_id=user_id, tenant_id=tenant_id, user_role=user_role, user_email=email) - logging.debug(f"User tenant relationship created successfully for user {user_id}") + logging.debug( + f"User tenant relationship created successfully for user {user_id}") # Use invitation code now that we have the real user_id if invitation_info: @@ -215,7 +240,7 @@ async def signup_user_with_invitation(email: EmailStr, # Add user to groups specified in invitation code group_ids = invitation_result.get("group_ids", []) - if group_ids: + if group_ids and not is_asset_owner_registration: try: # Convert group_ids from string to list if needed if isinstance(group_ids, str): @@ -223,7 +248,8 @@ async def signup_user_with_invitation(email: EmailStr, group_ids = convert_string_to_list(group_ids) if group_ids: - group_results = add_user_to_groups(user_id, group_ids, user_id) + group_results = add_user_to_groups( + user_id, group_ids, user_id) successful_adds = [ r for r in group_results if not r.get("error")] logging.info( @@ -245,8 +271,9 @@ async def signup_user_with_invitation(email: EmailStr, await generate_tts_stt_4_admin(tenant_id, user_id) # Initialize tool list for the new tenant (only once per tenant) - await init_tool_list_for_tenant(tenant_id, user_id) - await init_skill_list_for_tenant(tenant_id, user_id) + if not is_asset_owner_registration: + await init_tool_list_for_tenant(tenant_id, user_id) + await init_skill_list_for_tenant(tenant_id, user_id) return await parse_supabase_response(False, response, user_role, auto_login) else: @@ -341,14 +368,24 @@ async def signin_user(email: EmailStr, "password": password }) + user_tenant = get_user_tenant_by_user_id(response.user.id) + if user_tenant and user_tenant.get("user_role") == ASSET_OWNER_ROLE: + try: + require_asset_owner_enabled() + except ValidationError: + client.auth.sign_out() + raise + # Get actual expiration time from access_token expiry_seconds = get_jwt_expiry_seconds(response.session.access_token) expires_at = calculate_expires_at(response.session.access_token) - # Get role information from user metadata - user_role = "user" # Default role - if 'role' in response.user.user_metadata: # Adapt to historical user data - user_role = response.user.user_metadata['role'] + # Prefer user_tenant_t role; fall back to Supabase metadata for legacy users + user_role = "user" + if user_tenant and user_tenant.get("user_role"): + user_role = user_tenant["user_role"] + elif "role" in response.user.user_metadata: + user_role = response.user.user_metadata["role"] logging.info( f"User {email} logged in successfully, session validity is {expiry_seconds} seconds, role: {user_role}") @@ -385,7 +422,8 @@ async def refresh_user_token(authorization, refresh_token: str): async def get_session_by_authorization(authorization): # Extract clean token from authorization header - clean_token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization + clean_token = authorization.replace( + "Bearer ", "") if authorization.startswith("Bearer ") else authorization # Use the unified token validation function is_valid, user = validate_token(clean_token) @@ -442,7 +480,7 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: ) return None - tenant_id = user_tenant["tenant_id"] + tenant_id = resolve_tenant_id_from_user_tenant_record(user_tenant) user_role = user_tenant["user_role"] user_email = user_tenant["user_email"] @@ -466,7 +504,7 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: "user_email": user_email, "user_role": user_role, "permissions": permissions_data["permissions"], - "accessibleRoutes": permissions_data["accessibleRoutes"] + "accessibleRoutes": permissions_data["accessibleRoutes"], } } @@ -505,9 +543,13 @@ def format_role_permissions(permissions: List[Dict[str, Any]]) -> Dict[str, List # Add permission_subtype to accessible routes for LEFT_NAV_MENU type accessible_routes.append(permission_subtype) + accessible_routes = filter_accessible_routes_for_asset_owner_feature( + accessible_routes + ) + return { "permissions": formatted_permissions, - "accessibleRoutes": accessible_routes + "accessibleRoutes": accessible_routes, } @@ -615,7 +657,8 @@ async def update_password(user_id: str, old_password: str, new_password: str) -> "password": old_password }) except Exception as auth_err: - logging.warning(f"Password verification failed for user {user_id}: {str(auth_err)}") + logging.warning( + f"Password verification failed for user {user_id}: {str(auth_err)}") raise UnauthorizedError("Invalid old password") # Update to new password using admin client @@ -629,5 +672,6 @@ async def update_password(user_id: str, old_password: str, new_password: str) -> except AppException: raise except Exception as exc: - logging.error(f"Failed to update password for user {user_id}: {str(exc)}") + logging.error( + f"Failed to update password for user {user_id}: {str(exc)}") raise diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index c3289555b..185bbfe6d 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -26,7 +26,7 @@ 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 +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.model import ChunkCreateRequest, ChunkUpdateRequest from database.attachment_db import delete_file from database.knowledge_db import ( @@ -46,6 +46,7 @@ from database.model_management_db import get_model_by_display_name, get_model_by_model_id, get_model_records from services.redis_service import get_redis_service from services.group_service import get_tenant_default_group_id +from services.asset_owner_visibility import postprocess_knowledge_visibility from utils.config_utils import tenant_config_manager, get_model_name_from_config from utils.file_management_utils import get_all_files_status, get_file_size from utils.str_utils import convert_string_to_list @@ -134,7 +135,8 @@ def get_embedding_model_by_index_name(tenant_id: str, index_name: str) -> tuple[ try: knowledge_record = get_knowledge_record({ "index_name": index_name, - "tenant_id": tenant_id + "tenant_id": tenant_id, + "include_asset_owner_assets": True, }) if not knowledge_record: @@ -855,7 +857,9 @@ def list_indices( Permission logic: - SU: All knowledgebases visible, all editable - ADMIN: Knowledgebases from same tenant visible, all editable - - USER/DEV: Knowledgebases where user belongs to intersecting groups, permission determined by: + - DEV on ASSET_OWNER-scoped records: all visible, read-only (READ_ONLY) + - SU/ADMIN/SPEED cross-tenant view of ASSET_OWNER records: read-only + - USER/DEV (non-ASSET_OWNER records): group intersection required; permission by: * If user is creator: editable * If ingroup_permission=EDIT: editable * If ingroup_permission=READ_ONLY: read-only @@ -887,7 +891,9 @@ def list_indices( es_indices_list = vdb_core.get_user_indices(pattern) # Get all knowledgebase records from database (for cleanup and permission checking) - all_db_records = get_knowledge_info_by_tenant_id(target_tenant_id) + all_db_records = get_knowledge_info_by_tenant_id( + target_tenant_id + ) # Filter visible knowledgebases based on user role and permissions visible_knowledgebases = [] @@ -903,6 +909,8 @@ def list_indices( # Check permission based on user role permission = None + record_tenant_id = str(record.get("tenant_id") or "") + is_asset_owner_record = record_tenant_id == ASSET_OWNER_TENANT_ID # Fallback logic: if user_id equals user_tenant_id, treat as legacy admin user # even if user_role is None or empty @@ -914,7 +922,12 @@ def list_indices( effective_user_role = "SPEED" logger.info("User under SPEED version is treated as admin") - if effective_user_role in ["SU", "ADMIN", "SPEED"]: + if is_asset_owner_record: + if effective_user_role in ["ASSET_OWNER"]: + permission = PERMISSION_EDIT + elif effective_user_role in ["SU", "ADMIN", "SPEED", "DEV"]: + permission = PERMISSION_READ + elif effective_user_role in ["SU", "ADMIN", "SPEED", "ASSET_OWNER"]: # SU, ADMIN and SPEED roles can see all knowledgebases permission = PERMISSION_EDIT elif effective_user_role in ["USER", "DEV"]: @@ -980,6 +993,11 @@ def list_indices( model_name_is_none_list.append(index_name) # Build response + visible_knowledgebases = postprocess_knowledge_visibility( + visible_knowledgebases, + caller_role=user_role, + caller_tenant_id=target_tenant_id, + ) indices = [record["index_name"] for record in visible_knowledgebases] response = { diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index 543d49693..15ceef050 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -10,6 +10,8 @@ from supabase import create_client from consts.const import ( + ASSET_OWNER_TENANT_ID, + ASSET_OWNER_ROLE, DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE, @@ -27,6 +29,36 @@ # Module logger logger = logging.getLogger(__name__) + +def resolve_tenant_id_from_user_tenant_record( + user_tenant_record: Optional[dict], +) -> str: + """ + Resolve tenant_id from a user_tenant row. + + ASSET_OWNER virtual tenant_id is valid for asset administrator users. + + ENABLE_ASSET_OWNER_ROLE gates invites, registrations, and sign-in; existing + ASSET_OWNER rows still resolve to the virtual tenant for API paths that read + user_tenant_t (e.g. JWT-authenticated requests until tokens expire). + """ + if user_tenant_record is None: + return DEFAULT_TENANT_ID + + user_role = str(user_tenant_record.get("user_role") or "").upper() + if "tenant_id" in user_tenant_record: + tenant_value = user_tenant_record["tenant_id"] + if tenant_value is not None: + # Legacy asset-owner rows may still use empty string before DB migration + if tenant_value == "": + return ASSET_OWNER_TENANT_ID + return tenant_value + + if user_role == ASSET_OWNER_ROLE: + return ASSET_OWNER_TENANT_ID + + return DEFAULT_TENANT_ID + # --------------------------------------------------------------------------- # Shared test constants # --------------------------------------------------------------------------- @@ -211,12 +243,9 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: if not user_id: raise UnauthorizedError("No user associated with this access key") - # Query tenant from user_tenant_t user_tenant_record = get_user_tenant_by_user_id(user_id) - if user_tenant_record and user_tenant_record.get("tenant_id"): - tenant_id = user_tenant_record["tenant_id"] - else: - tenant_id = DEFAULT_TENANT_ID + tenant_id = resolve_tenant_id_from_user_tenant_record(user_tenant_record) + if user_tenant_record is None: logger.warning( f"No tenant relationship found for user {user_id}, using default tenant" ) @@ -387,11 +416,10 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: raise UnauthorizedError("Invalid or expired authentication token") user_tenant_record = get_user_tenant_by_user_id(user_id) - if user_tenant_record and user_tenant_record.get("tenant_id"): - tenant_id = user_tenant_record["tenant_id"] - logging.debug(f"Found tenant ID for user {user_id}: {tenant_id}") + tenant_id = resolve_tenant_id_from_user_tenant_record(user_tenant_record) + if user_tenant_record is not None: + logging.debug(f"Found tenant ID for user {user_id}: {tenant_id!r}") else: - tenant_id = DEFAULT_TENANT_ID logging.warning( f"No tenant relationship found for user {user_id}, using default tenant" ) diff --git a/docker/.env.example b/docker/.env.example index 64358219f..2c974ea07 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -212,3 +212,6 @@ WECHAT_OAUTH_APP_SECRET= OAUTH_SSL_VERIFY=true OAUTH_CA_BUNDLE= OAUTH_CALLBACK_BASE_URL=http://localhost:3000 + +# Asset owner role (opt-in; default false). Set true to enable ASSET_OWNER. +ENABLE_ASSET_OWNER_ROLE=false diff --git a/docker/init.sql b/docker/init.sql index fed8001e5..4a6065ed6 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -820,7 +820,7 @@ COMMENT ON COLUMN nexent.tenant_invitation_code_t.group_ids IS 'Associated group COMMENT ON COLUMN nexent.tenant_invitation_code_t.capacity IS 'Invitation code capacity'; COMMENT ON COLUMN nexent.tenant_invitation_code_t.expiry_date IS 'Invitation code expiry date'; COMMENT ON COLUMN nexent.tenant_invitation_code_t.status IS 'Invitation code status: IN_USE, EXPIRE, DISABLE, RUN_OUT'; -COMMENT ON COLUMN nexent.tenant_invitation_code_t.code_type IS 'Invitation code type: ADMIN_INVITE, DEV_INVITE, USER_INVITE'; +COMMENT ON COLUMN nexent.tenant_invitation_code_t.code_type IS 'Invitation code type: ADMIN_INVITE, DEV_INVITE, USER_INVITE, ASSET_OWNER_INVITE'; COMMENT ON COLUMN nexent.tenant_invitation_code_t.create_time IS 'Create time'; COMMENT ON COLUMN nexent.tenant_invitation_code_t.update_time IS 'Update time'; COMMENT ON COLUMN nexent.tenant_invitation_code_t.created_by IS 'Created by'; @@ -1101,7 +1101,42 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), (185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), (186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'); +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(188, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'CREATE'), +(189, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'READ'), +(190, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'UPDATE'), +(191, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'DELETE'), +(192, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(193, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(194, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(195, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(196, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(197, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(198, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(199, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'CREATE'), +(200, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'READ'), +(201, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'UPDATE'), +(202, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'DELETE'), +(203, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'CREATE'), +(204, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'READ'), +(205, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'UPDATE'), +(206, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'DELETE'), +(207, 'ASSET_OWNER', 'RESOURCE', 'KB', 'CREATE'), +(208, 'ASSET_OWNER', 'RESOURCE', 'KB', 'READ'), +(209, 'ASSET_OWNER', 'RESOURCE', 'KB', 'UPDATE'), +(210, 'ASSET_OWNER', 'RESOURCE', 'KB', 'DELETE'), +(211, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'CREATE'), +(212, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'READ'), +(213, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'UPDATE'), +(214, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'DELETE'), +(215, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'CREATE'), +(216, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'READ'), +(217, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'UPDATE'), +(218, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'DELETE'), +(219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'), +(220, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(221, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/asset-owner-resources') +; -- Insert SPEED role user into user_tenant_t table if not exists INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) @@ -1223,6 +1258,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_skill_info_t ( config_schemas JSON, config_values JSON, source VARCHAR(30) DEFAULT 'official', + tenant_id VARCHAR(100), created_by VARCHAR(100), create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(100), diff --git a/frontend/app/[locale]/agents/AgentVersionCard.tsx b/frontend/app/[locale]/agents/AgentVersionCard.tsx index 260db9fda..4ef6f052e 100644 --- a/frontend/app/[locale]/agents/AgentVersionCard.tsx +++ b/frontend/app/[locale]/agents/AgentVersionCard.tsx @@ -45,6 +45,7 @@ import { searchAgentInfo } from "@/services/agentConfigService"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import log from "@/lib/logger"; +import { resolveAgentListTenantKey } from "@/lib/agentListTenant"; import { message } from "antd"; import { useQueryClient } from "@tanstack/react-query"; import AgentVersionCompareModal from "./versions/AgentVersionCompareModal"; @@ -148,7 +149,7 @@ export function VersionCardItem({ ); const { tools: toolList } = useToolList(); - const { agents: agentList } = useAgentList(user?.tenantId ?? null); + const { agents: agentList } = useAgentList(""); // Get current agent's permission from agent list const currentAgent = useMemo(() => { diff --git a/frontend/app/[locale]/agents/components/AgentManageComp.tsx b/frontend/app/[locale]/agents/components/AgentManageComp.tsx new file mode 100644 index 000000000..f983e7dab --- /dev/null +++ b/frontend/app/[locale]/agents/components/AgentManageComp.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { App, Row, Col, Flex, Tooltip, Badge, Divider } from "antd"; +import { FileInput, Plus, X } from "lucide-react"; + +import AgentList from "./agentManage/AgentList"; + +import { useAgentConfigStore } from "@/stores/agentConfigStore"; +import { importAgent } from "@/services/agentConfigService"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAgentList } from "@/hooks/agent/useAgentList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import log from "@/lib/logger"; +import { useState } from "react"; +import { ImportAgentData } from "@/hooks/useAgentImport"; +import AgentImportWizard from "@/components/agent/AgentImportWizard"; + + +export default function AgentManageComp() { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + const { user } = useAuthorizationContext(); + + // Get state from store + const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); + const enterCreateMode = useAgentConfigStore((state) => state.enterCreateMode); + const reset = useAgentConfigStore((state) => state.reset); + + // Import wizard state + const [importWizardVisible, setImportWizardVisible] = useState(false); + const [importWizardData, setImportWizardData] = + useState(null); + + // Always resolve tenant from auth on the agent dev page (matches published_list; avoids stale/wrong tenant_id query params) + const { agents: agentList, isLoading: loading, refetch } = useAgentList(""); + + // Handle import agent for space view - open wizard instead of direct import + const handleImportAgent = () => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".json"; + fileInput.onchange = async (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".json")) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + try { + // Read and parse file + const fileContent = await file.text(); + let agentData: ImportAgentData; + + try { + agentData = JSON.parse(fileContent); + } catch (parseError) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + // Validate structure + if (!agentData.agent_id || !agentData.agent_info) { + message.error(t("businessLogic.config.error.invalidFileType")); + return; + } + + // Open wizard with parsed data + setImportWizardData(agentData); + setImportWizardVisible(true); + } catch (error) { + log.error("Failed to read import file:", error); + message.error(t("businessLogic.config.error.agentImportFailed")); + } + }; + + fileInput.click(); + }; + + return ( + <> + {/* Import handled by Ant Design Upload (no hidden input required) */} + + + + + +

+ {t("subAgentPool.management")} +

+
+ +
+ + + + + + {isCreatingMode ? ( + +
+ + + + +
+ {t("subAgentPool.button.exitCreate")} +
+
+ {t("subAgentPool.description.exitCreate")} +
+
+
+
+
+ ) : ( + +
+ + + + +
+ {t("subAgentPool.button.create")} +
+
+ {t("subAgentPool.description.createAgent")} +
+
+
+
+
+ )} + + + + +
+ + + + +
+ {t("subAgentPool.button.import")} +
+
+ {t("subAgentPool.description.importAgent")} +
+
+
+
+
+ +
+ +
+ +
+
+ + {/* Import Wizard Modal */} + { + setImportWizardVisible(false); + setImportWizardData(null); + }} + initialData={importWizardData} + onImportComplete={() => { + setImportWizardVisible(false); + setImportWizardData(null); + refetch(); // Refresh the agent list + }} + /> + + ); +} diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index 81704ac68..7f969edb9 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx @@ -53,6 +53,7 @@ import { import { fetchSkillFiles, fetchSkillFileContent, + SkillFilesAccessDeniedError, type SkillFileNode, } from "@/services/agentConfigService"; import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; @@ -520,6 +521,10 @@ export default function SkillBuildModal({ setActiveSkillTab("SKILL.md"); } catch (error) { log.error("Failed to load skill files:", error); + if (error instanceof SkillFilesAccessDeniedError) { + message.warning(error.message); + return; + } // Fallback to basic content const skill = allSkills.find((s) => s.name === skillName); if (skill?.content) { diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillDetailModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillDetailModal.tsx index 075229d57..4161a3b1a 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillDetailModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillDetailModal.tsx @@ -2,10 +2,14 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Modal, Descriptions, Tag, Tree } from "antd"; +import { Alert, Modal, Descriptions, Tag, Tree } from "antd"; import type { TreeProps } from "antd/es/tree"; import { Skill } from "@/types/agentConfig"; -import { fetchSkillFiles, fetchSkillFileContent } from "@/services/agentConfigService"; +import { + fetchSkillFiles, + fetchSkillFileContent, + SkillFilesAccessDeniedError, +} from "@/services/agentConfigService"; import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; import { buildTreeData, @@ -19,6 +23,7 @@ import { } from "@/lib/skillFileUtils"; import type { ExtendedSkillFileNode } from "@/types/skill"; import { SKILL_DETAIL_CONTENT_HEIGHT } from "@/types/skill"; +import log from "@/lib/logger"; interface SkillDetailModalProps { skill: Skill | null; @@ -35,6 +40,7 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo const [loadingContent, setLoadingContent] = useState(false); const [loadingTree, setLoadingTree] = useState(false); const [expandedKeys, setExpandedKeys] = useState([]); + const [fileTreeMessage, setFileTreeMessage] = useState(null); useEffect(() => { if (skill && open) { @@ -51,6 +57,7 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo const loadSkillFiles = async () => { if (!skill) return; setLoadingTree(true); + setFileTreeMessage(null); try { const files = await fetchSkillFiles(skill.name); const normalizedFiles = normalizeSkillFiles(files); @@ -59,7 +66,11 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo setTreeData(built); setExpandedKeys(collectDirKeys(built)); } catch (error) { - console.error("Failed to load skill files:", error); + if (error instanceof SkillFilesAccessDeniedError) { + setFileTreeMessage(error.message); + } else { + log.error("Failed to load skill files:", error); + } setTreeData([]); } finally { setLoadingTree(false); @@ -76,7 +87,7 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo const content = await fetchSkillFileContent(skill.name, relativePath); setFileContent(content || ""); } catch (error) { - console.error("Failed to load file content:", error); + log.error("Failed to load file content:", error); setFileContent(""); } finally { setLoadingContent(false); @@ -88,6 +99,7 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo setFileContent(""); setTreeData([]); setExpandedKeys([]); + setFileTreeMessage(null); onClose(); }; @@ -249,6 +261,13 @@ export default function SkillDetailModal({ skill, open, onClose }: SkillDetailMo
{t("common.loading")}
+ ) : fileTreeMessage ? ( + ) : treeData.length > 0 ? ( ("agent-info"); @@ -331,6 +333,8 @@ export default function AgentGenerateDetail({}) { ); }; + const promptsHidden = isAgentPromptsHidden(editedAgent); + const renderPromptSection = ( type: "duty" | "constraint" | "few-shots", fieldName: "dutyPrompt" | "constraintPrompt" | "fewShotsPrompt", @@ -339,6 +343,14 @@ export default function AgentGenerateDetail({}) { ) => { return (
+ {promptsHidden && ( + + )} {renderPromptToolbar(type, title)}
onBlurUpdate(e.target.value)} /> @@ -665,7 +677,7 @@ export default function AgentGenerateDetail({}) { { + onValueChange={(value: string) => { setActiveTab(value); }} className="agent-config-tabs flex flex-col h-full w-full" diff --git a/frontend/app/[locale]/asset-owner-resources/page.tsx b/frontend/app/[locale]/asset-owner-resources/page.tsx new file mode 100644 index 000000000..24a3105ce --- /dev/null +++ b/frontend/app/[locale]/asset-owner-resources/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import React from "react"; +import { Flex } from "antd"; + +import AssetOwnerResourcesComp from "../tenant-resources/components/AssetOwnerResourcesComp"; + +export default function AssetOwnerResourcesPage() { + return ( + + + + ); +} diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx index 5e963c545..feebfca6f 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentChunk.tsx @@ -201,14 +201,14 @@ const DocumentChunk: React.FC = ({ // Load chunks for active document with server-side pagination const loadChunks = React.useCallback(async () => { - if (!knowledgeBaseName || !activeDocumentKey) { + if (!knowledgeBaseId || !activeDocumentKey) { return; } setLoading(true); try { const result = await knowledgeBaseService.previewChunksPaginated( - knowledgeBaseName, + knowledgeBaseId, pagination.page, pagination.pageSize, activeDocumentKey @@ -240,7 +240,7 @@ const DocumentChunk: React.FC = ({ setLoading(false); } }, [ - knowledgeBaseName, + knowledgeBaseId, activeDocumentKey, pagination.page, pagination.pageSize, @@ -330,7 +330,7 @@ const DocumentChunk: React.FC = ({ return; } - if (!knowledgeBaseName) { + if (!knowledgeBaseId) { message.error(t("document.chunk.error.searchFailed")); return; } @@ -373,7 +373,6 @@ const DocumentChunk: React.FC = ({ setChunkSearchLoading(false); } }, [ - knowledgeBaseName, knowledgeBaseId, message, pagination.pageSize, @@ -454,7 +453,7 @@ const DocumentChunk: React.FC = ({ }; const handleChunkSubmit = async () => { - if (!knowledgeBaseName) { + if (!knowledgeBaseId) { message.error(t("document.chunk.error.loadFailed")); return; } @@ -482,7 +481,7 @@ const DocumentChunk: React.FC = ({ setChunkSubmitting(true); if (chunkModalMode === "create") { const filenamePayload = values.filename?.trim() || undefined; - await knowledgeBaseService.createChunk(knowledgeBaseName, { + await knowledgeBaseService.createChunk(knowledgeBaseId, { content: values.content, filename: filenamePayload, path_or_url: activeDocumentKey, @@ -503,7 +502,7 @@ const DocumentChunk: React.FC = ({ return; } await knowledgeBaseService.updateChunk( - knowledgeBaseName, + knowledgeBaseId, editingChunk.id, { content: values.content, @@ -541,7 +540,7 @@ const DocumentChunk: React.FC = ({ message.error(t("document.chunk.error.missingChunkId")); return; } - if (!knowledgeBaseName) { + if (!knowledgeBaseId) { message.error(t("document.chunk.error.deleteFailed")); return; } @@ -556,7 +555,7 @@ const DocumentChunk: React.FC = ({ danger: true, onOk: async () => { try { - await knowledgeBaseService.deleteChunk(knowledgeBaseName, chunk.id); + await knowledgeBaseService.deleteChunk(knowledgeBaseId, chunk.id); message.success(t("document.chunk.success.delete")); forceCloseTooltips(); // Update chunk count immediately for better UX diff --git a/frontend/app/[locale]/market/components/MarketAgentDetailModal.tsx b/frontend/app/[locale]/market/components/MarketAgentDetailModal.tsx index daf1b42bb..418c633d7 100644 --- a/frontend/app/[locale]/market/components/MarketAgentDetailModal.tsx +++ b/frontend/app/[locale]/market/components/MarketAgentDetailModal.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Modal, Tabs, Tag, Descriptions, Empty } from "antd"; +import { Modal, Tabs, Tag, Descriptions, Empty, Alert } from "antd"; import { useTranslation } from "react-i18next"; import { Bot, @@ -15,6 +15,10 @@ import { MarketAgentDetail } from "@/types/market"; import { getToolSourceLabel, getGenericLabel } from "@/lib/agentLabelMapper"; import { getCategoryIcon } from "@/const/marketConfig"; import { getLocalizedDescription } from "@/lib/utils"; +import { + isAgentPromptsHidden, + renderAgentPromptFieldValue, +} from "@/lib/agentPromptVisibility"; import { useLocalTools } from "@/hooks/useLocalTools"; interface MarketAgentDetailModalProps { @@ -210,13 +214,24 @@ export default function MarketAgentDetailModal({ ), children: (
+ {isAgentPromptsHidden(agentDetails) && ( + + )}

{t("market.detail.dutyPrompt", "Duty Prompt")}

- {needsConfig(agentDetails?.duty_prompt) ? ( + {isAgentPromptsHidden(agentDetails) ? ( + + {renderAgentPromptFieldValue(agentDetails, "duty_prompt", t)} + + ) : needsConfig(agentDetails?.duty_prompt) ? ( renderFieldValue(agentDetails?.duty_prompt) ) : (
@@ -231,7 +246,11 @@ export default function MarketAgentDetailModal({
               {t("market.detail.constraintPrompt", "Constraint Prompt")}
             
             
- {needsConfig(agentDetails?.constraint_prompt) ? ( + {isAgentPromptsHidden(agentDetails) ? ( + + {renderAgentPromptFieldValue(agentDetails, "constraint_prompt", t)} + + ) : needsConfig(agentDetails?.constraint_prompt) ? ( renderFieldValue(agentDetails?.constraint_prompt) ) : (
@@ -246,7 +265,11 @@ export default function MarketAgentDetailModal({
               {t("market.detail.fewShotsPrompt", "Few-Shots Prompt")}
             
             
- {needsConfig(agentDetails?.few_shots_prompt) ? ( + {isAgentPromptsHidden(agentDetails) ? ( + + {renderAgentPromptFieldValue(agentDetails, "few_shots_prompt", t)} + + ) : needsConfig(agentDetails?.few_shots_prompt) ? ( renderFieldValue(agentDetails?.few_shots_prompt) ) : (
diff --git a/frontend/app/[locale]/space/components/AgentDetailModal.tsx b/frontend/app/[locale]/space/components/AgentDetailModal.tsx
index de9802905..0b574dbbf 100644
--- a/frontend/app/[locale]/space/components/AgentDetailModal.tsx
+++ b/frontend/app/[locale]/space/components/AgentDetailModal.tsx
@@ -1,7 +1,7 @@
 "use client";
 
 import React from "react";
-import { Modal, Tabs, Tag, Descriptions, Empty, Avatar } from "antd";
+import { Modal, Tabs, Tag, Descriptions, Empty, Avatar, Alert } from "antd";
 import { useTranslation } from "react-i18next";
 import {
   CheckCircle,
@@ -17,6 +17,10 @@ import {
 import { generateAvatarFromName } from "@/lib/avatar";
 import { getToolSourceLabel, getCategoryLabel } from "@/lib/agentLabelMapper";
 import { getLocalizedDescription } from "@/lib/utils";
+import {
+  isAgentPromptsHidden,
+  renderAgentPromptFieldValue,
+} from "@/lib/agentPromptVisibility";
 
 interface AgentDetailModalProps {
   visible: boolean;
@@ -122,14 +126,21 @@ export default function AgentDetailModal({
       ),
       children: (
         
+ {isAgentPromptsHidden(agentDetails) && ( + + )}

{t("space.detail.dutyPrompt", "Duty Prompt")}

-
-                {agentDetails?.duty_prompt || t("common.none", "None")}
+              
+                {renderAgentPromptFieldValue(agentDetails, "duty_prompt", t)}
               
@@ -139,8 +150,8 @@ export default function AgentDetailModal({ {t("space.detail.constraintPrompt", "Constraint Prompt")}
-
-                {agentDetails?.constraint_prompt || t("common.none", "None")}
+              
+                {renderAgentPromptFieldValue(agentDetails, "constraint_prompt", t)}
               
@@ -150,8 +161,8 @@ export default function AgentDetailModal({ {t("space.detail.fewShotsPrompt", "Few-Shots Prompt")}
-
-                {agentDetails?.few_shots_prompt || t("common.none", "None")}
+              
+                {renderAgentPromptFieldValue(agentDetails, "few_shots_prompt", t)}
               
diff --git a/frontend/app/[locale]/tenant-resources/components/AssetOwnerResourcesComp.tsx b/frontend/app/[locale]/tenant-resources/components/AssetOwnerResourcesComp.tsx new file mode 100644 index 000000000..38fb3ceb1 --- /dev/null +++ b/frontend/app/[locale]/tenant-resources/components/AssetOwnerResourcesComp.tsx @@ -0,0 +1,110 @@ +"use client"; + +import React from "react"; +import { Tabs } from "antd"; +import { Building2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; + +import { ASSET_OWNER_TENANT_ID } from "@/const/auth"; +import UserList from "./resources/UserList"; +import ModelList from "./resources/ModelList"; +import KnowledgeList from "./resources/KnowledgeList"; +import InvitationList from "./resources/InvitationList"; +import AgentList from "./resources/AgentList"; +import McpList from "./resources/McpList"; +import SkillList from "./resources/SkillList"; + +export default function AssetOwnerResourcesComp() { + const { t } = useTranslation("common"); + const userListRefreshKey = 0; + const invitationListRefreshKey = 0; + + return ( +
+
+ +
+
+ +
+
+

+ {t("assetOwnerResources.title")} +

+

+ {t("assetOwnerResources.subtitle")} +

+
+
+
+
+ +
+
+
+

+ {t("assetOwnerResources.tenantName")} +

+
+ + + ), + }, + { + key: "models", + label: t("tenantResources.tabs.models"), + children: , + }, + { + key: "knowledge", + label: t("tenantResources.tabs.knowledge"), + children: , + }, + { + key: "agents", + label: t("tenantResources.tabs.agents"), + children: , + }, + { + key: "mcp", + label: t("tenantResources.tabs.mcp"), + children: , + }, + { + key: "skills", + label: "SKILLS", + children: , + }, + { + key: "invitations", + label: t("tenantResources.invitation.tab"), + children: ( + + ), + }, + ]} + /> +
+
+
+ ); +} diff --git a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx index 1d9095a64..0c04b39d4 100644 --- a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx +++ b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx @@ -19,7 +19,19 @@ import { Alert, Space, } from "antd"; -import { Users, Plus, Edit, Edit2, Building2, Trash2, AlertTriangle, CircleCheckBig, CircleOff, CircleDot, LoaderCircle } from "lucide-react"; +import { + Users, + Plus, + Edit, + Edit2, + Building2, + Trash2, + AlertTriangle, + CircleCheckBig, + CircleOff, + CircleDot, + LoaderCircle, +} from "lucide-react"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import { useTenantList } from "@/hooks/tenant/useTenantList"; @@ -31,7 +43,10 @@ import { getTenantUsers, getTenant, } from "@/services/tenantService"; -import { createInvitation, deleteInvitation } from "@/services/invitationService"; +import { + createInvitation, + deleteInvitation, +} from "@/services/invitationService"; import { authService } from "@/services/authService"; import { fetchOfficialSkillsWithStatus } from "@/services/skillService"; import { InstallableSkill } from "@/types/agentConfig"; @@ -81,9 +96,9 @@ function TenantList({ onTenantsRefetch: () => Promise; loading?: boolean; t: (key: string, options?: any) => string; - onUserListRefresh?: () => void; - onInvitationListRefresh?: () => void; - locale?: string; + onUserListRefresh?: () => void; + onInvitationListRefresh?: () => void; + locale?: string; }) { const [editingTenant, setEditingTenant] = useState(null); const [modalVisible, setModalVisible] = useState(false); @@ -199,7 +214,8 @@ function TenantList({ } } } catch (error: any) { - const errorMessage = error?.response?.data?.detail || error?.message || ""; + const errorMessage = + error?.response?.data?.detail || error?.message || ""; message.error(errorMessage || t("tenantResources.tenantDeleteFailed")); } finally { setDeleteModalVisible(false); @@ -290,10 +306,15 @@ function TenantList({ if (signupResult.error) { // Handle signup error const errorMsg = signupResult.error.message || ""; - if (errorMsg.includes("already exists") || errorMsg.includes("EMAIL_ALREADY_EXISTS")) { + if ( + errorMsg.includes("already exists") || + errorMsg.includes("EMAIL_ALREADY_EXISTS") + ) { message.error(t("tenantResources.tenants.emailAlreadyExists")); } else { - message.error(t("tenantResources.tenants.failedToCreateAdminAccount")); + message.error( + t("tenantResources.tenants.failedToCreateAdminAccount") + ); } } else { message.success(t("tenantResources.tenants.adminAccountCreated")); @@ -302,7 +323,10 @@ function TenantList({ await deleteInvitation(invitation.invitation_code); } catch (deleteError) { // Log error but don't block the success flow - console.warn("Failed to delete invitation code after admin registration:", deleteError); + console.warn( + "Failed to delete invitation code after admin registration:", + deleteError + ); } // Refresh user list and invitation list to show the newly created admin onUserListRefresh?.(); @@ -310,11 +334,17 @@ function TenantList({ } } catch (adminError: any) { // Handle admin account creation error - const errorMsg = adminError?.response?.data?.message || adminError?.message || ""; - if (errorMsg.includes("already exists") || errorMsg.includes("EMAIL_ALREADY_EXISTS")) { + const errorMsg = + adminError?.response?.data?.message || adminError?.message || ""; + if ( + errorMsg.includes("already exists") || + errorMsg.includes("EMAIL_ALREADY_EXISTS") + ) { message.error(t("tenantResources.tenants.emailAlreadyExists")); } else { - message.error(t("tenantResources.tenants.failedToCreateAdminAccount")); + message.error( + t("tenantResources.tenants.failedToCreateAdminAccount") + ); } } } @@ -322,11 +352,17 @@ function TenantList({ setModalVisible(false); } catch (err: any) { const errorMessage = err?.response?.data?.message || err?.message || ""; - const nameConflictMatch = errorMessage.match(/Tenant with name '(.*)' already exists/i); + const nameConflictMatch = errorMessage.match( + /Tenant with name '(.*)' already exists/i + ); if (nameConflictMatch && nameConflictMatch[1]) { // Extract the duplicate name and show translated error - message.error(t("tenantResources.tenants.nameExists", { name: nameConflictMatch[1] })); + message.error( + t("tenantResources.tenants.nameExists", { + name: nameConflictMatch[1], + }) + ); } else if (errorMessage.includes("Tenant name cannot be empty")) { // Handle empty name error message.error(t("tenantResources.tenants.nameRequired")); @@ -361,49 +397,51 @@ function TenantList({
)} {!loading && tenants.length === 0 && ( -
No tenants found
+
+ No tenants found +
)} {!loading && tenants.length > 0 && ( <> {tenants.map((tenant, index) => ( -
onSelect(tenant.tenant_id)} - > -
-
- {tenant.tenant_name || t("tenantResources.tenants.unnamed")} -
-
-
-
))} )} @@ -437,7 +475,12 @@ function TenantList({ okText={t("common.confirm")} cancelText={t("common.cancel")} > - + - +
- {t("tenantResources.tenants.generateAdminAccount")} + + {t("tenantResources.tenants.generateAdminAccount")} + { setGenerateAdminAccount(checked); if (!checked) { - form.resetFields(["adminEmail", "adminPassword", "confirmAdminPassword"]); + form.resetFields([ + "adminEmail", + "adminPassword", + "confirmAdminPassword", + ]); } }} /> @@ -481,15 +527,22 @@ function TenantList({ rules={[ { required: true, - message: t("tenantResources.tenants.adminEmailRequired"), + message: t( + "tenantResources.tenants.adminEmailRequired" + ), }, { type: "email", - message: t("tenantResources.tenants.invalidEmailFormat"), + message: t( + "tenantResources.tenants.invalidEmailFormat" + ), }, ]} > - + ({ validator(_, value) { - if (!value || getFieldValue("adminPassword") === value) { + if ( + !value || + getFieldValue("adminPassword") === value + ) { return Promise.resolve(); } - return Promise.reject(new Error(t("tenantResources.tenants.passwordsDoNotMatch"))); + return Promise.reject( + new Error( + t("tenantResources.tenants.passwordsDoNotMatch") + ) + ); }, }), ]} > @@ -849,11 +915,15 @@ export default function UserManageComp() { if (!isSuperAdmin && directTenantData) { // Non-super-admin: use directly fetched tenant info currentTenant = directTenantData; - currentTenantName = directTenantData.tenant_name || t("tenantResources.tenants.unnamed"); + currentTenantName = + directTenantData.tenant_name || t("tenantResources.tenants.unnamed"); } else { // Super-admin: search in paginated list - currentTenant = tenantData?.data?.find((t: Tenant) => t.tenant_id === tenantId); - currentTenantName = currentTenant?.tenant_name || t("tenantResources.tenants.unnamed"); + currentTenant = tenantData?.data?.find( + (t: Tenant) => t.tenant_id === tenantId + ); + currentTenantName = + currentTenant?.tenant_name || t("tenantResources.tenants.unnamed"); } // Tenant name editing states @@ -1004,7 +1074,12 @@ export default function UserManageComp() { { key: "users", label: t("tenantResources.tabs.users") || "Users", - children: , + children: ( + + ), }, { key: "groups", @@ -1023,9 +1098,9 @@ export default function UserManageComp() { children: , }, { - key: "agents", - label: t("tenantResources.tabs.agents") || "Agents", - children: , + key: "agents", + label: t("tenantResources.tabs.agents") || "Agents", + children: , }, { key: "mcp", @@ -1040,7 +1115,12 @@ export default function UserManageComp() { { key: "invitations", label: t("tenantResources.invitation.tab") || "Invitations", - children: , + children: ( + + ), }, ]} /> @@ -1049,7 +1129,7 @@ export default function UserManageComp() {
-

+

{t("tenantResources.selectTenantFirst") || "Please select a tenant"}

diff --git a/frontend/app/[locale]/tenant-resources/components/resources/GroupList.tsx b/frontend/app/[locale]/tenant-resources/components/resources/GroupList.tsx index cf9843889..ec3397219 100644 --- a/frontend/app/[locale]/tenant-resources/components/resources/GroupList.tsx +++ b/frontend/app/[locale]/tenant-resources/components/resources/GroupList.tsx @@ -316,14 +316,14 @@ export default function GroupList({ tenantId }: { tenantId: string | null }) { setModalVisible(false); editGroupForm.resetFields(); }} - destroyOnHidden okText={t("common.confirm")} cancelText={t("common.cancel")} width={editingGroup ? 600 : 400} > - {editingGroup ? ( + {/* Edit mode form - always mounted to keep form instance connected */} + + {/* Create mode form - always mounted to keep form instance connected */} + {/* User List Modal */} diff --git a/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx b/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx index 9e497471c..b88076659 100644 --- a/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx +++ b/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx @@ -31,30 +31,52 @@ import { type CreateInvitationRequest, type UpdateInvitationRequest, } from "@/services/invitationService"; -import { Plus, Edit, Trash2, CheckCircle, Clock, XCircle, Copy, CircleSlash } from "lucide-react"; +import { + Plus, + Edit, + Trash2, + CheckCircle, + Clock, + XCircle, + Copy, + CircleSlash, +} from "lucide-react"; import { Tooltip } from "@/components/ui/tooltip"; import { formatDate } from "@/lib/date"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; +import { + ASSET_OWNER_INVITE_CODE_TYPE, + ASSET_OWNER_TENANT_ID, + USER_ROLES, +} from "@/const/auth"; const { Panel } = Collapse; -export default function InvitationList({ tenantId, refreshKey }: { tenantId: string | null; refreshKey?: number }) { +export default function InvitationList({ + tenantId, + refreshKey, +}: { + tenantId: string | null; + refreshKey?: number; +}) { const { t } = useTranslation("common"); const { user } = useAuthorizationContext(); const userRole = user?.role; const isAdminRole = userRole === USER_ROLES.ADMIN; - + const isSuperAdmin = userRole === USER_ROLES.SU; + const isAssetOwnerInviteContext = tenantId === ASSET_OWNER_TENANT_ID; const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); - const [editingInvitation, setEditingInvitation] = useState(null); + const [editingInvitation, setEditingInvitation] = useState( + null + ); const [modalVisible, setModalVisible] = useState(false); const [form] = Form.useForm(); // Fetch invitations const { data, isLoading, refetch } = useInvitationList({ - tenant_id: tenantId || undefined, + tenant_id: tenantId === null ? undefined : tenantId, page: currentPage, page_size: pageSize, sort_by: "update_time", @@ -63,13 +85,15 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str // Trigger refetch when refreshKey changes useEffect(() => { - if (refreshKey && refreshKey > 0 && tenantId) { + if (refreshKey && refreshKey > 0 && tenantId !== null) { refetch(); } }, [refreshKey, tenantId, refetch]); // Fetch groups for group selection - const { data: groupData } = useGroupList(tenantId); // Get all groups for selection + const { data: groupData } = useGroupList( + isAssetOwnerInviteContext ? null : tenantId + ); const groups = groupData?.groups || []; const invitations = data?.items || []; @@ -78,27 +102,33 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str setEditingInvitation(null); form.resetFields(); - // Get default group for the tenant let defaultGroupIds: number[] = []; - if (tenantId) { - try { - const defaultGroupId = await getTenantDefaultGroupId(tenantId); - if (defaultGroupId) { - defaultGroupIds = [defaultGroupId]; + if (isAssetOwnerInviteContext) { + form.setFieldsValue({ + code_type: ASSET_OWNER_INVITE_CODE_TYPE, + capacity: 1, + group_ids: [], + }); + } else { + if (tenantId) { + try { + const defaultGroupId = await getTenantDefaultGroupId(tenantId); + if (defaultGroupId) { + defaultGroupIds = [defaultGroupId]; + } + } catch (error) { + console.warn("Failed to get default group:", error); + message.warning( + t("tenantResources.invitation.loadDefaultGroupFailed") + ); } - } catch (error) { - console.warn("Failed to get default group:", error); - // Show user-friendly message - message.warning(t("tenantResources.invitation.loadDefaultGroupFailed")); } - } else { - console.log("No tenantId available for getting default group"); + form.setFieldsValue({ + code_type: "USER_INVITE", + capacity: 1, + group_ids: defaultGroupIds, + }); } - form.setFieldsValue({ - code_type: "USER_INVITE", - capacity: 1, - group_ids: defaultGroupIds, - }); setModalVisible(true); }; @@ -109,7 +139,9 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str capacity: invitation.capacity, invitation_code: invitation.invitation_code, group_ids: invitation.group_ids || [], - expiry_date: invitation.expiry_date ? dayjs(invitation.expiry_date) : undefined, + expiry_date: invitation.expiry_date + ? dayjs(invitation.expiry_date) + : undefined, }); setModalVisible(true); }; @@ -121,12 +153,19 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str refetch(); } catch (error: any) { // Check if it's an authentication error - if (error.code === 401 || error.code === 499 || error.message?.includes("Login expired")) { + if ( + error.code === 401 || + error.code === 499 || + error.message?.includes("Login expired") + ) { // Let the global session expired handler deal with it throw error; } else { // For other errors, show specific error message - const errorMessage = error.response?.data?.message || error.message || "Failed to delete invitation"; + const errorMessage = + error.response?.data?.message || + error.message || + "Failed to delete invitation"; message.error(errorMessage); } } @@ -136,7 +175,7 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str try { const values = await form.validateFields(); - if (!tenantId) { + if (tenantId === null) { message.error(t("common.noTenantSelected")); return; } @@ -157,13 +196,18 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str await updateInvitation(editingInvitation.invitation_code, updateData); message.success(t("tenantResources.invitation.invitationUpdated")); } else { - // Create invitation + // Asset-owner page hides code_type in the form; always send ASSET_OWNER_INVITE on create. + const codeType = isAssetOwnerInviteContext + ? ASSET_OWNER_INVITE_CODE_TYPE + : values.code_type; const createData: CreateInvitationRequest = { - tenant_id: tenantId, - code_type: values.code_type, + tenant_id: isAssetOwnerInviteContext + ? ASSET_OWNER_TENANT_ID + : tenantId!, + code_type: codeType, invitation_code: values.invitation_code?.toUpperCase(), capacity: values.capacity, - group_ids: values.group_ids || [], + group_ids: isAssetOwnerInviteContext ? [] : values.group_ids || [], expiry_date: formattedExpiryDate, }; await createInvitation(createData); @@ -173,12 +217,17 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str refetch(); } catch (error: any) { // Check if it's an authentication error - if (error.code === 401 || error.code === 499 || error.message?.includes("Login expired")) { + if ( + error.code === 401 || + error.code === 499 || + error.message?.includes("Login expired") + ) { // Let the global session expired handler deal with it throw error; } else { // For other errors, show specific error message - const errorMessage = error.response?.data?.message || error.message || "Operation failed"; + const errorMessage = + error.response?.data?.message || error.message || "Operation failed"; message.error(errorMessage); } } @@ -196,7 +245,9 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str // Get group names for invitation const getGroupNames = (groupIds?: number[]) => { if (!groupIds || groupIds.length === 0) return []; - return groupIds.map((id) => groupNameMap.get(id) || `Group ${id}`).filter(Boolean); + return groupIds + .map((id) => groupNameMap.get(id) || `Group ${id}`) + .filter(Boolean); }; const columns: ColumnsType = useMemo( @@ -229,7 +280,11 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str key: "code_type", width: 80, render: (type: string) => { - return {t(`tenantResources.invitation.codeType.${type}`)}; + return ( + + {t(`tenantResources.invitation.codeType.${type}`)} + + ); }, }, { @@ -246,7 +301,9 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str type="dashboard" percent={percent} gapDegree={100} - format={() => t("tenantResources.invitation.remaining", { remaining })} + format={() => + t("tenantResources.invitation.remaining", { remaining }) + } size={20} strokeColor={remaining > 0 ? "#52c41a" : "#ff4d4f"} /> @@ -260,7 +317,13 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str key: "expiry_date", width: 120, render: (date: string) => - date ? formatDate(date) : {t("tenantResources.invitation.noExpiry")}, + date ? ( + formatDate(date) + ) : ( + + {t("tenantResources.invitation.noExpiry")} + + ), }, { title: t("tenantResources.invitation.groupNames"), @@ -273,16 +336,14 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str
{names.length > 0 ? ( names.map((name, index) => ( - + {name} )) ) : ( - {t("tenantResources.invitation.noGroups")} + + {t("tenantResources.invitation.noGroups")} + )}
); @@ -295,14 +356,24 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str width: 120, render: (status: string) => { const color = - status === "IN_USE" ? "#229954" : - status === "EXPIRE" ? "#AEB6BF" : - status === "RUN_OUT" ? "#E74C3C" : "#2E4053"; + status === "IN_USE" + ? "#229954" + : status === "EXPIRE" + ? "#AEB6BF" + : status === "RUN_OUT" + ? "#E74C3C" + : "#2E4053"; - const icon = status === "IN_USE" ? : - status === "EXPIRE" ? : - status === "RUN_OUT" ? : - ; + const icon = + status === "IN_USE" ? ( + + ) : status === "EXPIRE" ? ( + + ) : status === "RUN_OUT" ? ( + + ) : ( + + ); return ( handleDelete(record.invitation_code)} okText={t("common.confirm")} @@ -374,7 +447,11 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str
-
@@ -394,19 +471,28 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str ) : ( // Multi-tenant view with collapse - {Object.entries(groupedInvitations || {}).map(([tenantId, tenantInvitations]) => ( - - - - ))} + {Object.entries(groupedInvitations || {}).map( + ([tenantId, tenantInvitations]) => ( + +
+ + ) + )} )} @@ -427,18 +513,38 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str width={600} > - {!editingInvitation && ( + {!editingInvitation && !isAssetOwnerInviteContext && ( { - const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""); + const value = e.target.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, ""); form.setFieldsValue({ invitation_code: value }); }} /> @@ -485,41 +603,60 @@ export default function InvitationList({ tenantId, refreshKey }: { tenantId: str name="capacity" label={t("tenantResources.invitation.capacity")} rules={[ - { required: true, message: t("tenantResources.invitation.capacityRequired") }, + { + required: true, + message: t("tenantResources.invitation.capacityRequired"), + }, { validator: (_, value) => { if (!value) return Promise.resolve(); const numValue = Number(value); if (isNaN(numValue) || numValue < 1) { - return Promise.reject(new Error(t("tenantResources.invitation.capacityMin"))); + return Promise.reject( + new Error(t("tenantResources.invitation.capacityMin")) + ); } return Promise.resolve(); - } - } + }, + }, ]} > - - - - - - + {!isAssetOwnerInviteContext && ( + + @@ -368,8 +404,11 @@ function ParamsDynamicFields({ if (typeof sample === "string") { const inlineCommentTip = meta.get(pathToKey(namePath)); - const tooltip = - locked ? lockTip : inlineCommentTip ? { title: inlineCommentTip } : undefined; + const tooltip = locked + ? lockTip + : inlineCommentTip + ? { title: inlineCommentTip } + : undefined; return ( + ); @@ -413,13 +457,17 @@ function ParamsDynamicFields({ return (
{namePath.length > 0 && ( -
{label}
+
+ {label} +
)} {sample.map((item, i) => ( {namePath.length > 0 && ( -
{label}
+
+ {label} +
)}
{ try { - return await fetchSkillsList(); + return await fetchSkillsList(tenantId); } catch (e) { log.error("Failed to fetch skills list", e); throw e; @@ -545,7 +591,9 @@ export default function SkillList({ useEffect(() => { if (!paramsEditorState) return; try { - snapshotRef.current = JSON.parse(JSON.stringify(paramsEditorState.parsed)) as Record; + snapshotRef.current = JSON.parse( + JSON.stringify(paramsEditorState.parsed) + ) as Record; } catch { snapshotRef.current = paramsEditorState.parsed; } @@ -570,11 +618,24 @@ export default function SkillList({ try { await form.validateFields(); const values = form.getFieldsValue(true) as Record; - const restored = restorePrimitiveArraysFromForm(values, snapshotRef.current) as Record; - const withComments = applyStringComments(restored, metaRef.current) as Record; - const merged = deepMergePreserveUnderscore(snapshotRef.current, withComments) as Record; + const restored = restorePrimitiveArraysFromForm( + values, + snapshotRef.current + ) as Record; + const withComments = applyStringComments( + restored, + metaRef.current + ) as Record; + const merged = deepMergePreserveUnderscore( + snapshotRef.current, + withComments + ) as Record; - if (merged === null || typeof merged !== "object" || Array.isArray(merged)) { + if ( + merged === null || + typeof merged !== "object" || + Array.isArray(merged) + ) { message.error(t("tenantResources.skills.configModal.invalidJson")); return; } @@ -664,7 +725,9 @@ export default function SkillList({ }, ]; - const formKey = editingSkill ? `skill-params-${editingSkill.skill_id}` : "closed"; + const formKey = editingSkill + ? `skill-params-${editingSkill.skill_id}` + : "closed"; return (
@@ -704,7 +767,6 @@ export default function SkillList({ cancelText={t("common.cancel")} width={660} centered - destroyOnClose styles={{ body: { maxHeight: "70vh", overflowY: "auto" } }} > {t("tenantResources.skills.configModal.emptyParams")}

+ Object.keys(paramsEditorState.initialValues as object).length === + 0 && ( +

+ {t("tenantResources.skills.configModal.emptyParams")} +

)} {paramsEditorState && ( )} diff --git a/frontend/app/[locale]/tenant-resources/components/resources/UserList.tsx b/frontend/app/[locale]/tenant-resources/components/resources/UserList.tsx index 8e7438a5c..64f4e6760 100644 --- a/frontend/app/[locale]/tenant-resources/components/resources/UserList.tsx +++ b/frontend/app/[locale]/tenant-resources/components/resources/UserList.tsx @@ -152,12 +152,14 @@ export default function UserList({ tenantId, refreshKey }: { tenantId: string | ADMIN: t("user.role.admin"), DEV: t("user.role.dev"), USER: t("user.role.user"), + ASSET_OWNER: t("user.role.assetOwner"), }; const color = role === "SUPER_ADMIN" ? "magenta" : role === "ADMIN" ? "purple" : role === "DEV" ? "cyan" : - role === "USER" ? "blue" : "gray"; + role === "USER" ? "blue" : + role === "ASSET_OWNER" ? "gold" : "gray"; return {roleLabels[role] || role} ; diff --git a/frontend/app/[locale]/users/components/UserProfileComp.tsx b/frontend/app/[locale]/users/components/UserProfileComp.tsx index 50c7b91b7..67b34d250 100644 --- a/frontend/app/[locale]/users/components/UserProfileComp.tsx +++ b/frontend/app/[locale]/users/components/UserProfileComp.tsx @@ -59,8 +59,8 @@ import { ErrorCode } from "@/const/errorCode"; export default function UserProfileComp() { const { t } = useTranslation("common"); const { message: antdMessage } = App.useApp(); - const { logout, revoke, isLoading } = useAuthenticationContext() - const { user, groupIds } = useAuthorizationContext() + const { logout, revoke, isLoading } = useAuthenticationContext(); + const { user, groupIds } = useAuthorizationContext(); // Fetch groups for group name mapping const { data: groupData } = useGroupList(user?.tenantId || null); @@ -81,7 +81,8 @@ export default function UserProfileComp() { return groupIds.map((id) => ({ id, name: groupNameMap.get(id) || t("common.unknown"), - description: groups.find((g) => g.group_id === id)?.group_description || "", + description: + groups.find((g) => g.group_id === id)?.group_description || "", })); }, [groupIds, groupNameMap, groups, t]); @@ -104,7 +105,8 @@ export default function UserProfileComp() { const [passwordForm] = Form.useForm(); // Check if user is admin or super admin (cannot delete account) - const isAdminOrSuperAdmin = user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU; + const isAdminOrSuperAdmin = + user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU; const getRoleDisplayName = (role: string) => { switch (role) { case USER_ROLES.SPEED: @@ -175,9 +177,13 @@ export default function UserProfileComp() { const newToken = await createUserToken(); setAkInfo(newToken.access_key); setExistingTokenIds([newToken.token_id]); - antdMessage.success(t("profile.generateAkSkSuccess") || "Access key generated successfully"); + antdMessage.success( + t("profile.generateAkSkSuccess") || "Access key generated successfully" + ); } catch (error) { - antdMessage.error(t("profile.generateAkSkFailed") || "Failed to generate access key"); + antdMessage.error( + t("profile.generateAkSkFailed") || "Failed to generate access key" + ); } finally { setIsGeneratingAkSk(false); } @@ -188,9 +194,13 @@ export default function UserProfileComp() { if (akInfo) { try { await navigator.clipboard.writeText(akInfo); - antdMessage.success(t("profile.copyAkSuccess") || "Access key copied to clipboard"); + antdMessage.success( + t("profile.copyAkSuccess") || "Access key copied to clipboard" + ); } catch (error) { - antdMessage.error(t("profile.copyAkFailed") || "Failed to copy access key"); + antdMessage.error( + t("profile.copyAkFailed") || "Failed to copy access key" + ); } } }; @@ -285,9 +295,12 @@ export default function UserProfileComp() { {userGroupNames.length > 0 ? ( userGroupNames.map((group) => ( + key={group.id} + title={ + group.description || + t("tenantResources.groups.noDescription") + } + >
-
+
@@ -334,7 +345,8 @@ export default function UserProfileComp() { {t("profile.editProfile") || "Edit Profile"}
- {t("profile.editProfileDesc") || "Update your account information"} + {t("profile.editProfileDesc") || + "Update your account information"}
@@ -367,8 +379,12 @@ export default function UserProfileComp() { onClick={() => { if (akInfo) { Modal.confirm({ - title: t("profile.generateAkSkConfirmTitle") || "Generate New Access Key", - content: t("profile.generateAkSkConfirmContent") || "You already have an access key. Generating a new one will overwrite the existing key. Continue?", + title: + t("profile.generateAkSkConfirmTitle") || + "Generate New Access Key", + content: + t("profile.generateAkSkConfirmContent") || + "You already have an access key. Generating a new one will overwrite the existing key. Continue?", okText: t("common.confirm") || "Confirm", cancelText: t("common.cancel") || "Cancel", onOk: handleGenerateAkSk, @@ -409,8 +425,12 @@ export default function UserProfileComp() { onClick={(e) => { e.stopPropagation(); Modal.confirm({ - title: t("profile.deleteAkSkConfirmTitle") || "Delete Access Key", - content: t("profile.deleteAkSkConfirmContent") || "Are you sure you want to delete this access key? This action cannot be undone.", + title: + t("profile.deleteAkSkConfirmTitle") || + "Delete Access Key", + content: + t("profile.deleteAkSkConfirmContent") || + "Are you sure you want to delete this access key? This action cannot be undone.", okText: t("common.confirm") || "Confirm", cancelText: t("common.cancel") || "Cancel", okButtonProps: { danger: true }, @@ -421,9 +441,15 @@ export default function UserProfileComp() { } setAkInfo(null); setExistingTokenIds([]); - antdMessage.success(t("profile.deleteAkSkSuccess") || "Access key deleted successfully"); + antdMessage.success( + t("profile.deleteAkSkSuccess") || + "Access key deleted successfully" + ); } catch (error) { - antdMessage.error(t("profile.deleteAkSkFailed") || "Failed to delete access key"); + antdMessage.error( + t("profile.deleteAkSkFailed") || + "Failed to delete access key" + ); } }, }); @@ -433,7 +459,8 @@ export default function UserProfileComp() {
) : (
- {t("profile.generateAkSkDesc") || "Create or regenerate your API access key"} + {t("profile.generateAkSkDesc") || + "Create or regenerate your API access key"}
)}
@@ -454,7 +481,8 @@ export default function UserProfileComp() { {t("profile.deleteAccount") || "Delete Account"}
- {t("profile.deleteAccountDesc") || "Permanently delete your account"} + {t("profile.deleteAccountDesc") || + "Permanently delete your account"}
@@ -472,7 +500,9 @@ export default function UserProfileComp() { loading={isLoading} className="text-gray-500 hover:text-red-500" > - {t("auth.logout") || "Logout"} + + {t("auth.logout") || "Logout"} + @@ -499,7 +529,9 @@ export default function UserProfileComp() { form={editForm} layout="vertical" onFinish={(values) => { - antdMessage.success(t("profile.updateSuccess") || "Profile updated successfully"); + antdMessage.success( + t("profile.updateSuccess") || "Profile updated successfully" + ); setIsEditModalOpen(false); }} > @@ -507,12 +539,13 @@ export default function UserProfileComp() { name="displayName" label={t("profile.displayName") || "Display Name"} > - +
- + @@ -548,15 +581,23 @@ export default function UserProfileComp() { ); if (result.errorCode) { const errorMessages: Record = { - [ErrorCode.INVALID_CREDENTIALS]: t("profile.invalidOldPassword"), + [ErrorCode.INVALID_CREDENTIALS]: t( + "profile.invalidOldPassword" + ), [ErrorCode.PASSWORD_WEAK]: t("profile.passwordWeak"), - [ErrorCode.PASSWORD_SAME_AS_OLD]: t("profile.passwordSameAsOld"), + [ErrorCode.PASSWORD_SAME_AS_OLD]: t( + "profile.passwordSameAsOld" + ), }; - const translatedError = errorMessages[result.errorCode] || result.error; + const translatedError = + errorMessages[result.errorCode] || result.error; antdMessage.error(translatedError); return; } - antdMessage.success(t("profile.passwordUpdateSuccess") || "Password updated successfully"); + antdMessage.success( + t("profile.passwordUpdateSuccess") || + "Password updated successfully" + ); setIsPasswordModalOpen(false); passwordForm.resetFields(); }} @@ -575,42 +616,55 @@ export default function UserProfileComp() { { required: true, message: t("auth.passwordRequired") }, { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, - message: t("auth.passwordStrengthError") || "Password must contain uppercase, lowercase, and digit", + message: + t("auth.passwordStrengthError") || + "Password must contain uppercase, lowercase, and digit", }, ]} > setNewPasswordValue(e.target.value)} /> {/* Password Strength Indicator */} - {newPasswordValue && (() => { - const checks = getPasswordChecks(newPasswordValue); - const levelInfo = getStrengthLevel(newPasswordValue, t); - return ( -
-
- {t("auth.passwordStrength") || "Password strength"} - - {levelInfo.label} - -
-
- {[0, 1, 2, 3].map((level) => ( -
- ))} + {newPasswordValue && + (() => { + const checks = getPasswordChecks(newPasswordValue); + const levelInfo = getStrengthLevel(newPasswordValue, t); + return ( +
+
+ + {t("auth.passwordStrength") || "Password strength"} + + + {levelInfo.label} + +
+
+ {[0, 1, 2, 3].map((level) => ( +
+ ))} +
-
- ); - })()} + ); + })()} { + switch (role) { + case USER_ROLES.SPEED: + return t("auth.speed"); + case USER_ROLES.SU: + return t("auth.su"); + case USER_ROLES.ADMIN: + return t("auth.admin"); + case USER_ROLES.DEV: + return t("auth.dev"); + case USER_ROLES.USER: + return t("auth.user"); + case USER_ROLES.ASSET_OWNER: + return t("auth.assetOwner"); + default: + return t("auth.user"); + } + }; + // Show loading while authentication is in progress if (isLoading) { return ; @@ -96,7 +115,7 @@ export function AvatarDropdown() {
{user.email}
- {t(`auth.${(user.role).toLowerCase()}`)} + {getRoleDisplayName(user.role)}
diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index 77671114b..77b74fee1 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -60,6 +60,7 @@ const ROUTE_CONFIG: RouteConfig[] = [ { path: "/memory", Icon: Database, labelKey: "sidebar.memoryManagement", order: 10 }, { path: "/users", Icon: User, labelKey: "sidebar.userManagement", order: 11 }, { path: "/tenant-resources", Icon: Building2, labelKey: "sidebar.tenantResources", order: 12 }, + { path: "/asset-owner-resources", Icon: Building2, labelKey: "sidebar.assetOwnerResources", order: 13 }, ]; /** diff --git a/frontend/const/auth.ts b/frontend/const/auth.ts index 009604ea5..62924cf5a 100644 --- a/frontend/const/auth.ts +++ b/frontend/const/auth.ts @@ -5,8 +5,14 @@ export enum USER_ROLES { DEV = "DEV", USER = "USER", SPEED = "SPEED", + ASSET_OWNER = "ASSET_OWNER", } +export const ASSET_OWNER_INVITE_CODE_TYPE = "ASSET_OWNER_INVITE"; + +/** Virtual tenant ID for asset administrators (matches backend consts.const.ASSET_OWNER). */ +export const ASSET_OWNER_TENANT_ID = "asset_owner_tenant_id"; + export const STATUS_CODES = { SUCCESS: 200, @@ -41,7 +47,7 @@ export const AUTH_EVENTS = { LOGIN_SUCCESS: "auth:login-success", REGISTER_SUCCESS: "auth:register-success", LOGOUT: "auth:logout", - SESSION_EXPIRED: "auth:session-expired", // Deprecated: this is an authorization event; prefer AUTHZ_EVENTS.PERMISSION_DENIED. + SESSION_EXPIRED: "auth:session-expired", // Deprecated: this is an authorization event; prefer AUTHZ_EVENTS.PERMISSION_DENIED. TOKEN_REFRESHED: "auth:token-refreshed", SERVICE_UNAVAILABLE: "auth:service-unavailable", BACK_TO_HOME: "nav:back-to-home", diff --git a/frontend/const/modelConfig.ts b/frontend/const/modelConfig.ts index b7762ace0..c85b0b2c6 100644 --- a/frontend/const/modelConfig.ts +++ b/frontend/const/modelConfig.ts @@ -103,6 +103,7 @@ export const USER_ROLES = { ADMIN: "ADMIN", DEV: "DEV", USER: "USER", + ASSET_OWNER: "ASSET_OWNER", } as const; // Memory tab key constants diff --git a/frontend/hooks/agent/useAgentList.ts b/frontend/hooks/agent/useAgentList.ts index be0fed130..0ce51805d 100644 --- a/frontend/hooks/agent/useAgentList.ts +++ b/frontend/hooks/agent/useAgentList.ts @@ -1,22 +1,26 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchAgentList as fetchAgentListService } from "@/services/agentConfigService"; -import { useMemo, useEffect } from "react"; +import { useMemo } from "react"; import { Agent } from "@/types/agentConfig"; export function useAgentList(tenantId: string | null) { const queryClient = useQueryClient(); + // null = caller is waiting (e.g. tenant not selected); empty string = use auth tenant from backend + const queryEnabled = tenantId !== null; + const apiTenantId = + tenantId !== null && tenantId.trim() !== "" ? tenantId : undefined; const query = useQuery({ queryKey: ["agents", tenantId], queryFn: async () => { - const res = await fetchAgentListService(tenantId ?? undefined); + const res = await fetchAgentListService(apiTenantId); if (!res || !res.success) { throw new Error(res?.message || "Failed to fetch agents"); } return res.data || []; }, staleTime: 60_000, - enabled: !!tenantId, + enabled: queryEnabled, }); const agents = query.data ?? []; @@ -32,5 +36,3 @@ export function useAgentList(tenantId: string | null) { invalidate: () => queryClient.invalidateQueries({ queryKey: ["agents"] }), }; } - - diff --git a/frontend/hooks/invitation/useInvitationList.ts b/frontend/hooks/invitation/useInvitationList.ts index 382df2bee..fcb037ca7 100644 --- a/frontend/hooks/invitation/useInvitationList.ts +++ b/frontend/hooks/invitation/useInvitationList.ts @@ -6,7 +6,7 @@ export function useInvitationList(request: InvitationListRequest) { return useQuery({ queryKey: ["invitations", request.tenant_id, request.page, request.page_size, request.sort_by, request.sort_order], queryFn: () => listInvitations(request), - enabled: true, // Always enabled since tenant_id is optional + enabled: Boolean(request.tenant_id), staleTime: 1000 * 30, refetchOnMount: 'always', // Always refetch when component mounts (e.g., when switching tabs) }); diff --git a/frontend/hooks/useAgentImport.ts b/frontend/hooks/useAgentImport.ts index 44cfd12e5..81c808bdc 100644 --- a/frontend/hooks/useAgentImport.ts +++ b/frontend/hooks/useAgentImport.ts @@ -12,6 +12,9 @@ import { } from "@/lib/agentImportUtils"; import log from "@/lib/logger"; +// Re-export for consumers that import this type from the hook module. +export type { ImportAgentData }; + export interface UseAgentImportOptions { onSuccess?: () => void; onError?: (error: Error) => void; diff --git a/frontend/lib/agentListTenant.ts b/frontend/lib/agentListTenant.ts new file mode 100644 index 000000000..0cc692817 --- /dev/null +++ b/frontend/lib/agentListTenant.ts @@ -0,0 +1,30 @@ +import { USER_ROLES } from "@/const/auth"; + +type AgentListUser = { tenantId?: string; role?: string } | null | undefined; + +/** + * Resolve the tenant key passed to useAgentList. + * - null: caller is waiting (e.g. tenant picker not selected) + * - "": fetch /agent/list without tenant_id; backend resolves from auth cookies + * Asset owners always use auth resolution to avoid stale default tenant_id values. + */ +export function resolveAgentListTenantKey( + user: AgentListUser, + explicitTenantId?: string | null +): string | null { + if (explicitTenantId === null) { + return null; + } + if (user?.role === USER_ROLES.ASSET_OWNER) { + return ""; + } + const fromUser = user?.tenantId?.trim(); + if (fromUser) { + return fromUser; + } + const fromExplicit = explicitTenantId?.trim(); + if (fromExplicit) { + return fromExplicit; + } + return ""; +} diff --git a/frontend/lib/agentPromptVisibility.ts b/frontend/lib/agentPromptVisibility.ts new file mode 100644 index 000000000..852ff6b74 --- /dev/null +++ b/frontend/lib/agentPromptVisibility.ts @@ -0,0 +1,35 @@ +import type { TFunction } from "i18next"; + +/** Agent-like object that may include prompts_hidden from /agent/search_info. */ +export type AgentPromptVisibilitySource = { + prompts_hidden?: boolean; + duty_prompt?: string | null; + constraint_prompt?: string | null; + few_shots_prompt?: string | null; +}; + +export function isAgentPromptsHidden( + agent: AgentPromptVisibilitySource | null | undefined +): boolean { + return agent?.prompts_hidden === true; +} + +/** + * Render prompt field content for read-only views. + * When prompts are hidden, show a permission message instead of None/empty. + */ +export function renderAgentPromptFieldValue( + agent: AgentPromptVisibilitySource | null | undefined, + field: "duty_prompt" | "constraint_prompt" | "few_shots_prompt", + t: TFunction, + noneLabel?: string +): string { + if (isAgentPromptsHidden(agent)) { + return t("agent.prompts.noPermission", "You do not have permission to view prompts."); + } + const value = agent?.[field]; + if (value == null || value === "") { + return noneLabel ?? t("common.none", "None"); + } + return value; +} diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index 16d7b5d7f..7c6cafa11 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -25,6 +25,7 @@ const ROLE_COLORS: Record = { [USER_ROLES.DEV]: "cyan", [USER_ROLES.USER]: "geekblue", [USER_ROLES.SPEED]: "green", + [USER_ROLES.ASSET_OWNER]: "gold", }; /** @@ -89,4 +90,4 @@ export function getEffectiveRoutePath(pathname: string): string { segments.shift(); } return "/" + (segments.join("/") || ""); -} \ No newline at end of file +} diff --git a/frontend/lib/tenantScope.ts b/frontend/lib/tenantScope.ts new file mode 100644 index 000000000..3a4f45fa4 --- /dev/null +++ b/frontend/lib/tenantScope.ts @@ -0,0 +1,35 @@ +import { ASSET_OWNER_TENANT_ID, USER_ROLES } from "@/const/auth"; + +type UserTenantScope = { + tenantId?: string; + role?: string; +}; + +/** + * Resolve tenant id for /agent/list calls. + * Asset owners must rely on auth-header tenant resolution (never pass a stale/wrong query tenant). + */ +export function resolveAgentListTenantParam( + user?: UserTenantScope | null +): string | undefined { + if (!user) { + return undefined; + } + if (user.role === USER_ROLES.ASSET_OWNER) { + return undefined; + } + const trimmed = user.tenantId?.trim(); + if (!trimmed || trimmed === ASSET_OWNER_TENANT_ID) { + return undefined; + } + return trimmed; +} + +/** + * React Query key segment for agent list hooks on authenticated pages. + */ +export function resolveAgentListQueryTenantId( + user?: UserTenantScope | null +): string { + return resolveAgentListTenantParam(user) ?? ""; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index b008cf70b..3ae4fc6a5 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -353,6 +353,7 @@ "agent.error.fetchAgentListRetry": "Failed to get agent list, please try again later", "agent.debug.title": "Agent Debug", "agent.noEditPermission": "No permission to edit this agent", + "agent.prompts.noPermission": "You do not have permission to view prompts.", "mcpConfig.permission.noEdit": "No permission to edit MCP", "agent.action.create": "Create Agent", "agent.action.modify": "Edit Agent Information", @@ -1094,6 +1095,7 @@ "auth.su": "Super Admin", "auth.dev": "Developer", "auth.speed": "Default Role", + "auth.assetOwner": "Asset Owner", "auth.inviteCodeLabel": "Invite Code", "auth.inviteCodeRequired": "Invite code is required", "auth.inviteCodePlaceholder": "Please enter invite code", @@ -1563,6 +1565,7 @@ "sidebar.memoryManagement": "Memory Management", "sidebar.userManagement": "Profile", "sidebar.tenantResources": "Tenant Resources", + "sidebar.assetOwnerResources": "Asset Administrator Resources", "sidebar.mcpToolsManagement": "MCP Tools", "sidebar.monitoringManagement": "Monitoring & Ops", @@ -1570,6 +1573,10 @@ "tenantResources.subtitle": "Manage tenants, users, groups and resources", "tenantResources.title": "Tenant Resource Management", + "assetOwnerResources.subtitle": "Manage asset administrator users, models, knowledge bases, and resources", + "assetOwnerResources.tenantName": "Asset Administrator", + "assetOwnerResources.title": "Asset Administrator Resource Management", + "tenantResources.tabs.groups": "Groups", "tenantResources.tabs.knowledge": "Knowledge Base", "tenantResources.tabs.models": "Models", @@ -1777,6 +1784,8 @@ "tenantResources.invitation.codeType.ADMIN_INVITE": "Admin Invite", "tenantResources.invitation.codeType.DEV_INVITE": "Dev Invite", "tenantResources.invitation.codeType.USER_INVITE": "User Invite", + "tenantResources.invitation.codeType.ASSET_OWNER_INVITE": "Asset Owner Invite", + "tenantResources.invitation.assetOwnerTab": "Asset Owner Invitations", "tenantResources.invitation.status.IN_USE": "Available", "tenantResources.invitation.status.EXPIRE": "Expired", @@ -2300,6 +2309,7 @@ "user.role.admin": "Admin", "user.role.dev": "Developer", "user.role.user": "User", + "user.role.assetOwner": "Asset Owner", "profile.title": "Profile", "profile.subtitle": "Manage your account settings and preferences", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index b258edf20..2d72c6190 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -355,6 +355,7 @@ "agent.error.fetchAgentListRetry": "获取智能体列表失败,请稍后重试", "agent.debug.title": "智能体调试", "agent.noEditPermission": "无智能体编辑权限", + "agent.prompts.noPermission": "您无权查看提示词内容。", "mcpConfig.permission.noEdit": "无MCP编辑权限", "agent.action.create": "创建智能体", "agent.action.modify": "编辑智能体信息", @@ -1085,6 +1086,7 @@ "auth.su": "超级管理员", "auth.dev": "开发者", "auth.speed": "默认角色", + "auth.assetOwner": "资产管理员", "auth.inviteCodeLabel": "邀请码", "auth.inviteCodeRequired": "请输入邀请码", "auth.inviteCodePlaceholder": "请输入邀请码", @@ -1554,6 +1556,7 @@ "sidebar.memoryManagement": "记忆管理", "sidebar.userManagement": "个人信息", "sidebar.tenantResources": "租户资源", + "sidebar.assetOwnerResources": "资产管理员资源", "sidebar.mcpToolsManagement": "MCP 工具", "sidebar.monitoringManagement": "监控与运维", @@ -1561,6 +1564,10 @@ "tenantResources.subtitle": "管理租户、用户、用户组和资源", "tenantResources.title": "租户资源管理", + "assetOwnerResources.subtitle": "管理资产管理员的用户、模型、知识库和资源", + "assetOwnerResources.tenantName": "资产管理员", + "assetOwnerResources.title": "资产管理员资源管理", + "tenantResources.tabs.groups": "用户组", "tenantResources.tabs.knowledge": "知识库", "tenantResources.tabs.models": "模型", @@ -1768,6 +1775,8 @@ "tenantResources.invitation.codeType.ADMIN_INVITE": "管理员邀请", "tenantResources.invitation.codeType.DEV_INVITE": "开发者邀请", "tenantResources.invitation.codeType.USER_INVITE": "用户邀请", + "tenantResources.invitation.codeType.ASSET_OWNER_INVITE": "资产管理员邀请", + "tenantResources.invitation.assetOwnerTab": "资产管理员邀请码", "tenantResources.invitation.status.IN_USE": "可用", "tenantResources.invitation.status.EXPIRE": "已过期", @@ -2347,6 +2356,7 @@ "user.role.admin": "管理员", "user.role.dev": "开发者", "user.role.user": "普通用户", + "user.role.assetOwner": "资产管理员", "profile.title": "个人信息", "profile.subtitle": "管理您的账户设置和偏好", diff --git a/frontend/services/agentConfigService.ts b/frontend/services/agentConfigService.ts index bb983ac84..527c21ca8 100644 --- a/frontend/services/agentConfigService.ts +++ b/frontend/services/agentConfigService.ts @@ -113,8 +113,9 @@ export const fetchTools = async () => { */ export const fetchAgentList = async (tenantId?: string) => { try { - const url = tenantId - ? `${API_ENDPOINTS.agent.list}?tenant_id=${encodeURIComponent(tenantId)}` + const trimmedTenantId = tenantId?.trim(); + const url = trimmedTenantId + ? `${API_ENDPOINTS.agent.list}?tenant_id=${encodeURIComponent(trimmedTenantId)}` : API_ENDPOINTS.agent.list; const response = await fetch(url, { headers: getAuthHeaders(), @@ -493,7 +494,8 @@ export const exportAgent = async (agentId: number) => { if (contentType.includes("application/zip")) { const blob = await response.blob(); - const filename = response.headers.get("Content-Disposition") || `agent_${agentId}.zip`; + const filename = + response.headers.get("Content-Disposition") || `agent_${agentId}.zip`; downloadBlob(blob, filename.replace("attachment; filename=", "")); return { success: true, @@ -550,7 +552,10 @@ const downloadBlob = (blob: Blob, filename: string) => { */ export const importAgent = async ( agentInfo: any, - options?: { forceImport?: boolean; skillZips?: Array<{ skill_name: string; skill_zip_base64: string }> } + options?: { + forceImport?: boolean; + skillZips?: Array<{ skill_name: string; skill_zip_base64: string }>; + } ) => { try { const payload: any = { @@ -573,9 +578,11 @@ export const importAgent = async ( return { success: false, data: { detail: errMsg }, - message: errMsg?.type === "skill_duplicate" - ? "Skill name conflict detected" - : (errorData?.message ?? "Failed to import Agent, please try again later"), + message: + errMsg?.type === "skill_duplicate" + ? "Skill name conflict detected" + : (errorData?.message ?? + "Failed to import Agent, please try again later"), }; } const error = new Error(`Request failed: ${response.status}`); @@ -604,9 +611,10 @@ export const importAgent = async ( */ export const clearAgentNewMark = async (agentId: string | number) => { try { - const url = typeof API_ENDPOINTS.agent.clearNew === 'function' - ? API_ENDPOINTS.agent.clearNew(agentId) - : `${API_ENDPOINTS.agent.clearNew}/${agentId}`; + const url = + typeof API_ENDPOINTS.agent.clearNew === "function" + ? API_ENDPOINTS.agent.clearNew(agentId) + : `${API_ENDPOINTS.agent.clearNew}/${agentId}`; const response = await fetch(url, { method: "PUT", headers: getAuthHeaders(), @@ -709,7 +717,11 @@ export const regenerateAgentNameBatch = async (payload: { * @param versionNo optional version number (default 0 for current/draft version) * @returns agent detail info */ -export const searchAgentInfo = async (agentId: number, tenantId?: string, versionNo?: number) => { +export const searchAgentInfo = async ( + agentId: number, + tenantId?: string, + versionNo?: number +) => { try { const url = tenantId ? `${API_ENDPOINTS.agent.searchInfo}?tenant_id=${encodeURIComponent(tenantId)}` @@ -755,6 +767,7 @@ export const searchAgentInfo = async (agentId: number, tenantId?: string, versio group_ids: data.group_ids || [], ingroup_permission: data.ingroup_permission || "READ_ONLY", permission: data.permission, // Per-agent edit permission + prompts_hidden: data.prompts_hidden === true, tools: data.tools ? data.tools.map((tool: any) => { const params = @@ -784,7 +797,7 @@ export const searchAgentInfo = async (agentId: number, tenantId?: string, versio }) : [], skills: data.skills || [], - current_version_no: data.current_version_no + current_version_no: data.current_version_no, }; return { @@ -849,9 +862,12 @@ export const fetchAllAgents = async () => { */ export const fetchAgentCallRelationship = async (agentId: number) => { try { - const response = await fetch(`${API_ENDPOINTS.agent.callRelationship}/${agentId}`, { - headers: getAuthHeaders(), - }); + const response = await fetch( + `${API_ENDPOINTS.agent.callRelationship}/${agentId}`, + { + headers: getAuthHeaders(), + } + ); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); @@ -862,14 +878,14 @@ export const fetchAgentCallRelationship = async (agentId: number) => { return { success: true, data: data, - message: '' + message: "", }; } catch (error) { - log.error('Failed to fetch agent call relationship:', error); + log.error("Failed to fetch agent call relationship:", error); return { success: false, data: null, - message: 'agentConfig.agents.callRelationshipFetchFailed' + message: "agentConfig.agents.callRelationshipFetchFailed", }; } }; @@ -991,7 +1007,7 @@ export const validateTool = async ( * @param tenantId - Optional tenant ID. If not provided, fetches for the current user's tenant. * @returns list of skills with skill_id, name, description, source, etc. */ -export const fetchSkills = async (tenantId?: string) => { +export const fetchSkills = async (tenantId?: string | null) => { try { const url = tenantId ? `${API_ENDPOINTS.skills.list}?tenant_id=${encodeURIComponent(tenantId)}` @@ -1205,7 +1221,8 @@ export const createSkill = async (skillData: { return { success: false, data: null, - message: error instanceof Error ? error.message : "Failed to create skill", + message: + error instanceof Error ? error.message : "Failed to create skill", }; } }; @@ -1225,18 +1242,26 @@ export const updateSkill = async ( content?: string; config_values?: Record; files?: Array<{ path: string; content: string }>; - } + }, + tenantId?: string | null ) => { try { const requestBody: Record = {}; - if (skillData.description !== undefined) requestBody.description = skillData.description; + if (skillData.description !== undefined) + requestBody.description = skillData.description; if (skillData.source !== undefined) requestBody.source = skillData.source; - if (skillData.tags !== undefined) requestBody.tags = normalizeTags(skillData.tags); - if (skillData.content !== undefined) requestBody.content = skillData.content; - if (skillData.config_values !== undefined) requestBody.config_values = skillData.config_values; + if (skillData.tags !== undefined) + requestBody.tags = normalizeTags(skillData.tags); + if (skillData.content !== undefined) + requestBody.content = skillData.content; + if (skillData.config_values !== undefined) + requestBody.config_values = skillData.config_values; if (skillData.files !== undefined) requestBody.files = skillData.files; - const response = await fetch(API_ENDPOINTS.skills.update(skillName), { + const url = tenantId + ? `${API_ENDPOINTS.skills.update(skillName)}?tenant_id=${encodeURIComponent(tenantId)}` + : API_ENDPOINTS.skills.update(skillName); + const response = await fetch(url, { method: "PUT", headers: { ...getAuthHeaders(), @@ -1262,7 +1287,8 @@ export const updateSkill = async ( return { success: false, data: null, - message: error instanceof Error ? error.message : "Failed to update skill", + message: + error instanceof Error ? error.message : "Failed to update skill", }; } }; @@ -1286,9 +1312,10 @@ export const createSkillFromFile = async ( formData.append("skill_name", skillName); } - const endpoint = isUpdate && skillName - ? API_ENDPOINTS.skills.updateUpload(skillName) - : API_ENDPOINTS.skills.upload; + const endpoint = + isUpdate && skillName + ? API_ENDPOINTS.skills.updateUpload(skillName) + : API_ENDPOINTS.skills.upload; const method = isUpdate ? "PUT" : "POST"; @@ -1311,11 +1338,14 @@ export const createSkillFromFile = async ( // JSON parse failed } - const errorMessage = typeof errorData.detail === 'string' - ? errorData.detail - : Array.isArray(errorData.detail) - ? errorData.detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ') - : JSON.stringify(errorData.detail); + const errorMessage = + typeof errorData.detail === "string" + ? errorData.detail + : Array.isArray(errorData.detail) + ? errorData.detail + .map((e: any) => e.msg || JSON.stringify(e)) + .join("; ") + : JSON.stringify(errorData.detail); throw new Error(errorMessage || `Request failed: ${response.status}`); } @@ -1331,7 +1361,10 @@ export const createSkillFromFile = async ( return { success: false, data: null, - message: error instanceof Error ? error.message : "Failed to create skill from file", + message: + error instanceof Error + ? error.message + : "Failed to create skill from file", }; } }; @@ -1366,7 +1399,16 @@ export interface SkillFileNode { children?: SkillFileNode[]; } -export const fetchSkillFiles = async (skillName: string): Promise => { +export class SkillFilesAccessDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = "SkillFilesAccessDeniedError"; + } +} + +export const fetchSkillFiles = async ( + skillName: string +): Promise => { try { const response = await fetch(API_ENDPOINTS.skills.files(skillName), { headers: getAuthHeaders(), @@ -1375,6 +1417,9 @@ export const fetchSkillFiles = async (skillName: string): Promise => { @@ -1425,12 +1475,18 @@ export const getAgentByName = async (agentName: string): Promise<{ * @param filePath file path relative to skill directory * @returns file content */ -export const fetchSkillFileContent = async (skillName: string, filePath: string): Promise => { +export const fetchSkillFileContent = async ( + skillName: string, + filePath: string +): Promise => { try { const encodedPath = encodeURIComponent(filePath); - const response = await fetch(`${API_ENDPOINTS.skills.fileContent(skillName, encodedPath)}`, { - headers: getAuthHeaders(), - }); + const response = await fetch( + `${API_ENDPOINTS.skills.fileContent(skillName, encodedPath)}`, + { + headers: getAuthHeaders(), + } + ); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } @@ -1448,13 +1504,19 @@ export const fetchSkillFileContent = async (skillName: string, filePath: string) * @param filePath file path relative to skill directory * @returns delete result */ -export const deleteSkillTempFile = async (skillName: string, filePath: string): Promise => { +export const deleteSkillTempFile = async ( + skillName: string, + filePath: string +): Promise => { try { const encodedPath = encodeURIComponent(filePath); - const response = await fetch(`${API_ENDPOINTS.skills.deleteFile(skillName, encodedPath)}`, { - method: "DELETE", - headers: getAuthHeaders(), - }); + const response = await fetch( + `${API_ENDPOINTS.skills.deleteFile(skillName, encodedPath)}`, + { + method: "DELETE", + headers: getAuthHeaders(), + } + ); if (!response.ok) { log.warn(`Failed to delete skill temp file: ${response.status}`); return false; @@ -1476,7 +1538,9 @@ export const deleteSkillTempFile = async (skillName: string, filePath: string): * @param skillName The skill name * @returns Parsed config object with temp_filename and progress info */ -export const fetchSkillConfig = async (skillName: string): Promise | null> => { +export const fetchSkillConfig = async ( + skillName: string +): Promise | null> => { try { const response = await fetch( `${API_ENDPOINTS.skills.fileContent(skillName, "config.yaml")}`, @@ -1524,7 +1588,8 @@ export const deleteSkill = async (skillName: string) => { log.error("Error deleting skill:", error); return { success: false, - message: error instanceof Error ? error.message : "Failed to delete skill", + message: + error instanceof Error ? error.message : "Failed to delete skill", }; } }; diff --git a/frontend/services/authService.ts b/frontend/services/authService.ts index 322d810f8..132e70809 100644 --- a/frontend/services/authService.ts +++ b/frontend/services/authService.ts @@ -10,7 +10,7 @@ import { API_ENDPOINTS } from "@/services/api"; import { sessionService } from "@/services/sessionService"; import { Session, SessionResponse, AuthInfoResponse } from "@/types/auth"; -import { STATUS_CODES } from "@/const/auth"; +import { ASSET_OWNER_TENANT_ID, STATUS_CODES, USER_ROLES } from "@/const/auth"; import { generateAvatarUrl } from "@/lib/auth"; import { fetchWithAuth } from "@/lib/auth"; @@ -25,6 +25,19 @@ import log from "@/lib/logger"; import { ErrorCode } from "@/const/errorCode"; import { getI18nErrorMessage } from "@/const/errorMessageI18n"; +/** Map legacy empty tenant_id to the asset-owner virtual tenant for API consumers. */ +function resolveTenantIdForClient( + tenantId?: string | null, + userRole?: string +): string | undefined { + if (tenantId && tenantId.trim() !== "") { + return tenantId.trim(); + } + if (userRole === USER_ROLES.ASSET_OWNER) { + return ASSET_OWNER_TENANT_ID; + } + return undefined; +} export const authService = { getSession: async (): Promise => { @@ -311,7 +324,10 @@ export const authService = { user: { id: data.data.user.user_id, groupIds: data.data.user.group_ids, - tenantId: data.data.user.tenant_id, + tenantId: resolveTenantIdForClient( + data.data.user.tenant_id, + data.data.user.user_role + ), email: data.data.user.user_email, role: data.data.user.user_role, avatarUrl: data.data.user.avatarUrl, diff --git a/frontend/services/skillService.ts b/frontend/services/skillService.ts index 85af14afc..cee4c27c7 100644 --- a/frontend/services/skillService.ts +++ b/frontend/services/skillService.ts @@ -156,7 +156,7 @@ export const processSkillStream = async ( * Maps API payload to {@link SkillListItem} including config_schemas for config editing. * @param tenantId - Optional tenant ID for super admin to query a specific tenant's skills. */ -export async function fetchSkillsList(tenantId?: string): Promise { +export async function fetchSkillsList(tenantId?: string | null): Promise { const res = await fetchSkills(tenantId); if (!res.success) { throw new Error(res.message || "Failed to fetch skills"); diff --git a/frontend/stores/agentConfigStore.ts b/frontend/stores/agentConfigStore.ts index e42bca5b2..f6261b637 100644 --- a/frontend/stores/agentConfigStore.ts +++ b/frontend/stores/agentConfigStore.ts @@ -44,6 +44,7 @@ export type EditableAgent = Pick< > & { skills: Skill[]; external_sub_agent_id_list?: number[]; + prompts_hidden?: boolean; }; interface AgentConfigStoreState { @@ -200,6 +201,7 @@ const toEditable = (agent: Agent | null): EditableAgent => external_sub_agent_id_list: agent.external_sub_agent_id_list || [], group_ids: agent.group_ids || [], ingroup_permission: agent.ingroup_permission || "READ_ONLY", + prompts_hidden: agent.prompts_hidden, } : { ...emptyEditableAgent }; diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts index 376703076..729738db7 100644 --- a/frontend/types/agentConfig.ts +++ b/frontend/types/agentConfig.ts @@ -63,6 +63,8 @@ export interface Agent { * EDIT: editable, READ_ONLY: read-only. */ permission?: "EDIT" | "READ_ONLY"; + /** When true, system prompts were withheld (ASSET_OWNER agent viewed by non-ASSET_OWNER caller). */ + prompts_hidden?: boolean; current_version_no?: number; is_a2a_server?: boolean; } diff --git a/frontend/types/market.ts b/frontend/types/market.ts index 2663da990..cc4bb9684 100644 --- a/frontend/types/market.ts +++ b/frontend/types/market.ts @@ -64,6 +64,7 @@ export interface MarketAgentDetail extends MarketAgentListItem { duty_prompt: string; constraint_prompt: string; few_shots_prompt: string; + prompts_hidden?: boolean; enabled: boolean; model_id: number; model_name: string; diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index b60856c94..c33732396 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1101,7 +1101,40 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), (185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), (186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'); +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), +(188, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'CREATE'), +(189, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'READ'), +(190, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'UPDATE'), +(191, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'DELETE'), +(192, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), +(193, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), +(194, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), +(195, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), +(196, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), +(197, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), +(198, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), +(220, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), +(199, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'CREATE'), +(200, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'READ'), +(201, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'UPDATE'), +(202, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'DELETE'), +(203, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'CREATE'), +(204, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'READ'), +(205, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'UPDATE'), +(206, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'DELETE'), +(207, 'ASSET_OWNER', 'RESOURCE', 'KB', 'CREATE'), +(208, 'ASSET_OWNER', 'RESOURCE', 'KB', 'READ'), +(209, 'ASSET_OWNER', 'RESOURCE', 'KB', 'UPDATE'), +(210, 'ASSET_OWNER', 'RESOURCE', 'KB', 'DELETE'), +(211, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'CREATE'), +(212, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'READ'), +(213, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'UPDATE'), +(214, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'DELETE'), +(215, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'CREATE'), +(216, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'READ'), +(217, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'UPDATE'), +(218, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'DELETE'), +(219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'); -- Insert SPEED role user into user_tenant_t table if not exists INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) From a005f8ec500cf1220534715bb719d3eec9f0f21e Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Thu, 28 May 2026 14:02:22 +0800 Subject: [PATCH 2/6] feat(asset-owner): add invitation & user management support - Add tenant_id migration and asset owner permissions/menu SQL - Expose northbound knowledge/vector database updates for asset owner visibility - Add backend auth/utils and invitation/agent/user management services - Update invitation list UI --- backend/apps/northbound_knowledge_app.py | 146 ++++++++---------- backend/apps/vectordatabase_app.py | 55 ++++--- backend/database/agent_db.py | 9 +- backend/database/agent_version_db.py | 19 ++- backend/pyproject.toml | 1 + backend/services/agent_service.py | 5 +- backend/services/asset_owner_visibility.py | 10 -- backend/services/invitation_service.py | 4 +- backend/services/user_management_service.py | 2 - backend/utils/auth_utils.py | 75 +++++---- .../components/resources/InvitationList.tsx | 29 ++-- .../charts/nexent-common/files/init.sql | 35 +---- test/backend/app/test_agent_app.py | 8 +- test/backend/app/test_file_management_app.py | 56 +++++-- test/backend/app/test_northbound_base_app.py | 43 ++++++ test/backend/app/test_skill_app.py | 5 + test/backend/app/test_vectordatabase_app.py | 32 ++-- test/backend/services/test_agent_service.py | 26 +++- .../services/test_invitation_service.py | 77 ++++++++- .../services/test_user_management_service.py | 34 +++- .../services/test_vectordatabase_service.py | 90 +++++++---- 21 files changed, 469 insertions(+), 292 deletions(-) diff --git a/backend/apps/northbound_knowledge_app.py b/backend/apps/northbound_knowledge_app.py index 6999315ba..775d6c567 100644 --- a/backend/apps/northbound_knowledge_app.py +++ b/backend/apps/northbound_knowledge_app.py @@ -1,7 +1,7 @@ import base64 import logging from http import HTTPStatus -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Annotated from fastapi import APIRouter, Body, File, Form, Path, Path as PathParam, Query, Request, HTTPException, UploadFile from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse @@ -34,6 +34,8 @@ __all__ = ["router"] +RATE_LIMIT_EXCEEDED_DETAIL = "Too Many Requests: rate limit exceeded" + async def _require_asset_owner_context(request: Request) -> NorthboundContext: """Resolve northbound context and ensure the caller belongs to the asset-owner tenant.""" @@ -49,7 +51,7 @@ async def _require_asset_owner_context(request: Request) -> NorthboundContext: @router.get("/indices") async def get_list_indices( request: Request, - pattern: str = Query("*", description="Pattern to match index names"), + pattern: Annotated[str, Query(description="Pattern to match index names")] = "*", ): """List knowledge bases visible to the asset-owner tenant. @@ -62,32 +64,38 @@ async def get_list_indices( pattern, True, ctx.tenant_id, ctx.user_id, vdb_core ) except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while listing knowledge bases") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error(f"Error listing knowledge bases: {str(e)}", exc_info=True) + except Exception: + logger.exception("Error listing knowledge bases") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error listing knowledge bases: {str(e)}") + detail="Error listing knowledge bases") @router.post("/indices/{index_name}") async def create_new_index( request: Request, - index_name: str = Path(..., description="Name of the index to create"), - embedding_dim: Optional[int] = Query( - None, description="Dimension of the embedding vectors"), - body: Optional[Dict[str, Any]] = Body( - None, - description="Request body with optional fields (ingroup_permission, group_ids, embedding_model_name)"), + index_name: Annotated[str, Path(..., description="Name of the index to create")], + embedding_dim: Annotated[ + Optional[int], + Query(description="Dimension of the embedding vectors"), + ] = None, + body: Annotated[ + Optional[Dict[str, Any]], + Body( + description=( + "Request body with optional fields (ingroup_permission, group_ids, embedding_model_name)" + ), + ), + ] = None, ): """Create a new vector index and store it in the knowledge table. @@ -118,34 +126,32 @@ async def create_new_index( embedding_model_name=embedding_model_name, ) except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while creating index") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error( - f"Error creating index '{index_name}': {str(e)}", exc_info=True) + except Exception: + logger.exception("Error creating index") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error creating index: {str(e)}") + detail="Error creating index") @router.delete("/indices/{index_name}") async def delete_index( request: Request, - index_name: str = Path(..., description="Name of the index to delete"), + index_name: Annotated[str, Path(..., description="Name of the index to delete")], ): """Delete a knowledge base and all related data. Restricted to asset administrators (same auth as create_new_index). """ - logger.debug(f"Received northbound request to delete knowledge base: {index_name}") + logger.debug("Received northbound request to delete knowledge base") try: ctx = await _require_asset_owner_context(request) vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) @@ -153,28 +159,26 @@ async def delete_index( index_name, vdb_core, ctx.user_id ) except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while deleting index") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error( - f"Error deleting index '{index_name}': {str(e)}", exc_info=True) + except Exception: + logger.exception("Error deleting index") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error deleting index: {str(e)}") + detail="Error deleting index") @router.get("/indices/{index_name}/files") async def get_index_files( request: Request, - index_name: str = Path(..., description="Name of the index"), + index_name: Annotated[str, Path(..., description="Name of the index")], ): """Get all files from an index, including those that are not yet stored in ES. @@ -197,32 +201,27 @@ async def get_index_files( "files": result.get("files", []), } except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while listing files") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error( - f"Error getting files for index '{index_name}': {str(e)}", - exc_info=True, - ) + except Exception: + logger.exception("Error getting files for index") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error getting index files: {str(e)}") + detail="Error getting index files") @router.delete("/indices/{index_name}/documents") async def delete_documents( request: Request, - index_name: str = Path(..., description="Name of the index"), - path_or_url: str = Query( - ..., description="Path or URL of documents to delete"), + index_name: Annotated[str, Path(..., description="Name of the index")], + path_or_url: Annotated[str, Query(..., description="Path or URL of documents to delete")], ): """Delete documents by path or URL and clean up related Redis records. @@ -231,13 +230,7 @@ async def delete_documents( try: ctx = await _require_asset_owner_context(request) vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH) - logger.debug( - "Deleting documents for index %s, path_or_url=%s, tenant_id=%s, user_id=%s", - index_name, - path_or_url, - ctx.tenant_id, - ctx.user_id, - ) + logger.debug("Deleting documents for index %s", index_name) result = ElasticSearchService.delete_documents( index_name, path_or_url, vdb_core) @@ -262,10 +255,9 @@ async def delete_documents( except Exception as redis_error: logger.warning( - "Redis cleanup failed for document %s in index %s: %s", - path_or_url, + "Redis cleanup failed for index %s: %s", index_name, - str(redis_error), + redis_error, ) result["redis_cleanup_error"] = str(redis_error) original_message = result.get( @@ -277,30 +269,26 @@ async def delete_documents( return result except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while deleting documents") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error( - f"Error deleting documents for index '{index_name}': {str(e)}", - exc_info=True, - ) + except Exception: + logger.exception("Error deleting documents for index") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error deleting documents: {str(e)}") + detail="Error deleting documents") @router.post("/file/upload") async def upload_files( request: Request, - file: List[UploadFile] = File(..., alias="file"), + file: Annotated[List[UploadFile], File(..., alias="file")], index_name: str = Form(..., description="Knowledge base index"), ): """Upload files to MinIO and trigger knowledge base data processing. @@ -365,18 +353,17 @@ async def upload_files( detail="No valid files uploaded", ) except LimitExceededError as e: - logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + logger.exception("Rate limit exceeded while uploading files") raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error(f"File upload error: {str(e)}", exc_info=True) + except Exception: + logger.exception("File upload error") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="File upload error.") @@ -409,8 +396,7 @@ async def get_storage_file( if not check_file_access(object_name, ctx.user_id, ctx.tenant_id): logger.warning( - "[get_storage_file] Access denied: object_name=%s, user_id=%s", - object_name, + "[get_storage_file] Access denied: user_id=%s", ctx.user_id, ) raise HTTPException( @@ -419,10 +405,8 @@ async def get_storage_file( ) logger.info( - "[get_storage_file] object_name=%s, download=%s, filename=%s", - object_name, + "[get_storage_file] download=%s", download, - filename, ) if download == "redirect": result = await get_file_url_impl( @@ -484,21 +468,21 @@ async def get_storage_file( object_name=object_name, expires=expires) except LimitExceededError as e: logger.error( - f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) + "%s: %s", + RATE_LIMIT_EXCEEDED_DETAIL, + str(e), + exc_info=e, + ) raise HTTPException( status_code=HTTPStatus.TOO_MANY_REQUESTS, - detail="Too Many Requests: rate limit exceeded") + detail=RATE_LIMIT_EXCEEDED_DETAIL) except UnauthorizedError as e: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except HTTPException: raise - except Exception as e: - logger.error( - f"Failed to get file: object_name={object_name}, error={str(e)}", - exc_info=True, - ) + except Exception: + logger.exception("Failed to get file") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to get file.") - diff --git a/backend/apps/vectordatabase_app.py b/backend/apps/vectordatabase_app.py index f25af418a..118537766 100644 --- a/backend/apps/vectordatabase_app.py +++ b/backend/apps/vectordatabase_app.py @@ -22,13 +22,15 @@ from services.redis_service import get_redis_service from utils.auth_utils import get_current_user_id from utils.file_management_utils import get_all_files_status -from database.knowledge_db import get_knowledge_record +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 router = APIRouter(prefix="/indices") service = ElasticSearchService() logger = logging.getLogger("vectordatabase_app") +INTERNAL_INDEX_NAME_DESC = "Internal index_name from knowledge_record_t" + @router.get("/summary_frequency_options") async def get_summary_frequency_options(): @@ -44,6 +46,7 @@ async def get_summary_frequency_options(): } ) + @router.post("/check_exist") async def check_knowledge_base_exist( request: Dict[str, str] = Body( @@ -211,7 +214,8 @@ async def update_summary_frequency_endpoint( if success: return JSONResponse( status_code=HTTPStatus.OK, - content={"message": "Summary frequency updated successfully", "status": "success"} + content={ + "message": "Summary frequency updated successfully", "status": "success"} ) else: raise HTTPException( @@ -307,7 +311,8 @@ def get_embedding_model_status( except HTTPException: raise except Exception as e: - logger.error(f"Error getting embedding model status for '{index_name}': {e}", exc_info=True) + logger.error( + f"Error getting embedding model status for '{index_name}': {e}", exc_info=True) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error checking embedding model status: {str(e)}" @@ -316,7 +321,8 @@ def get_embedding_model_status( @router.put("/{index_name}/embedding-model") def update_embedding_model( - index_name: str = Path(..., description="Internal index name of the knowledge base to update"), + index_name: str = Path( + ..., description="Internal index name of the knowledge base to update"), request: Dict[str, Any] = Body(..., description="Update payload with model_id"), authorization: Optional[str] = Header(None) @@ -356,7 +362,8 @@ def update_embedding_model( except HTTPException: raise except Exception as exc: - logger.error(f"Error updating embedding model for '{index_name}': {exc}", exc_info=True) + logger.error( + f"Error updating embedding model for '{index_name}': {exc}", exc_info=True) raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error updating embedding model: {str(exc)}" @@ -380,14 +387,16 @@ def _merge_list_indices_results( asset_owner: Dict[str, Any], ) -> Dict[str, Any]: """Merge tenant and ASSET_OWNER list_indices responses (concat, no dedup).""" - merged_indices = primary.get("indices", []) + asset_owner.get("indices", []) + merged_indices = primary.get("indices", []) + \ + asset_owner.get("indices", []) merged: Dict[str, Any] = { "indices": merged_indices, "count": len(merged_indices), } if "indices_info" in primary or "indices_info" in asset_owner: merged["indices_info"] = ( - primary.get("indices_info", []) + asset_owner.get("indices_info", []) + primary.get("indices_info", []) + + asset_owner.get("indices_info", []) ) return merged @@ -413,7 +422,8 @@ def get_list_indices( asset_result = ElasticSearchService.list_indices( pattern, include_stats, ASSET_OWNER_TENANT_ID, user_id, vdb_core ) - asset_result = _apply_read_only_to_asset_indices_info(asset_result) + asset_result = _apply_read_only_to_asset_indices_info( + asset_result) return _merge_list_indices_results(result, asset_result) return result return ElasticSearchService.list_indices( @@ -448,10 +458,12 @@ def create_index_documents( knowledge_record = get_knowledge_record({'index_name': index_name}) saved_embedding_model_id = None if knowledge_record: - saved_embedding_model_id = knowledge_record.get('embedding_model_id') + saved_embedding_model_id = knowledge_record.get( + 'embedding_model_id') # Use the saved model from knowledge base by model_id - embedding_model, _ = get_embedding_model_by_id(tenant_id, saved_embedding_model_id) if saved_embedding_model_id else (None, None) + embedding_model, _ = get_embedding_model_by_id( + tenant_id, saved_embedding_model_id) if saved_embedding_model_id else (None, None) return ElasticSearchService.index_documents( embedding_model=embedding_model, @@ -611,13 +623,14 @@ def health_check(vdb_core: VectorDatabaseCore = Depends(get_vector_db_core)): # Try to list indices as a health check return ElasticSearchService.health_check(vdb_core) except Exception as e: - raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{str(e)}") @router.post("/{index_name}/chunks") def get_index_chunks( index_name: str = Path(..., - description="Internal index_name from knowledge_record_t"), + description=INTERNAL_INDEX_NAME_DESC), page: int = Query( None, description="Page number (1-based) for pagination"), page_size: int = Query( @@ -634,11 +647,6 @@ def get_index_chunks( if path_or_url is not None and not check_file_access( path_or_url, user_id, tenant_id ): - logger.warning( - "[get_index_chunks] Access denied: path_or_url=%s, user_id=%s", - path_or_url, - user_id, - ) raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="You don't have permission to access this file", @@ -659,8 +667,6 @@ def get_index_chunks( ) except Exception as e: error_msg = str(e) - logger.error( - f"Error getting chunks for index '{index_name}': {error_msg}") raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error getting chunks: {error_msg}") @@ -668,7 +674,7 @@ def get_index_chunks( @router.post("/{index_name}/chunk") def create_chunk( index_name: str = Path(..., - description="Internal index_name from knowledge_record_t"), + description=INTERNAL_INDEX_NAME_DESC), payload: ChunkCreateRequest = Body(..., description="Chunk data"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None), @@ -701,7 +707,7 @@ def create_chunk( @router.put("/{index_name}/chunk/{chunk_id}") def update_chunk( index_name: str = Path(..., - description="Internal index_name from knowledge_record_t"), + description=INTERNAL_INDEX_NAME_DESC), chunk_id: str = Path(..., description="Chunk identifier"), payload: ChunkUpdateRequest = Body(..., description="Chunk update payload"), @@ -710,7 +716,7 @@ def update_chunk( ): """Update an existing chunk.""" try: - user_id, _ = get_current_user_id(authorization) + user_id, tenant_id = get_current_user_id(authorization) result = ElasticSearchService.update_chunk( index_name=index_name, chunk_id=chunk_id, @@ -741,7 +747,7 @@ def update_chunk( @router.delete("/{index_name}/chunk/{chunk_id}") def delete_chunk( index_name: str = Path(..., - description="Internal index_name from knowledge_record_t"), + description=INTERNAL_INDEX_NAME_DESC), chunk_id: str = Path(..., description="Chunk identifier"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None), @@ -812,7 +818,8 @@ async def hybrid_search( } ) except ValueError as exc: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) except Exception as exc: logger.error(f"Hybrid search failed: {exc}", exc_info=True) raise HTTPException( diff --git a/backend/database/agent_db.py b/backend/database/agent_db.py index f1273a417..5d41d1e7a 100644 --- a/backend/database/agent_db.py +++ b/backend/database/agent_db.py @@ -1,9 +1,10 @@ import logging from typing import List -from sqlalchemy import update +from sqlalchemy import or_, update from database.client import get_db_session, as_dict, filter_property from database.db_models import AgentInfo, ToolInstance, AgentRelation +from consts.const import ASSET_OWNER_TENANT_ID from utils.str_utils import convert_list_to_string logger = logging.getLogger("agent_db") @@ -23,7 +24,11 @@ def search_agent_info_by_agent_id(agent_id: int, tenant_id: str, version_no: int agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, AgentInfo.version_no == version_no, - AgentInfo.delete_flag != 'Y' + or_( + AgentInfo.tenant_id == tenant_id, + AgentInfo.tenant_id == ASSET_OWNER_TENANT_ID, + ), + AgentInfo.delete_flag != 'Y', ).first() if not agent: diff --git a/backend/database/agent_version_db.py b/backend/database/agent_version_db.py index 7a12aa80d..c895cb249 100644 --- a/backend/database/agent_version_db.py +++ b/backend/database/agent_version_db.py @@ -1,9 +1,10 @@ import logging from typing import List, Optional, Tuple -from sqlalchemy import select, insert, update, delete, func +from sqlalchemy import or_, select, insert, update, delete, func from database.client import get_db_session, as_dict from database.db_models import AgentInfo, ToolInstance, AgentRelation, AgentVersion, SkillInstance +from consts.const import ASSET_OWNER_TENANT_ID logger = logging.getLogger("agent_version_db") @@ -76,7 +77,10 @@ def query_current_version_no( with get_db_session() as session: agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, - AgentInfo.tenant_id == tenant_id, + or_( + AgentInfo.tenant_id == tenant_id, + AgentInfo.tenant_id == ASSET_OWNER_TENANT_ID, + ), AgentInfo.version_no == 0, AgentInfo.delete_flag == 'N', ).first() @@ -95,14 +99,21 @@ def query_agent_snapshot( # Query agent info snapshot agent = session.query(AgentInfo).filter( AgentInfo.agent_id == agent_id, + or_( + AgentInfo.tenant_id == tenant_id, + AgentInfo.tenant_id == ASSET_OWNER_TENANT_ID, + ), AgentInfo.version_no == version_no, AgentInfo.delete_flag == 'N', ).first() + if agent is not None: + tenant_id = agent.tenant_id + # Query tool instances snapshot tools = session.query(ToolInstance).filter( ToolInstance.agent_id == agent_id, - ToolInstance.tenant_id == agent.tenant_id, + ToolInstance.tenant_id == tenant_id, ToolInstance.version_no == version_no, ToolInstance.delete_flag == 'N', ).all() @@ -110,7 +121,7 @@ def query_agent_snapshot( # Query relations snapshot relations = session.query(AgentRelation).filter( AgentRelation.parent_agent_id == agent_id, - AgentRelation.tenant_id == agent.tenant_id, + AgentRelation.tenant_id == tenant_id, AgentRelation.version_no == version_no, AgentRelation.delete_flag == 'N', ).all() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 12b2ebebd..dff0e8693 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,7 @@ dependencies = [ "aiofiles>=0.8.0", "uvicorn>=0.34.0", "fastapi>=0.115.12", + "python-multipart>=0.0.9", "email-validator>=2.0.0", "aiohttp>=3.8.0", "authlib>=1.3.0", diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index a5dd09914..1db8bcd09 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -764,7 +764,10 @@ async def get_agent_info_impl(agent_id: int, tenant_id: str, version_no: int = 0 try: agent_info = search_agent_info_by_agent_id( agent_id, tenant_id, version_no) - tenant_id = agent_info.get("tenant_id") + # Keep the request-scoped tenant_id unless the record explicitly provides one. + record_tenant_id = agent_info.get("tenant_id") + if record_tenant_id: + tenant_id = record_tenant_id except Exception as e: logger.error(f"Failed to get agent info: {str(e)}") raise ValueError(f"Failed to get agent info: {str(e)}") diff --git a/backend/services/asset_owner_visibility.py b/backend/services/asset_owner_visibility.py index eda2d2100..24cb697b2 100644 --- a/backend/services/asset_owner_visibility.py +++ b/backend/services/asset_owner_visibility.py @@ -94,16 +94,6 @@ def apply_agent_detail_prompt_visibility( return result -def postprocess_agent_visibility( - items: List[Dict[str, Any]], - caller_role: Optional[str], - caller_tenant_id: Optional[str], -) -> List[Dict[str, Any]]: - """Return agent records after visibility post-processing (no-op for now).""" - _ = (caller_role, caller_tenant_id) - return items - - def postprocess_knowledge_visibility( items: List[Dict[str, Any]], caller_role: Optional[str], diff --git a/backend/services/invitation_service.py b/backend/services/invitation_service.py index 316df484c..2ff4a7707 100644 --- a/backend/services/invitation_service.py +++ b/backend/services/invitation_service.py @@ -240,10 +240,10 @@ def delete_invitation_code(invitation_id: int, user_id: str) -> bool: code_type = invitation_info.get("code_type") if code_type == ASSET_OWNER_INVITE_CODE_TYPE and user_role not in ["SU"]: raise UnauthorizedError( - f"User role {user_role} not authorized to update invitation codes") + f"User role {user_role} not authorized to delete invitation codes") elif user_role not in ["SU", "ADMIN"]: raise UnauthorizedError( - f"User role {user_role} not authorized to update invitation codes") + f"User role {user_role} not authorized to delete invitation codes") # Delete invitation code success = remove_invitation( diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index ab3a9288e..730113e83 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -196,8 +196,6 @@ async def signup_user_with_invitation(email: EmailStr, except IncorrectInviteCodeException: raise - except ValidationError: - raise except Exception as e: logging.error( f"Invitation code {invite_code} validation failed: {str(e)}") diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index 15ceef050..04e81e6e3 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -3,15 +3,15 @@ import hmac import hashlib from datetime import datetime, timedelta -from typing import Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple import jwt from fastapi import Request from supabase import create_client from consts.const import ( - ASSET_OWNER_TENANT_ID, ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE, @@ -29,36 +29,6 @@ # Module logger logger = logging.getLogger(__name__) - -def resolve_tenant_id_from_user_tenant_record( - user_tenant_record: Optional[dict], -) -> str: - """ - Resolve tenant_id from a user_tenant row. - - ASSET_OWNER virtual tenant_id is valid for asset administrator users. - - ENABLE_ASSET_OWNER_ROLE gates invites, registrations, and sign-in; existing - ASSET_OWNER rows still resolve to the virtual tenant for API paths that read - user_tenant_t (e.g. JWT-authenticated requests until tokens expire). - """ - if user_tenant_record is None: - return DEFAULT_TENANT_ID - - user_role = str(user_tenant_record.get("user_role") or "").upper() - if "tenant_id" in user_tenant_record: - tenant_value = user_tenant_record["tenant_id"] - if tenant_value is not None: - # Legacy asset-owner rows may still use empty string before DB migration - if tenant_value == "": - return ASSET_OWNER_TENANT_ID - return tenant_value - - if user_role == ASSET_OWNER_ROLE: - return ASSET_OWNER_TENANT_ID - - return DEFAULT_TENANT_ID - # --------------------------------------------------------------------------- # Shared test constants # --------------------------------------------------------------------------- @@ -131,7 +101,8 @@ def verify_aksk_signature( if access_key != expected_access_key: return False - expected_sig = calculate_hmac_signature(secret_key, access_key, timestamp, body) + expected_sig = calculate_hmac_signature( + secret_key, access_key, timestamp, body) return hmac.compare_digest(expected_sig, signature) @@ -243,9 +214,12 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: if not user_id: raise UnauthorizedError("No user associated with this access key") + # Query tenant from user_tenant_t user_tenant_record = get_user_tenant_by_user_id(user_id) - tenant_id = resolve_tenant_id_from_user_tenant_record(user_tenant_record) - if user_tenant_record is None: + if user_tenant_record and user_tenant_record.get("tenant_id"): + tenant_id = user_tenant_record["tenant_id"] + else: + tenant_id = DEFAULT_TENANT_ID logger.warning( f"No tenant relationship found for user {user_id}, using default tenant" ) @@ -257,6 +231,24 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: } +def resolve_tenant_id_from_user_tenant_record(user_tenant: Dict[str, Any]) -> str: + """ + Resolve the effective tenant_id from a user_tenant_t record. + + ASSET_OWNER users may have an empty legacy tenant_id; map them to the + virtual ASSET_OWNER tenant. Fall back to DEFAULT_TENANT_ID when unset. + """ + tenant_id = user_tenant.get("tenant_id") + if tenant_id: + return tenant_id + + user_role = (user_tenant.get("user_role") or "").upper() + if user_role == ASSET_OWNER_ROLE: + return ASSET_OWNER_TENANT_ID + + return DEFAULT_TENANT_ID + + def get_supabase_client(): """Get Supabase client instance with regular key (user-context operations).""" try: @@ -292,7 +284,8 @@ def get_jwt_expiry_seconds(token: str) -> int: return 10 * 365 * 24 * 60 * 60 # Ensure token is pure JWT, remove possible Bearer prefix jwt_token = ( - token.replace("Bearer ", "") if token.startswith("Bearer ") else token + token.replace("Bearer ", "") if token.startswith( + "Bearer ") else token ) # If debug expiration time is set, return directly for quick debugging @@ -401,7 +394,8 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: """ # In speed mode, allow unauthenticated access with default user for demo/dev if IS_SPEED_MODE: - logging.debug("Speed mode detected - returning default user ID and tenant ID") + logging.debug( + "Speed mode detected - returning default user ID and tenant ID") return DEFAULT_USER_ID, DEFAULT_TENANT_ID # In normal mode, missing auth header means unauthorized - return 401, not default user @@ -416,10 +410,11 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: raise UnauthorizedError("Invalid or expired authentication token") user_tenant_record = get_user_tenant_by_user_id(user_id) - tenant_id = resolve_tenant_id_from_user_tenant_record(user_tenant_record) - if user_tenant_record is not None: - logging.debug(f"Found tenant ID for user {user_id}: {tenant_id!r}") + if user_tenant_record and user_tenant_record.get("tenant_id"): + tenant_id = user_tenant_record["tenant_id"] + logging.debug(f"Found tenant ID for user {user_id}: {tenant_id}") else: + tenant_id = DEFAULT_TENANT_ID logging.warning( f"No tenant relationship found for user {user_id}, using default tenant" ) diff --git a/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx b/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx index b88076659..688fda8b1 100644 --- a/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx +++ b/frontend/app/[locale]/tenant-resources/components/resources/InvitationList.tsx @@ -564,21 +564,17 @@ export default function InvitationList({ { validator: async (_, value) => { if (!value) { - return Promise.resolve(); + return; } + let exists: boolean; try { - const exists = await checkInvitationCodeExists(value); - if (exists) { - return Promise.reject( - new Error( - t("tenantResources.invitation.alreadyExists") - ) - ); - } - return Promise.resolve(); + exists = await checkInvitationCodeExists(value); } catch { - return Promise.reject( - new Error("Failed to check invitation code") + throw new Error("Failed to check invitation code"); + } + if (exists) { + throw new Error( + t("tenantResources.invitation.alreadyExists") ); } }, @@ -608,15 +604,12 @@ export default function InvitationList({ message: t("tenantResources.invitation.capacityRequired"), }, { - validator: (_, value) => { - if (!value) return Promise.resolve(); + validator: async (_, value) => { + if (!value) return; const numValue = Number(value); if (isNaN(numValue) || numValue < 1) { - return Promise.reject( - new Error(t("tenantResources.invitation.capacityMin")) - ); + throw new Error(t("tenantResources.invitation.capacityMin")); } - return Promise.resolve(); }, }, ]} diff --git a/k8s/helm/nexent/charts/nexent-common/files/init.sql b/k8s/helm/nexent/charts/nexent-common/files/init.sql index bd014ee97..f27f2ad85 100644 --- a/k8s/helm/nexent/charts/nexent-common/files/init.sql +++ b/k8s/helm/nexent/charts/nexent-common/files/init.sql @@ -1103,40 +1103,7 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_ (184, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'CREATE'), (185, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'READ'), (186, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'UPDATE'), -(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'), -(188, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'CREATE'), -(189, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'READ'), -(190, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'UPDATE'), -(191, 'SU', 'RESOURCE', 'INVITE.ASSET_OWNER', 'DELETE'), -(192, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'), -(193, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agents'), -(194, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges'), -(195, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'), -(196, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/space'), -(197, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/market'), -(198, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models'), -(220, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users'), -(199, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'CREATE'), -(200, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'READ'), -(201, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'UPDATE'), -(202, 'ASSET_OWNER', 'RESOURCE', 'AGENT', 'DELETE'), -(203, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'CREATE'), -(204, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'READ'), -(205, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'UPDATE'), -(206, 'ASSET_OWNER', 'RESOURCE', 'SKILL', 'DELETE'), -(207, 'ASSET_OWNER', 'RESOURCE', 'KB', 'CREATE'), -(208, 'ASSET_OWNER', 'RESOURCE', 'KB', 'READ'), -(209, 'ASSET_OWNER', 'RESOURCE', 'KB', 'UPDATE'), -(210, 'ASSET_OWNER', 'RESOURCE', 'KB', 'DELETE'), -(211, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'CREATE'), -(212, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'READ'), -(213, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'UPDATE'), -(214, 'ASSET_OWNER', 'RESOURCE', 'MCP', 'DELETE'), -(215, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'CREATE'), -(216, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'READ'), -(217, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'UPDATE'), -(218, 'ASSET_OWNER', 'RESOURCE', 'MODEL', 'DELETE'), -(219, 'ASSET_OWNER', 'RESOURCE', 'USER.ROLE', 'READ'); +(187, 'SPEED', 'RESOURCE', 'TENANT.INVITE', 'DELETE'); -- Insert SPEED role user into user_tenant_t table if not exists INSERT INTO nexent.user_tenant_t (user_id, tenant_id, user_role, user_email, created_by, updated_by) diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index 427d7cc58..db67ec363 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -17,6 +17,8 @@ from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient +from consts.const import ASSET_OWNER_TENANT_ID + # Filter out deprecation warnings from third-party libraries warnings.filterwarnings("ignore", category=DeprecationWarning, module="pyiceberg") pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pyiceberg.*") @@ -820,8 +822,10 @@ def test_list_all_agent_info_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_list_all_agent.assert_called_once_with(tenant_id="test_tenant", user_id="test_user") - assert len(response.json()) == 2 + assert mock_list_all_agent.call_count == 2 + mock_list_all_agent.assert_any_call(tenant_id="test_tenant", user_id="test_user") + mock_list_all_agent.assert_any_call(tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") + assert len(response.json()) == 4 def test_list_all_agent_info_api_with_explicit_tenant_id(mocker, mock_auth_header): diff --git a/test/backend/app/test_file_management_app.py b/test/backend/app/test_file_management_app.py index eff392537..81c4efd4e 100644 --- a/test/backend/app/test_file_management_app.py +++ b/test/backend/app/test_file_management_app.py @@ -51,21 +51,43 @@ async def _stub_list_files_impl(prefix: str, limit: int | None = None): files = [{"name": "a.txt", "url": "http://u", "key": "knowledge_base/a.txt"}] return files[:limit] if limit else files -def _stub_check_file_access(object_name: str, user_id: str) -> bool: +def _stub_resolve_minio_upload_folder( + folder: str | None, + user_id: str | None = None, + uploader_tenant_id: str | None = None, +) -> str: + # Keep behavior consistent with production expectations for tests: + # - knowledge_base stays shared + # - otherwise default to attachments/{user_id} when user_id is present + if folder == "knowledge_base": + return "knowledge_base" + if user_id: + return f"attachments/{user_id}" + return folder or "attachments" + + +def _stub_check_file_access(object_name: str, user_id: str | None, caller_tenant_id: str | None = None) -> bool: """Stub for check_file_access - allows access by default for testing.""" + if not user_id: + return False if object_name.startswith("attachments/"): # attachments/{user_id}/*: only owner can access - if user_id: - expected_prefix = f"attachments/{user_id}" - return object_name.startswith(expected_prefix) - return False + expected_prefix = f"attachments/{user_id}" + return object_name.startswith(expected_prefix) # knowledge_base/*: all authenticated users can access return object_name.startswith("knowledge_base/") -def _stub_check_file_access_batch(object_names: List[str], user_id: str) -> Dict[str, bool]: +def _stub_check_file_access_batch( + object_names: List[str], + user_id: str | None, + caller_tenant_id: str | None = None, +) -> Dict[str, bool]: """Stub for check_file_access_batch - returns dict of object_name -> allowed.""" - return {name: _stub_check_file_access(name, user_id) for name in object_names} + return { + name: _stub_check_file_access(name, user_id, caller_tenant_id) + for name in object_names + } async def _stub_preprocess_files_generator(*_: Any, **__: Any) -> AsyncGenerator[str, None]: yield "data: {\"type\": \"progress\", \"progress\": 0}\n\n" @@ -88,6 +110,7 @@ def _stub_get_preview_stream(actual_object_name, start=None, end=None): sfms_stub.delete_file_impl = _stub_delete_file_impl sfms_stub.list_files_impl = _stub_list_files_impl sfms_stub.preprocess_files_generator = _stub_preprocess_files_generator +sfms_stub.resolve_minio_upload_folder = _stub_resolve_minio_upload_folder sfms_stub.check_file_access = _stub_check_file_access sfms_stub.check_file_access_batch = _stub_check_file_access_batch sys.modules["services.file_management_service"] = sfms_stub @@ -184,7 +207,7 @@ async def test_options_route_ok(): @pytest.mark.asyncio async def test_upload_files_success(monkeypatch): - async def fake_upload_impl(dest, files, folder, index_name, user_id=None): + async def fake_upload_impl(dest, files, folder, index_name, user_id=None, uploader_tenant_id=None): return [], ["/abs/path1"], ["a.txt"] monkeypatch.setattr(file_management_app, "upload_files_impl", fake_upload_impl) @@ -211,7 +234,7 @@ async def test_upload_files_no_files_bad_request(): @pytest.mark.asyncio async def test_upload_files_no_valid_files_uploaded(monkeypatch): - async def fake_upload_impl(dest, files, folder, index_name, user_id=None): + async def fake_upload_impl(dest, files, folder, index_name, user_id=None, uploader_tenant_id=None): return ["err"], [], [] monkeypatch.setattr(file_management_app, "upload_files_impl", fake_upload_impl) @@ -226,7 +249,7 @@ async def fake_upload_impl(dest, files, folder, index_name, user_id=None): @pytest.mark.asyncio async def test_upload_files_internal_error(monkeypatch): """Test upload_files with internal error returns 500.""" - async def fake_upload_impl(dest, files, folder, index_name, user_id=None): + async def fake_upload_impl(dest, files, folder, index_name, user_id=None, uploader_tenant_id=None): raise RuntimeError("Storage failed") monkeypatch.setattr(file_management_app, "upload_files_impl", fake_upload_impl) @@ -318,7 +341,7 @@ async def test_storage_upload_files_attachments_folder_user_isolation(monkeypatc """Test storage_upload_files with attachments folder uses user_id for isolation.""" captured_params = {} - async def fake_upload(files, folder, user_id=None): + async def fake_upload(files, folder, user_id=None, **kwargs): captured_params["folder"] = folder captured_params["user_id"] = user_id return [{"success": True, "file_name": "private.txt"}] @@ -333,7 +356,6 @@ async def fake_upload(files, folder, user_id=None): ) # Folder should be prefixed with user_id assert captured_params["folder"] == "attachments/user1" - assert captured_params["user_id"] == "user1" assert result["success_count"] == 1 @@ -617,7 +639,7 @@ async def boom_url(object_name, expires): @pytest.mark.asyncio async def test_get_storage_file_access_denied_for_attachments(monkeypatch): """Test that access to other user's attachments is forbidden.""" - def fake_check_access(object_name, user_id): + def fake_check_access(object_name, user_id, caller_tenant_id=None): if object_name.startswith("attachments/"): expected_prefix = f"attachments/{user_id}" return object_name.startswith(expected_prefix) @@ -672,7 +694,7 @@ async def ok_delete(object_name): @pytest.mark.asyncio async def test_remove_storage_file_access_denied(monkeypatch): """Test that deletion of other user's file is forbidden.""" - def fake_check_access(object_name, user_id): + def fake_check_access(object_name, user_id, caller_tenant_id=None): if object_name.startswith("attachments/"): expected_prefix = f"attachments/{user_id}" return object_name.startswith(expected_prefix) @@ -733,7 +755,7 @@ def fake_get(object_name, expires): @pytest.mark.asyncio async def test_get_storage_file_batch_urls_all_denied(monkeypatch): """Test batch URLs when all files are denied access.""" - def fake_check_access(object_name, user_id): + def fake_check_access(object_name, user_id, caller_tenant_id=None): return False # Deny all access def fake_get(object_name, expires): @@ -756,7 +778,7 @@ def fake_get(object_name, expires): @pytest.mark.asyncio async def test_get_storage_file_batch_urls_error(monkeypatch): """Test batch URLs with internal error returns error in results, not exception.""" - def fake_check_access(object_name, user_id): + def fake_check_access(object_name, user_id, caller_tenant_id=None): return True def fake_get(object_name, expires): @@ -1503,7 +1525,7 @@ async def test_preview_file_office_converted_to_pdf(monkeypatch): @pytest.mark.asyncio async def test_preview_file_access_denied(monkeypatch): """Test preview_file access denied for other user's attachments.""" - def fake_check_access(object_name, user_id): + def fake_check_access(object_name, user_id, caller_tenant_id=None): if object_name.startswith("attachments/"): expected_prefix = f"attachments/{user_id}" return object_name.startswith(expected_prefix) diff --git a/test/backend/app/test_northbound_base_app.py b/test/backend/app/test_northbound_base_app.py index 9393bb1b8..4f58b3062 100644 --- a/test/backend/app/test_northbound_base_app.py +++ b/test/backend/app/test_northbound_base_app.py @@ -62,6 +62,38 @@ class NorthboundContext: a2a_server_service_mock.get_task = MagicMock() a2a_service_module.a2a_server_service = a2a_server_service_mock +# services.file_management_service - stub used by northbound_knowledge_app +file_mgmt_module = types.ModuleType("services.file_management_service") +file_mgmt_module.upload_to_minio = AsyncMock() +file_mgmt_module.upload_files_impl = AsyncMock() +file_mgmt_module.get_file_url_impl = AsyncMock() +file_mgmt_module.get_file_stream_impl = AsyncMock() +file_mgmt_module.delete_file_impl = AsyncMock() +file_mgmt_module.list_files_impl = AsyncMock() +file_mgmt_module.check_file_access = MagicMock(return_value=True) +file_mgmt_module.check_file_access_batch = MagicMock(return_value={}) +file_mgmt_module.resolve_preview_file = AsyncMock() +file_mgmt_module.get_preview_stream = MagicMock() +file_mgmt_module.resolve_minio_upload_folder = MagicMock(return_value="attachments") +sys.modules["services.file_management_service"] = file_mgmt_module + +# services.redis_service - stub to avoid importing redis dependency +redis_service_module = types.ModuleType("services.redis_service") +redis_service_module.get_redis_service = MagicMock() +sys.modules["services.redis_service"] = redis_service_module + +# services.vectordatabase_service - stub to avoid heavy SDK imports +vectordb_service_module = types.ModuleType("services.vectordatabase_service") + +class _ElasticSearchServiceStub: + @staticmethod + def list_indices(*args, **kwargs): + return {"indices": []} + +vectordb_service_module.ElasticSearchService = _ElasticSearchServiceStub +vectordb_service_module.get_vector_db_core = MagicMock() +sys.modules["services.vectordatabase_service"] = vectordb_service_module + # --------------------------------------------------------------------------- # BLOCK 2: Mock minimal consts modules needed by apps layer # --------------------------------------------------------------------------- @@ -75,6 +107,7 @@ class NorthboundContext: consts_model_module.UnauthorizedError = type("UnauthorizedError", (Exception,), {}) consts_model_module.SignatureValidationError = type("SignatureValidationError", (Exception,), {}) consts_model_module.AgentRequest = type("AgentRequest", (), {}) +consts_model_module.ProcessParams = type("ProcessParams", (), {}) consts_module.model = consts_model_module sys.modules['consts.model'] = consts_model_module @@ -86,6 +119,9 @@ class NorthboundContext: consts_exceptions_module.SignatureValidationError = consts_model_module.SignatureValidationError consts_exceptions_module.MemoryPreparationException = type("MemoryPreparationException", (Exception,), {}) consts_exceptions_module.AgentRunException = type("AgentRunException", (Exception,), {}) +consts_exceptions_module.NotFoundException = type("NotFoundException", (Exception,), {}) +consts_exceptions_module.UnsupportedFileTypeException = type("UnsupportedFileTypeException", (Exception,), {}) +consts_exceptions_module.FileTooLargeException = type("FileTooLargeException", (Exception,), {}) consts_module.exceptions = consts_exceptions_module sys.modules['consts.exceptions'] = consts_exceptions_module @@ -184,8 +220,15 @@ def _create_app_impl(title, description="", version="1.0.0", root_path="/api", # Mock utils.auth_utils (referenced by northbound_app._get_northbound_context) auth_utils_module = types.ModuleType("utils.auth_utils") auth_utils_module.validate_bearer_token = MagicMock(return_value=(True, {"user_id": "test", "tenant_id": "test"})) +auth_utils_module.generate_session_jwt = MagicMock(return_value="jwt-token") +auth_utils_module.get_current_user_id = MagicMock(return_value=("test", "test")) sys.modules['utils.auth_utils'] = auth_utils_module +# Mock utils.file_management_utils to avoid database/storage imports +file_management_utils_module = types.ModuleType("utils.file_management_utils") +file_management_utils_module.trigger_data_process = AsyncMock(return_value=[]) +sys.modules["utils.file_management_utils"] = file_management_utils_module + # --------------------------------------------------------------------------- # Helper to build async iterators without passing keyword args through mock # --------------------------------------------------------------------------- diff --git a/test/backend/app/test_skill_app.py b/test/backend/app/test_skill_app.py index 512f5a806..f8b6a5af9 100644 --- a/test/backend/app/test_skill_app.py +++ b/test/backend/app/test_skill_app.py @@ -168,10 +168,14 @@ class MockSkillCreateInteractiveRequest(BaseModel): # Mock services services_mock = types.ModuleType('services') +services_mock.__path__ = [] # Make it a package so submodules can be imported services_skill_service_mock = types.ModuleType('services.skill_service') +services_asset_owner_visibility_mock = types.ModuleType('services.asset_owner_visibility') sys.modules['services'] = services_mock sys.modules['services.skill_service'] = services_skill_service_mock +sys.modules['services.asset_owner_visibility'] = services_asset_owner_visibility_mock setattr(services_mock, 'skill_service', services_skill_service_mock) +setattr(services_mock, 'asset_owner_visibility', services_asset_owner_visibility_mock) class MockSkillService: def __init__(self): @@ -184,6 +188,7 @@ def __init__(self): services_skill_service_mock.update_skill_list = MagicMock() services_skill_service_mock.get_official_skills_with_status = MagicMock(return_value=[]) services_skill_service_mock.install_skills_from_zip_for_tenant = MagicMock(return_value=[]) +services_asset_owner_visibility_mock.can_view_skill = MagicMock(return_value=True) # Mock utils utils_mock = types.ModuleType('utils') diff --git a/test/backend/app/test_vectordatabase_app.py b/test/backend/app/test_vectordatabase_app.py index cde9107a8..c4820d177 100644 --- a/test/backend/app/test_vectordatabase_app.py +++ b/test/backend/app/test_vectordatabase_app.py @@ -440,6 +440,7 @@ async def test_get_list_indices_success(vdb_core_mock, auth_data): # Setup mocks - get_current_user_id is now required 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.ASSET_OWNER_TENANT_ID", auth_data["tenant_id"]), \ patch("backend.apps.vectordatabase_app.ElasticSearchService.list_indices") as mock_list: expected_response = {"indices": ["index1", "index2"]} @@ -559,6 +560,7 @@ async def test_get_list_indices_uses_auth_tenant_id_when_no_query_param(vdb_core # Setup mocks 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.ASSET_OWNER_TENANT_ID", auth_data["tenant_id"]), \ patch("backend.apps.vectordatabase_app.ElasticSearchService.list_indices") as mock_list: expected_response = {"indices": ["index1"], "count": 1} @@ -574,9 +576,9 @@ async def test_get_list_indices_uses_auth_tenant_id_when_no_query_param(vdb_core # Verify assert response.status_code == 200 - # Verify that list_indices was called with auth tenant_id + # Verify that list_indices was called with auth tenant_id (no asset-owner merge) + mock_list.assert_called_once() call_args = mock_list.call_args - # Falls back to auth tenant_id assert call_args[0][2] == auth_data["tenant_id"] @@ -987,7 +989,7 @@ async def test_get_index_chunks_success(vdb_core_mock, auth_data): 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.get_index_name_by_knowledge_name", return_value="resolved_index"), \ + patch("backend.apps.vectordatabase_app.check_file_access", return_value=True), \ patch("backend.apps.vectordatabase_app.ElasticSearchService.get_index_chunks") as mock_get_chunks: expected_response = { @@ -1009,7 +1011,7 @@ async def test_get_index_chunks_success(vdb_core_mock, auth_data): assert response.status_code == 200 assert response.json() == expected_response mock_get_chunks.assert_called_once_with( - index_name="resolved_index", + index_name=index_name, page=2, page_size=50, path_or_url="/foo", @@ -1027,7 +1029,6 @@ async def test_get_index_chunks_error(vdb_core_mock, auth_data): 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.get_index_name_by_knowledge_name", return_value="resolved_index"), \ patch("backend.apps.vectordatabase_app.ElasticSearchService.get_index_chunks") as mock_get_chunks: mock_get_chunks.side_effect = Exception("Chunk failure") @@ -1041,7 +1042,7 @@ async def test_get_index_chunks_error(vdb_core_mock, auth_data): assert response.json() == { "detail": "Error getting chunks: Chunk failure"} mock_get_chunks.assert_called_once_with( - index_name="resolved_index", + index_name=index_name, page=None, page_size=None, path_or_url=None, @@ -2187,7 +2188,6 @@ async def test_get_index_chunks_value_error(vdb_core_mock, auth_data): 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.get_index_name_by_knowledge_name", return_value="resolved_index"), \ patch("backend.apps.vectordatabase_app.ElasticSearchService.get_index_chunks") as mock_get_chunks: mock_get_chunks.side_effect = ValueError("Unknown index") @@ -2197,15 +2197,15 @@ async def test_get_index_chunks_value_error(vdb_core_mock, auth_data): headers=auth_data["auth_header"] ) - assert response.status_code == 404 - assert response.json() == {"detail": "Unknown index"} - mock_get_chunks.assert_called_once_with( - index_name="resolved_index", - page=None, - page_size=None, - path_or_url=None, - vdb_core=ANY, - ) + assert response.status_code == 404 + assert response.json() == {"detail": "Unknown index"} + mock_get_chunks.assert_called_once_with( + index_name=index_name, + page=None, + page_size=None, + path_or_url=None, + vdb_core=ANY, + ) @pytest.mark.asyncio diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py index 372d9e095..e8323d54e 100644 --- a/test/backend/services/test_agent_service.py +++ b/test/backend/services/test_agent_service.py @@ -49,8 +49,7 @@ def model_dump(self, **kwargs): sys.modules['sqlalchemy'] = MagicMock() sys.modules['sqlalchemy.create_engine'] = MagicMock() -# Mock database submodules -sys.modules['database'] = MagicMock() +# Mock database submodules (do not replace the parent `database` package to avoid breaking other tests) sys.modules['database.agent_db'] = MagicMock() sys.modules['database.tool_db'] = MagicMock() sys.modules['database.remote_mcp_db'] = MagicMock() @@ -61,6 +60,16 @@ def model_dump(self, **kwargs): # Mock a2a_agent_db (referenced by agent_service.py) sys.modules['database.a2a_agent_db'] = MagicMock() +sys.modules['database.skill_db'] = MagicMock() + +# Stub database.client early so real DB modules are not loaded during import +_mock_db_client = MagicMock() +_mock_db_client.get_db_session = MagicMock() +_mock_db_client.as_dict = MagicMock() +_mock_db_client.MinioClient = MagicMock() +_mock_db_client.db_client = MagicMock() +sys.modules['database.client'] = _mock_db_client +sys.modules['backend.database.client'] = _mock_db_client # Mock services submodules services_module = types.ModuleType("services") @@ -86,6 +95,19 @@ def model_dump(self, **kwargs): sys.modules['services.skill_service'] = MagicMock() setattr(services_module, 'skill_service', sys.modules['services.skill_service']) +# Load real asset_owner_visibility (agent_service imports resolve_agent_list_permission) +import importlib.util +from pathlib import Path + +_asset_owner_path = Path(__file__).resolve().parents[3] / "backend" / "services" / "asset_owner_visibility.py" +_asset_owner_spec = importlib.util.spec_from_file_location( + "services.asset_owner_visibility", _asset_owner_path +) +_asset_owner_mod = importlib.util.module_from_spec(_asset_owner_spec) +_asset_owner_spec.loader.exec_module(_asset_owner_mod) +sys.modules["services.asset_owner_visibility"] = _asset_owner_mod +setattr(services_module, "asset_owner_visibility", _asset_owner_mod) + # Mock agents submodules sys.modules['agents'] = MagicMock() sys.modules['agents.create_agent_info'] = MagicMock() diff --git a/test/backend/services/test_invitation_service.py b/test/backend/services/test_invitation_service.py index 109c2b8a9..7f3363235 100644 --- a/test/backend/services/test_invitation_service.py +++ b/test/backend/services/test_invitation_service.py @@ -4,6 +4,12 @@ import importlib.machinery from unittest.mock import patch, MagicMock +# Ensure repository root is importable so the `backend.*` namespace resolves. +from pathlib import Path +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + # Mock external dependencies before importing sys.modules['psycopg2'] = MagicMock() boto3_module = types.ModuleType("boto3") @@ -13,13 +19,82 @@ sys.modules['boto3'] = boto3_module sys.modules['supabase'] = MagicMock() +# Stub nexent.storage modules to avoid importing the real SDK package (which has optional deps). +nexent_module = types.ModuleType("nexent") +setattr(nexent_module, "__path__", []) +nexent_storage_module = types.ModuleType("nexent.storage") +setattr(nexent_storage_module, "__path__", []) +nexent_storage_factory_module = types.ModuleType("nexent.storage.storage_client_factory") +nexent_storage_factory_module.create_storage_client_from_config = MagicMock() +nexent_minio_config_module = types.ModuleType("nexent.storage.minio_config") + + +class _MockMinIOStorageConfig: + def validate(self): + return None + + +nexent_minio_config_module.MinIOStorageConfig = _MockMinIOStorageConfig +sys.modules["nexent"] = nexent_module +sys.modules["nexent.storage"] = nexent_storage_module +sys.modules["nexent.storage.storage_client_factory"] = nexent_storage_factory_module +sys.modules["nexent.storage.minio_config"] = nexent_minio_config_module + +# Mock mem0 to prevent optional dependency import failures during test collection +mem0_module = types.ModuleType("mem0") +setattr(mem0_module, "__path__", []) +mem0_memory_module = types.ModuleType("mem0.memory") +mem0_memory_main_module = types.ModuleType("mem0.memory.main") +mem0_embeddings_module = types.ModuleType("mem0.embeddings") +mem0_embeddings_base_module = types.ModuleType("mem0.embeddings.base") + + +class _MockAsyncMemory: + pass + + +mem0_memory_main_module.AsyncMemory = _MockAsyncMemory + + +class _MockEmbeddingBase: + pass + + +mem0_embeddings_base_module.EmbeddingBase = _MockEmbeddingBase +sys.modules["mem0"] = mem0_module +sys.modules["mem0.memory"] = mem0_memory_module +sys.modules["mem0.memory.main"] = mem0_memory_main_module +sys.modules["mem0.embeddings"] = mem0_embeddings_module +sys.modules["mem0.embeddings.base"] = mem0_embeddings_base_module + +# Stub database modules used by invitation_service to avoid loading real SQLAlchemy client +_db_client_stub = types.ModuleType("database.client") +_db_client_stub.get_db_session = MagicMock() +_db_client_stub.as_dict = MagicMock() +_db_client_stub.MinioClient = MagicMock() +sys.modules["database.client"] = _db_client_stub +sys.modules["database.invitation_db"] = MagicMock() +sys.modules["database.user_tenant_db"] = MagicMock() +sys.modules["database.group_db"] = MagicMock() +sys.modules["database.role_permission_db"] = MagicMock() + # Patch storage factory and MinIO config validation to avoid errors during initialization # These patches must be started before any imports that use MinioClient storage_client_mock = MagicMock() minio_client_mock = MagicMock() patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock).start() patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() -patch('backend.database.client.MinioClient', return_value=minio_client_mock).start() +patch('database.client.MinioClient', return_value=minio_client_mock).start() + +_services_pkg = types.ModuleType("services") +_services_pkg.__path__ = [] +sys.modules["services"] = _services_pkg +sys.modules["services.group_service"] = MagicMock() +_asset_owner_visibility_stub = types.ModuleType("services.asset_owner_visibility") +_asset_owner_visibility_stub.require_asset_owner_enabled = lambda: None +sys.modules["services.asset_owner_visibility"] = _asset_owner_visibility_stub +setattr(_services_pkg, "asset_owner_visibility", _asset_owner_visibility_stub) +setattr(_services_pkg, "group_service", sys.modules["services.group_service"]) from consts.exceptions import NotFoundException, UnauthorizedError, DuplicateError from backend.services.invitation_service import ( diff --git a/test/backend/services/test_user_management_service.py b/test/backend/services/test_user_management_service.py index 5bc7b6a67..bbdec7d18 100644 --- a/test/backend/services/test_user_management_service.py +++ b/test/backend/services/test_user_management_service.py @@ -27,12 +27,20 @@ sys.modules['nexent.storage.storage_client_factory'] = MagicMock() # Mock services -sys.modules['services'] = MagicMock() +services_pkg = types.ModuleType('services') +services_pkg.__path__ = [] +sys.modules['services'] = services_pkg sys.modules['services.invitation_service'] = MagicMock() sys.modules['services.group_service'] = MagicMock() sys.modules['services.tool_configuration_service'] = MagicMock() sys.modules['services.skill_service'] = MagicMock() +asset_owner_visibility_mock = types.ModuleType('services.asset_owner_visibility') +asset_owner_visibility_mock.filter_accessible_routes_for_asset_owner_feature = lambda routes: routes +asset_owner_visibility_mock.require_asset_owner_enabled = lambda: None +sys.modules['services.asset_owner_visibility'] = asset_owner_visibility_mock +setattr(services_pkg, 'asset_owner_visibility', asset_owner_visibility_mock) + from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError, AppException from consts.error_code import ErrorCode @@ -44,6 +52,18 @@ patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() patch('backend.database.client.MinioClient', return_value=minio_client_mock).start() +# Stub database modules used by user_management_service to avoid loading real SQLAlchemy client +_db_client_stub = types.ModuleType("database.client") +_db_client_stub.get_db_session = MagicMock() +_db_client_stub.as_dict = MagicMock() +_db_client_stub.MinioClient = MagicMock(return_value=minio_client_mock) +sys.modules["database.client"] = _db_client_stub +sys.modules["database.token_db"] = MagicMock() +sys.modules["database.model_management_db"] = MagicMock() +sys.modules["database.user_tenant_db"] = MagicMock() +sys.modules["database.group_db"] = MagicMock() +sys.modules["database.db_models"] = MagicMock() + with patch('backend.database.client.MinioClient', return_value=minio_client_mock): from backend.services.user_management_service import ( set_auth_token_to_client, @@ -1239,11 +1259,15 @@ async def test_verify_invite_code_wrong_code(self): class TestSigninUser(unittest.IsolatedAsyncioTestCase): """Test signin_user""" + @patch('backend.services.user_management_service.get_user_tenant_by_user_id') @patch('backend.services.user_management_service.get_jwt_expiry_seconds') @patch('backend.services.user_management_service.calculate_expires_at') @patch('backend.services.user_management_service.get_supabase_client') - async def test_signin_user_success(self, mock_get_client, mock_calc_expires, mock_get_expiry): + async def test_signin_user_success( + self, mock_get_client, mock_calc_expires, mock_get_expiry, mock_get_user_tenant + ): """Test successful user signin""" + mock_get_user_tenant.return_value = None mock_client = MagicMock() mock_user = MagicMock() mock_user.id = "user-123" @@ -1283,11 +1307,15 @@ async def test_signin_user_success(self, mock_get_client, mock_calc_expires, moc } self.assertEqual(result, expected) + @patch('backend.services.user_management_service.get_user_tenant_by_user_id') @patch('backend.services.user_management_service.get_jwt_expiry_seconds') @patch('backend.services.user_management_service.calculate_expires_at') @patch('backend.services.user_management_service.get_supabase_client') - async def test_signin_user_default_role(self, mock_get_client, mock_calc_expires, mock_get_expiry): + async def test_signin_user_default_role( + self, mock_get_client, mock_calc_expires, mock_get_expiry, mock_get_user_tenant + ): """Test signin with default user role""" + mock_get_user_tenant.return_value = None mock_client = MagicMock() mock_user = MagicMock() mock_user.id = "user-123" diff --git a/test/backend/services/test_vectordatabase_service.py b/test/backend/services/test_vectordatabase_service.py index e66028f29..5d6f87297 100644 --- a/test/backend/services/test_vectordatabase_service.py +++ b/test/backend/services/test_vectordatabase_service.py @@ -1,4 +1,5 @@ import asyncio +import importlib import io import sys import os @@ -58,6 +59,7 @@ def _create_package_mock(name): monitor_module = types.ModuleType('nexent.monitor') monitor_module.set_monitoring_context = MagicMock() monitor_module.set_monitoring_operation = MagicMock() +monitor_module.get_monitoring_manager = MagicMock() sys.modules['nexent.monitor'] = monitor_module setattr(nexent_mock, 'monitor', monitor_module) @@ -75,29 +77,42 @@ def _create_package_mock(name): embedding_model_module = types.ModuleType('nexent.core.models.embedding_model') -consts_mock = MagicMock() -consts_mock.const = MagicMock() -consts_mock.const.MINIO_ENDPOINT = "http://localhost:9000" -consts_mock.const.MINIO_ACCESS_KEY = "test_access_key" -consts_mock.const.MINIO_SECRET_KEY = "test_secret_key" -consts_mock.const.MINIO_REGION = "us-east-1" -consts_mock.const.MINIO_DEFAULT_BUCKET = "test-bucket" -consts_mock.const.POSTGRES_HOST = "localhost" -consts_mock.const.POSTGRES_USER = "test_user" -consts_mock.const.NEXENT_POSTGRES_PASSWORD = "test_password" -consts_mock.const.POSTGRES_DB = "test_db" -consts_mock.const.POSTGRES_PORT = 5432 -consts_mock.const.DEFAULT_TENANT_ID = "default_tenant" -consts_mock.const.PERMISSION_EDIT = "EDIT" -consts_mock.const.PERMISSION_READ = "READ_ONLY" -consts_mock.const.PERMISSION_PRIVATE = "PRIVATE" -sys.modules['consts'] = consts_mock -sys.modules['consts.const'] = consts_mock.const -sys.modules['consts.model'] = MagicMock() -sys.modules['consts.error_code'] = MagicMock() -sys.modules['consts.exceptions'] = MagicMock() -sys.modules['consts.scheduler'] = MagicMock() -sys.modules['consts.prompt_template'] = MagicMock() +consts_exceptions_mod = types.ModuleType("consts.exceptions") + + +class UnauthorizedError(Exception): + pass + + +class NotFoundException(Exception): + pass + + +class DuplicateError(Exception): + pass + + +class ValidationError(Exception): + pass + + +consts_exceptions_mod.UnauthorizedError = UnauthorizedError +consts_exceptions_mod.NotFoundException = NotFoundException +consts_exceptions_mod.DuplicateError = DuplicateError +consts_exceptions_mod.ValidationError = ValidationError + +# Use real consts.const/scheduler (env vars are configured in test/conftest.py) +consts_pkg = importlib.import_module("consts") +consts_const_mod = importlib.import_module("consts.const") +consts_scheduler_mod = importlib.import_module("consts.scheduler") + +sys.modules["consts"] = consts_pkg +sys.modules["consts.const"] = consts_const_mod +sys.modules["consts.exceptions"] = consts_exceptions_mod +sys.modules["consts.model"] = MagicMock() +sys.modules["consts.scheduler"] = consts_scheduler_mod +sys.modules["consts.error_code"] = MagicMock() +sys.modules["consts.prompt_template"] = MagicMock() class _VectorDatabaseCore: @@ -264,6 +279,16 @@ def validate(self): sys.modules['services.group_service'] = group_service_mock setattr(sys.modules['services'], 'group_service', group_service_mock) +# Create mock asset_owner_visibility module +def _mock_postprocess_knowledge_visibility(items, caller_role=None, caller_tenant_id=None): + return items + + +asset_owner_visibility_mock = types.ModuleType('services.asset_owner_visibility') +asset_owner_visibility_mock.postprocess_knowledge_visibility = _mock_postprocess_knowledge_visibility +sys.modules['services.asset_owner_visibility'] = asset_owner_visibility_mock +setattr(sys.modules['services'], 'asset_owner_visibility', asset_owner_visibility_mock) + # Create mock utils modules - backend.utils needs __path__ for submodule lookups utils_mock = types.ModuleType('utils') # No __path__ so Python won't try submodule lookup utils_mock.__path__ = [] # Empty __path__ to make it a namespace package @@ -324,7 +349,6 @@ async def _mock_get_all_files_status(index_name): minio_client_mock._storage_client = storage_client_mock # Load actual backend modules so that patch targets resolve correctly -import importlib # noqa: E402 backend_module = importlib.import_module('backend') sys.modules['backend'] = backend_module # Set backend.utils as attribute so imports like 'from backend.utils.xxx import yyy' work @@ -1891,7 +1915,7 @@ def test_index_documents_uses_multi_embedding_config_key(self): patch('backend.services.vectordatabase_service.tenant_config_manager') as mock_tenant_cfg, \ patch('backend.services.vectordatabase_service.update_last_doc_update_time'): mock_get_record.return_value = { - "tenant_id": consts_mock.const.DEFAULT_TENANT_ID} + "tenant_id": consts_const_mod.DEFAULT_TENANT_ID} mock_tenant_cfg.get_model_config.return_value = {"chunk_batch": 6} result = ElasticSearchService.index_documents( @@ -1903,7 +1927,7 @@ def test_index_documents_uses_multi_embedding_config_key(self): self.assertTrue(result["success"]) mock_tenant_cfg.get_model_config.assert_called_once_with( - key="MULTI_EMBEDDING_ID", tenant_id=consts_mock.const.DEFAULT_TENANT_ID + key="MULTI_EMBEDDING_ID", tenant_id=consts_const_mod.DEFAULT_TENANT_ID ) def test_index_documents_fetches_image_bytes(self): @@ -1918,7 +1942,7 @@ def test_index_documents_fetches_image_bytes(self): patch('backend.services.vectordatabase_service.get_file_stream') as mock_get_stream, \ patch('backend.services.vectordatabase_service.update_last_doc_update_time'): mock_get_record.return_value = { - "tenant_id": consts_mock.const.DEFAULT_TENANT_ID} + "tenant_id": consts_const_mod.DEFAULT_TENANT_ID} mock_tenant_cfg.get_model_config.return_value = {"chunk_batch": 5} mock_get_stream.return_value = io.BytesIO(b"img-bytes") @@ -2449,7 +2473,7 @@ def test_search_hybrid_success(self, mock_get_embedding_by_index): result = ElasticSearchService.search_hybrid( index_names=["test_index"], query="test query", - tenant_id=consts_mock.const.DEFAULT_TENANT_ID, + tenant_id=consts_const_mod.DEFAULT_TENANT_ID, top_k=10, weight_accurate=0.5, vdb_core=self.mock_vdb_core @@ -2472,7 +2496,7 @@ def test_search_hybrid_success(self, mock_get_embedding_by_index): top_k=10, weight_accurate=0.5 ) - mock_get_embedding_by_index.assert_called_once_with(consts_mock.const.DEFAULT_TENANT_ID, "test_index") + mock_get_embedding_by_index.assert_called_once_with(consts_const_mod.DEFAULT_TENANT_ID, "test_index") def test_search_hybrid_missing_tenant_id(self): """Test search_hybrid raises ValueError when tenant_id is missing.""" @@ -2553,7 +2577,7 @@ def test_search_hybrid_no_embedding_model(self, mock_get_embedding_by_index): ElasticSearchService.search_hybrid( index_names=["test_index"], query="test query", - tenant_id=consts_mock.const.DEFAULT_TENANT_ID, + tenant_id=consts_const_mod.DEFAULT_TENANT_ID, top_k=10, weight_accurate=0.5, vdb_core=self.mock_vdb_core @@ -2595,7 +2619,7 @@ def test_search_hybrid_weight_accurate_boundary_values(self, mock_get_embedding_ result = ElasticSearchService.search_hybrid( index_names=["test_index"], query="test query", - tenant_id=consts_mock.const.DEFAULT_TENANT_ID, + tenant_id=consts_const_mod.DEFAULT_TENANT_ID, top_k=10, weight_accurate=0.0, vdb_core=self.mock_vdb_core @@ -2614,7 +2638,7 @@ def test_search_hybrid_weight_accurate_boundary_values(self, mock_get_embedding_ result = ElasticSearchService.search_hybrid( index_names=["test_index"], query="test query", - tenant_id=consts_mock.const.DEFAULT_TENANT_ID, + tenant_id=consts_const_mod.DEFAULT_TENANT_ID, top_k=10, weight_accurate=1.0, vdb_core=self.mock_vdb_core @@ -2632,7 +2656,7 @@ def test_search_hybrid_weight_accurate_boundary_values(self, mock_get_embedding_ result = ElasticSearchService.search_hybrid( index_names=["test_index"], query="test query", - tenant_id=consts_mock.const.DEFAULT_TENANT_ID, + tenant_id=consts_const_mod.DEFAULT_TENANT_ID, top_k=10, weight_accurate=0.3, vdb_core=self.mock_vdb_core From 3c569549104b664700dd7c2848388822af12a4c0 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Thu, 28 May 2026 14:50:32 +0800 Subject: [PATCH 3/6] feat(asset-owner): add invitation & user management support - Add tenant_id migration and asset owner permissions/menu SQL - Expose northbound knowledge/vector database updates for asset owner visibility - Add backend auth/utils and invitation/agent/user management services - Update invitation list UI --- backend/services/invitation_service.py | 3 +- .../agents/components/AgentManageComp.tsx | 64 ++++++------------- .../services/test_invitation_service.py | 12 ++-- 3 files changed, 30 insertions(+), 49 deletions(-) diff --git a/backend/services/invitation_service.py b/backend/services/invitation_service.py index 2ff4a7707..4011c67cc 100644 --- a/backend/services/invitation_service.py +++ b/backend/services/invitation_service.py @@ -507,8 +507,7 @@ def _generate_unique_invitation_code(length: int = 6) -> str: while attempts < max_attempts: # Generate random code with letters and digits - code = ''.join(random.choices( - string.ascii_letters + string.digits, k=length)) + code = ''.join(random.choices(string.ascii_letters + string.digits, k=length)) # Check uniqueness if not query_invitation_by_code(code): diff --git a/frontend/app/[locale]/agents/components/AgentManageComp.tsx b/frontend/app/[locale]/agents/components/AgentManageComp.tsx index f983e7dab..7dabff4dd 100644 --- a/frontend/app/[locale]/agents/components/AgentManageComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentManageComp.tsx @@ -7,20 +7,22 @@ import { FileInput, Plus, X } from "lucide-react"; import AgentList from "./agentManage/AgentList"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; -import { importAgent } from "@/services/agentConfigService"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAgentList } from "@/hooks/agent/useAgentList"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import log from "@/lib/logger"; import { useState } from "react"; -import { ImportAgentData } from "@/hooks/useAgentImport"; +import { + parseAgentImportFile, + selectFile, + type ImportAgentData, +} from "@/lib/agentImportUtils"; import AgentImportWizard from "@/components/agent/AgentImportWizard"; export default function AgentManageComp() { const { t } = useTranslation("common"); const { message } = App.useApp(); - const { user } = useAuthorizationContext(); + useAuthorizationContext(); // Get state from store const isCreatingMode = useAgentConfigStore((state) => state.isCreatingMode); @@ -36,47 +38,23 @@ export default function AgentManageComp() { const { agents: agentList, isLoading: loading, refetch } = useAgentList(""); // Handle import agent for space view - open wizard instead of direct import - const handleImportAgent = () => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".json"; - fileInput.onchange = async (event) => { - const file = (event.target as HTMLInputElement).files?.[0]; - if (!file) return; - - if (!file.name.endsWith(".json")) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - try { - // Read and parse file - const fileContent = await file.text(); - let agentData: ImportAgentData; - - try { - agentData = JSON.parse(fileContent); - } catch (parseError) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - // Validate structure - if (!agentData.agent_id || !agentData.agent_info) { - message.error(t("businessLogic.config.error.invalidFileType")); - return; - } - - // Open wizard with parsed data - setImportWizardData(agentData); - setImportWizardVisible(true); - } catch (error) { + const handleImportAgent = async () => { + const file = await selectFile(".json"); + if (!file) return; + + const agentData = await parseAgentImportFile(file, { + onParseError: (msgKey) => message.error(t(msgKey)), + onValidationError: (msgKey) => message.error(t(msgKey)), + onGenericError: (error) => { log.error("Failed to read import file:", error); message.error(t("businessLogic.config.error.agentImportFailed")); - } - }; + }, + }); - fileInput.click(); + if (!agentData) return; + + setImportWizardData(agentData); + setImportWizardVisible(true); }; return ( @@ -160,7 +138,7 @@ export default function AgentManageComp() {
void handleImportAgent()} > Date: Thu, 28 May 2026 14:53:49 +0800 Subject: [PATCH 4/6] feat(asset-owner): add invitation & user management support - Add tenant_id migration and asset owner permissions/menu SQL - Expose northbound knowledge/vector database updates for asset owner visibility - Add backend auth/utils and invitation/agent/user management services - Update invitation list UI --- backend/apps/agent_app.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 923251906..1ab62e4c9 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -292,18 +292,18 @@ async def list_all_agent_info_api( list all agent info """ try: - user_id, auth_tenant_id, _ = get_current_user_info( + user_id, tenant_id, _ = get_current_user_info( authorization, request) - if tenant_id is None: - agent_list = await list_all_agent_info_impl( - tenant_id=auth_tenant_id, user_id=user_id + + agent_list = await list_all_agent_info_impl( + tenant_id=tenant_id, user_id=user_id + ) + if tenant_id != ASSET_OWNER_TENANT_ID: + asset_agent_list = await list_all_agent_info_impl( + tenant_id=ASSET_OWNER_TENANT_ID, user_id=user_id ) - if auth_tenant_id != ASSET_OWNER_TENANT_ID: - asset_agent_list = await list_all_agent_info_impl( - tenant_id=ASSET_OWNER_TENANT_ID, user_id=user_id - ) - return agent_list + asset_agent_list - return agent_list + return agent_list + asset_agent_list + return agent_list return await list_all_agent_info_impl(tenant_id=tenant_id, user_id=user_id) except Exception as e: logger.error(f"Agent list error: {str(e)}") @@ -395,12 +395,10 @@ async def get_version_list_api( Get version list for an agent """ try: - user_id, auth_tenant_id, _ = get_current_user_info( + _, auth_tenant_id, _ = get_current_user_info( authorization, request) # Use explicit tenant_id if provided, otherwise fall back to auth tenant_id effective_tenant_id = tenant_id or auth_tenant_id - logger.info( - f"Get version list for agent_id: {agent_id}, tenant_id: {effective_tenant_id}") result = get_version_list_impl( agent_id=agent_id, tenant_id=effective_tenant_id, From 013fb45c69595dac6c920671e21f93ba1d0f01b8 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Thu, 28 May 2026 15:24:01 +0800 Subject: [PATCH 5/6] feat(asset-owner): add invitation & user management support - Add tenant_id migration and asset owner permissions/menu SQL - Expose northbound knowledge/vector database updates for asset owner visibility - Add backend auth/utils and invitation/agent/user management services - Update invitation list UI --- test/backend/app/test_agent_app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index db67ec363..b44b6f923 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -845,7 +845,9 @@ def test_list_all_agent_info_api_with_explicit_tenant_id(mocker, mock_auth_heade ) assert response.status_code == 200 - mock_list_all_agent.assert_called_once_with(tenant_id=explicit_tenant_id, user_id="test_user") + assert mock_list_all_agent.call_count == 2 + mock_list_all_agent.assert_any_call(tenant_id=explicit_tenant_id, user_id="test_user") + mock_list_all_agent.assert_any_call(tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") def test_list_all_agent_info_api_exception(mocker, mock_auth_header): From e09ae3bf07b38cc367965d3e88ed2ab98ae5b5e6 Mon Sep 17 00:00:00 2001 From: Chenlifeng <174292121+Lifeng-Chen@users.noreply.github.com> Date: Thu, 28 May 2026 15:59:42 +0800 Subject: [PATCH 6/6] feat(asset-owner): add invitation & user management support - Add tenant_id migration and asset owner permissions/menu SQL - Expose northbound knowledge/vector database updates for asset owner visibility - Add backend auth/utils and invitation/agent/user management services - Update invitation list UI --- backend/apps/agent_app.py | 1 - test/backend/app/test_agent_app.py | 203 +++++++++++++++++++---------- 2 files changed, 133 insertions(+), 71 deletions(-) diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 1ab62e4c9..77bcf93fd 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -304,7 +304,6 @@ async def list_all_agent_info_api( ) return agent_list + asset_agent_list return agent_list - return await list_all_agent_info_impl(tenant_id=tenant_id, user_id=user_id) except Exception as e: logger.error(f"Agent list error: {str(e)}") raise HTTPException( diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index b44b6f923..0f33f57c9 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -3,6 +3,7 @@ Tests all agent management API endpoints including runtime and configuration operations. """ +from apps.agent_app import agent_config_router, agent_runtime_router import atexit from unittest.mock import AsyncMock, patch, Mock, MagicMock, ANY @@ -20,8 +21,10 @@ from consts.const import ASSET_OWNER_TENANT_ID # Filter out deprecation warnings from third-party libraries -warnings.filterwarnings("ignore", category=DeprecationWarning, module="pyiceberg") -pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pyiceberg.*") +warnings.filterwarnings( + "ignore", category=DeprecationWarning, module="pyiceberg") +pytestmark = pytest.mark.filterwarnings( + "ignore::DeprecationWarning:pyiceberg.*") # Dynamically determine the backend path - MUST BE FIRST current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -45,8 +48,10 @@ minio_mock = MagicMock() minio_mock._ensure_bucket_exists = MagicMock() minio_mock.client = MagicMock() -patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock).start() -patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None).start() +patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock).start() +patch('nexent.storage.minio_config.MinIOStorageConfig.validate', + lambda self: None).start() patch('backend.database.client.MinioClient', return_value=minio_mock).start() patch('database.client.MinioClient', return_value=minio_mock).start() patch('backend.database.client.minio_client', minio_mock).start() @@ -62,7 +67,6 @@ p.start() # Import target endpoints with all external dependencies patched -from apps.agent_app import agent_config_router, agent_runtime_router # Mock external dependencies before importing the modules that use them # Stub nexent.core.agents.agent_model.ToolConfig to satisfy type imports in consts.model @@ -116,7 +120,8 @@ def decorator(func): sys.modules['utils.thread_utils'] = MagicMock() sys.modules['utils.monitoring'] = MagicMock() sys.modules['utils.monitoring'].monitoring_manager = monitoring_manager_mock -sys.modules['utils.monitoring'].setup_fastapi_app = MagicMock(return_value=True) +sys.modules['utils.monitoring'].setup_fastapi_app = MagicMock( + return_value=True) sys.modules['agents.agent_run_manager'] = MagicMock() sys.modules['services.agent_service'] = MagicMock() sys.modules['services.skill_service'] = MagicMock() @@ -125,7 +130,6 @@ def decorator(func): sys.modules['services.agent_version_service'] = MagicMock() # Now safe to import app modules after all mocks are set up -from apps.agent_app import agent_config_router, agent_runtime_router # Create FastAPI apps for runtime and config routers @@ -272,7 +276,8 @@ def test_search_agent_info_api_success(mocker, mock_auth_header): assert response.status_code == 200 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) # Should use auth tenant_id when query parameter is not provided, and default version_no=0 - mock_get_agent_info.assert_called_once_with(123, "auth_tenant_id", 0, "user_id") + mock_get_agent_info.assert_called_once_with( + 123, "auth_tenant_id", 0, "user_id") assert response.json()["agent_id"] == 123 assert response.json()["name"] == "Test Agent" @@ -301,7 +306,8 @@ def test_search_agent_info_api_with_explicit_tenant_id(mocker, mock_auth_header) assert response.status_code == 200 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) # Should use explicit tenant_id when provided, not auth tenant_id, and default version_no=0 - mock_get_agent_info.assert_called_once_with(456, explicit_tenant_id, 0, "user_id") + mock_get_agent_info.assert_called_once_with( + 456, explicit_tenant_id, 0, "user_id") assert response.json()["agent_id"] == 456 @@ -321,7 +327,8 @@ def test_search_agent_info_api_exception(mocker, mock_auth_header): assert response.status_code == 500 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) - mock_get_agent_info.assert_called_once_with(123, "auth_tenant_id", 0, "user_id") + mock_get_agent_info.assert_called_once_with( + 123, "auth_tenant_id", 0, "user_id") assert "Agent search info error" in response.json()["detail"] @@ -333,7 +340,8 @@ def test_search_agent_info_api_exception_with_explicit_tenant_id(mocker, mock_au "apps.agent_app.get_agent_info_impl", new_callable=AsyncMock) # Mock return values and exception mock_get_user_id.return_value = ("user_id", "auth_tenant_id") - mock_get_agent_info.side_effect = Exception("Test error with explicit tenant") + mock_get_agent_info.side_effect = Exception( + "Test error with explicit tenant") # Test the endpoint with explicit tenant_id query parameter explicit_tenant_id = "explicit_tenant_999" @@ -348,7 +356,8 @@ def test_search_agent_info_api_exception_with_explicit_tenant_id(mocker, mock_au assert response.status_code == 500 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) # Should use explicit tenant_id even when exception occurs, and default version_no=0 - mock_get_agent_info.assert_called_once_with(789, explicit_tenant_id, 0, "user_id") + mock_get_agent_info.assert_called_once_with( + 789, explicit_tenant_id, 0, "user_id") assert "Agent search info error" in response.json()["detail"] @@ -358,7 +367,8 @@ def test_search_agent_info_api_with_version_no(mocker, mock_auth_header): mock_get_agent_info = mocker.patch( "apps.agent_app.get_agent_info_impl", new_callable=AsyncMock) mock_get_user_id.return_value = ("user_id", "auth_tenant_id") - mock_get_agent_info.return_value = {"agent_id": 123, "name": "Test Agent", "version_no": 2} + mock_get_agent_info.return_value = { + "agent_id": 123, "name": "Test Agent", "version_no": 2} response = config_client.post( "/agent/search_info", @@ -367,7 +377,8 @@ def test_search_agent_info_api_with_version_no(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_agent_info.assert_called_once_with(123, "auth_tenant_id", 2, "user_id") + mock_get_agent_info.assert_called_once_with( + 123, "auth_tenant_id", 2, "user_id") # get_agent_by_name_api Tests @@ -377,7 +388,8 @@ def test_search_agent_info_api_with_version_no(mocker, mock_auth_header): def test_get_agent_by_name_api_success(mocker, mock_auth_header): """Test get_agent_by_name_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_agent_by_name = mocker.patch("apps.agent_app.get_agent_by_name_impl") + mock_get_agent_by_name = mocker.patch( + "apps.agent_app.get_agent_by_name_impl") mock_get_user_id.return_value = ("user_id", "auth_tenant_id") mock_get_agent_by_name.return_value = {"agent_id": 123, "version_no": 1} @@ -388,7 +400,8 @@ def test_get_agent_by_name_api_success(mocker, mock_auth_header): assert response.status_code == 200 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) - mock_get_agent_by_name.assert_called_once_with("TestAgent", "auth_tenant_id") + mock_get_agent_by_name.assert_called_once_with( + "TestAgent", "auth_tenant_id") assert response.json()["agent_id"] == 123 assert response.json()["version_no"] == 1 @@ -396,7 +409,8 @@ def test_get_agent_by_name_api_success(mocker, mock_auth_header): def test_get_agent_by_name_api_with_explicit_tenant_id(mocker, mock_auth_header): """Test get_agent_by_name_api with explicit tenant_id.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_agent_by_name = mocker.patch("apps.agent_app.get_agent_by_name_impl") + mock_get_agent_by_name = mocker.patch( + "apps.agent_app.get_agent_by_name_impl") mock_get_user_id.return_value = ("user_id", "auth_tenant_id") mock_get_agent_by_name.return_value = {"agent_id": 123, "version_no": 1} @@ -408,8 +422,8 @@ def test_get_agent_by_name_api_with_explicit_tenant_id(mocker, mock_auth_header) ) assert response.status_code == 200 - mock_get_agent_by_name.assert_called_once_with("TestAgent", explicit_tenant_id) - + mock_get_agent_by_name.assert_called_once_with( + "TestAgent", explicit_tenant_id) def test_get_agent_by_name_api_exception(mocker, mock_auth_header): @@ -419,7 +433,6 @@ def test_get_agent_by_name_api_exception(mocker, mock_auth_header): "apps.agent_app.get_agent_info_impl", new_callable=AsyncMock) mock_get_user_id.return_value = ("user_id", "auth_tenant_id") - response = config_client.get( "/agent/by-name/NonExistentAgent", headers=mock_auth_header @@ -540,7 +553,8 @@ def test_delete_agent_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_user_info.assert_called_once_with(mock_auth_header["Authorization"], ANY) + mock_get_user_info.assert_called_once_with( + mock_auth_header["Authorization"], ANY) mock_delete_agent.assert_called_once_with(123, "test_tenant", "test_user") assert response.json() == {} @@ -564,7 +578,8 @@ def test_delete_agent_api_with_explicit_tenant_id(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_delete_agent.assert_called_once_with(456, explicit_tenant_id, "test_user") + mock_delete_agent.assert_called_once_with( + 456, explicit_tenant_id, "test_user") def test_delete_agent_api_exception(mocker, mock_auth_header): @@ -597,7 +612,8 @@ def test_delete_agent_api_exception_with_explicit_tenant_id(mocker, mock_auth_he mock_logger = mocker.patch("apps.agent_app.logger") # Mock return values and exception mock_get_user_info.return_value = ("test_user", "auth_tenant", "en") - mock_delete_agent.side_effect = Exception("Test error with explicit tenant") + mock_delete_agent.side_effect = Exception( + "Test error with explicit tenant") # Test the endpoint with explicit tenant_id query parameter explicit_tenant_id = "explicit_tenant_456" @@ -611,12 +627,15 @@ def test_delete_agent_api_exception_with_explicit_tenant_id(mocker, mock_auth_he # Assertions assert response.status_code == 500 - mock_get_user_info.assert_called_once_with(mock_auth_header["Authorization"], ANY) + mock_get_user_info.assert_called_once_with( + mock_auth_header["Authorization"], ANY) # Should use explicit tenant_id even when exception occurs - mock_delete_agent.assert_called_once_with(789, explicit_tenant_id, "test_user") + mock_delete_agent.assert_called_once_with( + 789, explicit_tenant_id, "test_user") assert "Agent delete error" in response.json()["detail"] # Verify error was logged - mock_logger.error.assert_called_once_with("Agent delete error: Test error with explicit tenant") + mock_logger.error.assert_called_once_with( + "Agent delete error: Test error with explicit tenant") def test_export_agent_api_success(mocker, mock_auth_header): @@ -632,7 +651,8 @@ def test_export_agent_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_export_agent.assert_called_once_with(123, mock_auth_header["Authorization"]) + mock_export_agent.assert_called_once_with( + 123, mock_auth_header["Authorization"]) assert response.json()["code"] == 0 assert response.json()["message"] == "success" @@ -735,7 +755,8 @@ def test_import_agent_api_duplicate_error(mocker, mock_auth_header): from consts.exceptions import SkillDuplicateError mock_import_agent = mocker.patch( "apps.agent_app.import_agent_impl", new_callable=AsyncMock) - mock_import_agent.side_effect = SkillDuplicateError(duplicate_names=["skill1", "skill2"]) + mock_import_agent.side_effect = SkillDuplicateError( + duplicate_names=["skill1", "skill2"]) response = config_client.post( "/agent/import", @@ -823,8 +844,10 @@ def test_list_all_agent_info_api_success(mocker, mock_auth_header): assert response.status_code == 200 assert mock_list_all_agent.call_count == 2 - mock_list_all_agent.assert_any_call(tenant_id="test_tenant", user_id="test_user") - mock_list_all_agent.assert_any_call(tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") + mock_list_all_agent.assert_any_call( + tenant_id="test_tenant", user_id="test_user") + mock_list_all_agent.assert_any_call( + tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") assert len(response.json()) == 4 @@ -846,8 +869,10 @@ def test_list_all_agent_info_api_with_explicit_tenant_id(mocker, mock_auth_heade assert response.status_code == 200 assert mock_list_all_agent.call_count == 2 - mock_list_all_agent.assert_any_call(tenant_id=explicit_tenant_id, user_id="test_user") - mock_list_all_agent.assert_any_call(tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") + mock_list_all_agent.assert_any_call( + tenant_id="auth_tenant", user_id="test_user") + mock_list_all_agent.assert_any_call( + tenant_id=ASSET_OWNER_TENANT_ID, user_id="test_user") def test_list_all_agent_info_api_exception(mocker, mock_auth_header): @@ -876,7 +901,8 @@ def test_list_all_agent_info_api_exception_with_explicit_tenant_id(mocker, mock_ "apps.agent_app.list_all_agent_info_impl", new_callable=AsyncMock) # Mock return values and exception mock_get_user_info.return_value = ("test_user", "auth_tenant", "en") - mock_list_all_agent.side_effect = Exception("Test error with explicit tenant") + mock_list_all_agent.side_effect = Exception( + "Test error with explicit tenant") # Test the endpoint with explicit tenant_id query parameter explicit_tenant_id = "explicit_tenant_456" @@ -888,9 +914,14 @@ def test_list_all_agent_info_api_exception_with_explicit_tenant_id(mocker, mock_ # Assertions assert response.status_code == 500 - mock_get_user_info.assert_called_once_with(mock_auth_header["Authorization"], ANY) - # Should use explicit tenant_id even when exception occurs - mock_list_all_agent.assert_called_once_with(tenant_id=explicit_tenant_id, user_id="test_user") + mock_get_user_info.assert_called_once_with( + mock_auth_header["Authorization"], ANY) + # list_all_agent_info_impl is expected to be called twice: + # - once for explicit tenant_id + # - once for asset owner tenant_id + assert mock_list_all_agent.call_count == 1 + mock_list_all_agent.assert_any_call( + tenant_id="auth_tenant", user_id="test_user") assert "Agent list error" in response.json()["detail"] @@ -987,7 +1018,8 @@ def test_get_agent_call_relationship_api_success(mocker, mock_auth_header): "tree": {"tools": [], "sub_agents": []} } - resp = config_client.get("/agent/call_relationship/1", headers=mock_auth_header) + resp = config_client.get( + "/agent/call_relationship/1", headers=mock_auth_header) assert resp.status_code == 200 mock_get_user_id.assert_called_once_with(mock_auth_header["Authorization"]) @@ -1003,7 +1035,8 @@ def test_get_agent_call_relationship_api_exception(mocker, mock_auth_header): mock_get_user_id.return_value = ("user_id_x", "tenant_abc") mock_impl.side_effect = Exception("boom") - resp = config_client.get("/agent/call_relationship/999", headers=mock_auth_header) + resp = config_client.get( + "/agent/call_relationship/999", headers=mock_auth_header) assert resp.status_code == 500 assert "Failed to get agent call relationship" in resp.json()["detail"] @@ -1082,11 +1115,13 @@ def test_regenerate_agent_name_batch_api_success(mocker, mock_auth_header): "apps.agent_app.regenerate_agent_name_batch_impl", new_callable=AsyncMock, ) - mock_impl.return_value = [{"name": "NewName", "display_name": "New Display"}] + mock_impl.return_value = [ + {"name": "NewName", "display_name": "New Display"}] payload = { "items": [ - {"agent_id": 1, "name": "AgentA", "display_name": "Agent A", "task_description": "desc"}, + {"agent_id": 1, "name": "AgentA", + "display_name": "Agent A", "task_description": "desc"}, ] } @@ -1145,7 +1180,8 @@ def test_clear_agent_new_mark_api_success(mocker, mock_auth_header): mock_clear_agent_new_mark = mocker.patch( "apps.agent_app.clear_agent_new_mark_impl", new_callable=AsyncMock) - mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "extra_info") + mock_get_user_info.return_value = ( + "test_user_id", "test_tenant_id", "extra_info") mock_clear_agent_new_mark.return_value = 1 response = config_client.put( @@ -1157,7 +1193,8 @@ def test_clear_agent_new_mark_api_success(mocker, mock_auth_header): response_data = response.json() assert response_data["message"] == "Agent NEW mark cleared successfully" assert response_data["affected_rows"] == 1 - mock_clear_agent_new_mark.assert_called_once_with(123, "test_tenant_id", "test_user_id") + mock_clear_agent_new_mark.assert_called_once_with( + 123, "test_tenant_id", "test_user_id") def test_clear_agent_new_mark_api_exception(mocker, mock_auth_header): @@ -1167,8 +1204,10 @@ def test_clear_agent_new_mark_api_exception(mocker, mock_auth_header): "apps.agent_app.clear_agent_new_mark_impl", new_callable=AsyncMock) mock_logger = mocker.patch("apps.agent_app.logger") - mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "extra_info") - mock_clear_agent_new_mark.side_effect = Exception("Database connection failed") + mock_get_user_info.return_value = ( + "test_user_id", "test_tenant_id", "extra_info") + mock_clear_agent_new_mark.side_effect = Exception( + "Database connection failed") response = config_client.put( "/agent/clear_new/456", @@ -1279,7 +1318,8 @@ def test_publish_version_api_exception(mocker, mock_auth_header): def test_compare_versions_api_success(mocker, mock_auth_header): """Test compare_versions_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_compare_versions = mocker.patch("apps.agent_app.compare_versions_impl") + mock_compare_versions = mocker.patch( + "apps.agent_app.compare_versions_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_compare_versions.return_value = { @@ -1303,7 +1343,8 @@ def test_compare_versions_api_success(mocker, mock_auth_header): def test_compare_versions_api_bad_request(mocker, mock_auth_header): """Test compare_versions_api with ValueError.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_compare_versions = mocker.patch("apps.agent_app.compare_versions_impl") + mock_compare_versions = mocker.patch( + "apps.agent_app.compare_versions_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_compare_versions.side_effect = ValueError("Version not found") @@ -1321,7 +1362,8 @@ def test_compare_versions_api_bad_request(mocker, mock_auth_header): def test_compare_versions_api_exception(mocker, mock_auth_header): """Test compare_versions_api with general exception.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_compare_versions = mocker.patch("apps.agent_app.compare_versions_impl") + mock_compare_versions = mocker.patch( + "apps.agent_app.compare_versions_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_compare_versions.side_effect = Exception("Database error") @@ -1339,7 +1381,8 @@ def test_compare_versions_api_exception(mocker, mock_auth_header): def test_get_version_list_api_success(mocker, mock_auth_header): """Test get_version_list_api success case.""" mock_get_user_info = mocker.patch("apps.agent_app.get_current_user_info") - mock_get_version_list = mocker.patch("apps.agent_app.get_version_list_impl") + mock_get_version_list = mocker.patch( + "apps.agent_app.get_version_list_impl") mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "en") mock_get_version_list.return_value = { @@ -1355,14 +1398,16 @@ def test_get_version_list_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_version_list.assert_called_once_with(agent_id=123, tenant_id="test_tenant_id") + mock_get_version_list.assert_called_once_with( + agent_id=123, tenant_id="test_tenant_id") assert len(response.json()["versions"]) == 2 def test_get_version_list_api_with_explicit_tenant_id(mocker, mock_auth_header): """Test get_version_list_api with explicit tenant_id.""" mock_get_user_info = mocker.patch("apps.agent_app.get_current_user_info") - mock_get_version_list = mocker.patch("apps.agent_app.get_version_list_impl") + mock_get_version_list = mocker.patch( + "apps.agent_app.get_version_list_impl") mock_get_user_info.return_value = ("test_user_id", "auth_tenant_id", "en") mock_get_version_list.return_value = {"versions": []} @@ -1375,13 +1420,15 @@ def test_get_version_list_api_with_explicit_tenant_id(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_version_list.assert_called_once_with(agent_id=123, tenant_id=explicit_tenant_id) + mock_get_version_list.assert_called_once_with( + agent_id=123, tenant_id=explicit_tenant_id) def test_get_version_list_api_exception(mocker, mock_auth_header): """Test get_version_list_api with exception.""" mock_get_user_info = mocker.patch("apps.agent_app.get_current_user_info") - mock_get_version_list = mocker.patch("apps.agent_app.get_version_list_impl") + mock_get_version_list = mocker.patch( + "apps.agent_app.get_version_list_impl") mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "en") mock_get_version_list.side_effect = Exception("Database error") @@ -1414,7 +1461,8 @@ def test_get_version_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_version.assert_called_once_with(agent_id=123, tenant_id="test_tenant_id", version_no=1) + mock_get_version.assert_called_once_with( + agent_id=123, tenant_id="test_tenant_id", version_no=1) assert response.json()["version_no"] == 1 @@ -1455,7 +1503,8 @@ def test_get_version_api_exception(mocker, mock_auth_header): def test_get_version_detail_api_success(mocker, mock_auth_header): """Test get_version_detail_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_version_detail = mocker.patch("apps.agent_app.get_version_detail_impl") + mock_get_version_detail = mocker.patch( + "apps.agent_app.get_version_detail_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_get_version_detail.return_value = { @@ -1481,7 +1530,8 @@ def test_get_version_detail_api_success(mocker, mock_auth_header): def test_get_version_detail_api_not_found(mocker, mock_auth_header): """Test get_version_detail_api with ValueError (not found).""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_version_detail = mocker.patch("apps.agent_app.get_version_detail_impl") + mock_get_version_detail = mocker.patch( + "apps.agent_app.get_version_detail_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_get_version_detail.side_effect = ValueError("Version not found") @@ -1498,7 +1548,8 @@ def test_get_version_detail_api_not_found(mocker, mock_auth_header): def test_get_version_detail_api_exception(mocker, mock_auth_header): """Test get_version_detail_api with general exception.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_version_detail = mocker.patch("apps.agent_app.get_version_detail_impl") + mock_get_version_detail = mocker.patch( + "apps.agent_app.get_version_detail_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_get_version_detail.side_effect = Exception("Database error") @@ -1515,7 +1566,8 @@ def test_get_version_detail_api_exception(mocker, mock_auth_header): def test_rollback_version_api_success(mocker, mock_auth_header): """Test rollback_version_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_rollback_version = mocker.patch("apps.agent_app.rollback_version_impl") + mock_rollback_version = mocker.patch( + "apps.agent_app.rollback_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_rollback_version.return_value = { @@ -1539,7 +1591,8 @@ def test_rollback_version_api_success(mocker, mock_auth_header): def test_rollback_version_api_bad_request(mocker, mock_auth_header): """Test rollback_version_api with ValueError.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_rollback_version = mocker.patch("apps.agent_app.rollback_version_impl") + mock_rollback_version = mocker.patch( + "apps.agent_app.rollback_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_rollback_version.side_effect = ValueError("Version not found") @@ -1556,7 +1609,8 @@ def test_rollback_version_api_bad_request(mocker, mock_auth_header): def test_rollback_version_api_exception(mocker, mock_auth_header): """Test rollback_version_api with general exception.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_rollback_version = mocker.patch("apps.agent_app.rollback_version_impl") + mock_rollback_version = mocker.patch( + "apps.agent_app.rollback_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_rollback_version.side_effect = Exception("Database error") @@ -1573,7 +1627,8 @@ def test_rollback_version_api_exception(mocker, mock_auth_header): def test_update_version_status_api_success(mocker, mock_auth_header): """Test update_version_status_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_update_version_status = mocker.patch("apps.agent_app.update_version_status_impl") + mock_update_version_status = mocker.patch( + "apps.agent_app.update_version_status_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_update_version_status.return_value = { @@ -1598,7 +1653,8 @@ def test_update_version_status_api_success(mocker, mock_auth_header): def test_update_version_status_api_bad_request(mocker, mock_auth_header): """Test update_version_status_api with ValueError.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_update_version_status = mocker.patch("apps.agent_app.update_version_status_impl") + mock_update_version_status = mocker.patch( + "apps.agent_app.update_version_status_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_update_version_status.side_effect = ValueError("Invalid status") @@ -1616,7 +1672,8 @@ def test_update_version_status_api_bad_request(mocker, mock_auth_header): def test_update_version_status_api_exception(mocker, mock_auth_header): """Test update_version_status_api with general exception.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_update_version_status = mocker.patch("apps.agent_app.update_version_status_impl") + mock_update_version_status = mocker.patch( + "apps.agent_app.update_version_status_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_update_version_status.side_effect = Exception("Database error") @@ -1644,7 +1701,8 @@ def test_update_version_api_success(mocker, mock_auth_header): response = config_client.put( "/agent/123/versions/1", - json={"version_name": "Updated Version", "release_note": "Updated note"}, + json={"version_name": "Updated Version", + "release_note": "Updated note"}, headers=mock_auth_header ) @@ -1752,7 +1810,8 @@ def test_delete_version_api_exception(mocker, mock_auth_header): def test_get_current_version_api_success(mocker, mock_auth_header): """Test get_current_version_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_current_version = mocker.patch("apps.agent_app.get_current_version_impl") + mock_get_current_version = mocker.patch( + "apps.agent_app.get_current_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_get_current_version.return_value = { @@ -1767,17 +1826,20 @@ def test_get_current_version_api_success(mocker, mock_auth_header): ) assert response.status_code == 200 - mock_get_current_version.assert_called_once_with(agent_id=123, tenant_id="test_tenant_id") + mock_get_current_version.assert_called_once_with( + agent_id=123, tenant_id="test_tenant_id") assert response.json()["version_no"] == 1 def test_get_current_version_api_not_found(mocker, mock_auth_header): """Test get_current_version_api with ValueError (not found).""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_current_version = mocker.patch("apps.agent_app.get_current_version_impl") + mock_get_current_version = mocker.patch( + "apps.agent_app.get_current_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") - mock_get_current_version.side_effect = ValueError("No published version found") + mock_get_current_version.side_effect = ValueError( + "No published version found") response = config_client.get( "/agent/123/current_version", @@ -1791,7 +1853,8 @@ def test_get_current_version_api_not_found(mocker, mock_auth_header): def test_get_current_version_api_exception(mocker, mock_auth_header): """Test get_current_version_api with general exception.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") - mock_get_current_version = mocker.patch("apps.agent_app.get_current_version_impl") + mock_get_current_version = mocker.patch( + "apps.agent_app.get_current_version_impl") mock_get_user_id.return_value = ("test_user_id", "test_tenant_id") mock_get_current_version.side_effect = Exception("Database error") @@ -1810,7 +1873,7 @@ def test_list_published_agents_api_success(mocker, mock_auth_header): mock_get_user_info = mocker.patch("apps.agent_app.get_current_user_info") mock_list_published_agents = mocker.patch( "apps.agent_app.list_published_agents_impl", new_callable=AsyncMock) - + mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "en") mock_list_published_agents.return_value = [ {"agent_id": 1, "name": "Agent 1", "published_version_no": 1}, @@ -1834,7 +1897,7 @@ def test_list_published_agents_api_exception(mocker, mock_auth_header): mock_get_user_info = mocker.patch("apps.agent_app.get_current_user_info") mock_list_published_agents = mocker.patch( "apps.agent_app.list_published_agents_impl", new_callable=AsyncMock) - + mock_get_user_info.return_value = ("test_user_id", "test_tenant_id", "en") mock_list_published_agents.side_effect = Exception("Database error")