diff --git a/backend/contracts/authentication.py b/backend/contracts/authentication.py index ea32945a7a..c75f19b0c8 100644 --- a/backend/contracts/authentication.py +++ b/backend/contracts/authentication.py @@ -19,6 +19,7 @@ class AuthenticatedIdentity: language: str = "zh" token_id: Optional[int] = None kind: CredentialKind = CredentialKind.SESSION + roles: tuple[str, ...] = () @dataclass diff --git a/backend/contracts/configuration.py b/backend/contracts/configuration.py index 7ae6b4a2ac..ca539e1a7c 100644 --- a/backend/contracts/configuration.py +++ b/backend/contracts/configuration.py @@ -5,6 +5,13 @@ from pathlib import Path +def validate_deployment_version(value: str) -> str: + """Reject retired authentication modes instead of silently enabling a bypass.""" + if value.strip().lower() == "speed": + raise ValueError("The speed deployment mode has been retired; configure standard authentication") + return value + + def parse_positive_int(raw_value: object, name: str, default: int) -> int: """Parse a positive integer, falling back to ``default`` when unset.""" value = default if raw_value is None else raw_value diff --git a/backend/contracts/constants/constants.py b/backend/contracts/constants/constants.py index 765f4d54ab..05b5e6e657 100644 --- a/backend/contracts/constants/constants.py +++ b/backend/contracts/constants/constants.py @@ -139,7 +139,7 @@ ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL = 'ASSET_OWNER_USE_OAUTH' # 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', 'ASSET_OWNER'} +CAN_EDIT_ALL_USER_ROLES = {'SU', 'ADMIN', 'ASSET_OWNER'} # Permission constants used by list endpoints (e.g., /agent/list, /mcp/list). PERMISSION_READ = 'READ_ONLY' PERMISSION_EDIT = 'EDIT' @@ -166,7 +166,7 @@ QUOTA_PAYLOAD_DESCRIPTION = 'Quota payload' PERSONAL_KB_CAPACITY_READ_PERMISSION = 'kb.capacity:read' PERSONAL_KB_CAPACITY_MANAGE_PERMISSION = 'kb.capacity:manage' -TARGET_TENANT_ID_DESCRIPTION = 'Target tenant ID (SU/SPEED only)' +TARGET_TENANT_ID_DESCRIPTION = 'Target tenant ID (SU only)' INTERNAL_INDEX_NAME_DESC = 'Internal index_name from knowledge_record_t' ACTIVE_STATUSES = ('UPLOADING', 'UPLOADED', 'PROCESSING', 'FORWARDING', 'FAILED', 'COMPLETED') HIDDEN_STATUSES = ('DELETE_REQUESTED', 'DELETED') diff --git a/backend/contracts/constants/environment.py b/backend/contracts/constants/environment.py index cfe4d9a436..a258715f13 100644 --- a/backend/contracts/constants/environment.py +++ b/backend/contracts/constants/environment.py @@ -2,7 +2,7 @@ import os from pathlib import Path from dotenv import load_dotenv -from ..configuration import parse_positive_float, parse_positive_int, parse_port, _require_positive, _build_otlp_headers, _resolve_app_version +from ..configuration import _build_otlp_headers, _require_positive, _resolve_app_version, parse_port, parse_positive_float, parse_positive_int, validate_deployment_version # Environment initialization # Repository-root callers import this module through the backend namespace. @@ -174,8 +174,7 @@ CAPACITY_VISIBILITY_ENABLED = os.getenv('CAPACITY_VISIBILITY_ENABLED', 'true').lower() in ('true', '1', 'yes', 'on') # Deployment Version Configuration -DEPLOYMENT_VERSION = os.getenv('DEPLOYMENT_VERSION', 'full') -IS_SPEED_MODE = DEPLOYMENT_VERSION == 'speed' +DEPLOYMENT_VERSION = validate_deployment_version(os.getenv('DEPLOYMENT_VERSION', 'full')) # AIDP Knowledge Base configuration ENABLE_AIDP_KNOWLEDGE = os.getenv('ENABLE_AIDP_KNOWLEDGE', 'false').lower() in ('true', '1', 'yes', 'on') @@ -350,7 +349,6 @@ DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0) - # Telemetry and Monitoring Configuration (OTLP Protocol) MONITORING_PROVIDER = os.getenv('MONITORING_PROVIDER', '') ENABLE_TELEMETRY_RAW = os.getenv('ENABLE_TELEMETRY') @@ -383,7 +381,7 @@ MONITORING_EXCLUDED_SPAN_NAMES = os.getenv('MONITORING_EXCLUDED_SPAN_NAMES', 'knowledge.process.submit,knowledge.upload.batch') MONITORING_PROJECT_NAME = os.getenv('MONITORING_PROJECT_NAME', '') MONITORING_DASHBOARD_URL = os.getenv('MONITORING_DASHBOARD_URL', '') -MONITORING_DASHBOARD_ALLOWED_ROLES = os.getenv('MONITORING_DASHBOARD_ALLOWED_ROLES', 'SU,SPEED') +MONITORING_DASHBOARD_ALLOWED_ROLES = os.getenv('MONITORING_DASHBOARD_ALLOWED_ROLES', 'SU') MONITORING_TRACE_CONTENT_MODE = os.getenv('MONITORING_TRACE_CONTENT_MODE', 'summary') MONITORING_TRACE_MAX_CHARS = os.getenv('MONITORING_TRACE_MAX_CHARS', '4000') MONITORING_TRACE_MAX_ITEMS = os.getenv('MONITORING_TRACE_MAX_ITEMS', '20') diff --git a/backend/contracts/errors/exceptions.py b/backend/contracts/errors/exceptions.py index 3123e3a1d7..68cdaa94e3 100644 --- a/backend/contracts/errors/exceptions.py +++ b/backend/contracts/errors/exceptions.py @@ -137,6 +137,10 @@ class TokenExpiredError(UnauthorizedError): pass +class RoleAssignmentConflict(ValueError): + """An otherwise valid role change violates a membership invariant.""" + + class ForbiddenError(Exception): """Raised when an authenticated user lacks permission.""" diff --git a/backend/contracts/identity.py b/backend/contracts/identity.py index d25f18b11f..aaf2fc831b 100644 --- a/backend/contracts/identity.py +++ b/backend/contracts/identity.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator +from contracts.roles import normalize_roles + # User authentication related request models class UserSignUpRequest(BaseModel): @@ -55,7 +57,16 @@ class UserUpdateRequest(BaseModel): username: Optional[str] = Field(None, min_length=1, max_length=50) email: Optional[EmailStr] = None - role: Optional[str] = Field(None, pattern="^(ADMIN|DEV|USER)$") + role: Optional[str] = Field(None, pattern="^(ADMIN|DEV|USER|OPS)$") + roles: Optional[List[str]] = None + + @model_validator(mode="after") + def validate_role_input(self): + if self.role is not None and self.roles is not None: + raise ValueError("Use either role or roles, not both") + if self.roles is not None: + self.roles = list(normalize_roles(self.roles, ordinary_only=True)) + return self class UserDeleteRequest(BaseModel): @@ -263,9 +274,8 @@ class InvitationCreateRequest(BaseModel): tenant_id: str = Field( ..., min_length=1, description="Tenant ID where the invitation belongs" ) - code_type: str = Field( - ..., description="Invitation code type (ADMIN_INVITE, DEV_INVITE, USER_INVITE)" - ) + code_type: Optional[str] = None + roles: Optional[List[str]] = None invitation_code: Optional[str] = Field( None, description="Custom invitation code (auto-generated if not provided)" ) @@ -273,6 +283,14 @@ class InvitationCreateRequest(BaseModel): capacity: int = Field(default=1, ge=1, description="Maximum usage capacity") expiry_date: Optional[str] = Field(None, description="Expiry date in ISO format") + @model_validator(mode="after") + def validate_invitation_roles(self): + if self.roles is not None: + self.roles = list(normalize_roles(self.roles, ordinary_only=True)) + elif self.code_type is None: + raise ValueError("Invitation roles are required") + return self + class InvitationUpdateRequest(BaseModel): """Request model for updating invitation code""" @@ -281,6 +299,13 @@ class InvitationUpdateRequest(BaseModel): expiry_date: Optional[str] = Field(None, description="New expiry date") group_ids: Optional[List[int]] = Field(None, description="New group IDs") + @model_validator(mode="before") + @classmethod + def reject_role_mutation(cls, value): + if isinstance(value, dict) and ("roles" in value or "code_type" in value): + raise ValueError("Invitation roles are immutable") + return value + class InvitationResponse(BaseModel): """Response model for invitation information""" diff --git a/backend/contracts/roles.py b/backend/contracts/roles.py new file mode 100644 index 0000000000..2e971f9bbb --- /dev/null +++ b/backend/contracts/roles.py @@ -0,0 +1,69 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Built-in role contracts shared by identity and authorization boundaries.""" + +from collections.abc import Iterable, Mapping + +ORDINARY_ROLES = frozenset({"ADMIN", "DEV", "USER", "OPS"}) +SPECIAL_ROLES = frozenset({"SU", "ASSET_OWNER"}) +ROLE_ORDER = ("SU", "ADMIN", "DEV", "USER", "OPS", "ASSET_OWNER") +INVITATION_ROLES = { + "ADMIN_INVITE": ("ADMIN",), + "DEV_INVITE": ("DEV",), + "USER_INVITE": ("USER",), + "ASSET_OWNER_INVITE": ("ASSET_OWNER",), +} + + +def normalize_roles(value, *, ordinary_only: bool = False, allow_empty: bool = False) -> tuple[str, ...]: + """Validate complete role sets; never silently discard retired or unknown roles.""" + if isinstance(value, str): + values = (value,) + elif value is None: + values = () + elif isinstance(value, Iterable) and not isinstance(value, (bytes, Mapping)): + values = tuple(value) + else: + raise ValueError("Roles must be a list of built-in role codes") + if any(not isinstance(role, str) or not role.strip() for role in values): + raise ValueError("Roles must not contain empty values") + roles = {role.strip().upper() for role in values} + allowed = ORDINARY_ROLES if ordinary_only else ORDINARY_ROLES | SPECIAL_ROLES + if roles - allowed: + raise ValueError("Unknown or retired role") + if not roles and not allow_empty: + raise ValueError("At least one role is required") + if roles & SPECIAL_ROLES and len(roles) != 1: + raise ValueError("Special identities cannot be combined with ordinary roles") + return tuple(role for role in ROLE_ORDER if role in roles) + + +def roles_from_record(record: Mapping | None) -> tuple[str, ...]: + """Use the array as authority, with single-role fallback only for unmigrated records.""" + if not record: + return () + values = record.get("user_roles", record.get("roles")) + if values is None: + values = record.get("user_role") or record.get("role") + return normalize_roles(values, allow_empty=True) + + +def has_any_role(value, allowed: Iterable[str]) -> bool: + """Fail closed for malformed identities while allowing legacy single-role callers.""" + try: + return bool(set(normalize_roles(value, allow_empty=True)) & set(allowed)) + except ValueError: + return False + + +def primary_role(value, preferred: str | None = None) -> str: + """Return a compatibility display value, never an authorization decision.""" + roles = normalize_roles(value) + return preferred if preferred in roles else roles[0] + + +def invitation_roles(record: Mapping) -> tuple[str, ...]: + """Resolve old invitation types without guessing a role for unknown new types.""" + value = record.get("roles") + if value is None: + value = INVITATION_ROLES.get(record.get("code_type")) + return normalize_roles(value) diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 3c6a4b5028..367ad71090 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -27,15 +27,15 @@ import httpx from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile from fastapi.responses import JSONResponse, StreamingResponse -from nexent.core.concurrency import run_blocking from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy.exc import IntegrityError from starlette.background import BackgroundTask +import gateway.authentication.auth as auth_utils_module from contracts.constants.environment import AIDP_API_KEY, AIDP_SERVER_URL from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, UnauthorizedError -from management.db.identity.user_tenant import get_user_role_by_tenant +from contracts.roles import has_any_role from ext_components.aidp.consts.aidp_exceptions import ( AidpGroupValidationError, AidpKbNotFoundError, @@ -78,8 +78,10 @@ update_aidp_kb_impl, upload_aidp_docs_impl, ) -import gateway.authentication.auth as auth_utils_module - +from management.db.identity.user_tenant import get_user_role_by_tenant +from management.services.permission.identity.display import enrich_cas_usernames +from management.services.tag.library import TagManagementService +from nexent.core.concurrency import run_blocking aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -144,7 +146,6 @@ def _cleanup_document_assignments_for_deleted_knowledge_base( ) -> None: """Keep document assignment usage in sync after AIDP confirms knowledge-base deletion.""" - from management.services.tag.library import TagManagementService TagManagementService.cleanup_document_assignments_for_knowledge_base( tenant_id, "aidp", knowledge_base_id, user_id @@ -435,7 +436,7 @@ def _is_user_role(user_id: str, tenant_id: str) -> bool: provisioned. """ role = get_user_role_by_tenant(user_id, tenant_id) - return (role or "USER").upper() == "USER" + return has_any_role(role, {"USER"}) and not has_any_role(role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}) def _current_accessible_rows( @@ -680,7 +681,6 @@ async def resolve_detail(row: dict) -> tuple[dict, str]: sum(1 for row in page_rows if not _has_kb_card_metadata(row)), ) - from management.services.permission.identity.display import enrich_cas_usernames await run_blocking( "aidp-list-creator-names", enrich_cas_usernames, @@ -878,7 +878,6 @@ async def get_knowledge_base( detail["group_ids"] = permission_record.get("group_ids") or [] detail["created_by"] = permission_record.get("owner_user_id") detail["creator_name"] = permission_record.get("creator_name") - from management.services.permission.identity.display import enrich_cas_usernames await run_blocking( "aidp-detail-creator-name", enrich_cas_usernames, diff --git a/backend/ext_components/aidp/services/aidp_permission_service.py b/backend/ext_components/aidp/services/aidp_permission_service.py index 2babe555a1..dd1d5ed56a 100644 --- a/backend/ext_components/aidp/services/aidp_permission_service.py +++ b/backend/ext_components/aidp/services/aidp_permission_service.py @@ -15,7 +15,7 @@ 2. ``PRIVATE`` -> no access for every non-creator, including management roles. 3. USER -> no access to any non-owned KB. - 4. Management roles (SU/ADMIN/SPEED/ASSET_OWNER) -> EDIT for shared KBs. + 4. Management roles (SU/ADMIN/ASSET_OWNER) -> EDIT for shared KBs. 5. DEV with a group intersection -> ``ingroup_permission``. 6. Empty ``group_ids`` or no intersection -> no access. @@ -32,8 +32,7 @@ from typing import Any, Iterable, Sequence from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES -from management.db.identity import group as group_db_module -from management.db.identity import user_tenant as user_tenant_db_module +from contracts.roles import has_any_role from ext_components.aidp.consts.aidp_exceptions import ( AidpGroupValidationError, AidpKbConflictError, @@ -41,7 +40,8 @@ AidpKbPermissionDeniedError, ) from ext_components.aidp.database import aidp_permission_db - +from management.db.identity import group as group_db_module +from management.db.identity import user_tenant as user_tenant_db_module logger = logging.getLogger("aidp_permission_service") @@ -173,7 +173,7 @@ def _resolve_permission( matched_group_ids=tuple(), ) - if role == "USER": + if has_any_role(role, {"USER"}) and not has_any_role(role, CAN_EDIT_ALL_USER_ROLES): return AidpPermissionDecision( kb_id=kb_id, tenant_id=tenant_id, @@ -183,7 +183,7 @@ def _resolve_permission( matched_group_ids=tuple(), ) - if role in CAN_EDIT_ALL_USER_ROLES: + if has_any_role(role, CAN_EDIT_ALL_USER_ROLES): return AidpPermissionDecision( kb_id=kb_id, tenant_id=tenant_id, @@ -193,7 +193,7 @@ def _resolve_permission( matched_group_ids=tuple(), ) - if role != "DEV" or not record_groups: + if not has_any_role(role, {'DEV'}) or not record_groups: return AidpPermissionDecision( kb_id=kb_id, tenant_id=tenant_id, diff --git a/backend/gateway/api/northbound.py b/backend/gateway/api/northbound.py index 4a618dea08..75d1b9faf3 100644 --- a/backend/gateway/api/northbound.py +++ b/backend/gateway/api/northbound.py @@ -8,18 +8,40 @@ import logging import re from http import HTTPStatus -from typing import Annotated, Any, Dict, List, Optional, Literal +from typing import Annotated, Any, Dict, List, Literal, Optional from urllib.parse import unquote, urlparse import httpx -from contracts.constants.environment import MINIO_ENDPOINT +from fastapi import ( + APIRouter, + Body, + File, + Form, + Header, + HTTPException, + Path, + Query, + Request, + UploadFile, +) +from fastapi import Path as PathParam +from fastapi.responses import ( + JSONResponse, + RedirectResponse, + Response, + StreamingResponse, +) +from pydantic import ValidationError as PydanticValidationError + from contracts.a2a import JSONRPCRequest from contracts.agent import reject_legacy_agent_fields from contracts.authentication import NorthboundContext -from contracts.constants.constants import ASSET_OWNER_TENANT_ID -from contracts.constants.constants import RATE_LIMIT_EXCEEDED_DETAIL -from contracts.knowledge import VectorDatabaseType -from contracts.constants.constants import NORTHBOUND_SERVICE_NAME +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + NORTHBOUND_SERVICE_NAME, + RATE_LIMIT_EXCEEDED_DETAIL, +) +from contracts.constants.environment import MINIO_ENDPOINT from contracts.conversation import GenerateTitleRequest from contracts.errors.exceptions import ( AppException, @@ -36,29 +58,12 @@ tenant_resource_limit_error_payload, ) from contracts.identity import ApiKeyTargetRequest, ApiUserBatchCreateRequest -from contracts.knowledge import HybridSearchRequest, ProcessParams +from contracts.knowledge import HybridSearchRequest, ProcessParams, VectorDatabaseType from contracts.tool import ToolParamsRequest -from fastapi import ( - APIRouter, - Body, - File, - Form, - Header, - HTTPException, - Path, - Query, - Request, - UploadFile, -) -from fastapi import Path as PathParam -from fastapi.responses import ( - JSONResponse, - RedirectResponse, - Response, - StreamingResponse, -) from gateway.authentication.auth import generate_session_jwt, get_user_language -from gateway.authentication.northbound_auth import get_northbound_context as _get_northbound_context +from gateway.authentication.northbound_auth import ( + get_northbound_context as _get_northbound_context, +) from management.db.identity.user_tenant import get_user_role_by_tenant from management.db.knowledge_base.repository import get_index_name_by_knowledge_name from management.services.agent.a2a.service import ( @@ -88,8 +93,10 @@ refresh_user_api_key, revoke_user_api_keys, ) -from management.utils.file_http import build_content_disposition_header, build_storage_proxy_url -from pydantic import ValidationError as PydanticValidationError +from management.utils.file_http import ( + build_content_disposition_header, + build_storage_proxy_url, +) from services.northbound_service import ( generate_conversation_title, get_agent_info_by_name_for_northbound, @@ -127,15 +134,13 @@ def _resolve_proxy_download_filename(presigned_url: str, content_disposition: st return filename or "download" - - @router.get("/health") async def health_check(): return {"status": "healthy", "service": "northbound-api"} -def _role_for_context(user_id: str, tenant_id: str) -> str: - return get_user_role_by_tenant(user_id, tenant_id).upper() +def _role_for_context(user_id: str, tenant_id: str) -> tuple[str, ...]: + return get_user_role_by_tenant(user_id, tenant_id) def _raise_api_key_http_exception(exc: Exception) -> None: @@ -454,7 +459,6 @@ async def stop_chat_stream( conversation_id: int, meta_data: Optional[str] = Query(None, description="Optional metadata as JSON string"), ): - import json parsed_meta_data = None if meta_data: @@ -643,7 +647,6 @@ async def update_convs_title( meta_data: Optional[str] = Query(None, description="Optional metadata as JSON string"), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), ): - import json parsed_meta_data = None if meta_data: diff --git a/backend/gateway/authentication/auth.py b/backend/gateway/authentication/auth.py index 5eda2178bd..5e1611a921 100644 --- a/backend/gateway/authentication/auth.py +++ b/backend/gateway/authentication/auth.py @@ -14,18 +14,34 @@ import uuid from datetime import datetime, timedelta from http import HTTPStatus +from types import SimpleNamespace from typing import Any, Dict, List, Optional, Tuple import jwt -from contracts.constants.environment import DEBUG_JWT_EXPIRE_SECONDS, IS_SPEED_MODE, JWT_EXPIRY_SECONDS, JWT_SECRET, JWT_ISSUER +from fastapi import Depends, Header, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + from contracts.authentication import ( AuthenticatedIdentity, CredentialKind, NorthboundContext, PendingOAuthIdentity, ) -from contracts.constants.constants import ASSET_OWNER_ROLE, ASSET_OWNER_TENANT_ID, DEFAULT_TENANT_ID, DEFAULT_USER_ID, OAUTH_PENDING_PURPOSE -from contracts.constants.constants import LANGUAGE +from contracts.constants.constants import ( + ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, + DEFAULT_TENANT_ID, + DEFAULT_USER_ID, + LANGUAGE, + MOCK_JWT_SECRET_KEY, + OAUTH_PENDING_PURPOSE, +) +from contracts.constants.environment import ( + DEBUG_JWT_EXPIRE_SECONDS, + JWT_EXPIRY_SECONDS, + JWT_ISSUER, + JWT_SECRET, +) from contracts.errors.exceptions import ( LimitExceededError, OAuthLinkError, @@ -33,8 +49,17 @@ TokenExpiredError, UnauthorizedError, ) -from fastapi import Depends, Header, HTTPException, Request -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from contracts.roles import ( + has_any_role, + normalize_roles, + primary_role, + roles_from_record, +) +from gateway.authentication.credentials import ( + extract_bearer_token, + require_active_account, +) +from gateway.authentication.northbound_auth import authenticate_api_key from infrastructure.security.internal_tokens import ( INTERNAL_RUNTIME_JWT_AUDIENCE, INTERNAL_RUNTIME_JWT_ISSUER, @@ -47,6 +72,7 @@ calculate_hmac_signature, validate_timestamp, ) +from management.db.identity.cas_session import is_cas_session_active from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.utils.request_context import get_user_language from permissions.models import CurrentUser @@ -54,8 +80,6 @@ # Module logger logger = logging.getLogger(__name__) -from contracts.constants.constants import MOCK_JWT_SECRET_KEY - def resolve_tenant_id_from_user_tenant_record(user_tenant: Dict[str, Any]) -> str: """ @@ -68,8 +92,8 @@ def resolve_tenant_id_from_user_tenant_record(user_tenant: Dict[str, Any]) -> st if tenant_id: return tenant_id - user_role = (user_tenant.get("user_role") or "").upper() - if user_role == ASSET_OWNER_ROLE: + user_role = roles_from_record(user_tenant) + if has_any_role(user_role, {ASSET_OWNER_ROLE}): return ASSET_OWNER_TENANT_ID return DEFAULT_TENANT_ID @@ -86,10 +110,6 @@ def get_jwt_expiry_seconds(token: str) -> int: int: Token validity period (seconds), returns configured default if parsing fails """ try: - # Speed mode: treat sessions as never expiring - if IS_SPEED_MODE: - # 10 years in seconds - return 10 * 365 * 24 * 60 * 60 # Ensure token is pure JWT, remove possible Bearer prefix jwt_token = extract_bearer_token(token) @@ -126,9 +146,6 @@ def calculate_expires_at(token: Optional[str] = None) -> int: Returns: int: Expiration time timestamp """ - # Speed mode: far future expiration - if IS_SPEED_MODE: - return int((datetime.now() + timedelta(days=3650)).timestamp()) expiry_seconds = get_jwt_expiry_seconds(token) if token else JWT_EXPIRY_SECONDS return int((datetime.now() + timedelta(seconds=expiry_seconds)).timestamp()) @@ -152,16 +169,6 @@ def _decode_jwt_token_for_expiry(token: str) -> dict: ) -def require_active_account(user_id: str) -> dict: - """Reject deleted identities before any business authorization.""" - from management.db.identity.account import get_account - - account = get_account(user_id) - if not account or account["delete_flag"] != "N": - raise TokenExpiredError("Account is unavailable") - return account - - def _decode_jwt_token(authorization: str) -> dict: """ Extract user ID from JWT token after verifying signature and expiration. @@ -244,7 +251,6 @@ def ensure_cas_session_active_from_authorization(authorization: Optional[str]) - if not session_id: return - from management.db.identity.cas_session import is_cas_session_active if not is_cas_session_active(str(session_id)): raise TokenExpiredError("CAS session has expired or been revoked") @@ -260,10 +266,6 @@ def _resolve_session_user(authorization: Optional[str] = None) -> tuple[str, str Returns: tuple[str, str]: (user_id, tenant_id) """ - # 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") - return DEFAULT_USER_ID, DEFAULT_TENANT_ID # In normal mode, missing auth header means unauthorized - return 401, not default user if authorization is None or ( @@ -280,6 +282,10 @@ def _resolve_session_user(authorization: Optional[str] = None) -> tuple[str, str ensure_cas_session_active_from_authorization(authorization) user_tenant_record = get_user_tenant_by_user_id(user_id) + if not user_tenant_record: + raise TokenExpiredError("No active tenant relationship") + if not roles_from_record(user_tenant_record): + raise TokenExpiredError("No active roles for this account") 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}") @@ -299,18 +305,19 @@ def _resolve_session_user(authorization: Optional[str] = None) -> tuple[str, str def get_current_user_context( authorization: Optional[str] = None, -) -> tuple[str, str, str]: - """Return the authenticated user ID, tenant ID, and normalized role.""" +) -> tuple[str, str, tuple[str, ...]]: + """Read current membership roles on every authenticated request.""" user_id, tenant_id = get_current_user_id(authorization) - if IS_SPEED_MODE: - return user_id, tenant_id, "SPEED" user_tenant_record = get_user_tenant_by_user_id(user_id) if not user_tenant_record: raise TokenExpiredError("User tenant relationship not found") - user_role = str(user_tenant_record.get("user_role") or "").upper() + try: + user_role = roles_from_record(user_tenant_record) + except ValueError as exc: + raise TokenExpiredError("User roles are unavailable") from exc if not user_role: raise TokenExpiredError("User role not found") @@ -390,7 +397,6 @@ def get_current_user_info( def validate_token(token: str) -> Tuple[bool, Optional[Any]]: """Apply gateway identity policy and return the existing user response shape.""" - from types import SimpleNamespace try: user_id, _ = get_current_user_id(token) @@ -425,22 +431,6 @@ def get_current_user_id(authorization: Optional[str] = None) -> tuple[str, str]: return principal.user_id, principal.tenant_id -def extract_bearer_token(authorization: Optional[str]) -> Optional[str]: - """Accept Bearer credentials and the established single-token legacy form.""" - if not authorization: - return None - if not isinstance(authorization, str): - raise TokenExpiredError("Invalid authorization header") - parts = authorization.strip().split() - if not parts: - return None - if len(parts) == 1 and parts[0].lower() != "bearer": - return parts[0] - if len(parts) == 2 and parts[0].lower() == "bearer": - return parts[1] - raise TokenExpiredError("Invalid authorization header") - - def verify_internal_runtime_jwt(authorization: Optional[str]) -> tuple[str, str]: """Verify a northbound runtime token and return its user and tenant.""" if not JWT_SECRET: @@ -531,16 +521,16 @@ def authenticate_identity( user_id, tenant_id, role = get_current_user_context(authorization) else: user_id, tenant_id = _resolve_session_user(authorization) - role = "SPEED" if IS_SPEED_MODE else "" + role = () return AuthenticatedIdentity( user_id, tenant_id, - role=role, + role=primary_role(role) if role else "", + roles=normalize_roles(role, allow_empty=True), language=get_user_language(request), kind=kind, ) if kind == CredentialKind.API_KEY: - from gateway.authentication.northbound_auth import authenticate_api_key return authenticate_api_key(authorization, request) if kind == CredentialKind.INTERNAL: @@ -559,9 +549,9 @@ def authenticate( header_token = authorization if not header_token and _credentials: header_token = _credentials.credentials - if not header_token and not IS_SPEED_MODE: + if not header_token: raise HTTPException(status_code=401, detail="Not authenticated") principal = authenticate_identity(header_token, require_role=True) return CurrentUser( - user_id=principal.user_id, tenant_id=principal.tenant_id, role=principal.role + user_id=principal.user_id, tenant_id=principal.tenant_id, role=principal.role, roles=principal.roles ) diff --git a/backend/gateway/authentication/credentials.py b/backend/gateway/authentication/credentials.py new file mode 100644 index 0000000000..b46c7f9203 --- /dev/null +++ b/backend/gateway/authentication/credentials.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Credential parsing and active-account checks shared by authentication policies.""" + +from typing import Optional + +from contracts.errors.exceptions import TokenExpiredError +from management.db.identity import account as account_repository + + +def require_active_account(user_id: str) -> dict: + """Reject deleted identities before any business authorization.""" + account = account_repository.get_account(user_id) + if not account or account["delete_flag"] != "N": + raise TokenExpiredError("Account is unavailable") + return account + + +def extract_bearer_token(authorization: Optional[str]) -> Optional[str]: + """Accept Bearer credentials and the established single-token legacy form.""" + if not authorization: + return None + if not isinstance(authorization, str): + raise TokenExpiredError("Invalid authorization header") + parts = authorization.strip().split() + if not parts: + return None + if len(parts) == 1 and parts[0].lower() != "bearer": + return parts[0] + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + raise TokenExpiredError("Invalid authorization header") diff --git a/backend/gateway/authentication/northbound_auth.py b/backend/gateway/authentication/northbound_auth.py index 359f8f43e1..1203f79bbc 100644 --- a/backend/gateway/authentication/northbound_auth.py +++ b/backend/gateway/authentication/northbound_auth.py @@ -8,6 +8,8 @@ from http import HTTPStatus from typing import Dict, Optional, Tuple +from fastapi import HTTPException, Request + from contracts.authentication import ( AuthenticatedIdentity, CredentialKind, @@ -16,11 +18,15 @@ from contracts.constants.constants import DEFAULT_TENANT_ID from contracts.errors.exceptions import ( LimitExceededError, + SignatureValidationError, TokenExpiredError, UnauthorizedError, ) -from fastapi import HTTPException, Request -from gateway.authentication.auth import extract_bearer_token, require_active_account +from contracts.roles import roles_from_record +from gateway.authentication.credentials import ( + extract_bearer_token, + require_active_account, +) from infrastructure.security.signatures import ( calculate_hmac_signature, validate_timestamp, @@ -84,7 +90,6 @@ def validate_aksk_authentication( Returns True when valid, otherwise raises domain exceptions. """ - from contracts.errors.exceptions import SignatureValidationError try: access_key, ts, sig = extract_aksk_headers(headers) @@ -187,6 +192,9 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: user_tenant_record = get_user_tenant_by_user_id(user_id) if not user_tenant_record or not user_tenant_record.get("tenant_id"): raise TokenExpiredError("No active tenant relationship for this access key") + roles = roles_from_record(user_tenant_record) + if not roles: + raise TokenExpiredError("No active roles for this account") tenant_id = user_tenant_record["tenant_id"] return { @@ -196,7 +204,6 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: } - def authenticate_api_key(authorization: Optional[str], request=None) -> AuthenticatedIdentity: valid, record = validate_bearer_token(authorization) if not valid or not record: diff --git a/backend/infrastructure/security/account_cli.py b/backend/infrastructure/security/account_cli.py index 21bf9709c0..a1edb9e258 100644 --- a/backend/infrastructure/security/account_cli.py +++ b/backend/infrastructure/security/account_cli.py @@ -5,21 +5,22 @@ import json import sys +from sqlalchemy import select from contracts.errors.exceptions import AccountConflictError +from infrastructure.security.passwords import hash_password +from management.db.client import get_db_session +from management.db.identity.account import ( + create_account, + get_account_by_email, + lock_account_keys, +) +from management.db.identity.user_tenant import insert_user_tenant +from management.db.models import UserTenant def bootstrap_super_admin(email: str, password: str, *, check_only=False): - from management.db.client import get_db_session - from management.db.identity.account import ( - create_account, - get_account_by_email, - lock_account_keys, - ) - from management.db.identity.user_tenant import insert_user_tenant - from infrastructure.security.passwords import hash_password - from sqlalchemy import select - from management.db.models import UserTenant + email = email.strip().lower() with get_db_session() as session: @@ -39,7 +40,7 @@ def bootstrap_super_admin(email: str, password: str, *, check_only=False): if ( account["delete_flag"] != "N" or not members - or any(m.user_role != "SU" for m in members) + or any(tuple(m.roles or ()) != ("SU",) for m in members) ): raise AccountConflictError( "Bootstrap email belongs to an unavailable or non-SU identity" @@ -48,7 +49,7 @@ def bootstrap_super_admin(email: str, password: str, *, check_only=False): # A pre-cutover SU membership must be migrated, never replaced by a new ID. if session.execute( select(UserTenant.user_id).where( - UserTenant.user_role == "SU", UserTenant.delete_flag == "N" + UserTenant.roles.contains(["SU"]), UserTenant.delete_flag == "N" ) ).first(): raise AccountConflictError( diff --git a/backend/management/api/agent/agent.py b/backend/management/api/agent/agent.py index e577b304ac..1e0bf75283 100644 --- a/backend/management/api/agent/agent.py +++ b/backend/management/api/agent/agent.py @@ -63,6 +63,7 @@ compare_versions_impl, list_published_agents_impl, ) +from permissions.resource_access import resource_read_tenant from gateway.authentication.auth import get_current_user_info, get_current_user_id agent_config_router = APIRouter(prefix="/agent") @@ -170,6 +171,7 @@ async def search_agent_info_api( version_no: int = Body(0), tenant_id: Optional[str] = Query(None, description="Tenant ID for filtering (uses auth if not provided)"), authorization: Optional[str] = Header(None), + request: Request = None, ): """ Search agent info by agent_id and version_no @@ -180,7 +182,16 @@ 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 + inventory_tenant = resource_read_tenant(request, shared=True) + if inventory_tenant and effective_tenant_id != inventory_tenant: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Resource reads are limited to your tenant") agent_info = await get_agent_info_impl(agent_id, effective_tenant_id, version_no, user_id) + if inventory_tenant: + if agent_info.get("tenant_id") != auth_tenant_id: + raise AgentNotFoundError("Agent not found") + agent_info = { + key: agent_info.get(key) for key in ("display_name", "description", "prompt", "group_ids") + } return apply_agent_detail_prompt_visibility(auth_tenant_id, agent_info) except AgentNotFoundError as e: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Agent not found.") from e @@ -189,6 +200,8 @@ async def search_agent_info_api( status_code=HTTPStatus.FORBIDDEN, detail=str(e), ) from e + except HTTPException: + raise except Exception as e: logger.error(f"Agent search info error: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent search info error.") @@ -603,6 +616,11 @@ async def list_all_agent_info_api( try: user_id, auth_tenant_id, _ = get_current_user_info(authorization, request) + if authorized_tenant := resource_read_tenant(request, shared=True): + return await list_all_agent_info_impl( + tenant_id=authorized_tenant, user_id=user_id, inventory_read=True, + ) + 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: @@ -610,6 +628,8 @@ 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 HTTPException: + raise except Exception as e: logger.error(f"Agent list error: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent list error.") @@ -761,13 +781,15 @@ async def get_version_list_api( try: _, 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 + effective_tenant_id = resource_read_tenant(request) or tenant_id or auth_tenant_id result = get_version_list_impl( agent_id=agent_id, tenant_id=effective_tenant_id, ) logger.info(f"Version list: {result}") return JSONResponse(status_code=HTTPStatus.OK, content=jsonable_encoder(result)) + except HTTPException: + raise 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.") diff --git a/backend/management/api/agent/agent_repository.py b/backend/management/api/agent/agent_repository.py index ff3affd56d..57631bab98 100644 --- a/backend/management/api/agent/agent_repository.py +++ b/backend/management/api/agent/agent_repository.py @@ -1,29 +1,40 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.agent import AgentRepositoryListingCreateRequest -from contracts.knowledge import KnowledgeBaseResolution -from contracts.skill import SkillResolution -from contracts.tag import TagAssignmentFilter -from contracts.errors.exceptions import AgentToolImportError import json import logging from http import HTTPStatus from typing import Annotated, Optional -from fastapi import APIRouter, Body, File, Header, HTTPException, Query, Request, UploadFile +from fastapi import ( + APIRouter, + Body, + File, + Header, + HTTPException, + Query, + Request, + UploadFile, +) from starlette.responses import JSONResponse, Response -from contracts.errors.exceptions import SkillDuplicateError, UnauthorizedError from contracts.agent import AgentRepositoryListingCreateRequest +from contracts.errors.exceptions import ( + AgentToolImportError, + SkillDuplicateError, + UnauthorizedError, +) from contracts.knowledge import KnowledgeBaseResolution +from contracts.roles import has_any_role from contracts.skill import SkillResolution from contracts.tag import TagAssignmentFilter +from gateway.authentication.auth import get_current_user_context, get_current_user_id +from management.services.monitor.audit import record_security_event from management.services.repository.agent.listing import ( check_repository_import_precheck_impl, create_agent_repository_listing_impl, delete_official_agent_impl, - get_agent_repository_listing_detail_impl, get_agent_repository_icon_impl, + get_agent_repository_listing_detail_impl, import_agent_from_repository_impl, list_agent_repository_listings_impl, list_agent_repository_tag_stats_impl, @@ -32,10 +43,7 @@ update_agent_repository_status_impl, upload_agent_repository_icon_impl, ) -from management.services.monitor.audit import record_security_event from management.services.repository.agent.sync import sync_official_agents -from gateway.authentication.auth import get_current_user_context, get_current_user_id -from contracts.errors.exceptions import AgentToolImportError logger = logging.getLogger(__name__) agent_repository_router = APIRouter(prefix="/repository/agent") @@ -85,7 +93,7 @@ async def get_agent_repository_icon_api( def _require_super_admin(user_role: str) -> None: - if user_role.upper() != "SU": + if not has_any_role(user_role, {'SU'}): raise UnauthorizedError("Super admin role is required") diff --git a/backend/management/api/application.py b/backend/management/api/application.py index a95dd943d2..532809ab0c 100644 --- a/backend/management/api/application.py +++ b/backend/management/api/application.py @@ -1,57 +1,83 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from management.utils.app_factory import create_app -from contracts.constants.constants import CONFIG_SERVICE_NAME + + import logging from contextlib import asynccontextmanager -from management.utils.app_factory import create_app +from contracts.constants.constants import CONFIG_SERVICE_NAME +from contracts.constants.environment import ( + AIDP_API_KEY, + AIDP_SERVER_URL, + ENABLE_AIDP_KNOWLEDGE, + RUNTIME_THREAD_SHUTDOWN_GRACE_SECONDS, +) +from infrastructure.concurrency import config_thread_manager +from management.api.a2a.a2a_client import router as a2a_client_router +from management.api.a2a.a2a_server import router as a2a_server_router from management.api.agent.agent import agent_config_router as agent_router from management.api.agent.agent_repository import agent_repository_router -from management.api.skill.skill_repository import skill_repository_router from management.api.configuration.config_sync import router as config_sync_router -from management.api.knowledge_base.datamate import router as datamate_router -from management.api.knowledge_base.vectordatabase import router as vectordatabase_router -from management.api.knowledge_base.dify import router as dify_router -from management.api.knowledge_base.idata import router as idata_router -from management.api.knowledge_base.ragflow import router as ragflow_router -from management.api.file.file_management import file_management_config_router as file_manager_router +from management.api.configuration.tenant_config import router as tenant_config_router +from management.api.evaluation.agent_evaluation import router as agent_evaluation_router +from management.api.evaluation.evaluation_annotation import ( + router as evaluation_annotation_router, +) +from management.api.evaluation.evaluation_set import router as evaluation_set_router +from management.api.evaluation.evaluator import router as evaluator_router +from management.api.file.file_management import ( + file_management_config_router as file_manager_router, +) from management.api.file.image import router as proxy_router -from management.api.knowledge_base.knowledge_summary import router as summary_router -from management.api.identity.mock_user_management import router as mock_user_management_router -from management.api.model.model_managment import router as model_manager_router +from management.api.identity.api_key import router as api_key_router +from management.api.identity.cas import router as cas_router +from management.api.identity.group import router as group_router +from management.api.identity.invitation import router as invitation_router from management.api.identity.oauth import router as oauth_router -from management.api.prompt.prompt import router as prompt_router -from management.api.mcp.mcp_management import router as mcp_management_router -from management.api.mcp.remote_mcp import router as remote_mcp_router -from management.api.skill.skill import router as skill_router -from management.api.configuration.tenant_config import router as tenant_config_router -from management.api.tool.tool_config import router as tool_config_router -from management.api.identity.user_management import router as user_management_router -from management.api.voice.voice import voice_config_router as voice_router from management.api.identity.tenant import router as tenant_router -from management.api.identity.group import router as group_router from management.api.identity.user import router as user_router -from management.api.identity.api_key import router as api_key_router -from management.api.identity.invitation import router as invitation_router -from management.api.notification.notification import router as notification_router -from management.api.a2a.a2a_client import router as a2a_client_router -from management.api.monitoring.monitoring import router as monitoring_router -from management.api.a2a.a2a_server import router as a2a_server_router +from management.api.identity.user_management import router as user_management_router +from management.api.knowledge_base.datamate import router as datamate_router +from management.api.knowledge_base.dify import router as dify_router from management.api.knowledge_base.haotian import router as haotian_router +from management.api.knowledge_base.idata import router as idata_router from management.api.knowledge_base.ind_aidp import router as ind_aidp_router -from management.api.evaluation.evaluation_set import router as evaluation_set_router -from management.api.evaluation.agent_evaluation import router as agent_evaluation_router -from management.api.evaluation.evaluator import router as evaluator_router -from management.api.evaluation.evaluation_annotation import router as evaluation_annotation_router -from management.api.identity.cas import router as cas_router +from management.api.knowledge_base.knowledge_summary import router as summary_router +from management.api.knowledge_base.quota import ( + personal_quota_router, + platform_quota_router, + tenant_quota_router, +) +from management.api.knowledge_base.ragflow import router as ragflow_router +from management.api.knowledge_base.vectordatabase import router as vectordatabase_router +from management.api.mcp.mcp_management import router as mcp_management_router +from management.api.mcp.remote_mcp import router as remote_mcp_router from management.api.memory.memory_config import router as memory_config_router -from management.api.memory.memory_record import router as memory_record_router -from management.api.memory.memory_long_term import router as memory_long_term_router from management.api.memory.memory_dreaming import router as memory_dreaming_router +from management.api.memory.memory_long_term import router as memory_long_term_router from management.api.memory.memory_provider import router as memory_provider_router +from management.api.memory.memory_record import router as memory_record_router +from management.api.model.model_managment import router as model_manager_router +from management.api.monitoring.monitoring import router as monitoring_router +from management.api.notification.notification import router as notification_router +from management.api.prompt.prompt import router as prompt_router +from management.api.skill.skill import router as skill_router +from management.api.skill.skill_repository import skill_repository_router from management.api.tag.tag_management import router as tag_management_router -from management.api.knowledge_base.quota import tenant_quota_router, platform_quota_router, personal_quota_router -from contracts.constants.environment import AIDP_API_KEY, AIDP_SERVER_URL, ENABLE_AIDP_KNOWLEDGE, IS_SPEED_MODE, RUNTIME_THREAD_SHUTDOWN_GRACE_SECONDS +from management.api.tool.tool_config import router as tool_config_router +from management.api.voice.voice import voice_config_router as voice_router +from management.recovery import ( + recover_config_tasks, + schedule_interrupted_upload_cleanup, +) +from management.services.evaluation.maintenance import ( + start as start_eval_maintenance, +) +from management.services.evaluation.maintenance import ( + stop as stop_eval_maintenance, +) +from management.services.memory.dreaming.scheduler import dreaming_scheduler +from management.services.model.outlet_migration import migrate_legacy_outlets +from management.utils.app_factory import create_app from nexent.core.concurrency import ( ManagedTaskSpec, ManagerState, @@ -59,18 +85,12 @@ run_blocking, set_default_thread_manager, ) -from contracts.constants.constants import CONFIG_SERVICE_NAME -from infrastructure.concurrency import config_thread_manager +from ops.api.access import router as ops_access_router logger = logging.getLogger("base_app") async def recover_config_tasks_on_startup(): - from management.services.evaluation.maintenance import start as start_eval_maintenance - from management.recovery import ( - recover_config_tasks, - schedule_interrupted_upload_cleanup, - ) await config_thread_manager.run( "control-io", @@ -90,15 +110,12 @@ async def validate_external_service_configuration(): raise RuntimeError("AIDP_SERVER_URL and AIDP_API_KEY are required when ENABLE_AIDP_KNOWLEDGE=true") - async def start_dreaming_scheduler(): - from management.services.memory.dreaming.scheduler import dreaming_scheduler await dreaming_scheduler.start() async def stop_dreaming_scheduler(): - from management.services.memory.dreaming.scheduler import dreaming_scheduler await dreaming_scheduler.stop() @@ -109,7 +126,6 @@ async def config_lifespan(_app): config_thread_manager.start() set_default_thread_manager(config_thread_manager) try: - from management.services.model.outlet_migration import migrate_legacy_outlets migration = await run_blocking("model-outlet-upgrade", migrate_legacy_outlets) logger.info("LLM outlet upgrade: migrated=%s failed=%s", migration["migrated"], migration["failed"]) @@ -121,7 +137,6 @@ async def config_lifespan(_app): # Preserve the scheduler's existing graceful shutdown behavior. Task # state recovery remains exclusively in the startup path above. await stop_dreaming_scheduler() - from management.services.evaluation.maintenance import stop as stop_eval_maintenance stop_eval_maintenance() try: @@ -161,13 +176,7 @@ async def thread_capacity(): app.include_router(ragflow_router) app.include_router(monitoring_router) -# Choose user management router based on IS_SPEED_MODE -if IS_SPEED_MODE: - logger.info("Speed mode enabled - using mock user management router") - app.include_router(mock_user_management_router) -else: - logger.info("Normal mode - using real user management router") - app.include_router(user_management_router) +app.include_router(user_management_router) app.include_router(oauth_router) app.include_router(cas_router) @@ -206,3 +215,6 @@ async def thread_capacity(): app.include_router(memory_dreaming_router) app.include_router(memory_provider_router) app.include_router(tag_management_router) + + +app.include_router(ops_access_router) diff --git a/backend/management/api/identity/group.py b/backend/management/api/identity/group.py index d5f6c8338b..a244a49c70 100644 --- a/backend/management/api/identity/group.py +++ b/backend/management/api/identity/group.py @@ -3,17 +3,16 @@ Group management API endpoints """ -from contracts.identity import GroupCreateRequest, GroupUpdateRequest, GroupUserRequest, GroupListRequest, SetDefaultGroupRequest, GroupMembersUpdateRequest -from contracts.errors.exceptions import NotFoundException, TenantResourceLimitError, UnauthorizedError, ValidationError, tenant_resource_limit_error_payload import logging +from http import HTTPStatus from typing import Optional -from fastapi import APIRouter, Header, HTTPException, Request -from http import HTTPStatus +from fastapi import APIRouter, Depends, Header, HTTPException, Request from starlette.responses import JSONResponse from contracts.identity import GroupCreateRequest, GroupUpdateRequest, GroupUserRequest, GroupListRequest, SetDefaultGroupRequest, GroupMembersUpdateRequest from contracts.errors.exceptions import NotFoundException, TenantResourceLimitError, UnauthorizedError, ValidationError, tenant_resource_limit_error_payload +from gateway.authentication.auth import authenticate, get_current_user_id from management.services.monitor.audit import record_security_event from management.services.permission.identity.groups import ( create_group, get_group_info, update_group, delete_group, @@ -22,7 +21,8 @@ get_groups_by_tenant, update_group_members ) from management.services.permission.identity.tenants import get_tenant_info -from gateway.authentication.auth import get_current_user_id +from permissions.models import CurrentUser +from permissions.tenant_scope import resolve_tenant_scope logger = logging.getLogger(__name__) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -118,6 +118,7 @@ async def get_group_endpoint(group_id: int) -> JSONResponse: @router.post("/list") async def get_groups_endpoint( request: GroupListRequest, + current_user: CurrentUser = Depends(authenticate), ) -> JSONResponse: """ Search groups for a specific tenant with pagination @@ -129,9 +130,9 @@ async def get_groups_endpoint( Returns: JSONResponse: List of groups for the tenant (paginated or all) """ + resolve_tenant_scope(current_user, request.tenant_id) try: - # Validate tenant exists - get_tenant_info(request.tenant_id) + get_tenant_info(request.tenant_id, initialize_name=False) # Get groups under given tenant with pagination and sorting group_kwargs = { "tenant_id": request.tenant_id, @@ -379,7 +380,10 @@ async def remove_user_from_group_endpoint( @router.get("/{group_id}/members") -async def get_group_users_endpoint(group_id: int) -> JSONResponse: +async def get_group_users_endpoint( + group_id: int, + current_user: CurrentUser = Depends(authenticate), +) -> JSONResponse: """ Get all users in a group @@ -390,9 +394,9 @@ async def get_group_users_endpoint(group_id: int) -> JSONResponse: Returns: JSONResponse: List of group users """ + authorized_tenant = resolve_tenant_scope(current_user, allow_all_tenants=True) try: - # Get group users - users = get_group_users(group_id) + users = get_group_users(group_id, tenant_id=authorized_tenant) if authorized_tenant else get_group_users(group_id) return JSONResponse( status_code=HTTPStatus.OK, content={"message": "Group users retrieved successfully", "data": users} diff --git a/backend/management/api/identity/invitation.py b/backend/management/api/identity/invitation.py index a04d2f3ff2..b3aa173f5d 100644 --- a/backend/management/api/identity/invitation.py +++ b/backend/management/api/identity/invitation.py @@ -3,25 +3,39 @@ Invitation management API endpoints """ -from contracts.identity import InvitationCreateRequest, InvitationUpdateRequest, InvitationListRequest -from contracts.errors.exceptions import NotFoundException, ValidationError, UnauthorizedError, DuplicateError import logging +import traceback +from http import HTTPStatus from typing import Optional from fastapi import APIRouter, Header, HTTPException, Request -from http import HTTPStatus from starlette.responses import JSONResponse -from contracts.identity import InvitationCreateRequest, InvitationUpdateRequest, InvitationListRequest -from contracts.errors.exceptions import NotFoundException, ValidationError, UnauthorizedError, DuplicateError +from contracts.errors.exceptions import ( + DuplicateError, + ForbiddenError, + NotFoundException, + UnauthorizedError, + ValidationError, +) +from contracts.identity import ( + InvitationCreateRequest, + InvitationListRequest, + InvitationUpdateRequest, +) +from gateway.authentication.auth import get_current_user_id +from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.monitor.audit import record_security_event from management.services.permission.identity.invitations import ( - create_invitation_code, update_invitation_code, get_invitation_by_code, - check_invitation_available, use_invitation_code, update_invitation_code_status, - get_invitations_list, delete_invitation_code + check_invitation_available, + create_invitation_code, + delete_invitation_code, + get_invitation_by_code, + get_invitations_list, + update_invitation_code, + update_invitation_code_status, + use_invitation_code, ) -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from gateway.authentication.auth import get_current_user_id logger = logging.getLogger(__name__) router = APIRouter(prefix="/invitations", tags=["invitations"]) @@ -63,6 +77,8 @@ async def list_invitations_endpoint( status_code=HTTPStatus.OK, content={"message": "Invitation codes retrieved successfully", "data": result} ) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except UnauthorizedError as exc: logger.warning(f"Unauthorized invitation list access attempt: {str(exc)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) @@ -104,6 +120,7 @@ async def create_invitation_endpoint( invitation_info = create_invitation_code( tenant_id=tenant_id, code_type=request.code_type, + roles=request.roles, invitation_code=invitation_code, group_ids=group_ids, capacity=request.capacity, @@ -145,6 +162,8 @@ async def create_invitation_endpoint( except NotFoundException as exc: logger.warning(f"User not found during invitation creation: {str(exc)}") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except UnauthorizedError as exc: logger.warning(f"Unauthorized invitation creation attempt: {str(exc)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) @@ -217,11 +236,12 @@ async def update_invitation_endpoint( except ValidationError as exc: logger.warning(f"Invitation update validation error: {str(exc)}") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except UnauthorizedError as exc: logger.warning(f"Unauthorized invitation update attempt: {str(exc)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) except Exception as exc: - import traceback logger.error(f"Unexpected error during invitation update: {str(exc)}") logger.error(f"Exception type: {type(exc).__name__}") @@ -336,6 +356,8 @@ async def delete_invitation_endpoint( except ValidationError as exc: logger.warning(f"Invitation deletion validation error: {str(exc)}") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except UnauthorizedError as exc: logger.warning(f"Unauthorized invitation deletion attempt: {str(exc)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) @@ -413,6 +435,8 @@ async def use_invitation_endpoint( except NotFoundException as exc: logger.warning(f"Invitation code not available: {str(exc)}") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) except UnauthorizedError as exc: logger.warning(f"Unauthorized invitation usage attempt: {str(exc)}") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) diff --git a/backend/management/api/identity/user.py b/backend/management/api/identity/user.py index 76c787f464..742ce147ea 100644 --- a/backend/management/api/identity/user.py +++ b/backend/management/api/identity/user.py @@ -4,7 +4,6 @@ User management API endpoints """ -from contracts.errors.exceptions import ForbiddenError, NotFoundException, TenantResourceLimitError, UnauthorizedError, tenant_resource_limit_error_payload import logging from http import HTTPStatus from typing import Optional @@ -12,14 +11,29 @@ from fastapi import APIRouter, Header, HTTPException, Request from starlette.responses import JSONResponse +from contracts.errors.exceptions import ( + ForbiddenError, + NotFoundException, + RoleAssignmentConflict, + TenantResourceLimitError, + UnauthorizedError, + tenant_resource_limit_error_payload, +) from contracts.identity import UserListRequest, UserUpdateRequest -from contracts.errors.exceptions import ForbiddenError, NotFoundException, TenantResourceLimitError, UnauthorizedError, tenant_resource_limit_error_payload +from gateway.authentication.auth import ( + extract_session_id_from_authorization, + get_current_user_context, + get_current_user_id, +) +from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.monitor.audit import record_security_event +from management.services.permission.identity.roles import assignable_roles from management.services.permission.identity.users import ( - delete_user_and_cleanup, get_users_for_requester, update_user_for_requester + delete_user_and_cleanup, + get_users_for_requester, + update_user_for_requester, ) -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from gateway.authentication.auth import extract_session_id_from_authorization, get_current_user_context, get_current_user_id +from permissions.role_assignment import check_managed_target logger = logging.getLogger("user_app") router = APIRouter(prefix="/users", tags=["users"]) @@ -110,7 +124,7 @@ async def update_user_endpoint( # Update user updated_user = await update_user_for_requester( user_id, - request.model_dump(), + request.model_dump(exclude_unset=True), updated_by=current_user_id, requester_tenant_id=requester_tenant_id, requester_role=requester_role, @@ -144,6 +158,8 @@ async def update_user_endpoint( status_code=HTTPStatus.TOO_MANY_REQUESTS, content=tenant_resource_limit_error_payload(exc), ) + except RoleAssignmentConflict as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(exc)) except ValueError as exc: logger.warning(f"User update validation error for user {user_id}: {str(exc)}") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) @@ -184,8 +200,14 @@ async def delete_user_endpoint( tenant_id = user_tenant["tenant_id"] + actor = get_user_tenant_by_user_id(current_user_id) + if not actor: + raise UnauthorizedError("User not found") + check_managed_target(actor, user_tenant) + if current_user_id == user_id: + raise RoleAssignmentConflict("Cannot delete your own managed identity") # Perform complete user cleanup - await delete_user_and_cleanup(user_id, tenant_id) + await delete_user_and_cleanup(user_id, tenant_id, actor_id=current_user_id) logger.info(f"Permanently deleted user {user_id} by admin {current_user_id}") @@ -198,6 +220,12 @@ async def delete_user_endpoint( ) return JSONResponse(status_code=HTTPStatus.OK, content={"message": "User deleted successfully"}) + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) + except RoleAssignmentConflict as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(exc)) except ValueError as exc: logger.warning(f"User deletion validation error for user {user_id}: {str(exc)}") raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)) @@ -205,3 +233,17 @@ async def delete_user_endpoint( logger.error(f"Unexpected error deleting user {user_id}: {str(exc)}") # Include the actual error message for debugging raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Failed to delete user: {str(exc)}") + + +@router.get("/{user_id}/assignable_roles") +async def get_assignable_roles_endpoint(user_id: str, authorization: Optional[str] = Header(None)): + """Return server-derived role choices without trusting client role claims.""" + try: + actor_id, _ = get_current_user_id(authorization) + return {"data": assignable_roles(actor_id, user_id)} + except UnauthorizedError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + except ForbiddenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except NotFoundException as exc: + raise HTTPException(status_code=404, detail=str(exc)) diff --git a/backend/management/api/identity/user_management.py b/backend/management/api/identity/user_management.py index 60aabb1421..e5794df842 100644 --- a/backend/management/api/identity/user_management.py +++ b/backend/management/api/identity/user_management.py @@ -1,31 +1,61 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.errors.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, AppException, UnauthorizedError, TenantResourceLimitError, ValidationError, tenant_resource_limit_error_payload -from contracts.errors.codes import ErrorCode -from gateway.authentication.auth import extract_bearer_token -import logging -from dotenv import load_dotenv -from fastapi import APIRouter, Header, Query, Request, HTTPException -from fastapi.responses import JSONResponse +import logging from http import HTTPStatus from typing import Optional -from contracts.errors.exceptions import AccountConflictError +from dotenv import load_dotenv +from fastapi import APIRouter, Header, HTTPException, Query, Request +from fastapi.responses import JSONResponse from contracts.constants.constants import ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL -from contracts.identity import UserSignInRequest, UserSignUpRequest, UpdatePasswordRequest -from contracts.errors.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, AppException, UnauthorizedError, TenantResourceLimitError, ValidationError, tenant_resource_limit_error_payload from contracts.errors.codes import ErrorCode -from management.services.permission.identity.cas import build_logout_url, CasAuthenticationError -from gateway.authentication.auth import validate_token, get_session_by_authorization -from management.services.permission.identity.authentication import \ - check_auth_service_health, signup_user_with_invitation, signin_user, refresh_user_token, \ - get_user_info, create_token, list_tokens_by_user, delete_token, \ - update_password, get_provider_username -from management.services.permission.identity.users import delete_user_and_cleanup +from contracts.errors.exceptions import ( + AccountConflictError, + AppException, + IncorrectInviteCodeException, + NoInviteCodeException, + TenantResourceLimitError, + UnauthorizedError, + UserRegistrationException, + ValidationError, + tenant_resource_limit_error_payload, +) +from contracts.identity import ( + UpdatePasswordRequest, + UserSignInRequest, + UserSignUpRequest, +) +from contracts.roles import has_any_role +from gateway.authentication.auth import ( + extract_bearer_token, + extract_session_id_from_authorization, + get_current_user_context, + get_current_user_id, + get_session_by_authorization, + validate_token, +) +from management.db.identity.cas_session import ( + revoke_cas_session_by_session_id, +) from management.services.monitor.audit import record_security_event -from gateway.authentication.auth import extract_session_id_from_authorization, get_current_user_context, get_current_user_id - +from management.services.permission.identity.authentication import ( + check_auth_service_health, + create_token, + delete_token, + get_provider_username, + get_user_info, + list_tokens_by_user, + refresh_user_token, + signin_user, + signup_user_with_invitation, + update_password, +) +from management.services.permission.identity.cas import ( + CasAuthenticationError, + build_logout_url, +) +from management.services.permission.identity.users import delete_user_and_cleanup load_dotenv() logging.getLogger("httpx").setLevel(logging.WARNING) @@ -168,7 +198,6 @@ async def logout(request: Request): if authorization: session_id = extract_session_id_from_authorization(authorization) if logout_user_id else None if session_id: - from management.db.identity.cas_session import revoke_cas_session_by_session_id revoke_cas_session_by_session_id(session_id, actor="user") try: @@ -293,13 +322,11 @@ async def revoke_user_account(request: Request): if not is_valid or not user: raise UnauthorizedError("User not logged in or session invalid") - # Extract role from user metadata - user_role = "user" - if getattr(user, "user_metadata", None) and "role" in user.user_metadata: - user_role = user.user_metadata["role"] + # Resolve authoritative roles, including a non-primary ADMIN grant. + _, _, user_role = get_current_user_context(authorization) # Disallow admin revocation by this endpoint - if user_role.upper() in {"ADMIN", "SU"}: + if has_any_role(user_role, {'ADMIN','SU'}): raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Admin account cannot be deleted via this endpoint") diff --git a/backend/management/api/knowledge_base/knowledge_summary.py b/backend/management/api/knowledge_base/knowledge_summary.py index 953b243226..13f6eabf0a 100644 --- a/backend/management/api/knowledge_base/knowledge_summary.py +++ b/backend/management/api/knowledge_base/knowledge_summary.py @@ -8,9 +8,12 @@ from contracts.knowledge import ChangeSummaryRequest from contracts.errors.exceptions import TokenExpiredError +from management.db.knowledge_base.repository import get_knowledge_record +from permissions.models import CurrentUser +from permissions.resource_access import resource_read_tenant from management.utils.knowledge_permission import require_knowledge_base_edit_permission from management.services.knowledge_base.service import ElasticSearchService, get_vector_db_core -from gateway.authentication.auth import get_current_user_id, get_current_user_info +from gateway.authentication.auth import authenticate, get_current_user_id, get_current_user_info from management.services.configuration.tenant.settings import tenant_config_manager router = APIRouter(prefix="/summary") @@ -93,8 +96,13 @@ def change_summary( @router.get("/{index_name}/summary") def get_summary( index_name: str = Path(..., description="Name of the index to get documents from"), + request: Request = None, + current_user: CurrentUser = Depends(authenticate), ): """Get Elasticsearch index_name Summary""" + if authorized_tenant := resource_read_tenant(request, user=current_user): + if not get_knowledge_record({"index_name": index_name, "tenant_id": authorized_tenant}): + raise HTTPException(status_code=404, detail="Knowledge base not found") try: # Try to list indices as a health check return ElasticSearchService().get_summary(index_name=index_name) diff --git a/backend/management/api/knowledge_base/quota.py b/backend/management/api/knowledge_base/quota.py index ad45368e86..afa1b87522 100644 --- a/backend/management/api/knowledge_base/quota.py +++ b/backend/management/api/knowledge_base/quota.py @@ -5,8 +5,6 @@ Provides tenant-level and platform-level quota configuration and usage tracking. """ -from contracts.constants.constants import PERSONAL_KB_CAPACITY_MANAGE_PERMISSION, PERSONAL_KB_CAPACITY_READ_PERMISSION, QUOTA_PAYLOAD_DESCRIPTION, TARGET_TENANT_ID_DESCRIPTION -from contracts.constants.constants import PERSONAL_KB_CAPACITY_MANAGE_PERMISSION, PERSONAL_KB_CAPACITY_READ_PERMISSION, QUOTA_PAYLOAD_DESCRIPTION, TARGET_TENANT_ID_DESCRIPTION import logging from http import HTTPStatus from typing import Any, Dict, Optional @@ -14,16 +12,30 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Query from fastapi.responses import JSONResponse -from contracts.constants.constants import ASSET_OWNER_TENANT_ID -from contracts.errors.exceptions import AppException, PlatformQuotaConflictError, TokenExpiredError +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + PERSONAL_KB_CAPACITY_MANAGE_PERMISSION, + PERSONAL_KB_CAPACITY_READ_PERMISSION, + QUOTA_PAYLOAD_DESCRIPTION, + TARGET_TENANT_ID_DESCRIPTION, +) +from contracts.errors.exceptions import ( + AppException, + PlatformQuotaConflictError, + TokenExpiredError, +) +from contracts.roles import has_any_role, roles_from_record +from gateway.authentication.auth import authenticate, get_current_user_id +from infrastructure.formatting import bytes_to_readable from management.db.identity.user_tenant import get_user_tenant_by_user_id -from gateway.authentication.auth import authenticate +from management.services.knowledge_base.quota import QuotaService +from management.services.knowledge_base.service import ( + ElasticSearchService, + get_vector_db_core, +) from permissions.depends import require from permissions.models import CurrentUser from permissions.tenant_scope import resolve_personal_target_tenant -from management.services.knowledge_base.quota import QuotaService -from gateway.authentication.auth import get_current_user_id -from infrastructure.formatting import bytes_to_readable logger = logging.getLogger(__name__) @@ -55,7 +67,7 @@ def _get_user_role(authorization: Optional[str]) -> str: try: user_id, _ = get_current_user_id(authorization) user_info = get_user_tenant_by_user_id(user_id) - return (user_info.get("user_role") or "USER").upper() if user_info else "USER" + return roles_from_record(user_info) except Exception: return "USER" @@ -63,7 +75,7 @@ def _get_user_role(authorization: Optional[str]) -> str: def _require_admin_or_su(authorization: Optional[str]) -> str: """Require ADMIN, SU, or ASSET_OWNER role. Returns the role string.""" role = _get_user_role(authorization) - if role not in ("SU", "ADMIN", "ASSET_OWNER"): + if not has_any_role(role, {'SU','ADMIN','ASSET_OWNER'}): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="This operation requires ADMIN, SU, or ASSET_OWNER role", @@ -74,20 +86,16 @@ def _require_admin_or_su(authorization: Optional[str]) -> str: def _require_platform_quota_manager(authorization: Optional[str]) -> str: """Require a role that can manage platform-level quotas.""" role = _get_user_role(authorization) - if role not in ("SU", "ASSET_OWNER", "SPEED"): + if not has_any_role(role, {'SU','ASSET_OWNER'}): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, - detail="This operation requires SU, ASSET_OWNER, or SPEED role", + detail="This operation requires SU or ASSET_OWNER role", ) return role def _get_manageable_index_names(tenant_id: str, user_id: str) -> set[str]: """Return KB index names the current user can manage.""" - from management.services.knowledge_base.service import ( - ElasticSearchService, - get_vector_db_core, - ) result = ElasticSearchService.list_indices( pattern="*", @@ -120,7 +128,7 @@ def get_tenant_quota( user_id, auth_tenant_id = get_current_user_id(authorization) # Use auth tenant_id if accessing own tenant; SU can see any role = _get_user_role(authorization) - if role not in ("SU", "ASSET_OWNER") and tenant_id != auth_tenant_id: + if not has_any_role(role, {'SU','ASSET_OWNER'}) and tenant_id != auth_tenant_id: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Cannot access quota config for another tenant", @@ -168,7 +176,7 @@ def update_tenant_quota( role = _require_admin_or_su(authorization) # Non-SU users can only modify their own tenant - if role not in ("SU", "ASSET_OWNER") and tenant_id != auth_tenant_id: + if not has_any_role(role, {'SU','ASSET_OWNER'}) and tenant_id != auth_tenant_id: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Cannot modify quota config for another tenant", @@ -185,14 +193,14 @@ def update_tenant_quota( hard_limit_info = service.get_hard_limit() if not hard_limit_info.get("hard_limit_editable", True): # Only allow SU to modify SU-set limits - if role not in ("SU", "ASSET_OWNER"): + if not has_any_role(role, {'SU','ASSET_OWNER'}): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Tenant hard quota is managed by the platform administrator", ) # SU sets tenant hard limit → mark as non-editable for tenant admin - if role in ("SU", "ASSET_OWNER"): + if has_any_role(role, {'SU','ASSET_OWNER'}): QuotaService.set_tenant_hard_limit( tenant_id, limit_gb=hard_limit_gb, @@ -260,7 +268,7 @@ def delete_tenant_quota( user_id, auth_tenant_id = get_current_user_id(authorization) role = _require_admin_or_su(authorization) - if role not in ("SU", "ASSET_OWNER") and tenant_id != auth_tenant_id: + if not has_any_role(role, {'SU','ASSET_OWNER'}) and tenant_id != auth_tenant_id: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Cannot modify quota config for another tenant", @@ -302,7 +310,7 @@ def get_tenant_quota_usage( try: user_id, auth_tenant_id = get_current_user_id(authorization) role = _get_user_role(authorization) - if role not in ("SU", "ASSET_OWNER") and tenant_id != auth_tenant_id: + if not has_any_role(role, {'SU','ASSET_OWNER'}) and tenant_id != auth_tenant_id: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Cannot access usage data for another tenant", @@ -310,7 +318,7 @@ def get_tenant_quota_usage( service = QuotaService(tenant_id, user_id) usage = service.get_usage(force_refresh=force_refresh, detail=detail) - if detail and role in ("USER", "DEV"): + if detail and has_any_role(role, {'USER','DEV'}): manageable_index_names = _get_manageable_index_names(tenant_id, user_id) usage["breakdown"] = [ item for item in usage.get("breakdown", []) if item.get("index_name") in manageable_index_names @@ -331,7 +339,7 @@ def get_tenant_quota_usage( # ═══════════════════════════════════════════════════════════════════════════ -# Platform-Level Quota Endpoints (SU/ASSET_OWNER/SPEED only) +# Platform-Level Quota Endpoints (SU/ASSET_OWNER only) # ═══════════════════════════════════════════════════════════════════════════ @@ -341,7 +349,7 @@ def get_platform_overview( ): """ Get platform-level storage overview: all tenants' quotas and usage. - Restricted to SU, ASSET_OWNER, and SPEED roles. + Restricted to SU and ASSET_OWNER roles. """ try: _require_platform_quota_manager(authorization) @@ -369,7 +377,7 @@ def set_platform_capacity( ): """ Set platform-wide declared storage capacity. - Restricted to SU, ASSET_OWNER, and SPEED roles. + Restricted to SU and ASSET_OWNER roles. """ try: _require_platform_quota_manager(authorization) @@ -402,7 +410,7 @@ def delete_platform_capacity( ): """ Remove platform capacity declaration. - Restricted to SU, ASSET_OWNER, and SPEED roles. + Restricted to SU and ASSET_OWNER roles. """ try: _require_platform_quota_manager(authorization) @@ -434,7 +442,7 @@ def set_tenant_hard_quota( ): """ SU sets a hard quota on a target tenant. - Restricted to SU, ASSET_OWNER, and SPEED roles. + Restricted to SU and ASSET_OWNER roles. """ try: _require_platform_quota_manager(authorization) @@ -478,7 +486,7 @@ def delete_tenant_hard_quota( ): """ SU removes a tenant's hard quota. - Restricted to SU, ASSET_OWNER, and SPEED roles. + Restricted to SU and ASSET_OWNER roles. """ try: _require_platform_quota_manager(authorization) @@ -531,7 +539,7 @@ def get_personal_self_capacity( @personal_quota_router.get("/users") def list_personal_capacity_users( - current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION, alternative="resource.inventory:read")), tenant_id: Optional[str] = Query(None, description=TARGET_TENANT_ID_DESCRIPTION), page: int = Query(1, ge=1, description="Page number starting from 1"), page_size: int = Query(20, ge=1, le=100, description="Page size from 1 to 100"), @@ -585,7 +593,7 @@ def list_personal_capacity_users( @personal_quota_router.get("/users/{user_id}/kbs") def get_personal_capacity_kbs( user_id: str = Path(..., description="User ID"), - current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION, alternative="resource.inventory:read")), tenant_id: Optional[str] = Query(None, description=TARGET_TENANT_ID_DESCRIPTION), page: int = Query(1, ge=1, description="Page number starting from 1"), page_size: int = Query(20, ge=1, le=100, description="Page size from 1 to 100"), @@ -614,7 +622,7 @@ def get_personal_capacity_kbs( @personal_quota_router.get("/summary") def get_personal_capacity_summary( - current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION, alternative="resource.inventory:read")), tenant_id: Optional[str] = Query(None, description=TARGET_TENANT_ID_DESCRIPTION), ): """Return aggregate personal KB capacity stats for a tenant.""" @@ -675,7 +683,7 @@ def set_personal_user_quota( @personal_quota_router.get("/default-quota") def get_personal_default_quota( - current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(PERSONAL_KB_CAPACITY_READ_PERMISSION, alternative="resource.inventory:read")), tenant_id: Optional[str] = Query(None, description=TARGET_TENANT_ID_DESCRIPTION), ): """Get the tenant default personal KB quota.""" diff --git a/backend/management/api/knowledge_base/vectordatabase.py b/backend/management/api/knowledge_base/vectordatabase.py index e60c15052a..59e54a99da 100644 --- a/backend/management/api/knowledge_base/vectordatabase.py +++ b/backend/management/api/knowledge_base/vectordatabase.py @@ -1,30 +1,49 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.constants.constants import INTERNAL_INDEX_NAME_DESC -from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException, DuplicateError, TokenExpiredError -from contracts.knowledge import ChunkCreateRequest, ChunkUpdateRequest, HybridSearchRequest, IndexingResponse -from contracts.constants.constants import VALID_SUMMARY_FREQUENCIES, SUMMARY_FREQUENCY_OPTIONS_FOR_API -from management.utils.knowledge_permission import require_knowledge_base_edit_permission, require_knowledge_base_read_permission import json import logging import re from http import HTTPStatus from typing import Annotated, Any, Dict, List, Optional -from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Query, Request +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Path, + Query, + Request, +) from fastapi.responses import JSONResponse -from nexent.core.concurrency import run_blocking -from nexent.vector_database.base import VectorDatabaseCore -from management.utils.knowledge_permission import require_knowledge_base_edit_permission, require_knowledge_base_read_permission -from contracts.constants.constants import ASSET_OWNER_TENANT_ID +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + INTERNAL_INDEX_NAME_DESC, + SUMMARY_FREQUENCY_OPTIONS_FOR_API, + VALID_SUMMARY_FREQUENCIES, +) from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, DuplicateError, TokenExpiredError -from contracts.knowledge import ChunkCreateRequest, ChunkUpdateRequest, HybridSearchRequest, IndexingResponse -from contracts.constants.constants import SUMMARY_FREQUENCY_OPTIONS_FOR_API, VALID_SUMMARY_FREQUENCIES -from management.db.knowledge_base.repository import get_index_name_by_knowledge_name, get_knowledge_record +from contracts.knowledge import ( + ChunkCreateRequest, + ChunkUpdateRequest, + HybridSearchRequest, + IndexingResponse, +) +from contracts.roles import has_any_role +from permissions.resource_access import resource_read_tenant +from gateway.authentication.auth import get_current_user_context, get_current_user_id +from management.db.knowledge_base.file_lifecycle import get_file_record +from management.db.knowledge_base.repository import ( + get_index_name_by_knowledge_name, + get_knowledge_record, + update_summary_frequency, +) from management.db.model.repository import get_model_by_model_id +from management.services.file.service import check_file_access +from management.services.knowledge_base.quota import QuotaService from management.services.knowledge_base.service import ( ElasticSearchService, KnowledgeBaseNeedsModelConfigError, @@ -32,13 +51,19 @@ get_vector_db_core, ) from management.services.knowledge_base.status import get_all_files_status +from management.services.knowledge_base.task_state import get_redis_service from management.services.model.resolver import get_embedding_model_by_id from management.services.monitor.audit import record_security_event -from management.services.file.service import check_file_access -from management.services.knowledge_base.quota import QuotaService -from management.services.knowledge_base.task_state import get_redis_service -from gateway.authentication.auth import get_current_user_context, get_current_user_id - +from management.services.tag.library import TagManagementService +from management.services.tag.projection import ( + project_authorized_document_tags, +) +from management.utils.knowledge_permission import ( + require_knowledge_base_edit_permission, + require_knowledge_base_read_permission, +) +from nexent.core.concurrency import run_blocking +from nexent.vector_database.base import VectorDatabaseCore router = APIRouter(prefix="/indices") service = ElasticSearchService() @@ -175,7 +200,7 @@ async def get_knowledge_base_bound_agents( except TokenExpiredError as exc: raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(exc)) from exc target_tenant_id = tenant_id or auth_tenant_id - if target_tenant_id != auth_tenant_id and role != "SU": + if target_tenant_id != auth_tenant_id and not has_any_role(role, {'SU'}): raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not authorized to access this tenant") try: require_knowledge_base_read_permission(index_name, user_id, target_tenant_id) @@ -205,7 +230,6 @@ async def delete_index( require_knowledge_base_edit_permission(index_name, user_id, tenant_id) # Call the centralized full deletion service result = await ElasticSearchService.full_delete_knowledge_base(index_name, vdb_core, user_id) - from management.services.tag.library import TagManagementService TagManagementService.cleanup_resource_assignments(tenant_id, "knowledge_base", index_name, user_id) TagManagementService.cleanup_document_assignments_for_knowledge_base(tenant_id, "local", index_name, user_id) @@ -302,7 +326,6 @@ async def update_summary_frequency_endpoint( detail=f"Invalid summary_frequency. Must be one of: {valid_frequencies}", ) - from management.db.knowledge_base.repository import update_summary_frequency success = update_summary_frequency( index_name=index_name, summary_frequency=summary_frequency, _tenant_id=tenant_id, user_id=user_id @@ -478,6 +501,7 @@ def get_list_indices( models: Optional[List[str]] = Query(None, description="Embedding model names to include"), vdb_core: VectorDatabaseCore = Depends(get_vector_db_core), authorization: Optional[str] = Header(None), + request: Request = None, ): """List all user indices with optional stats""" try: @@ -493,6 +517,11 @@ def get_list_indices( "sources": sources, "models": models, } + if authorized_tenant := resource_read_tenant(request, shared=True): + return ElasticSearchService.list_indices( + pattern, include_stats, authorized_tenant, user_id, vdb_core, + inventory_read=True, **pagination_args, + ) if tenant_id is None: if limit is not None and auth_tenant_id != ASSET_OWNER_TENANT_ID: prefix_limit = offset + limit @@ -538,6 +567,8 @@ def get_list_indices( except TokenExpiredError as e: logger.warning("Session expired") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error get index: {str(e)}") @@ -647,9 +678,6 @@ async def get_index_files( user_id, tenant_id = get_current_user_id(authorization) require_knowledge_base_read_permission(index_name, user_id, tenant_id) result = await ElasticSearchService.list_files(index_name, include_chunks=False, vdb_core=vdb_core) - from management.services.tag.projection import ( - project_authorized_document_tags, - ) # The read permission check above authorized this page; project tags in # one batch so each row never issues its own assignment request. @@ -695,7 +723,6 @@ async def delete_documents( require_knowledge_base_edit_permission(index_name, user_id, tenant_id) if file_id: try: - from management.db.knowledge_base.file_lifecycle import get_file_record lifecycle_record = get_file_record( file_id=file_id, @@ -741,7 +768,6 @@ async def delete_documents( ) if scope == "full": - from management.services.tag.library import TagManagementService TagManagementService.cleanup_document_assignments(tenant_id, "local", index_name, path_or_url, user_id) # The deletion service performs Redis cleanup for new callers. Keep @@ -822,7 +848,6 @@ async def get_document_error_info( user_id, tenant_id = get_current_user_id(authorization) require_knowledge_base_read_permission(index_name, user_id, tenant_id) try: - from management.db.knowledge_base.file_lifecycle import get_file_record lifecycle_record = get_file_record( file_id=file_id, diff --git a/backend/management/api/mcp/remote_mcp.py b/backend/management/api/mcp/remote_mcp.py index 9639e301de..a5eb46be50 100644 --- a/backend/management/api/mcp/remote_mcp.py +++ b/backend/management/api/mcp/remote_mcp.py @@ -14,6 +14,7 @@ from contracts.errors.codes import ErrorCode from contracts.errors.exceptions import AppException, MCPConnectionError, MCPNameIllegal, MCPContainerError, McpNotFoundError, McpValidationError, McpNameConflictError, McpPortConflictError, UnauthorizedError, TenantResourceLimitError from contracts.tool import MCPConfigRequest, AddMcpServiceRequest, AddContainerMcpServiceRequest, UpdateMcpServiceRequest, EnableMcpServiceRequest, DisableMcpServiceRequest, HealthcheckMcpServiceRequest, TestMcpConnectionRequest, ListMcpServicesQuery +from permissions.resource_access import resource_read_tenant from management.services.mcp.service import get_remote_mcp_server_list, check_mcp_health_and_update_db, delete_mcp_by_container_id, upload_and_start_mcp_image, update_remote_mcp_server_list, attach_mcp_container_permissions, get_mcp_record_by_id, list_mcp_service_tools_by_id, refresh_mcp_service_tool_count, add_container_mcp_service, update_mcp_service, update_mcp_service_enabled, delete_mcp_service, check_mcp_service_health, test_mcp_connection, check_container_port_conflict, suggest_container_port from management.services.mcp.registration import add_mcp_service from management.services.tool.discovery import get_tool_from_remote_mcp_server @@ -513,9 +514,16 @@ async def get_mcp_list( user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) effective_tenant_id = tenant_id or auth_tenant_id - remote_mcp_list = await get_remote_mcp_server_list( - tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True - ) + if authorized_tenant := resource_read_tenant(http_request, shared=True): + remote_mcp_list = await get_remote_mcp_server_list( + tenant_id=authorized_tenant, is_need_auth=False, + ) + for record in remote_mcp_list: + record.update(permission="READ_ONLY", registry_json=None, config_json=None, shared_fields=None) + else: + remote_mcp_list = await get_remote_mcp_server_list( + tenant_id=effective_tenant_id, user_id=user_id, is_need_auth=True, + ) return JSONResponse( status_code=HTTPStatus.OK, @@ -575,7 +583,7 @@ async def list_mcp_containers( """List all MCP containers for the current tenant.""" try: user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) - effective_tenant_id = tenant_id or auth_tenant_id + effective_tenant_id = resource_read_tenant(http_request, shared=True) or tenant_id or auth_tenant_id try: container_manager = MCPContainerManager() @@ -612,7 +620,8 @@ async def get_container_logs( """Get logs from MCP container via SSE stream.""" try: user_id, auth_tenant_id, _ = get_current_user_info(authorization, http_request) - effective_tenant_id = tenant_id or auth_tenant_id + inventory_tenant = resource_read_tenant(http_request) + effective_tenant_id = inventory_tenant or tenant_id or auth_tenant_id try: container_manager = MCPContainerManager() @@ -620,6 +629,12 @@ async def get_container_logs( logger.error(f"Failed to initialize container manager: {e}") raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Docker service unavailable") + if inventory_tenant and not any( + container.get("container_id") == container_id + for container in container_manager.list_mcp_containers(tenant_id=effective_tenant_id) + ): + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="MCP container not found") + async def generate_log_stream(): """Generate SSE stream of container logs.""" try: @@ -662,23 +677,28 @@ async def check_mcp_health( """Check MCP service health by ID.""" try: user_id, tenant_id, _ = get_current_user_info(authorization, http_request) + read_only = bool(resource_read_tenant(http_request, shared=True)) health_status = await check_mcp_service_health( tenant_id=tenant_id, user_id=user_id, mcp_id=mcp_id, + **({"persist_status": False} if read_only else {}), ) return JSONResponse( status_code=HTTPStatus.OK, content={"status": "success", "data": {"health_status": health_status}} ) + except HTTPException: + raise except McpNotFoundError as e: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) except McpValidationError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except MCPConnectionError as e: logger.error(f"MCP connection failed: {e}") - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e) or "MCP connection failed") + detail = "MCP connection failed" if read_only else (str(e) or "MCP connection failed") + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=detail) except Exception as e: logger.error(f"Failed to check MCP health: {e}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to check MCP health") diff --git a/backend/management/api/memory/memory_dreaming.py b/backend/management/api/memory/memory_dreaming.py index 249a184757..faeca928d8 100644 --- a/backend/management/api/memory/memory_dreaming.py +++ b/backend/management/api/memory/memory_dreaming.py @@ -1,35 +1,37 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Manual Dreaming run and audit endpoints.""" -from contracts.memory import DreamingRunRequest, DreamingScheduleRequest -from contracts.constants.constants import USER_DREAMING_SCOPE -from contracts.memory import DreamingRunRequest, DreamingScheduleRequest -from contracts.constants.constants import USER_DREAMING_SCOPE -from http import HTTPStatus from datetime import datetime, timezone +from http import HTTPStatus from typing import Annotated, Literal, Optional from zoneinfo import ZoneInfo from fastapi import APIRouter, Header, HTTPException, Query -from pydantic import BaseModel, Field -from pydantic import model_validator -from nexent.scheduler import ScheduleMode, ScheduleRuleType +from pydantic import BaseModel, Field, model_validator + from contracts.automation import ScheduleTrigger +from contracts.constants.constants import USER_DREAMING_SCOPE +from contracts.constants.environment import ( + DREAMING_LONG_TERM_MAX_CHARS, + DREAMING_SOURCE_LIMIT, + DREAMING_SUMMARIZATION_MAX_ATTEMPTS, +) +from contracts.memory import DreamingRunRequest, DreamingScheduleRequest +from contracts.roles import roles_from_record +from gateway.authentication.auth import get_current_user_id +from management.db.identity.role_permission import check_role_permission +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.memory import dreaming as memory_dreaming_db from management.services.automation.scheduler import ( compute_next_fire_at, is_valid_cron_expression, ) - -from contracts.constants.environment import DREAMING_SUMMARIZATION_MAX_ATTEMPTS, DREAMING_LONG_TERM_MAX_CHARS, DREAMING_SOURCE_LIMIT -from management.db.memory import dreaming as memory_dreaming_db -from management.db.identity.role_permission import check_role_permission -from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.memory.dreaming.service import ( DreamingConflictError, DreamingRunError, get_memory_dreaming_service, ) -from gateway.authentication.auth import get_current_user_id +from nexent.scheduler import ScheduleMode, ScheduleRuleType router = APIRouter(prefix="/memory/dreaming", tags=["memory-dreaming"]) @@ -44,7 +46,7 @@ def _resolve_target_user( if not target_user_id or target_user_id == caller_user_id: return caller_user_id, tenant_id caller = get_user_tenant_by_user_id(caller_user_id) or {} - caller_role = str(caller.get("user_role") or "").upper() + caller_role = roles_from_record(caller) if not check_role_permission( caller_role, permission_category="RESOURCE", diff --git a/backend/management/api/memory/memory_long_term.py b/backend/management/api/memory/memory_long_term.py index c4d603d45e..216360f763 100644 --- a/backend/management/api/memory/memory_long_term.py +++ b/backend/management/api/memory/memory_long_term.py @@ -1,18 +1,21 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """HTTP API for versioned tenant and user Markdown long-term memory.""" -from contracts.memory import ActivateVersionRequest, CreateVersionRequest from http import HTTPStatus from typing import Literal, Optional from fastapi import APIRouter, Header, HTTPException, Query, Response from pydantic import BaseModel +from contracts.memory import ActivateVersionRequest, CreateVersionRequest +from contracts.roles import has_any_role, roles_from_record +from gateway.authentication.auth import get_current_user_id from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.memory.long_term import ( - LongTermMemoryConflict, LongTermMemoryError, get_memory_long_term_service, + LongTermMemoryConflict, + LongTermMemoryError, + get_memory_long_term_service, ) -from gateway.authentication.auth import get_current_user_id router = APIRouter(prefix="/memory/long-term", tags=["long-term-memory"]) Scope = Literal["tenant", "user"] @@ -21,7 +24,7 @@ def _authorize_mutation(scope: Scope, user_id: str) -> None: if scope == "tenant": row = get_user_tenant_by_user_id(user_id) or {} - if str(row.get("user_role") or "").upper() != "ADMIN": + if not has_any_role(roles_from_record(row), {"ADMIN"}): raise HTTPException(HTTPStatus.FORBIDDEN, "Tenant memory mutation requires the ADMIN role") diff --git a/backend/management/api/memory/memory_record.py b/backend/management/api/memory/memory_record.py index f4ae20bfed..8e2f3a014a 100644 --- a/backend/management/api/memory/memory_record.py +++ b/backend/management/api/memory/memory_record.py @@ -23,8 +23,6 @@ from __future__ import annotations -from contracts.memory import CreateMemoryRequest, SearchMemoryRequest, UpdateMemoryRequest - import logging from http import HTTPStatus from typing import Any, Dict, List, Optional @@ -33,6 +31,13 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from contracts.memory import ( + CreateMemoryRequest, + SearchMemoryRequest, + UpdateMemoryRequest, +) +from contracts.roles import has_any_role, roles_from_record +from gateway.authentication.auth import get_current_user_id from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.memory.context import get_memory_context_service from management.services.memory.records import ( @@ -40,8 +45,6 @@ get_memory_record_service, ) from management.services.memory.retrieval import get_memory_retrieval_service -from gateway.authentication.auth import get_current_user_id - logger = logging.getLogger("memory_record_app") logger.setLevel(logging.INFO) @@ -51,7 +54,7 @@ def _require_tenant_admin(user_id: str) -> None: """Reject tenant-memory writes from non-ADMIN users.""" user_tenant = get_user_tenant_by_user_id(user_id) or {} - if str(user_tenant.get("user_role") or "").upper() != "ADMIN": + if not has_any_role(roles_from_record(user_tenant), {"ADMIN"}): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Tenant memory creation requires the ADMIN role", diff --git a/backend/management/api/model/model_managment.py b/backend/management/api/model/model_managment.py index 06876ea27b..270ce8897d 100644 --- a/backend/management/api/model/model_managment.py +++ b/backend/management/api/model/model_managment.py @@ -17,57 +17,105 @@ ``CurrentUser`` and propagated as ``user_id`` / ``tenant_id`` to services. """ -from contracts.constants.constants import MODEL_CREATE_PERMISSION, MODEL_DELETE_PERMISSION, MODEL_READ_PERMISSION, MODEL_UPDATE_PERMISSION, _AUDIT_SAFE_MODEL_KEYS, _CATALOG_UNAVAILABLE_MESSAGE, _MANAGE_ALLOWED_ROLES -from contracts.model import BatchCreateModelsRequest, BackfillDefaultsRequest, CapacitySuggestionRequestFields as CapacitySuggestionFields, ModelRequest, ModelProbeRequest, ModelCapacitySuggestionRequest, ModelCapacitySuggestionResponse, ProviderModelRequest, ManageTenantModelListRequest, ManageTenantModelListResponse, ManageTenantModelCreateRequest, ManageTenantModelUpdateRequest, ManageTenantModelDeleteRequest, ManageTenantModelHealthcheckRequest, ManageBatchCreateModelsRequest, ManageProviderModelListRequest, ManageProviderModelCreateRequest, FIXED_INFERENCE_FIELDS_BY_TYPE -from contracts.errors.exceptions import TokenExpiredError, ModelProviderError -from contracts.constants.constants import MODEL_CREATE_PERMISSION, MODEL_DELETE_PERMISSION, MODEL_READ_PERMISSION, MODEL_UPDATE_PERMISSION, _AUDIT_SAFE_MODEL_KEYS, _CATALOG_UNAVAILABLE_MESSAGE, _MANAGE_ALLOWED_ROLES -from management.utils.app_factory import ModelCredentialRoute import logging import re - -from contracts.model import BatchCreateModelsRequest, BackfillDefaultsRequest, CapacitySuggestionRequestFields as CapacitySuggestionFields, ModelRequest, ModelProfilePreviewInput, ModelOutletCreateRequest, ModelOutletPatchRequest, ModelOutletValidationRequest, ModelOutletMigrationReviewRequest, ModelProbeRequest, ModelCapacitySuggestionRequest, ModelCapacitySuggestionResponse, ProviderModelRequest, ManageTenantModelListRequest, ManageTenantModelListResponse, ManageTenantModelCreateRequest, ManageTenantModelUpdateRequest, ManageTenantModelDeleteRequest, ManageTenantModelHealthcheckRequest, ManageBatchCreateModelsRequest, ManageProviderModelListRequest, ManageProviderModelCreateRequest, FIXED_INFERENCE_FIELDS_BY_TYPE -from contracts.constants.environment import CAPACITY_SUGGESTION_ENABLED - -from fastapi import APIRouter, Depends, Header, Query, HTTPException, Request, Path -from fastapi.responses import JSONResponse -from fastapi.encoders import jsonable_encoder from http import HTTPStatus from typing import Annotated, Any, Dict, List, Optional -from management.services.monitor.audit import record_security_event + +from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse + +from contracts.constants.constants import ( + _AUDIT_SAFE_MODEL_KEYS, + _CATALOG_UNAVAILABLE_MESSAGE, + _MANAGE_ALLOWED_ROLES, + MODEL_CREATE_PERMISSION, + MODEL_DELETE_PERMISSION, + MODEL_READ_PERMISSION, + MODEL_UPDATE_PERMISSION, +) +from contracts.constants.environment import CAPACITY_SUGGESTION_ENABLED +from contracts.errors.exceptions import ( + DuplicateError, + ModelOutletConflictError, + ModelOutletValidationError, + ModelProviderError, + TokenExpiredError, +) +from contracts.model import ( + FIXED_INFERENCE_FIELDS_BY_TYPE, + BackfillDefaultsRequest, + BatchCreateModelsRequest, + ManageBatchCreateModelsRequest, + ManageProviderModelCreateRequest, + ManageProviderModelListRequest, + ManageTenantModelCreateRequest, + ManageTenantModelDeleteRequest, + ManageTenantModelHealthcheckRequest, + ManageTenantModelListRequest, + ManageTenantModelListResponse, + ManageTenantModelUpdateRequest, + ModelCapacitySuggestionRequest, + ModelCapacitySuggestionResponse, + ModelOutletCreateRequest, + ModelOutletMigrationReviewRequest, + ModelOutletPatchRequest, + ModelOutletValidationRequest, + ModelProbeRequest, + ModelProfilePreviewInput, + ModelRequest, + ProviderModelRequest, +) +from contracts.model import CapacitySuggestionRequestFields as CapacitySuggestionFields +from contracts.roles import has_any_role +from gateway.authentication.auth import authenticate, get_current_user_id +from management.db.model.repository import get_model_by_model_id +from management.services.model.capacity import ( + _record_capacity_suggestion_accept, + get_capacity_coverage, + pop_capacity_accept_signal, + suggest_capacity, +) from management.services.model.health import ( check_model_connectivity, verify_model_config_connectivity, ) -from management.services.model.capacity import suggest_capacity from management.services.model.management import ( - create_model_for_tenant, - create_provider_models_for_tenant, + _backfill_default_model_slots, + _ids_for_created_models, batch_create_models_for_tenant, - list_provider_models_for_tenant, - update_single_model_for_tenant, batch_update_models_for_tenant, + create_model_for_tenant, + create_provider_models_for_tenant, delete_model_for_tenant, - list_models_for_tenant, list_llm_models_for_tenant, list_models_for_admin, - _ids_for_created_models, - _backfill_default_model_slots, + list_models_for_tenant, + list_provider_models_for_tenant, + update_single_model_for_tenant, ) -from management.services.model.capacity import ( - get_capacity_coverage, - pop_capacity_accept_signal, - _record_capacity_suggestion_accept, +from management.services.model.profiles import ( + confirm_outlet_migration, + create_model_outlet, + get_model_outlet, + list_atomic_profiles, + list_profile_protocols, + preview_model_profile, + preview_outlet_patch, + update_model_outlet, + validate_model_outlet, ) from management.services.model.reasoning import ( get_model_reasoning_capability, ) -from gateway.authentication.auth import authenticate +from management.services.monitor.audit import record_security_event +from management.utils.app_factory import ModelCredentialRoute +from nexent.core.concurrency import run_blocking from permissions.depends import require from permissions.models import CurrentUser -from gateway.authentication.auth import get_current_user_id -from contracts.errors.exceptions import DuplicateError, TokenExpiredError, ModelProviderError, ModelOutletValidationError, ModelOutletConflictError -from nexent.core.concurrency import run_blocking -from management.db.model.repository import get_model_by_model_id +from permissions.resource_access import INVENTORY_READ, require_inventory_scope +from permissions.tenant_scope import resolve_tenant_scope # Permission strings normalized by backend RBAC cache (lower-case type:subtype). # Roles allowed on the cross-tenant /manage/* endpoints. ADMIN shares the same @@ -78,10 +126,12 @@ # Model Catalog loader (with graceful fallback) try: from configs.model_catalog_loader import ( + dump_full_catalog, list_catalog_providers, list_models_by_provider, + ) + from configs.model_catalog_loader import ( get_model_profile as _catalog_get_model_profile, - dump_full_catalog, ) _CATALOG_AVAILABLE = True @@ -102,8 +152,6 @@ def dump_full_catalog(): return {"version": "0.0.0", "metadata": {}, "providers": []} - - router = APIRouter(prefix="/model", route_class=ModelCredentialRoute) logger = logging.getLogger("model_management_app") @@ -159,17 +207,15 @@ def _require_manage_scope(current_user: CurrentUser, target_tenant_id: str) -> N managing their own models while blocking no cross-tenant access. Any other role, or an ADMIN naming a foreign tenant, is rejected. """ - role = current_user.normalized_role - if role not in _MANAGE_ALLOWED_ROLES: + role = current_user.effective_roles + if not has_any_role(role, _MANAGE_ALLOWED_ROLES): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="This operation requires SU or tenant ADMIN role", ) - if role != "SU" and target_tenant_id != current_user.tenant_id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Tenant admins may only manage models of their own tenant", - ) + resolve_tenant_scope( + current_user, target_tenant_id, denied_detail="Tenant admins may only manage models of their own tenant", + ) def _catalog_unavailable_response(status_code: HTTPStatus, **extra: Any) -> JSONResponse: @@ -376,7 +422,7 @@ async def suggest_model_capacity( @router.get("/capacity-coverage") async def get_model_capacity_coverage( - current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION, alternative=INVENTORY_READ)), ): """Return bare-capacity LLM/VLM coverage for the current tenant. @@ -811,7 +857,7 @@ async def check_temporary_model_health( @router.post("/manage/healthcheck") async def manage_check_model_health( request: ManageTenantModelHealthcheckRequest, - current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION, alternative=INVENTORY_READ)), ): """Check and update model connectivity for a specified tenant (admin/manage operation). @@ -823,14 +869,23 @@ async def manage_check_model_health( Returns: Connectivity check result with updated status. """ - _require_manage_scope(current_user, request.tenant_id) + read_only = not has_any_role(current_user.effective_roles, _MANAGE_ALLOWED_ROLES) + if read_only: + require_inventory_scope(current_user, request.tenant_id) + else: + _require_manage_scope(current_user, request.tenant_id) try: logger.debug( f"Start to check model connectivity for tenant, user_id: {current_user.user_id}, " f"target_tenant_id: {request.tenant_id}, display_name: {request.display_name}" ) - result = await check_model_connectivity(request.display_name, request.tenant_id, request.model_type) + result = await check_model_connectivity( + request.display_name, request.tenant_id, request.model_type, + **({"persist_status": False} if read_only else {}), + ) + if read_only and not result.get("connectivity"): + result["error"] = "Model connectivity check failed" return JSONResponse( status_code=HTTPStatus.OK, content={"message": "Successfully checked model connectivity", "data": result} ) @@ -1091,7 +1146,7 @@ async def manage_batch_create_models( @router.post("/manage/list", response_model=ManageTenantModelListResponse) async def manage_list_models( request: ManageTenantModelListRequest, - current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION, alternative=INVENTORY_READ)), ): """List models for a specified tenant (admin/manage operation). @@ -1103,7 +1158,12 @@ async def manage_list_models( Returns: Paginated model list for the specified tenant. """ - _require_manage_scope(current_user, request.tenant_id) + if has_any_role(current_user.effective_roles, {"OPS"}) and not has_any_role( + current_user.effective_roles, _MANAGE_ALLOWED_ROLES + ): + require_inventory_scope(current_user, request.tenant_id) + else: + _require_manage_scope(current_user, request.tenant_id) try: logger.debug( f"Start to list models for tenant, user_id: {current_user.user_id}, target_tenant_id: {request.tenant_id}, " @@ -1437,7 +1497,6 @@ async def get_model_profiles( current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """List local model references without claiming provider/model qualification.""" - from management.services.model.profiles import list_atomic_profiles data = await run_blocking("model-profile-list", list_atomic_profiles, "model", query=query, family=family, page=page, page_size=page_size) @@ -1452,7 +1511,6 @@ async def get_provider_profiles( current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """List provider references; endpoint hints are suggestions only.""" - from management.services.model.profiles import list_atomic_profiles data = await run_blocking("provider-profile-list", list_atomic_profiles, "provider", query=query, page=page, page_size=page_size) @@ -1462,7 +1520,6 @@ async def get_provider_profiles( @router.get("/profile_protocols") async def get_profile_protocols(current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION))): """List the SDK's registered protocol and authentication choices.""" - from management.services.model.profiles import list_profile_protocols return JSONResponse(status_code=HTTPStatus.OK, content={"data": list_profile_protocols()}) @@ -1473,7 +1530,6 @@ async def create_profile_preview( current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """Compose a draft from trusted catalog selections without saving an outlet.""" - from management.services.model.profiles import preview_model_profile try: data = await run_blocking("model-profile-preview", preview_model_profile, request, current_user.tenant_id) @@ -1492,7 +1548,6 @@ async def create_model_outlet_endpoint( current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), ): """Save a fully composed LLM outlet under the authenticated tenant.""" - from management.services.model.profiles import create_model_outlet try: data = await run_blocking("model-outlet-create", create_model_outlet, request, @@ -1521,7 +1576,6 @@ async def get_model_outlet_endpoint( current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """Return the tenant-owned saved outlet, not a newly composed catalog view.""" - from management.services.model.profiles import get_model_outlet data = await run_blocking("model-outlet-get", get_model_outlet, model_id, current_user.tenant_id) if data is None: @@ -1536,7 +1590,6 @@ async def preview_model_outlet_patch_endpoint( current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), ): """Preview a tenant-owned edit without writing or exposing its credentials.""" - from management.services.model.profiles import preview_outlet_patch try: data = await run_blocking("model-outlet-preview", preview_outlet_patch, model_id, request, current_user.tenant_id) @@ -1558,7 +1611,6 @@ async def patch_model_outlet_endpoint( current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Apply revision-checked changes using the saved outlet's own rules.""" - from management.services.model.profiles import update_model_outlet try: data = await run_blocking("model-outlet-update", update_model_outlet, model_id, request, @@ -1583,7 +1635,6 @@ async def validate_model_outlet_endpoint( current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Check only endpoint/model availability through the common model service.""" - from management.services.model.profiles import validate_model_outlet try: data = await validate_model_outlet(request, current_user.tenant_id, @@ -1606,7 +1657,6 @@ async def confirm_model_outlet_migration_endpoint( current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), ): """Allow tenant administrators to confirm the displayed outlet revision.""" - from management.services.model.profiles import confirm_outlet_migration _require_manage_scope(current_user, current_user.tenant_id) try: diff --git a/backend/management/api/skill/skill.py b/backend/management/api/skill/skill.py index e13cf1e110..cf21e5bfe5 100644 --- a/backend/management/api/skill/skill.py +++ b/backend/management/api/skill/skill.py @@ -19,6 +19,7 @@ from management.services.agent.authorization import require_agent_draft_edit from management.services.skill.service import SkillService, UnsupportedSkillFilePreview from management.services.skill.transfer import get_official_skills_with_status, install_skills_from_zip_for_tenant, update_skill_list +from permissions.resource_access import resource_read_tenant from gateway.authentication.auth import get_current_user_id @@ -60,18 +61,26 @@ def _build_skill_update_data(request: SkillUpdateRequest) -> Dict[str, Any]: async def list_skills( tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to query specific tenant's skills"), authorization: Optional[str] = Header(None), + request: Request = None, ) -> JSONResponse: """List all available skills for the current tenant (or a specific tenant for super admin).""" try: user_id, current_tenant_id = get_current_user_id(authorization) # Super admin can query a specific tenant's skills; otherwise use current user's tenant - effective_tenant_id = tenant_id if tenant_id else current_tenant_id + inventory_tenant = resource_read_tenant(request, shared=True) + effective_tenant_id = inventory_tenant or tenant_id or current_tenant_id service = SkillService(tenant_id=effective_tenant_id) + if inventory_tenant: + fields = ("skill_id", "name", "description", "source", "tags", "update_time") + skills = [{field: skill.get(field) for field in fields} for skill in service.list_skills()] + return JSONResponse(content={"skills": skills}) skills = service.list_visible_skills( tenant_id=effective_tenant_id, user_id=user_id, ) return JSONResponse(content={"skills": skills}) + except HTTPException: + raise except SkillException as e: raise HTTPException(status_code=500, detail=str(e)) except Exception as e: diff --git a/backend/management/api/tag/tag_management.py b/backend/management/api/tag/tag_management.py index c7dd9bea16..77535611fd 100644 --- a/backend/management/api/tag/tag_management.py +++ b/backend/management/api/tag/tag_management.py @@ -2,20 +2,50 @@ """HTTP endpoints for managing fixed tenant tag libraries.""" -from contracts.tag import SkillTagAssignmentBatchRequest, KnowledgeTagAssignmentBatchRequest, AgentTagAssignmentBatchRequest, TagAssignmentBulkOutcome, TagAssignmentBulkReplaceRequest, TagAssignmentReplaceRequest, TagAssignmentResponse, TagDefinitionCreateRequest, TagDefinitionResponse, TagDefinitionUpdateRequest, TagDefinitionUsageResponse, TagDeleteResponse, TagDocumentBatchStatusRequest, TagDocumentBatchStatusResponse, TagDocumentProjectionStatusResponse, TagLegacyFlatTagsProjectionResponse, TagLibraryResponse, TagOrderUpdateRequest, TagResourceFilterRequest, TagResourceFilterResponse, TagStatusUpdateRequest, TagValueCreateRequest, TagValueResponse, TagValueUpdateRequest, TagValueUsageResponse, ToolTagAssignmentBatchRequest -from contracts.tag import TAG_CONFLICT_RESPONSES from typing import Annotated from fastapi import APIRouter, Header, HTTPException, Query from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES -from contracts.errors.exceptions import TagManagementConflictError, TagManagementNotFoundError, ValidationError -from contracts.tag import SkillTagAssignmentBatchRequest, KnowledgeTagAssignmentBatchRequest, AgentTagAssignmentBatchRequest, TagAssignmentBulkOutcome, TagAssignmentBulkReplaceRequest, TagAssignmentReplaceRequest, TagAssignmentResponse, TagDefinitionCreateRequest, TagDefinitionResponse, TagDefinitionUpdateRequest, TagDefinitionUsageResponse, TagDeleteResponse, TagDocumentBatchStatusRequest, TagDocumentBatchStatusResponse, TagDocumentProjectionStatusResponse, TagLegacyFlatTagsProjectionResponse, TagLibraryResponse, TagOrderUpdateRequest, TagResourceFilterRequest, TagResourceFilterResponse, TagStatusUpdateRequest, TagValueCreateRequest, TagValueResponse, TagValueUpdateRequest, TagValueUsageResponse, ToolTagAssignmentBatchRequest +from contracts.errors.exceptions import ( + TagManagementConflictError, + TagManagementNotFoundError, + ValidationError, +) +from contracts.roles import has_any_role +from contracts.tag import ( + TAG_CONFLICT_RESPONSES, + AgentTagAssignmentBatchRequest, + KnowledgeTagAssignmentBatchRequest, + SkillTagAssignmentBatchRequest, + TagAssignmentBulkOutcome, + TagAssignmentBulkReplaceRequest, + TagAssignmentReplaceRequest, + TagAssignmentResponse, + TagDefinitionCreateRequest, + TagDefinitionResponse, + TagDefinitionUpdateRequest, + TagDefinitionUsageResponse, + TagDeleteResponse, + TagDocumentBatchStatusRequest, + TagDocumentBatchStatusResponse, + TagDocumentProjectionStatusResponse, + TagLegacyFlatTagsProjectionResponse, + TagLibraryResponse, + TagOrderUpdateRequest, + TagResourceFilterRequest, + TagResourceFilterResponse, + TagStatusUpdateRequest, + TagValueCreateRequest, + TagValueResponse, + TagValueUpdateRequest, + TagValueUsageResponse, + ToolTagAssignmentBatchRequest, +) +from gateway.authentication.auth import get_current_user_context from management.db.identity.role_permission import check_role_permission -from fastapi import APIRouter, Header, HTTPException, Query from management.services.tag.library import TagManagementService from management.services.tag.resources import AuthenticatedCaller -from gateway.authentication.auth import get_current_user_context router = APIRouter(prefix="/tag-libraries", tags=["tag-libraries"]) @@ -38,7 +68,7 @@ def _assignment_caller(authorization: str | None) -> AuthenticatedCaller: user_id=user_id, authenticated_tenant_id=tenant_id, role=role, - can_edit_all=role in CAN_EDIT_ALL_USER_ROLES, + can_edit_all=has_any_role(role, CAN_EDIT_ALL_USER_ROLES), ) diff --git a/backend/management/api/tool/tool_config.py b/backend/management/api/tool/tool_config.py index a694ed2fa5..e8162ddee8 100644 --- a/backend/management/api/tool/tool_config.py +++ b/backend/management/api/tool/tool_config.py @@ -27,6 +27,7 @@ from contracts.errors.exceptions import AgentDraftEditError, ResourceBindingError from management.db.identity.user_tenant import get_user_email_map from management.services.monitor.audit import record_security_event +from permissions.resource_access import resource_read_tenant from gateway.authentication.auth import get_current_user_id router = APIRouter(prefix="/tool") @@ -270,17 +271,22 @@ async def import_openapi_service_api( @router.get("/openapi_services") -async def list_openapi_services_api(authorization: Optional[str] = Header(None)): +async def list_openapi_services_api(authorization: Optional[str] = Header(None), request: Request = None): """ List all OpenAPI services for the current tenant. """ try: _, tenant_id = get_current_user_id(authorization) services = list_openapi_services(tenant_id) + if resource_read_tenant(request, shared=True): + fields = ("id", "mcp_service_name", "description") + services = [{field: service.get(field) for field in fields} for service in services] return JSONResponse(status_code=HTTPStatus.OK, content={"message": "success", "data": services}) except TokenExpiredError as e: logger.warning("Session expired") raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) + except HTTPException: + raise except Exception as e: logger.error(f"Failed to list OpenAPI services: {e}") raise HTTPException( diff --git a/backend/management/db/client.py b/backend/management/db/client.py index 697e0aa003..22fbfa718d 100644 --- a/backend/management/db/client.py +++ b/backend/management/db/client.py @@ -8,10 +8,23 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from contracts.constants.environment import MINIO_ACCESS_KEY, MINIO_DEFAULT_BUCKET, MINIO_ENDPOINT, MINIO_REGION, MINIO_SECRET_KEY, NEXENT_POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER +from contracts.constants.environment import ( + MINIO_ACCESS_KEY, + MINIO_DEFAULT_BUCKET, + MINIO_ENDPOINT, + MINIO_REGION, + MINIO_SECRET_KEY, + NEXENT_POSTGRES_PASSWORD, + POSTGRES_DB, + POSTGRES_HOST, + POSTGRES_PORT, + POSTGRES_USER, +) from management.db.models import TableBase -from nexent.storage.storage_client_factory import create_storage_client_from_config, MinIOStorageConfig - +from nexent.storage.storage_client_factory import ( + MinIOStorageConfig, + create_storage_client_from_config, +) logger = logging.getLogger("management.db.client") diff --git a/backend/management/db/conversation/repository.py b/backend/management/db/conversation/repository.py index 5ae8125a25..53c6b214a0 100644 --- a/backend/management/db/conversation/repository.py +++ b/backend/management/db/conversation/repository.py @@ -1,8 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.constants.constants import CHAT_MODE_VALUES, HISTORY_SUMMARY_UNIT_TYPE -from contracts.conversation import ConversationHistory -from contracts.errors.exceptions import HistorySummaryPersistenceError import json from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence, Set @@ -10,31 +7,59 @@ from sqlalchemy import and_, asc, desc, func, insert, or_, select, text, update from sqlalchemy.orm import Session -from contracts.constants.environment import MAX_CONVERSATION_TURNS, MAX_CONVERSATIONS_PER_USER -from contracts.constants.constants import MESSAGE_ROLE +from contracts.clarification import ClarificationResponse, normalize_form +from contracts.constants.constants import ( + ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, + CHAT_MODE_VALUES, + DEFAULT_TENANT_ID, + HISTORY_SUMMARY_UNIT_TYPE, + MESSAGE_ROLE, +) +from contracts.constants.environment import ( + MAX_CONVERSATION_TURNS, + MAX_CONVERSATIONS_PER_USER, +) +from contracts.conversation import ( + ConversationHistory, + MessageSource, + decode_activity_cursor, + encode_activity_cursor, +) from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException, ConversationNotFoundError, RuntimeMetadataVersionConflict, WorkbenchConfigVersionConflict, WorkbenchError -from contracts.conversation import MessageSource, decode_activity_cursor, encode_activity_cursor -from management.db.models import KnowledgeRecord - -from management.utils.orm import as_dict +from contracts.errors.exceptions import ( + AppException, + ConversationNotFoundError, + HistorySummaryPersistenceError, + RuntimeMetadataVersionConflict, + WorkbenchConfigVersionConflict, + WorkbenchError, +) +from contracts.roles import has_any_role, roles_from_record from management.db.client import db_client, get_db_session +from management.db.conversation.classification import ( + executor_system_key, + is_workbench_history, +) +from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.db.models import ( + AgentAutomationProposal, ConversationMessage, ConversationMessageUnit, ConversationRecord, ConversationSourceImage, ConversationSourceSearch, + KnowledgeRecord, + UserTenant, ) from management.utils.audit_fields import add_creation_tracking, add_update_tracking -from management.db.conversation.classification import executor_system_key, is_workbench_history +from management.utils.orm import as_dict def load_execution_result_content( user_id: str, tenant_id: str, conversation_id: int, message_id: int, unit_index: int, ) -> str: """Load one execution unit in the caller's current conversation scope.""" - from management.db.models import UserTenant membership = select(UserTenant.user_id).where( UserTenant.user_id == user_id, @@ -100,19 +125,17 @@ def _parse_history_summary_content(content: Any) -> Optional[Dict[str, Any]]: def _get_user_tenant(user_id: str) -> Optional[Dict[str, Any]]: """Resolve tenant ownership lazily to keep database modules decoupled.""" - from management.db.identity.user_tenant import get_user_tenant_by_user_id return get_user_tenant_by_user_id(user_id) def _get_effective_tenant_id(user_tenant: Dict[str, Any]) -> str: """Resolve legacy empty tenant fields consistently with authentication.""" - from contracts.constants.constants import ASSET_OWNER_ROLE, ASSET_OWNER_TENANT_ID, DEFAULT_TENANT_ID tenant_id = user_tenant.get("tenant_id") if tenant_id: return tenant_id - if (user_tenant.get("user_role") or "").upper() == ASSET_OWNER_ROLE: + if has_any_role(roles_from_record(user_tenant), {ASSET_OWNER_ROLE}): return ASSET_OWNER_TENANT_ID return DEFAULT_TENANT_ID @@ -379,7 +402,6 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ def _resolve_clarification_response(session, conversation_id, user_id, role, raw): """Resolve an owned latest form before writing either side of the response.""" - from contracts.clarification import ClarificationResponse, normalize_form response = ClarificationResponse.model_validate(raw) if role != MESSAGE_ROLE['USER'] or not user_id: @@ -613,7 +635,7 @@ def persist_assistant_run_batch( session.execute(insert(ConversationSourceImage), image_rows) if automation_proposals: - from management.db.models import AgentAutomationProposal + pass for proposal_link in automation_proposals: unit_index = int(proposal_link["unit_index"]) diff --git a/backend/management/db/identity/account.py b/backend/management/db/identity/account.py index fd0adbbef1..a2a7718df0 100644 --- a/backend/management/db/identity/account.py +++ b/backend/management/db/identity/account.py @@ -7,9 +7,16 @@ from sqlalchemy import select, text, update from contracts.errors.exceptions import AccountConflictError -from management.utils.orm import as_dict +from contracts.roles import normalize_roles, roles_from_record +from infrastructure.security.passwords import password_scheme from management.db.client import get_db_session -from management.db.models import UserAccount +from management.db.identity.membership import ( + check_admin_removal, + lock_tenants, + locked_members, +) +from management.db.models import UserAccount, UserTenant +from management.utils.orm import as_dict def lock_account_keys(session, user_id: str, email: str | None) -> None: @@ -21,9 +28,7 @@ def lock_account_keys(session, user_id: str, email: str | None) -> None: if email is not None: keys.add(f"account-email:{email}") for key in sorted(keys): - session.execute( - text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": key} - ) + session.execute(text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": key}) def _one(statement, db_session=None): @@ -36,9 +41,7 @@ def _one(statement, db_session=None): def get_account(user_id: str, db_session=None): return _one( - select(UserAccount).where( - UserAccount.user_id == user_id, UserAccount.delete_flag == "N" - ), + select(UserAccount).where(UserAccount.user_id == user_id, UserAccount.delete_flag == "N"), db_session, ) @@ -53,33 +56,28 @@ def get_account_by_email(email: str, db_session=None): ) -def create_account(email: str | None, password_hash=None, *, user_id=None, db_session=None, - api_only=False, created_by=None): +def create_account( + email: str | None, password_hash=None, *, user_id=None, db_session=None, api_only=False, created_by=None +): """Create a login identity or an explicitly passwordless, email-free API identity.""" user_id = user_id or str(uuid4()) if api_only and (email is not None or password_hash is not None): raise ValueError("API-only accounts cannot have an email or password") email = None if api_only else str(email or "").strip().lower() if not api_only and ( - not email - or len(email) > 255 - or email.count("@") != 1 - or any(character.isspace() for character in email) + not email or len(email) > 255 or email.count("@") != 1 or any(character.isspace() for character in email) ): raise ValueError("Invalid account email") with get_db_session(db_session) as session: lock_account_keys(session, user_id, email) if get_account(user_id, session) or (email is not None and get_account_by_email(email, session)): raise AccountConflictError("Email already exists") - from infrastructure.security.passwords import password_scheme - account = UserAccount( user_id=user_id, user_email=email, + user_roles=[], password_hash=password_hash, - password_hash_scheme=password_scheme(password_hash) - if password_hash - else None, + password_hash_scheme=password_scheme(password_hash) if password_hash else None, password_login_enabled=bool(password_hash), token_version=0, email_confirmed_at=None if api_only else datetime.utcnow(), @@ -95,6 +93,13 @@ def create_account(email: str | None, password_hash=None, *, user_id=None, db_se def delete_account(user_id: str, db_session=None): with get_db_session(db_session) as session: + tenant_ids = session.scalars( + select(UserTenant.tenant_id).where(UserTenant.user_id == user_id, UserTenant.delete_flag == "N") + ).all() + lock_tenants(session, tenant_ids) + member = locked_members(session, [user_id]).get(user_id) + if member: + check_admin_removal(session, member, roles_from_record(member), ()) session.execute( update(UserAccount) .where(UserAccount.user_id == user_id, UserAccount.delete_flag == "N") @@ -111,8 +116,6 @@ def delete_account(user_id: str, db_session=None): def change_password(user_id: str, expected_hash: str, password_hash: str): """Compare-and-swap prevents overwriting a concurrent password change.""" - from infrastructure.security.passwords import password_scheme - with get_db_session() as session: result = session.execute( update(UserAccount) @@ -135,16 +138,24 @@ def change_password(user_id: str, expected_hash: str, password_hash: str): def _account_dict(account): result = as_dict(account) + roles = normalize_roles(result.get("user_roles"), allow_empty=True) + result["roles"] = list(roles) + result["user_role"] = roles[0] if roles else None # Existing HTTP/service contracts still expose the established email key. result["email"] = result.get("user_email") return result -def set_account_profile(user_id, role, email, actor, session): +def set_account_profile(user_id, role, email, actor, session, *, roles=None): account = get_account(user_id, session) if not account: raise AccountConflictError("Account profile is missing; run the account schema migration first") - values = {"user_role": role, "updated_by": actor, "update_time": datetime.utcnow()} + assigned = normalize_roles(role if roles is None else roles) + values = { + "user_roles": list(assigned), + "updated_by": actor, + "update_time": datetime.utcnow(), + } if email is not None: email = str(email).strip().lower() lock_account_keys(session, user_id, email) @@ -152,4 +163,6 @@ def set_account_profile(user_id, role, email, actor, session): if existing and existing["user_id"] != user_id: raise AccountConflictError("Email already exists") values["user_email"] = email - session.execute(update(UserAccount).where(UserAccount.user_id == user_id, UserAccount.delete_flag == "N").values(**values)) + session.execute( + update(UserAccount).where(UserAccount.user_id == user_id, UserAccount.delete_flag == "N").values(**values) + ) diff --git a/backend/management/db/identity/invitation.py b/backend/management/db/identity/invitation.py index bdb20cdad0..05183266b4 100644 --- a/backend/management/db/identity/invitation.py +++ b/backend/management/db/identity/invitation.py @@ -3,12 +3,14 @@ Database operations for invitation code management """ -from management.utils.orm import as_dict from typing import Any, Dict, List, Optional -from management.utils.orm import as_dict +from sqlalchemy import func, outerjoin + +from contracts.roles import invitation_roles from management.db.client import get_db_session from management.db.models import TenantInvitationCode, TenantInvitationRecord +from management.utils.orm import as_dict from management.utils.serialization import convert_list_to_string @@ -85,6 +87,8 @@ def add_invitation( expiry_date: Optional[str] = None, status: str = "IN_USE", created_by: Optional[str] = None, + roles=None, + db_session=None, ) -> int: """ Add a new invitation @@ -102,11 +106,12 @@ def add_invitation( Returns: int: Created invitation ID """ - with get_db_session() as session: + with get_db_session(db_session) as session: invitation = TenantInvitationCode( tenant_id=tenant_id, invitation_code=invitation_code, code_type=code_type, + roles=list(invitation_roles({"roles": roles, "code_type": code_type})), group_ids=convert_list_to_string(group_ids), capacity=capacity, expiry_date=expiry_date, @@ -119,7 +124,7 @@ def add_invitation( return invitation.invitation_id -def modify_invitation(invitation_id: int, updates: Dict[str, Any], updated_by: Optional[str] = None) -> bool: +def modify_invitation(invitation_id: int, updates: Dict[str, Any], updated_by: Optional[str] = None, db_session=None) -> bool: """ Modify invitation @@ -131,7 +136,9 @@ def modify_invitation(invitation_id: int, updates: Dict[str, Any], updated_by: O Returns: bool: Whether update was successful """ - with get_db_session() as session: + with get_db_session(db_session) as session: + if "roles" in updates or "code_type" in updates: + raise ValueError("Invitation roles are immutable") update_data = updates.copy() if updated_by: update_data["updated_by"] = updated_by @@ -149,7 +156,7 @@ def modify_invitation(invitation_id: int, updates: Dict[str, Any], updated_by: O return result > 0 -def remove_invitation(invitation_id: int, updated_by: Optional[str] = None) -> bool: +def remove_invitation(invitation_id: int, updated_by: Optional[str] = None, db_session=None) -> bool: """ Remove invitation (soft delete) @@ -160,7 +167,7 @@ def remove_invitation(invitation_id: int, updated_by: Optional[str] = None) -> b Returns: bool: Whether removal was successful """ - with get_db_session() as session: + with get_db_session(db_session) as session: update_data: Dict[str, Any] = {"delete_flag": "Y"} if updated_by: update_data["updated_by"] = updated_by @@ -295,7 +302,6 @@ def query_invitations_with_pagination( Returns: Dict[str, Any]: Dictionary containing items list and total count """ - from sqlalchemy import func, outerjoin with get_db_session() as session: # Create subquery to count usage records per invitation diff --git a/backend/management/db/identity/membership.py b/backend/management/db/identity/membership.py new file mode 100644 index 0000000000..b9fdb85c13 --- /dev/null +++ b/backend/management/db/identity/membership.py @@ -0,0 +1,81 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Membership reads, transaction locks and active-administrator invariants.""" + +from typing import Any, Dict, Optional + +from sqlalchemy import func, select, text + +from contracts.errors.exceptions import RoleAssignmentConflict +from management.db.client import get_db_session +from management.db.models import UserAccount, UserTenant +from management.utils.orm import as_dict + + +def lock_tenants(session, tenant_ids): + """Use the same order as member creation and quota checks.""" + for tenant_id in sorted(set(tenant_ids)): + for kind in ("user", "admin"): + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:key))"), {"key": f"tenant-{kind}-limit:{tenant_id}"} + ) + + +def locked_members(session, user_ids): + rows = ( + session.execute( + select(UserTenant) + .join(UserAccount, UserAccount.user_id == UserTenant.user_id) + .where( + UserTenant.user_id.in_(sorted(set(user_ids))), + UserTenant.delete_flag == "N", + UserAccount.delete_flag == "N", + ) + .order_by(UserTenant.user_id) + .with_for_update(of=(UserTenant, UserAccount)) + ) + .scalars() + .all() + ) + return {row.user_id: as_dict(row) for row in rows} + + +def active_admin_count(session, tenant_id): + return session.scalar( + select(func.count()) + .select_from(UserTenant) + .join(UserAccount, UserAccount.user_id == UserTenant.user_id) + .where( + UserTenant.tenant_id == tenant_id, + UserTenant.delete_flag == "N", + UserAccount.delete_flag == "N", + UserAccount.user_roles.contains(["ADMIN"]), + ) + ) + + +def api_only_account(session, user_id): + metadata = ( + session.scalar( + select(UserAccount.source_metadata).where(UserAccount.user_id == user_id, UserAccount.delete_flag == "N") + ) + or {} + ) + return bool(metadata.get("api_only")) + + +def get_user_tenant_by_user_id(user_id: str) -> Optional[Dict[str, Any]]: + """Return the active membership, including its account compatibility fields.""" + with get_db_session() as session: + result = session.query(UserTenant).filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N").first() + + if result: + return as_dict(result) + return None + + +def check_admin_removal(session, target, old, requested, actor_id=None): + if "ADMIN" in old and "ADMIN" not in requested: + if actor_id == target["user_id"]: + raise RoleAssignmentConflict("Cannot remove your own ADMIN role") + if active_admin_count(session, target["tenant_id"]) <= 1: + raise RoleAssignmentConflict("Cannot remove the last active tenant administrator") diff --git a/backend/management/db/identity/models.py b/backend/management/db/identity/models.py index da63ab9815..85f5dbd643 100644 --- a/backend/management/db/identity/models.py +++ b/backend/management/db/identity/models.py @@ -9,15 +9,12 @@ Sequence, String, UniqueConstraint, - text, select, + text, ) - - -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import column_property - from management.db.base import SCHEMA, SimpleTableBase, TableBase @@ -66,6 +63,8 @@ class TenantInvitationCode(TableBase): doc="Tenant ID, foreign key") invitation_code = Column(String(100), nullable=False, unique=True, doc="Invitation code") + roles = Column(ARRAY(String(30)), nullable=False, + comment="Immutable built-in roles granted when this invitation is consumed") group_ids = Column(String, doc="Associated group IDs list") capacity = Column(Integer, nullable=False, default=1, doc="Invitation code capacity") @@ -236,8 +235,6 @@ class UserCasSession(TableBase): revoked_at = Column(TIMESTAMP(timezone=False), doc="Revocation time") - - class UserAccount(TableBase): """Authentication identity independent of tenant membership and provider bindings.""" @@ -251,7 +248,8 @@ class UserAccount(TableBase): primary_key=True, comment="Technical account record identifier") user_id = Column(String(100), nullable=False, comment="Stable user identifier including legacy system IDs; service-serialized uniqueness") user_email = Column(String(255), comment="Normalized login email; null for passwordless identities") - user_role = Column(String(30), comment="Canonical account role transferred from membership and maintained by identity services") + user_roles = Column(ARRAY(String(30)), nullable=False, default=list, server_default=text("'{}'::varchar[]"), + comment="Authoritative built-in account roles; empty until assigned within registration") password_hash = Column(String(512), comment="Encoded local or imported password hash; never returned to API callers") password_hash_scheme = Column(String(30), comment="Password encoding scheme: scrypt or imported bcrypt") password_login_enabled = Column(Boolean, nullable=False, server_default=text("false"), @@ -263,9 +261,13 @@ class UserAccount(TableBase): comment="Migration/provider provenance, API-only and system identity markers; never grants permissions") # Read projections preserve established association query/response contracts. -# These expressions create no role/email columns on the association table. +# These expressions create no role or email columns on the association table. +UserTenant.roles = column_property( + select(UserAccount.user_roles).where(UserAccount.user_id == UserTenant.user_id, + UserAccount.delete_flag == "N").correlate_except(UserAccount).scalar_subquery() +) UserTenant.user_role = column_property( - select(UserAccount.user_role).where(UserAccount.user_id == UserTenant.user_id, + select(UserAccount.user_roles[1]).where(UserAccount.user_id == UserTenant.user_id, UserAccount.delete_flag == "N").correlate_except(UserAccount).scalar_subquery().label("user_role") ) UserTenant.user_email = column_property( diff --git a/backend/management/db/identity/role_permission.py b/backend/management/db/identity/role_permission.py index e3738f6d59..7f2f351633 100644 --- a/backend/management/db/identity/role_permission.py +++ b/backend/management/db/identity/role_permission.py @@ -6,9 +6,10 @@ from typing import Any, Dict, List, Optional -from management.utils.orm import as_dict +from contracts.roles import normalize_roles from management.db.client import get_db_session from management.db.models import RolePermission +from management.utils.orm import as_dict def get_all_role_permissions() -> List[Dict[str, Any]]: @@ -43,7 +44,7 @@ def check_role_permission( bool: Whether the role has the permission """ with get_db_session() as session: - query = session.query(RolePermission).filter(RolePermission.user_role == user_role) + query = session.query(RolePermission).filter(RolePermission.user_role.in_(normalize_roles(user_role, allow_empty=True))) if permission_category: query = query.filter(RolePermission.permission_category == permission_category) diff --git a/backend/management/db/identity/roles.py b/backend/management/db/identity/roles.py new file mode 100644 index 0000000000..60bc2e1cd2 --- /dev/null +++ b/backend/management/db/identity/roles.py @@ -0,0 +1,60 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Atomic role writes with locked authorization and membership invariants.""" + +from contracts.constants.environment import MAX_ADMINS_PER_TENANT +from contracts.errors.exceptions import ( + ForbiddenError, + NotFoundException, + RoleAssignmentConflict, +) +from contracts.roles import normalize_roles, primary_role, roles_from_record +from management.db.client import get_db_session +from management.db.identity.account import set_account_profile +from management.db.identity.membership import ( + active_admin_count, + api_only_account, + check_admin_removal, + get_user_tenant_by_user_id, + lock_tenants, + locked_members, +) +from permissions.role_assignment import check_managed_target + + +def write_roles(session, member, roles, actor): + """Update the authoritative account array and compatibility value atomically.""" + set_account_profile( + member["user_id"], primary_role(roles), None, actor, session, roles=roles + ) + + +def replace_member_roles(actor_id, user_id, values, *, legacy=False): + requested = normalize_roles(values, ordinary_only=True) + snapshot = get_user_tenant_by_user_id(user_id) + actor_snapshot = get_user_tenant_by_user_id(actor_id) + if not snapshot or not actor_snapshot: + raise NotFoundException("User not found") + with get_db_session() as session: + lock_tenants(session, [snapshot["tenant_id"], actor_snapshot["tenant_id"]]) + members = locked_members(session, [actor_id, user_id]) + actor, target = members.get(actor_id), members.get(user_id) + if not actor or not target: + raise NotFoundException("User not found") + if target["tenant_id"] != snapshot["tenant_id"] or actor["tenant_id"] != actor_snapshot["tenant_id"]: + raise RoleAssignmentConflict("Membership changed; reload before retrying") + check_managed_target(actor, target) + old = roles_from_record(target) + if legacy and len(old) > 1: + raise RoleAssignmentConflict("Use roles to update a user with multiple roles") + if api_only_account(session, user_id) and set(requested) - {"DEV", "USER"}: + raise ForbiddenError("API-only identities cannot receive administrative or operations roles") + check_admin_removal(session, target, old, requested, actor_id) + if ( + "ADMIN" in requested + and "ADMIN" not in old + and active_admin_count(session, target["tenant_id"]) >= MAX_ADMINS_PER_TENANT + ): + raise RoleAssignmentConflict("Tenant administrator limit reached") + if old != requested: + write_roles(session, target, requested, actor_id) + return target, old, requested diff --git a/backend/management/db/identity/user_tenant.py b/backend/management/db/identity/user_tenant.py index b9cc66cd7e..c73b37aab7 100644 --- a/backend/management/db/identity/user_tenant.py +++ b/backend/management/db/identity/user_tenant.py @@ -5,17 +5,40 @@ """ import logging -from typing import Any, List, Dict, Optional +from typing import Any, Dict, List, Optional from sqlalchemy import exists, func, or_, text from contracts.constants.constants import DEFAULT_TENANT_ID -from contracts.constants.environment import MAX_ADMINS_PER_TENANT, MAX_SUPER_ADMIN_COUNT, MAX_USERS_PER_TENANT -from management.utils.orm import as_dict +from contracts.constants.environment import ( + MAX_ADMINS_PER_TENANT, + MAX_SUPER_ADMIN_COUNT, + MAX_USERS_PER_TENANT, +) +from contracts.errors.exceptions import RoleAssignmentConflict, TenantResourceLimitError +from contracts.roles import ( + has_any_role, + normalize_roles, + primary_role, + roles_from_record, +) from management.db.client import get_db_session from management.db.identity.account import set_account_profile -from management.db.models import TenantGroupInfo, TenantGroupUser, UserOAuthAccount, UserTenant -from contracts.errors.exceptions import TenantResourceLimitError +from management.db.identity.membership import ( + check_admin_removal, + get_user_tenant_by_user_id, + lock_tenants, + locked_members, +) +from management.db.identity.roles import replace_member_roles +from management.db.models import ( + TenantGroupInfo, + TenantGroupUser, + UserOAuthAccount, + UserTenant, +) +from management.utils.orm import as_dict +from permissions.role_assignment import check_managed_target logger = logging.getLogger(__name__) @@ -59,14 +82,14 @@ def _validate_user_tenant_limit( error.current_count = user_count raise error - normalized_role = (user_role or "").upper() - if normalized_role == "ADMIN": + normalized_role = normalize_roles(user_role) + if "ADMIN" in normalized_role: _lock_resource_limit(session, f"tenant-admin-limit:{tenant_id}") admin_count = _count_or_zero( session.query(UserTenant) .filter( getattr(UserTenant, "tenant_id", None) == tenant_id, - getattr(UserTenant, "user_role", None) == "ADMIN", + UserTenant.roles.contains(["ADMIN"]), getattr(UserTenant, "delete_flag", None) == "N", ) .count() @@ -80,12 +103,12 @@ def _validate_user_tenant_limit( error.limit = _ADMIN_LIMIT error.current_count = admin_count raise error - elif normalized_role == "SU": + elif "SU" in normalized_role: _lock_resource_limit(session, "super-admin-limit") super_admin_count = _count_or_zero( session.query(UserTenant) .filter( - getattr(UserTenant, "user_role", None) == "SU", + UserTenant.roles.contains(["SU"]), getattr(UserTenant, "delete_flag", None) == "N", ) .count() @@ -101,15 +124,10 @@ def _validate_user_tenant_limit( raise error -def get_user_role_by_tenant(user_id: str, tenant_id: str) -> str: - """Return the user's role within the given tenant. - - Joins ``user_tenant_t`` by ``(user_id, tenant_id)`` so the result is - strictly tenant-scoped. Returns the empty string when no active row - exists; callers should treat that as "no role". - """ +def get_user_role_by_tenant(user_id: str, tenant_id: str) -> tuple[str, ...]: + """Return the account roles for an active membership in the given tenant.""" if not user_id or not tenant_id: - return "" + return () with get_db_session() as session: result = ( session.query(UserTenant) @@ -123,25 +141,7 @@ def get_user_role_by_tenant(user_id: str, tenant_id: str) -> str: # Access ORM attributes INSIDE the session context — the session # closes on context-manager exit and lazy-loaded attributes are not # reachable after that (would raise DetachedInstanceError). - return (result.user_role or "") if result is not None else "" - - -def get_user_tenant_by_user_id(user_id: str) -> Optional[Dict[str, Any]]: - """ - Get user tenant relationship by user ID - - Args: - user_id (str): User ID - - Returns: - Optional[Dict[str, Any]]: User tenant relationship record - """ - with get_db_session() as session: - result = session.query(UserTenant).filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N").first() - - if result: - return as_dict(result) - return None + return roles_from_record(as_dict(result)) if result is not None else () def get_user_email_map(user_ids: List[str]) -> Dict[str, str]: @@ -189,6 +189,7 @@ def insert_user_tenant( user_email: str = None, created_by: Optional[str] = None, db_session=None, + roles=None, ) -> Dict[str, Any]: """ Insert user tenant relationship @@ -201,9 +202,11 @@ def insert_user_tenant( """ session_context = get_db_session() if db_session is None else get_db_session(db_session) with session_context as session: - _validate_user_tenant_limit(session, tenant_id, user_role) + assigned = normalize_roles(roles if roles is not None else user_role) + _validate_user_tenant_limit(session, tenant_id, assigned) + user_role = primary_role(assigned, user_role) actor = created_by or user_id - set_account_profile(user_id, user_role, user_email, actor, session) + set_account_profile(user_id, user_role, user_email, actor, session, roles=assigned) user_tenant = UserTenant( user_id=user_id, tenant_id=tenant_id, @@ -252,24 +255,33 @@ def get_user_tenant_by_email(email: str, tenant_id: str) -> Optional[Dict[str, A def upsert_user_tenant( - user_id: str, tenant_id: str, user_role: str = "USER", user_email: str = None + user_id: str, tenant_id: str, user_role: str = "USER", user_email: str = None, roles=None ) -> Dict[str, Any]: """ Create or update the active user-tenant relationship for an external identity login. """ + assigned = normalize_roles(roles if roles is not None else user_role) + snapshot = get_user_tenant_by_user_id(user_id) with get_db_session() as session: + lock_tenants(session, [tenant_id] + ([snapshot["tenant_id"]] if snapshot else [])) result = session.query(UserTenant).filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N").first() if result: + if snapshot and result.tenant_id != snapshot["tenant_id"]: + raise RoleAssignmentConflict("Membership changed; reload before retrying") + old_roles = roles_from_record(as_dict(result)) + check_admin_removal(session, as_dict(result), old_roles, + assigned if result.tenant_id == tenant_id else (), None) is_new_tenant = result.tenant_id != tenant_id - is_role_promotion = (result.user_role or "").upper() != (user_role or "").upper() + promoted_roles = set(assigned) - set(old_roles) if is_new_tenant: - _validate_user_tenant_limit(session, tenant_id, user_role) - elif is_role_promotion and (user_role or "").upper() in {"ADMIN", "SU"}: - _validate_user_tenant_limit(session, tenant_id, user_role, include_user_count=False) + _validate_user_tenant_limit(session, tenant_id, assigned) + elif has_any_role(promoted_roles, {'ADMIN','SU'}): + _validate_user_tenant_limit(session, tenant_id, assigned, include_user_count=False) result.tenant_id = tenant_id result.updated_by = user_id else: + _validate_user_tenant_limit(session, tenant_id, assigned) result = UserTenant( user_id=user_id, tenant_id=tenant_id, @@ -278,7 +290,7 @@ def upsert_user_tenant( ) session.add(result) - set_account_profile(user_id, user_role, user_email, user_id, session) + set_account_profile(user_id, primary_role(assigned, user_role), user_email, user_id, session, roles=assigned) session.flush() session.expire(result) return as_dict(result) @@ -337,7 +349,8 @@ def get_users_by_tenant_id( else: filters.append(email_match) if roles: - filters.append(UserTenant.user_role.in_(roles)) + role_filters = [role.strip().upper() for role in roles] + filters.append(UserTenant.roles.overlap(role_filters)) if group_ids: matching_user_ids = ( session.query(TenantGroupUser.user_id) @@ -377,45 +390,14 @@ def get_users_by_tenant_id( def update_user_tenant_role(user_id: str, role: str, updated_by: str) -> bool: - """ - Update user role in user_tenant table - - Args: - user_id (str): User ID - role (str): New role - updated_by (str): User who made the update - - Returns: - bool: True if update successful, False otherwise - """ - with get_db_session() as session: - target = ( - session.query(UserTenant) - .filter( - UserTenant.user_id == user_id, - UserTenant.delete_flag == "N", - ) - .first() - ) - if target is None: - return False - current_role = (target.user_role or "").upper() - normalized_role = (role or "").upper() - if current_role != normalized_role and normalized_role in {"ADMIN", "SU"}: - _validate_user_tenant_limit(session, target.tenant_id, normalized_role, include_user_count=False) - set_account_profile(user_id, role, None, updated_by, session) - result = ( - session.query(UserTenant) - .filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N") - .update( - { - "updated_by": updated_by, - "update_time": "NOW()", # This will be handled by the database trigger - } - ) + """Keep legacy callers inside the same locked assignment policy.""" + target, old, requested = replace_member_roles(updated_by, user_id, role, legacy=True) + if old != requested: + logger.info( + "Role assignment actor=%s target=%s tenant=%s before=%s after=%s", + updated_by, user_id, target["tenant_id"], old, requested, ) - - return result > 0 + return bool(get_user_tenant_by_user_id(user_id)) def soft_delete_user_tenant_by_user_id(user_id: str, deleted_by: str) -> bool: @@ -429,7 +411,26 @@ def soft_delete_user_tenant_by_user_id(user_id: str, deleted_by: str) -> bool: Returns: bool: True if any records were deleted """ + snapshot = get_user_tenant_by_user_id(user_id) + if not snapshot: + return False with get_db_session() as session: + actor_snapshot = get_user_tenant_by_user_id(deleted_by) + if not actor_snapshot: + raise ValueError("Acting user is unavailable") + lock_tenants(session, [snapshot["tenant_id"], actor_snapshot["tenant_id"]]) + members = locked_members(session, [user_id, deleted_by]) + member = members.get(user_id) + if not member: + return False + if member["tenant_id"] != snapshot["tenant_id"]: + raise RoleAssignmentConflict("Membership changed; reload before retrying") + actor = members.get(deleted_by) + if not actor: + raise ValueError("Acting user is unavailable") + if deleted_by != user_id or has_any_role(roles_from_record(member), {"ADMIN", "SU", "ASSET_OWNER"}): + check_managed_target(actor, member) + check_admin_removal(session, member, roles_from_record(member), (), deleted_by) result = ( session.query(UserTenant) .filter(UserTenant.user_id == user_id, UserTenant.delete_flag == "N") diff --git a/backend/management/db/knowledge_base/repository.py b/backend/management/db/knowledge_base/repository.py index 52ff07ed24..5bb168abaf 100644 --- a/backend/management/db/knowledge_base/repository.py +++ b/backend/management/db/knowledge_base/repository.py @@ -1,25 +1,25 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException -from contracts.errors.exceptions import DuplicateError -from contracts.constants.constants import VALID_SUMMARY_FREQUENCIES -from management.utils.orm import as_dict import logging import uuid +from datetime import datetime from typing import Any, Dict, List, Optional from sqlalchemy import func, text from sqlalchemy.exc import SQLAlchemyError -from contracts.constants.environment import MAX_KNOWLEDGE_BASES_PER_TENANT, MAX_KNOWLEDGE_BASES_PER_USER, MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER -from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException -from contracts.errors.exceptions import DuplicateError from contracts.constants.constants import VALID_SUMMARY_FREQUENCIES -from management.utils.orm import as_dict +from contracts.constants.environment import ( + MAX_KNOWLEDGE_BASES_PER_TENANT, + MAX_KNOWLEDGE_BASES_PER_USER, + MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER, +) +from contracts.errors.codes import ErrorCode +from contracts.errors.exceptions import AppException, DuplicateError +from contracts.roles import has_any_role, normalize_roles from management.db.client import get_db_session -from management.db.models import KnowledgeRecord +from management.db.models import KnowledgeRecord, UserTenant +from management.utils.orm import as_dict from management.utils.serialization import convert_list_to_string logger = logging.getLogger("knowledge_db") @@ -43,10 +43,9 @@ def _get_user_role_in_session(session, user_id: Optional[str], tenant_id: Option if not user_id or not tenant_id: return "USER" try: - from management.db.models import UserTenant role = ( - session.query(UserTenant.user_role) + session.query(UserTenant.roles) .filter( UserTenant.user_id == user_id, UserTenant.tenant_id == tenant_id, @@ -57,7 +56,7 @@ def _get_user_role_in_session(session, user_id: Optional[str], tenant_id: Option except Exception: logger.exception("Failed to resolve knowledge-base creator role") return "USER" - return role.upper() if isinstance(role, str) and role else "USER" + return normalize_roles(role or "USER") def _raise_knowledge_limit_error(scope: str, limit: int, current_count: int, role: str = None) -> None: @@ -103,7 +102,7 @@ def _enforce_knowledge_limits(session, tenant_id: Optional[str], user_id: Option role = _get_user_role_in_session(session, user_id, tenant_id) user_limit = ( MAX_PRIVILEGED_KNOWLEDGE_BASES_PER_USER - if role in _PRIVILEGED_KNOWLEDGE_ROLES + if has_any_role(role, _PRIVILEGED_KNOWLEDGE_ROLES) else MAX_KNOWLEDGE_BASES_PER_USER ) session.execute( @@ -771,7 +770,6 @@ def update_summary_frequency(index_name: str, summary_frequency: Optional[str], def update_last_summary_time(index_name: str): """Update last_summary_time to now after a successful summary generation.""" - from datetime import datetime try: with get_db_session() as session: @@ -790,7 +788,6 @@ def update_last_summary_time(index_name: str): def update_last_doc_update_time(index_name: str): """Update last_doc_update_time to now after document add/delete operation.""" - from datetime import datetime try: with get_db_session() as session: diff --git a/backend/management/db/notification/repository.py b/backend/management/db/notification/repository.py index 3880cc605e..a1bfcbe92b 100644 --- a/backend/management/db/notification/repository.py +++ b/backend/management/db/notification/repository.py @@ -10,7 +10,20 @@ import logging from typing import Any, Dict, List, Optional -from contracts.constants.constants import VALID_EVENT_TYPES, VALID_RESOURCE_TYPES, VALID_NOTIFICATION_SCOPES, TENANT_REQUIRED_SCOPES, SCOPE_SU, SCOPE_TENANT, SCOPE_TENANT_ADMIN, SCOPE_TENANT_USER, SCOPE_USER, SU_ROLES, TENANT_ADMIN_ROLES, TENANT_USER_ROLES +from contracts.constants.constants import ( + SCOPE_SU, + SCOPE_TENANT, + SCOPE_TENANT_ADMIN, + SCOPE_TENANT_USER, + SCOPE_USER, + SU_ROLES, + TENANT_ADMIN_ROLES, + TENANT_REQUIRED_SCOPES, + TENANT_USER_ROLES, + VALID_EVENT_TYPES, + VALID_NOTIFICATION_SCOPES, + VALID_RESOURCE_TYPES, +) from management.db.client import get_db_session from management.db.models import Notification, NotificationReceiver, UserTenant @@ -29,18 +42,18 @@ def _resolve_receivers( query = session.query(UserTenant.user_id, UserTenant.tenant_id).filter(UserTenant.delete_flag == "N") if scope == SCOPE_SU: - query = query.filter(UserTenant.user_role.in_(SU_ROLES)) + query = query.filter(UserTenant.roles.overlap(list(SU_ROLES))) elif scope == SCOPE_TENANT: query = query.filter(UserTenant.tenant_id == tenant_id) elif scope == SCOPE_TENANT_ADMIN: query = query.filter( UserTenant.tenant_id == tenant_id, - UserTenant.user_role.in_(TENANT_ADMIN_ROLES), + UserTenant.roles.overlap(list(TENANT_ADMIN_ROLES)), ) elif scope == SCOPE_TENANT_USER: query = query.filter( UserTenant.tenant_id == tenant_id, - UserTenant.user_role.in_(TENANT_USER_ROLES), + UserTenant.roles.overlap(list(TENANT_USER_ROLES)), ) seen: set = set() diff --git a/backend/management/services/agent/authorization.py b/backend/management/services/agent/authorization.py index afbf7a3ae3..48923ab92e 100644 --- a/backend/management/services/agent/authorization.py +++ b/backend/management/services/agent/authorization.py @@ -1,19 +1,24 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + from typing import Any from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT -from management.db.agent.repository import query_agent_records_for_nl2agent +from contracts.errors.exceptions import ( + AgentDraftEditError, + AgentNotFoundError, + ForbiddenError, + ResourceBindingError, +) +from contracts.roles import has_any_role +from management.db.agent.repository import ( + is_system_agent, + query_agent_records_for_nl2agent, +) from management.db.identity.user_tenant import get_user_role_by_tenant from management.services.permission.asset_owner import resolve_agent_list_permission -from contracts.errors.exceptions import AgentDraftEditError - - -from contracts.errors.exceptions import ResourceBindingError - - def require_agent_draft_edit( *, agent_id: int, @@ -40,18 +45,13 @@ def require_agent_draft_edit( user_role=user_role, agent=draft, user_id=user_id, - can_edit_all=(user_role or "").upper() in CAN_EDIT_ALL_USER_ROLES, + can_edit_all=has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES), ) if permission != PERMISSION_EDIT: raise AgentDraftEditError("agent_read_only") return draft -from contracts.errors.exceptions import AgentNotFoundError, ForbiddenError -from management.db.agent.repository import is_system_agent -from contracts.errors.exceptions import AgentDraftEditError - - def require_external_relation_edit(local_agent_id: int, tenant_id: str, user_id: str) -> None: """Require a tenant-owned, editable ordinary parent before relationship writes.""" try: diff --git a/backend/management/services/agent/management.py b/backend/management/services/agent/management.py index 0812095866..321b165ef2 100644 --- a/backend/management/services/agent/management.py +++ b/backend/management/services/agent/management.py @@ -11,20 +11,41 @@ Optional, ) -from contracts.constants.constants import STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED -from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE -from contracts.constants.constants import TOOL_TYPE_MAPPING +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + PERMISSION_PRIVATE, + PERMISSION_READ, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + TOOL_TYPE_MAPPING, +) from contracts.errors.exceptions import ForbiddenError +from contracts.roles import has_any_role, roles_from_record from contracts.tool import ToolSourceEnum -from management.db.skill import repository as skill_db from management.db.a2a.repository import get_server_agent_ids -from management.db.agent.repository import clear_agent_new_mark, delete_agent_by_id, delete_agent_relationship, is_system_agent, query_agent_info_by_ids, query_agent_list_candidates_by_tenant_id, query_all_agent_info_by_tenant_id, query_sub_agents_id_list, search_agent_id_by_agent_name, search_agent_info_by_agent_id from management.db.agent.catalog import list_agent_repository_by_agent_ids +from management.db.agent.repository import ( + clear_agent_new_mark, + delete_agent_by_id, + delete_agent_relationship, + is_system_agent, + query_agent_info_by_ids, + query_agent_list_candidates_by_tenant_id, + query_all_agent_info_by_tenant_id, + query_sub_agents_id_list, + search_agent_id_by_agent_name, + search_agent_info_by_agent_id, +) from management.db.agent.version import batch_search_version_names, query_version_list from management.db.identity.group import query_group_ids_by_user -from management.db.tag.repository import TagManagementDB -from management.db.tool.repository import delete_tools_by_agent_id, search_tools_for_sub_agent from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.skill import repository as skill_db +from management.db.tag.repository import TagManagementDB +from management.db.tool.repository import ( + delete_tools_by_agent_id, + search_tools_for_sub_agent, +) from management.services.agent.read import ( apply_deleted_model_reason, apply_duplicate_name_availability_rules, @@ -35,6 +56,7 @@ resolve_agent_list_permission, ) from management.services.permission.identity.display import enrich_cas_usernames +from management.services.tag.library import TagManagementService from management.utils.serialization import convert_string_to_list logger = logging.getLogger(__name__) @@ -65,7 +87,6 @@ async def delete_agent_impl(agent_id: int, tenant_id: str, user_id: str): delete_tools_by_agent_id(agent_id, tenant_id, user_id) skill_db.delete_skills_by_agent_id(agent_id, tenant_id, user_id) if is_tenant_owned_agent: - from management.services.tag.library import TagManagementService TagManagementService.cleanup_resource_assignments( tenant_id, "agent", str(agent_id), user_id @@ -160,7 +181,7 @@ def _order_agents_by_publish_time( async def list_all_agent_info_impl( - tenant_id: str, user_id: str, agent_ids: Optional[list[int]] = None + tenant_id: str, user_id: str, agent_ids: Optional[list[int]] = None, *, inventory_read: bool = False ) -> list[dict]: """ list all agent info @@ -177,13 +198,13 @@ async def list_all_agent_info_impl( """ try: user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() + user_role = roles_from_record(user_tenant_record) - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) # For DEV/USER, restrict visible agents to those whose group_ids overlap user's groups. user_group_ids: set[int] = set() - if not can_edit_all: + if not (can_edit_all or inventory_read): try: user_group_ids = set(query_group_ids_by_user(user_id) or []) except Exception as e: @@ -211,7 +232,7 @@ async def list_all_agent_info_impl( continue # Apply visibility filter for DEV/USER based on group overlap - if not can_edit_all: + if not (can_edit_all or inventory_read): 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) @@ -284,7 +305,7 @@ async def list_all_agent_info_impl( first_model_name = agent["model_name"] # Permission logic (ASSET_OWNER-scoped + non-ASSET_OWNER role => READ_ONLY first): - permission = resolve_agent_list_permission( + permission = PERMISSION_READ if inventory_read else resolve_agent_list_permission( user_role=user_role, agent=agent, user_id=user_id, @@ -364,8 +385,8 @@ async def list_agent_page_impl( raise ValueError("created_by and created_by_not cannot be used together") user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_role = roles_from_record(user_tenant_record) + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) user_group_ids: set[int] = set() if not can_edit_all: try: @@ -597,13 +618,13 @@ async def list_agent_page_impl( publisher_tenant_id=scope_tenant_id, ) publisher_records = [] - if scope_tenant_id == caller_tenant_id and user_role == "ADMIN": + if scope_tenant_id == caller_tenant_id and has_any_role(user_role, {'ADMIN'}): publisher_records = list_agent_repository_by_agent_ids( scoped_agent_ids, statuses=(STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED), publisher_tenant_id=scope_tenant_id, ) - elif scope_tenant_id == caller_tenant_id and user_role == "DEV": + elif scope_tenant_id == caller_tenant_id and has_any_role(user_role, {'DEV'}): publisher_records = list_agent_repository_by_agent_ids( scoped_agent_ids, statuses=(STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED), diff --git a/backend/management/services/agent/service.py b/backend/management/services/agent/service.py index 6dc77766a5..870e608935 100644 --- a/backend/management/services/agent/service.py +++ b/backend/management/services/agent/service.py @@ -1,66 +1,68 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +# Monitoring utilities: bind Agent metadata once at the request boundary. + +# Import monitoring utilities + + import logging from collections import deque from typing import Optional from uuid import uuid4 -from contracts.agent import AgentUnavailableReason -from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES -from contracts.constants.constants import LANGUAGE -from contracts.constants.constants import MODEL_CONFIG_MAPPING +from contracts.agent import ( + AgentInfoRequest, + AgentNameBatchCheckRequest, + AgentNameBatchRegenerateRequest, + AgentUnavailableReason, +) +from contracts.authentication import AuthenticatedIdentity +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + LANGUAGE, + MODEL_CONFIG_MAPPING, +) from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AgentNotFoundError, AgentRelationValidationError, AppException, ForbiddenError, TenantResourceLimitError -from contracts.agent import AgentInfoRequest, AgentNameBatchCheckRequest, AgentNameBatchRegenerateRequest +from contracts.errors.exceptions import ( + AgentNotFoundError, + AgentRelationValidationError, + AppException, + ForbiddenError, + TenantResourceLimitError, +) +from contracts.roles import has_any_role, roles_from_record from contracts.skill import SkillInstanceInfoRequest from contracts.tool import ToolInstanceInfoRequest from management.db.a2a import repository as a2a_agent_db -from management.db.skill import repository as skill_db from management.db.a2a.repository import query_external_sub_agents -from management.db.agent.repository import batch_search_agent_display_names, create_agent, is_system_agent, query_all_agent_info_by_tenant_id, query_sub_agent_relations, query_sub_agents_id_list, search_agent_info_by_agent_id, update_agent, update_agent_icon, update_related_agents -from management.db.agent.version import batch_query_current_version_nos, batch_search_version_names, query_current_version_no -from management.db.model.repository import get_model_by_model_id -from management.db.tool.repository import create_or_update_tool_by_tool_info, query_all_enabled_tool_instances, query_tool_instances_by_agent_id, query_tools_by_ids, search_tools_for_sub_agent -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from management.services.agent.icon import ( - read_icon_image, - upload_icon_image, - validate_icon_image, -) -from management.services.agent.naming import ( - check_agent_value_duplicate, - generate_unique_agent_value, - regenerate_agent_value, +from management.db.agent.repository import ( + batch_search_agent_display_names, + create_agent, + is_system_agent, + query_all_agent_info_by_tenant_id, + query_sub_agent_relations, + query_sub_agents_id_list, + search_agent_info_by_agent_id, + update_agent, + update_agent_icon, + update_related_agents, ) -from management.services.agent.read import ( - apply_deleted_model_reason, - check_agent_availability, - project_agent_models, - tool_has_deleted_model, -) -from management.services.skill.service import SkillService -from nexent.core.concurrency import run_blocking -from management.services.agent.reasoning import ( - snapshot_agent_reasoning_config as build_reasoning_snapshot, +from management.db.agent.version import ( + batch_query_current_version_nos, + batch_search_version_names, + query_current_version_no, ) -from management.services.permission.asset_owner import resolve_agent_list_permission -from management.services.conversation.service import ( - generate_conversation_title_service, # noqa: F401 - compatibility patch point - save_message_unit, # noqa: F401 - retained as a compatibility re-export - update_unit_status, # noqa: F401 - retained as a compatibility re-export +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.model.repository import get_model_by_model_id +from management.db.skill import repository as skill_db +from management.db.tool.repository import ( + create_or_update_tool_by_tool_info, + query_all_enabled_tool_instances, + query_tool_instances_by_agent_id, + query_tools_by_ids, + search_tools_for_sub_agent, ) -from management.services.permission.identity.display import enrich_cas_usernames -from contracts.authentication import AuthenticatedIdentity -from management.services.configuration.tenant.settings import tenant_config_manager -from management.utils.serialization import convert_list_to_string, convert_string_to_list - -# Monitoring utilities: bind Agent metadata once at the request boundary. - -# Import monitoring utilities - -logger = logging.getLogger(__name__) - - from management.services.agent.execution import ( _agent_run_identifier, _cancel_task_on_runtime_signal, @@ -70,6 +72,11 @@ _stream_agent_chunks, shutdown_agent_stream_tasks, ) +from management.services.agent.icon import ( + read_icon_image, + upload_icon_image, + validate_icon_image, +) from management.services.agent.management import ( clear_agent_new_mark_impl, delete_agent_impl, @@ -78,10 +85,24 @@ list_agent_page_impl, list_all_agent_info_impl, ) +from management.services.agent.naming import ( + check_agent_value_duplicate, + generate_unique_agent_value, + regenerate_agent_value, +) from management.services.agent.policy import ( build_sandbox_policy, get_sandbox_minio_client, ) +from management.services.agent.read import ( + apply_deleted_model_reason, + check_agent_availability, + project_agent_models, + tool_has_deleted_model, +) +from management.services.agent.reasoning import ( + snapshot_agent_reasoning_config as build_reasoning_snapshot, +) from management.services.agent.run import ( generate_stream, is_agent_running, @@ -115,6 +136,23 @@ import_agent_impl, import_agent_with_skills_impl, ) +from management.services.configuration.tenant.settings import tenant_config_manager +from management.services.conversation.service import ( + generate_conversation_title_service, # noqa: F401 - compatibility patch point + save_message_unit, # noqa: F401 - retained as a compatibility re-export + update_unit_status, # noqa: F401 - retained as a compatibility re-export +) +from management.services.permission.asset_owner import resolve_agent_list_permission +from management.services.permission.identity.display import enrich_cas_usernames +from management.services.skill.service import SkillService +from management.utils.serialization import ( + convert_list_to_string, + convert_string_to_list, +) +from nexent.core.concurrency import run_blocking + +logger = logging.getLogger(__name__) + __all__ = ( "_get_user_group_ids", @@ -354,8 +392,8 @@ async def get_agent_info_impl( if user_id is not None: try: user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_role = roles_from_record(user_tenant_record) + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) # Permission logic (same as agent list, including ASSET_OWNER read-only override) agent_info["permission"] = resolve_agent_list_permission( @@ -682,12 +720,12 @@ async def update_agent_info_impl( if agent_id is not None: agent_record = search_agent_info_by_agent_id(agent_id, tenant_id) user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() + user_role = roles_from_record(user_tenant_record) permission = resolve_agent_list_permission( user_role=user_role, agent=agent_record, user_id=user_id, - can_edit_all=user_role in CAN_EDIT_ALL_USER_ROLES, + can_edit_all=has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES), ) if permission != "EDIT": raise ForbiddenError("You do not have permission to edit this agent") diff --git a/backend/management/services/agent/versions.py b/backend/management/services/agent/versions.py index 934bd59854..5dfe40e24a 100644 --- a/backend/management/services/agent/versions.py +++ b/backend/management/services/agent/versions.py @@ -1,17 +1,63 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import logging -from typing import Optional, Tuple, List, Dict, Any +from typing import Any, Dict, List, Optional, Tuple -from management.db.agent.version import search_version_by_version_no, query_version_list, query_current_version_no, query_agent_snapshot, query_agent_draft, insert_version, update_version_status, update_version, update_agent_current_version, insert_agent_snapshot, insert_tool_snapshot, insert_relation_snapshot, insert_skill_snapshot, delete_agent_snapshot, delete_tool_snapshot, delete_relation_snapshot, delete_skill_snapshot, restore_agent_draft, get_next_version_no, delete_version -from contracts.constants.constants import SOURCE_TYPE_NORMAL, STATUS_RELEASED, STATUS_DISABLED, STATUS_ARCHIVED +from contracts.agent import AgentUnavailableReason +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + SOURCE_TYPE_NORMAL, + STATUS_ARCHIVED, + STATUS_DISABLED, + STATUS_RELEASED, +) +from contracts.roles import has_any_role, roles_from_record +from management.db.a2a import repository as a2a_repository +from management.db.agent.repository import ( + is_system_agent, + query_all_agent_info_by_tenant_id, +) +from management.db.agent.version import ( + delete_agent_snapshot, + delete_relation_snapshot, + delete_skill_snapshot, + delete_tool_snapshot, + delete_version, + get_next_version_no, + insert_agent_snapshot, + insert_relation_snapshot, + insert_skill_snapshot, + insert_tool_snapshot, + insert_version, + query_agent_draft, + query_agent_snapshot, + query_current_version_no, + query_version_list, + restore_agent_draft, + search_version_by_version_no, + update_agent_current_version, + update_version, + update_version_status, +) +from management.db.identity.group import query_group_ids_by_user +from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.db.model.repository import get_model_by_model_id, get_valid_model_ids -from management.db.agent.repository import is_system_agent +from management.db.skill import repository as skill_db_module +from management.services.agent.a2a.validation import validate_a2a_agent_name +from management.services.agent.read import ( + apply_duplicate_name_availability_rules, + check_agent_availability, +) +from management.services.model.resolver import is_model_configured +from management.services.permission.asset_owner import ( + resolve_agent_list_permission, +) from management.services.permission.identity.display import enrich_cas_usernames +from management.services.tag.projection import ( + project_authorized_resource_tags, +) from management.utils.serialization import convert_string_to_list -from management.services.model.resolver import is_model_configured -from contracts.agent import AgentUnavailableReason -from management.services.agent.a2a.validation import validate_a2a_agent_name +from nexent.core.agent_names import validate_agent_name logger = logging.getLogger("agent_version_service") @@ -76,7 +122,6 @@ def publish_version_impl( if not agent_draft: raise ValueError("Agent draft not found") - from nexent.core.agent_names import validate_agent_name publish_as_a2a = bool(agent_draft.get("is_a2a", False)) if publish_as_a2a: @@ -125,7 +170,6 @@ def publish_version_impl( insert_relation_snapshot(rel_snapshot) # Get skill instances from draft (version_no=0) - from management.db.skill import repository as skill_db_module skills_draft = skill_db_module.query_skill_instances_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, @@ -163,8 +207,7 @@ def publish_version_impl( a2a_agent_info = None a2a_agent_card = None if publish_as_a2a: - from management.db.a2a.repository import create_server_agent, get_server_agent_by_agent_id - existing_a2a = get_server_agent_by_agent_id(agent_id, tenant_id) + existing_a2a = a2a_repository.get_server_agent_by_agent_id(agent_id, tenant_id) logger.info(f"Creating/updating A2A Server agent for agent_id={agent_id}") agent_name = agent_draft.get("name") or f"Agent-{agent_id}" # Build supported interfaces for this endpoint @@ -176,7 +219,7 @@ def publish_version_impl( else: endpoint_id = None # Will be generated by create_server_agent # supported_interfaces will be built with the actual endpoint_id after creation - a2a_agent_info = create_server_agent( + a2a_agent_info = a2a_repository.create_server_agent( agent_id=agent_id, user_id=user_id, tenant_id=tenant_id, @@ -304,7 +347,6 @@ def get_version_detail_impl( result['sub_agent_relations'] = _build_sub_agent_relations(relations_snapshot) # Get skill instances for this version (from ag_skill_instance_t with version_no) - from management.db.skill import repository as skill_db_module skills_snapshot = skill_db_module.query_skill_instances_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, @@ -433,7 +475,6 @@ def rollback_version_impl( raise ValueError("Agent draft not found") # Get skill snapshots for target version - from management.db.skill import repository as skill_db_module target_skills = skill_db_module.query_skill_instances_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, @@ -749,7 +790,6 @@ def _get_version_detail_or_draft( Returns structured agent info similar to get_version_detail_impl. """ _ensure_system_agent_hidden(agent_id, tenant_id) - from management.db.skill import repository as skill_db_module result: Dict[str, Any] = {} @@ -837,21 +877,11 @@ async def list_published_agents_impl( list[dict]: List of published agent info """ try: - from management.db.agent.repository import query_all_agent_info_by_tenant_id - from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES - from management.db.identity.user_tenant import get_user_tenant_by_user_id - from management.db.identity.group import query_group_ids_by_user - from management.db.model.repository import get_model_by_model_id - from management.services.agent.read import ( - check_agent_availability, apply_duplicate_name_availability_rules, - ) - from management.services.permission.asset_owner import resolve_agent_list_permission - from management.db.agent.version import query_agent_snapshot, query_version_list # Get user role for permission check user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_role = roles_from_record(user_tenant_record) + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) # Get user's group IDs for filtering user_group_ids: set[int] = set() @@ -1034,7 +1064,6 @@ async def list_published_agents_impl( }) try: - from management.services.tag.projection import project_authorized_resource_tags return project_authorized_resource_tags( simple_agent_list, diff --git a/backend/management/services/agent/workbench.py b/backend/management/services/agent/workbench.py index 897d4b7cf7..af807e96b2 100644 --- a/backend/management/services/agent/workbench.py +++ b/backend/management/services/agent/workbench.py @@ -11,26 +11,41 @@ from math import isfinite from pathlib import Path from typing import Any, Dict, Mapping, Optional, Tuple -from nexent.core.agents.agent_model import AgentConfig -from contracts.errors.exceptions import ValidationError, WorkbenchConfigVersionConflict, WorkbenchError -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, CAN_EDIT_ALL_USER_ROLES, PERMISSION_PRIVATE from contracts.agent import WorkbenchSessionConfig +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + CAN_EDIT_ALL_USER_ROLES, + PERMISSION_PRIVATE, +) +from contracts.errors.exceptions import ( + ValidationError, + WorkbenchConfigVersionConflict, + WorkbenchError, +) +from contracts.knowledge import ConversationKnowledgeScopeRequest +from contracts.roles import has_any_role +from infrastructure.serialization.containers import clone_runtime_config +from management.db.agent.repository import search_agent_info_by_agent_id +from management.db.identity.group import query_group_ids_by_user from management.db.skill import repository as skill_db from management.db.tool.repository import query_tools_by_ids -from management.db.agent.repository import search_agent_info_by_agent_id from management.services.agent.system import ( system_agent_provider, ) -from management.services.skill.service import SkillService -from management.services.skill.permission import can_view_skill, _get_user_role, _to_group_id_set -from management.db.identity.group import query_group_ids_by_user -from infrastructure.serialization.containers import clone_runtime_config from management.services.knowledge_base.scope import ( ResolvedKnowledgeScope, get_agent_knowledge_capabilities, + resolve_executable_knowledge_scope, resolve_root_version, ) +from management.services.skill.permission import ( + _get_user_role, + _to_group_id_set, + can_view_skill, +) +from management.services.skill.service import SkillService +from nexent.core.agents.agent_model import AgentConfig @dataclass(frozen=True) @@ -299,8 +314,6 @@ def resolve_knowledge( Callers consume the returned tree and scope summary before starting a model. Resource checks are separate from the pure root compilation. """ - from contracts.knowledge import ConversationKnowledgeScopeRequest - from management.services.knowledge_base.scope import resolve_executable_knowledge_scope scope = ConversationKnowledgeScopeRequest.model_validate(dict(tree.overlay.knowledge_scope or {})) compiled, resolution = resolve_executable_knowledge_scope( @@ -479,7 +492,7 @@ def resolve_workbench_config( def authorize_workbench_agent(snapshot: Mapping[str, Any], user_id: str) -> None: """Apply the Agent list's creator/group visibility rules before resolving resources.""" role = _get_user_role(user_id) - if role in CAN_EDIT_ALL_USER_ROLES or str(snapshot.get("created_by")) == str(user_id): + if has_any_role(role, CAN_EDIT_ALL_USER_ROLES) or str(snapshot.get("created_by")) == str(user_id): return groups = set(query_group_ids_by_user(user_id) or []) if snapshot.get("ingroup_permission") == PERMISSION_PRIVATE or not groups.intersection(_to_group_id_set(snapshot.get("group_ids"))): diff --git a/backend/management/services/evaluation/queries.py b/backend/management/services/evaluation/queries.py index 9ceab4a3a2..f24d00c005 100644 --- a/backend/management/services/evaluation/queries.py +++ b/backend/management/services/evaluation/queries.py @@ -6,9 +6,17 @@ from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES from contracts.errors.codes import ErrorCode -from contracts.evaluation import EvalPassStatus from contracts.errors.exceptions import AppException -from management.db.evaluation.runs import get_agent_evaluation, get_evaluation_case_scores, hard_delete_agent_evaluation, list_agent_evaluation_cases, list_agent_evaluations_by_agent +from contracts.evaluation import EvalPassStatus +from contracts.roles import has_any_role, roles_from_record +from management.db.evaluation.runs import ( + get_agent_evaluation, + get_evaluation_case_scores, + hard_delete_agent_evaluation, + list_agent_evaluation_cases, + list_agent_evaluations_by_agent, +) +from management.db.evaluation.sets import hard_delete_evaluation_set from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.evaluation.common import ( logger, @@ -174,7 +182,7 @@ def get_evaluation_stats_impl( def _resolve_user_role(user_id: str) -> str: """Return the caller's tenant role; unknown callers fall back to USER.""" record = get_user_tenant_by_user_id(user_id) - return str((record or {}).get("user_role") or "USER").upper() + return roles_from_record(record) def delete_agent_evaluation_run_impl( @@ -186,7 +194,7 @@ def delete_agent_evaluation_run_impl( agent_evaluation_id=agent_evaluation_id, tenant_id=tenant_id ) is_creator = run.get("created_by") == user_id - if not is_creator and _resolve_user_role(user_id) not in CAN_EDIT_ALL_USER_ROLES: + if not is_creator and not has_any_role(_resolve_user_role(user_id), CAN_EDIT_ALL_USER_ROLES): raise AppException(ErrorCode.AGENT_EVALUATION_ONLY_CREATOR_CAN_DELETE) evaluator_config_raw = run.get("evaluator_config") @@ -194,7 +202,6 @@ def delete_agent_evaluation_run_impl( "no_set_mode" ): try: - from management.db.evaluation.sets import hard_delete_evaluation_set hard_delete_evaluation_set(run["evaluation_set_id"], tenant_id) except Exception as exc: # noqa: BLE001 diff --git a/backend/management/services/knowledge_base/deletion.py b/backend/management/services/knowledge_base/deletion.py index 988b4f0be8..d04816fcd0 100644 --- a/backend/management/services/knowledge_base/deletion.py +++ b/backend/management/services/knowledge_base/deletion.py @@ -17,29 +17,47 @@ import time from typing import Any, Dict, Optional -from contracts.constants.environment import DOCUMENT_DELETE_DRAIN_TIMEOUT_S, DOCUMENT_DELETE_RETRY_INTERVAL_S -from management.db.file.attachment import delete_file -from management.db.knowledge_base.repository import get_knowledge_record, update_last_doc_update_time -from management.db.knowledge_base.file_lifecycle import create_delete_tombstone, delete_file_record, get_file_record, transition_file_record from fastapi import Depends, Path, Query + +from contracts.constants.environment import ( + DOCUMENT_DELETE_DRAIN_TIMEOUT_S, + DOCUMENT_DELETE_RETRY_INTERVAL_S, +) +from management.db.file.attachment import delete_file +from management.db.knowledge_base.file_lifecycle import ( + create_delete_tombstone, + delete_file_record, + get_file_record, + transition_file_record, +) +from management.db.knowledge_base.repository import ( + get_knowledge_record, + update_last_doc_update_time, +) from management.services.knowledge_base.common import ( get_vector_db_core, logger, ) +from management.services.knowledge_base.document_tasks import _document_delete_tasks from management.services.knowledge_base.management import ( KNOWLEDGE_BASE_DELETE_BLOCKING_STATUSES, KnowledgeBaseManagementService, ) from management.services.knowledge_base.status import get_all_files_status -from nexent.core.concurrency import run_blocking -from nexent.vector_database.base import VectorDatabaseCore from management.services.knowledge_base.storage import ( release_storage_charge, resolve_storage_reference, ) from management.services.knowledge_base.task_state import get_redis_service +from nexent.core.concurrency import run_blocking +from nexent.vector_database.base import VectorDatabaseCore -_document_delete_tasks: Dict[str, asyncio.Task] = {} +# Worker task control is optional when Redis or the worker package is unavailable. +try: + from data_process.app import app as celery_app +except Exception as error: # noqa: BLE001 - preserve optional worker integration + celery_app = None + logger.debug("Celery task control is unavailable: %s", error) class KnowledgeBaseDocumentDeletionService(KnowledgeBaseManagementService): @@ -174,8 +192,6 @@ def _cancel_document_tasks( } file_id = (lifecycle_record or {}).get("file_id") try: - from data_process.app import app as celery_app - inspector = celery_app.control.inspect(timeout=0.5) for method_name in ("active", "reserved"): try: @@ -213,10 +229,6 @@ def _cancel_document_tasks( redis_service = get_redis_service() except Exception: redis_service = None - try: - from data_process.app import app as celery_app - except Exception: - celery_app = None for task_id in sorted(task_ids): if redis_service: try: @@ -402,8 +414,9 @@ def delete_lifecycle_record_without_object( "message": "Lifecycle record deleted; no storage object was created.", } - @staticmethod + @classmethod async def _finalize_document_delete( + cls, *, index_name: str, path_or_url: str, @@ -413,12 +426,8 @@ async def _finalize_document_delete( requested_by: Optional[str] = None, ) -> Dict[str, Any]: """Finish a document deletion after its parent chain has stopped.""" - # Resolve the concrete service lazily to avoid the management -> - # deletion -> service import cycle while still honoring subclass - # overrides and test doubles. - from management.services.knowledge_base.service import ElasticSearchService - - service_cls = ElasticSearchService + # Resolve helpers through the concrete class to honor subclass overrides. + service_cls = cls file_id = (lifecycle_record or {}).get("file_id") legacy_mode = lifecycle_record is None or not lifecycle_record.get( "object_name" @@ -579,8 +588,9 @@ async def _finalize_document_delete( result["lifecycle_deleted"] = True return result - @staticmethod + @classmethod async def _retry_document_delete( + cls, *, index_name: str, path_or_url: str, @@ -589,13 +599,11 @@ async def _retry_document_delete( lifecycle_record: Optional[Dict[str, Any]], requested_by: Optional[str] = None, ) -> None: - from management.services.knowledge_base.service import ElasticSearchService - file_id = (lifecycle_record or {}).get("file_id") or path_or_url try: while True: try: - result = await ElasticSearchService._finalize_document_delete( + result = await cls._finalize_document_delete( index_name=index_name, path_or_url=path_or_url, scope=scope, @@ -618,8 +626,9 @@ async def _retry_document_delete( finally: _document_delete_tasks.pop(str(file_id), None) - @staticmethod + @classmethod def _schedule_document_delete_retry( + cls, *, index_name: str, path_or_url: str, @@ -628,14 +637,12 @@ def _schedule_document_delete_retry( lifecycle_record: Optional[Dict[str, Any]], requested_by: Optional[str] = None, ) -> None: - from management.services.knowledge_base.service import ElasticSearchService - key = str((lifecycle_record or {}).get("file_id") or path_or_url) existing = _document_delete_tasks.get(key) if existing and not existing.done(): return _document_delete_tasks[key] = asyncio.create_task( - ElasticSearchService._retry_document_delete( + cls._retry_document_delete( index_name=index_name, path_or_url=path_or_url, scope=scope, @@ -645,8 +652,9 @@ def _schedule_document_delete_retry( ) ) - @staticmethod + @classmethod async def delete_document_by_scope( + cls, index_name: str, path_or_url: str, scope: str, @@ -654,9 +662,7 @@ async def delete_document_by_scope( file_id: Optional[str] = None, requested_by: Optional[str] = None, ) -> Dict[str, Any]: - from management.services.knowledge_base.service import ElasticSearchService - - service_cls = ElasticSearchService + service_cls = cls if scope not in KnowledgeBaseDocumentDeletionService.DOCUMENT_DELETE_SCOPES: raise ValueError( f"Invalid scope '{scope}'. " diff --git a/backend/management/services/knowledge_base/document_tasks.py b/backend/management/services/knowledge_base/document_tasks.py new file mode 100644 index 0000000000..32bd1ec712 --- /dev/null +++ b/backend/management/services/knowledge_base/document_tasks.py @@ -0,0 +1,7 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Process-local document deletion tasks shared by file and index cleanup.""" + +import asyncio +from typing import Dict + +_document_delete_tasks: Dict[str, asyncio.Task] = {} diff --git a/backend/management/services/knowledge_base/management.py b/backend/management/services/knowledge_base/management.py index af94106bcd..ce063e2864 100644 --- a/backend/management/services/knowledge_base/management.py +++ b/backend/management/services/knowledge_base/management.py @@ -16,20 +16,33 @@ import time from typing import Any, Dict, List, Optional -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, PERMISSION_PRIVATE -from contracts.constants.environment import IS_SPEED_MODE +from fastapi import Body, Depends, Path, Query + +from contracts.constants.constants import ASSET_OWNER_TENANT_ID, PERMISSION_PRIVATE, PERMISSION_READ from contracts.errors.exceptions import AppException, DuplicateError +from contracts.roles import has_any_role, roles_from_record from management.db.agent.repository import query_knowledge_binding_agents -from management.db.file.attachment import get_file_stream from management.db.client import get_db_session from management.db.conversation.repository import remove_conversation_knowledge_bindings +from management.db.file.attachment import get_file_stream from management.db.identity.group import query_group_ids_by_user -from management.db.knowledge_base.repository import create_knowledge_record, delete_knowledge_record, get_knowledge_info_by_tenant_id, get_knowledge_record, update_embedding_model_by_index_name, update_knowledge_record, update_last_doc_update_time, update_model_name_by_index_name -from management.db.knowledge_base.file_lifecycle import delete_file_records_for_knowledge_base +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.knowledge_base.file_lifecycle import ( + delete_file_records_for_knowledge_base, +) +from management.db.knowledge_base.repository import ( + create_knowledge_record, + delete_knowledge_record, + get_knowledge_info_by_tenant_id, + get_knowledge_record, + update_embedding_model_by_index_name, + update_knowledge_record, + update_last_doc_update_time, + update_model_name_by_index_name, +) from management.db.model.repository import get_model_by_model_id from management.db.tool.repository import remove_tool_knowledge_references -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from fastapi import Body, Depends, Path, Query +from management.services.configuration.tenant.settings import tenant_config_manager from management.services.knowledge_base.common import ( _QUOTA_LIMIT_UNSET, _SKIP_INDEX_SOURCE_CLEANUP, @@ -38,10 +51,10 @@ get_vector_db_core, logger, ) - -# A knowledge base deletion is rejected while any file is still owned by the -# ingestion pipeline. DELETE_REQUESTED/DELETED rows are already owned by a -# file-deletion operation and therefore do not block the broader deletion. +from management.services.knowledge_base.document_tasks import _document_delete_tasks +from management.services.knowledge_base.files import ( + KNOWLEDGE_BASE_DELETE_BLOCKING_STATUSES as KNOWLEDGE_BASE_DELETE_BLOCKING_STATUSES, +) from management.services.knowledge_base.files import ( KnowledgeBaseFileService, ) @@ -57,20 +70,29 @@ require_knowledge_base_read_permission, resolve_knowledge_base_permission, ) +from management.services.knowledge_base.task_state import get_redis_service from management.services.model.resolver import ( create_embedding_model, get_embedding_model_by_id, get_model_descriptor, ) +from management.services.permission.asset_owner import postprocess_knowledge_visibility +from management.services.permission.identity import groups as group_service +from management.services.tag.projection import ( + project_authorized_resource_tags, +) +from management.utils.serialization import ( + convert_list_to_string, + convert_string_to_list, +) from nexent.core.gateway.modality import EmbeddingAdapter from nexent.vector_database.base import VectorDatabaseCore from permissions.dac import ResourceAccessControl from permissions.models import Resource -from management.services.permission.asset_owner import postprocess_knowledge_visibility -from management.services.permission.identity.groups import get_tenant_default_group_id -from management.services.knowledge_base.task_state import get_redis_service -from management.services.configuration.tenant.settings import tenant_config_manager -from management.utils.serialization import convert_list_to_string, convert_string_to_list + +# A knowledge base deletion is rejected while any file is still owned by the +# ingestion pipeline. DELETE_REQUESTED/DELETED rows are already owned by a +# file-deletion operation and therefore do not block the broader deletion. class KnowledgeBaseManagementService(KnowledgeBaseFileService): @@ -187,9 +209,6 @@ async def full_delete_knowledge_base( # A per-file deletion retry task is in-memory and can only # exist in this process. A knowledge-base deletion supersedes # it after the index and lifecycle rows have been removed. - from management.services.knowledge_base.deletion import ( - _document_delete_tasks, - ) pending_delete_task = _document_delete_tasks.pop(str(file_id), None) if pending_delete_task and not pending_delete_task.done(): @@ -368,7 +387,7 @@ def create_knowledge_base( } # Add group permission and group IDs if provided. - if str(user_role or "").upper() == "USER": + if has_any_role(user_role, {"USER"}) and not has_any_role(user_role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}): knowledge_data["ingroup_permission"] = PERMISSION_PRIVATE knowledge_data["group_ids"] = None else: @@ -466,7 +485,7 @@ def update_knowledge_base( f"Invalid ingroup_permission. Must be one of: {valid_permissions}" ) - if str(user_role or "").upper() == "USER": + if has_any_role(user_role, {"USER"}) and not has_any_role(user_role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}): record = get_knowledge_record({"index_name": index_name}) if not record: raise ValueError(f"Knowledge base '{index_name}' not found") @@ -720,6 +739,8 @@ def list_indices( keyword: str | None = None, sources: List[str] | None = None, models: List[str] | None = None, + *, + inventory_read: bool = False, ): """ List all indices that the current user has permissions to access based on role and group permissions. @@ -728,7 +749,7 @@ def list_indices( - SU: All knowledgebases visible, all editable - ADMIN: Knowledgebases from same tenant visible, all editable - DEV on ASSET_OWNER-scoped records: all visible, read-only (READ_ONLY) - - SU/ADMIN/SPEED cross-tenant view of ASSET_OWNER records: read-only + - SU/ADMIN 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 @@ -752,10 +773,10 @@ def list_indices( if not user_tenant: return {"indices": [], "count": 0} - user_role = user_tenant.get("user_role") + user_role = roles_from_record(user_tenant) user_tenant_id = str(user_tenant.get("tenant_id") or target_tenant_id or "") # Get user group IDs from tenant_group_user_t table - user_group_ids = query_group_ids_by_user(user_id) + user_group_ids = [] if inventory_read else query_group_ids_by_user(user_id) # Get all indices from Elasticsearch es_indices_list = vdb_core.get_user_indices(pattern) @@ -781,38 +802,30 @@ def list_indices( if index_name not in es_indices_list: continue - # Fallback logic: if user_id equals user_tenant_id, treat as legacy admin user - # even if user_role is None or empty effective_user_role = user_role - if user_id == user_tenant_id: - effective_user_role = "ADMIN" - logger.info(f"User {user_id} identified as legacy admin") - elif IS_SPEED_MODE and not user_role: - effective_user_role = "SPEED" - logger.info("User under SPEED version is treated as admin") - - # SPEED mode may run without a user_tenant_t row; trust the - # requested tenant for the check in that legacy deployment. - effective_user_tenant_id = user_tenant_id or str( - record.get("tenant_id") or "" - ) - access = ResourceAccessControl.check( - Resource( - resource_type="knowledge_base", - resource_id=index_name, - tenant_id=record.get("tenant_id"), - created_by=record.get("created_by"), - ingroup_permission=record.get("ingroup_permission"), - group_ids=record.get("group_ids"), - knowledge_sources=record.get("knowledge_sources"), - ), - user_id=user_id, - role=(effective_user_role or "").upper(), - user_groups=user_group_ids, - user_tenant_id=effective_user_tenant_id, - asset_owner_tenant_id=ASSET_OWNER_TENANT_ID, - ) - permission = access.permission_label + effective_user_tenant_id = user_tenant_id + if has_any_role(user_role, {"SU"}): + effective_user_tenant_id = target_tenant_id + if inventory_read: + permission = PERMISSION_READ + else: + access = ResourceAccessControl.check( + Resource( + resource_type="knowledge_base", + resource_id=index_name, + tenant_id=record.get("tenant_id"), + created_by=record.get("created_by"), + ingroup_permission=record.get("ingroup_permission"), + group_ids=record.get("group_ids"), + knowledge_sources=record.get("knowledge_sources"), + ), + user_id=user_id, + role=effective_user_role, + user_groups=user_group_ids, + user_tenant_id=effective_user_tenant_id, + asset_owner_tenant_id=ASSET_OWNER_TENANT_ID, + ) + permission = access.permission_label # Add to visible list if permission is granted if permission: @@ -825,7 +838,7 @@ def list_indices( ) else: # If no group_ids specified, use tenant default group - default_group_id = get_tenant_default_group_id( + default_group_id = group_service.get_tenant_default_group_id( record.get("tenant_id") ) record_with_permission["group_ids"] = ( @@ -859,9 +872,6 @@ def list_indices( indices = [record["index_name"] for record in visible_knowledgebases] if include_stats: - from management.services.tag.projection import ( - project_authorized_resource_tags, - ) visible_knowledgebases = project_authorized_resource_tags( visible_knowledgebases, @@ -938,7 +948,7 @@ def list_indices( ) # Update model name if missing - if index_name in model_name_is_none_list: + if not inventory_read and index_name in model_name_is_none_list: update_model_name_by_index_name( index_name, index_stats.get("base_info", {}).get("embedding_model", ""), @@ -1166,8 +1176,3 @@ def index_documents( _rethrow_or_plain(e) DOCUMENT_DELETE_SCOPES = ("source_only", "full") - - -from management.services.knowledge_base.files import ( - KNOWLEDGE_BASE_DELETE_BLOCKING_STATUSES as KNOWLEDGE_BASE_DELETE_BLOCKING_STATUSES, -) diff --git a/backend/management/services/knowledge_base/permission.py b/backend/management/services/knowledge_base/permission.py index 18a2683bfe..847b2227f9 100644 --- a/backend/management/services/knowledge_base/permission.py +++ b/backend/management/services/knowledge_base/permission.py @@ -6,10 +6,10 @@ from typing import List, Optional from contracts.constants.constants import ASSET_OWNER_TENANT_ID, PERMISSION_EDIT -from contracts.constants.environment import IS_SPEED_MODE +from contracts.roles import roles_from_record from management.db.identity.group import query_group_ids_by_user -from management.db.knowledge_base.repository import get_knowledge_record from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.knowledge_base.repository import get_knowledge_record from permissions.dac import ResourceAccessControl from permissions.models import Resource @@ -38,22 +38,16 @@ def resolve_knowledge_base_permissions( if not records: return {} user_tenant = get_user_tenant_by_user_id(user_id) - if not user_tenant and not IS_SPEED_MODE: + if not user_tenant: return {record["index_name"]: None for record in records} - user_role = (user_tenant or {}).get("user_role") + user_role = roles_from_record(user_tenant or {}) user_tenant_id = str((user_tenant or {}).get("tenant_id") or tenant_id or "") - if user_id == user_tenant_id: - user_role = "ADMIN" - elif IS_SPEED_MODE and not user_role: - user_role = "SPEED" - role = (user_role or "").upper() + role = user_role groups = query_group_ids_by_user(user_id) permissions = {} for record in records: index_name = record["index_name"] effective_tenant = user_tenant_id - if IS_SPEED_MODE and not effective_tenant: - effective_tenant = str(record.get("tenant_id") or tenant_id or "") access = ResourceAccessControl.check( Resource( resource_type="knowledge_base", diff --git a/backend/management/services/mcp/health.py b/backend/management/services/mcp/health.py index 58f9bb82bb..a2f5990e90 100644 --- a/backend/management/services/mcp/health.py +++ b/backend/management/services/mcp/health.py @@ -247,6 +247,7 @@ async def check_mcp_service_health( tenant_id: str, user_id: str, mcp_id: int, + persist_status: bool = True, ) -> str: """Check MCP service health by ID. @@ -276,12 +277,13 @@ async def check_mcp_service_health( # as healthy (consistent with the enable/disable path). config_json = record.get("config_json") if isinstance(config_json, dict) and "openapi" in config_json: - update_mcp_record_status_by_id( - mcp_id=mcp_id, - tenant_id=tenant_id, - user_id=user_id, - status=True, - ) + if persist_status: + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=True, + ) return "healthy" authorization_token = record.get("authorization_token") @@ -294,29 +296,32 @@ async def check_mcp_service_health( custom_headers=custom_headers, ) except MCPConnectionError: - update_mcp_record_status_by_id( - mcp_id=mcp_id, - tenant_id=tenant_id, - user_id=user_id, - status=False, - ) + if persist_status: + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=False, + ) raise except Exception as exc: logger.error(f"MCP health check failed: {exc}") + if persist_status: + update_mcp_record_status_by_id( + mcp_id=mcp_id, + tenant_id=tenant_id, + user_id=user_id, + status=False, + ) + raise MCPConnectionError(str(exc) or "MCP connection failed") + + if persist_status: update_mcp_record_status_by_id( mcp_id=mcp_id, tenant_id=tenant_id, user_id=user_id, - status=False, + status=status, ) - raise MCPConnectionError(str(exc) or "MCP connection failed") - - update_mcp_record_status_by_id( - mcp_id=mcp_id, - tenant_id=tenant_id, - user_id=user_id, - status=status, - ) if not status: raise MCPConnectionError("MCP connection failed") diff --git a/backend/management/services/mcp/service.py b/backend/management/services/mcp/service.py index 441055f03f..ddeba0fbb8 100644 --- a/backend/management/services/mcp/service.py +++ b/backend/management/services/mcp/service.py @@ -1,5 +1,6 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import asyncio import logging import os @@ -9,22 +10,93 @@ from contextlib import AsyncExitStack from typing import Awaitable, Callable -from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ -from contracts.constants.environment import MCP_REQUEST_TIMEOUT_SECONDS, NEXENT_MCP_DOCKER_IMAGE -from contracts.errors.exceptions import MCPConnectionError, MCPContainerError, McpNameConflictError, MCPNameIllegal, McpNotFoundError, McpPortConflictError, McpValidationError -from contracts.tool import MCPConfigRequest -from management.db.identity.group import query_group_ids_by_user -from management.db.mcp.market import get_mcp_market_record_by_source_mcp_id, update_mcp_market_record -from management.db.mcp.remote import check_enabled_mcp_name_exists, check_mcp_name_exists, create_mcp_record, delete_mcp_record_by_container_id, delete_mcp_record_by_id, get_mcp_authorization_token_by_name_and_url, get_mcp_custom_headers_by_name_and_url, get_mcp_record_by_id_and_tenant, get_mcp_records_by_tenant, update_mcp_record_by_name_and_url, update_mcp_record_container_fields_by_id, update_mcp_record_enabled_by_id, update_mcp_record_manage_fields_by_id, update_mcp_record_registry_json_by_id, update_mcp_record_status_by_id, update_mcp_status_by_name_and_url -from management.db.tool.repository import set_mcp_tools_unavailable -from management.db.identity.user_tenant import get_user_tenant_by_user_id from fastmcp import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport + +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + PERMISSION_EDIT, + PERMISSION_READ, +) +from contracts.constants.environment import ( + MCP_REQUEST_TIMEOUT_SECONDS, + NEXENT_MCP_DOCKER_IMAGE, +) +from contracts.errors.exceptions import ( + MCPConnectionError, + MCPContainerError, + McpNameConflictError, + MCPNameIllegal, + McpNotFoundError, + McpPortConflictError, + McpValidationError, +) +from contracts.roles import has_any_role, roles_from_record +from contracts.tool import MCPConfigRequest from infrastructure.http.client import create_httpx_client +from management.db.identity.group import query_group_ids_by_user +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.mcp.market import ( + get_mcp_market_record_by_source_mcp_id, + update_mcp_market_record, +) +from management.db.mcp.remote import ( + check_enabled_mcp_name_exists, + check_mcp_name_exists, + create_mcp_record, + delete_mcp_record_by_container_id, + delete_mcp_record_by_id, + get_mcp_authorization_token_by_name_and_url, + get_mcp_custom_headers_by_name_and_url, + get_mcp_record_by_id_and_tenant, + get_mcp_records_by_tenant, + update_mcp_record_by_name_and_url, + update_mcp_record_container_fields_by_id, + update_mcp_record_enabled_by_id, + update_mcp_record_manage_fields_by_id, + update_mcp_record_registry_json_by_id, + update_mcp_record_status_by_id, + update_mcp_status_by_name_and_url, +) +from management.db.tag.repository import TagManagementDB +from management.db.tool.repository import set_mcp_tools_unavailable from management.services.mcp.common import ( logger, ) from management.services.mcp.containers import MCPContainerManager +from management.services.mcp.health import ( + _format_mcp_connection_error, + _iter_exception_chain, + _mcp_protocol_connect, + _mcp_protocol_health_check, + check_mcp_health_and_update_db, + check_mcp_service_health, + mcp_server_health, + test_mcp_connection, +) +from management.services.mcp.provisioning import ( + _is_container_record, + add_container_mcp_service, + check_container_port_conflict, + check_container_port_conflict_records, + check_runtime_host_port_available, + suggest_container_port, + upload_and_start_mcp_image, +) +from management.services.mcp.registration import ( + _build_mcp_headers, + _check_mcp_connectivity, + add_mcp_service, + add_remote_mcp_server_list, +) +from management.services.mcp.tools import ( + list_mcp_service_tools_by_id, + refresh_mcp_service_tool_count, +) +from management.services.tool.configuration import ( + _refresh_openapi_services_in_mcp, + delete_openapi_service, +) async def update_remote_mcp_server_list( @@ -441,10 +513,6 @@ async def delete_mcp_service( # API-to-MCP services have a separate service record and all their # persisted tools use the shared ``outer-apis`` usage value. try: - from management.services.tool.configuration import ( - _refresh_openapi_services_in_mcp, - delete_openapi_service, - ) delete_openapi_service( service_name=current_record.get("mcp_name") or "", @@ -471,9 +539,8 @@ async def delete_mcp_service( tenant_id=tenant_id, user_id=user_id, ) - from management.services.tag.library import TagManagementService - TagManagementService.cleanup_resource_assignments( + TagManagementDB.soft_delete_resource_assignments( tenant_id, "mcp_service", str(mcp_id), user_id ) @@ -500,9 +567,8 @@ async def delete_mcp_by_container_id( user_id=user_id, ) if deleted_mcp_id is not None: - from management.services.tag.library import TagManagementService - TagManagementService.cleanup_resource_assignments( + TagManagementDB.soft_delete_resource_assignments( tenant_id, "mcp_service", str(deleted_mcp_id), user_id ) @@ -530,8 +596,8 @@ async def get_remote_mcp_server_list( user_groups: list[str] | None = None if user_id: user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_role = roles_from_record(user_tenant_record) + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) try: raw_groups = query_group_ids_by_user(user_id) or [] user_groups = [str(g) for g in raw_groups] @@ -671,8 +737,8 @@ def attach_mcp_container_permissions( can_edit_all = False if user_id: user_tenant_record = get_user_tenant_by_user_id(user_id) or {} - user_role = str(user_tenant_record.get("user_role") or "").upper() - can_edit_all = user_role in CAN_EDIT_ALL_USER_ROLES + user_role = roles_from_record(user_tenant_record) + can_edit_all = has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) created_by_by_container_id = {} try: @@ -741,34 +807,3 @@ async def get_mcp_record_by_id( "authorization_token": mcp_record.get("authorization_token"), "custom_headers": mcp_record.get("custom_headers"), } - - -from management.services.mcp.health import ( - _format_mcp_connection_error, - _iter_exception_chain, - _mcp_protocol_connect, - _mcp_protocol_health_check, - check_mcp_health_and_update_db, - check_mcp_service_health, - mcp_server_health, - test_mcp_connection, -) -from management.services.mcp.provisioning import ( - _is_container_record, - add_container_mcp_service, - check_container_port_conflict, - check_container_port_conflict_records, - check_runtime_host_port_available, - suggest_container_port, - upload_and_start_mcp_image, -) -from management.services.mcp.registration import ( - _build_mcp_headers, - _check_mcp_connectivity, - add_mcp_service, - add_remote_mcp_server_list, -) -from management.services.mcp.tools import ( - list_mcp_service_tools_by_id, - refresh_mcp_service_tool_count, -) diff --git a/backend/management/services/model/health.py b/backend/management/services/model/health.py index 1e2444c011..49ed0a2be1 100644 --- a/backend/management/services/model/health.py +++ b/backend/management/services/model/health.py @@ -329,7 +329,7 @@ async def _perform_connectivity_check( return connectivity -async def check_model_connectivity(display_name: str, tenant_id: str, model_type: str = None) -> dict: +async def check_model_connectivity(display_name: str, tenant_id: str, model_type: str = None, *, persist_status: bool = True) -> dict: """Use one streamed LLM check; retain all other model checks unchanged.""" model = get_model_by_display_name(display_name, tenant_id=tenant_id, model_type=model_type) if model and model.get("model_type") in {"llm", "chat"}: @@ -337,11 +337,14 @@ async def check_model_connectivity(display_name: str, tenant_id: str, model_type from management.services.model.profiles import validate_model_outlet result = await validate_model_outlet(ModelOutletValidationRequest(outlet_id=model["model_id"], - expected_revision=model.get("config_revision") or 1), tenant_id) + expected_revision=model.get("config_revision") or 1), tenant_id, + **({} if persist_status else {"persist_status": False})) return {"connectivity": result["ok"], "model_name": model["model_name"], **({"error": result["error"]["category"]} if result["error"] else {})} if not model: raise LookupError("MODEL_CONFIGURATION_NOT_FOUND") + if not persist_status: + return await _verify_non_llm_config_connectivity(model) return await _check_non_llm_connectivity(display_name, tenant_id, model_type, _model=model) diff --git a/backend/management/services/model/profiles.py b/backend/management/services/model/profiles.py index cca19d729c..a01b76acc0 100644 --- a/backend/management/services/model/profiles.py +++ b/backend/management/services/model/profiles.py @@ -594,7 +594,7 @@ def _load_validation_outlet(model_id, tenant_id): raise ModelOutletValidationError("PROFILE_STORAGE_INVALID") from None -async def validate_model_outlet(request, tenant_id, *, is_disconnected=None): +async def validate_model_outlet(request, tenant_id, *, is_disconnected=None, persist_status=True): """Probe one captured endpoint/model with the common sender; no tool qualification.""" from nexent.core.concurrency import run_blocking @@ -619,7 +619,7 @@ async def validate_model_outlet(request, tenant_id, *, is_disconnected=None): import asyncio raise asyncio.CancelledError() ok, error = result["ok"], result["error"] - if request.outlet_id is not None and request.patch is None: + if persist_status and request.outlet_id is not None and request.patch is None: from management.db.model.outlets import update_model_outlet_connectivity await run_blocking("outlet-validation-status", update_model_outlet_connectivity, diff --git a/backend/management/services/permission/api_keys.py b/backend/management/services/permission/api_keys.py index e23c375c38..167f899b85 100644 --- a/backend/management/services/permission/api_keys.py +++ b/backend/management/services/permission/api_keys.py @@ -5,17 +5,32 @@ import uuid from typing import Any, Dict, List, Optional -from contracts.errors.exceptions import ForbiddenError, NotFoundException, ValidationError +from contracts.errors.exceptions import ( + ForbiddenError, + NotFoundException, + ValidationError, +) +from contracts.roles import has_any_role from management.db.client import get_db_session from management.db.identity.account import create_account from management.db.identity.group import add_user_to_group, query_groups -from management.db.identity.token import create_token, generate_access_key, list_active_tokens_by_tenant, soft_delete_tokens_by_user -from management.db.identity.user_tenant import get_user_tenant_by_email, get_user_tenant_in_tenant, insert_user_tenant +from management.db.identity.token import ( + create_token, + generate_access_key, + list_active_tokens_by_tenant, + soft_delete_tokens_by_user, +) +from management.db.identity.user_tenant import ( + get_user_tenant_by_email, + get_user_tenant_in_tenant, + insert_user_tenant, +) +from management.services.permission.identity.display import enrich_cas_usernames from management.services.permission.identity.groups import get_tenant_default_group_id -def _require_tenant_admin(actor_tenant_id: str, actor_role: str) -> None: - if (actor_role or "").upper() not in {"ADMIN", "SU"}: +def _require_tenant_admin(actor_tenant_id: str, actor_role: str | tuple[str, ...]) -> None: + if not has_any_role(actor_role, {'ADMIN','SU'}): raise ForbiddenError("Only administrators can manage API keys") if not actor_tenant_id: raise ForbiddenError("Tenant context is required") @@ -56,14 +71,14 @@ def create_api_users_batch( *, actor_user_id: str, actor_tenant_id: str, - actor_role: str, + actor_role: str | tuple[str, ...], role: str = "USER", group_id: Optional[int] = None, count: int = 1, ) -> List[Dict[str, Any]]: """Create API-only users, group memberships, and keys.""" _require_tenant_admin(actor_tenant_id, actor_role) - normalized_role = (role or "USER").upper() + normalized_role = role.strip().upper() if isinstance(role, str) else "" if normalized_role not in {"DEV", "USER"}: raise ValidationError("API user role must be DEV or USER") if count < 1 or count > 100: @@ -100,6 +115,7 @@ def create_api_users_batch( { "user_id": user_id, "role": normalized_role, + "roles": [normalized_role], "group_id": group["group_id"], "group_name": group.get("group_name"), "api_key": token["access_key"], @@ -112,7 +128,7 @@ def refresh_user_api_key( *, actor_user_id: str, actor_tenant_id: str, - actor_role: str, + actor_role: str | tuple[str, ...], user_id: Optional[str] = None, email: Optional[str] = None, ) -> Dict[str, Any]: @@ -143,7 +159,7 @@ def revoke_user_api_keys( *, actor_user_id: str, actor_tenant_id: str, - actor_role: str, + actor_role: str | tuple[str, ...], user_id: Optional[str] = None, email: Optional[str] = None, ) -> Dict[str, Any]: @@ -164,20 +180,26 @@ def revoke_user_api_keys( def list_tenant_api_keys( *, actor_tenant_id: str, - actor_role: str, + actor_role: str | tuple[str, ...], tenant_id: str, page: int = 1, page_size: int = 20, sort_order: str = "desc", ) -> Dict[str, Any]: - """List active API keys after enforcing tenant administrator access.""" - _require_tenant_admin(actor_tenant_id, actor_role) - if tenant_id != actor_tenant_id: + """List active keys, with metadata-only access for observers and cross-tenant administrators.""" + is_admin = has_any_role(actor_role, {"ADMIN", "SU"}) + is_observer = has_any_role(actor_role, {"OPS"}) and not is_admin + if not is_observer: + _require_tenant_admin(actor_tenant_id, actor_role) + cross_tenant = tenant_id != actor_tenant_id + if cross_tenant and not has_any_role(actor_role, {"SU"}): raise NotFoundException("Tenant not found") result = list_active_tokens_by_tenant(tenant_id, page, page_size, sort_order) - from management.services.permission.identity.display import enrich_cas_usernames enrich_cas_usernames(result.get("items", []), { "owner_cas_username": "user_id", "creator_cas_username": "created_by", }) + if is_observer or cross_tenant: + for item in result.get("items", []): + item.pop("access_key", None) return result diff --git a/backend/management/services/permission/asset_owner.py b/backend/management/services/permission/asset_owner.py index 721ab0ab53..1deaac851a 100644 --- a/backend/management/services/permission/asset_owner.py +++ b/backend/management/services/permission/asset_owner.py @@ -4,11 +4,16 @@ from typing import Any, Dict, List, Optional -from contracts.constants.constants import AGENT_PROMPTS_HIDDEN_FLAG -from contracts.constants.constants import ASSET_OWNER_ROLE, ASSET_OWNER_TENANT_ID, PERMISSION_EDIT, PERMISSION_READ +from contracts.constants.constants import ( + AGENT_PROMPTS_HIDDEN_FLAG, + ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, + PERMISSION_EDIT, + PERMISSION_READ, +) from contracts.constants.environment import ENABLE_ASSET_OWNER_ROLE from contracts.errors.exceptions import ValidationError - +from contracts.roles import has_any_role _PROMPT_FIELDS = ("prompt",) @@ -67,8 +72,8 @@ def resolve_agent_list_permission( 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: + role = user_role + if agent.get("tenant_id") == ASSET_OWNER_TENANT_ID and not has_any_role(role, {ASSET_OWNER_ROLE}): return PERMISSION_READ if can_edit_all or str(agent.get("created_by")) == str(user_id): return PERMISSION_EDIT diff --git a/backend/management/services/permission/identity/authentication.py b/backend/management/services/permission/identity/authentication.py index 87d245ef53..1d54403376 100644 --- a/backend/management/services/permission/identity/authentication.py +++ b/backend/management/services/permission/identity/authentication.py @@ -2,53 +2,88 @@ import logging from types import SimpleNamespace -from sqlalchemy import select -from typing import Optional, Any, Tuple, Dict, List - -from management.db.identity.token import ( - create_token as create_token_record, - generate_access_key, - list_tokens_by_user as list_tokens_by_user_record, - mask_access_key, - delete_token as delete_token_record, -) +from typing import Any, Dict, List, Optional, Tuple from pydantic import EmailStr +from sqlalchemy import select +from contracts.constants.constants import ( + ASSET_OWNER_INVITE_CODE_TYPE, + ASSET_OWNER_ROLE, + ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL, + ASSET_OWNER_TENANT_ID, +) +from contracts.constants.environment import INVITE_CODE, JWT_SECRET +from contracts.errors.codes import ErrorCode +from contracts.errors.exceptions import ( + AppException, + IncorrectInviteCodeException, + NoInviteCodeException, + TenantResourceLimitError, + UnauthorizedError, + UserRegistrationException, + ValidationError, +) +from contracts.roles import ( + has_any_role, + invitation_roles, + primary_role, + roles_from_record, +) from gateway.authentication.auth import ( - get_current_user_id, - generate_session_jwt, - require_active_account, calculate_expires_at, - get_jwt_expiry_seconds, ensure_cas_session_active_from_authorization, + generate_session_jwt, + get_current_user_id, + get_jwt_expiry_seconds, + require_active_account, resolve_tenant_id_from_user_tenant_record, ) -from contracts.constants.environment import INVITE_CODE, JWT_SECRET -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, ASSET_OWNER_INVITE_CODE_TYPE, ASSET_OWNER_ROLE, ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL - +from infrastructure.security.passwords import hash_password, verify_password +from management.db.client import get_db_session +from management.db.identity.account import ( + change_password, + create_account, + get_account_by_email, +) +from management.db.identity.group import query_group_ids_by_user +from management.db.identity.oauth_account import list_oauth_accounts_by_user_id +from management.db.identity.token import ( + create_token as create_token_record, +) +from management.db.identity.token import ( + delete_token as delete_token_record, +) +from management.db.identity.token import ( + generate_access_key, + mask_access_key, + soft_delete_tokens_by_user, +) +from management.db.identity.token import ( + list_tokens_by_user as list_tokens_by_user_record, +) +from management.db.identity.user_tenant import ( + get_user_tenant_by_user_id, + insert_user_tenant, +) +from management.db.model.repository import create_model_record +from management.db.models import RolePermission, UserAccount from management.services.permission.asset_owner import ( filter_accessible_routes_for_asset_owner_feature, require_asset_owner_enabled, ) -from contracts.errors.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException, UnauthorizedError, TenantResourceLimitError, ValidationError -from contracts.errors.codes import ErrorCode -from contracts.errors.exceptions import AppException - -from management.db.model.repository import create_model_record -from management.db.identity.user_tenant import insert_user_tenant, get_user_tenant_by_user_id -from management.db.identity.oauth_account import list_oauth_accounts_by_user_id -from management.db.identity.group import query_group_ids_by_user -from management.utils.orm import as_dict -from management.db.client import get_db_session -from management.db.models import RolePermission, UserAccount -from management.db.identity.account import create_account, get_account_by_email, change_password -from infrastructure.security.passwords import hash_password, verify_password -from management.services.permission.identity.invitations import use_invitation_code, check_invitation_available, get_invitation_by_code from management.services.permission.identity.groups import add_user_to_groups -from management.services.tool.configuration import init_tool_list_for_tenant +from management.services.permission.identity.invitations import ( + check_invitation_available, + get_invitation_by_code, + use_invitation_code, +) +from management.services.permission.identity.registration import ( + register_invited_account, +) from management.services.skill.transfer import init_skill_list_for_tenant - +from management.services.tool.configuration import init_tool_list_for_tenant +from management.utils.orm import as_dict logging.getLogger("user_management_service").setLevel(logging.INFO) @@ -72,8 +107,6 @@ def get_provider_username(user_id: str, provider: str) -> Optional[str]: return None - - def build_token_response(user_id: str) -> dict: """Keep the existing response object without a server-side session store.""" token = generate_session_jwt(user_id) @@ -91,119 +124,27 @@ async def check_auth_service_health(): raise ConnectionError("Local account store is unavailable") from exc -async def signup_user_with_invitation(email: EmailStr, - password: str, +async def signup_user_with_invitation(email: EmailStr, password: str, invite_code: Optional[str] = None, auto_login: Optional[bool] = True): - """User registration with invitation code support""" + """Create local account, tenant roles, groups and invitation usage atomically.""" if not isinstance(invite_code, str) or not invite_code.strip(): raise ValidationError("INVITE_CODE_REQUIRED") - invite_code = invite_code.strip().upper() - - # Validate password strength before registration if not validate_password_strength(password): - raise AppException(ErrorCode.PROFILE_PASSWORD_WEAK, - "Password must be at least 8 characters with uppercase, lowercase, and digit.") - - logging.info( - f"Receive registration request: email={email}, invite_code={'provided' if invite_code else 'not provided'}, auto_login={auto_login}") - - # Default user role is USER - user_role = "USER" - invitation_info = None - - # Validate invitation code if provided (without using it yet) - if invite_code: - try: - # Check if invitation is available - if not check_invitation_available(invite_code): - raise IncorrectInviteCodeException( - f"Invitation code {invite_code} is not available") - - # Get invitation code details - invitation_info = get_invitation_by_code(invite_code) - if not invitation_info: - raise IncorrectInviteCodeException( - f"Invitation code {invite_code} not found") - - # Determine user role based on invitation code type - code_type = invitation_info["code_type"] - if code_type == "ADMIN_INVITE": - user_role = "ADMIN" - elif code_type == "DEV_INVITE": - user_role = "DEV" - elif code_type == ASSET_OWNER_INVITE_CODE_TYPE: - require_asset_owner_enabled() - raise ValidationError(ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL) - - 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)}") - raise IncorrectInviteCodeException( - f"Invalid invitation code: {str(e)}") - - # Identity and membership share a transaction so resource-limit failures - # cannot leave a login-capable orphan behind. - tenant_id = invitation_info["tenant_id"] - is_asset_owner_registration = user_role == ASSET_OWNER_ROLE - with get_db_session() as session: - account = create_account(email, hash_password(password), db_session=session) - user_id = account["user_id"] - insert_user_tenant(user_id=user_id, tenant_id=tenant_id, user_role=user_role, - user_email=account["email"], db_session=session) - - # Use invitation code now that we have the real user_id - if invitation_info: - try: - invitation_result = use_invitation_code(invite_code, user_id) - logging.debug( - f"Invitation code {invite_code} used successfully for user {user_id}") - - # Add user to groups specified in invitation code - group_ids = invitation_result.get("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): - from management.utils.serialization import convert_string_to_list - group_ids = convert_string_to_list(group_ids) - - if group_ids: - 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( - f"User {user_id} added to {len(successful_adds)} groups from invitation code") - - except Exception as e: - logging.error( - f"Failed to add user {user_id} to invitation groups: {str(e)}") - - except Exception as e: - # If using invitation code fails after registration, log error but don't fail registration - logging.error( - f"Failed to use invitation code {invite_code} for user {user_id}: {str(e)}") - - logging.info( - f"User {email} registered successfully, role: {user_role}, tenant: {tenant_id}, auto_login={auto_login}") - - - # Initialize tool list for the new tenant (only once per tenant) - 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 {"user": {"id": user_id, "email": account["email"], "role": user_role}, - "session": build_token_response(user_id) if auto_login else None, - "registration_type": "user"} + raise AppException(ErrorCode.PROFILE_PASSWORD_WEAK, "Password is too weak") + invitation = get_invitation_by_code(invite_code.strip().upper()) + if not invitation: + raise IncorrectInviteCodeException("Invitation is unavailable") + if "ASSET_OWNER" in invitation_roles(invitation): + require_asset_owner_enabled() + raise ValidationError(ASSET_OWNER_SIGNUP_USE_OAUTH_DETAIL) + account, member = register_invited_account(email, hash_password(password), invite_code) + user_id, tenant_id = account["user_id"], member["tenant_id"] + await init_tool_list_for_tenant(tenant_id, user_id) + await init_skill_list_for_tenant(tenant_id, user_id) + roles = roles_from_record(member) + return {"user": {"id": user_id, "email": account["email"], "role": primary_role(roles), "roles": list(roles)}, + "session": build_token_response(user_id) if auto_login else None, "registration_type": "user"} async def verify_invite_code(invite_code): @@ -239,10 +180,10 @@ async def signin_user(email: EmailStr, password: str): membership = get_user_tenant_by_user_id(account["user_id"]) if not membership: raise UnauthorizedError("No active tenant relationship") - if membership.get("user_role") == ASSET_OWNER_ROLE: + if has_any_role(roles_from_record(membership), {ASSET_OWNER_ROLE}): require_asset_owner_enabled() return {"message": "Login successful", "data": { - "user": {"id": account["user_id"], "email": account["email"], "role": membership["user_role"]}, + "user": {"id": account["user_id"], "email": account["email"], "role": membership["user_role"], "roles": list(roles_from_record(membership))}, "session": build_token_response(account["user_id"]), }} @@ -252,8 +193,6 @@ async def refresh_user_token(authorization, refresh_token: str): raise UnauthorizedError("Token refresh is unavailable; please log in again") - - async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: """ Get user information including user ID, group IDs, tenant ID, user role, permissions, and accessible routes. @@ -284,7 +223,7 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: # Get user permissions directly from database with get_db_session() as session: permission_records = session.query(RolePermission).filter( - RolePermission.user_role == user_role + RolePermission.user_role.in_(roles_from_record(user_tenant)) ).all() permissions = [as_dict(record) for record in permission_records] @@ -297,6 +236,7 @@ async def get_user_info(user_id: str) -> Optional[Dict[str, Any]]: "tenant_id": tenant_id, "user_email": user_email, "user_role": user_role, + "roles": list(roles_from_record(user_tenant)), "permissions": permissions_data["permissions"], "accessibleRoutes": permissions_data["accessibleRoutes"], } @@ -343,8 +283,8 @@ def format_role_permissions(permissions: List[Dict[str, Any]]) -> Dict[str, List ) return { - "permissions": formatted_permissions, - "accessibleRoutes": accessible_routes, + "permissions": list(dict.fromkeys(formatted_permissions)), + "accessibleRoutes": list(dict.fromkeys(accessible_routes)), } @@ -361,8 +301,6 @@ def create_token(user_id: str) -> Dict[str, Any]: Returns: Dictionary containing the API token information including token_id. """ - from management.db.client import get_db_session - from management.db.identity.token import soft_delete_tokens_by_user access_key = generate_access_key() with get_db_session() as session: @@ -391,7 +329,7 @@ def list_tokens_by_user(user_id: str, actor_role: str) -> List[Dict[str, Any]]: ``can_copy`` is True. Copying therefore yields the complete key while other roles keep seeing a masked, non-copyable value. """ - can_copy = (actor_role or "").upper() in {"ADMIN", "SU"} + can_copy = has_any_role(actor_role, {'ADMIN','SU'}) # The persistence layer masks access keys by default, so the stored value is # requested here and role-based exposure below stays the single decision # point. Requesting the mask instead made can_copy True while the value was diff --git a/backend/management/services/permission/identity/cas.py b/backend/management/services/permission/identity/cas.py index 78281c9ae0..b34517afe9 100644 --- a/backend/management/services/permission/identity/cas.py +++ b/backend/management/services/permission/identity/cas.py @@ -7,26 +7,65 @@ import ssl import urllib.parse import urllib.request -from xml.etree.ElementTree import Element from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Dict, Optional +from xml.etree.ElementTree import Element import defusedxml.ElementTree as ET from defusedxml.common import DefusedXmlException -from contracts.constants.environment import CAS_CA_BUNDLE, CAS_CALLBACK_BASE_URL, CAS_DEFAULT_ROLE, CAS_DEFAULT_TENANT_ID, CAS_EMAIL_ATTRIBUTE, CAS_ENABLED, CAS_HEARTBEAT_COOKIE_NAME, CAS_HEARTBEAT_INTERVAL_SECONDS, CAS_HEARTBEAT_URL, CAS_INTERNAL_SERVER_URL, CAS_LOGIN_MODE, CAS_LOGOUT_URL, CAS_RENEW_BEFORE_SECONDS, CAS_RENEW_TIMEOUT_SECONDS, CAS_ROLE_ATTRIBUTE, CAS_ROLE_MAP_JSON, CAS_SERVER_URL, CAS_SESSION_MAX_AGE_SECONDS, CAS_SSL_VERIFY, CAS_SYNTHETIC_EMAIL_DOMAIN, CAS_TENANT_ATTRIBUTE, CAS_USER_ATTRIBUTE, CAS_VALIDATE_PATH, LOCAL_SESSION_MAX_AGE_SECONDS from contracts.constants.constants import DEFAULT_TENANT_ID -from management.db.identity.cas_session import create_cas_session, revoke_cas_session_by_index, revoke_cas_sessions_by_user_id +from contracts.constants.environment import ( + CAS_CA_BUNDLE, + CAS_CALLBACK_BASE_URL, + CAS_DEFAULT_ROLE, + CAS_DEFAULT_TENANT_ID, + CAS_EMAIL_ATTRIBUTE, + CAS_ENABLED, + CAS_HEARTBEAT_COOKIE_NAME, + CAS_HEARTBEAT_INTERVAL_SECONDS, + CAS_HEARTBEAT_URL, + CAS_INTERNAL_SERVER_URL, + CAS_LOGIN_MODE, + CAS_LOGOUT_URL, + CAS_RENEW_BEFORE_SECONDS, + CAS_RENEW_TIMEOUT_SECONDS, + CAS_ROLE_ATTRIBUTE, + CAS_ROLE_MAP_JSON, + CAS_SERVER_URL, + CAS_SESSION_MAX_AGE_SECONDS, + CAS_SSL_VERIFY, + CAS_SYNTHETIC_EMAIL_DOMAIN, + CAS_TENANT_ATTRIBUTE, + CAS_USER_ATTRIBUTE, + CAS_VALIDATE_PATH, + LOCAL_SESSION_MAX_AGE_SECONDS, +) +from contracts.errors.exceptions import AccountConflictError +from contracts.roles import roles_from_record +from gateway.authentication.auth import ( + calculate_expires_at, + generate_session_jwt, + require_active_account, +) +from management.db.identity.account import create_account +from management.db.identity.cas_session import ( + create_cas_session, + revoke_cas_session_by_index, + revoke_cas_sessions_by_user_id, +) from management.db.identity.oauth_account import get_oauth_account_by_provider -from management.db.identity.user_tenant import get_user_tenant_by_user_id, upsert_user_tenant +from management.db.identity.user_tenant import ( + get_user_tenant_by_user_id, + upsert_user_tenant, +) from management.services.permission.identity.oauth import ( create_or_update_oauth_account, find_account_user_id_by_email, ) from management.services.skill.transfer import init_skill_list_for_tenant from management.services.tool.configuration import init_tool_list_for_tenant -from gateway.authentication.auth import calculate_expires_at, generate_session_jwt, require_active_account logger = logging.getLogger(__name__) @@ -244,6 +283,7 @@ async def _create_project_session(principal: CasPrincipal, redirect: str = "/", "id": str(user_id), "email": principal.email, "role": user_tenant.get("user_role", principal.role), + "roles": list(roles_from_record(user_tenant)), }, "session": { "access_token": jwt_token, @@ -270,8 +310,6 @@ def _resolve_project_user(principal: CasPrincipal) -> str: ) return existing["user_id"] - from management.db.identity.account import create_account - from contracts.errors.exceptions import AccountConflictError user_id = find_account_user_id_by_email(principal.email) if not user_id: @@ -373,7 +411,7 @@ def _map_role(raw_role: str) -> str: if not normalized_role: return default_role - role = normalized_role.upper() + role = normalized_role try: role_map = json.loads(CAS_ROLE_MAP_JSON) if CAS_ROLE_MAP_JSON else {} role = str(role_map.get(normalized_role, role_map.get(role, role))).strip().upper() diff --git a/backend/management/services/permission/identity/groups.py b/backend/management/services/permission/identity/groups.py index 5600c14500..a8a3efa824 100644 --- a/backend/management/services/permission/identity/groups.py +++ b/backend/management/services/permission/identity/groups.py @@ -3,15 +3,39 @@ """ Group service for managing groups and group memberships. """ + import logging from typing import Any, Dict, List, Optional, Union -from management.db.identity.group import query_groups, query_groups_by_tenant, add_group, modify_group, remove_group, add_user_to_group, remove_user_from_group, query_group_users, query_groups_by_user, query_group_ids_by_user, check_user_in_group, count_group_users, check_group_name_exists -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from management.db.configuration.tenant import get_single_config_info, insert_config, update_config_by_tenant_config_id -from contracts.errors.exceptions import NotFoundException, UnauthorizedError, ValidationError from contracts.constants.constants import DEFAULT_GROUP_ID -from management.services.permission.identity.tenants import get_tenant_info +from contracts.errors.exceptions import ( + NotFoundException, + UnauthorizedError, + ValidationError, +) +from contracts.roles import has_any_role, roles_from_record +from management.db.configuration.tenant import ( + get_single_config_info, + insert_config, + update_config_by_tenant_config_id, +) +from management.db.identity.group import ( + add_group, + add_user_to_group, + check_group_name_exists, + check_user_in_group, + count_group_users, + modify_group, + query_group_ids_by_user, + query_group_users, + query_groups, + query_groups_by_tenant, + query_groups_by_user, + remove_group, + remove_user_from_group, +) +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.services.permission.identity import tenants as tenant_service from management.services.permission.identity.display import enrich_cas_usernames logger = logging.getLogger(__name__) @@ -109,7 +133,7 @@ def get_tenant_default_group_id(tenant_id: str) -> Optional[int]: Optional[int]: Default group ID if exists, None otherwise """ try: - tenant_info = get_tenant_info(tenant_id) + tenant_info = tenant_service.get_tenant_info(tenant_id) default_group_id = tenant_info.get("default_group_id") return int(default_group_id) if default_group_id else None except Exception as e: @@ -135,7 +159,7 @@ def set_tenant_default_group_id(tenant_id: str, group_id: int, updated_by: Optio """ # Verify tenant exists try: - tenant_info = get_tenant_info(tenant_id) + tenant_info = tenant_service.get_tenant_info(tenant_id) if not tenant_info: raise NotFoundException(f"Tenant {tenant_id} not found") except NotFoundException: @@ -209,8 +233,8 @@ def create_group(tenant_id: str, group_name: str, group_description: Optional[st if not user_info: raise NotFoundException(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: + user_role = roles_from_record(user_info) + if not has_any_role(user_role, {'SU','ADMIN'}): raise UnauthorizedError(f"User role {user_role} not authorized to create groups") # Check if group name already exists in the tenant @@ -256,8 +280,8 @@ def update_group(group_id: int, updates: Dict[str, Any], user_id: str) -> bool: if not user_info: raise NotFoundException(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: + user_role = roles_from_record(user_info) + if not has_any_role(user_role, {'SU','ADMIN'}): raise UnauthorizedError(f"User role {user_role} not authorized to update groups") # Check if group exists @@ -305,8 +329,8 @@ def delete_group(group_id: int, user_id: str) -> bool: if not user_info: raise NotFoundException(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: + user_role = roles_from_record(user_info) + if not has_any_role(user_role, {'SU','ADMIN'}): raise UnauthorizedError(f"User role {user_role} not authorized to delete groups") # Check if group exists @@ -398,8 +422,8 @@ def remove_user_from_single_group(group_id: int, user_id: str, current_user_id: if not user_info: raise UnauthorizedError(f"User {current_user_id} not found") - user_role = user_info.get("user_role", "USER") - if user_role not in ["SU", "ADMIN"]: + user_role = roles_from_record(user_info) + if not has_any_role(user_role, {'SU','ADMIN'}): raise UnauthorizedError(f"User role {user_role} not authorized to manage group memberships") # Check if group exists @@ -420,7 +444,7 @@ def remove_user_from_single_group(group_id: int, user_id: str, current_user_id: return success -def get_group_users(group_id: int) -> List[Dict[str, Any]]: +def get_group_users(group_id: int, *, tenant_id: str | None = None) -> List[Dict[str, Any]]: """ Get all users in a group with their details. @@ -435,7 +459,7 @@ def get_group_users(group_id: int) -> List[Dict[str, Any]]: """ # Check if group exists group = query_groups(group_id) - if not group: + if not group or (tenant_id is not None and group.get('tenant_id') != tenant_id): raise NotFoundException(f"Group {group_id} not found") # Get group membership records @@ -447,11 +471,12 @@ def get_group_users(group_id: int) -> List[Dict[str, Any]]: if user_id: # Get user details from user_tenant table user_info = get_user_tenant_by_user_id(user_id) - if user_info: + if user_info and (tenant_id is None or user_info.get('tenant_id') == tenant_id): filtered_users.append({ "id": user_id, # Keep user_id as string "username": user_info.get("user_email", ""), - "role": user_info.get("user_role", "") + "role": user_info.get("user_role", ""), + "roles": list(roles_from_record(user_info)) }) return enrich_cas_usernames(filtered_users, {"cas_username": "id"}) diff --git a/backend/management/services/permission/identity/invitations.py b/backend/management/services/permission/identity/invitations.py index b160e6c660..f635e28e57 100644 --- a/backend/management/services/permission/identity/invitations.py +++ b/backend/management/services/permission/identity/invitations.py @@ -2,21 +2,53 @@ """ Invitation service for managing invitation codes and records. """ + import logging import random import string +from contextlib import contextmanager from datetime import datetime -from typing import Optional, Dict, Any, List - -from management.db.identity.invitation import query_invitation_by_code, query_invitation_by_id, add_invitation, modify_invitation, add_invitation_record, count_invitation_usage, query_invitations_with_pagination, remove_invitation +from typing import Any, Dict, List, Optional + +from sqlalchemy import select + +from contracts.constants.constants import ( + ASSET_OWNER_INVITE_CODE_TYPE, + ASSET_OWNER_TENANT_ID, +) +from contracts.errors.exceptions import ( + DuplicateError, + ForbiddenError, + IncorrectInviteCodeException, + NotFoundException, + UnauthorizedError, +) +from contracts.roles import ( + has_any_role, + invitation_roles, + normalize_roles, + roles_from_record, +) +from management.db.client import get_db_session +from management.db.identity.group import query_group_ids_by_user, query_groups +from management.db.identity.invitation import ( + add_invitation, + count_invitation_usage, + modify_invitation, + query_invitation_by_code, + query_invitation_by_id, + query_invitations_with_pagination, + remove_invitation, +) +from management.db.identity.roles import lock_tenants, locked_members from management.db.identity.user_tenant import get_user_tenant_by_user_id -from management.db.identity.group import query_group_ids_by_user -from management.db.identity.role_permission import check_role_permission -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, ASSET_OWNER_INVITE_CODE_TYPE -from contracts.constants.environment import ENABLE_ASSET_OWNER_ROLE -from contracts.errors.exceptions import NotFoundException, UnauthorizedError, DuplicateError -from management.services.permission.identity.groups import get_tenant_default_group_id +from management.db.models import TenantInvitationCode, TenantInvitationRecord from management.services.permission.asset_owner import require_asset_owner_enabled +from management.services.permission.identity.groups import get_tenant_default_group_id +from management.services.permission.identity.registration import ( + invitation_usage, + locked_invitation, +) from management.utils.serialization import convert_string_to_list logger = logging.getLogger(__name__) @@ -24,133 +56,88 @@ def create_invitation_code( tenant_id: str, - code_type: str, + code_type: Optional[str] = None, invitation_code: Optional[str] = None, group_ids: Optional[List[int]] = None, capacity: int = 1, expiry_date: Optional[str] = None, status: str = "IN_USE", - user_id: str = None + user_id: str = None, + roles=None, ) -> Dict[str, Any]: - """ - Create a new invitation code with business logic. - - Args: - tenant_id (str): Tenant ID - 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 - expiry_date (Optional[str]): Expiry date - status (str): Status - user_id (str): Current user ID - - Returns: - Dict[str, Any]: Created invitation code information - - Raises: - NotFoundException: When user not found - UnauthorizedError: When user doesn't have permission - ValueError: When code_type is invalid - """ - # Validate code_type - 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}") - - 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) - if not user_info: - raise NotFoundException(f"User {user_id} not found") - - user_role = user_info.get("user_role", "USER") - - # Check permission based on code_type - 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") - - 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 + """Create a fixed grant using the same ordinary role policy as user editing.""" + actor = get_user_tenant_by_user_id(user_id) + if not actor: + raise UnauthorizedError("User not found") + actor_roles = roles_from_record(actor) + if not has_any_role(actor_roles, {"SU", "ADMIN"}): + raise ForbiddenError("Not authorized to create invitations") + special = code_type == ASSET_OWNER_INVITE_CODE_TYPE + if special: + require_asset_owner_enabled() + if not has_any_role(actor_roles, {"SU"}): + raise UnauthorizedError("Asset owner invitations require platform administration") + assigned = ("ASSET_OWNER",) + if roles is not None and normalize_roles(roles) != assigned: + raise ValueError("Invitation role and type conflict") + tenant_id, group_ids = ASSET_OWNER_TENANT_ID, [] + else: + assigned = invitation_roles({"roles": roles, "code_type": code_type}) + normalize_roles(assigned, ordinary_only=True) + if not has_any_role(actor_roles, {"SU"}) and tenant_id != actor["tenant_id"]: + raise ForbiddenError("Cannot invite users into another tenant") + if roles is not None and code_type and code_type != "TENANT_INVITE": + if invitation_roles({"code_type": code_type}) != assigned: + raise ValueError("Invitation role and type conflict") + code_type = {("ADMIN",): "ADMIN_INVITE", ("DEV",): "DEV_INVITE", ("USER",): "USER_INVITE"}.get( + assigned, "TENANT_INVITE" + ) + if capacity < 1: + raise ValueError("Invitation capacity must be positive") if group_ids is None: - if code_type == "ADMIN_INVITE": - # For admin invites, try to use tenant default group, fallback to empty list - default_group_id = get_tenant_default_group_id(tenant_id) - group_ids = [default_group_id] if default_group_id else [] - elif code_type in ["DEV_INVITE", "USER_INVITE"]: - group_ids = query_group_ids_by_user(user_id) + if "ADMIN" in assigned: + default_group = get_tenant_default_group_id(tenant_id) + group_ids = [default_group] if default_group else [] else: - group_ids = [] - - # Generate invitation code if not provided - if not invitation_code: - invitation_code = _generate_unique_invitation_code() - else: - # Change to upper case by default - invitation_code = invitation_code.upper() - - # Check if invitation code already exists + group_ids = query_group_ids_by_user(user_id) if not special and tenant_id == actor["tenant_id"] else [] + if group_ids: + groups = query_groups(group_ids) + if isinstance(groups, dict): + groups = [groups] + if {int(g["group_id"]) for g in groups if g.get("tenant_id") == tenant_id} != {int(g) for g in group_ids}: + raise ForbiddenError("Invitation groups must belong to the target tenant") + invitation_code = invitation_code.upper() if invitation_code else _generate_unique_invitation_code() if query_invitation_by_code(invitation_code): - raise DuplicateError( - f"Invitation code '{invitation_code}' already exists") - - # Create invitation (status will be set automatically) - invitation_id = add_invitation( - tenant_id=tenant_id, - invitation_code=invitation_code, - code_type=code_type, - group_ids=group_ids, - capacity=capacity, - expiry_date=expiry_date, - status=status, - created_by=user_id - ) - - # 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}") - - # 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 - + raise DuplicateError("Invitation code already exists") + with get_db_session() as session: + lock_tenants(session, [tenant_id, actor["tenant_id"]]) + locked_actor = locked_members(session, [user_id]).get(user_id) + _check_invitation_manager(locked_actor, tenant_id, special) + invitation_id = add_invitation( + tenant_id=tenant_id, + invitation_code=invitation_code, + code_type=code_type, + roles=list(assigned), + group_ids=group_ids, + capacity=capacity, + expiry_date=expiry_date, + status=status, + created_by=user_id, + db_session=session, + ) return { "invitation_id": invitation_id, "invitation_code": invitation_code, "code_type": code_type, + "roles": list(assigned), "group_ids": group_ids, "capacity": capacity, "expiry_date": expiry_date, - "status": normalized_info.get("status", "IN_USE") if normalized_info else "IN_USE" + "status": status, } -def update_invitation_code( - invitation_id: int, - updates: Dict[str, Any], - user_id: str -) -> bool: +def update_invitation_code(invitation_id: int, updates: Dict[str, Any], user_id: str) -> bool: """ Update invitation code information. @@ -170,34 +157,40 @@ def update_invitation_code( if not user_info: raise UnauthorizedError(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") + user_role = roles_from_record(user_info) invitation_info = query_invitation_by_id(invitation_id) if not invitation_info: raise NotFoundException(f"Invitation {invitation_id} not found") + if not has_any_role(user_role, {"SU"}) and invitation_info["tenant_id"] != user_info["tenant_id"]: + raise ForbiddenError("Cannot manage another tenant's invitations") 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( - invitation_id=invitation_id, - updates=updates, - updated_by=user_id - ) - - if success: - logger.info( - f"Updated invitation code {invitation_id} by user {user_id}") - # Automatically update status after successful update - update_invitation_code_status(invitation_id) - - return success + if code_type == ASSET_OWNER_INVITE_CODE_TYPE and not has_any_role(user_role, {"SU"}): + raise UnauthorizedError(f"User role {user_role} not authorized to update invitation codes") + elif not has_any_role(user_role, {"SU", "ADMIN"}): + raise UnauthorizedError(f"User role {user_role} not authorized to update invitation codes") + + if "roles" in updates or "code_type" in updates: + raise ValueError("Invitation roles are immutable") + if updates.get("group_ids"): + groups = query_groups(updates["group_ids"]) + groups = [groups] if isinstance(groups, dict) else groups + if {int(g["group_id"]) for g in groups if g.get("tenant_id") == invitation_info["tenant_id"]} != set( + updates["group_ids"] + ): + raise ForbiddenError("Invitation groups must belong to the target tenant") + with _managed_invitation(invitation_id, user_id) as (session, row): + success = modify_invitation(invitation_id, updates, user_id, db_session=session) + session.expire(row) + if row.status != "DISABLE": + if row.expiry_date and row.expiry_date.date() < datetime.utcnow().date(): + row.status = "EXPIRE" + elif invitation_usage(session, row.invitation_id) >= row.capacity: + row.status = "RUN_OUT" + else: + row.status = "IN_USE" + return success def delete_invitation_code(invitation_id: int, user_id: str) -> bool: @@ -220,29 +213,22 @@ def delete_invitation_code(invitation_id: int, user_id: str) -> bool: if not user_info: raise UnauthorizedError(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") + user_role = roles_from_record(user_info) invitation_info = query_invitation_by_id(invitation_id) if not invitation_info: raise NotFoundException(f"Invitation {invitation_id} not found") + if not has_any_role(user_role, {"SU"}) and invitation_info["tenant_id"] != user_info["tenant_id"]: + raise ForbiddenError("Cannot manage another tenant's invitations") 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 delete invitation codes") - elif user_role not in ["SU", "ADMIN"]: - raise UnauthorizedError( - f"User role {user_role} not authorized to delete invitation codes") + if code_type == ASSET_OWNER_INVITE_CODE_TYPE and not has_any_role(user_role, {"SU"}): + raise UnauthorizedError(f"User role {user_role} not authorized to delete invitation codes") + elif not has_any_role(user_role, {"SU", "ADMIN"}): + raise UnauthorizedError(f"User role {user_role} not authorized to delete invitation codes") - # Delete invitation code - success = remove_invitation( - invitation_id=invitation_id, updated_by=user_id) - - if success: - logger.info( - f"Deleted invitation code {invitation_id} by user {user_id}") - - return success + with _managed_invitation(invitation_id, user_id) as (session, row): + return remove_invitation(invitation_id, user_id, db_session=session) def _normalize_invitation_data(invitation_data: Dict[str, Any]) -> Dict[str, Any]: @@ -257,9 +243,9 @@ def _normalize_invitation_data(invitation_data: Dict[str, Any]) -> Dict[str, Any """ if not invitation_data: return invitation_data - - # Create a copy to avoid modifying the original - normalized = invitation_data.copy() + normalized = dict(invitation_data) + if "roles" in normalized or "code_type" in normalized: + normalized["roles"] = list(invitation_roles(normalized)) # Convert datetime objects to ISO format strings for key, value in normalized.items(): @@ -311,6 +297,7 @@ def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any] """ if not invitation_data: return invitation_data + invitation_data = dict(invitation_data) invitation_id = invitation_data.get("invitation_id") if not invitation_id: @@ -324,6 +311,8 @@ def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any] usage_count = count_invitation_usage(invitation_id) current_status = invitation_data.get("status", "IN_USE") + if current_status == "DISABLE": + return invitation_data new_status = current_status # Check expiry @@ -332,18 +321,16 @@ def _calculate_current_status(invitation_data: Dict[str, Any]) -> Dict[str, Any] if isinstance(expiry_date, datetime): expiry_datetime = expiry_date else: - expiry_datetime = datetime.fromisoformat( - str(expiry_date).replace('Z', '+00:00')) + expiry_datetime = datetime.fromisoformat(str(expiry_date).replace("Z", "+00:00")) # Treat same date as not expired - only expire when current date is strictly after expiry date 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: - new_status = "RUN_OUT" + if new_status != "EXPIRE": + new_status = "RUN_OUT" if usage_count >= capacity else "IN_USE" # Update status in the data dict invitation_data["status"] = new_status @@ -364,62 +351,61 @@ def check_invitation_available(invitation_code: str) -> bool: if not invitation: return False + if invitation.get("status") != "IN_USE": + return False + invitation = _calculate_current_status(invitation) # Check status if invitation.get("status") != "IN_USE": return False - # Check capacity - usage_count = count_invitation_usage(invitation["invitation_id"]) - return usage_count < invitation["capacity"] - - -def use_invitation_code( - invitation_code: str, - user_id: str -) -> 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 - - Returns: - Dict[str, Any]: Invitation usage result including code_type - - Raises: - NotFoundException: When invitation code not found or not available - """ - # Check if invitation is available - if not check_invitation_available(invitation_code): - raise NotFoundException( - f"Invitation code {invitation_code} is not available") - - # Get invitation code details - invitation_info = query_invitation_by_code(invitation_code) - if not invitation_info: - raise NotFoundException(f"Invitation code {invitation_code} not found") - - # Create usage record - record_id = add_invitation_record( - invitation_id=invitation_info["invitation_id"], - user_id=user_id, - created_by=user_id - ) - - # Update invitation status - update_invitation_code_status(invitation_info["invitation_id"]) - - logger.info(f"User {user_id} used invitation code {invitation_code}") - - return { - "invitation_record_id": record_id, - "invitation_code": invitation_code, - "user_id": user_id, - "invitation_id": invitation_info["invitation_id"], - "code_type": invitation_info["code_type"], - "group_ids": invitation_info["group_ids"] - } + return True + + +def use_invitation_code(invitation_code: str, user_id: str) -> Dict[str, Any]: + """Record an existing matching membership once, without granting extra roles.""" + code = invitation_code.strip().upper() + snapshot = query_invitation_by_code(code) + if not snapshot: + raise NotFoundException("Invitation is unavailable") + with get_db_session() as session: + row = locked_invitation(session, code, snapshot) + member = locked_members(session, [user_id]).get(user_id) + if ( + not member + or member["tenant_id"] != row.tenant_id + or roles_from_record(member) != invitation_roles({"roles": row.roles}) + ): + raise ForbiddenError("Invitation usage requires its matching registered membership") + record = session.execute( + select(TenantInvitationRecord).where( + TenantInvitationRecord.invitation_id == row.invitation_id, + TenantInvitationRecord.user_id == user_id, + TenantInvitationRecord.delete_flag == "N", + ) + ).scalar_one_or_none() + if record is None: + if row.status != "IN_USE" or invitation_usage(session, row.invitation_id) >= row.capacity: + raise IncorrectInviteCodeException("Invitation capacity is exhausted") + record = TenantInvitationRecord( + invitation_id=row.invitation_id, + user_id=user_id, + created_by=user_id, + updated_by=user_id, + delete_flag="N", + ) + session.add(record) + session.flush() + if invitation_usage(session, row.invitation_id) >= row.capacity: + row.status = "RUN_OUT" + return { + "invitation_record_id": record.invitation_record_id, + "invitation_code": code, + "user_id": user_id, + "invitation_id": row.invitation_id, + "code_type": row.code_type, + "roles": list(row.roles), + "group_ids": convert_string_to_list(row.group_ids) if row.group_ids else [], + } def update_invitation_code_status(invitation_id: int) -> bool: @@ -443,6 +429,8 @@ def update_invitation_code_status(invitation_id: int) -> bool: usage_count = count_invitation_usage(invitation_id) current_status = invitation_info["status"] + if current_status == "DISABLE": + return False # Determine new status based on current conditions # Priority: EXPIRE > RUN_OUT > IN_USE @@ -454,14 +442,12 @@ def update_invitation_code_status(invitation_id: int) -> bool: if isinstance(expiry_date, datetime): expiry_datetime = expiry_date else: - expiry_datetime = datetime.fromisoformat( - str(expiry_date).replace('Z', '+00:00')) + expiry_datetime = datetime.fromisoformat(str(expiry_date).replace("Z", "+00:00")) # Treat same date as not expired - only expire when current date is strictly after expiry date 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: @@ -469,13 +455,8 @@ def update_invitation_code_status(invitation_id: int) -> bool: # Update status if changed if new_status != current_status: - modify_invitation( - invitation_id=invitation_id, - updates={"status": new_status}, - updated_by="system" - ) - logger.info( - f"Updated invitation code {invitation_id} status to {new_status}") + modify_invitation(invitation_id=invitation_id, updates={"status": new_status}, updated_by="system") + logger.info(f"Updated invitation code {invitation_id} status to {new_status}") return True return False @@ -496,7 +477,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): @@ -504,8 +485,7 @@ 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( @@ -514,7 +494,7 @@ def get_invitations_list( page_size: int, user_id: str, sort_by: Optional[str] = None, - sort_order: Optional[str] = None + sort_order: Optional[str] = None, ) -> Dict[str, Any]: """ Get invitations list with pagination and permission checks. @@ -538,7 +518,9 @@ def get_invitations_list( if not user_info: raise UnauthorizedError(f"User {user_id} not found") - user_role = user_info.get("user_role", "USER") + user_role = roles_from_record(user_info) + + read_only = has_any_role(user_role, {"OPS"}) and not has_any_role(user_role, {"SU", "ADMIN"}) # Permission logic: # - If tenant_id is provided: ADMIN or SU can view that tenant's invitations @@ -546,33 +528,62 @@ def get_invitations_list( 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") + if not has_any_role(user_role, {"SU"}): + raise UnauthorizedError(f"User role {user_role} not authorized to view asset owner invitations") + elif not has_any_role(user_role, {"SU", "ADMIN", "OPS"}): + raise UnauthorizedError(f"User role {user_role} not authorized to view invitation lists") else: # If no tenant_id specified, only SU can view all invitations - if user_role not in ["SU"]: - raise UnauthorizedError( - f"User role {user_role} not authorized to view all tenant invitations") + if not has_any_role(user_role, {"SU"}): + raise UnauthorizedError(f"User role {user_role} not authorized to view all tenant invitations") + + if not has_any_role(user_role, {"SU"}) and tenant_id != user_info["tenant_id"]: + raise ForbiddenError("Cannot list another tenant's invitations") # Query invitations with pagination result = query_invitations_with_pagination( - tenant_id=tenant_id, - page=page, - page_size=page_size, - sort_by=sort_by, - sort_order=sort_order + tenant_id=tenant_id, page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order ) logger.info( - f"User {user_id} queried invitations list (tenant: {tenant_id or 'all'}, page: {page}, size: {page_size})") + f"User {user_id} queried invitations list (tenant: {tenant_id or 'all'}, page: {page}, size: {page_size})" + ) # 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"]] + if read_only: + for item in result["items"]: + item["invitation_code"] = "" return result + + +def _check_invitation_manager(actor, tenant_id, special=False): + if not actor: + raise ForbiddenError("Acting user is unavailable") + roles = roles_from_record(actor) + if has_any_role(roles, {"SU"}): + return + if special or not has_any_role(roles, {"ADMIN"}) or actor["tenant_id"] != tenant_id: + raise ForbiddenError("Not authorized to manage these invitations") + + +@contextmanager +def _managed_invitation(invitation_id, user_id): + snapshot = query_invitation_by_id(invitation_id) + actor = get_user_tenant_by_user_id(user_id) + if not snapshot or not actor: + raise NotFoundException("Invitation or acting user is unavailable") + with get_db_session() as session: + lock_tenants(session, [snapshot["tenant_id"], actor["tenant_id"]]) + actor = locked_members(session, [user_id]).get(user_id) + row = session.execute( + select(TenantInvitationCode) + .where(TenantInvitationCode.invitation_id == invitation_id, TenantInvitationCode.delete_flag == "N") + .with_for_update() + ).scalar_one_or_none() + if row is None or row.tenant_id != snapshot["tenant_id"]: + raise NotFoundException("Invitation is unavailable") + _check_invitation_manager(actor, row.tenant_id, row.code_type == ASSET_OWNER_INVITE_CODE_TYPE) + yield session, row diff --git a/backend/management/services/permission/identity/oauth.py b/backend/management/services/permission/identity/oauth.py index 5ee89fd8c0..9295bb51e6 100644 --- a/backend/management/services/permission/identity/oauth.py +++ b/backend/management/services/permission/identity/oauth.py @@ -1,6 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -from contracts.constants.constants import OAUTH_PENDING_EXPIRE_SECONDS, OAUTH_PENDING_PURPOSE -from contracts.authentication import PendingOAuthIdentity + import json import logging import os @@ -9,19 +8,77 @@ import time import urllib.request from typing import Any, Dict, List, Optional -from urllib.parse import urlencode, quote +from urllib.parse import quote, urlencode import jwt -from pydantic import EmailStr, TypeAdapter, ValidationError as PydanticValidationError +from pydantic import EmailStr, TypeAdapter +from pydantic import ValidationError as PydanticValidationError -from contracts.constants.constants import ASSET_OWNER_INVITE_CODE_TYPE, ASSET_OWNER_ROLE, ASSET_OWNER_TENANT_ID, DEFAULT_TENANT_ID -from contracts.constants.environment import OAUTH_CALLBACK_BASE_URL, OAUTH_LOGIN_MODE, OAUTH_SSL_VERIFY, OAUTH_CA_BUNDLE, JWT_SECRET, JWT_EXPIRY_SECONDS +from contracts.authentication import PendingOAuthIdentity +from contracts.constants.constants import ( + ASSET_OWNER_INVITE_CODE_TYPE, + ASSET_OWNER_ROLE, + ASSET_OWNER_TENANT_ID, + DEFAULT_TENANT_ID, + OAUTH_PENDING_EXPIRE_SECONDS, + OAUTH_PENDING_PURPOSE, +) from contracts.constants.environment import GET_OAUTH_SETTING as get_oauth_setting -from contracts.errors.exceptions import OAuthLinkError, OAuthProviderError, TenantResourceLimitError +from contracts.constants.environment import ( + JWT_EXPIRY_SECONDS, + JWT_SECRET, + OAUTH_CA_BUNDLE, + OAUTH_CALLBACK_BASE_URL, + OAUTH_LOGIN_MODE, + OAUTH_SSL_VERIFY, +) +from contracts.errors.exceptions import ( + OAuthLinkError, + OAuthProviderError, + TenantResourceLimitError, +) +from contracts.roles import ( + has_any_role, + invitation_roles, + primary_role, + roles_from_record, +) +from gateway.authentication.auth import calculate_expires_at, generate_session_jwt +from infrastructure.security.passwords import hash_password +from management.db.client import get_db_session +from management.db.identity.account import create_account, get_account_by_email +from management.db.identity.oauth_account import ( + delete_oauth_account, + get_oauth_account_by_provider, + get_soft_deleted_oauth_account, + insert_oauth_account, + list_oauth_accounts_by_user_id, + reactivate_oauth_account, + update_oauth_account_tokens, +) +from management.db.identity.user_tenant import ( + get_user_tenant_by_user_id, + insert_user_tenant, +) from management.services.permission.asset_owner import require_asset_owner_enabled -from management.utils.oauth_providers import get_all_provider_definitions, get_provider_definition, is_provider_enabled -from management.db.identity.oauth_account import delete_oauth_account, get_oauth_account_by_provider, get_soft_deleted_oauth_account, insert_oauth_account, list_oauth_accounts_by_user_id, reactivate_oauth_account, update_oauth_account_tokens -from management.db.identity.user_tenant import get_user_tenant_by_user_id, insert_user_tenant +from management.services.permission.identity.authentication import ( + validate_password_strength, +) +from management.services.permission.identity.groups import add_user_to_groups +from management.services.permission.identity.invitations import ( + check_invitation_available, + get_invitation_by_code, + use_invitation_code, +) +from management.services.permission.identity.registration import ( + register_invited_account, +) +from management.services.tool.configuration import init_tool_list_for_tenant +from management.utils.oauth_providers import ( + get_all_provider_definitions, + get_provider_definition, + is_provider_enabled, +) logger = logging.getLogger(__name__) @@ -42,8 +99,6 @@ def _build_ssl_context() -> ssl.SSLContext: _SSL_CTX = _build_ssl_context() - - def _resolve_field(data: dict, field_path: str) -> Any: if "." not in field_path: return data.get(field_path) @@ -288,8 +343,6 @@ def generate_pending_oauth_token( return jwt.encode(payload, JWT_SECRET, algorithm="HS256") - - def get_pending_oauth_info(payload: PendingOAuthIdentity) -> Dict[str, Any]: provider_email = payload.get("provider_email") or "" return { @@ -312,7 +365,6 @@ def _validate_email(email: Optional[str]) -> str: def find_account_user_id_by_email(email: Optional[str]) -> Optional[str]: if not email: return None - from management.db.identity.account import get_account_by_email account = get_account_by_email(email) if email else None return account["user_id"] if account else None @@ -335,14 +387,6 @@ async def complete_pending_oauth_account( invite_code: str, email: Optional[str] = None, ) -> Dict[str, Any]: - from management.services.permission.identity.groups import add_user_to_groups - from management.services.permission.identity.invitations import ( - check_invitation_available, - get_invitation_by_code, - use_invitation_code, - ) - from management.services.tool.configuration import init_tool_list_for_tenant - from gateway.authentication.auth import calculate_expires_at, generate_session_jwt pending = pending_identity provider = pending["provider"] @@ -366,10 +410,6 @@ async def complete_pending_oauth_account( if not invitation_info: raise OAuthLinkError("Invitation code is invalid or unavailable") - from management.db.identity.account import create_account - from infrastructure.security.passwords import hash_password - from management.db.client import get_db_session - from management.services.permission.identity.authentication import validate_password_strength if not validate_password_strength(password): raise OAuthLinkError("Password must be at least 8 characters with uppercase, lowercase, and digit") @@ -379,23 +419,12 @@ async def complete_pending_oauth_account( tenant_id = invitation_info["tenant_id"] if invitation_info.get("code_type") == ASSET_OWNER_INVITE_CODE_TYPE: tenant_id = ASSET_OWNER_TENANT_ID - user_role = _role_from_invitation_type(invitation_info.get("code_type", "USER_INVITE")) - is_asset_owner_registration = user_role == ASSET_OWNER_ROLE - - with get_db_session() as session: - account = create_account(final_email, hash_password(password), db_session=session) - user_id = account["user_id"] - insert_user_tenant(user_id=user_id, tenant_id=tenant_id, user_role=user_role, - user_email=final_email, db_session=session) - - invitation_result = use_invitation_code(normalized_invite_code, user_id) - group_ids = invitation_result.get("group_ids", []) - if isinstance(group_ids, str): - from management.utils.serialization import convert_string_to_list + assigned_roles = invitation_roles(invitation_info) + user_role = primary_role(assigned_roles) + is_asset_owner_registration = has_any_role(user_role, {ASSET_OWNER_ROLE}) - group_ids = convert_string_to_list(group_ids) - if group_ids and not is_asset_owner_registration: - add_user_to_groups(user_id, group_ids, user_id) + account, member = register_invited_account(final_email, hash_password(password), normalized_invite_code) + user_id = account["user_id"] if not is_asset_owner_registration: await init_tool_list_for_tenant(tenant_id, user_id) @@ -420,6 +449,7 @@ async def complete_pending_oauth_account( "id": str(user_id), "email": final_email, "role": user_role, + "roles": list(assigned_roles), }, "session": { "access_token": jwt_token, diff --git a/backend/management/services/permission/identity/registration.py b/backend/management/services/permission/identity/registration.py new file mode 100644 index 0000000000..80e4e9e31b --- /dev/null +++ b/backend/management/services/permission/identity/registration.py @@ -0,0 +1,91 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Invitation-backed local identity creation with one membership transaction.""" + +from datetime import datetime + +from sqlalchemy import func, select + +from contracts.errors.exceptions import IncorrectInviteCodeException +from contracts.roles import invitation_roles, primary_role +from management.db.client import get_db_session +from management.db.identity.account import create_account +from management.db.identity.group import add_user_to_group +from management.db.identity.invitation import query_invitation_by_code +from management.db.identity.roles import lock_tenants +from management.db.identity.user_tenant import insert_user_tenant +from management.db.models import ( + TenantGroupInfo, + TenantInvitationCode, + TenantInvitationRecord, +) +from management.utils.orm import as_dict +from management.utils.serialization import convert_string_to_list + + +def locked_invitation(session, code, snapshot): + """Do not recheck the issuer: valid invitations survive issuer revocation.""" + lock_tenants(session, [snapshot["tenant_id"]]) + row = session.execute( + select(TenantInvitationCode) + .where(TenantInvitationCode.invitation_code == code, TenantInvitationCode.delete_flag == "N") + .with_for_update() + ).scalar_one_or_none() + if row is None or row.tenant_id != snapshot["tenant_id"]: + raise IncorrectInviteCodeException("Invitation is unavailable") + if row.status not in {"IN_USE", "RUN_OUT"} or ( + row.expiry_date and row.expiry_date.date() < datetime.utcnow().date() + ): + raise IncorrectInviteCodeException("Invitation is unavailable") + return row + + +def invitation_usage(session, invitation_id): + return session.scalar( + select(func.count()) + .select_from(TenantInvitationRecord) + .where(TenantInvitationRecord.invitation_id == invitation_id, TenantInvitationRecord.delete_flag == "N") + ) + + +def register_invited_account(email, encoded_password, code): + code = code.strip().upper() + snapshot = query_invitation_by_code(code) + if not snapshot: + raise IncorrectInviteCodeException("Invitation is unavailable") + with get_db_session() as session: + invite = locked_invitation(session, code, snapshot) + if invite.status != "IN_USE" or invitation_usage(session, invite.invitation_id) >= invite.capacity: + raise IncorrectInviteCodeException("Invitation capacity is exhausted") + roles = invitation_roles(as_dict(invite)) + account = create_account(email, encoded_password, db_session=session) + user_id = account["user_id"] + member = insert_user_tenant( + user_id, invite.tenant_id, primary_role(roles), account["email"], db_session=session, roles=roles + ) + groups = ( + convert_string_to_list(invite.group_ids) if isinstance(invite.group_ids, str) else invite.group_ids or [] + ) + for group_id in groups: + group = session.execute( + select(TenantGroupInfo).where( + TenantGroupInfo.group_id == int(group_id), + TenantGroupInfo.tenant_id == invite.tenant_id, + TenantGroupInfo.delete_flag == "N", + ) + ).scalar_one_or_none() + if group is None: + raise IncorrectInviteCodeException("Invitation group is no longer available") + add_user_to_group(group.group_id, user_id, user_id, db_session=session) + session.add( + TenantInvitationRecord( + invitation_id=invite.invitation_id, + user_id=user_id, + created_by=user_id, + updated_by=user_id, + delete_flag="N", + ) + ) + session.flush() + if invitation_usage(session, invite.invitation_id) >= invite.capacity: + invite.status = "RUN_OUT" + return account, member diff --git a/backend/management/services/permission/identity/roles.py b/backend/management/services/permission/identity/roles.py new file mode 100644 index 0000000000..a5b6bdfd06 --- /dev/null +++ b/backend/management/services/permission/identity/roles.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Role options and application audit around atomic membership writes.""" + +import logging + +from contracts.errors.exceptions import NotFoundException +from contracts.roles import ORDINARY_ROLES, roles_from_record +from management.db.client import get_db_session +from management.db.identity.membership import ( + api_only_account, + get_user_tenant_by_user_id, +) +from management.db.identity.roles import replace_member_roles +from permissions.role_assignment import check_managed_target + +logger = logging.getLogger(__name__) + + +def assignable_roles(actor_id, user_id): + actor, target = get_user_tenant_by_user_id(actor_id), get_user_tenant_by_user_id(user_id) + if not actor or not target: + raise NotFoundException("User not found") + check_managed_target(actor, target) + locked = ["ADMIN"] if actor_id == user_id and "ADMIN" in roles_from_record(target) else [] + with get_db_session() as session: + grantable = frozenset({"DEV", "USER"}) if api_only_account(session, user_id) else ORDINARY_ROLES + return { + "grantable_roles": sorted(grantable), + "revocable_roles": sorted(grantable - set(locked)), + "locked_roles": locked, + "reasons": {"ADMIN": "Cannot remove your own ADMIN role"} if locked else {}, + } + + +def replace_roles(actor_id, user_id, values, *, legacy=False): + target, old, requested = replace_member_roles(actor_id, user_id, values, legacy=legacy) + if old != requested: + logger.info( + "Role assignment actor=%s target=%s tenant=%s before=%s after=%s", + actor_id, + user_id, + target["tenant_id"], + old, + requested, + ) + return get_user_tenant_by_user_id(user_id) diff --git a/backend/management/services/permission/identity/tenants.py b/backend/management/services/permission/identity/tenants.py index 36527c4fc4..bcba4273ee 100644 --- a/backend/management/services/permission/identity/tenants.py +++ b/backend/management/services/permission/identity/tenants.py @@ -3,6 +3,7 @@ """ Tenant service for managing tenant operations """ + import asyncio import logging import os @@ -10,21 +11,60 @@ import uuid from typing import Any, Dict, List, Optional -from management.db.skill import repository as skill_db -from management.db.configuration.tenant import create_tenant_with_default_group, get_single_config_info, insert_config, update_config_by_tenant_config_id, get_all_tenant_ids, delete_config_by_tenant_config_id, get_all_configs_by_tenant_id -from management.db.identity.user_tenant import get_users_by_tenant_id, soft_delete_users_by_tenant_id -from management.services.permission.identity.users import delete_user_and_cleanup +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + DEFAULT_GROUP_ID, + DEFAULT_TENANT_ID, + TENANT_ID, + TENANT_NAME, +) +from contracts.constants.environment import CONTAINER_SKILLS_PATH +from contracts.errors.exceptions import ( + ForbiddenError, + NotFoundException, + TenantResourceLimitError, + UserRegistrationException, + ValidationError, +) +from contracts.roles import has_any_role +from management.db.agent.repository import ( + delete_agent_by_id, + delete_agent_relationship, + query_all_agent_info_by_tenant_id, +) +from management.db.configuration.tenant import ( + create_tenant_with_default_group, + delete_config_by_tenant_config_id, + get_all_configs_by_tenant_id, + get_all_tenant_ids, + get_single_config_info, + insert_config, + update_config_by_tenant_config_id, +) from management.db.identity.group import query_groups_by_tenant, remove_group -from management.db.model.repository import get_model_records, delete_model_record -from management.db.knowledge_base.repository import get_knowledge_info_by_tenant_id, delete_knowledge_record -from management.db.agent.repository import query_all_agent_info_by_tenant_id, delete_agent_by_id, delete_agent_relationship -from management.db.mcp.remote import get_mcp_records_by_tenant, delete_mcp_record_by_name_and_url -from management.db.identity.invitation import query_invitations_by_tenant, remove_invitation +from management.db.identity.invitation import ( + query_invitations_by_tenant, + remove_invitation, +) +from management.db.identity.user_tenant import ( + get_users_by_tenant_id, + soft_delete_users_by_tenant_id, +) +from management.db.knowledge_base.repository import ( + delete_knowledge_record, + get_knowledge_info_by_tenant_id, +) +from management.db.mcp.remote import ( + delete_mcp_record_by_name_and_url, + get_mcp_records_by_tenant, +) +from management.db.model.repository import delete_model_record, get_model_records +from management.db.skill import repository as skill_db from management.db.tool.repository import delete_tools_by_agent_id -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, DEFAULT_TENANT_ID -from contracts.constants.environment import CONTAINER_SKILLS_PATH, IS_SPEED_MODE -from contracts.constants.constants import DEFAULT_GROUP_ID, TENANT_ID, TENANT_NAME -from contracts.errors.exceptions import ForbiddenError, NotFoundException, TenantResourceLimitError, UserRegistrationException, ValidationError +from management.services.permission.identity import users as user_service +from management.services.skill.transfer import ( + install_skills_for_tenant as install_by_ids, +) from management.services.skill.transfer import install_skills_from_zip_for_tenant logger = logging.getLogger(__name__) @@ -36,7 +76,7 @@ def _is_displayable_tenant_id(tenant_id: Optional[str]) -> bool: return normalized_tenant_id not in {"", DEFAULT_TENANT_ID, ASSET_OWNER_TENANT_ID} -def get_tenant_info(tenant_id: str) -> Dict[str, Any]: +def get_tenant_info(tenant_id: str, *, initialize_name: bool = True) -> Dict[str, Any]: """ Get tenant information by tenant ID @@ -53,7 +93,7 @@ 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: + if not name_config and initialize_name: logger.warning( f"The name of tenant {tenant_id} not found, creating default config.") # Auto-create TENANT_NAME config with default name @@ -79,10 +119,12 @@ def get_tenant_info_for_user( requester_role: str, ) -> Dict[str, Any]: """Get tenant information after enforcing tenant-scoped access.""" - role = (requester_role or "").upper() - is_speed_admin = IS_SPEED_MODE and role == "SPEED" - if role != "SU" and not is_speed_admin and tenant_id != requester_tenant_id: + role = requester_role + + if not has_any_role(role, {'SU'}) and tenant_id != requester_tenant_id: raise ForbiddenError("Not authorized to access this tenant") + if has_any_role(role, {"OPS"}) and not has_any_role(role, {"ADMIN", "SU"}): + return get_tenant_info(tenant_id, initialize_name=False) return get_tenant_info(tenant_id) @@ -202,8 +244,8 @@ def get_tenants_paginated_for_user( requester_role: str, ) -> Dict[str, Any]: """List tenants for platform administrators only.""" - role = (requester_role or "").upper() - if role != "SU" and not (IS_SPEED_MODE and role == "SPEED"): + role = requester_role + if not has_any_role(role, {'SU'}): raise ForbiddenError("Only super administrators can list tenants") return get_tenants_paginated(page=page, page_size=page_size) @@ -263,7 +305,6 @@ def create_tenant( f"Failed to install skills from ZIP for tenant {tenant_id}: {e}") elif skill_ids: try: - from management.services.skill.transfer import install_skills_for_tenant as install_by_ids installed_by_ids = install_by_ids( skill_ids=skill_ids, tenant_id=tenant_id, @@ -445,11 +486,12 @@ async def delete_tenant(tenant_id: str, deleted_by: Optional[str] = None) -> boo users = users_result.get("users", []) if users: + soft_delete_users_by_tenant_id(tenant_id, deleted_by) async def delete_single_user(user: Dict[str, Any]) -> None: user_id = user.get("user_id") if user_id: try: - await delete_user_and_cleanup(user_id, tenant_id) + await user_service.delete_user_and_cleanup(user_id, tenant_id, actor_id=deleted_by, membership_removed=True) logger.info( f"Deactivated user {user_id} for tenant {tenant_id}") except Exception as e: diff --git a/backend/management/services/permission/identity/users.py b/backend/management/services/permission/identity/users.py index 34263cf249..b40b1bdda8 100644 --- a/backend/management/services/permission/identity/users.py +++ b/backend/management/services/permission/identity/users.py @@ -3,19 +3,35 @@ """ User service layer - handles user-related business logic """ + import logging from typing import Any, Dict, List, Optional -from management.db.identity.user_tenant import get_users_by_tenant_id, update_user_tenant_role, get_user_tenant_by_user_id, soft_delete_user_tenant_by_user_id -from management.db.identity.group import remove_user_from_all_groups, query_groups_by_users -from management.db.memory.config import soft_delete_all_configs_by_user_id -from management.db.conversation.repository import soft_delete_all_conversations_by_user -from management.db.knowledge_base.repository import get_private_knowledge_info_by_creator -from management.db.identity.oauth_account import soft_delete_all_oauth_accounts_by_user_id -from contracts.constants.environment import IS_SPEED_MODE from contracts.errors.exceptions import ForbiddenError, NotFoundException +from contracts.roles import has_any_role, primary_role, roles_from_record +from management.db.conversation.repository import soft_delete_all_conversations_by_user from management.db.identity.account import delete_account +from management.db.identity.group import ( + query_groups_by_users, + remove_user_from_all_groups, +) +from management.db.identity.oauth_account import ( + soft_delete_all_oauth_accounts_by_user_id, +) +from management.db.identity.token import soft_delete_tokens_by_user +from management.db.identity.user_tenant import ( + get_user_tenant_by_user_id, + get_users_by_tenant_id, + soft_delete_user_tenant_by_user_id, +) +from management.db.knowledge_base.repository import ( + get_private_knowledge_info_by_creator, +) +from management.db.memory.config import soft_delete_all_configs_by_user_id +from management.services.knowledge_base import management as knowledge_management +from management.services.knowledge_base.common import get_vector_db_core from management.services.permission.identity.display import enrich_cas_usernames +from management.services.permission.identity.roles import replace_roles logger = logging.getLogger(__name__) @@ -63,6 +79,7 @@ def get_users(tenant_id: str, page: Optional[int] = 1, page_size: Optional[int] "id": r["user_id"], "username": r.get("user_email"), "role": r["user_role"], + "roles": list(roles_from_record(r)), "tenant_id": r["tenant_id"], "group_names": user_group_map.get(r["user_id"], []), } @@ -101,11 +118,11 @@ def get_users_for_requester( search_cas_username: bool = False, ) -> Dict[str, Any]: """List users after enforcing role and tenant boundaries.""" - role = (requester_role or "").upper() - is_speed_admin = IS_SPEED_MODE and role == "SPEED" - if role == "SU" or is_speed_admin: + role = requester_role + + if has_any_role(role, {'SU'}): pass - elif role == "ADMIN" and tenant_id == requester_tenant_id: + elif has_any_role(role, {'ADMIN', 'OPS'}) and tenant_id == requester_tenant_id: pass else: raise ForbiddenError("Not authorized to list users for this tenant") @@ -122,52 +139,19 @@ def get_users_for_requester( async def update_user(user_id: str, update_data: Dict[str, Any], updated_by: str) -> Dict[str, Any]: - """ - Update user information - - Args: - user_id (str): User ID to update - update_data (Dict[str, Any]): Update data containing role - updated_by (str): ID of the user making the update - - Returns: - Dict[str, Any]: Updated user information - - Raises: - ValueError: When user not found or invalid data - """ - try: - # Validate role if provided - if "role" in update_data: - valid_roles = ["ADMIN", "DEV", "USER"] - if update_data["role"] not in valid_roles: - raise ValueError(f"Invalid role. Must be one of: {', '.join(valid_roles)}") - - # Update user role in database - success = update_user_tenant_role(user_id, update_data.get("role"), updated_by) - - if not success: - raise ValueError(f"User {user_id} not found or update failed") - - # Get updated user information - user_tenant_data = get_user_tenant_by_user_id(user_id) - - if not user_tenant_data: - raise ValueError(f"User {user_id} not found after update") - - user_info = { - "id": user_tenant_data["user_id"], - "username": user_tenant_data.get("user_email"), - "role": user_tenant_data["user_role"] - } - - enrich_cas_usernames([user_info], {"cas_username": "id"}) - logger.info(f"Updated user {user_id} role to {update_data.get('role')} by user {updated_by}") - return user_info - - except Exception as exc: - logger.error(f"Failed to update user {user_id}: {str(exc)}") - raise + """Replace roles atomically; preserve compatibility fields in the response.""" + values = update_data.get("roles") + legacy = values is None + if legacy: + values = update_data.get("role") + if values is None: + raise ValueError("Role update is required") + member = replace_roles(updated_by, user_id, values, legacy=legacy) + roles = roles_from_record(member) + result = {"id": user_id, "username": member.get("user_email"), + "role": primary_role(roles, member.get("user_role")), "roles": list(roles)} + enrich_cas_usernames([result], {"cas_username": "id"}) + return result async def update_user_for_requester( @@ -183,14 +167,14 @@ async def update_user_for_requester( if not target_user: raise NotFoundException(f"User {user_id} not found") - role = (requester_role or "").upper() - target_role = str(target_user.get("user_role") or "").upper() + role = requester_role + target_role = roles_from_record(target_user) target_tenant_id = target_user.get("tenant_id") - is_speed_admin = IS_SPEED_MODE and role == "SPEED" - if role == "SU" or is_speed_admin: + + if has_any_role(role, {'SU'}): pass - elif role == "ADMIN" and target_tenant_id == requester_tenant_id and target_role != "SU": + elif has_any_role(role, {'ADMIN'}) and target_tenant_id == requester_tenant_id and not has_any_role(target_role, {'SU'}): pass else: raise ForbiddenError("Not authorized to update this user") @@ -204,10 +188,6 @@ async def _delete_private_knowledge_bases(user_id: str, tenant_id: str) -> Optio if not private_kbs: return None - from management.services.knowledge_base.service import ( - ElasticSearchService, - get_vector_db_core, - ) vdb_core = get_vector_db_core() succeeded = 0 @@ -224,7 +204,7 @@ async def _delete_private_knowledge_bases(user_id: str, tenant_id: str) -> Optio ) continue try: - await ElasticSearchService.full_delete_knowledge_base( + await knowledge_management.KnowledgeBaseManagementService.full_delete_knowledge_base( index_name, vdb_core, user_id ) succeeded += 1 @@ -246,7 +226,7 @@ async def _delete_private_knowledge_bases(user_id: str, tenant_id: str) -> Optio return cleanup_result -async def delete_user_and_cleanup(user_id: str, tenant_id: str) -> None: +async def delete_user_and_cleanup(user_id: str, tenant_id: str, *, actor_id: str = None, membership_removed: bool = False) -> None: """ Permanently delete user account and all related data. @@ -265,14 +245,18 @@ async def delete_user_and_cleanup(user_id: str, tenant_id: str) -> None: logger.debug(f"Start permanently deleting user {user_id} and all related data...") # 1) Core user deletion (soft-delete user-tenant and groups) try: - tenant_deleted = soft_delete_user_tenant_by_user_id(user_id, user_id) + tenant_deleted = membership_removed or soft_delete_user_tenant_by_user_id(user_id, actor_id or user_id) if not tenant_deleted: raise ValueError(f"User {user_id} not found in any tenant") - remove_user_from_all_groups(user_id, user_id) - logger.debug("\tUser tenant relationship and groups deleted.") except Exception as e: logger.error(f"Failed core deletion for user {user_id}: {e}") + raise + + try: + remove_user_from_all_groups(user_id, actor_id or user_id) + except Exception: + logger.exception("Failed group cleanup for user %s", user_id) # 2) Soft-delete memory configs try: @@ -301,7 +285,6 @@ async def delete_user_and_cleanup(user_id: str, tenant_id: str) -> None: # 6) Revoke all API keys before the identity is removed. try: - from management.db.identity.token import soft_delete_tokens_by_user soft_delete_tokens_by_user(user_id, user_id) logger.debug("\tUser API keys revoked.") diff --git a/backend/management/services/repository/agent/common.py b/backend/management/services/repository/agent/common.py index d2cd9e8d87..ae6d47d466 100644 --- a/backend/management/services/repository/agent/common.py +++ b/backend/management/services/repository/agent/common.py @@ -1,9 +1,22 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import logging -from typing import FrozenSet, Tuple -from contracts.constants.constants import STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED +import logging +import uuid +from typing import Any, Dict, FrozenSet, List, Optional, Tuple + +from contracts.constants.constants import ( + STATUS_NOT_SHARED, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + SYSTEM_TENANT_ID, +) +from contracts.errors.exceptions import UnauthorizedError +from contracts.roles import has_any_role, roles_from_record +from management.db.agent.catalog import reset_agent_repository_status +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.services.permission.identity.display import repository_identity_fields logger = logging.getLogger("agent_repository_service") @@ -46,8 +59,6 @@ {STATUS_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED} ) -import uuid - def _repository_icon_url(agent_id: int, version_no: int, image_id: str) -> str: return f"/api/repository/agent/{agent_id}/versions/{version_no}/icon/{image_id}" @@ -72,18 +83,12 @@ def _repository_image_id(icon_url: str, agent_id: int, version_no: int) -> str: return image_id -from typing import Any, Dict - -from contracts.errors.exceptions import UnauthorizedError -from management.db.identity.user_tenant import get_user_tenant_by_user_id - - def _get_user_role(user_id: str) -> str: """Resolve user role from user_tenant_t; default to USER when unset.""" user_tenant = get_user_tenant_by_user_id(user_id) if not user_tenant: return "USER" - return str(user_tenant.get("user_role") or "USER") + return roles_from_record(user_tenant) def _validate_create_listing_permission( @@ -93,9 +98,9 @@ def _validate_create_listing_permission( ) -> None: """Only ADMIN, or DEV who created the agent, may share to marketplace.""" user_role = _get_user_role(user_id) - if user_role == "ADMIN": + if has_any_role(user_role, {'ADMIN'}): return - if user_role == "DEV": + if has_any_role(user_role, {'DEV'}): agent_created_by = agent_info.get("created_by") if agent_created_by is not None and str(agent_created_by) == str(user_id): return @@ -105,15 +110,6 @@ def _validate_create_listing_permission( ) -from typing import Any, Dict, List, Optional - -from contracts.constants.constants import STATUS_PENDING_REVIEW, STATUS_REJECTED -from contracts.constants.constants import SYSTEM_TENANT_ID -from management.db.agent.catalog import reset_agent_repository_status -from management.db.identity.user_tenant import get_user_tenant_by_user_id -from management.services.permission.identity.display import repository_identity_fields - - def _to_summary_item( record: Dict[str, Any], *, diff --git a/backend/management/services/repository/agent/publishing.py b/backend/management/services/repository/agent/publishing.py index 4fcaf58739..7b321f2949 100644 --- a/backend/management/services/repository/agent/publishing.py +++ b/backend/management/services/repository/agent/publishing.py @@ -1,14 +1,28 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + from typing import Any, Dict, Optional -from contracts.constants.constants import STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_REPOSITORY_STATUSES -from contracts.constants.constants import SYSTEM_TENANT_ID -from contracts.errors.exceptions import UnauthorizedError from contracts.agent import AgentRepositorySnapshot -from contracts.constants.constants import EVENT_TYPE_REPOSITORY_REVIEW_PENDING, RESOURCE_TYPE_AGENT_REPOSITORY +from contracts.constants.constants import ( + EVENT_TYPE_REPOSITORY_REVIEW_PENDING, + RESOURCE_TYPE_AGENT_REPOSITORY, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + SYSTEM_TENANT_ID, + VALID_REPOSITORY_STATUSES, +) +from contracts.errors.exceptions import UnauthorizedError +from contracts.roles import has_any_role +from management.db.agent.catalog import ( + get_agent_repository_by_agent_id, + get_agent_repository_by_id, + insert_agent_repository_record, + update_agent_repository_by_id, + update_agent_repository_status_by_id, +) from management.db.agent.repository import search_agent_info_by_agent_id -from management.db.agent.catalog import get_agent_repository_by_agent_id, get_agent_repository_by_id, insert_agent_repository_record, update_agent_repository_by_id, update_agent_repository_status_by_id from management.db.agent.version import search_version_by_version_no from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.services.agent.icon import read_icon_image @@ -30,6 +44,16 @@ _PUBLISHER_RESUBMIT_TRANSITIONS, _PUBLISHER_STATUS_TRANSITIONS, _SU_STATUS_TRANSITIONS, + _count_tools_in_snapshot, + _get_user_role, + _normalize_listing_tags, + _repository_icon_object_name, + _repository_image_id, + _reset_repository_peer_statuses, + _resolve_submitter_email, + _to_summary_item, + _validate_card_fields, + _validate_create_listing_permission, ) @@ -49,19 +73,19 @@ def _validate_repository_status_transition( """ transition = (current_status, new_status) - if user_role == "SU": + if has_any_role(user_role, {'SU'}): if transition not in _SU_STATUS_TRANSITIONS: raise ValueError( f"Invalid status transition from '{current_status}' to '{new_status}'" ) return None - if user_role in ("ADMIN", "DEV"): + if has_any_role(user_role, {'ADMIN','DEV'}): if record.get("publisher_tenant_id") != tenant_id: raise UnauthorizedError("Not authorized to update this repository listing") - if user_role == "DEV" and record.get("publisher_user_id") != user_id: + if has_any_role(user_role, {'DEV'}) and not has_any_role(user_role, {'ADMIN'}) and record.get("publisher_user_id") != user_id: raise UnauthorizedError("Not authorized to update this repository listing") - if user_role == "ADMIN" and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: + if has_any_role(user_role, {'ADMIN'}) and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: raise ValueError( @@ -448,17 +472,3 @@ async def create_agent_repository_listing_impl( created_by=user_id, ) return _to_detail_item(record, is_updated=is_updated) - - -from management.services.repository.agent.common import ( - _count_tools_in_snapshot, - _get_user_role, - _normalize_listing_tags, - _repository_icon_object_name, - _repository_image_id, - _reset_repository_peer_statuses, - _resolve_submitter_email, - _to_summary_item, - _validate_card_fields, - _validate_create_listing_permission, -) diff --git a/backend/management/services/repository/mcp/service.py b/backend/management/services/repository/mcp/service.py index e41f038512..8ffebc9e7d 100644 --- a/backend/management/services/repository/mcp/service.py +++ b/backend/management/services/repository/mcp/service.py @@ -3,30 +3,64 @@ import logging from datetime import datetime from typing import Any, Dict, FrozenSet, List, Tuple -from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES -from contracts.errors.exceptions import MCPConnectionError, McpNameConflictError, McpNotFoundError, McpValidationError, UnauthorizedError -from contracts.constants.constants import STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_MARKET_STATUSES -from contracts.constants.constants import EVENT_TYPE_REPOSITORY_REVIEW_PENDING, RESOURCE_TYPE_MCP_REPOSITORY -from management.db.mcp.market import check_mcp_market_name_exists, create_mcp_market_record, delete_mcp_market_record_by_id, get_mcp_market_record_by_id, get_mcp_market_records, get_mcp_market_tag_stats_by_tenant, increment_mcp_market_download_count, list_mcp_market_records_by_status, list_mcp_market_records_by_tenant_and_user, update_mcp_market_record, update_mcp_market_status -from management.db.mcp.remote import clear_mcp_record_market_id, update_mcp_record_market_id_by_id, update_mcp_record_manage_fields_by_id -from management.db.identity.user_tenant import get_user_tenant_by_user_id + +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + EVENT_TYPE_REPOSITORY_REVIEW_PENDING, + RESOURCE_TYPE_MCP_REPOSITORY, + STATUS_NOT_SHARED, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + VALID_MARKET_STATUSES, +) +from contracts.errors.exceptions import ( + MCPConnectionError, + McpNameConflictError, + McpNotFoundError, + McpValidationError, + UnauthorizedError, +) +from contracts.roles import has_any_role, roles_from_record from management.db.identity.group import query_group_ids_by_user +from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.mcp import remote as remote_mcp_db +from management.db.mcp.market import ( + check_mcp_market_name_exists, + create_mcp_market_record, + delete_mcp_market_record_by_id, + get_mcp_market_record_by_id, + get_mcp_market_records, + get_mcp_market_tag_stats_by_tenant, + increment_mcp_market_download_count, + list_mcp_market_records_by_status, + list_mcp_market_records_by_tenant_and_user, + update_mcp_market_record, + update_mcp_market_status, +) +from management.db.mcp.remote import ( + clear_mcp_record_market_id, + update_mcp_record_manage_fields_by_id, + update_mcp_record_market_id_by_id, +) from management.services.notification.service import ( create_repository_pending_review_notification, create_repository_review_notification, deactivate_notifications, ) from management.services.permission.identity.display import enrich_cas_usernames -from management.utils.serialization import convert_list_to_string, convert_string_to_list +from management.utils.serialization import ( + convert_list_to_string, + convert_string_to_list, +) logger = logging.getLogger("mcp_management_service") def get_mcp_record_by_id_and_tenant(*args, **kwargs): """Load a source MCP record through the database module boundary.""" - from management.db.mcp.remote import get_mcp_record_by_id_and_tenant as load_record - return load_record(*args, **kwargs) + return remote_mcp_db.get_mcp_record_by_id_and_tenant(*args, **kwargs) ADMIN_ROLES = {"ADMIN", "SUPER_ADMIN", "SU"} SUPER_ADMIN_ROLES = {"SUPER_ADMIN", "SU"} @@ -67,10 +101,10 @@ def get_mcp_record_by_id_and_tenant(*args, **kwargs): def _get_mcp_review_admin_scope(user_id: str, tenant_id: str) -> str | None: """Return tenant scope for review queries. None means no scope (SU sees all).""" user_tenant = get_user_tenant_by_user_id(user_id) - user_role = (user_tenant or {}).get("user_role", "").upper() - if user_role not in ADMIN_ROLES: + user_role = roles_from_record(user_tenant or {}) + if not has_any_role(user_role, ADMIN_ROLES): raise UnauthorizedError("Only administrators can review MCP submissions") - if user_role in SUPER_ADMIN_ROLES: + if has_any_role(user_role, SUPER_ADMIN_ROLES): return None return tenant_id @@ -147,7 +181,7 @@ def _to_community_card(row: Dict[str, Any]) -> Dict[str, Any]: def _get_user_role(user_id: str) -> str: user_tenant = get_user_tenant_by_user_id(user_id) or {} - return str(user_tenant.get("user_role", "")).upper() + return roles_from_record(user_tenant) def _validate_market_status_transition( @@ -166,21 +200,21 @@ def _validate_market_status_transition( """ transition = (current_status, new_status) - if user_role == "SU": + if has_any_role(user_role, {'SU'}): if transition not in _SU_TRANSITIONS: raise ValueError( f"Invalid status transition from '{current_status}' to '{new_status}'" ) return None - if user_role in ("ADMIN", "DEV"): + if has_any_role(user_role, {'ADMIN','DEV'}): if record.get("tenant_id") != tenant_id: raise UnauthorizedError("Not authorized to update this marketplace listing") - if user_role == "DEV" and record.get("user_id") != user_id: + if has_any_role(user_role, {'DEV'}) and not has_any_role(user_role, {'ADMIN'}) and record.get("user_id") != user_id: raise UnauthorizedError("Not authorized to update this marketplace listing") # ADMIN can also act as reviewer - if user_role == "ADMIN" and transition in _ADMIN_REVIEW_TRANSITIONS: + if has_any_role(user_role, {'ADMIN'}) and transition in _ADMIN_REVIEW_TRANSITIONS: return None if transition not in _PUBLISHER_TRANSITIONS: @@ -215,7 +249,7 @@ async def list_community_mcp_services( """List shared (approved) community MCP services scoped to a tenant with permission filtering.""" user_role = _get_user_role(user_id) user_group_ids = None - if user_role not in CAN_EDIT_ALL_USER_ROLES: + if not has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES): try: user_group_ids = list(query_group_ids_by_user(user_id) or []) except Exception as e: @@ -228,7 +262,7 @@ async def list_community_mcp_services( transport_type=transport_type, cursor=cursor, limit=limit, - user_id=user_id if user_role not in CAN_EDIT_ALL_USER_ROLES else None, + user_id=user_id if not has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES) else None, user_group_ids=user_group_ids, ) if page is not None: @@ -690,7 +724,7 @@ async def approve_community_mcp_service( ) -> None: """Approve: pending_review -> shared.""" user_role = _get_user_role(user_id) - if user_role not in ADMIN_ROLES: + if not has_any_role(user_role, ADMIN_ROLES): raise UnauthorizedError("Only administrators can approve MCP submissions") await change_mcp_market_status( tenant_id=tenant_id, @@ -710,7 +744,7 @@ async def reject_community_mcp_service( ) -> None: """Reject: pending_review -> rejected.""" user_role = _get_user_role(user_id) - if user_role not in ADMIN_ROLES: + if not has_any_role(user_role, ADMIN_ROLES): raise UnauthorizedError("Only administrators can reject MCP submissions") await change_mcp_market_status( tenant_id=tenant_id, diff --git a/backend/management/services/skill/permission.py b/backend/management/services/skill/permission.py index 43aaba6f2c..da72209ff4 100644 --- a/backend/management/services/skill/permission.py +++ b/backend/management/services/skill/permission.py @@ -4,8 +4,14 @@ from typing import Any, Dict, Optional -from contracts.constants.constants import CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_PRIVATE, PERMISSION_READ +from contracts.constants.constants import ( + CAN_EDIT_ALL_USER_ROLES, + PERMISSION_EDIT, + PERMISSION_PRIVATE, + PERMISSION_READ, +) from contracts.errors.exceptions import ForbiddenError +from contracts.roles import has_any_role, roles_from_record from management.db.identity.group import query_group_ids_by_user from management.db.identity.user_tenant import get_user_tenant_by_user_id from management.utils.serialization import convert_list_to_string @@ -37,7 +43,7 @@ def can_view_skill( """Return whether a skill is available to the current user.""" if skill.get("source") == "official": return True - if user_role in CAN_EDIT_ALL_USER_ROLES: + if has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES): return True if str(skill.get("created_by")) == str(user_id): return True @@ -54,7 +60,7 @@ def resolve_skill_permission( user_group_ids: set[int], ) -> str: """Resolve whether the current user can edit or only use a visible skill.""" - if user_role in CAN_EDIT_ALL_USER_ROLES: + if has_any_role(user_role, CAN_EDIT_ALL_USER_ROLES): return PERMISSION_EDIT if str(skill.get("created_by")) == str(user_id): return PERMISSION_EDIT @@ -88,7 +94,7 @@ def _get_user_role(user_id: Optional[str]) -> str: user_tenant = get_user_tenant_by_user_id(user_id) if not user_tenant: return "USER" - return str(user_tenant.get("user_role") or "USER") + return roles_from_record(user_tenant) def _can_edit_skill(skill: Dict[str, Any], user_id: Optional[str]) -> bool: @@ -110,7 +116,7 @@ def _can_edit_skill(skill: Dict[str, Any], user_id: Optional[str]) -> bool: def _can_manage_skill_access(skill: Dict[str, Any], user_id: Optional[str]) -> bool: if not user_id: return False - return _get_user_role(user_id) in CAN_EDIT_ALL_USER_ROLES or str( + return has_any_role(_get_user_role(user_id), CAN_EDIT_ALL_USER_ROLES) or str( skill.get("created_by") ) == str(user_id) diff --git a/backend/management/services/tag/identity.py b/backend/management/services/tag/identity.py new file mode 100644 index 0000000000..60b65dac89 --- /dev/null +++ b/backend/management/services/tag/identity.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Provider-independent identifiers used by tag adapters and display projections.""" + +import base64 +import json + +KNOWLEDGE_CONTENT_LIBRARY_CODE = "knowledge_content" +LOCAL_DOCUMENT_PROVIDER = "local" + + +def _encode_document_resource_id( + provider: str, + knowledge_base_id: str, + provider_document_id: str, +) -> str: + """Encode the provider document tuple as a stable, reversible resource ID.""" + + payload = json.dumps( + [provider, knowledge_base_id, provider_document_id], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii") diff --git a/backend/management/services/tag/projection.py b/backend/management/services/tag/projection.py index f88fd07537..7ced1d02da 100644 --- a/backend/management/services/tag/projection.py +++ b/backend/management/services/tag/projection.py @@ -5,9 +5,16 @@ from collections import defaultdict from typing import Any +from contracts.constants.constants import ( + DOCUMENT_TAG_BATCH_SIZE, + KNOWLEDGE_DOCUMENT_TYPE, +) +from contracts.constants.constants import ( + PUBLIC_DOCUMENT_ASSIGNMENT_FIELDS as _PUBLIC_ASSIGNMENT_FIELDS, +) from management.db.knowledge_base.repository import get_knowledge_record from management.db.tag.repository import TagManagementDB -from management.services.tag.resources import ( +from management.services.tag.identity import ( KNOWLEDGE_CONTENT_LIBRARY_CODE, LOCAL_DOCUMENT_PROVIDER, _encode_document_resource_id, @@ -15,9 +22,6 @@ logger = logging.getLogger(__name__) -from contracts.constants.constants import DOCUMENT_TAG_BATCH_SIZE, KNOWLEDGE_DOCUMENT_TYPE -from contracts.constants.constants import PUBLIC_DOCUMENT_ASSIGNMENT_FIELDS as _PUBLIC_ASSIGNMENT_FIELDS - def project_authorized_resource_tags( resources: list[dict[str, Any]], *, resource_type: str, id_field: str, default_tenant_id: str, diff --git a/backend/management/services/tag/resources.py b/backend/management/services/tag/resources.py index 8c4ec2c293..9a0c561bd2 100644 --- a/backend/management/services/tag/resources.py +++ b/backend/management/services/tag/resources.py @@ -10,62 +10,65 @@ from __future__ import annotations -import base64 import inspect -import json from collections.abc import Awaitable, Callable, Collection, Mapping from dataclasses import dataclass, field -from enum import Enum from typing import Any, Protocol, TypeVar from contracts.constants.constants import PERMISSION_EDIT, PERMISSION_READ -from management.db.agent.repository import search_agent_info_by_agent_id +from contracts.constants.environment import AIDP_API_KEY, AIDP_SERVER_URL +from contracts.roles import has_any_role +from contracts.tag import ( + AuthenticatedCaller, + CanonicalResourceIdentity, + ResolvedTagResource, + ResourceCapabilities, + ResourceDisplayMetadata, + ResourceOrigin, + ResourceReference, + ResourceType, + TagAssignmentCleanupCallback, + TagAssignmentCleanupDescriptor, +) +from ext_components.aidp.consts.aidp_exceptions import ( + AidpKbNotFoundError, + AidpKbPermissionDeniedError, +) +from ext_components.aidp.database.aidp_permission_db import ( + get_permission_by_kb_id, +) +from ext_components.aidp.services.aidp_permission_service import ( + REQUIRE_EDIT, + REQUIRE_READ, + require_permission, +) +from ext_components.aidp.services.aidp_service import list_aidp_docs_impl from management.db.agent.catalog import get_agent_repository_by_id +from management.db.agent.repository import search_agent_info_by_agent_id from management.db.knowledge_base.repository import get_knowledge_record from management.db.mcp.market import get_mcp_market_record_by_id from management.db.mcp.remote import get_mcp_record_by_id_and_tenant from management.db.skill.repository import get_skill_by_id from management.db.tool.repository import query_all_tools -from management.services.permission.asset_owner import resolve_agent_list_permission +from management.services.knowledge_base.service import ( + ElasticSearchService as service, +) +from management.services.knowledge_base.service import get_vector_db_core from management.services.mcp.service import get_remote_mcp_server_list +from management.services.permission.asset_owner import resolve_agent_list_permission +from management.services.tag.identity import ( + KNOWLEDGE_CONTENT_LIBRARY_CODE, + LOCAL_DOCUMENT_PROVIDER, + _encode_document_resource_id, +) -# Imported only when a knowledge-base or document adapter is used. Keeping the -# slot patchable preserves the existing isolated adapter test seam. +# Keep the optional override patchable for isolated provider-adapter tests. ElasticSearchService: Any | None = None DEFAULT_RESOURCE_LIBRARY_CODE = "default_resource" -KNOWLEDGE_CONTENT_LIBRARY_CODE = "knowledge_content" -LOCAL_DOCUMENT_PROVIDER = "local" AIDP_DOCUMENT_PROVIDER = "aidp" -from contracts.tag import ResourceType - - -from contracts.tag import ResourceOrigin - - -from contracts.tag import AuthenticatedCaller - - -from contracts.tag import ResourceReference - - -from contracts.tag import ResourceCapabilities - - -from contracts.tag import ResourceDisplayMetadata - - -from contracts.tag import CanonicalResourceIdentity - - -from contracts.tag import TagAssignmentCleanupDescriptor - - -from contracts.tag import TagAssignmentCleanupCallback - - async def run_tag_assignment_cleanup( descriptor: TagAssignmentCleanupDescriptor, callback: TagAssignmentCleanupCallback, @@ -77,9 +80,6 @@ async def run_tag_assignment_cleanup( await result -from contracts.tag import ResolvedTagResource - - @dataclass(slots=True) class ResourceAdapterDependencies: """Dependency-injection seam for tests and for isolated service integration.""" @@ -150,7 +150,6 @@ def _get_elasticsearch_service() -> Any: if ElasticSearchService is not None: return ElasticSearchService - from management.services.knowledge_base.service import ElasticSearchService as service return service @@ -166,7 +165,6 @@ async def _resolve_provider_document( normalized_provider = provider.strip().lower() if normalized_provider == LOCAL_DOCUMENT_PROVIDER: - from management.services.knowledge_base.service import get_vector_db_core files = await _get_elasticsearch_service().list_files( knowledge_base_id, @@ -185,8 +183,6 @@ async def _resolve_provider_document( return None if normalized_provider == AIDP_DOCUMENT_PROVIDER: - from contracts.constants.environment import AIDP_API_KEY, AIDP_SERVER_URL - from ext_components.aidp.services.aidp_service import list_aidp_docs_impl page = 1 while True: @@ -225,9 +221,6 @@ def _get_provider_knowledge_base( if normalized_provider == LOCAL_DOCUMENT_PROVIDER: return get_knowledge_record({"index_name": knowledge_base_id, "tenant_id": tenant_id}) if normalized_provider == AIDP_DOCUMENT_PROVIDER: - from ext_components.aidp.database.aidp_permission_db import ( - get_permission_by_kb_id, - ) return get_permission_by_kb_id(kb_id=knowledge_base_id, tenant_id=tenant_id) return None @@ -242,14 +235,6 @@ def _resolve_provider_document_permission( if normalized_provider == LOCAL_DOCUMENT_PROVIDER: return _resolve_local_knowledge_permission(knowledge_base_id, user_id, tenant_id) if normalized_provider == AIDP_DOCUMENT_PROVIDER: - from ext_components.aidp.consts.aidp_exceptions import ( - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - ) - from ext_components.aidp.services.aidp_permission_service import ( - REQUIRE_READ, - require_permission, - ) try: return require_permission(knowledge_base_id, user_id, tenant_id, REQUIRE_READ).permission @@ -268,14 +253,6 @@ def _require_provider_document_edit_permission( _require_local_knowledge_edit_permission(knowledge_base_id, user_id, tenant_id) return if normalized_provider == AIDP_DOCUMENT_PROVIDER: - from ext_components.aidp.consts.aidp_exceptions import ( - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - ) - from ext_components.aidp.services.aidp_permission_service import ( - REQUIRE_EDIT, - require_permission, - ) try: require_permission(knowledge_base_id, user_id, tenant_id, REQUIRE_EDIT) @@ -294,21 +271,6 @@ def _as_int(value: int | str) -> int | None: return None -def _encode_document_resource_id( - provider: str, - knowledge_base_id: str, - provider_document_id: str, -) -> str: - """Encode the provider document tuple as a stable, reversible resource ID.""" - - payload = json.dumps( - [provider, knowledge_base_id, provider_document_id], - ensure_ascii=False, - separators=(",", ":"), - ).encode("utf-8") - return base64.urlsafe_b64encode(payload).decode("ascii") - - def _tenant_matches(record: Mapping[str, Any] | None, tenant_id: str, field: str = "tenant_id") -> bool: """Require an explicit tenant match; empty legacy ownership is never inferred.""" @@ -483,7 +445,7 @@ async def resolve(self, reference: ResourceReference, caller: AuthenticatedCalle # The legacy local ToolInfo policy is admin-or-creator after the tenant # ownership check. An injected resolver remains the authoritative seam. created_by = tool.get("created_by") - can_edit = caller.role == "ADMIN" or ( + can_edit = has_any_role(caller.role, {"ADMIN"}) or ( bool(created_by) and str(created_by) == str(caller.user_id) ) if self._dependencies.resolve_tool_edit_permission is not None: diff --git a/backend/management/utils/app_factory.py b/backend/management/utils/app_factory.py index ff3122b47b..032c83a640 100644 --- a/backend/management/utils/app_factory.py +++ b/backend/management/utils/app_factory.py @@ -5,13 +5,19 @@ import logging -from management.utils.health import install_health_contract -from contracts.errors.exceptions import AppException, QuotaExceededError, TokenExpiredError -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.exceptions import RequestValidationError -from fastapi.routing import APIRoute from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute + +from management.utils.exception_handlers import register_exception_handlers +from management.utils.health import install_health_contract + +try: + from infrastructure.observability.bootstrap import monitoring_manager +except ImportError: + monitoring_manager = None logger = logging.getLogger(__name__) @@ -68,7 +74,8 @@ def create_app( # Initialize monitoring if enabled if enable_monitoring: try: - from infrastructure.observability.bootstrap import monitoring_manager + if monitoring_manager is None: + raise ImportError("Monitoring utilities not available") monitoring_manager.setup_fastapi_app(app) except ImportError: logger.warning("Monitoring utilities not available") @@ -76,150 +83,6 @@ def create_app( return app -def register_exception_handlers(app: FastAPI) -> None: - """ - Register common exception handlers for the FastAPI application. - - Args: - app: FastAPI application instance - """ - - @app.exception_handler(HTTPException) - async def http_exception_handler(request, exc): - logger.error(f"HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - @app.exception_handler(AppException) - async def app_exception_handler(request, exc): - logger.error(f"AppException: {exc.error_code.value} - {exc.message}") - return JSONResponse( - status_code=exc.http_status, - content={ - "code": exc.error_code.value, - "message": exc.message, - "details": exc.details if exc.details else None, - }, - ) - - @app.exception_handler(TokenExpiredError) - async def token_expired_exception_handler(request, exc): - logger.info("TokenExpiredError: %s", exc) - return JSONResponse( - status_code=401, - content={ - "message": "Session expired, please log in again", - "code": "TOKEN_EXPIRED", - }, - ) - - @app.exception_handler(QuotaExceededError) - async def quota_exceeded_exception_handler(request, exc): - logger.warning("QuotaExceededError: %s", exc) - return JSONResponse( - status_code=413, - content={ - "error": "TenantStorageFull", - "message": str(exc), - "usage_bytes": exc.usage_bytes, - "hard_limit_bytes": exc.hard_limit_bytes, - "exceeded_by_bytes": exc.exceeded_by_bytes, - }, - ) - - # ---- AIDP permission subsystem exceptions (v7.1) ---- - # These are domain exceptions (inherit from plain Exception) that map to - # HTTP status codes per v7.1 design. Without explicit handlers they would - # fall through to the generic handler below and surface as 500, hiding - # the real error from the client. - try: - from ext_components.aidp.consts.aidp_exceptions import ( - AidpGroupValidationError, - AidpKbConflictError, - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - AidpKbSyncError, - ) - - @app.exception_handler(AidpKbNotFoundError) - async def aidp_kb_not_found_handler(request, exc): - logger.warning("AidpKbNotFoundError: %s", exc) - return JSONResponse( - status_code=404, - content={"message": str(exc), "code": "AIDP_KB_NOT_FOUND", "kb_id": exc.kb_id}, - ) - - @app.exception_handler(AidpKbPermissionDeniedError) - async def aidp_permission_denied_handler(request, exc): - logger.warning( - "AidpKbPermissionDeniedError: user=%s kb=%s required=%s", exc.user_id, exc.kb_id, exc.required - ) - return JSONResponse( - status_code=403, - content={ - "message": str(exc), - "code": "AIDP_PERMISSION_DENIED", - "kb_id": exc.kb_id, - "required": exc.required, - }, - ) - - @app.exception_handler(AidpKbConflictError) - async def aidp_conflict_handler(request, exc): - logger.warning("AidpKbConflictError: %s", exc) - return JSONResponse( - status_code=409, - content={"message": str(exc), "code": "AIDP_KB_CONFLICT", "kb_id": exc.kb_id}, - ) - - @app.exception_handler(AidpKbSyncError) - async def aidp_sync_error_handler(request, exc): - logger.error("AidpKbSyncError: %s", exc) - return JSONResponse( - status_code=502, - content={"message": str(exc), "code": "AIDP_SYNC_ERROR", "operation": exc.operation}, - ) - - @app.exception_handler(AidpGroupValidationError) - async def aidp_group_validation_handler(request, exc): - logger.warning("AidpGroupValidationError: %s", exc) - return JSONResponse( - status_code=400, - content={ - "message": str(exc), - "code": "AIDP_GROUP_VALIDATION", - "invalid_ids": exc.invalid_ids, - }, - ) - except ImportError: - # AIDP subsystem not installed in this deployment; safe to skip - logger.debug("AIDP exception classes not available, skipping AIDP exception handlers") - - @app.exception_handler(Exception) - async def generic_exception_handler(request, exc): - # Don't catch AppException - it has its own handler - if isinstance(exc, AppException): - return await app_exception_handler(request, exc) - - # Handle NexentCapabilityError with a friendly message - from adapters.exception import NexentCapabilityError as _NCE - - if isinstance(exc, _NCE): - logger.warning(f"NexentCapabilityError: {exc}") - return JSONResponse( - status_code=400, - content={"message": str(exc)}, - ) - - logger.error(f"Generic Exception: {exc}") - return JSONResponse( - status_code=500, - content={"message": "Internal server error, please try again later."}, - ) - - class ModelCredentialRoute(APIRoute): """Keep input validation diagnostics from reflecting model credential values.""" diff --git a/backend/management/utils/exception_handlers.py b/backend/management/utils/exception_handlers.py new file mode 100644 index 0000000000..5f98238e9e --- /dev/null +++ b/backend/management/utils/exception_handlers.py @@ -0,0 +1,164 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Shared HTTP error mappings without application or telemetry initialization.""" + +import logging + +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse + +from adapters.exception import NexentCapabilityError as _NCE +from contracts.errors.exceptions import ( + AppException, + QuotaExceededError, + TokenExpiredError, +) + +try: + from ext_components.aidp.consts.aidp_exceptions import ( + AidpGroupValidationError, + AidpKbConflictError, + AidpKbNotFoundError, + AidpKbPermissionDeniedError, + AidpKbSyncError, + ) + AIDP_EXCEPTIONS_AVAILABLE = True +except ImportError: + AIDP_EXCEPTIONS_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register common exception handlers for the FastAPI application. + + Args: + app: FastAPI application instance + """ + + @app.exception_handler(HTTPException) + async def http_exception_handler(request, exc): + logger.error(f"HTTPException: {exc.detail}") + return JSONResponse( + status_code=exc.status_code, + content={"message": exc.detail}, + ) + + @app.exception_handler(AppException) + async def app_exception_handler(request, exc): + logger.error(f"AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None, + }, + ) + + @app.exception_handler(TokenExpiredError) + async def token_expired_exception_handler(request, exc): + logger.info("TokenExpiredError: %s", exc) + return JSONResponse( + status_code=401, + content={ + "message": "Session expired, please log in again", + "code": "TOKEN_EXPIRED", + }, + ) + + @app.exception_handler(QuotaExceededError) + async def quota_exceeded_exception_handler(request, exc): + logger.warning("QuotaExceededError: %s", exc) + return JSONResponse( + status_code=413, + content={ + "error": "TenantStorageFull", + "message": str(exc), + "usage_bytes": exc.usage_bytes, + "hard_limit_bytes": exc.hard_limit_bytes, + "exceeded_by_bytes": exc.exceeded_by_bytes, + }, + ) + + # ---- AIDP permission subsystem exceptions (v7.1) ---- + # These are domain exceptions (inherit from plain Exception) that map to + # HTTP status codes per v7.1 design. Without explicit handlers they would + # fall through to the generic handler below and surface as 500, hiding + # the real error from the client. + if AIDP_EXCEPTIONS_AVAILABLE: + + @app.exception_handler(AidpKbNotFoundError) + async def aidp_kb_not_found_handler(request, exc): + logger.warning("AidpKbNotFoundError: %s", exc) + return JSONResponse( + status_code=404, + content={"message": str(exc), "code": "AIDP_KB_NOT_FOUND", "kb_id": exc.kb_id}, + ) + + @app.exception_handler(AidpKbPermissionDeniedError) + async def aidp_permission_denied_handler(request, exc): + logger.warning( + "AidpKbPermissionDeniedError: user=%s kb=%s required=%s", exc.user_id, exc.kb_id, exc.required + ) + return JSONResponse( + status_code=403, + content={ + "message": str(exc), + "code": "AIDP_PERMISSION_DENIED", + "kb_id": exc.kb_id, + "required": exc.required, + }, + ) + + @app.exception_handler(AidpKbConflictError) + async def aidp_conflict_handler(request, exc): + logger.warning("AidpKbConflictError: %s", exc) + return JSONResponse( + status_code=409, + content={"message": str(exc), "code": "AIDP_KB_CONFLICT", "kb_id": exc.kb_id}, + ) + + @app.exception_handler(AidpKbSyncError) + async def aidp_sync_error_handler(request, exc): + logger.error("AidpKbSyncError: %s", exc) + return JSONResponse( + status_code=502, + content={"message": str(exc), "code": "AIDP_SYNC_ERROR", "operation": exc.operation}, + ) + + @app.exception_handler(AidpGroupValidationError) + async def aidp_group_validation_handler(request, exc): + logger.warning("AidpGroupValidationError: %s", exc) + return JSONResponse( + status_code=400, + content={ + "message": str(exc), + "code": "AIDP_GROUP_VALIDATION", + "invalid_ids": exc.invalid_ids, + }, + ) + else: + # AIDP subsystem not installed in this deployment; safe to skip + logger.debug("AIDP exception classes not available, skipping AIDP exception handlers") + + @app.exception_handler(Exception) + async def generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await app_exception_handler(request, exc) + + # Handle NexentCapabilityError with a friendly message + + if isinstance(exc, _NCE): + logger.warning(f"NexentCapabilityError: {exc}") + return JSONResponse( + status_code=400, + content={"message": str(exc)}, + ) + + logger.error(f"Generic Exception: {exc}") + return JSONResponse( + status_code=500, + content={"message": "Internal server error, please try again later."}, + ) diff --git a/backend/ops/README.md b/backend/ops/README.md index c95bd9c04f..7a0d5c9398 100644 --- a/backend/ops/README.md +++ b/backend/ops/README.md @@ -1,9 +1,10 @@ # Nexent operations backend The operations backend runs in its own process and container using the existing -backend image. It currently exposes only process health and API documentation. -It does not initialize databases, Redis, MinIO, Elasticsearch, telemetry, Agent -executors or schedulers. +backend image. It exposes process health, authenticated operations scope and a +read-only resource inventory. Authenticated requests reuse the existing PostgreSQL +identity/permission tables and JWT settings. It does not start Redis, MinIO, +Elasticsearch, telemetry, Agent executors or schedulers. ## Local startup @@ -25,8 +26,11 @@ a nearby `.env`, set `PYTHON_DOTENV_DISABLED=1` before starting Python. | `/health/ready` | HTTP 200 with `{"status":"ready"}` after startup; otherwise HTTP 503 with `{"status":"not_ready"}` | | `/docs` | OpenAPI UI; health endpoints are omitted from the schema | -Health checks describe only the operations process. Business monitoring APIs -and their authentication will be added together in a later change. +Health checks describe only the operations process and do not probe dependencies. +`GET /ops/authorization`, `/ops/resources` and `/ops/scope` require a valid session. +OPS is restricted to its tenant; SU can select any tenant or all tenants. The +dashboard and configuration action grants are permission contracts; monitoring +and configuration CRUD implementations are outside this change. ## Container startup @@ -76,20 +80,20 @@ container rollout is performed by the bootstrap code. - `ops_service.py`: process launcher. - `ops/api/`: HTTP parsing, responses and application composition. -- `ops/services/`: future business rules and orchestration. +- `ops/services/`: operations scope and read-only inventory orchestration. - `ops/lifespan.py`: process resources and readiness lifecycle. -Introduce database access and integration adapters only when a feature needs -them. Simple process health does not require a forwarding service class. +Database identity/permission access is reused from management. Resource inventory +selects metadata fields explicitly; it does not return credentials or content. ## Kubernetes The existing `bash deploy/deploy.sh --defaults k8s` entry point enables the `nexent-ops` subchart whenever `application` is selected and waits for its Deployment to become ready. It reuses the selected backend image and does not -mount business configuration, credentials, SQL or business storage. Only explicit -ops settings, selected logging keys from the existing `nexent-env` Secret and -SQL-startup-off are passed to its container; the Kubernetes API +mount SQL or business storage. Explicit ops settings and selected logging, +PostgreSQL and JWT keys from the existing `nexent-env` Secret are passed to its +container. SQL startup remains off; the Kubernetes API service-account token is not mounted. The only storage mount is the logs PVC, using `global.sharedStorage.logs.existingClaim` diff --git a/backend/ops/api/access.py b/backend/ops/api/access.py new file mode 100644 index 0000000000..c9042f28d0 --- /dev/null +++ b/backend/ops/api/access.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Permission contracts and metadata; operations business CRUD is separate work.""" + +from fastapi import APIRouter, Depends, Query + +from gateway.authentication.auth import authenticate +from ops.services.access import resource_inventory +from permissions.models import CurrentUser +from permissions.operations import authorization_metadata, operations_scope + +router = APIRouter(prefix="/ops", tags=["operations access"]) + + +@router.get("/authorization") +def authorization(user: CurrentUser = Depends(authenticate)): + return {"data": authorization_metadata(user)} + + +@router.get("/resources") +def resources( + tenant_id: str | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user: CurrentUser = Depends(authenticate), +): + return {"data": resource_inventory(user, tenant_id, page, page_size)} + + +@router.get("/scope") +def scope( + tenant_id: str | None = None, + module: str = Query("dashboard", pattern="^(dashboard|configuration)$"), + action: str = Query("read", pattern="^(read|create|update|delete)$"), + user: CurrentUser = Depends(authenticate), +): + tenant = operations_scope(user, tenant_id, f"ops.{module}:{action}") + return {"data": {"tenant_id": tenant, "scope": "all_tenants" if tenant is None else "tenant"}} diff --git a/backend/ops/api/application.py b/backend/ops/api/application.py index 3d59d6f9e0..15e34c0004 100644 --- a/backend/ops/api/application.py +++ b/backend/ops/api/application.py @@ -4,6 +4,8 @@ from fastapi import FastAPI from contracts.constants.environment import APP_VERSION +from management.utils.exception_handlers import register_exception_handlers +from ops.api.access import router as access_router from ops.api.health import router as health_router from ops.lifespan import ops_lifespan @@ -16,6 +18,8 @@ def create_app() -> FastAPI: lifespan=ops_lifespan, ) application.state.ready = False + register_exception_handlers(application) + application.include_router(access_router) application.include_router(health_router) return application diff --git a/backend/ops/services/access.py b/backend/ops/services/access.py new file mode 100644 index 0000000000..f0a41973d7 --- /dev/null +++ b/backend/ops/services/access.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Read an explicit metadata allowlist without fetching credentials or content.""" + +from sqlalchemy import func, literal, select, union_all + +from management.db.client import get_db_session +from management.db.models import AgentInfo, KnowledgeRecord, McpRecord, ModelRecord +from permissions.resource_access import require_inventory_scope + + +def resource_inventory(user, tenant_id=None, page=1, page_size=20): + tenant = require_inventory_scope(user, tenant_id) + definitions = ( + (ModelRecord, ModelRecord.model_id, ModelRecord.model_name, "model"), + (McpRecord, McpRecord.mcp_id, McpRecord.mcp_name, "mcp"), + (AgentInfo, AgentInfo.agent_id, AgentInfo.name, "agent"), + (KnowledgeRecord, KnowledgeRecord.knowledge_id, KnowledgeRecord.knowledge_name, "knowledge"), + ) + statements = [] + for model, identity, name, kind in definitions: + query = select(literal(kind).label("kind"), identity.label("id"), name.label("name"), model.tenant_id) + query = query.where(model.delete_flag == "N") + if tenant is not None: + query = query.where(model.tenant_id == tenant) + if model is AgentInfo: + query = query.where(model.version_no == 0) + statements.append(query) + inventory = union_all(*statements).subquery() + + with get_db_session() as session: + total = session.scalar(select(func.count()).select_from(inventory)) + rows = ( + session.execute( + select(inventory) + .order_by(inventory.c.kind, inventory.c.tenant_id, inventory.c.id) + .offset((page - 1) * page_size) + .limit(page_size) + ) + .mappings() + .all() + ) + return {"items": [dict(row) for row in rows], "total": total, "page": page, "page_size": page_size} diff --git a/backend/permissions/dac.py b/backend/permissions/dac.py index aff098e530..8f0bd9c527 100644 --- a/backend/permissions/dac.py +++ b/backend/permissions/dac.py @@ -8,12 +8,17 @@ from typing import List, Optional -from contracts.constants.constants import ASSET_OWNER_TENANT_ID, PERMISSION_EDIT, PERMISSION_PRIVATE, PERMISSION_READ +from contracts.constants.constants import ( + ASSET_OWNER_TENANT_ID, + PERMISSION_EDIT, + PERMISSION_PRIVATE, + PERMISSION_READ, +) +from contracts.roles import has_any_role, normalize_roles from permissions.models import Resource, ResourceAccess - -MANAGEMENT_ROLES = {"SU", "ADMIN", "SPEED", "ASSET_OWNER"} -ASSET_OWNER_READER_ROLES = {"SU", "ADMIN", "SPEED", "DEV"} +MANAGEMENT_ROLES = {"SU", "ADMIN", "ASSET_OWNER"} +ASSET_OWNER_READER_ROLES = {"SU", "ADMIN", "DEV"} GROUP_ACCESS_ROLES = {"USER", "DEV"} @@ -22,6 +27,25 @@ class ResourceAccessControl: @staticmethod def check( + resource: Resource, + user_id: str, + role, + user_groups=None, + user_tenant_id=None, + asset_owner_tenant_id=None, + ) -> ResourceAccess: + """Union each role's resource grant without weakening any role's boundaries.""" + try: + roles = normalize_roles(role, allow_empty=True) + except ValueError: + return ResourceAccess.deny() + grants = [ResourceAccessControl._check_one( + resource, user_id, item, user_groups, user_tenant_id, asset_owner_tenant_id + ) for item in roles if item != "OPS"] + return max(grants, key=lambda grant: (grant.can_edit, grant.can_read), default=ResourceAccess.deny()) + + @staticmethod + def _check_one( resource: Resource, user_id: str, role: Optional[str], @@ -38,7 +62,7 @@ def check( ``asset_owner_tenant_id`` is injectable for callers that override the default tenant marker (for example in tests or for deployment configs). """ - normalized_role = (role or "").upper() + normalized_role = role normalized_user_id = str(user_id or "") record_tenant_id = str(resource.tenant_id or "") normalized_user_tenant_id = str(user_tenant_id or "") @@ -54,7 +78,7 @@ def check( return _check_asset_owner_access(normalized_role) if record_tenant_id and record_tenant_id != normalized_user_tenant_id: return ResourceAccess.deny() - if normalized_role == "USER" and str(resource.created_by or "") != normalized_user_id: + if has_any_role(normalized_role, {'USER'}) and str(resource.created_by or "") != normalized_user_id: return ResourceAccess.deny() if str(resource.knowledge_sources or "") == "datamate": return ResourceAccess.read_only() @@ -65,7 +89,7 @@ def check( if str(resource.created_by or "") == normalized_user_id: return ResourceAccess.creator(matched_groups=matched_groups) - if normalized_role in MANAGEMENT_ROLES: + if has_any_role(normalized_role, MANAGEMENT_ROLES): return _check_management_access(resource) return _check_group_access(resource, normalized_role, matched_groups, resource_groups, normalized_user_groups) @@ -87,9 +111,9 @@ def _normalize_group_ids(group_ids: Optional[List[object]]) -> List[object]: def _check_asset_owner_access(role: str) -> ResourceAccess: """Resolve access to resources owned by the asset-owner tenant.""" - if role == "ASSET_OWNER": + if has_any_role(role, {'ASSET_OWNER'}): return ResourceAccess.edit() - if role in ASSET_OWNER_READER_ROLES: + if has_any_role(role, ASSET_OWNER_READER_ROLES): return ResourceAccess.read_only() return ResourceAccess.deny() @@ -109,7 +133,7 @@ def _check_group_access( user_groups: List[object], ) -> ResourceAccess: """Resolve access for users whose permission is group-scoped.""" - if role not in GROUP_ACCESS_ROLES or _is_private(resource): + if not has_any_role(role, GROUP_ACCESS_ROLES) or _is_private(resource): return ResourceAccess.deny() # Legacy data may leave both sides empty (NULL/empty groups). Keep the diff --git a/backend/permissions/depends.py b/backend/permissions/depends.py index f8462b59c8..51510421e7 100644 --- a/backend/permissions/depends.py +++ b/backend/permissions/depends.py @@ -3,26 +3,19 @@ from typing import Callable -from contracts.constants.environment import IS_SPEED_MODE from fastapi import Depends, HTTPException + +from gateway.authentication.auth import authenticate from permissions.models import CurrentUser from permissions.rbac import has_permission -from gateway.authentication.auth import authenticate - - - - -def require(permission: str) -> Callable[[CurrentUser], CurrentUser]: +def require(permission: str, *, alternative: str | None = None) -> Callable[[CurrentUser], CurrentUser]: """Return a dependency that requires the given permission string.""" def dependency(current_user: CurrentUser = Depends(authenticate)) -> CurrentUser: - if IS_SPEED_MODE and current_user.normalized_role == "SPEED": - # Speed mode has no role_permission_t seeds, so allow the built-in - # SPEED account to pass through permission checks. - return current_user - if not has_permission(current_user.normalized_role, permission): + roles = current_user.effective_roles + if not has_permission(roles, permission) and not (alternative and has_permission(roles, alternative)): raise HTTPException( status_code=403, detail=f"Missing required permission: {permission}", diff --git a/backend/permissions/models.py b/backend/permissions/models.py index c6bf4c078a..c0340cf323 100644 --- a/backend/permissions/models.py +++ b/backend/permissions/models.py @@ -1,8 +1,11 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Shared permission data models.""" from dataclasses import dataclass, field from typing import List, Optional +from contracts.roles import normalize_roles + @dataclass(frozen=True) class CurrentUser: @@ -12,6 +15,14 @@ class CurrentUser: tenant_id: str role: str groups: List[int] = field(default_factory=list) + roles: Optional[tuple[str, ...]] = None + + @property + def effective_roles(self) -> tuple[str, ...]: + try: + return normalize_roles(self.role if self.roles is None else self.roles, allow_empty=True) + except ValueError: + return () @property def normalized_role(self) -> str: diff --git a/backend/permissions/operations.py b/backend/permissions/operations.py new file mode 100644 index 0000000000..9b120d4c1e --- /dev/null +++ b/backend/permissions/operations.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Authorization for the operations dashboard and system configuration.""" + +from fastapi import HTTPException + +from permissions.rbac import has_permission +from permissions.tenant_scope import resolve_tenant_scope + +ACTIONS = ("read", "create", "update", "delete") + + +def operations_scope(user, tenant_id=None, permission="ops.dashboard:read"): + roles = user.effective_roles + if not has_permission(roles, permission) or not set(roles) & {"OPS", "SU"}: + raise HTTPException(status_code=403, detail="Operations permission required") + return resolve_tenant_scope( + user, tenant_id, allow_all_tenants=True, denied_detail="Operations are limited to your tenant", + ) + + +def authorization_metadata(user): + operations_scope(user) + return { + "scope": "all_tenants" if "SU" in user.effective_roles else "current_tenant", + "tenant_id": user.tenant_id, + "permissions": { + module: [action for action in ACTIONS if has_permission(user.effective_roles, f"ops.{module}:{action}")] + for module in ("dashboard", "configuration") + }, + } diff --git a/backend/permissions/rbac.py b/backend/permissions/rbac.py index 634489f054..2ab2dc9431 100644 --- a/backend/permissions/rbac.py +++ b/backend/permissions/rbac.py @@ -6,9 +6,9 @@ import threading from typing import Dict, Optional, Set +from contracts.roles import normalize_roles, roles_from_record from management.db.identity.role_permission import get_all_role_permissions - logger = logging.getLogger(__name__) _PERMISSION_LOCK = threading.RLock() @@ -54,20 +54,26 @@ def _ensure_loaded() -> None: init_rbac() -def has_permission(role: Optional[str], permission: str) -> bool: +def has_permission(role, permission: str) -> bool: """Return whether the normalized role has the lower-case permission string.""" - normalized_role = (role or "").upper() + try: + roles = normalize_roles(role, allow_empty=True) + except ValueError: + return False normalized_permission = (permission or "").lower() - if not normalized_role or not normalized_permission: + if not roles or not normalized_permission: return False _ensure_loaded() with _PERMISSION_LOCK: - return normalized_permission in _ROLE_PERMISSIONS.get(normalized_role, set()) + return any(normalized_permission in _ROLE_PERMISSIONS.get(item, set()) for item in roles) -def get_role_permissions(role: Optional[str]) -> Set[str]: +def get_role_permissions(role) -> Set[str]: """Return the cached permission set for a role.""" - normalized_role = (role or "").upper() + try: + roles = normalize_roles(role, allow_empty=True) + except ValueError: + return set() _ensure_loaded() with _PERMISSION_LOCK: - return set(_ROLE_PERMISSIONS.get(normalized_role, set())) + return set().union(*(_ROLE_PERMISSIONS.get(item, set()) for item in roles)) diff --git a/backend/permissions/resource_access.py b/backend/permissions/resource_access.py new file mode 100644 index 0000000000..40c114ed93 --- /dev/null +++ b/backend/permissions/resource_access.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Read-only access to the tenant resource inventory.""" + +from fastapi import HTTPException, Request + +from contracts.errors.exceptions import TokenExpiredError, UnauthorizedError +from gateway.authentication.auth import get_current_user_context +from permissions.models import CurrentUser +from permissions.rbac import has_permission +from permissions.tenant_scope import resolve_tenant_scope + +INVENTORY_READ = "resource.inventory:read" + + +def require_inventory_scope(user: CurrentUser, tenant_id: str | None = None) -> str | None: + """Check the inventory grant and resolve the requested tenant.""" + if not has_permission(user.effective_roles, INVENTORY_READ): + raise HTTPException(status_code=403, detail="Operations permission required") + return resolve_tenant_scope( + user, tenant_id, allow_all_tenants=True, denied_detail="Operations are limited to your tenant", + ) + + +def resource_read_tenant( + request: Request | None, *, shared: bool = False, user: CurrentUser | None = None, +) -> str | None: + """Resolve the inventory view for a route that also serves ordinary resource readers.""" + if user is None: + authorization = request.headers.get("authorization") if request is not None else None + if not authorization: + return None + try: + user_id, tenant_id, roles = get_current_user_context(authorization) + except (UnauthorizedError, TokenExpiredError): + return None + user = CurrentUser(user_id, tenant_id, roles[0], roles=roles) + + roles = user.effective_roles + if {"ADMIN", "SU"}.intersection(roles) or not has_permission(roles, INVENTORY_READ): + return None + targets = [target for target in request.query_params.getlist("tenant_id") if target] if request else [] + if shared and len(roles) > 1 and not targets: + return None + + for target in targets or [None]: + resolve_tenant_scope(user, target) + return user.tenant_id diff --git a/backend/permissions/role_assignment.py b/backend/permissions/role_assignment.py new file mode 100644 index 0000000000..bead256c41 --- /dev/null +++ b/backend/permissions/role_assignment.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Pure authorization policy shared by locked membership write paths.""" + +from contracts.errors.exceptions import ForbiddenError +from contracts.roles import has_any_role, roles_from_record + + +def check_managed_target(actor, target): + actor_roles = roles_from_record(actor) + if has_any_role(roles_from_record(target), {"SU", "ASSET_OWNER"}): + raise ForbiddenError("Special identities are not editable through ordinary role management") + if has_any_role(actor_roles, {"SU"}): + return + if not has_any_role(actor_roles, {"ADMIN"}) or actor["tenant_id"] != target["tenant_id"]: + raise ForbiddenError("Not authorized to manage this user") diff --git a/backend/permissions/tenant_scope.py b/backend/permissions/tenant_scope.py index 6c5a6e7118..bb00277892 100644 --- a/backend/permissions/tenant_scope.py +++ b/backend/permissions/tenant_scope.py @@ -1,26 +1,33 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Tenant-scope authorization helpers.""" from http import HTTPStatus from fastapi import HTTPException + +from contracts.roles import has_any_role from permissions.models import CurrentUser -# Temporary policy for personal KB capacity APIs. Keep this policy in the -# permission layer until cross-tenant access is represented by RBAC. -_PERSONAL_KB_CROSS_TENANT_ROLES = frozenset({"SU", "SPEED"}) + +def resolve_tenant_scope( + current_user: CurrentUser, + tenant_id: str | None = None, + *, + allow_all_tenants: bool = False, + denied_detail: str = "Resource reads are limited to your tenant", +) -> str | None: + """Limit tenant selection to the authenticated tenant unless the caller is SU.""" + if has_any_role(current_user.effective_roles, {"SU"}): + return tenant_id if tenant_id is not None or allow_all_tenants else current_user.tenant_id + if not current_user.tenant_id or (tenant_id is not None and tenant_id != current_user.tenant_id): + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=denied_detail) + return current_user.tenant_id -def resolve_personal_target_tenant( - current_user: CurrentUser, tenant_id: str | None -) -> str: +def resolve_personal_target_tenant(current_user: CurrentUser, tenant_id: str | None) -> str: """Resolve the tenant used by personal KB capacity APIs.""" - target_tenant_id = tenant_id or current_user.tenant_id - if ( - target_tenant_id != current_user.tenant_id - and current_user.normalized_role not in _PERSONAL_KB_CROSS_TENANT_ROLES - ): - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Cannot access personal KB capacity for another tenant", - ) - return target_tenant_id + target_tenant = tenant_id or current_user.tenant_id + resolve_tenant_scope( + current_user, target_tenant, denied_detail="Cannot access personal KB capacity for another tenant", + ) + return target_tenant diff --git a/backend/services/skill_repository_service.py b/backend/services/skill_repository_service.py index 7d6c86e10f..cedb67c908 100644 --- a/backend/services/skill_repository_service.py +++ b/backend/services/skill_repository_service.py @@ -7,22 +7,53 @@ import re from typing import Any, Dict, FrozenSet, List, Optional, Tuple -from contracts.constants.constants import OWNERSHIP_ALL, OWNERSHIP_CREATED, OWNERSHIP_OTHERS, STATUS_NOT_SHARED, STATUS_PENDING_REVIEW, STATUS_REJECTED, STATUS_SHARED, VALID_OWNERSHIP_FILTERS, VALID_REPOSITORY_STATUSES -from contracts.constants.constants import PERMISSION_PRIVATE, PERMISSION_READ -from contracts.errors.exceptions import ForbiddenError, SkillDuplicateError, SkillException -from contracts.constants.constants import EVENT_TYPE_REPOSITORY_REVIEW_PENDING, RESOURCE_TYPE_SKILL_REPOSITORY -from management.db.skill.catalog import get_skill_repository_by_id_and_publisher, get_skill_repository_by_skill_id, increment_skill_repository_downloads, insert_skill_repository_record, list_skill_repository_by_skill_ids, list_skill_repository_tag_stats, list_skill_repository_summaries, reset_skill_repository_status, update_skill_repository_by_id, update_skill_repository_status_by_id -from management.db.skill.repository import get_skill_by_name +from contracts.constants.constants import ( + EVENT_TYPE_REPOSITORY_REVIEW_PENDING, + OWNERSHIP_ALL, + OWNERSHIP_CREATED, + OWNERSHIP_OTHERS, + PERMISSION_PRIVATE, + PERMISSION_READ, + RESOURCE_TYPE_SKILL_REPOSITORY, + STATUS_NOT_SHARED, + STATUS_PENDING_REVIEW, + STATUS_REJECTED, + STATUS_SHARED, + VALID_OWNERSHIP_FILTERS, + VALID_REPOSITORY_STATUSES, +) +from contracts.errors.exceptions import ( + ForbiddenError, + SkillDuplicateError, + SkillException, +) +from contracts.roles import has_any_role, roles_from_record from management.db.identity.user_tenant import get_user_tenant_by_user_id +from management.db.skill.catalog import ( + get_skill_repository_by_id_and_publisher, + get_skill_repository_by_skill_id, + increment_skill_repository_downloads, + insert_skill_repository_record, + list_skill_repository_by_skill_ids, + list_skill_repository_summaries, + list_skill_repository_tag_stats, + reset_skill_repository_status, + update_skill_repository_by_id, + update_skill_repository_status_by_id, +) +from management.db.skill.repository import get_skill_by_name +from management.db.tag.repository import TagManagementDB from management.services.notification.service import ( create_repository_pending_review_notification, create_repository_review_notification, deactivate_notifications, ) -from management.services.skill.service import SkillService +from management.services.permission.identity.display import ( + enrich_repository_identities, + repository_identity_fields, +) from management.services.skill.naming import generate_available_copy_skill_name - -from management.services.permission.identity.display import enrich_repository_identities, repository_identity_fields +from management.services.skill.service import SkillService logger = logging.getLogger("skill_repository_service") _REPOSITORY_LISTING_NOT_FOUND = "Repository listing not found" @@ -262,13 +293,13 @@ def _get_user_role(user_id: str) -> str: user_tenant = get_user_tenant_by_user_id(user_id) if not user_tenant: return "USER" - return str(user_tenant.get("user_role") or "USER") + return roles_from_record(user_tenant) def ensure_skill_repository_access(user_id: str) -> None: """Reject ordinary users from the Skill Repository API surface.""" - user_role = _get_user_role(user_id).upper() - if user_role == "USER": + user_role = _get_user_role(user_id) + if not has_any_role(user_role, {"DEV", "ADMIN", "SU", "ASSET_OWNER"}): raise ForbiddenError("User role USER is not authorized to access Skill Repository") @@ -279,10 +310,10 @@ def _can_publish_skill( user_role: str, ) -> bool: """Return whether the user may submit the skill to the repository.""" - if user_role == "ADMIN": + if has_any_role(user_role, {'ADMIN'}): return True return ( - user_role == "DEV" + has_any_role(user_role, {'DEV'}) and str(skill.get("created_by")) == str(user_id) ) @@ -602,12 +633,12 @@ def _validate_publisher_status_transition( ) -> Optional[Dict[str, str]]: if record.get("publisher_tenant_id") != tenant_id: raise ForbiddenError("Not authorized to update this repository listing") - if user_role == "DEV": + if has_any_role(user_role, {'DEV'}) and not has_any_role(user_role, {'ADMIN'}): snapshot = _as_dict(record.get("skill_info_json")) owner_user_id = snapshot.get("created_by") or record.get("publisher_user_id") if str(owner_user_id) != str(user_id): raise ForbiddenError("Not authorized to update this repository listing") - if user_role == "ADMIN" and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: + if has_any_role(user_role, {'ADMIN'}) and transition in _ADMIN_REVIEW_STATUS_TRANSITIONS: return None if transition not in _PUBLISHER_STATUS_TRANSITIONS: raise ValueError( @@ -633,11 +664,11 @@ def _validate_repository_status_transition( """Validate role, ownership, and allowed status transition.""" transition = (current_status, new_status) - if user_role == "SU": + if has_any_role(user_role, {'SU'}): _validate_su_status_transition(transition, current_status, new_status) return None - if user_role in ("ADMIN", "DEV"): + if has_any_role(user_role, {'ADMIN','DEV'}): return _validate_publisher_status_transition( user_role=user_role, transition=transition, @@ -975,7 +1006,6 @@ def list_my_editable_skills_impl( user_id=user_id, ) if tag_predicates: - from management.db.tag.repository import TagManagementDB skill_ids = [ str(skill["skill_id"]) @@ -1019,7 +1049,6 @@ def list_my_editable_skills_impl( paged_skills, tenant_id, ) - from management.db.tag.repository import TagManagementDB managed_tags_by_skill_id = TagManagementDB.list_resource_assignment_display_values_by_ids( tenant_id=tenant_id, @@ -1091,7 +1120,6 @@ def list_skill_repository_listings_impl( skill_ids = None if tag_predicates: - from management.db.tag.repository import TagManagementDB visible_skill_ids = [ str(skill["skill_id"]) @@ -1130,9 +1158,9 @@ def list_skill_repository_listings_impl( can_take_down=( record.get("status") == STATUS_SHARED and ( - user_role in ("ADMIN", "SU") + has_any_role(user_role, {'ADMIN','SU'}) or ( - user_role == "DEV" + has_any_role(user_role, {'DEV'}) and str(record.get("publisher_user_id")) == str(user_id) ) ) diff --git a/deploy/common/common.sh b/deploy/common/common.sh index 157650535e..ae4a27eb32 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -1138,6 +1138,11 @@ deployment_apply_legacy_inputs() { esac fi + # Legacy component choices never disable authentication. + if [ "${DEPLOYMENT_VERSION:-}" = "speed" ]; then + DEPLOYMENT_VERSION="full" + fi + if [ "$DEPLOYMENT_CONFIG_VALUES_LOADED" != "true" ]; then case "${DEPLOYMENT_MODE:-}" in development) diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 60ebd7e1be..7881c90af8 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -6,7 +6,7 @@ NEXENT_MCP_INTERNAL_TOKEN=token # Frontend and backend container timezone (IANA name, e.g. Asia/Shanghai or UTC). TZ=Asia/Shanghai -# Independent operations backend (no external infrastructure required). +# Independent operations backend; authenticated operations use application PostgreSQL. OPS_PORT=5016 # ===== Proxy Configuration (Optional) ===== diff --git a/deploy/images/dockerfiles/main/Dockerfile b/deploy/images/dockerfiles/main/Dockerfile index 644d641228..4c9376a151 100644 --- a/deploy/images/dockerfiles/main/Dockerfile +++ b/deploy/images/dockerfiles/main/Dockerfile @@ -86,6 +86,7 @@ COPY --from=litellm-models /litellm_models.json /opt/nexent/litellm_models.json COPY backend /opt/backend COPY deploy/sql/tools/supabase_to_postgres /opt/deploy/sql/tools/supabase_to_postgres COPY deploy/sql/migrations/v2.9.0_001_local_user_accounts.sql /opt/deploy/sql/migrations/v2.9.0_001_local_user_accounts.sql +COPY deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql /opt/deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql COPY --from=litellm-models /models_dev_catalog.json /opt/backend/configs/models_dev_catalog.json COPY VERSION /opt/nexent/VERSION RUN /opt/backend/.venv/bin/python -c "from nexent.core.model_service.profiles.resolver import load_profile_catalog; load_profile_catalog()" diff --git a/deploy/k8s/helm/nexent/charts/nexent-ops/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-ops/templates/deployment.yaml index 1bd756c6f3..a5de2d2fed 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-ops/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-ops/templates/deployment.yaml @@ -48,7 +48,7 @@ spec: value: "/mnt/nexent-data/logs/$(POD_NAME)" - name: OPS_PORT value: {{ .Values.service.port | quote }} - {{- range list "LOG_LEVEL" "IS_DEBUG" "LOG_MAX_BYTES" "LOG_ROTATION_INTERVAL" "LOG_BACKUP_COUNT" }} + {{- range list "LOG_LEVEL" "IS_DEBUG" "LOG_MAX_BYTES" "LOG_ROTATION_INTERVAL" "LOG_BACKUP_COUNT" "POSTGRES_HOST" "POSTGRES_PORT" "POSTGRES_USER" "POSTGRES_DB" "NEXENT_POSTGRES_PASSWORD" "JWT_SECRET" "JWT_ISSUER" }} - name: {{ . }} valueFrom: secretKeyRef: diff --git a/deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql b/deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql new file mode 100644 index 0000000000..7486c8addc --- /dev/null +++ b/deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql @@ -0,0 +1,113 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +-- Finalize account roles, invitation grants and operations permissions. +DO $account_and_ops_roles$ +DECLARE + invalid_ids text; +BEGIN + PERFORM pg_advisory_xact_lock(hashtext('account-writers')); + LOCK TABLE nexent.user_account_t, nexent.user_tenant_t IN SHARE ROW EXCLUSIVE MODE; + + -- Preserve arrays from the unpublished multi-role schema without retaining aliases. + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='nexent' AND table_name='user_account_t' AND column_name='roles') THEN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='nexent' AND table_name='user_account_t' AND column_name='user_roles') THEN + RAISE EXCEPTION 'Resolve simultaneous roles and user_roles columns before upgrade'; + END IF; + ALTER TABLE nexent.user_account_t RENAME COLUMN roles TO user_roles; + END IF; + ALTER TABLE nexent.user_account_t ADD COLUMN IF NOT EXISTS user_roles VARCHAR(30)[]; + + -- to_jsonb allows safe replay after the old scalar column has been removed. + UPDATE nexent.user_account_t a + SET user_roles=CASE WHEN nullif(to_jsonb(a)->>'user_role','') IS NULL THEN ARRAY[]::VARCHAR(30)[] + ELSE ARRAY[to_jsonb(a)->>'user_role']::VARCHAR(30)[] END, + updated_by='account-role-migration', update_time=CURRENT_TIMESTAMP + WHERE a.user_roles IS NULL; + + SELECT string_agg(a.user_id, ',') INTO invalid_ids FROM nexent.user_account_t a + WHERE a.delete_flag='N' + AND EXISTS (SELECT 1 FROM nexent.user_tenant_t u WHERE u.user_id=a.user_id AND u.delete_flag='N') + AND 'SPEED'=ANY(a.user_roles) AND cardinality(array_remove(a.user_roles,'SPEED'))=0; + IF invalid_ids IS NOT NULL THEN + RAISE EXCEPTION 'Resolve active SPEED-only accounts before upgrade: %',invalid_ids; + END IF; + UPDATE nexent.user_account_t SET user_roles=array_remove(user_roles,'SPEED'), + updated_by='account-role-migration', update_time=CURRENT_TIMESTAMP + WHERE 'SPEED'=ANY(user_roles); + + IF EXISTS (SELECT 1 FROM nexent.user_account_t a + WHERE a.delete_flag='N' + AND EXISTS (SELECT 1 FROM nexent.user_tenant_t u WHERE u.user_id=a.user_id AND u.delete_flag='N') + AND (cardinality(a.user_roles)=0 OR array_position(a.user_roles,NULL) IS NOT NULL + OR NOT a.user_roles <@ ARRAY['SU','ADMIN','DEV','USER','OPS','ASSET_OWNER']::VARCHAR(30)[] + OR (a.user_roles && ARRAY['SU','ASSET_OWNER']::VARCHAR(30)[] AND cardinality(a.user_roles)>1))) THEN + RAISE EXCEPTION 'Resolve missing or unknown account roles before upgrade'; + END IF; + ALTER TABLE nexent.user_account_t ALTER COLUMN user_roles SET NOT NULL; + ALTER TABLE nexent.user_account_t ALTER COLUMN user_roles SET DEFAULT '{}'::VARCHAR(30)[]; + COMMENT ON COLUMN nexent.user_account_t.user_roles IS + 'Authoritative built-in account roles; empty until assigned within registration'; + ALTER TABLE nexent.user_account_t DROP COLUMN IF EXISTS user_role; + + -- The historical credential import prepares only the account schema. + -- The startup SQL runner applies the remaining changes after that import commits. + IF current_setting('nexent.account_import', true) = 'on' THEN + RETURN; + END IF; + + LOCK TABLE nexent.tenant_invitation_code_t, nexent.role_permission_t IN SHARE ROW EXCLUSIVE MODE; + ALTER TABLE nexent.tenant_invitation_code_t ADD COLUMN IF NOT EXISTS roles VARCHAR(30)[]; + IF EXISTS (SELECT 1 FROM nexent.tenant_invitation_code_t + WHERE delete_flag='N' AND roles IS NOT NULL + AND cardinality(array_remove(roles,'SPEED'))=0) THEN + RAISE EXCEPTION 'Resolve SPEED-only invitations before upgrade'; + END IF; + UPDATE nexent.tenant_invitation_code_t + SET roles=array_remove(COALESCE(roles, CASE code_type + WHEN 'ADMIN_INVITE' THEN ARRAY['ADMIN']::VARCHAR(30)[] + WHEN 'DEV_INVITE' THEN ARRAY['DEV']::VARCHAR(30)[] + WHEN 'USER_INVITE' THEN ARRAY['USER']::VARCHAR(30)[] + WHEN 'ASSET_OWNER_INVITE' THEN ARRAY['ASSET_OWNER']::VARCHAR(30)[] END), 'SPEED'), + updated_by='ops-role-migration', update_time=CURRENT_TIMESTAMP + WHERE roles IS NULL OR 'SPEED'=ANY(roles); + IF EXISTS (SELECT 1 FROM nexent.tenant_invitation_code_t + WHERE delete_flag='N' AND (roles IS NULL OR cardinality(roles)=0 + OR array_position(roles,NULL) IS NOT NULL + OR NOT roles <@ ARRAY['ADMIN','DEV','USER','OPS','ASSET_OWNER']::VARCHAR(30)[] + OR ('ASSET_OWNER'=ANY(roles) AND cardinality(roles)>1))) THEN + RAISE EXCEPTION 'Resolve unknown invitation roles before upgrade'; + END IF; + UPDATE nexent.tenant_invitation_code_t SET roles=ARRAY[]::VARCHAR(30)[] WHERE roles IS NULL; + + ALTER TABLE nexent.tenant_invitation_code_t ALTER COLUMN roles SET NOT NULL; + COMMENT ON COLUMN nexent.tenant_invitation_code_t.roles IS 'Immutable built-in roles granted by this invitation'; + DELETE FROM nexent.role_permission_t WHERE user_role='SPEED'; + -- Historical explicit primary keys did not advance the SERIAL sequence. + PERFORM setval('nexent.role_permission_t_role_permission_id_seq', GREATEST( + (SELECT COALESCE(MAX(role_permission_id),1) FROM nexent.role_permission_t), + (SELECT last_value FROM nexent.role_permission_t_role_permission_id_seq)), true); + + -- Existing role permission storage remains the only grant/menu source. + INSERT INTO nexent.role_permission_t + (role_permission_id, user_role, permission_category, permission_type, permission_subtype) + SELECT nextval('nexent.role_permission_t_role_permission_id_seq'), r.role_code, + 'RESOURCE', g.permission_type, g.permission_subtype + FROM (VALUES ('OPS'),('SU')) AS r(role_code) + CROSS JOIN (VALUES + ('OPS.DASHBOARD','READ'),('OPS.DASHBOARD','CREATE'),('OPS.DASHBOARD','UPDATE'),('OPS.DASHBOARD','DELETE'), + ('OPS.CONFIGURATION','READ'),('OPS.CONFIGURATION','CREATE'),('OPS.CONFIGURATION','UPDATE'),('OPS.CONFIGURATION','DELETE'), + ('RESOURCE.INVENTORY','READ')) AS g(permission_type,permission_subtype) + WHERE NOT EXISTS (SELECT 1 FROM nexent.role_permission_t p WHERE p.user_role=r.role_code + AND p.permission_category='RESOURCE' AND p.permission_type=g.permission_type + AND p.permission_subtype=g.permission_subtype); + + INSERT INTO nexent.role_permission_t + (role_permission_id, user_role, permission_category, permission_type, permission_subtype) + SELECT nextval('nexent.role_permission_t_role_permission_id_seq'), r.role_code, + 'VISIBILITY','LEFT_NAV_MENU',m.route + FROM (VALUES ('OPS'),('SU')) AS r(role_code) + CROSS JOIN (VALUES ('/ops/dashboard'),('/ops/configuration'),('/resource-manage')) AS m(route) + WHERE NOT EXISTS (SELECT 1 FROM nexent.role_permission_t p WHERE p.user_role=r.role_code + AND p.permission_type='LEFT_NAV_MENU' AND p.permission_subtype=m.route); +END $account_and_ops_roles$; diff --git a/deploy/sql/tools/supabase_to_postgres/README.md b/deploy/sql/tools/supabase_to_postgres/README.md index 23eb2f2f3d..bf6e8aa286 100644 --- a/deploy/sql/tools/supabase_to_postgres/README.md +++ b/deploy/sql/tools/supabase_to_postgres/README.md @@ -24,7 +24,7 @@ from the target's pre-upgrade backup; Supabase cannot reconstruct application ro | --- | --- | --- | | Database ready | Start selected infrastructure; PostgreSQL initializes only `deploy/sql/init.sql` on an empty data directory | Install/wait for infrastructure; its SQL ConfigMap contains only `init.sql` | | Historical account import | Host Shell `migrate.sh --apply` runs before HTTPS/core service startup | Host Shell `migrate.sh --deployment kubernetes --apply` runs before application Secret replacement and application Helm rollout | -| Target transaction | Recheck account coverage under the account-writer lock, run consolidated 001, transfer profiles and credentials, then commit | Same transaction through target PostgreSQL pod | +| Target transaction | Recheck account coverage under the account-writer lock, run historical 001 and account-array 010, transfer profiles and credentials, then commit | Same transaction through target PostgreSQL pod | | Normal application SQL | Shared Workbench initialization and Config startup may run the normal SQL runner after import | Application Helm rollout runs SQL in Config or its bootstrap Job after import, then waits for business workloads | | Administrator bootstrap | Run only after core deployment and its SQL succeed | Run only after the application bootstrap/rollout succeeds | @@ -46,7 +46,7 @@ An existing incomplete table is not imported into or modified automatically. For a new migration, failed validation rolls back the new table, exclusions and profile moves before commit. Completed local accounts are preserved on rerun. -`v2.9.0_001_local_user_accounts.sql` creates the final schema directly, without +The immutable `v2.9.0_001_local_user_accounts.sql` creates the legacy account schema, without `account_status` or pending placeholders. Legacy status columns are converted to `delete_flag` and dropped inside the same transaction; ordinary replay never recreates them. Existing soft-deleted accounts, credentials and operator changes @@ -56,6 +56,13 @@ and emails to the importer without persisting unavailable ordinary identities. Active CAS/OAuth bindings remain in the project database; provider-only accounts have no local password login, while actual local password credentials retain it. +The final account schema stores only `user_roles`; scalar response roles are +computed. The importer applies 010 after historical 001 inside the same transaction +and writes only the array. Inspection recognizes both layouts without recreating +columns. Historical replay is followed by 010, which preserves existing arrays +and drops any restored scalar column. Operations grants remain in independent +011; account recovery has no dependency on operations tables. + ## Prepare the upgrade Back up the original Supabase and target PostgreSQL data, settings and signing @@ -66,9 +73,11 @@ acceptance completes. The host needs Bash and Docker CLI, or Bash and kubectl with access to the original namespace. **No host Python, pip, PostgreSQL driver or jq is required.** The selected `NEXENT_IMAGE` must package the current Python worker, its existing psycopg2 -runtime, and `v2.9.0_001_local_user_accounts.sql`. Standard deployment selects that -image automatically; preload it for offline deployment. The independent gateway -image is not the migration image. +runtime, `v2.9.0_001_local_user_accounts.sql`, and +`v2.9.0_010_account_and_ops_roles.sql`. The worker applies the account schema changes +inside the credential transaction. The startup SQL runner also applies invitation +roles and operations grants. Standard deployment selects the image automatically. +Preload it for offline deployment. The migration worker uses the main backend image. `migrate.sh` is the host entry point for both Docker and Kubernetes. `migrate_docker.py` runs inside the worker container or Job. Recovery defaults @@ -201,12 +210,13 @@ source-directory deletion. No recovery or migration command needs host Python. | Supabase password users | Preserve UUID, compatible bcrypt/scrypt hash, source timestamps, email confirmation and bounded provider metadata | | Banned/deleted/anonymous users and orphans | Exclude from the new account table. Original Supabase records and membership rows remain; no role or tenant is synthesized | | API-only batch users | Active blank-email USER/DEV membership plus historical token evidence creates a passwordless account; preserve ID, original keys and revoked key state | -| Default SPEED seed | Preserve `user_id` / `tenant_id`, mark `system_identity=speed`, keep unavailable for full-mode login. Speed mode retains its existing demo context | +| Default SPEED seed | Preserve `user_id` / `tenant_id`, mark `system_identity=speed`, keep unavailable for full-mode login. The retired SPEED role is removed without granting replacement permissions | | Unknown/custom identities | Unresolved active members stop migration with IDs; recover source/business profiles without generating replacement IDs or roles | | CAS/OAuth | Existing provider bindings and CAS sessions remain in business PostgreSQL. Preserve original IDs and valid bindings. Provider-only identities stay passwordless; accounts with actual local password credentials retain password login. Import Supabase credentials only where those source identities exist | | Audit and refresh data | Do not read/import `auth.audit_log_entries`, Supabase sessions, refresh tokens or recovery/confirmation secrets. No audit archive is created | -Canonical `user_email` and `user_role` move from `user_tenant_t` to the account; +Canonical `user_email` and the legacy role move from `user_tenant_t` to the account. +The role is stored only in `user_roles`; scalar API values are derived from that array; tenant, group, key and business ownership remain unchanged. Provider metadata never supplies an application role. `source` counts rows read from the original source. `updated` counts hydrated legacy profiles, diff --git a/deploy/sql/tools/supabase_to_postgres/inspect_accounts.sql b/deploy/sql/tools/supabase_to_postgres/inspect_accounts.sql index 2095eafa7b..d005f21170 100644 --- a/deploy/sql/tools/supabase_to_postgres/inspect_accounts.sql +++ b/deploy/sql/tools/supabase_to_postgres/inspect_accounts.sql @@ -10,6 +10,8 @@ DECLARE excluded text := 'false'; provider text := 'false'; api_only text := 'false'; + role_values text := 'ARRAY[]::varchar[]'; + valid_roles text; BEGIN IF legacy THEN SELECT count(DISTINCT user_id) INTO members FROM nexent.user_tenant_t WHERE delete_flag='N'; @@ -18,6 +20,17 @@ BEGIN SELECT count(*) INTO account_count FROM nexent.user_account_t; END IF; IF legacy AND accounts THEN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='nexent' AND table_name='user_account_t' AND column_name='user_roles') THEN + role_values := 'a.user_roles'; + ELSIF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='nexent' AND table_name='user_account_t' AND column_name='roles') THEN + role_values := 'a.roles'; + ELSIF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='nexent' AND table_name='user_account_t' AND column_name='user_role') THEN + role_values := 'CASE WHEN nullif(trim(a.user_role),'''') IS NULL THEN ARRAY[]::varchar[] ELSE ARRAY[a.user_role]::varchar[] END'; + END IF; + valid_roles := format('(cardinality(%1$s)>0 AND array_position(%1$s,NULL) IS NULL AND %1$s <@ ARRAY[''SU'',''ADMIN'',''DEV'',''USER'',''OPS'',''ASSET_OWNER'',''SPEED'']::varchar[])',role_values); IF to_regclass('nexent.account_migration_exclusion_t') IS NOT NULL THEN excluded := 'EXISTS (SELECT 1 FROM nexent.account_migration_exclusion_t e WHERE e.user_id=u.user_id AND e.delete_flag=''N'')'; END IF; @@ -25,7 +38,7 @@ BEGIN provider := 'EXISTS (SELECT 1 FROM nexent.user_oauth_account_t p WHERE p.user_id=u.user_id AND p.delete_flag=''N'')'; END IF; IF to_regclass('nexent.user_token_info_t') IS NOT NULL THEN - api_only := '(a.user_email IS NULL AND a.user_role IN (''USER'',''DEV'') AND EXISTS (SELECT 1 FROM nexent.user_token_info_t t WHERE t.user_id=u.user_id))'; + api_only := format('(a.user_email IS NULL AND %s <@ ARRAY[''USER'',''DEV'']::varchar[] AND EXISTS (SELECT 1 FROM nexent.user_token_info_t t WHERE t.user_id=u.user_id))',role_values); END IF; EXECUTE format($query$ SELECT coalesce(json_agg(user_id ORDER BY user_id),'[]'::json) FROM ( @@ -33,8 +46,8 @@ BEGIN WHERE u.delete_flag='N' AND NOT (%s) AND NOT EXISTS ( SELECT 1 FROM nexent.user_account_t a WHERE a.user_id=u.user_id AND ( (a.delete_flag='Y' AND NOT (a.source_metadata ? 'credential_migration_pending')) - OR (a.user_id='user_id' AND a.user_role='SPEED' AND a.source_metadata->>'system_identity'='speed') - OR (a.delete_flag='N' AND nullif(trim(a.user_role),'') IS NOT NULL AND ( + OR (a.user_id='user_id' AND a.source_metadata->>'system_identity'='speed') + OR (a.delete_flag='N' AND %s AND ( (a.token_version>0 AND NOT a.password_login_enabled) OR (a.password_login_enabled AND nullif(trim(a.user_email),'') IS NOT NULL AND nullif(a.password_hash,'') IS NOT NULL AND a.password_hash_scheme IN ('bcrypt','scrypt')) OR (%s AND nullif(trim(a.user_email),'') IS NOT NULL) OR %s @@ -42,7 +55,7 @@ BEGIN ) ) ) missing - $query$,excluded,provider,api_only) INTO missing_ids; + $query$,excluded,valid_roles,provider,api_only) INTO missing_ids; ELSIF legacy THEN SELECT coalesce(json_agg(user_id ORDER BY user_id),'[]'::json) INTO missing_ids FROM (SELECT DISTINCT user_id FROM nexent.user_tenant_t WHERE delete_flag='N') m; diff --git a/deploy/sql/tools/supabase_to_postgres/migrate_docker.py b/deploy/sql/tools/supabase_to_postgres/migrate_docker.py index 3aca85a786..bdc02487e7 100644 --- a/deploy/sql/tools/supabase_to_postgres/migrate_docker.py +++ b/deploy/sql/tools/supabase_to_postgres/migrate_docker.py @@ -159,9 +159,10 @@ def _account_import_sql(source_rows, *, dry_run=False): row["user_email"] = row.pop("email") encoded = json.dumps(rows, default=lambda value: value.isoformat()).replace("'", "''") schema = (ROOT / "deploy/sql/migrations/v2.9.0_001_local_user_accounts.sql").read_text(encoding="utf-8") + roles_schema = (ROOT / "deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql").read_text(encoding="utf-8") return "SET LOCAL standard_conforming_strings=on;\n" + inspection_sql() + r""" SET LOCAL nexent.account_import='on'; -""" + schema + "\n" + r""" +""" + schema + "\n" + roles_schema + "\n" + r""" CREATE TABLE IF NOT EXISTS nexent.account_migration_exclusion_t ( exclusion_id SERIAL PRIMARY KEY, user_id VARCHAR(100) NOT NULL UNIQUE, @@ -183,7 +184,7 @@ def _account_import_sql(source_rows, *, dry_run=False): COMMENT ON COLUMN nexent.account_migration_exclusion_t.delete_flag IS 'N for applicable historical exclusion, Y for retired evidence'; CREATE TEMP TABLE account_import ON COMMIT DROP AS -SELECT s.*, (s.delete_flag='Y') AS source_inactive, coalesce(p.role_value,a.user_role) AS role_value, +SELECT s.*, (s.delete_flag='Y') AS source_inactive, coalesce(CASE WHEN p.role_value IS NOT NULL THEN ARRAY[p.role_value]::varchar[] END,a.user_roles) AS role_values, coalesce(p.email_value,a.user_email) AS profile_email, (p.user_id IS NOT NULL OR a.user_id IS NOT NULL) AS existing, NOT EXISTS (SELECT 1 FROM nexent.user_tenant_t u @@ -191,7 +192,7 @@ def _account_import_sql(source_rows, *, dry_run=False): FROM jsonb_populate_recordset(NULL::nexent.user_account_t, '__ROWS__'::jsonb) s LEFT JOIN pg_temp.account_migration_profiles p ON p.user_id=s.user_id LEFT JOIN nexent.user_account_t a ON a.user_id=s.user_id; -UPDATE account_import SET delete_flag='Y' WHERE orphan OR nullif(trim(role_value),'') IS NULL; +UPDATE account_import SET delete_flag='Y' WHERE orphan OR role_values IS NULL OR cardinality(role_values)=0; DO $validate$ DECLARE conflict_ids json; BEGIN @@ -205,15 +206,15 @@ def _account_import_sql(source_rows, *, dry_run=False): END $validate$; UPDATE nexent.user_account_t a SET - user_email=s.user_email,user_role=s.role_value,password_hash=s.password_hash,password_hash_scheme=s.password_hash_scheme, + user_email=s.user_email,user_roles=s.role_values,password_hash=s.password_hash,password_hash_scheme=s.password_hash_scheme, password_login_enabled=s.password_login_enabled,token_version=s.token_version, email_confirmed_at=s.email_confirmed_at,source_metadata=s.source_metadata, create_time=s.create_time,update_time=s.update_time,delete_flag=s.delete_flag,updated_by='auth-migration' FROM account_import s WHERE a.user_id=s.user_id; INSERT INTO nexent.user_account_t - (user_id,user_email,user_role,password_hash,password_hash_scheme,password_login_enabled,token_version, + (user_id,user_email,user_roles,password_hash,password_hash_scheme,password_login_enabled,token_version, email_confirmed_at,source_metadata,create_time,update_time,delete_flag,created_by,updated_by) -SELECT user_id,user_email,role_value,password_hash,password_hash_scheme,password_login_enabled,token_version, +SELECT user_id,user_email,role_values,password_hash,password_hash_scheme,password_login_enabled,token_version, email_confirmed_at,source_metadata,create_time,update_time,delete_flag,'auth-migration','auth-migration' FROM account_import s WHERE delete_flag='N' AND NOT EXISTS (SELECT 1 FROM nexent.user_account_t a WHERE a.user_id=s.user_id); diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 2b65efc18a..a2271ac9f8 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -323,6 +323,7 @@ deployment_prepare_config --local-config "$FULL_CONFIG" --use-local-config --app assert_contains "$DEPLOYMENT_COMPONENTS" "data-process" "use local config should keep saved data-process when legacy env exists" assert_contains "$DEPLOYMENT_SELECTED_DOCKER_SERVICES" "nexent-data-process" "use local config should select data-process docker service" assert_eq "development" "$DEPLOYMENT_PORT_POLICY" "use local config should keep saved port policy over legacy mode" +assert_eq "full" "$DEPLOYMENT_VERSION" "legacy speed input must retain authentication" assert_eq "local-latest" "$DEPLOYMENT_IMAGE_SOURCE" "use local config should keep saved image source over legacy mainland flag" unset DEPLOYMENT_VERSION DEPLOYMENT_MODE IS_MAINLAND diff --git a/deploy/tests/test_images_build.sh b/deploy/tests/test_images_build.sh index 221390feb4..5eda90515f 100755 --- a/deploy/tests/test_images_build.sh +++ b/deploy/tests/test_images_build.sh @@ -167,5 +167,6 @@ assert_contains "$(cat "$PROJECT_ROOT/deploy/images/dockerfiles/main/Dockerfile" [ -f "$PROJECT_ROOT/deploy/sql/tools/supabase_to_postgres/migrate.sh" ] || fail "Host Shell migration entry point is missing" [ -f "$PROJECT_ROOT/deploy/sql/tools/supabase_to_postgres/migrate_kubernetes.sh" ] || fail "Kubernetes Shell transport is missing" assert_contains "$(cat "$PROJECT_ROOT/deploy/images/dockerfiles/main/Dockerfile")" 'COPY deploy/sql/migrations/v2.9.0_001_local_user_accounts.sql /opt/deploy/sql/migrations/' "backend image must package the worker account SQL" +assert_contains "$(cat "$PROJECT_ROOT/deploy/images/dockerfiles/main/Dockerfile")" 'COPY deploy/sql/migrations/v2.9.0_010_account_and_ops_roles.sql /opt/deploy/sql/migrations/' "backend image must package the canonical account role SQL" echo "All image build tests passed." diff --git a/frontend/app/[locale]/agent-space/agent-space.tsx b/frontend/app/[locale]/agent-space/agent-space.tsx index 861206b043..73f9a4675d 100644 --- a/frontend/app/[locale]/agent-space/agent-space.tsx +++ b/frontend/app/[locale]/agent-space/agent-space.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; import { useMemo, useState } from "react"; import type { MenuProps } from "antd"; @@ -17,7 +17,7 @@ import { import { useUserDisplayName } from "@/hooks/auth/useUserDisplayName"; import { useTranslation } from "react-i18next"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; + import { useTagLibraries, useTagDefinitions } from "@/hooks/useTagManagement"; import { getTagSearchPredicates } from "@/lib/systemTagLabels"; @@ -45,7 +45,7 @@ export function AgentSpace({ active }: { active: boolean }) { const displayName = useUserDisplayName(); const { message } = App.useApp(); const { user } = useAuthorizationContext(); - const showAdminMenu = user?.role === USER_ROLES.ADMIN; + const showAdminMenu = hasRole(user, "ADMIN"); const [searchQuery, setSearchQuery] = useState(""); const [tagPredicates, setTagPredicates] = useState( [] diff --git a/frontend/app/[locale]/agent-space/page.tsx b/frontend/app/[locale]/agent-space/page.tsx index bde58e2f0a..2df0519b5e 100644 --- a/frontend/app/[locale]/agent-space/page.tsx +++ b/frontend/app/[locale]/agent-space/page.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; import { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; @@ -9,7 +9,7 @@ import { motion } from "framer-motion"; import { Bot, Inbox, ShieldCheck, User } from "lucide-react"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; + import { useSetupFlow } from "@/hooks/useSetupFlow"; import { useAgentRepositoryListings } from "@/hooks/agentRepository/useAgentRepositoryListings"; import { useAgentList } from "@/hooks/agent/useAgentList"; @@ -28,7 +28,7 @@ export default function AgentRepositoryPage() { const { pageVariants, pageTransition } = useSetupFlow(); const searchParams = useSearchParams(); const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; + const isAdmin = hasRole(user, "ADMIN"); const [tab, setTab] = useState(() => { const backTab = searchParams.get("back_tab"); if (backTab === "mine") return AgentRepositoryTab.MINE; diff --git a/frontend/app/[locale]/agent-space/review-center.tsx b/frontend/app/[locale]/agent-space/review-center.tsx index 8359d21568..189a832beb 100644 --- a/frontend/app/[locale]/agent-space/review-center.tsx +++ b/frontend/app/[locale]/agent-space/review-center.tsx @@ -1,4 +1,6 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; +import { hasRole } from "@/lib/roles"; import { useMemo, useState } from "react"; import { App, Button, Empty, Spin } from "antd"; @@ -6,7 +8,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { AgentDetail } from "@/components/agent/agent-detail"; -import { USER_ROLES } from "@/const/auth"; + import { useAgentRepositoryListings, useUpdateAgentRepositoryStatus, @@ -27,7 +29,7 @@ export function ReviewCenter({ active }: { active: boolean }) { const { t } = useTranslation("common"); const { message } = App.useApp(); const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; + const isAdmin = hasRole(user, "ADMIN"); const currentUserEmail = user?.email; const [page, setPage] = useState(1); const pageSize = REVIEW_PAGE_SIZE; diff --git a/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx b/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx index 8e66bc2614..60b919a68e 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx @@ -36,7 +36,10 @@ import Image from "next/image"; import { useAgentStore } from "@/stores/agentStore"; import { ModelDrawer } from "@/components/resource-picker/ModelDrawer"; -import { MODEL_TOKEN_LIMIT_KEYS, withoutModelTokenLimits } from "@/lib/modelParameterLayers"; +import { + MODEL_TOKEN_LIMIT_KEYS, + withoutModelTokenLimits, +} from "@/lib/modelParameterLayers"; import { AgentModelParameters } from "@/components/model/AgentModelParameters"; import { ModelParamsModal } from "@/components/resource-picker/ModelParamsModal"; import { useModelList } from "@/hooks/model/useModelList"; @@ -440,7 +443,7 @@ export default function AgentPrompt({ agentId === null || modelInitialization.current.complete || isReadOnly || - (!canManageModels(user?.role ?? "") && !isSpeedMode) + (!canManageModels(user?.roles ?? user?.role) && !isSpeedMode) ) return; @@ -468,7 +471,7 @@ export default function AgentPrompt({ modelListLoaded, modelOptions, updateAgent, - user?.role, + user?.roles ?? user?.role, ]); useEffect(() => { @@ -632,7 +635,8 @@ export default function AgentPrompt({ for (const [key, val] of Object.entries(editingOverrideValue)) { // Custom params are diffed per-key below (deleting an inherited row // must persist a removal, not drop the key). - if (key === "__custom__" || MODEL_TOKEN_LIMIT_KEYS.includes(key)) continue; + if (key === "__custom__" || MODEL_TOKEN_LIMIT_KEYS.includes(key)) + continue; // Reasoning is an agent-owned snapshot; do not compare with the model // level setting. if ( @@ -659,7 +663,7 @@ export default function AgentPrompt({ setConfiguringModelId(null); }; - const canManage = canManageModels(user?.role ?? ""); + const canManage = canManageModels(user?.roles ?? user?.role); const isModelSelectionDisabled = isReadOnly || (!canManage && !isSpeedMode); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), @@ -1078,40 +1082,48 @@ export default function AgentPrompt({ onClose={() => setConfiguringModelId(null)} onApply={(entry) => { const updated = { ...modelParamsOverride }; - if (Object.keys(entry).length) updated[String(configuringModel.id)] = entry; + if (Object.keys(entry).length) + updated[String(configuringModel.id)] = entry; else delete updated[String(configuringModel.id)]; - updateAgent({ model_params_override: Object.keys(updated).length ? updated : null }); + updateAgent({ + model_params_override: Object.keys(updated).length + ? updated + : null, + }); }} /> - ) : setConfiguringModelId(null)} - onConfirm={handleConfirmOverride} - modelOptions={modelOptions} - selectedModelId={configuringModelId} - onSelectModel={(id) => setConfiguringModelId(id)} - value={editingOverrideValue ?? {}} - onChange={(next) => setEditingOverrideValue(next)} - modelType={(configuringModel as any)?.type ?? "llm"} - reasoningCapability={(configuringModel as any)?.reasoningCapability} - inheritedDefaults={{ - context_window_tokens: (configuringModel as any)?.contextWindowTokens, - max_input_tokens: (configuringModel as any)?.maxInputTokens, - max_output_tokens: (configuringModel as any)?.maxOutputTokens, - default_output_reserve_tokens: (configuringModel as any) - ?.defaultOutputReserveTokens, - temperature: (configuringModel as any)?.temperature, - top_p: (configuringModel as any)?.topP, - }} - disabled={isModelSelectionDisabled} - />} + ) : ( + setConfiguringModelId(null)} + onConfirm={handleConfirmOverride} + modelOptions={modelOptions} + selectedModelId={configuringModelId} + onSelectModel={(id) => setConfiguringModelId(id)} + value={editingOverrideValue ?? {}} + onChange={(next) => setEditingOverrideValue(next)} + modelType={(configuringModel as any)?.type ?? "llm"} + reasoningCapability={(configuringModel as any)?.reasoningCapability} + inheritedDefaults={{ + context_window_tokens: (configuringModel as any) + ?.contextWindowTokens, + max_input_tokens: (configuringModel as any)?.maxInputTokens, + max_output_tokens: (configuringModel as any)?.maxOutputTokens, + default_output_reserve_tokens: (configuringModel as any) + ?.defaultOutputReserveTokens, + temperature: (configuringModel as any)?.temperature, + top_p: (configuringModel as any)?.topP, + }} + disabled={isModelSelectionDisabled} + /> + )} ); } diff --git a/frontend/app/[locale]/agents/components/capability/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/capability/SkillBuildModal.tsx index cd0a23d1b3..ef4d55c479 100644 --- a/frontend/app/[locale]/agents/components/capability/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/capability/SkillBuildModal.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; import { useState, @@ -60,7 +60,6 @@ const { TextArea } = Input; const CAN_EDIT_ALL_ROLES: ReadonlySet = new Set([ USER_ROLES.SU, USER_ROLES.ADMIN, - USER_ROLES.SPEED, USER_ROLES.ASSET_OWNER, ]); @@ -165,7 +164,7 @@ export default function SkillBuildModal({ const isEditMode = Boolean(editingSkill); const creationConversationId = useRef(undefined); const savedCreatorSkillId = useRef(undefined); - const isAdmin = !!user?.role && CAN_EDIT_ALL_ROLES.has(user.role); + const isAdmin = hasRole(user, ...CAN_EDIT_ALL_ROLES); const isCreator = !isEditMode || (!!editingSkill?.created_by && @@ -216,12 +215,11 @@ export default function SkillBuildModal({ const [tagManagementOpen, setTagManagementOpen] = useState(false); const { data: tagLibraries } = useTagLibraries(); const defaultTagLibrary = - tagLibraries?.find((library) => library.bucket_key === "default_resource") ?? - null; - const { - data: tagDefinitions, - refresh: refreshTagDefinitions, - } = useTagDefinitions(defaultTagLibrary?.bucket_id ?? null); + tagLibraries?.find( + (library) => library.bucket_key === "default_resource" + ) ?? null; + const { data: tagDefinitions, refresh: refreshTagDefinitions } = + useTagDefinitions(defaultTagLibrary?.bucket_id ?? null); const [interactiveSkillName, setInteractiveSkillName] = useState(""); @@ -509,7 +507,7 @@ export default function SkillBuildModal({ ...values, // Legacy flat tags are compatibility-only. Existing values are // preserved on edit; new assignments use the structured modal. - tags: isEditMode ? editingSkill?.tags ?? [] : [], + tags: isEditMode ? (editingSkill?.tags ?? []) : [], content, files: extraFiles.length > 0 ? extraFiles : undefined, } as SkillData, @@ -692,10 +690,7 @@ export default function SkillBuildModal({ }; const handleAttemptRollback = (event: Nl2SkillStreamEvent): boolean => { - if ( - event.type === "model_attempt_control" && - event.phase === "rollback" - ) { + if (event.type === "model_attempt_control" && event.phase === "rollback") { rollbackDraftStream(); return true; } @@ -909,10 +904,13 @@ export default function SkillBuildModal({ const bindSavedSkill = async (id: number) => { savedCreatorSkillId.current = id; if (creationConversationId.current) { - await conversationService.bindTarget(Number(creationConversationId.current), { - type: "skill", - id, - }); + await conversationService.bindTarget( + Number(creationConversationId.current), + { + type: "skill", + id, + } + ); } }; @@ -992,148 +990,148 @@ export default function SkillBuildModal({ return ( <> - -
- {isEditMode - ? t("skillManagement.edit.title") - : t("skillManagement.title")} -
-
- {isEditMode - ? t("skillManagement.edit.subtitle", { name: editingSkillName }) - : t("skillManagement.create.subtitle")} + +
+ {isEditMode + ? t("skillManagement.edit.title") + : t("skillManagement.title")} +
+
+ {isEditMode + ? t("skillManagement.edit.subtitle", { name: editingSkillName }) + : t("skillManagement.create.subtitle")} +
- - } - open={isOpen} - onCancel={handleModalClose} - destroyOnHidden - zIndex={zIndex} - centered - width="min(1180px, calc(100vw - 32px))" - styles={{ - container: { - display: "flex", - flexDirection: "column", - maxHeight: modalViewportFrame, - overflow: "hidden", - }, - body: { - display: "flex", - flex: "1 1 auto", - flexDirection: "column", - height: modalBodyFrame, - maxHeight: modalBodyFrame, - minHeight: 0, - overflow: "hidden", - }, - }} - footer={[ - , - isEditMode || activeTab === "interactive" ? ( - , + isEditMode || activeTab === "interactive" ? ( + + ) : ( + + ), + ]} + > + { + if (!isEditMode) { + setActiveTab(key); } - > - {getConfirmButtonText()} - + }} + items={visibleTabItems} + className="skill-build-tabs shrink-0" + /> + {isEditMode && !isEditContentReady ? ( +
+ + {editFilesError ? ( +

{editFilesError}

+ ) : ( +
+ )} + +
+ ) : isEditMode || activeTab === "interactive" ? ( +
+ {renderChatPanel()} + {renderDraftPanel()} +
) : ( - - ), - ]} - > - { - if (!isEditMode) { - setActiveTab(key); +
{renderUploadTab()}
+ )} + - - { - setAssignTagsOpen(false); - setTagPreviewRefreshKey((current) => current + 1); - }} - resourceType="skill" - resourceId={String(editingSkill?.skill_id ?? "")} - definitions={tagDefinitions ?? []} - canEdit={editingSkill?.permission !== "READ_ONLY"} - onManageDefinitions={() => { - setTagManagementOpen(true); - }} - /> - { - setTagManagementOpen(false); - void refreshTagDefinitions(); - }} - bucketId={defaultTagLibrary?.bucket_id ?? 0} - bucketName={defaultTagLibrary?.bucket_name ?? ""} - canManage={editingSkill?.permission !== "READ_ONLY"} - /> + .expanded-file-editor textarea { + max-height: 70vh !important; + overflow-y: auto !important; + resize: none !important; + } + `} + + { + setAssignTagsOpen(false); + setTagPreviewRefreshKey((current) => current + 1); + }} + resourceType="skill" + resourceId={String(editingSkill?.skill_id ?? "")} + definitions={tagDefinitions ?? []} + canEdit={editingSkill?.permission !== "READ_ONLY"} + onManageDefinitions={() => { + setTagManagementOpen(true); + }} + /> + { + setTagManagementOpen(false); + void refreshTagDefinitions(); + }} + bucketId={defaultTagLibrary?.bucket_id ?? 0} + bucketName={defaultTagLibrary?.bucket_name ?? ""} + canManage={editingSkill?.permission !== "READ_ONLY"} + /> ); } diff --git a/frontend/app/[locale]/evaluation/page.tsx b/frontend/app/[locale]/evaluation/page.tsx index f9e947e23b..1ac8afed41 100644 --- a/frontend/app/[locale]/evaluation/page.tsx +++ b/frontend/app/[locale]/evaluation/page.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; + import { useState, useEffect, @@ -71,7 +72,6 @@ const { Text, Title } = Typography; const EVALUATION_DELETE_ALL_ROLES = new Set([ USER_ROLES.SU, USER_ROLES.ADMIN, - USER_ROLES.SPEED, USER_ROLES.ASSET_OWNER, ]); @@ -182,7 +182,7 @@ function RunsTab() { const { user } = useAuthorizationContext(); const canDeleteRun = (r: any) => (user?.id != null && r?.created_by === user.id) || - EVALUATION_DELETE_ALL_ROLES.has(user?.role ?? ""); + hasRole(user, ...EVALUATION_DELETE_ALL_ROLES); // ── Drawer (create-evaluation form) state ───────────────────────────── // Short variable names intentionally match the drawer inputs one-to-one: diff --git a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx index 9c011fe6a1..2f715e03ca 100644 --- a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx +++ b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseList.tsx @@ -1,4 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +import { hasRole } from "@/lib/roles"; import React, { useCallback, @@ -57,7 +58,6 @@ import knowledgeBaseService from "@/services/knowledgeBaseService"; import { formatDateOrFallback } from "@/lib/date"; import { formatFileSize } from "@/lib/utils"; import { getKnowledgeBaseQuotaDisplay } from "@/lib/knowledgeBaseQuota"; -import { USER_ROLES } from "@/const/auth"; import log from "@/lib/logger"; import type { KBQuotaStatus, QuotaUsageResponse } from "@/types/quota"; @@ -288,7 +288,7 @@ const KnowledgeBaseList: React.FC = ({ const { user } = useAuthorizationContext(); const { isReady: permissionsReady, can } = usePermission(); const tenantId = user?.tenantId || null; - const showPersonalCapacity = user?.role === USER_ROLES.USER; + const showPersonalCapacity = hasRole(user, "USER"); const quotaMap = useMemo(() => { const map = new Map(); quotaUsage?.breakdown?.forEach((quota) => { diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index fe831a9829..b2d466a822 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -39,7 +39,7 @@ export default async function RootLayout({ const { locale } = await params; return ( - + user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU, - [user?.role] + () => hasRole(user, "ADMIN") || hasRole(user, "SU"), + [user?.role, user?.roles] ); const [tab, setTab] = useState( diff --git a/frontend/app/[locale]/models/components/model/ModelOutletEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelOutletEditDialog.tsx index f7f0c7c252..a24b7dae61 100644 --- a/frontend/app/[locale]/models/components/model/ModelOutletEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelOutletEditDialog.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; +import { hasRole } from "@/lib/roles"; import { useQuery } from "@tanstack/react-query"; import { Alert, Button, Modal, Spin } from "antd"; @@ -38,7 +39,7 @@ export function ModelOutletEditDialog({ (!("reviewed_revision" in migration) || typeof migration.reviewed_revision !== "number" || migration.reviewed_revision <= 0); - const admin = user?.role === "SU" || user?.role === "ADMIN"; + const admin = hasRole(user, "SU", "ADMIN"); return ( ; +} diff --git a/frontend/app/[locale]/ops/dashboard/page.tsx b/frontend/app/[locale]/ops/dashboard/page.tsx new file mode 100644 index 0000000000..498b731460 --- /dev/null +++ b/frontend/app/[locale]/ops/dashboard/page.tsx @@ -0,0 +1,6 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"use client"; +import { OperationsPage } from "@/components/operations/OperationsPage"; +export default function Page() { + return ; +} diff --git a/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx b/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx index f6f9a082cc..ebaaaba4f4 100644 --- a/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx +++ b/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx @@ -1,11 +1,11 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { effectiveRoles, hasRole, roleLabelKey } from "@/lib/roles"; import { UserIdentity } from "@/components/auth/UserIdentity"; import { UserIdentityLabel } from "@/components/auth/UserIdentityLabel"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useEffectEvent, useRef } from "react"; import { useParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { @@ -70,7 +70,7 @@ import McpList from "./resources/McpList"; import SkillList from "./resources/SkillList"; import { useDeployment } from "@/components/providers/deploymentProvider"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; + import { Can } from "@/components/permission/Can"; import { getPasswordChecks, @@ -154,6 +154,10 @@ function TenantList({ message: string; }>({ target: "", message: "" }); + const showSkillLoadError = useEffectEvent(() => { + message.error(t("tenantResources.skills.loadFailed")); + }); + // Fetch official skills when install switch is toggled on useEffect(() => { if (!installOfficialSkills) return; @@ -175,7 +179,7 @@ function TenantList({ }) .catch(() => { if (!cancelled) { - message.error("Failed to load official skills"); + showSkillLoadError(); } }) .finally(() => { @@ -248,12 +252,9 @@ function TenantList({ onSelect(""); } } - } catch (error: any) { - const errorMessage = - error?.response?.data?.detail || error?.message || ""; + } catch (error) { message.error( getTenantResourceLimitMessage(error, t) || - errorMessage || t("tenantResources.tenantDeleteFailed") ); } finally { @@ -454,6 +455,7 @@ function TenantList({ } setModalVisible(false); } catch (err: any) { + if (err?.errorFields) return; const errorMessage = err?.response?.data?.message || err?.message || ""; const limitMessage = getTenantResourceLimitMessage(err, t); const nameConflictMatch = errorMessage.match( @@ -472,9 +474,7 @@ function TenantList({ message.error(t("tenantResources.tenants.nameRequired")); } else { message.error( - limitMessage || - errorMessage || - t("tenantResources.tenantOperationFailed") + limitMessage || t("tenantResources.tenantOperationFailed") ); } } @@ -1041,7 +1041,9 @@ function TenantList({ /> - {t(`user.role.${user.role?.toLowerCase()}`) || "-"} + {effectiveRoles(user) + .map((role) => t(roleLabelKey(role))) + .join(" / ") || "-"} ))} @@ -1083,7 +1085,8 @@ export default function UserManageComp() { }; // Check if user is super admin (speed mode or admin role) - const isSuperAdmin = isSpeedMode || user?.role === USER_ROLES.SU; + const isSuperAdmin = isSpeedMode || hasRole(user, "SU"); + const readOnly = hasRole(user, "OPS") && !hasRole(user, "ADMIN", "SU"); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -1204,10 +1207,9 @@ export default function UserManageComp() { } message.success(t("tenantResources.tenants.updated")); setIsEditingTenantName(false); - } catch (error: any) { + } catch (error) { message.error( getTenantResourceLimitMessage(error, t) || - error?.message || t("tenantResources.tenantOperationFailed") ); } @@ -1265,6 +1267,7 @@ export default function UserManageComp() {
)} + {readOnly && }
{isSuperAdmin && ( @@ -1309,7 +1312,11 @@ export default function UserManageComp() {
{hasSelectedTenant && (
- {isEditingTenantName ? ( + {readOnly ? ( +

+ {currentTenantName} +

+ ) : isEditingTenantName ? ( ), }, - ...(user?.role === USER_ROLES.ADMIN - ? [ - { - key: "apiKeys", - label: - t("tenantResources.tabs.apiKeys") || - "API Key", - children: , - }, - ] - : []), { key: "groups", label: t("tenantResources.tabs.groups") || "Groups", - children: , + children: , }, { key: "models", label: t("tenantResources.tabs.models") || "Models", - children: , + children: , }, // When AIDP knowledge is enabled, the knowledge tab is managed // via the unified `/knowledges` entry and must not appear here. @@ -1383,24 +1380,24 @@ export default function UserManageComp() { label: t("tenantResources.tabs.knowledge") || "Knowledge Base", - children: , + children: , }, ] : []), { key: "agents", label: t("tenantResources.tabs.agents") || "Agents", - children: , + children: , }, { key: "mcp", label: t("tenantResources.tabs.mcp") || "MCP", - children: , + children: , }, { key: "skills", label: "Skills", - children: , + children: , }, { key: "tags", @@ -1415,10 +1412,30 @@ export default function UserManageComp() { children: ( ), }, + ...(hasRole(user, "ADMIN", "SU") || readOnly + ? [ + { + key: "apiKeys", + label: + t("tenantResources.tabs.apiKeys") || + "API Key", + children: ( + + ), + }, + ] + : []), ]} /> ) : ( diff --git a/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx b/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx index e0be475fd5..a04a20e634 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -37,7 +37,7 @@ import ExpandEditModal from "@/components/common/ExpandEditModal"; import type { AgentVersion } from "@/services/agentVersionService"; import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; import ManageOfficialAgentsModal from "@/components/agent/ManageOfficialAgentsModal"; -import { USER_ROLES } from "@/const/auth"; + import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; const { Text } = Typography; @@ -65,12 +65,18 @@ type AgentListRow = Pick< current_version_no?: number; }; -export default function AgentList({ tenantId }: { tenantId: string | null }) { +export default function AgentList({ + tenantId, + readOnly = false, +}: { + tenantId: string | null; + readOnly?: boolean; +}) { const { confirm } = useConfirmModal(); const { t } = useTranslation("common"); const { message } = App.useApp(); const { user } = useAuthorizationContext(); - const canManageOfficialAgents = user?.role === USER_ROLES.SU; + const canManageOfficialAgents = hasRole(user, "SU"); const [form] = Form.useForm(); const queryClient = useQueryClient(); @@ -82,10 +88,7 @@ export default function AgentList({ tenantId }: { tenantId: string | null }) { // Fullscreen view modal state const [fullscreenEdit, setFullscreenEdit] = useState<{ visible: boolean; - field: - | "description" - | "prompt" - | null; + field: "description" | "prompt" | null; title: string; value: string; }>({ @@ -257,8 +260,7 @@ export default function AgentList({ tenantId }: { tenantId: string | null }) { // Fullscreen view handlers const openFullscreenEdit = ( - field: - "description" | "prompt", + field: "description" | "prompt", title: string ) => { const value = form.getFieldValue(field) || ""; @@ -509,25 +511,27 @@ export default function AgentList({ tenantId }: { tenantId: string | null }) { size="small" /> - -
), }, diff --git a/frontend/app/[locale]/resource-manage/components/resources/ApiKeyList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ApiKeyList.tsx index 0bbf474055..d7c3dcf77e 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ApiKeyList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ApiKeyList.tsx @@ -25,7 +25,13 @@ function formatTime(value?: string | null): string { return new Date(value).toLocaleString(); } -export default function ApiKeyList({ tenantId }: { tenantId: string | null }) { +export default function ApiKeyList({ + tenantId, + readOnly = false, +}: { + tenantId: string | null; + readOnly?: boolean; +}) { const { t } = useTranslation("common"); const { message, modal } = App.useApp(); const [page, setPage] = useState(1); @@ -73,7 +79,7 @@ export default function ApiKeyList({ tenantId }: { tenantId: string | null }) { key: "access_key", width: 320, render: (key: string) => ( - {key} + {readOnly ? "—" : key} ), }, { @@ -221,13 +227,16 @@ export default function ApiKeyList({ tenantId }: { tenantId: string | null }) { ), }, ], - [pendingUserId, t] + [pendingUserId, readOnly, t] ); + const visibleColumns = readOnly + ? columns.filter((column) => column.key !== "actions") + : columns; return ( column.key !== "actions") + : columns; const handlePageChange = (newPage: number, newPageSize: number) => { setPage(newPage); @@ -337,15 +346,17 @@ export default function GroupList({ tenantId }: { tenantId: string | null }) { className="w-48" />
- + {!readOnly && ( + + )}
String(r.group_id)} loading={isLoading} pagination={{ diff --git a/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx b/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx index cb25a95aff..28306d5706 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx @@ -1,5 +1,12 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; +import { getRoleColor } from "@/lib/auth"; +import log from "@/lib/logger"; +import { hasRole, roleLabelKey } from "@/lib/roles"; +import { isSessionExpired } from "@/const/errorCode"; +import { ApiError } from "@/services/api"; + import React, { useMemo, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; @@ -16,7 +23,7 @@ import { Collapse, DatePicker, Progress, - Tooltip + Tooltip, } from "antd"; import { copyToClipboard } from "@/lib/clipboard"; import { ColumnsType } from "antd/es/table"; @@ -48,24 +55,25 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { ASSET_OWNER_INVITE_CODE_TYPE, ASSET_OWNER_TENANT_ID, - USER_ROLES, } from "@/const/auth"; const { Panel } = Collapse; export default function InvitationList({ tenantId, + readOnly = false, refreshKey, }: { tenantId: string | null; + readOnly?: boolean; refreshKey?: number; }) { const { confirm } = useConfirmModal(); const { t } = useTranslation("common"); const { user } = useAuthorizationContext(); - const userRole = user?.role; - const isAdminRole = userRole === USER_ROLES.ADMIN; - const isSuperAdmin = userRole === USER_ROLES.SU; + const userRole = user?.roles ?? user?.role; + const isAdminRole = hasRole(userRole, "ADMIN"); + const isSuperAdmin = hasRole(userRole, "SU"); const isAssetOwnerInviteContext = tenantId === ASSET_OWNER_TENANT_ID; const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); @@ -119,14 +127,14 @@ export default function InvitationList({ defaultGroupIds = [defaultGroupId]; } } catch (error) { - console.warn("Failed to get default group:", error); + log.warn("Failed to get default group:", error); message.warning( t("tenantResources.invitation.loadDefaultGroupFailed") ); } } form.setFieldsValue({ - code_type: "USER_INVITE", + roles: ["USER"], capacity: 1, group_ids: defaultGroupIds, }); @@ -137,7 +145,14 @@ export default function InvitationList({ const openEdit = (invitation: Invitation) => { setEditingInvitation(invitation); form.setFieldsValue({ - code_type: invitation.code_type, + roles: invitation.roles ?? [ + { + ADMIN_INVITE: "ADMIN", + DEV_INVITE: "DEV", + USER_INVITE: "USER", + ASSET_OWNER_INVITE: "ASSET_OWNER", + }[invitation.code_type] ?? "USER", + ], capacity: invitation.capacity, invitation_code: invitation.invitation_code, group_ids: invitation.group_ids || [], @@ -148,28 +163,19 @@ export default function InvitationList({ setModalVisible(true); }; + const showInvitationError = (error: unknown, fallbackKey: string) => { + const code = error instanceof ApiError ? String(error.code) : ""; + if (code === "401" || code === "499" || isSessionExpired(code)) return; + message.error(t(code === "403" ? "errorCode.000201" : fallbackKey)); + }; + const handleDelete = async (invitationCode: string) => { try { await deleteInvitation(invitationCode); message.success(t("tenantResources.invitation.invitationDeleted")); refetch(); - } catch (error: any) { - // Check if it's an authentication error - 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"; - message.error(errorMessage); - } + } catch (error) { + showInvitationError(error, "tenantResources.invitation.deleteFailed"); } }; @@ -201,12 +207,13 @@ export default function InvitationList({ // 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; + : undefined; const createData: CreateInvitationRequest = { tenant_id: isAssetOwnerInviteContext ? ASSET_OWNER_TENANT_ID : tenantId!, code_type: codeType, + roles: isAssetOwnerInviteContext ? undefined : values.roles, invitation_code: values.invitation_code?.toUpperCase(), capacity: values.capacity, group_ids: isAssetOwnerInviteContext ? [] : values.group_ids || [], @@ -218,20 +225,13 @@ export default function InvitationList({ setModalVisible(false); refetch(); } catch (error: any) { - // Check if it's an authentication error - 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"; - message.error(errorMessage); - } + if (error?.errorFields) return; + const failureKey = editingInvitation + ? "tenantResources.invitation.updateFailed" + : Number(error?.code) === 409 + ? "tenantResources.invitation.alreadyExists" + : "tenantResources.invitation.createFailed"; + showInvitationError(error, failureKey); } }; @@ -259,7 +259,7 @@ export default function InvitationList({ dataIndex: "invitation_code", key: "invitation_code", width: 80, - render: (code: string) => ( + render: (code: string) => readOnly ? "—" : (
{code} @@ -281,13 +281,24 @@ export default function InvitationList({ dataIndex: "code_type", key: "code_type", width: 80, - render: (type: string) => { - return ( - - {t(`tenantResources.invitation.codeType.${type}`)} - - ); - }, + render: (type: string, record: Invitation) => ( + <> + {( + record.roles ?? [ + { + ADMIN_INVITE: "ADMIN", + DEV_INVITE: "DEV", + USER_INVITE: "USER", + ASSET_OWNER_INVITE: "ASSET_OWNER", + }[type] ?? type, + ] + ).map((role) => ( + + {t(roleLabelKey(role))} + + ))} + + ), }, { title: t("tenantResources.invitation.usage"), @@ -389,7 +400,9 @@ export default function InvitationList({ variant="solid" > {icon} - {t(`tenantResources.invitation.status.${status}`)} + {t(`tenantResources.invitation.status.${status}`, { + defaultValue: t("common.unknown"), + })} ); }, @@ -405,6 +418,7 @@ export default function InvitationList({
), }, ], - [groupNameMap] + [groupNameMap, readOnly, t] ); + const visibleColumns = readOnly + ? columns.filter((column) => column.key !== "actions") + : columns; // Group invitations by tenant for collapse view const groupedInvitations = useMemo(() => { @@ -451,20 +473,22 @@ export default function InvitationList({
- + {!readOnly && ( + + )}
{tenantId ? ( // Single tenant view with pagination
{!editingInvitation && !isAssetOwnerInviteContext && (
String(r.id)} loading={isLoading} pagination={{ @@ -411,16 +462,28 @@ export default function UserList({ /> setTenantId(value || undefined)} + options={[ + { value: "", label: t("ops.allTenants") }, + ...(tenants.data?.data ?? []).map((tenant) => ({ + value: tenant.tenant_id, + title: tenant.tenant_id, + label: tenant.tenant_name || tenant.tenant_id, + })), + ]} + /> + ) : ( + + {t("ops.tenant")}: {user?.tenantId} + + )} + {scope.isError && } + + {scope.isFetching ? : } + + + + ); +} diff --git a/frontend/components/providers/deploymentProvider.tsx b/frontend/components/providers/deploymentProvider.tsx index 010a2c41f6..0060d5f2e2 100644 --- a/frontend/components/providers/deploymentProvider.tsx +++ b/frontend/components/providers/deploymentProvider.tsx @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; import { @@ -43,7 +44,7 @@ interface DeploymentVersionResponse { } export function DeploymentProvider({ children }: { children: ReactNode }) { - const [isSpeedMode, setIsSpeedMode] = useState(false); + const isSpeedMode = false; const [isDeploymentReady, setIsDeploymentReady] = useState(false); const [appVersion, setAppVersion] = useState(APP_VERSION); const [deploymentVersion, setDeploymentVersion] = useState(""); @@ -59,7 +60,6 @@ export function DeploymentProvider({ children }: { children: ReactNode }) { ); const data: DeploymentVersionResponse = await response.json(); setDeploymentVersion(data.deployment_version); - setIsSpeedMode(data.deployment_version === "speed"); setEnableAidpKnowledge(data.enable_aidp_knowledge ?? false); setEnableAgentWorkbench(data.enable_agent_workbench ?? false); setHideHomePage(data.hide_home_page ?? false); @@ -68,7 +68,6 @@ export function DeploymentProvider({ children }: { children: ReactNode }) { } } catch (error) { log.error("Failed to fetch deployment info:", error); - setIsSpeedMode(false); } finally { setIsDeploymentReady(true); } diff --git a/frontend/components/providers/rootProvider.tsx b/frontend/components/providers/rootProvider.tsx index c36f2eec08..68436d6049 100644 --- a/frontend/components/providers/rootProvider.tsx +++ b/frontend/components/providers/rootProvider.tsx @@ -4,6 +4,10 @@ import { useEffect, type ReactNode } from "react"; import { ConfigProvider, App } from "antd"; +import antdEnUs from "antd/locale/en_US"; +import antdZhCn from "antd/locale/zh_CN"; +import "dayjs/locale/zh-cn"; +import { useTranslation } from "react-i18next"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@/lib/queryClient"; @@ -67,11 +71,16 @@ function AppReadyWrapper({ children }: { children?: ReactNode }) { * Integrates all necessary providers for the application */ export function RootProvider({ children }: { children: ReactNode }) { + const { i18n } = useTranslation("common"); + const language = i18n.resolvedLanguage ?? i18n.language; + const antdLocale = language?.startsWith("zh") ? antdZhCn : antdEnUs; + useEffect(() => { // Legacy static portals do not inherit the normal provider tree. ConfigProvider.config({ holderRender: (holder) => ( ConfigProvider.config({ holderRender: undefined }); - }, []); + }, [antdLocale]); return ( = { // User role constants export const USER_ROLES = { - SPEED: "SPEED", + OPS: "OPS", SU: "SU", ADMIN: "ADMIN", DEV: "DEV", diff --git a/frontend/ext_components/aidp/shared/hooks/useAidpGroupOptions.ts b/frontend/ext_components/aidp/shared/hooks/useAidpGroupOptions.ts index 1398288e8d..d33faa27fd 100644 --- a/frontend/ext_components/aidp/shared/hooks/useAidpGroupOptions.ts +++ b/frontend/ext_components/aidp/shared/hooks/useAidpGroupOptions.ts @@ -1,6 +1,7 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +import { hasRole } from "@/lib/roles"; import { useMemo } from "react"; -import { USER_ROLES } from "@/const/auth"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useGroupList } from "@/hooks/group/useGroupList"; import { usePermission } from "@/hooks/permission/usePermission"; @@ -14,7 +15,7 @@ export const useAidpGroupOptions = () => { const { user } = useAuthorizationContext(); const { can, isReady } = usePermission(); const canReadGroups = can("group:read"); - const isUser = user?.role === USER_ROLES.USER; + const isUser = hasRole(user, "USER"); const canConfigureGroupPermissions = Boolean(user) && !isUser; const tenantId = user?.tenantId ?? null; const groupQuery = useGroupList(canReadGroups ? tenantId : null); diff --git a/frontend/hooks/auth/useAuthenticationState.ts b/frontend/hooks/auth/useAuthenticationState.ts index b5a8efe37d..405b0f531c 100644 --- a/frontend/hooks/auth/useAuthenticationState.ts +++ b/frontend/hooks/auth/useAuthenticationState.ts @@ -1,10 +1,10 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { App } from "antd"; -import { useDeployment } from "@/components/providers/deploymentProvider"; import { useQueryClient } from "@tanstack/react-query"; import { authService } from "@/services/authService"; import { casService } from "@/services/casService"; @@ -32,7 +32,6 @@ import log from "@/lib/logger"; export function useAuthenticationState(): AuthenticationStateReturn { const { t } = useTranslation("common"); const { message } = App.useApp(); - const { isSpeedMode } = useDeployment(); const queryClient = useQueryClient(); // Authentication state @@ -43,25 +42,12 @@ export function useAuthenticationState(): AuthenticationStateReturn { const [authServiceUnavailable, setAuthServiceUnavailable] = useState(false); - // Speed mode: skip authentication checks, consider user as authenticated useEffect(() => { - if (isSpeedMode) { - // In speed mode, user is considered authenticated without session - setIsAuthenticated(true); - } else { - if (checkSessionValid()) { - const storedSession = getSessionFromStorage(); - if (storedSession) { - setSession(storedSession); - } - setIsAuthenticated(true); - } else { - setSession(null); - setIsAuthenticated(false); - } - } + const storedSession = checkSessionValid() ? getSessionFromStorage() : null; + setSession(storedSession); + setIsAuthenticated(!!storedSession); setIsAuthChecking(false); - }, [isSpeedMode]); + }, []); useEffect(() => { if (isAuthenticated) { @@ -70,7 +56,7 @@ export function useAuthenticationState(): AuthenticationStateReturn { }, [isAuthenticated]); useEffect(() => { - if (isSpeedMode || isAuthChecking || isAuthenticated) return; + if (isAuthChecking || isAuthenticated) return; if (typeof window === "undefined") return; const pathname = window.location.pathname; @@ -81,10 +67,10 @@ export function useAuthenticationState(): AuthenticationStateReturn { forcedLoginService.redirectIfNeeded( buildAuthenticationReturnPath(pathname, window.location.search) ); - }, [isSpeedMode, isAuthChecking, isAuthenticated]); + }, [isAuthChecking, isAuthenticated]); useEffect(() => { - if (isSpeedMode || !isAuthenticated) return; + if (!isAuthenticated) return; let timeoutId: ReturnType | null = null; let cancelled = false; @@ -113,7 +99,7 @@ export function useAuthenticationState(): AuthenticationStateReturn { cancelled = true; if (timeoutId) clearTimeout(timeoutId); }; - }, [isAuthenticated, session?.expires_at, isSpeedMode]); + }, [isAuthenticated, session?.expires_at]); const clearLocalSession = useCallback(() => { removeSessionFromStorage(); diff --git a/frontend/hooks/auth/useAuthenticationUI.ts b/frontend/hooks/auth/useAuthenticationUI.ts index f5cf0cab6a..0d5bd713fd 100644 --- a/frontend/hooks/auth/useAuthenticationUI.ts +++ b/frontend/hooks/auth/useAuthenticationUI.ts @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; import { useState, useCallback, useEffect } from "react"; @@ -5,7 +6,6 @@ import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { App } from "antd"; import { useTranslation } from "react-i18next"; -import { useDeployment } from "@/components/providers/deploymentProvider"; import { AUTH_EVENTS } from "@/const/auth"; import { getEffectiveRoutePath } from "@/lib/auth"; import { @@ -33,7 +33,6 @@ export function useAuthenticationUI({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { isSpeedMode } = useDeployment(); const { t } = useTranslation("common"); const { message } = App.useApp(); const effectivePath = pathname ? getEffectiveRoutePath(pathname) : "/"; @@ -60,7 +59,7 @@ export function useAuthenticationUI({ const handleUnauthenticatedModalClose = useCallback(() => { // Only emit back to home event and redirect if user is not authenticated - if (!isAuthenticated && !isSpeedMode && !preservesShareReturnPath) { + if (!isAuthenticated && !preservesShareReturnPath) { // Emit event to notify SideNavigation to reset selected key authEventUtils.emitBackToHome(); // Redirect to home page if not already there @@ -73,7 +72,6 @@ export function useAuthenticationUI({ isAuthenticated, isOAuthCompletePage, preservesShareReturnPath, - isSpeedMode, router, ]); @@ -182,7 +180,6 @@ export function useAuthenticationUI({ ); useEffect(() => { - if (isSpeedMode) return; if (isAnonymousConversationSharePage) return; const handleSessionExpired = () => { @@ -217,7 +214,6 @@ export function useAuthenticationUI({ }; }, [ effectivePath, - isSpeedMode, isAnonymousConversationSharePage, openSessionExpiredModal, isLoginModalOpen, @@ -226,7 +222,6 @@ export function useAuthenticationUI({ // Auto-open login modal when returning from a failed OAuth redirect useEffect(() => { - if (isSpeedMode) return; if (isOAuthCompletePage) return; if (isAnonymousConversationSharePage) return; if (isAuthChecking) return; @@ -248,7 +243,6 @@ export function useAuthenticationUI({ searchParams, isAuthChecking, isAuthenticated, - isSpeedMode, isLoginModalOpen, router, isOAuthCompletePage, @@ -266,7 +260,6 @@ export function useAuthenticationUI({ // Route guard for unauthenticated users - check when pathname changes useEffect(() => { - if (isSpeedMode) return; if (isOAuthCompletePage) return; if (isAnonymousConversationSharePage) return; // Skip while checking auth state @@ -292,7 +285,6 @@ export function useAuthenticationUI({ effectivePath, authenticationReturnPath, isAuthenticated, - isSpeedMode, isAuthChecking, isSessionExpiredModalOpen, isSessionExpiredLoginInProgress, diff --git a/frontend/hooks/auth/useAuthorization.ts b/frontend/hooks/auth/useAuthorization.ts index 1eb585f87d..5194d2a9bd 100644 --- a/frontend/hooks/auth/useAuthorization.ts +++ b/frontend/hooks/auth/useAuthorization.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { isOperationsOnly, hasRole } from "@/lib/roles"; import { useState, @@ -12,13 +12,12 @@ import { import { useQuery } from "@tanstack/react-query"; import { useRouter, usePathname } from "next/navigation"; import { User, AuthInfoResponse, AuthorizationContextType } from "@/types/auth"; -import { USER_ROLES } from "@/const/auth"; + import { authService } from "@/services/authService"; import { authEvents, authzEventUtils } from "@/lib/authEvents"; import { AUTH_EVENTS } from "@/const/auth"; import { getEffectiveRoutePath } from "@/lib/auth"; import log from "@/lib/logger"; -import { useDeployment } from "@/components/providers/deploymentProvider"; import { checkSessionValid } from "@/lib/session"; import { useGroupList } from "@/hooks/group/useGroupList"; @@ -29,7 +28,6 @@ import { useGroupList } from "@/hooks/group/useGroupList"; export function useAuthorization(): AuthorizationContextType { const router = useRouter(); const pathname = usePathname(); - const { isSpeedMode } = useDeployment(); // Authorization state const [user, setUser] = useState(null); @@ -127,13 +125,6 @@ export function useAuthorization(): AuthorizationContextType { // Initialize authorization on mount useEffect(() => { - // In speed mode, fetch authorization data immediately - if (isSpeedMode) { - log.info("Speed mode: fetching authorization info..."); - fetchAuthzData(); - return; - } - // On page refresh, if there is a valid session in storage, // proactively load authorization info so that user/permissions are ready. if (checkSessionValid()) { @@ -142,12 +133,10 @@ export function useAuthorization(): AuthorizationContextType { ); fetchAuthzData(); } - }, [isSpeedMode, fetchAuthzData]); + }, [fetchAuthzData]); // Listen for authentication events useEffect(() => { - if (isSpeedMode) return; - // Handle login success - fetch authorization data const handleLoginSuccess = () => { log.info("Login success: fetching authorization info..."); @@ -181,7 +170,7 @@ export function useAuthorization(): AuthorizationContextType { cleanupLogout(); cleanupSessionExpired(); }; - }, [isSpeedMode, fetchAuthzData, clearAuthzData]); + }, [fetchAuthzData, clearAuthzData]); // Authz prompt modal control functions const openAuthzPromptModal = useCallback( @@ -197,7 +186,8 @@ export function useAuthorization(): AuthorizationContextType { const cleanPath = getEffectiveRoutePath(pathname); // Share pages are always accessible to any logged-in user (covers /share/...). - const isSharePage = cleanPath.startsWith("/share/"); + const isSharePage = + !isOperationsOnly(user) && cleanPath.startsWith("/share/"); // Support prefix matching so nested routes such as /evaluation/{id} are // covered by /evaluation. @@ -212,6 +202,10 @@ export function useAuthorization(): AuthorizationContextType { // Route guard useLayoutEffect(() => { + if (isOperationsOnly(user) && cleanPath === "/") { + router.replace("/ops/dashboard"); + return; + } if (isSharePage) { return; } @@ -276,21 +270,20 @@ export function useAuthorization(): AuthorizationContextType { ); // Internal group list query - fetches all groups for the user's tenant - const { data: allGroupsData } = useGroupList(user?.tenantId ?? null); + const { data: allGroupsData } = useGroupList( + isOperationsOnly(user) ? null : (user?.tenantId ?? null) + ); const allGroupIds = useMemo( () => allGroupsData?.groups.map((g) => g.group_id) ?? [], [allGroupsData] ); const getAccessibleGroupIds = useCallback((): number[] => { - const canSelectAllGroups = - user?.role === USER_ROLES.SU || - user?.role === USER_ROLES.ADMIN || - user?.role === USER_ROLES.SPEED; + const canSelectAllGroups = hasRole(user, "SU") || hasRole(user, "ADMIN"); return canSelectAllGroups ? allGroupIds : allGroupIds.filter((id) => groupIds.includes(id)); - }, [allGroupIds, groupIds, user?.role]); + }, [allGroupIds, groupIds, user]); return { user, diff --git a/frontend/hooks/auth/useSessionManager.ts b/frontend/hooks/auth/useSessionManager.ts index e68fa83a76..a5c822a164 100644 --- a/frontend/hooks/auth/useSessionManager.ts +++ b/frontend/hooks/auth/useSessionManager.ts @@ -47,7 +47,7 @@ export const refreshSession = async (): Promise => false; // ============================================================================ export function useSessionManager(user?: User | null) { - const { isSpeedMode, isDeploymentReady } = useDeployment(); + const { isDeploymentReady } = useDeployment(); const reconcileExpiryRef = useRef<() => void>(() => {}); const lastHeartbeatAttemptAtRef = useRef(0); const heartbeatIntervalMsRef = useRef(getHeartbeatIntervalMs(300)); @@ -62,7 +62,7 @@ export function useSessionManager(user?: User | null) { }, [user?.authProvider, user?.id]); useEffect(() => { - if (isSpeedMode || !isDeploymentReady) return; + if (!isDeploymentReady) return; if (!hasAuthCookies()) { return; @@ -73,14 +73,14 @@ export function useSessionManager(user?: User | null) { } handleSessionExpired(); - }, [isSpeedMode, isDeploymentReady]); + }, [isDeploymentReady]); /** * Proactive session expiry watcher * Triggers session-expired even if user does not make any API request */ useEffect(() => { - if (isSpeedMode || !isDeploymentReady) return; + if (!isDeploymentReady) return; let timeoutId: number | null = null; let intervalId: number | null = null; @@ -152,14 +152,12 @@ export function useSessionManager(user?: User | null) { reconcileExpiryRef.current = () => {}; clearTimers(); }; - }, [isSpeedMode, isDeploymentReady]); + }, [isDeploymentReady]); /** * Reconcile expiry and send CAS heartbeats on user activity */ const setupTokenAutoRefresh = useCallback(() => { - if (isSpeedMode) return () => {}; - let lastActivityCheckAt = 0; const maybeRefreshOnActivity = async () => { @@ -291,7 +289,7 @@ export function useSessionManager(user?: User | null) { window.removeEventListener(eventName, handler); }); }; - }, [isSpeedMode, user?.authProvider, user?.id]); + }, [user?.authProvider, user?.id]); useEffect(() => { const cleanupAutoRefresh = setupTokenAutoRefresh(); diff --git a/frontend/hooks/useAccountMonitoring.ts b/frontend/hooks/useAccountMonitoring.ts index e60ec7b4f9..7861f3c42c 100644 --- a/frontend/hooks/useAccountMonitoring.ts +++ b/frontend/hooks/useAccountMonitoring.ts @@ -12,9 +12,9 @@ import { monitoringService } from "@/services/monitoringService"; export function useAccountMonitoring() { const { user } = useAuthorizationContext(); const { isSpeedMode } = useDeployment(); - const enabled = isSpeedMode || !!user; + const enabled = !!user; const { data: status } = useQuery({ - queryKey: ["account-monitoring-status", user?.id, user?.role, isSpeedMode], + queryKey: ["account-monitoring-status", user?.id, user?.role, user?.roles], queryFn: monitoringService.fetchStatus, enabled, retry: false, @@ -23,7 +23,7 @@ export function useAccountMonitoring() { return enabled && status?.telemetry_enabled && canViewMonitoringDashboard( - user?.role, + user?.roles ?? user?.role, isSpeedMode, status.dashboard_allowed_roles ) diff --git a/frontend/hooks/useMcpConfig.ts b/frontend/hooks/useMcpConfig.ts index 4a67c450f7..913f11f740 100644 --- a/frontend/hooks/useMcpConfig.ts +++ b/frontend/hooks/useMcpConfig.ts @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; import { useState, useRef, useCallback, useMemo } from "react"; @@ -26,6 +27,7 @@ import { export interface UseMcpConfigOptions { enabled?: boolean; + readOnly?: boolean; tenantId?: string | null; onServerAdded?: () => void; onServerDeleted?: () => void; @@ -71,8 +73,10 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { // Filter to match MCP space "My" tab: only show editable or group-shared MCPs const serverList = useMemo( - () => rawServerList.filter((s) => s.permission === "EDIT" || s.group_ids), - [rawServerList], + () => options.readOnly + ? rawServerList + : rawServerList.filter((s) => s.permission === "EDIT" || s.group_ids), + [rawServerList, options.readOnly], ); const { @@ -206,10 +210,15 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { const key = `${server.service_name}__${server.mcp_url}`; setHealthCheckLoading(prev => ({ ...prev, [key]: true })); try { - const result = await checkMcpServerHealth(server.mcp_id); - invalidateMcpServers(); - invalidateMcpContainers(); - await refreshToolsAndAgents(); + const result = await checkMcpServerHealth( + server.mcp_id, + options.readOnly ? options.tenantId : undefined, + ); + if (!options.readOnly) { + invalidateMcpServers(); + invalidateMcpContainers(); + await refreshToolsAndAgents(); + } if (result.success) { return { success: true, messageKey: "mcpConfig.message.healthCheckSuccess" }; } else { @@ -217,14 +226,16 @@ export function useMcpConfig(options: UseMcpConfigOptions = {}) { } } catch (error) { log.error("Health check failed:", error); - invalidateMcpServers(); - invalidateMcpContainers(); - await refreshToolsAndAgents(); + if (!options.readOnly) { + invalidateMcpServers(); + invalidateMcpContainers(); + await refreshToolsAndAgents(); + } return { success: false, message: "Health check failed", messageKey: "mcpConfig.message.healthCheckFailed" }; } finally { setHealthCheckLoading(prev => ({ ...prev, [key]: false })); } - }, [invalidateMcpServers, invalidateMcpContainers, refreshToolsAndAgents, options.tenantId]); + }, [invalidateMcpServers, invalidateMcpContainers, refreshToolsAndAgents, options.readOnly, options.tenantId]); // Update MCP server const handleUpdateServer = useCallback(async ( diff --git a/frontend/hooks/useQuotaWarningImpl.tsx b/frontend/hooks/useQuotaWarningImpl.tsx index 0761c7c85c..c5c3e4d3a7 100644 --- a/frontend/hooks/useQuotaWarningImpl.tsx +++ b/frontend/hooks/useQuotaWarningImpl.tsx @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. /** * Hook for quota warning notifications with role-based visibility. * @@ -15,6 +16,7 @@ * Aggregation: when multiple tenants/KBs trigger the same level, * shows " tenants/knowledge bases" instead of individual names. */ +import { hasRole } from "@/lib/roles"; import { useCallback, useRef } from "react"; import { notification } from "antd"; import { useParams, useRouter } from "next/navigation"; @@ -85,7 +87,7 @@ interface WarningItem { usagePct: number; } -type UserRole = "SU" | "ADMIN" | string; +type UserRole = string | readonly string[]; export function useQuotaWarning( userRole?: UserRole, @@ -100,7 +102,8 @@ export function useQuotaWarning( const notificationKeysByItemRef = useRef>>(new Map()); const activeLevelByItemRef = useRef>(new Map()); const programmaticCloseRef = useRef>(new Set()); - const isPlatformAdmin = userRole === "SU" || userRole === "ASSET_OWNER"; + const isPlatformAdmin = + hasRole(userRole, "SU") || hasRole(userRole, "ASSET_OWNER"); const dismissOwner = userId || "anonymous"; const unregisterNotificationKey = useCallback((notificationKey: string) => { @@ -300,7 +303,7 @@ export function useQuotaWarning( {t("quota.warning.manageTenant")} )} - {scope === "kb" && userRole === "ADMIN" && ( + {scope === "kb" && hasRole(userRole, "ADMIN") && (