From 20d94eddf561a6c8f33173b6229d922e7a2b0748 Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:34:32 +0800 Subject: [PATCH 1/8] feat(ops): add tenant operations administrators and multi-role access --- backend/contracts/authentication.py | 1 + backend/contracts/configuration.py | 7 + backend/contracts/constants/constants.py | 4 +- backend/contracts/constants/environment.py | 8 +- backend/contracts/errors/exceptions.py | 4 + backend/contracts/identity.py | 33 +- backend/contracts/roles.py | 69 ++ .../ext_components/aidp/apps/aidp_mgmt_app.py | 15 +- .../aidp/services/aidp_permission_service.py | 14 +- backend/gateway/api/northbound.py | 71 +- backend/gateway/authentication/auth.py | 106 ++- backend/gateway/authentication/credentials.py | 31 + .../gateway/authentication/northbound_auth.py | 17 +- .../infrastructure/security/account_cli.py | 25 +- .../management/api/agent/agent_repository.py | 32 +- backend/management/api/application.py | 124 ++-- backend/management/api/identity/invitation.py | 46 +- backend/management/api/identity/user.py | 56 +- .../api/identity/user_management.py | 77 +- .../management/api/knowledge_base/quota.py | 66 +- .../api/knowledge_base/vectordatabase.py | 72 +- .../management/api/memory/memory_dreaming.py | 32 +- .../management/api/memory/memory_long_term.py | 11 +- .../management/api/memory/memory_record.py | 13 +- .../management/api/model/model_managment.py | 128 ++-- backend/management/api/tag/tag_management.py | 44 +- backend/management/db/client.py | 19 +- .../management/db/conversation/repository.py | 56 +- backend/management/db/identity/account.py | 61 +- backend/management/db/identity/invitation.py | 22 +- backend/management/db/identity/membership.py | 81 +++ backend/management/db/identity/models.py | 22 +- .../management/db/identity/role_permission.py | 5 +- backend/management/db/identity/roles.py | 60 ++ backend/management/db/identity/user_tenant.py | 162 +++-- .../db/knowledge_base/repository.py | 31 +- .../management/db/notification/repository.py | 21 +- .../services/agent/authorization.py | 26 +- .../management/services/agent/management.py | 48 +- backend/management/services/agent/service.py | 142 ++-- backend/management/services/agent/versions.py | 85 ++- .../management/services/agent/workbench.py | 35 +- .../management/services/evaluation/queries.py | 17 +- .../services/knowledge_base/deletion.py | 70 +- .../services/knowledge_base/document_tasks.py | 7 + .../services/knowledge_base/management.py | 92 +-- .../services/knowledge_base/permission.py | 16 +- backend/management/services/mcp/service.py | 139 ++-- .../services/permission/api_keys.py | 27 +- .../services/permission/asset_owner.py | 15 +- .../permission/identity/authentication.py | 244 +++---- .../services/permission/identity/cas.py | 54 +- .../services/permission/identity/groups.py | 57 +- .../permission/identity/invitations.py | 504 ++++++------- .../services/permission/identity/oauth.py | 116 +-- .../permission/identity/registration.py | 91 +++ .../services/permission/identity/roles.py | 46 ++ .../services/permission/identity/tenants.py | 80 ++- .../services/permission/identity/users.py | 127 ++-- .../services/repository/agent/common.py | 42 +- .../services/repository/agent/publishing.py | 56 +- .../services/repository/mcp/service.py | 78 +- .../management/services/skill/permission.py | 16 +- backend/management/services/tag/identity.py | 23 + backend/management/services/tag/projection.py | 12 +- backend/management/services/tag/resources.py | 118 ++- backend/management/utils/app_factory.py | 164 +---- .../management/utils/exception_handlers.py | 164 +++++ backend/ops/README.md | 26 +- backend/ops/api/access.py | 37 + backend/ops/api/application.py | 4 + backend/ops/services/access.py | 42 ++ backend/permissions/dac.py | 44 +- backend/permissions/depends.py | 14 +- backend/permissions/models.py | 11 + backend/permissions/operations.py | 31 + backend/permissions/operations_boundary.py | 44 ++ backend/permissions/rbac.py | 22 +- backend/permissions/role_assignment.py | 15 + backend/permissions/tenant_scope.py | 7 +- backend/services/skill_repository_service.py | 74 +- deploy/common/common.sh | 5 + deploy/env/.env.example | 5 +- deploy/images/dockerfiles/main/Dockerfile | 1 + .../nexent-ops/templates/deployment.yaml | 2 +- .../v2.9.0_009_account_user_roles.sql | 52 ++ .../sql/migrations/v2.9.0_010_ops_roles.sql | 62 ++ .../sql/tools/supabase_to_postgres/README.md | 18 +- .../supabase_to_postgres/inspect_accounts.sql | 21 +- .../supabase_to_postgres/migrate_docker.py | 13 +- deploy/tests/test_common.sh | 1 + deploy/tests/test_images_build.sh | 1 + .../app/[locale]/agent-space/agent-space.tsx | 6 +- frontend/app/[locale]/agent-space/page.tsx | 6 +- .../[locale]/agent-space/review-center.tsx | 6 +- .../[agentId]/components/agent-prompt.tsx | 84 ++- .../components/capability/SkillBuildModal.tsx | 306 ++++---- frontend/app/[locale]/evaluation/page.tsx | 6 +- .../knowledge/KnowledgeBaseList.tsx | 4 +- frontend/app/[locale]/layout.tsx | 2 +- .../app/[locale]/mcp-space/agent-space.tsx | 8 +- .../model/ModelOutletEditDialog.tsx | 3 +- .../app/[locale]/ops/configuration/page.tsx | 6 + frontend/app/[locale]/ops/dashboard/page.tsx | 6 + .../components/UserManageComp.tsx | 33 +- .../components/resources/AgentList.tsx | 14 +- .../components/resources/InvitationList.tsx | 171 ++--- .../components/resources/KnowledgeList.tsx | 13 +- .../resources/ModelCapacityCoverageWidget.tsx | 6 +- .../PersonalKnowledgeBaseCapacity.tsx | 7 +- .../components/resources/TagList.tsx | 5 +- .../components/resources/UserList.tsx | 162 +++-- .../app/[locale]/resource-manage/page.tsx | 12 +- frontend/app/[locale]/skill-space/page.tsx | 6 +- .../app/[locale]/skill-space/skill-space.tsx | 6 +- .../users/components/UserProfileComp.tsx | 14 +- frontend/components/auth/avatarDropdown.tsx | 9 +- .../memory/MemoryEmbeddingMonitor.tsx | 3 +- .../components/navigation/SideNavigation.tsx | 90 ++- frontend/components/navigation/TopNavbar.tsx | 5 +- .../components/operations/OperationsPage.tsx | 64 ++ .../operations/ResourceInventory.tsx | 67 ++ .../providers/deploymentProvider.tsx | 5 +- .../components/providers/rootProvider.tsx | 12 +- .../components/quota/QuotaWarningMonitor.tsx | 9 +- frontend/const/auth.ts | 3 +- frontend/const/modelConfig.ts | 2 +- .../aidp/shared/hooks/useAidpGroupOptions.ts | 5 +- frontend/hooks/auth/useAuthenticationState.ts | 32 +- frontend/hooks/auth/useAuthenticationUI.ts | 12 +- frontend/hooks/auth/useAuthorization.ts | 37 +- frontend/hooks/auth/useSessionManager.ts | 14 +- frontend/hooks/useAccountMonitoring.ts | 6 +- frontend/hooks/useQuotaWarningImpl.tsx | 18 +- frontend/lib/auth.ts | 14 +- frontend/lib/monitoringAccess.ts | 31 +- frontend/lib/roles.ts | 57 ++ frontend/public/locales/en/common.json | 27 +- frontend/public/locales/zh/common.json | 25 +- frontend/server.js | 16 +- frontend/services/api.ts | 7 + frontend/services/authService.ts | 6 +- frontend/services/invitationService.ts | 24 +- frontend/services/operationsService.ts | 36 + frontend/services/userService.ts | 19 +- .../component/accountMonitoring.test.tsx | 13 +- .../component/authorizationRecovery.test.tsx | 2 +- .../component/casResourceIdentity.test.tsx | 55 +- .../component/invitationLocalization.test.tsx | 65 ++ .../component/multiRolePermissions.test.tsx | 39 + frontend/tests/monitoringAccess.test.ts | 10 +- frontend/types/auth.ts | 2 + frontend/types/operations.ts | 18 + test-e2e/cases/AUTH-LOCAL-D2-001/test.py | 8 +- .../AUTH-LOCAL-D5-001/automatic_upgrade.py | 9 +- test-e2e/cases/AUTH-LOCAL-D5-001/case.yaml | 6 +- .../cases/AUTH-LOCAL-D5-001/execution.yaml | 5 +- .../cases/AUTH-LOCAL-D5-001/schema_upgrade.py | 10 +- test-e2e/cases/AUTH-LOCAL-D5-001/test.py | 33 +- .../cases/AUTH-MULTI-ROLE-D2-001/case.yaml | 121 ++++ .../AUTH-MULTI-ROLE-D2-001/execution.yaml | 27 + test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py | 73 ++ .../cases/AUTH-MULTI-ROLE-D2-002/case.yaml | 97 +++ .../AUTH-MULTI-ROLE-D2-002/execution.yaml | 25 + test-e2e/cases/AUTH-MULTI-ROLE-D2-002/test.py | 79 ++ .../cases/AUTH-MULTI-ROLE-D2-003/case.yaml | 97 +++ .../AUTH-MULTI-ROLE-D2-003/execution.yaml | 25 + test-e2e/cases/AUTH-MULTI-ROLE-D2-003/test.py | 52 ++ .../cases/AUTH-MULTI-ROLE-D3-001/case.yaml | 93 +++ .../AUTH-MULTI-ROLE-D3-001/execution.yaml | 25 + test-e2e/cases/AUTH-MULTI-ROLE-D3-001/test.py | 109 +++ .../cases/AUTH-MULTI-ROLE-D4-001/case.yaml | 94 +++ .../AUTH-MULTI-ROLE-D4-001/execution.yaml | 24 + .../cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts | 313 ++++++++ .../cases/AUTH-MULTI-ROLE-D5-001/case.yaml | 93 +++ .../AUTH-MULTI-ROLE-D5-001/execution.yaml | 25 + test-e2e/cases/AUTH-MULTI-ROLE-D5-001/test.py | 77 ++ .../AUTH-MULTI-ROLE-20261031.yaml | 101 +++ test-e2e/features/AUTH-LOCAL/feature.yaml | 2 +- test-e2e/features/AUTH-MULTI-ROLE/cases.md | 10 + .../features/AUTH-MULTI-ROLE/feature.yaml | 51 ++ test-e2e/infra/automation/local_auth_http.py | 18 +- test-e2e/infra/automation/ops_roles_http.py | 252 +++++++ test-e2e/infra/config/report-ownership.yaml | 1 + test/backend/app/test_app_factory.py | 26 +- test/backend/app/test_quota_app.py | 18 +- test/backend/app/test_user_management_app.py | 9 +- test/backend/app/test_vectordatabase_app.py | 14 +- test/backend/database/test_conversation_db.py | 4 + test/backend/database/test_knowledge_db.py | 5 +- .../test_local_account_persistence.py | 5 +- test/backend/database/test_notification_db.py | 6 + test/backend/database/test_user_tenant_db.py | 117 ++- .../test_contracts_directory_architecture.py | 1 + .../management/test_gateway_authentication.py | 3 +- .../test_identity_import_architecture.py | 24 + test/backend/ops/test_bootstrap.py | 13 +- test/backend/permissions/test_dac.py | 27 +- test/backend/permissions/test_depends.py | 38 +- test/backend/permissions/test_operations.py | 47 ++ .../permissions/test_operations_boundary.py | 51 ++ test/backend/permissions/test_roles.py | 36 + test/backend/permissions/test_tenant_scope.py | 10 +- .../test_account_bootstrap_preflight.py | 22 +- test/backend/services/test_account_profile.py | 20 +- .../services/test_agent_version_service.py | 388 +++++----- test/backend/services/test_group_service.py | 20 +- .../backend/services/test_invitation_roles.py | 112 +++ .../services/test_invitation_service.py | 672 +----------------- .../services/test_local_account_cli.py | 14 +- .../services/test_mcp_management_service.py | 6 +- .../services/test_native_authentication.py | 44 +- .../test_native_authentication_flows.py | 101 ++- test/backend/services/test_oauth_service.py | 9 +- test/backend/services/test_role_assignment.py | 126 ++++ .../services/test_skill_repository_service.py | 12 +- .../services/test_tag_resource_adapters.py | 14 +- test/backend/services/test_tenant_service.py | 6 +- .../services/test_user_management_service.py | 4 +- test/backend/services/test_user_service.py | 289 +------- .../services/test_vectordatabase_service.py | 119 ++-- test/backend/utils/test_auth_utils.py | 64 +- 222 files changed, 7251 insertions(+), 3860 deletions(-) create mode 100644 backend/contracts/roles.py create mode 100644 backend/gateway/authentication/credentials.py create mode 100644 backend/management/db/identity/membership.py create mode 100644 backend/management/db/identity/roles.py create mode 100644 backend/management/services/knowledge_base/document_tasks.py create mode 100644 backend/management/services/permission/identity/registration.py create mode 100644 backend/management/services/permission/identity/roles.py create mode 100644 backend/management/services/tag/identity.py create mode 100644 backend/management/utils/exception_handlers.py create mode 100644 backend/ops/api/access.py create mode 100644 backend/ops/services/access.py create mode 100644 backend/permissions/operations.py create mode 100644 backend/permissions/operations_boundary.py create mode 100644 backend/permissions/role_assignment.py create mode 100644 deploy/sql/migrations/v2.9.0_009_account_user_roles.sql create mode 100644 deploy/sql/migrations/v2.9.0_010_ops_roles.sql create mode 100644 frontend/app/[locale]/ops/configuration/page.tsx create mode 100644 frontend/app/[locale]/ops/dashboard/page.tsx create mode 100644 frontend/components/operations/OperationsPage.tsx create mode 100644 frontend/components/operations/ResourceInventory.tsx create mode 100644 frontend/lib/roles.ts create mode 100644 frontend/services/operationsService.ts create mode 100644 frontend/tests/component/invitationLocalization.test.tsx create mode 100644 frontend/tests/component/multiRolePermissions.test.tsx create mode 100644 frontend/types/operations.ts create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-001/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-002/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-002/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-002/test.py create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-003/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-003/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D2-003/test.py create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D3-001/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D3-001/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D3-001/test.py create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D4-001/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D5-001/case.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D5-001/execution.yaml create mode 100644 test-e2e/cases/AUTH-MULTI-ROLE-D5-001/test.py create mode 100644 test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml create mode 100644 test-e2e/features/AUTH-MULTI-ROLE/cases.md create mode 100644 test-e2e/features/AUTH-MULTI-ROLE/feature.yaml create mode 100644 test-e2e/infra/automation/ops_roles_http.py create mode 100644 test/backend/management/test_identity_import_architecture.py create mode 100644 test/backend/permissions/test_operations.py create mode 100644 test/backend/permissions/test_operations_boundary.py create mode 100644 test/backend/permissions/test_roles.py create mode 100644 test/backend/services/test_invitation_roles.py create mode 100644 test/backend/services/test_role_assignment.py 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 38d55dcdcf..5585fff57b 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') @@ -356,7 +355,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') @@ -389,7 +387,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..0d78950625 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() + 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..5e1e183045 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,11 @@ 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") + if roles == ("OPS",): + raise HTTPException(status_code=403, detail="Operations-only identity cannot invoke Agents") tenant_id = user_tenant_record["tenant_id"] return { @@ -196,7 +206,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_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/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/quota.py b/backend/management/api/knowledge_base/quota.py index ad45368e86..1fc9705cf2 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) diff --git a/backend/management/api/knowledge_base/vectordatabase.py b/backend/management/api/knowledge_base/vectordatabase.py index e60c15052a..a3954de704 100644 --- a/backend/management/api/knowledge_base/vectordatabase.py +++ b/backend/management/api/knowledge_base/vectordatabase.py @@ -1,30 +1,48 @@ # 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 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 +50,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 +199,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 +229,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 +325,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 @@ -647,9 +669,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 +714,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 +759,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 +839,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/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..3c497616d6 100644 --- a/backend/management/api/model/model_managment.py +++ b/backend/management/api/model/model_managment.py @@ -17,57 +17,103 @@ ``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 # 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 +124,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 +150,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,13 +205,13 @@ 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: + if not has_any_role(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", @@ -1437,7 +1483,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 +1497,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 +1506,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 +1516,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 +1534,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 +1562,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 +1576,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 +1597,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 +1621,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 +1643,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/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/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..6fae0c15d3 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,7 +124,7 @@ def _validate_user_tenant_limit( raise error -def get_user_role_by_tenant(user_id: str, tenant_id: str) -> str: +def get_user_role_by_tenant(user_id: str, tenant_id: str) -> tuple[str, ...]: """Return the user's role within the given tenant. Joins ``user_tenant_t`` by ``(user_id, tenant_id)`` so the result is @@ -109,7 +132,7 @@ def get_user_role_by_tenant(user_id: str, tenant_id: str) -> str: exists; callers should treat that as "no role". """ if not user_id or not tenant_id: - return "" + return () with get_db_session() as session: result = ( session.query(UserTenant) @@ -123,25 +146,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 +194,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 +207,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 +260,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 +295,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 +354,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 +395,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() + """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, ) - 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 - } - ) - ) - - 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 +416,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..dc0bdb2a41 100644 --- a/backend/management/services/agent/management.py +++ b/backend/management/services/agent/management.py @@ -11,20 +11,40 @@ 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, + 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 +55,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 +86,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 @@ -177,9 +197,9 @@ 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() @@ -364,8 +384,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 +617,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..c99eed4aef 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 fastapi import Body, Depends, Path, Query + from contracts.constants.constants import ASSET_OWNER_TENANT_ID, PERMISSION_PRIVATE -from contracts.constants.environment import IS_SPEED_MODE 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") @@ -728,7 +747,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,7 +771,7 @@ 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) @@ -781,21 +800,10 @@ 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 "" - ) + effective_user_tenant_id = user_tenant_id + if has_any_role(user_role, {"SU"}): + effective_user_tenant_id = target_tenant_id access = ResourceAccessControl.check( Resource( resource_type="knowledge_base", @@ -807,7 +815,7 @@ def list_indices( knowledge_sources=record.get("knowledge_sources"), ), user_id=user_id, - role=(effective_user_role or "").upper(), + role=effective_user_role, user_groups=user_group_ids, user_tenant_id=effective_user_tenant_id, asset_owner_tenant_id=ASSET_OWNER_TENANT_ID, @@ -825,7 +833,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 +867,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, @@ -1166,8 +1171,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/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/permission/api_keys.py b/backend/management/services/permission/api_keys.py index e23c375c38..ec3aae9bda 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"}: + 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") @@ -63,7 +78,7 @@ def create_api_users_batch( ) -> 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"], @@ -175,7 +191,6 @@ def list_tenant_api_keys( if tenant_id != actor_tenant_id: 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", 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..d2efc03911 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 @@ -451,7 +475,8 @@ def get_group_users(group_id: int) -> List[Dict[str, Any]]: 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..d2b82bccdd 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,7 @@ 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) # Permission logic: # - If tenant_id is provided: ADMIN or SU can view that tenant's invitations @@ -546,33 +526,59 @@ 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"}): + 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"]] 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..ac8a957424 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__) @@ -79,9 +119,9 @@ 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") return get_tenant_info(tenant_id) @@ -202,8 +242,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 +303,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 +484,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..115ee663e8 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'}) 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..6c7228dfcc 100644 --- a/backend/management/utils/app_factory.py +++ b/backend/management/utils/app_factory.py @@ -5,13 +5,20 @@ 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 +from permissions.operations_boundary import install_operations_boundary + +try: + from infrastructure.observability.bootstrap import monitoring_manager +except ImportError: + monitoring_manager = None logger = logging.getLogger(__name__) @@ -59,6 +66,8 @@ def create_app( allow_headers=["*"], ) + install_operations_boundary(app) + # Register exception handlers register_exception_handlers(app) @@ -68,7 +77,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 +86,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..a55f52a742 --- /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.operations import operations_scope + + +def resource_inventory(user, tenant_id=None, page=1, page_size=20): + tenant = operations_scope(user, tenant_id, "resource.inventory:read") + 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..24c9cae0c6 100644 --- a/backend/permissions/depends.py +++ b/backend/permissions/depends.py @@ -3,26 +3,18 @@ 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]: """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): + if not has_permission(current_user.effective_roles, permission): 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..2cb70d1d7d --- /dev/null +++ b/backend/permissions/operations.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Action-specific operations scope; never expands ordinary business scope.""" + +from fastapi import HTTPException + +from permissions.rbac import has_permission + +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") + if "SU" in roles: + return tenant_id + if tenant_id is not None and tenant_id != user.tenant_id: + raise HTTPException(status_code=403, detail="Operations are limited to your tenant") + return user.tenant_id + + +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/operations_boundary.py b/backend/permissions/operations_boundary.py new file mode 100644 index 0000000000..5ecaae2b34 --- /dev/null +++ b/backend/permissions/operations_boundary.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Restrict operations-only identities at the shared business HTTP boundary.""" + +from starlette.concurrency import run_in_threadpool +from starlette.responses import JSONResponse + +from contracts.errors.exceptions import TokenExpiredError, UnauthorizedError +from gateway.authentication.auth import get_current_user_context + +# These existing endpoints only maintain the caller's own authenticated session. +SESSION_PATHS = frozenset( + { + "/user/current_user_info", + "/user/current_user_id", + "/user/session", + "/user/logout", + "/user/revoke", + "/user/refresh_token", + "/user/password", + } +) +BOOTSTRAP_PATHS = frozenset({"/tenant_config/deployment_version", "/user/cas/config", "/user/oauth/config"}) + + +def install_operations_boundary(app): + @app.middleware("http") + async def operations_boundary(request, call_next): + authorization = request.headers.get("authorization") + if authorization: + try: + _, _, roles = await run_in_threadpool(get_current_user_context, authorization) + except (UnauthorizedError, TokenExpiredError): + # Each existing route retains its credential policy and error envelope. + return await call_next(request) + path = request.scope.get("path", "") + root = request.scope.get("root_path", "") + if root and path.startswith(root + "/"): + path = path[len(root) :] + bootstrap = request.method == "GET" and path in BOOTSTRAP_PATHS + if set(roles) == {"OPS"} and path not in SESSION_PATHS and not bootstrap and not path.startswith("/ops/"): + return JSONResponse( + status_code=403, content={"message": "Operations-only identity cannot access business APIs"} + ) + return await call_next(request) 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/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..9adb00e089 100644 --- a/backend/permissions/tenant_scope.py +++ b/backend/permissions/tenant_scope.py @@ -1,13 +1,16 @@ +# 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"}) +_PERSONAL_KB_CROSS_TENANT_ROLES = frozenset({'SU'}) def resolve_personal_target_tenant( @@ -17,7 +20,7 @@ def resolve_personal_target_tenant( 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 + and not has_any_role(current_user.effective_roles, _PERSONAL_KB_CROSS_TENANT_ROLES) ): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, 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 15c8c44d77..87d803c8f0 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -1129,6 +1129,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 16f4864def..7615b58241 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -9,9 +9,12 @@ 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 +# Authentication is required in every deployment. The former speed identity is retired. +DEPLOYMENT_VERSION=full + CLUSTER=volcano_tts VOICE_TYPE=zh_male_jieshuonansheng_mars_bigtts SPEED_RATIO=1.3 diff --git a/deploy/images/dockerfiles/main/Dockerfile b/deploy/images/dockerfiles/main/Dockerfile index 644d641228..964f71dce4 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_009_account_user_roles.sql /opt/deploy/sql/migrations/v2.9.0_009_account_user_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_009_account_user_roles.sql b/deploy/sql/migrations/v2.9.0_009_account_user_roles.sql new file mode 100644 index 0000000000..bb4a4cb174 --- /dev/null +++ b/deploy/sql/migrations/v2.9.0_009_account_user_roles.sql @@ -0,0 +1,52 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +-- Finalize account roles independently of operations permissions or invitations. +DO $account_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; +END $account_roles$; diff --git a/deploy/sql/migrations/v2.9.0_010_ops_roles.sql b/deploy/sql/migrations/v2.9.0_010_ops_roles.sql new file mode 100644 index 0000000000..fc776126a1 --- /dev/null +++ b/deploy/sql/migrations/v2.9.0_010_ops_roles.sql @@ -0,0 +1,62 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +-- Account structure is finalized by 009; this migration owns operations grants. +BEGIN; +DO $ops_roles$ +BEGIN + 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); +END $ops_roles$; + +-- 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); + +COMMIT; diff --git a/deploy/sql/tools/supabase_to_postgres/README.md b/deploy/sql/tools/supabase_to_postgres/README.md index 23eb2f2f3d..7bc75323a7 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 009, 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 009 after historical 001 inside the same transaction +and writes only the array. Inspection recognizes both layouts without recreating +columns. Historical replay is followed by 009, which preserves existing arrays +and drops any restored scalar column. Operations grants remain in independent +010; account recovery has no dependency on operations tables. + ## Prepare the upgrade Back up the original Supabase and target PostgreSQL data, settings and signing @@ -66,7 +73,7 @@ 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 +runtime, `v2.9.0_001_local_user_accounts.sql`, and `v2.9.0_009_account_user_roles.sql`. Standard deployment selects that image automatically; preload it for offline deployment. The independent gateway image is not the migration image. @@ -201,12 +208,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..45229f9916 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_009_account_user_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 afbdc9204a..628755dacd 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..a64960ff43 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_009_account_user_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..f8b80ee479 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,7 @@ 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"); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -1204,10 +1206,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") ); } @@ -1353,7 +1354,7 @@ export default function UserManageComp() { /> ), }, - ...(user?.role === USER_ROLES.ADMIN + ...(hasRole(user, "ADMIN") ? [ { key: "apiKeys", diff --git a/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx b/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx index e0be475fd5..016eb6ebcc 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; @@ -70,7 +70,7 @@ export default function AgentList({ tenantId }: { tenantId: string | null }) { 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 +82,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 +254,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) || ""; diff --git a/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx b/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx index cb25a95aff..f4b6c12299 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,7 +55,6 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import { ASSET_OWNER_INVITE_CODE_TYPE, ASSET_OWNER_TENANT_ID, - USER_ROLES, } from "@/const/auth"; const { Panel } = Collapse; @@ -63,9 +69,9 @@ export default function InvitationList({ 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 +125,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 +143,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 +161,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 +205,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 +223,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); } }; @@ -281,13 +279,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 +398,9 @@ export default function InvitationList({ variant="solid" > {icon} - {t(`tenantResources.invitation.status.${status}`)} + {t(`tenantResources.invitation.status.${status}`, { + defaultValue: t("common.unknown"), + })} ); }, @@ -405,6 +416,7 @@ export default function InvitationList({
), }, ], - [groupNameMap] + [groupNameMap, t] ); // Group invitations by tenant for collapse view @@ -491,7 +508,10 @@ export default function InvitationList({ header={ tenantId === ASSET_OWNER_TENANT_ID ? t("tenantResources.invitation.assetOwnerTab") - : `Tenant: ${tenantId}` + : t("tenantResources.invitation.tenantHeading", { + tenant: + tenantId === "unknown" ? t("common.unknown") : tenantId, + }) } key={tenantId} > @@ -529,37 +549,22 @@ export default function InvitationList({
{!editingInvitation && !isAssetOwnerInviteContext && ( ({ + value, + label: t(roleLabelKey(value)), + disabled: roleOptions?.locked_roles.includes(value), + }))} /> ; return ( <> - + diff --git a/frontend/app/[locale]/skill-space/page.tsx b/frontend/app/[locale]/skill-space/page.tsx index aaf437748f..56422531ec 100644 --- a/frontend/app/[locale]/skill-space/page.tsx +++ b/frontend/app/[locale]/skill-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 { motion } from "framer-motion"; @@ -9,7 +9,7 @@ import { useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; 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 { useMyEditableSkillCounts, @@ -31,7 +31,7 @@ export default function SkillRepositoryPage() { 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( SkillRepositoryTab.REPOSITORY ); diff --git a/frontend/app/[locale]/skill-space/skill-space.tsx b/frontend/app/[locale]/skill-space/skill-space.tsx index 3e2f4b9d94..ba72d3c53b 100644 --- a/frontend/app/[locale]/skill-space/skill-space.tsx +++ b/frontend/app/[locale]/skill-space/skill-space.tsx @@ -1,12 +1,12 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; import { useEffect, useMemo, useState } from "react"; import { App, Input, Modal } from "antd"; import { useTranslation } from "react-i18next"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; + import { useTagDefinitions, useTagLibraries } from "@/hooks/useTagManagement"; import type { TagResourcePredicate } from "@/types/tagManagement"; import { @@ -37,7 +37,7 @@ export function SkillSpace({ active }: { active: boolean }) { const { t } = useTranslation("common"); const { message, modal } = App.useApp(); const { user } = useAuthorizationContext(); - const isAdmin = user?.role === USER_ROLES.ADMIN; + const isAdmin = hasRole(user, "ADMIN"); const [repositoryPage, setRepositoryPage] = useState(1); const [repositorySearch, setRepositorySearch] = useState(""); const [repositoryTagPredicates, setRepositoryTagPredicates] = useState< diff --git a/frontend/app/[locale]/users/components/UserProfileComp.tsx b/frontend/app/[locale]/users/components/UserProfileComp.tsx index b7a064f0e4..fa8ab40d74 100644 --- a/frontend/app/[locale]/users/components/UserProfileComp.tsx +++ b/frontend/app/[locale]/users/components/UserProfileComp.tsx @@ -1,7 +1,8 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; +import { hasRole } from "@/lib/roles"; + import React, { useState, useEffect } from "react"; import { Button, @@ -125,13 +126,12 @@ export default function UserProfileComp() { const [passwordForm] = Form.useForm(); // Check if user is admin or super admin (cannot delete account) - const isAdminOrSuperAdmin = - user?.role === USER_ROLES.ADMIN || user?.role === USER_ROLES.SU; + const isAdminOrSuperAdmin = hasRole(user, "ADMIN") || hasRole(user, "SU"); const isCasUser = user?.authProvider === "cas"; const getRoleDisplayName = (role: string) => { switch (role) { - case USER_ROLES.SPEED: - return t("auth.speed"); + case USER_ROLES.OPS: + return t("user.role.ops"); case USER_ROLES.SU: return t("auth.su"); case USER_ROLES.ADMIN: @@ -323,7 +323,9 @@ export default function UserProfileComp() { {t("profile.role") || "Role"} - {getRoleDisplayName(user?.role || "user")} + {(user?.roles ?? [user?.role || "USER"]) + .map(getRoleDisplayName) + .join(" / ")}
diff --git a/frontend/components/auth/avatarDropdown.tsx b/frontend/components/auth/avatarDropdown.tsx index bbc08aba82..f622ac7fc8 100644 --- a/frontend/components/auth/avatarDropdown.tsx +++ b/frontend/components/auth/avatarDropdown.tsx @@ -1,5 +1,4 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. - "use client"; import { useState } from "react"; @@ -85,8 +84,8 @@ export function AvatarDropdown({ const getRoleDisplayName = (role: string) => { switch (role) { - case USER_ROLES.SPEED: - return t("auth.speed"); + case USER_ROLES.OPS: + return t("user.role.ops"); case USER_ROLES.SU: return t("auth.su"); case USER_ROLES.ADMIN: @@ -132,7 +131,9 @@ export function AvatarDropdown({ {identity}
- {getRoleDisplayName(user.role)} + {(user.roles ?? [user.role]) + .map(getRoleDisplayName) + .join(" / ")} diff --git a/frontend/components/memory/MemoryEmbeddingMonitor.tsx b/frontend/components/memory/MemoryEmbeddingMonitor.tsx index f09ecb5030..98c6c3badd 100644 --- a/frontend/components/memory/MemoryEmbeddingMonitor.tsx +++ b/frontend/components/memory/MemoryEmbeddingMonitor.tsx @@ -1,3 +1,4 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. "use client"; import { useEffect, useState } from "react"; @@ -29,7 +30,7 @@ export function MemoryEmbeddingMonitor() { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [revision, setRevision] = useState(0); - const canConfigure = canManageModels(user?.role, isSpeedMode); + const canConfigure = canManageModels(user?.roles ?? user?.role, isSpeedMode); useEffect(() => { const unsubscribe = subscribeMemorySwitch((enabled) => { diff --git a/frontend/components/navigation/SideNavigation.tsx b/frontend/components/navigation/SideNavigation.tsx index 0006649bb3..5da87f2a8f 100644 --- a/frontend/components/navigation/SideNavigation.tsx +++ b/frontend/components/navigation/SideNavigation.tsx @@ -34,6 +34,7 @@ import { Zap, } from "lucide-react"; +import { isOperationsOnly } from "@/lib/roles"; import { AvatarDropdown } from "@/components/auth/avatarDropdown"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useAuthenticationContext } from "@/components/providers/AuthenticationProvider"; @@ -63,6 +64,20 @@ interface RouteConfig { } const ROUTE_CONFIG: RouteConfig[] = [ + { + path: "/ops/dashboard", + Icon: LineChart, + labelKey: "ops.dashboard", + order: 12.5, + parentKey: null, + }, + { + path: "/ops/configuration", + Icon: Settings, + labelKey: "ops.configuration", + order: 12.6, + parentKey: null, + }, { path: "/", Icon: Home, @@ -209,7 +224,7 @@ export function SideNavigation({ isNavigating = false, }: SideNavigationProps = {}) { const { t } = useTranslation("common"); - const { accessibleRoutes } = useAuthorizationContext(); + const { accessibleRoutes, user } = useAuthorizationContext(); const { isAuthenticated, openAuthPromptModal } = useAuthenticationContext(); const { isSpeedMode, enableAgentWorkbench, hideHomePage } = useDeployment(); const router = useRouter(); @@ -274,7 +289,9 @@ export function SideNavigation({ unreadCount, items, isLoading: isNotificationsLoading, - } = useNotifications(!isSpeedMode && isAuthenticated); + } = useNotifications( + !isSpeedMode && isAuthenticated && !isOperationsOnly(user) + ); const markNotificationReadMutation = useMarkNotificationRead(); const markAllNotificationsReadMutation = useMarkAllNotificationsRead(); @@ -380,42 +397,43 @@ export function SideNavigation({ else router.push(navigationPath); }; - const notificationAction = isSpeedMode ? null : isAuthenticated ? ( - handlePopupChange("notification", open)} - unreadCount={unreadCount} - items={items} - isLoading={isNotificationsLoading} - isMarkingAllRead={markAllNotificationsReadMutation.isPending} - onMarkRead={async (receiverId) => { - await markNotificationReadMutation.mutateAsync(receiverId); - }} - onMarkAllRead={async () => { - await markAllNotificationsReadMutation.mutateAsync(); - }} - /> - ) : ( - - ); + ) : ( + + ); return (
(null); const { config } = useGlobalConfigStore(); - const shouldFetchMonitoringStatus = isSpeedMode || !!user; + const shouldFetchMonitoringStatus = !!user; const canViewMonitoring = canViewMonitoringDashboard( - user?.role, + user?.roles ?? user?.role, isSpeedMode, monitoringStatus?.dashboard_allowed_roles ); diff --git a/frontend/components/operations/OperationsPage.tsx b/frontend/components/operations/OperationsPage.tsx new file mode 100644 index 0000000000..bf7c592518 --- /dev/null +++ b/frontend/components/operations/OperationsPage.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"use client"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Card, Empty, Select, Space, Spin, Typography } from "antd"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { hasRole } from "@/lib/roles"; +import { checkOperationsScope } from "@/services/operationsService"; +import { listTenants } from "@/services/tenantService"; + +export function OperationsPage({ + module, +}: { + module: "dashboard" | "configuration"; +}) { + const { t } = useTranslation("common"); + const { user, hasPermission } = useAuthorizationContext(); + const [tenantId, setTenantId] = useState(); + const allowed = hasPermission(`ops.${module}:read`); + const tenants = useQuery({ + queryKey: ["opsTenantChoices", user?.id], + queryFn: () => listTenants({ page: 1, page_size: 100 }), + enabled: allowed && hasRole(user, "SU"), + }); + const scope = useQuery({ + queryKey: ["opsScope", module, tenantId, user?.id], + queryFn: () => checkOperationsScope(module, tenantId), + enabled: allowed, + }); + if (!allowed) return ; + return ( +
+ + {t(`ops.${module}`)} + {hasRole(user, "SU") ? ( + ), }, - ...(hasRole(user, "ADMIN") + ...(hasRole(user, "ADMIN") || readOnly ? [ { key: "apiKeys", label: t("tenantResources.tabs.apiKeys") || "API Key", - children: , + 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. @@ -1384,24 +1391,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", @@ -1416,6 +1423,7 @@ export default function UserManageComp() { children: ( ), diff --git a/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx b/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx index 016eb6ebcc..a04a20e634 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/AgentList.tsx @@ -65,7 +65,13 @@ 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(); @@ -505,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 f4b6c12299..28306d5706 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/InvitationList.tsx @@ -61,9 +61,11 @@ const { Panel } = Collapse; export default function InvitationList({ tenantId, + readOnly = false, refreshKey, }: { tenantId: string | null; + readOnly?: boolean; refreshKey?: number; }) { const { confirm } = useConfirmModal(); @@ -257,7 +259,7 @@ export default function InvitationList({ dataIndex: "invitation_code", key: "invitation_code", width: 80, - render: (code: string) => ( + render: (code: string) => readOnly ? "—" : (
{code} @@ -445,8 +447,11 @@ export default function InvitationList({ ), }, ], - [groupNameMap, t] + [groupNameMap, readOnly, t] ); + const visibleColumns = readOnly + ? columns.filter((column) => column.key !== "actions") + : columns; // Group invitations by tenant for collapse view const groupedInvitations = useMemo(() => { @@ -468,20 +473,22 @@ export default function InvitationList({
- + {!readOnly && ( + + )}
{tenantId ? ( // Single tenant view with pagination
- - + {!readOnly && ( + + )}
diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index 2e7f5f49f4..61690146b5 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -39,7 +39,13 @@ interface UnifiedModelRow extends ModelOption { total_tokens?: number; } -export default function ModelList({ tenantId }: { tenantId: string | null }) { +export default function ModelList({ + tenantId, + readOnly = false, +}: { + tenantId: string | null; + readOnly?: boolean; +}) { const { t } = useTranslation("common"); const { confirm } = useConfirmModal(); @@ -266,7 +272,7 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { className="text-yellow-500 cursor-pointer ml-1.5 shrink-0" onClick={(e) => { e.stopPropagation(); - openEdit(record); + if (!readOnly) openEdit(record); }} /> @@ -458,31 +464,35 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { loading={checkingConnectivity.has(record.displayName)} /> - -
), }, @@ -518,9 +528,11 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { {t("monitoring.dashboard.refresh")} - + {!readOnly && ( + + )} diff --git a/frontend/app/[locale]/resource-manage/components/resources/SkillList.tsx b/frontend/app/[locale]/resource-manage/components/resources/SkillList.tsx index 637ca163a6..b514c03b8a 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/SkillList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/SkillList.tsx @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; @@ -544,7 +546,13 @@ function formatSkillUpdateTime(iso: string | null | undefined): string { return `${y}/${m}/${day} ${h}:${min}`; } -export default function SkillList({ tenantId }: { tenantId: string | null }) { +export default function SkillList({ + tenantId, + readOnly = false, +}: { + tenantId: string | null; + readOnly?: boolean; +}) { const { t } = useTranslation("common"); const { message } = App.useApp(); const [form] = Form.useForm(); @@ -772,13 +780,15 @@ export default function SkillList({ tenantId }: { tenantId: string | null }) { className="w-40" /> - + {!readOnly && ( + + )} columns={columns} diff --git a/frontend/app/[locale]/resource-manage/components/resources/UserList.tsx b/frontend/app/[locale]/resource-manage/components/resources/UserList.tsx index 19d8a4ae5d..d4ae4b8dd7 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/UserList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/UserList.tsx @@ -66,9 +66,11 @@ const ROLE_CONFLICT_KEYS = new Map([ export default function UserList({ tenantId, + readOnly = false, refreshKey, }: { tenantId: string | null; + readOnly?: boolean; refreshKey?: number; }) { const { confirm } = useConfirmModal(); @@ -358,6 +360,9 @@ export default function UserList({ ], [displayName, t, user?.id] ); + const visibleColumns = readOnly + ? columns.filter((column) => column.key !== "actions") + : columns; const handlePageChange = (newPage: number, newPageSize: number) => { setPage(newPage); @@ -428,7 +433,7 @@ export default function UserList({
String(r.id)} loading={isLoading} pagination={{ diff --git a/frontend/app/[locale]/resource-manage/page.tsx b/frontend/app/[locale]/resource-manage/page.tsx index e321ef4227..acc26a3aad 100644 --- a/frontend/app/[locale]/resource-manage/page.tsx +++ b/frontend/app/[locale]/resource-manage/page.tsx @@ -3,10 +3,6 @@ import React from "react"; import { Flex } from "antd"; -import { useSetupFlow } from "@/hooks/useSetupFlow"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { hasRole } from "@/lib/roles"; -import { ResourceInventory } from "@/components/operations/ResourceInventory"; import UserManageComp from "./components/UserManageComp"; /** @@ -18,10 +14,6 @@ import UserManageComp from "./components/UserManageComp"; * - Layout uses Flex for responsive design and proper content flow. */ export default function TenantResourcesPage() { - const { user } = useAuthorizationContext(); - if (hasRole(user, "OPS") && !hasRole(user, "ADMIN", "SU")) - return ; - return ( <> diff --git a/frontend/components/operations/ResourceInventory.tsx b/frontend/components/operations/ResourceInventory.tsx deleted file mode 100644 index 043c90c9cd..0000000000 --- a/frontend/components/operations/ResourceInventory.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"use client"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Alert, Table, Typography } from "antd"; -import { useTranslation } from "react-i18next"; -import { getResourceInventory } from "@/services/operationsService"; - -const RESOURCE_LABEL_KEYS = new Map([ - ["model", "tenantResources.tabs.models"], - ["mcp", "tenantResources.tabs.mcp"], - ["agent", "tenantResources.tabs.agents"], - ["knowledge", "tenantResources.tabs.knowledge"], -]); - -export function ResourceInventory() { - const { t } = useTranslation("common"); - const { user } = useAuthorizationContext(); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const query = useQuery({ - queryKey: [ - "opsResourceInventory", - user?.id, - user?.tenantId, - page, - pageSize, - ], - queryFn: () => getResourceInventory(page, pageSize), - enabled: !!user, - }); - return ( -
- - {t("sidebar.resourceManage")} - - - {query.isError && } -
`${row.kind}:${row.tenant_id}:${row.id}`} - loading={query.isFetching} - dataSource={query.data?.items ?? []} - columns={[ - { - key: "kind", - dataIndex: "kind", - title: t("common.type"), - render: (kind: string) => - t(RESOURCE_LABEL_KEYS.get(kind) ?? "common.unknown"), - }, - { key: "name", dataIndex: "name", title: t("common.name") }, - { key: "id", dataIndex: "id", title: "ID" }, - ]} - pagination={{ - current: page, - pageSize, - total: query.data?.total ?? 0, - onChange: (next, size) => { - setPage(next); - setPageSize(size); - }, - }} - /> - - ); -} 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/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0e7223a16d..8eea726623 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -6758,5 +6758,5 @@ "ops.noPermission": "Access denied", "ops.loadError": "Failed to load. Please retry.", "ops.empty": "No data yet", - "ops.readOnly": "Read-only resource inventory" + "ops.readOnly": "Read-only mode" } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index f8deef134d..870cac1c5c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -6756,5 +6756,5 @@ "ops.noPermission": "暂无访问权限", "ops.loadError": "加载失败,请重试", "ops.empty": "暂无数据", - "ops.readOnly": "当前为只读资源目录" + "ops.readOnly": "当前为只读模式" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 987d92c050..64c40158ec 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -21,7 +21,6 @@ export const API_BASE_URL = withBasePath("/api"); export const API_ENDPOINTS = { operations: { authorization: `${API_BASE_URL}/ops/authorization`, - resources: `${API_BASE_URL}/ops/resources`, scope: `${API_BASE_URL}/ops/scope`, }, skillTagAssignmentsBatch: `${API_BASE_URL}/tag-libraries/assignments/skill/batch_read`, diff --git a/frontend/services/mcpService.ts b/frontend/services/mcpService.ts index 009b6c972d..2681685d0d 100644 --- a/frontend/services/mcpService.ts +++ b/frontend/services/mcpService.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import i18n from 'i18next'; import { ErrorCode } from '@/const/errorCode'; @@ -405,10 +407,11 @@ export const updateToolList = async () => { /** * checkMcpServerHealth */ -export const checkMcpServerHealth = async (mcpId: number) => { +export const checkMcpServerHealth = async (mcpId: number, tenantId?: string | null) => { try { const query = new URLSearchParams(); query.set('mcp_id', mcpId.toString()); + if (tenantId) query.set('tenant_id', tenantId); const response = await fetch(`${API_ENDPOINTS.mcp.healthcheck}?${query.toString()}`, { method: 'GET', headers: getAuthHeaders(), diff --git a/frontend/services/operationsService.ts b/frontend/services/operationsService.ts index eb59e98972..e7820caa89 100644 --- a/frontend/services/operationsService.ts +++ b/frontend/services/operationsService.ts @@ -1,28 +1,12 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. import { API_ENDPOINTS } from "@/services/api"; import { fetchWithAuth } from "@/lib/auth"; -import type { - OperationsAuthorization, - ResourceInventory, -} from "@/types/operations"; +import type { OperationsAuthorization } from "@/types/operations"; export async function getOperationsAuthorization(): Promise { const response = await fetchWithAuth(API_ENDPOINTS.operations.authorization); return (await response.json()).data; } -export async function getResourceInventory( - page: number, - pageSize: number -): Promise { - const params = new URLSearchParams({ - page: String(page), - page_size: String(pageSize), - }); - const response = await fetchWithAuth( - `${API_ENDPOINTS.operations.resources}?${params}` - ); - return (await response.json()).data; -} export async function checkOperationsScope( module: string, tenantId?: string diff --git a/frontend/tests/component/casResourceIdentity.test.tsx b/frontend/tests/component/casResourceIdentity.test.tsx index 68e9918cfc..3008227dfd 100644 --- a/frontend/tests/component/casResourceIdentity.test.tsx +++ b/frontend/tests/component/casResourceIdentity.test.tsx @@ -138,6 +138,15 @@ function groupView() { ); } describe("resource identity operations", () => { + it("keeps user search and rows visible in read-only mode", () => { + render(); + + expect(screen.getByPlaceholderText("tenantResources.users.searchUsernameOrEmail")).toBeVisible(); + expect(screen.getAllByText("同名用户")).toHaveLength(2); + expect(screen.queryByRole("button", { name: "tenantResources.users.editUser" })).toBeNull(); + expect(screen.queryByRole("button", { name: "tenantResources.users.deleteUser" })).toBeNull(); + }); + it("keeps the editor open and does not refresh identity when saving roles fails", async () => { fixtures.viewerId = "a"; const detail = "Cannot remove the last active tenant administrator"; diff --git a/frontend/types/operations.ts b/frontend/types/operations.ts index ef098828ba..aa06922151 100644 --- a/frontend/types/operations.ts +++ b/frontend/types/operations.ts @@ -4,15 +4,3 @@ export interface OperationsAuthorization { tenant_id: string; permissions: Record<"dashboard" | "configuration", string[]>; } -export interface ResourceInventoryItem { - kind: string; - id: number; - name: string; - tenant_id: string; -} -export interface ResourceInventory { - items: ResourceInventoryItem[]; - total: number; - page: number; - page_size: number; -} diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml index ea48bdfb93..8ee012329c 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml @@ -40,9 +40,9 @@ case: contain both grants without duplicate entries. - order: 2 action: Assign OPS only, ADMIN plus OPS, and SU fixtures in turn; read permission metadata and use resource - read/write routes such as GET /ops/resources and POST /model/create with valid owned fixture payloads. - expected: Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, business - writes return 403; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is + read/write routes including the shared resource page lists, group members and model creation. + expected: Pure OPS receives exactly the three approved entry grants and credential-free tenant resource responses. Foreign tenant reads and business + writes are denied; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - order: 3 action: Submit old role-only update for a single ordinary-role target, then repeat against a multi-role target diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py index bdb054e1a0..b3718ddc2b 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py @@ -25,7 +25,19 @@ def test_roles_union_compatibility_and_operations_boundary(role_runtime): assert inventory.status_code == 200 assert {row["tenant_id"] for row in inventory.json()["data"]["items"]} == {"a"} assert "MUST-NOT-LEAK" not in inventory.text and "api_key" not in inventory.text - assert r.client.post("/model/create", headers=r.headers("user"), json={}).status_code == 403 + ops_headers = r.headers("user") + users = r.client.post("/users/list", headers=ops_headers, json={"tenant_id": "a", "page": 1, "page_size": 1}) + assert users.status_code == 200 and len(users.json()["data"]) == 1 + models = r.client.post("/model/manage/list", headers=ops_headers, json={"tenant_id": "a"}) + assert models.status_code == 200 and "Owned model" in models.text + assert "MUST-NOT-LEAK" not in models.text and "Foreign model" not in models.text + for path in ("/users/list", "/groups/list", "/model/manage/list", "/invitations/list"): + assert r.client.post(path, headers=ops_headers, json={"tenant_id": "b"}).status_code == 403 + assert r.client.get(f"/groups/{r.group}/members", headers=ops_headers).status_code == 200 + assert r.client.get(f"/groups/{r.foreign_group}/members", headers=ops_headers).status_code == 404 + assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "a"}).status_code == 200 + assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "b"}).status_code == 403 + assert r.client.post("/model/create", headers=ops_headers, json={}).status_code == 403 # Legacy bare JWTs must obey the same operations-only boundary as Bearer JWTs. bare_token = r.headers("user")["Authorization"].split()[1] assert r.client.get( diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml index e6268312fc..1706f7f651 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml @@ -41,7 +41,7 @@ case: action: Create an ADMIN plus OPS invitation, inspect its fixed role labels, then login with an owned pure OPS fixture. expected: Invitation has both roles and no role-edit control; pure OPS sees only operations dashboard, system - configuration and read-only resource management with business mutation controls absent. + configuration and the shared resource page. User search and model browsing work, with business mutation controls absent. - order: 3 action: Use owned DEV plus OPS and SU sessions to inspect entries and tenant scope, then remove OPS from the active target in a second admin session and refresh its authorization. The ADMIN viewer then adds OPS to @@ -52,7 +52,7 @@ case: expected_results: - Two role labels are displayed, the target appears once, and the saved selection survives reopening. - Invitation has both roles and no role-edit control; pure OPS sees only operations dashboard, system configuration - and read-only resource management with business mutation controls absent. + and the shared resource page with working user search and model browsing. Business mutation controls are absent. - Combined roles retain development entries; SU has cross-tenant operations selection; the revoked target loses OPS entries and direct protected navigation is denied or redirected without executing a business write. Saving the viewer's own roles refreshes navigation immediately while retaining ADMIN. @@ -68,7 +68,7 @@ case: - 1 - id: AC-02 description: Invitation has both roles and no role-edit control; pure OPS sees only operations dashboard, system - configuration and read-only resource management with business mutation controls absent. + configuration and the shared resource page with working user search and model browsing. Business mutation controls are absent. steps: - 2 expected_results: diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts index cab3316048..708d96dd32 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts @@ -1,5 +1,5 @@ // Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -import { expect, type BrowserContext } from "playwright/test"; +import { expect, type BrowserContext, type Page } from "playwright/test"; import { journey } from "../../infra/automation/d4/runner/journey"; /** Uses the current-source isolated identity fixture; never shared tenant accounts. */ @@ -30,6 +30,14 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { page: actor === "admin1" ? page : await context.newPage(), }; } + async function chooseRole(page: Page, role: RegExp) { + const dropdown = page.locator(".ant-select-dropdown:visible"); + await expect(dropdown).toHaveCount(1); + await dropdown + .locator(".ant-select-item-option-content") + .filter({ hasText: role }) + .click(); + } const admin = await login("admin1"); let invitationCode: string | undefined; let invitationRoles: string[] = []; @@ -93,15 +101,9 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { await modal.locator(".ant-select-selection-item-remove").first().click(); const roles = modal.locator("#roles"); await roles.click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Developer$/ }) - .click(); + await chooseRole(admin.page, /^Developer$/); await roles.click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Operations$/ }) - .click(); + await chooseRole(admin.page, /^Operations$/); await admin.page.keyboard.press("Escape"); await modal.getByRole("button", { name: "Confirm", exact: true }).click(); await expect(row.getByText("Developer", { exact: true })).toBeVisible(); @@ -116,10 +118,7 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { response.request().postDataJSON()?.roles?.includes("OPS"), ); await admin.page.getByRole("combobox").first().click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Operations$/ }) - .click(); + await chooseRole(admin.page, /^Operations$/); filteredIds = (await (await filtered).json()).data.map( (record: { id: string }) => record.id, ); @@ -138,15 +137,9 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { modal = admin.page.getByRole("dialog"); await modal.locator(".ant-select-selection-item-remove").first().click(); await modal.locator("#roles").click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Admin$/ }) - .click(); + await chooseRole(admin.page, /^Admin$/); await modal.locator("#roles").click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Operations$/ }) - .click(); + await chooseRole(admin.page, /^Operations$/); await admin.page.keyboard.press("Escape"); const savedInvitation = admin.page.waitForResponse( (response) => @@ -201,14 +194,17 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { ).toBeVisible(); await ops.page.goto(`${baseURL}/en/resource-manage`); await expect( - ops.page.getByText("Read-only resource inventory"), - ).toBeVisible(); - await expect( - ops.page.getByText("Owned model", { exact: true }), + ops.page.getByText("Read-only mode", { exact: true }), ).toBeVisible(); await expect( ops.page.getByRole("tab", { name: "Users", exact: true }), - ).toHaveCount(0); + ).toBeVisible(); + const search = ops.page.getByPlaceholder("Search email"); + await search.fill(fixture.emails.user); + await expect(ops.page.getByRole("row").filter({ hasText: fixture.emails.user })).toBeVisible(); + await expect(ops.page.getByRole("row").filter({ hasText: fixture.emails.admin1 })).toHaveCount(0); + await ops.page.getByRole("tab", { name: "Models", exact: true }).click(); + await expect(ops.page.getByText("Owned model", { exact: true })).toBeVisible(); await expect( ops.page.getByRole("button", { name: /Edit|Delete|Create/ }), ).toHaveCount(0); @@ -275,10 +271,7 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { .click(); const selfDialog = admin.page.getByRole("dialog"); await selfDialog.locator("#roles").click(); - await admin.page - .locator(".ant-select-dropdown:visible .ant-select-item-option-content") - .filter({ hasText: /^Operations$/ }) - .click(); + await chooseRole(admin.page, /^Operations$/); await admin.page.keyboard.press("Escape"); await selfDialog .getByRole("button", { name: "Confirm", exact: true }) diff --git a/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml index e2efd4843f..a1af051e8a 100644 --- a/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml +++ b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml @@ -46,6 +46,8 @@ change: - Reject retired SPEED inputs and remove missing-credential/implicit-admin fallbacks; migration prechecks protect active SPEED-only subjects and ASSET_OWNER remains unchanged. + - Reuse the resource page for OPS with tenant-scoped reads, hidden write controls, + redacted credentials and diagnostics that do not persist resource changes. reason: Existing RBAC and DAC UT prove single-role cases; user UT covers own-tenant ADMIN and cross-tenant/SU rejection; invitation UT has old admin-invite restriction that must change. Strengthen those units rather than duplicate them as formal @@ -62,6 +64,7 @@ change: - test/backend/permissions/test_dac.py - test/backend/permissions/test_operations.py - test/backend/permissions/test_operations_boundary.py + - test/backend/services/test_model_health_outlet_routing.py - test/backend/management/test_identity_import_architecture.py - test/backend/services/test_role_assignment.py - test/backend/services/test_invitation_roles.py @@ -93,6 +96,7 @@ change: - test/backend/app/test_user_management_app.py - test/backend/app/test_vectordatabase_app.py - test/backend/app/test_quota_app.py + - test/backend/app/test_skill_app.py notes: Implemented OPS permissions with supporting role-array refactor, fixed invitation grants, atomic local registration and administrator invariants. AUTH-LOCAL-D2-001 fixture setup now uses authoritative arrays; its original identity/API-key assertions @@ -105,4 +109,5 @@ change: does not add a maintained operations migration Case. Existing AUTH-LOCAL-D5-001 is strengthened only for its account-import and deployment responsibilities, including user_roles and scalar-column removal. Coverage backfill adds isolated assertions for locked invitation updates and redemption, registration rollback, user endpoint error mapping, and the inventory query projection and pagination. Existing D2 and D3 retain - live tenant authorization and registration concurrency proof. + live tenant authorization and registration concurrency proof. D2 and D4 also exercise + shared resource tabs, scoped lists and details, and the absence of write controls. diff --git a/test-e2e/infra/automation/ops_roles_http.py b/test-e2e/infra/automation/ops_roles_http.py index 55218257b3..93b00dd410 100644 --- a/test-e2e/infra/automation/ops_roles_http.py +++ b/test-e2e/infra/automation/ops_roles_http.py @@ -112,7 +112,7 @@ def isolated_runtime(): raise RuntimeError("Expected the versioned operations-role migration") connection.connection.cursor().execute(upgrades[0].read_text(encoding="utf-8")) from management.services.permission.identity import authentication as auth - from management.api.identity import user_management, user, invitation, group, tenant + from management.api.identity import api_key, user_management, user, invitation, group, tenant from management.api.configuration import tenant_config from management.api.model import model_managment from gateway.authentication import auth as helpers @@ -124,7 +124,7 @@ def isolated_runtime(): for routes in (user_management, user, invitation): patch.setattr(routes, "record_security_event", lambda *args, **kwargs: None) app = create_app(enable_monitoring=False) - for module in (user_management, user, invitation, group, tenant, tenant_config, model_managment, access): + for module in (user_management, user, invitation, group, tenant, api_key, tenant_config, model_managment, access): app.include_router(module.router if hasattr(module, "router") else module.model_manager_router) from permissions import rbac @@ -178,6 +178,8 @@ def seed(self): session.flush() if tenant == "a": self.group = group.group_id + else: + self.foreign_group = group.group_id for key, value in (("TENANT_NAME", "Owned " + tenant), ("DEFAULT_GROUP_ID", str(group.group_id))): session.add( self.models.TenantConfig(tenant_id=tenant, config_key=key, config_value=value, delete_flag="N") diff --git a/test/backend/app/test_skill_app.py b/test/backend/app/test_skill_app.py index c00854c1a2..ec8aaaaecd 100644 --- a/test/backend/app/test_skill_app.py +++ b/test/backend/app/test_skill_app.py @@ -133,11 +133,14 @@ def __init__(self, local_skills_dir=None, **kwargs): permissions_mock = types.ModuleType("permissions") permissions_depends_mock = types.ModuleType("permissions.depends") permissions_models_mock = types.ModuleType("permissions.models") +permissions_operations_mock = types.ModuleType("permissions.operations") +permissions_operations_mock.resource_read_tenant = lambda request: None permissions_depends_mock.require = lambda _permission: (lambda: None) permissions_models_mock.CurrentUser = object sys.modules["permissions"] = permissions_mock sys.modules["permissions.depends"] = permissions_depends_mock sys.modules["permissions.models"] = permissions_models_mock +sys.modules["permissions.operations"] = permissions_operations_mock class SkillException(Exception): pass @@ -376,6 +379,21 @@ def test_list_skills_success(self, mocker): assert "skills" in data assert len(data["skills"]) == 2 + def test_operations_list_exposes_skill_metadata_without_config(self, mocker): + mocker.patch.object(skill_app, "get_current_user_id", return_value=("actor", "tenant")) + mocker.patch.object(skill_app, "resource_read_tenant", return_value="tenant") + service = mocker.patch.object(skill_app, "SkillService").return_value + service.list_skills.return_value = [{"skill_id": 1, "name": "Example", "config_values": {"key": "hidden"}}] + app = FastAPI() + app.include_router(skill_app.router) + + response = TestClient(app).get("/skills", params={"tenant_id": "tenant"}) + + assert response.status_code == 200 + item = response.json()["skills"][0] + assert item["name"] == "Example" and "config_values" not in item + service.list_visible_skills.assert_not_called() + def test_list_skills_empty(self, mocker): """Test listing skills when none exist.""" with patch('management.api.skill.skill.get_current_user_id') as mock_auth: diff --git a/test/backend/permissions/test_operations_boundary.py b/test/backend/permissions/test_operations_boundary.py index 94dfaaaee6..a9ebce3f8e 100644 --- a/test/backend/permissions/test_operations_boundary.py +++ b/test/backend/permissions/test_operations_boundary.py @@ -2,7 +2,7 @@ """Operations-only HTTP isolation covers every method without weakening other roles.""" import pytest -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.testclient import TestClient from permissions.operations_boundary import install_operations_boundary @@ -49,3 +49,31 @@ def test_allowed_paths_keep_endpoint_authorization(mocker, path, method): app.add_api_route(path, lambda: {"ok": True}, methods=[method]) with TestClient(app) as client: assert client.request(method, path, headers={"Authorization": "Bearer owned"}).status_code == 200 + + +@pytest.mark.parametrize( + "method,path,permission,status", + [ + ("POST", "/model/manage/list", True, 200), + ("GET", "/agent/list?tenant_id=a", True, 200), + ("GET", "/agent/list?tenant_id=b&tenant_id=a", True, 403), + ("GET", "/agent/list", False, 403), + ("DELETE", "/agent/list", True, 403), + ("GET", "/mcp/record/1", True, 403), + ], +) +def test_resource_reads_bind_the_authenticated_tenant(mocker, method, path, permission, status): + mocker.patch("permissions.operations_boundary.get_current_user_context", return_value=("actor", "a", ("OPS",))) + mocker.patch("permissions.operations_boundary.has_permission", return_value=permission) + app = FastAPI() + install_operations_boundary(app) + + def read(request: Request): + return {"tenant": request.state.resource_read_tenant} + + app.add_api_route(path.split("?", 1)[0], read, methods=[method]) + with TestClient(app) as client: + response = client.request(method, path, headers={"Authorization": "Bearer owned"}) + assert response.status_code == status + if status == 200: + assert response.json() == {"tenant": "a"} diff --git a/test/backend/services/test_api_key_service.py b/test/backend/services/test_api_key_service.py index 3e0e57bc57..e4b58dd2d7 100644 --- a/test/backend/services/test_api_key_service.py +++ b/test/backend/services/test_api_key_service.py @@ -184,6 +184,17 @@ def test_list_tenant_api_keys_allows_superuser_and_forwards_pagination(mock_list mock_list.assert_called_once_with("tenant-a", 2, 10, "asc") +@patch("management.services.permission.api_keys.list_active_tokens_by_tenant") +def test_operations_api_key_list_keeps_metadata_without_the_key(mock_list): + mock_list.return_value = {"items": [{"token_id": 1, "access_key": "secret", "total_usage_count": 5}], "total": 1} + + result = api_key_service.list_tenant_api_keys( + actor_tenant_id="tenant-a", actor_role=("OPS",), tenant_id="tenant-a" + ) + + assert result == {"items": [{"token_id": 1, "total_usage_count": 5}], "total": 1} + + def test_list_tenant_api_keys_rejects_other_tenant(): with pytest.raises(NotFoundException, match="Tenant not found"): api_key_service.list_tenant_api_keys( diff --git a/test/backend/services/test_model_health_outlet_routing.py b/test/backend/services/test_model_health_outlet_routing.py index 350d536f35..e9c7afef43 100644 --- a/test/backend/services/test_model_health_outlet_routing.py +++ b/test/backend/services/test_model_health_outlet_routing.py @@ -72,3 +72,16 @@ async def test_missing_outlet_does_not_probe_any_endpoint(health, monkeypatch): with pytest.raises(LookupError, match="MODEL_CONFIGURATION_NOT_FOUND"): await module.check_model_connectivity("missing", "tenant") profiles.validate_model_outlet.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_inventory_probe_does_not_update_the_saved_outlet(health, monkeypatch): + module, profiles = health + monkeypatch.setattr(module, "get_model_by_display_name", Mock(return_value={ + "model_type": "llm", "model_id": 42, "model_name": "exact", + })) + + result = await module.check_model_connectivity("chosen", "tenant", persist_status=False) + + assert result["connectivity"] is True + assert profiles.validate_model_outlet.await_args.kwargs == {"persist_status": False} From 0453b81b1a886246734b863e75e619f6c5a8ce9a Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:58:47 +0800 Subject: [PATCH 6/8] fix(ops): use route authorization without a global allowlist --- .../gateway/authentication/northbound_auth.py | 2 - backend/management/api/agent/agent.py | 15 ++- backend/management/api/identity/group.py | 33 +++--- .../api/knowledge_base/knowledge_summary.py | 4 +- .../api/knowledge_base/vectordatabase.py | 4 +- backend/management/api/mcp/remote_mcp.py | 17 ++- .../management/api/model/model_managment.py | 5 +- backend/management/api/skill/skill.py | 7 +- backend/management/api/tool/tool_config.py | 4 +- backend/management/utils/app_factory.py | 3 - backend/permissions/operations.py | 35 +++++- backend/permissions/operations_boundary.py | 104 ------------------ .../cases/AUTH-MULTI-ROLE-D2-001/case.yaml | 10 +- test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py | 14 ++- .../AUTH-MULTI-ROLE-20261031.yaml | 3 +- test/backend/app/test_agent_app.py | 1 + test/backend/app/test_group_app.py | 4 + .../backend/app/test_knowledge_summary_app.py | 2 + test/backend/app/test_skill_app.py | 2 +- test/backend/permissions/test_operations.py | 30 ++++- .../permissions/test_operations_boundary.py | 79 ------------- 21 files changed, 134 insertions(+), 244 deletions(-) delete mode 100644 backend/permissions/operations_boundary.py delete mode 100644 test/backend/permissions/test_operations_boundary.py diff --git a/backend/gateway/authentication/northbound_auth.py b/backend/gateway/authentication/northbound_auth.py index 5e1e183045..1203f79bbc 100644 --- a/backend/gateway/authentication/northbound_auth.py +++ b/backend/gateway/authentication/northbound_auth.py @@ -195,8 +195,6 @@ def get_user_and_tenant_by_access_key(access_key: str) -> Dict[str, str]: roles = roles_from_record(user_tenant_record) if not roles: raise TokenExpiredError("No active roles for this account") - if roles == ("OPS",): - raise HTTPException(status_code=403, detail="Operations-only identity cannot invoke Agents") tenant_id = user_tenant_record["tenant_id"] return { diff --git a/backend/management/api/agent/agent.py b/backend/management/api/agent/agent.py index 85e38be858..2d4159f378 100644 --- a/backend/management/api/agent/agent.py +++ b/backend/management/api/agent/agent.py @@ -182,8 +182,11 @@ 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 resource_read_tenant(request): + if inventory_tenant: if agent_info.get("tenant_id") != auth_tenant_id: raise AgentNotFoundError("Agent not found") agent_info = { @@ -197,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.") @@ -611,7 +616,7 @@ 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): + 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, ) @@ -623,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.") @@ -774,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/identity/group.py b/backend/management/api/identity/group.py index be5378d411..0c72f1a82f 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,8 +21,8 @@ get_groups_by_tenant, update_group_members ) from management.services.permission.identity.tenants import get_tenant_info -from permissions.operations import resource_read_tenant -from gateway.authentication.auth import get_current_user_id +from permissions.models import CurrentUser +from permissions.operations import tenant_scope logger = logging.getLogger(__name__) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -119,7 +118,7 @@ async def get_group_endpoint(group_id: int) -> JSONResponse: @router.post("/list") async def get_groups_endpoint( request: GroupListRequest, - http_request: Request = None, + current_user: CurrentUser = Depends(authenticate), ) -> JSONResponse: """ Search groups for a specific tenant with pagination @@ -131,13 +130,9 @@ async def get_groups_endpoint( Returns: JSONResponse: List of groups for the tenant (paginated or all) """ - authorized_tenant = resource_read_tenant(http_request) - if authorized_tenant and request.tenant_id != authorized_tenant: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Cannot list groups for another tenant") + tenant_scope(current_user, request.tenant_id) try: - # Validate tenant exists - if not authorized_tenant: - 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, @@ -385,7 +380,10 @@ async def remove_user_from_group_endpoint( @router.get("/{group_id}/members") -async def get_group_users_endpoint(group_id: int, http_request: Request = None) -> JSONResponse: +async def get_group_users_endpoint( + group_id: int, + current_user: CurrentUser = Depends(authenticate), +) -> JSONResponse: """ Get all users in a group @@ -396,12 +394,9 @@ async def get_group_users_endpoint(group_id: int, http_request: Request = None) Returns: JSONResponse: List of group users """ + authorized_tenant = tenant_scope(current_user) try: - authorized_tenant = resource_read_tenant(http_request) - users = ( - get_group_users(group_id, tenant_id=authorized_tenant) - if authorized_tenant else 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/knowledge_base/knowledge_summary.py b/backend/management/api/knowledge_base/knowledge_summary.py index 864c0a1932..06019e0085 100644 --- a/backend/management/api/knowledge_base/knowledge_summary.py +++ b/backend/management/api/knowledge_base/knowledge_summary.py @@ -12,7 +12,7 @@ from permissions.operations 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") @@ -92,7 +92,7 @@ def change_summary( raise HTTPException(status_code=500, detail=f"Knowledge base summary update failed: {str(e)}") -@router.get("/{index_name}/summary") +@router.get("/{index_name}/summary", dependencies=[Depends(authenticate)]) def get_summary( index_name: str = Path(..., description="Name of the index to get documents from"), request: Request = None, diff --git a/backend/management/api/knowledge_base/vectordatabase.py b/backend/management/api/knowledge_base/vectordatabase.py index 7ee19c20db..99cc324256 100644 --- a/backend/management/api/knowledge_base/vectordatabase.py +++ b/backend/management/api/knowledge_base/vectordatabase.py @@ -517,7 +517,7 @@ def get_list_indices( "sources": sources, "models": models, } - if authorized_tenant := resource_read_tenant(request): + 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, @@ -567,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)}") diff --git a/backend/management/api/mcp/remote_mcp.py b/backend/management/api/mcp/remote_mcp.py index 9fa409ae19..a435df1025 100644 --- a/backend/management/api/mcp/remote_mcp.py +++ b/backend/management/api/mcp/remote_mcp.py @@ -514,7 +514,7 @@ 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 - if authorized_tenant := resource_read_tenant(http_request): + 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, ) @@ -583,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() @@ -620,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() @@ -628,7 +629,7 @@ 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 resource_read_tenant(http_request) and not any( + 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) ): @@ -676,24 +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 resource_read_tenant(http_request) else {}), + **({"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="MCP connection failed" if resource_read_tenant(http_request) else (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/model/model_managment.py b/backend/management/api/model/model_managment.py index cd1baa34ea..9f9a7a6327 100644 --- a/backend/management/api/model/model_managment.py +++ b/backend/management/api/model/model_managment.py @@ -113,7 +113,7 @@ from management.utils.app_factory import ModelCredentialRoute from nexent.core.concurrency import run_blocking from permissions.depends import require -from permissions.operations import operations_scope, resource_read_tenant +from permissions.operations import operations_scope from permissions.models import CurrentUser # Permission strings normalized by backend RBAC cache (lower-case type:subtype). @@ -859,7 +859,6 @@ async def check_temporary_model_health( async def manage_check_model_health( request: ManageTenantModelHealthcheckRequest, current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION, alternative="resource.inventory:read")), - http_request: Request = None, ): """Check and update model connectivity for a specified tenant (admin/manage operation). @@ -871,7 +870,7 @@ async def manage_check_model_health( Returns: Connectivity check result with updated status. """ - read_only = resource_read_tenant(http_request) is not None + read_only = not has_any_role(current_user.effective_roles, _MANAGE_ALLOWED_ROLES) if read_only: operations_scope(current_user, request.tenant_id, "resource.inventory:read") else: diff --git a/backend/management/api/skill/skill.py b/backend/management/api/skill/skill.py index 69c23a0aad..9da97dea7f 100644 --- a/backend/management/api/skill/skill.py +++ b/backend/management/api/skill/skill.py @@ -67,9 +67,10 @@ async def list_skills( 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 resource_read_tenant(request): + 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}) @@ -78,6 +79,8 @@ async def list_skills( 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/tool/tool_config.py b/backend/management/api/tool/tool_config.py index 528c8096f5..061528c483 100644 --- a/backend/management/api/tool/tool_config.py +++ b/backend/management/api/tool/tool_config.py @@ -278,13 +278,15 @@ async def list_openapi_services_api(authorization: Optional[str] = Header(None), try: _, tenant_id = get_current_user_id(authorization) services = list_openapi_services(tenant_id) - if resource_read_tenant(request): + 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/utils/app_factory.py b/backend/management/utils/app_factory.py index 6c7228dfcc..032c83a640 100644 --- a/backend/management/utils/app_factory.py +++ b/backend/management/utils/app_factory.py @@ -13,7 +13,6 @@ from management.utils.exception_handlers import register_exception_handlers from management.utils.health import install_health_contract -from permissions.operations_boundary import install_operations_boundary try: from infrastructure.observability.bootstrap import monitoring_manager @@ -66,8 +65,6 @@ def create_app( allow_headers=["*"], ) - install_operations_boundary(app) - # Register exception handlers register_exception_handlers(app) diff --git a/backend/permissions/operations.py b/backend/permissions/operations.py index a1f502d9ad..49d2b4983a 100644 --- a/backend/permissions/operations.py +++ b/backend/permissions/operations.py @@ -1,8 +1,11 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. """Action-specific operations scope; never expands ordinary business scope.""" -from fastapi import HTTPException +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 ACTIONS = ("read", "create", "update", "delete") @@ -31,6 +34,30 @@ def authorization_metadata(user): } -def resource_read_tenant(request) -> str | None: - """Return the tenant authorized for an operations resource read.""" - return getattr(request.state, "resource_read_tenant", None) if request is not None else None +def resource_read_tenant(request: Request | None, *, shared: bool = False) -> str | None: + """Resolve the tenant for a route that offers inventory reads.""" + authorization = request.headers.get("authorization") if request is not None else None + if not authorization: + return None + try: + _, tenant_id, roles = get_current_user_context(authorization) + except (UnauthorizedError, TokenExpiredError): + return None + + if {"ADMIN", "SU"}.intersection(roles) or not has_permission(roles, "resource.inventory:read"): + return None + targets = request.query_params.getlist("tenant_id") + if shared and len(roles) > 1 and not any(targets): + return None + if not tenant_id or any(target and target != tenant_id for target in targets): + raise HTTPException(status_code=403, detail="Resource reads are limited to your tenant") + return tenant_id + + +def tenant_scope(user: CurrentUser, requested_tenant: str | None = None) -> str | None: + """Resolve a resource tenant with the same rule for every account.""" + if "SU" in user.effective_roles: + return requested_tenant + if not user.tenant_id or (requested_tenant is not None and requested_tenant != user.tenant_id): + raise HTTPException(status_code=403, detail="Resource reads are limited to your tenant") + return user.tenant_id diff --git a/backend/permissions/operations_boundary.py b/backend/permissions/operations_boundary.py deleted file mode 100644 index 80b3aa919a..0000000000 --- a/backend/permissions/operations_boundary.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Restrict operations-only identities at the shared business HTTP boundary.""" - -import re - -from starlette.concurrency import run_in_threadpool -from starlette.responses import JSONResponse - -from contracts.errors.exceptions import TokenExpiredError, UnauthorizedError -from gateway.authentication.auth import get_current_user_context -from permissions.rbac import has_permission - -# These existing endpoints only maintain the caller's own authenticated session. -SESSION_PATHS = frozenset( - { - "/user/current_user_info", - "/user/current_user_id", - "/user/session", - "/user/logout", - "/user/revoke", - "/user/refresh_token", - "/user/password", - } -) -BOOTSTRAP_PATHS = frozenset({"/tenant_config/deployment_version", "/user/cas/config", "/user/oauth/config"}) - - -# Some existing list and detail endpoints use POST with a read-only request body. -RESOURCE_READ_POSTS = frozenset({ - "/users/list", "/groups/list", "/model/manage/list", "/model/manage/healthcheck", - "/invitations/list", "/agent/search_info", -}) -RESOURCE_READ_GETS = frozenset({ - "/api-keys", "/agent/list", "/model/capacity-coverage", "/monitoring/models", - "/indices", "/indices/health", "/skills", - "/mcp/list", "/mcp/containers", "/mcp/tools", "/mcp/healthcheck", "/tool/openapi_services", - "/tag-libraries", "/capacity/personal/users", "/capacity/personal/summary", - "/capacity/personal/default-quota", -}) -# Combined roles use their ordinary read behavior for general business views -# unless the request explicitly targets the authenticated tenant. -SHARED_READ_PATHS = frozenset({ - "/agent/list", "/agent/search_info", "/indices", "/skills", - "/mcp/list", "/mcp/containers", "/mcp/tools", "/mcp/healthcheck", "/tool/openapi_services", -}) -RESOURCE_READ_PATH = re.compile( - r"/(?:tenants/[^/]+(?:/quota/usage)?|groups/[0-9]+/members|" - r"agent/[0-9]+/versions|summary/[^/]+/summary|tag-libraries/[0-9]+/definitions|" - r"capacity/personal/users/[^/]+/kbs|mcp/container/[^/]+/logs)" -) - - -def is_resource_read(method: str, path: str) -> bool: - if method == "POST": - return path in RESOURCE_READ_POSTS - return method == "GET" and (path in RESOURCE_READ_GETS or RESOURCE_READ_PATH.fullmatch(path) is not None) - - -def install_operations_boundary(app): - @app.middleware("http") - async def operations_boundary(request, call_next): - path = request.scope.get("path", "") - root = request.scope.get("root_path", "") - if root and path.startswith(root + "/"): - path = path[len(root) :] - authorization = request.headers.get("authorization") - needs_session = ( - (request.method == "POST" and path == "/groups/list") - or (request.method == "GET" and ( - path.startswith("/summary/") and path.endswith("/summary") - or re.fullmatch(r"/groups/[0-9]+/members", path) is not None - )) - ) - if needs_session and not authorization: - return JSONResponse(status_code=401, content={"message": "Authentication required"}) - if authorization: - try: - _, tenant_id, roles = await run_in_threadpool(get_current_user_context, authorization) - except (UnauthorizedError, TokenExpiredError): - if needs_session: - return JSONResponse(status_code=401, content={"message": "Authentication required"}) - # Each existing route retains its credential policy and error envelope. - return await call_next(request) - role_set = set(roles) - targets = request.query_params.getlist("tenant_id") - uses_inventory = role_set == {"OPS"} or path not in SHARED_READ_PATHS or tenant_id in targets - if ( - "OPS" in role_set - and not role_set.intersection({"ADMIN", "SU"}) - and uses_inventory - and is_resource_read(request.method, path) - ): - allowed = await run_in_threadpool(has_permission, roles, "resource.inventory:read") - if not allowed or not tenant_id or any(target and target != tenant_id for target in targets): - return JSONResponse(status_code=403, content={"message": "Resource reads are limited to your tenant"}) - request.state.resource_read_tenant = tenant_id - return await call_next(request) - - bootstrap = request.method == "GET" and path in BOOTSTRAP_PATHS - if role_set == {"OPS"} and path not in SESSION_PATHS and not bootstrap and not path.startswith("/ops/"): - return JSONResponse( - status_code=403, content={"message": "Operations-only identity cannot access business APIs"} - ) - return await call_next(request) diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml index 8ee012329c..48a7a53b7c 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml @@ -40,8 +40,8 @@ case: contain both grants without duplicate entries. - order: 2 action: Assign OPS only, ADMIN plus OPS, and SU fixtures in turn; read permission metadata and use resource - read/write routes including the shared resource page lists, group members and model creation. - expected: Pure OPS receives exactly the three approved entry grants and credential-free tenant resource responses. Foreign tenant reads and business + read/write routes including the shared resource page lists, group members and model creation. Check the public authentication probe and the existing self-service token list with an OPS session. + expected: Pure OPS receives exactly the three approved entry grants and credential-free tenant resource responses. The authentication probe and self-service token list accept the OPS session. Foreign tenant resource reads and model writes are denied; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - order: 3 @@ -59,7 +59,7 @@ case: - Response retains its original envelope, includes both roles once, and permissions and accessibleRoutes contain both grants without duplicate entries. - Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, business writes - return 403; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. + with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - Single-role compatibility succeeds, multi-role target returns 409 without truncation, ambiguous input is rejected by the documented validation contract; unknown and empty role sets are rejected. - SPEED grant inputs are rejected with no persisted change; missing credentials return 401 instead of an implicit @@ -77,7 +77,7 @@ case: - 1 - id: AC-02 description: Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, - business writes return 403; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS + business writes with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. steps: - 2 @@ -114,7 +114,7 @@ case: - Response retains its original envelope, includes both roles once, and permissions and accessibleRoutes contain both grants without duplicate entries. - Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, business writes - return 403; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. + with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - Single-role compatibility succeeds, multi-role target returns 409 without truncation, ambiguous input is rejected by the documented validation contract; unknown and empty role sets are rejected. - SPEED grant inputs are rejected with no persisted change; missing credentials return 401 instead of an implicit diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py index b3718ddc2b..6190345444 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py @@ -36,13 +36,15 @@ def test_roles_union_compatibility_and_operations_boundary(role_runtime): assert r.client.get(f"/groups/{r.group}/members", headers=ops_headers).status_code == 200 assert r.client.get(f"/groups/{r.foreign_group}/members", headers=ops_headers).status_code == 404 assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "a"}).status_code == 200 - assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "b"}).status_code == 403 + assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "b"}).status_code == 404 assert r.client.post("/model/create", headers=ops_headers, json={}).status_code == 403 - # Legacy bare JWTs must obey the same operations-only boundary as Bearer JWTs. - bare_token = r.headers("user")["Authorization"].split()[1] - assert r.client.get( - "/user/tokens", headers={"Authorization": bare_token}, params={"user_id": r.users["user"]} - ).status_code == 403 + public_probe = r.client.get("/user/service_health") + ops_probe = r.client.get("/user/service_health", headers=ops_headers) + assert public_probe.status_code == ops_probe.status_code == 200 + assert public_probe.json() == ops_probe.json() + bare_headers = {"Authorization": ops_headers["Authorization"].split()[1]} + assert r.client.get("/user/tokens", headers=bare_headers, params={"user_id": r.users["user"]}).status_code == 200 + assert r.client.get("/user/tokens", headers=bare_headers, params={"user_id": r.users["foreign"]}).status_code == 403 for action in ("read", "create", "update", "delete"): assert ( r.client.get( diff --git a/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml index a1af051e8a..d4af1cc61a 100644 --- a/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml +++ b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml @@ -48,6 +48,8 @@ change: unchanged. - Reuse the resource page for OPS with tenant-scoped reads, hidden write controls, redacted credentials and diagnostics that do not persist resource changes. + - Apply the ordinary route authorization contract with an OPS session; verify + the public health probe and personal token endpoint through the D2 HTTP case. reason: Existing RBAC and DAC UT prove single-role cases; user UT covers own-tenant ADMIN and cross-tenant/SU rejection; invitation UT has old admin-invite restriction that must change. Strengthen those units rather than duplicate them as formal @@ -63,7 +65,6 @@ change: - test/backend/permissions/test_rbac.py - test/backend/permissions/test_dac.py - test/backend/permissions/test_operations.py - - test/backend/permissions/test_operations_boundary.py - test/backend/services/test_model_health_outlet_routing.py - test/backend/management/test_identity_import_architecture.py - test/backend/services/test_role_assignment.py diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index fbcff481d1..678bf0f376 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -328,6 +328,7 @@ async def test_workbench_bootstrap_is_unavailable_when_disabled(mocker): @pytest.fixture(autouse=True) def creator_history_boundary(mocker): + mocker.patch("permissions.operations.get_current_user_context", return_value=("test_user", "test_tenant", ("DEV",))) from contracts.agent import SystemAgentRef from management.services.conversation import creator_execution mocker.patch.object(creator_execution.system_agent_provider, "get_creator_ref", return_value=SystemAgentRef("shared", "nl2agent_main", 77, 0)) diff --git a/test/backend/app/test_group_app.py b/test/backend/app/test_group_app.py index 7a00b4cbc3..93864e6a3e 100644 --- a/test/backend/app/test_group_app.py +++ b/test/backend/app/test_group_app.py @@ -44,8 +44,12 @@ # Create a test client with a fresh FastAPI app from management.api.identity.group import router +from gateway.authentication.auth import authenticate +from permissions.models import CurrentUser + app = FastAPI() app.include_router(router) +app.dependency_overrides[authenticate] = lambda: CurrentUser("actor", "platform", "SU") client = TestClient(app) diff --git a/test/backend/app/test_knowledge_summary_app.py b/test/backend/app/test_knowledge_summary_app.py index de9b2dd463..c49b735dcc 100644 --- a/test/backend/app/test_knowledge_summary_app.py +++ b/test/backend/app/test_knowledge_summary_app.py @@ -104,6 +104,8 @@ class ChangeSummaryRequest(BaseModel): # Mock utils modules used by knowledge_summary_app to avoid deep DB/storage import chains utils_auth_utils_mock = types.ModuleType('gateway.authentication.auth') utils_auth_utils_mock.get_current_user_id = MagicMock(return_value=("test_user_id", "test_tenant_id")) +utils_auth_utils_mock.authenticate = lambda: None +utils_auth_utils_mock.get_current_user_context = MagicMock(return_value=("test_user_id", "test_tenant_id", ("DEV",))) utils_auth_utils_mock.get_current_user_info = MagicMock(return_value=("test_user_id", "test_tenant_id", "en")) sys.modules['gateway.authentication.auth'] = utils_auth_utils_mock diff --git a/test/backend/app/test_skill_app.py b/test/backend/app/test_skill_app.py index ec8aaaaecd..e3f79d7e0b 100644 --- a/test/backend/app/test_skill_app.py +++ b/test/backend/app/test_skill_app.py @@ -134,7 +134,7 @@ def __init__(self, local_skills_dir=None, **kwargs): permissions_depends_mock = types.ModuleType("permissions.depends") permissions_models_mock = types.ModuleType("permissions.models") permissions_operations_mock = types.ModuleType("permissions.operations") -permissions_operations_mock.resource_read_tenant = lambda request: None +permissions_operations_mock.resource_read_tenant = lambda request, *, shared=False: None permissions_depends_mock.require = lambda _permission: (lambda: None) permissions_models_mock.CurrentUser = object sys.modules["permissions"] = permissions_mock diff --git a/test/backend/permissions/test_operations.py b/test/backend/permissions/test_operations.py index 2bbe4a4be3..d78cd86250 100644 --- a/test/backend/permissions/test_operations.py +++ b/test/backend/permissions/test_operations.py @@ -2,11 +2,11 @@ """Operations grants and scope are independent of business permissions.""" import pytest -from fastapi import HTTPException +from fastapi import HTTPException, Request from contracts.configuration import validate_deployment_version from permissions.models import CurrentUser -from permissions.operations import authorization_metadata, operations_scope +from permissions.operations import authorization_metadata, operations_scope, resource_read_tenant @pytest.fixture(autouse=True) @@ -45,3 +45,29 @@ def test_retired_deployment_mode_is_not_silently_elevated(): with pytest.raises(ValueError): validate_deployment_version("speed") assert validate_deployment_version("full") == "full" + + +@pytest.mark.parametrize( + "roles, query, tenant", + [ + (("OPS",), b"", "a"), + (("OPS",), b"tenant_id=a", "a"), + (("DEV", "OPS"), b"", None), + (("DEV", "OPS"), b"tenant_id=a", "a"), + (("ADMIN", "OPS"), b"tenant_id=a", None), + (("DEV",), b"tenant_id=a", None), + ], +) +def test_resource_reads_use_the_inventory_grant(mocker, roles, query, tenant): + mocker.patch("permissions.operations.get_current_user_context", return_value=("actor", "a", roles)) + request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) + assert resource_read_tenant(request, shared=True) == tenant + + +@pytest.mark.parametrize("query", [b"tenant_id=b", b"tenant_id=b&tenant_id=a"]) +def test_resource_inventory_rejects_foreign_tenant_targets(mocker, query): + mocker.patch("permissions.operations.get_current_user_context", return_value=("actor", "a", ("OPS",))) + request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) + with pytest.raises(HTTPException) as error: + resource_read_tenant(request) + assert error.value.status_code == 403 diff --git a/test/backend/permissions/test_operations_boundary.py b/test/backend/permissions/test_operations_boundary.py deleted file mode 100644 index a9ebce3f8e..0000000000 --- a/test/backend/permissions/test_operations_boundary.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Operations-only HTTP isolation covers every method without weakening other roles.""" - -import pytest -from fastapi import FastAPI, Request -from fastapi.testclient import TestClient - -from permissions.operations_boundary import install_operations_boundary - - -@pytest.mark.parametrize( - "roles,method,authorization,status", - [ - (("OPS",), "GET", "Bearer owned", 403), - (("OPS",), "POST", "owned", 403), - (("OPS",), "PUT", "Bearer owned", 403), - (("OPS",), "DELETE", "owned", 403), - (("DEV", "OPS"), "POST", "owned", 200), - (("ADMIN", "OPS"), "PUT", "Bearer owned", 200), - (("SU",), "DELETE", "Bearer owned", 200), - ], -) -def test_business_boundary(mocker, roles, method, authorization, status): - mocker.patch( - "permissions.operations_boundary.get_current_user_context", - return_value=("actor", "a", roles), - ) - app = FastAPI() - install_operations_boundary(app) - app.add_api_route("/business", lambda: {"ok": True}, methods=[method]) - with TestClient(app) as client: - response = client.request(method, "/business", headers={"Authorization": authorization}) - assert response.status_code == status - - -@pytest.mark.parametrize( - "path,method", - [ - ("/ops/resources", "GET"), - ("/user/current_user_info", "GET"), - ("/user/logout", "POST"), - ("/tenant_config/deployment_version", "GET"), - ], -) -def test_allowed_paths_keep_endpoint_authorization(mocker, path, method): - mocker.patch("permissions.operations_boundary.get_current_user_context", return_value=("actor", "a", ("OPS",))) - app = FastAPI() - install_operations_boundary(app) - app.add_api_route(path, lambda: {"ok": True}, methods=[method]) - with TestClient(app) as client: - assert client.request(method, path, headers={"Authorization": "Bearer owned"}).status_code == 200 - - -@pytest.mark.parametrize( - "method,path,permission,status", - [ - ("POST", "/model/manage/list", True, 200), - ("GET", "/agent/list?tenant_id=a", True, 200), - ("GET", "/agent/list?tenant_id=b&tenant_id=a", True, 403), - ("GET", "/agent/list", False, 403), - ("DELETE", "/agent/list", True, 403), - ("GET", "/mcp/record/1", True, 403), - ], -) -def test_resource_reads_bind_the_authenticated_tenant(mocker, method, path, permission, status): - mocker.patch("permissions.operations_boundary.get_current_user_context", return_value=("actor", "a", ("OPS",))) - mocker.patch("permissions.operations_boundary.has_permission", return_value=permission) - app = FastAPI() - install_operations_boundary(app) - - def read(request: Request): - return {"tenant": request.state.resource_read_tenant} - - app.add_api_route(path.split("?", 1)[0], read, methods=[method]) - with TestClient(app) as client: - response = client.request(method, path, headers={"Authorization": "Bearer owned"}) - assert response.status_code == status - if status == 200: - assert response.json() == {"tenant": "a"} From 7dab7961d32368088e18fdd534484973b8e7202f Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:21:53 +0800 Subject: [PATCH 7/8] refactor(permissions): share tenant scope across resource routes --- backend/management/api/agent/agent.py | 2 +- backend/management/api/identity/group.py | 6 +-- .../api/knowledge_base/knowledge_summary.py | 8 ++-- .../api/knowledge_base/vectordatabase.py | 2 +- backend/management/api/mcp/remote_mcp.py | 2 +- .../management/api/model/model_managment.py | 21 ++++----- backend/management/api/skill/skill.py | 2 +- backend/management/api/tool/tool_config.py | 2 +- backend/ops/services/access.py | 4 +- backend/permissions/operations.py | 45 +++--------------- backend/permissions/resource_access.py | 47 +++++++++++++++++++ backend/permissions/tenant_scope.py | 36 +++++++------- .../AUTH-MULTI-ROLE-20261031.yaml | 2 + test/backend/app/test_agent_app.py | 2 +- test/backend/app/test_skill_app.py | 6 +-- test/backend/ops/test_resource_inventory.py | 2 +- test/backend/permissions/test_operations.py | 30 +----------- .../permissions/test_resource_access.py | 45 ++++++++++++++++++ 18 files changed, 152 insertions(+), 112 deletions(-) create mode 100644 backend/permissions/resource_access.py create mode 100644 test/backend/permissions/test_resource_access.py diff --git a/backend/management/api/agent/agent.py b/backend/management/api/agent/agent.py index 2d4159f378..1e0bf75283 100644 --- a/backend/management/api/agent/agent.py +++ b/backend/management/api/agent/agent.py @@ -63,7 +63,7 @@ compare_versions_impl, list_published_agents_impl, ) -from permissions.operations import resource_read_tenant +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") diff --git a/backend/management/api/identity/group.py b/backend/management/api/identity/group.py index 0c72f1a82f..a244a49c70 100644 --- a/backend/management/api/identity/group.py +++ b/backend/management/api/identity/group.py @@ -22,7 +22,7 @@ ) from management.services.permission.identity.tenants import get_tenant_info from permissions.models import CurrentUser -from permissions.operations import tenant_scope +from permissions.tenant_scope import resolve_tenant_scope logger = logging.getLogger(__name__) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -130,7 +130,7 @@ async def get_groups_endpoint( Returns: JSONResponse: List of groups for the tenant (paginated or all) """ - tenant_scope(current_user, request.tenant_id) + resolve_tenant_scope(current_user, request.tenant_id) try: get_tenant_info(request.tenant_id, initialize_name=False) # Get groups under given tenant with pagination and sorting @@ -394,7 +394,7 @@ async def get_group_users_endpoint( Returns: JSONResponse: List of group users """ - authorized_tenant = tenant_scope(current_user) + authorized_tenant = resolve_tenant_scope(current_user, allow_all_tenants=True) try: users = get_group_users(group_id, tenant_id=authorized_tenant) if authorized_tenant else get_group_users(group_id) diff --git a/backend/management/api/knowledge_base/knowledge_summary.py b/backend/management/api/knowledge_base/knowledge_summary.py index 06019e0085..13f6eabf0a 100644 --- a/backend/management/api/knowledge_base/knowledge_summary.py +++ b/backend/management/api/knowledge_base/knowledge_summary.py @@ -9,7 +9,8 @@ from contracts.knowledge import ChangeSummaryRequest from contracts.errors.exceptions import TokenExpiredError from management.db.knowledge_base.repository import get_knowledge_record -from permissions.operations import resource_read_tenant +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 authenticate, get_current_user_id, get_current_user_info @@ -92,13 +93,14 @@ def change_summary( raise HTTPException(status_code=500, detail=f"Knowledge base summary update failed: {str(e)}") -@router.get("/{index_name}/summary", dependencies=[Depends(authenticate)]) +@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): + 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: diff --git a/backend/management/api/knowledge_base/vectordatabase.py b/backend/management/api/knowledge_base/vectordatabase.py index 99cc324256..59e54a99da 100644 --- a/backend/management/api/knowledge_base/vectordatabase.py +++ b/backend/management/api/knowledge_base/vectordatabase.py @@ -33,7 +33,7 @@ IndexingResponse, ) from contracts.roles import has_any_role -from permissions.operations import resource_read_tenant +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 ( diff --git a/backend/management/api/mcp/remote_mcp.py b/backend/management/api/mcp/remote_mcp.py index a435df1025..a5eb46be50 100644 --- a/backend/management/api/mcp/remote_mcp.py +++ b/backend/management/api/mcp/remote_mcp.py @@ -14,7 +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.operations import resource_read_tenant +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 diff --git a/backend/management/api/model/model_managment.py b/backend/management/api/model/model_managment.py index 9f9a7a6327..270ce8897d 100644 --- a/backend/management/api/model/model_managment.py +++ b/backend/management/api/model/model_managment.py @@ -113,8 +113,9 @@ from management.utils.app_factory import ModelCredentialRoute from nexent.core.concurrency import run_blocking from permissions.depends import require -from permissions.operations import operations_scope from permissions.models import CurrentUser +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 @@ -212,11 +213,9 @@ def _require_manage_scope(current_user: CurrentUser, target_tenant_id: str) -> N status_code=HTTPStatus.FORBIDDEN, detail="This operation requires SU or tenant ADMIN role", ) - if not has_any_role(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: @@ -423,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, alternative="resource.inventory:read")), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION, alternative=INVENTORY_READ)), ): """Return bare-capacity LLM/VLM coverage for the current tenant. @@ -858,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, alternative="resource.inventory:read")), + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION, alternative=INVENTORY_READ)), ): """Check and update model connectivity for a specified tenant (admin/manage operation). @@ -872,7 +871,7 @@ async def manage_check_model_health( """ read_only = not has_any_role(current_user.effective_roles, _MANAGE_ALLOWED_ROLES) if read_only: - operations_scope(current_user, request.tenant_id, "resource.inventory:read") + require_inventory_scope(current_user, request.tenant_id) else: _require_manage_scope(current_user, request.tenant_id) try: @@ -1147,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, alternative="resource.inventory:read")), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION, alternative=INVENTORY_READ)), ): """List models for a specified tenant (admin/manage operation). @@ -1162,7 +1161,7 @@ async def manage_list_models( if has_any_role(current_user.effective_roles, {"OPS"}) and not has_any_role( current_user.effective_roles, _MANAGE_ALLOWED_ROLES ): - operations_scope(current_user, request.tenant_id, "resource.inventory:read") + require_inventory_scope(current_user, request.tenant_id) else: _require_manage_scope(current_user, request.tenant_id) try: diff --git a/backend/management/api/skill/skill.py b/backend/management/api/skill/skill.py index 9da97dea7f..cf21e5bfe5 100644 --- a/backend/management/api/skill/skill.py +++ b/backend/management/api/skill/skill.py @@ -19,7 +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.operations import resource_read_tenant +from permissions.resource_access import resource_read_tenant from gateway.authentication.auth import get_current_user_id diff --git a/backend/management/api/tool/tool_config.py b/backend/management/api/tool/tool_config.py index 061528c483..e8162ddee8 100644 --- a/backend/management/api/tool/tool_config.py +++ b/backend/management/api/tool/tool_config.py @@ -27,7 +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.operations import resource_read_tenant +from permissions.resource_access import resource_read_tenant from gateway.authentication.auth import get_current_user_id router = APIRouter(prefix="/tool") diff --git a/backend/ops/services/access.py b/backend/ops/services/access.py index a55f52a742..f0a41973d7 100644 --- a/backend/ops/services/access.py +++ b/backend/ops/services/access.py @@ -5,11 +5,11 @@ from management.db.client import get_db_session from management.db.models import AgentInfo, KnowledgeRecord, McpRecord, ModelRecord -from permissions.operations import operations_scope +from permissions.resource_access import require_inventory_scope def resource_inventory(user, tenant_id=None, page=1, page_size=20): - tenant = operations_scope(user, tenant_id, "resource.inventory:read") + tenant = require_inventory_scope(user, tenant_id) definitions = ( (ModelRecord, ModelRecord.model_id, ModelRecord.model_name, "model"), (McpRecord, McpRecord.mcp_id, McpRecord.mcp_name, "mcp"), diff --git a/backend/permissions/operations.py b/backend/permissions/operations.py index 49d2b4983a..9b120d4c1e 100644 --- a/backend/permissions/operations.py +++ b/backend/permissions/operations.py @@ -1,12 +1,10 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""Action-specific operations scope; never expands ordinary business scope.""" +"""Authorization for the operations dashboard and system configuration.""" -from fastapi import HTTPException, Request +from fastapi import HTTPException -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 ACTIONS = ("read", "create", "update", "delete") @@ -15,11 +13,9 @@ 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") - if "SU" in roles: - return tenant_id - if tenant_id is not None and tenant_id != user.tenant_id: - raise HTTPException(status_code=403, detail="Operations are limited to your tenant") - return user.tenant_id + return resolve_tenant_scope( + user, tenant_id, allow_all_tenants=True, denied_detail="Operations are limited to your tenant", + ) def authorization_metadata(user): @@ -32,32 +28,3 @@ def authorization_metadata(user): for module in ("dashboard", "configuration") }, } - - -def resource_read_tenant(request: Request | None, *, shared: bool = False) -> str | None: - """Resolve the tenant for a route that offers inventory reads.""" - authorization = request.headers.get("authorization") if request is not None else None - if not authorization: - return None - try: - _, tenant_id, roles = get_current_user_context(authorization) - except (UnauthorizedError, TokenExpiredError): - return None - - if {"ADMIN", "SU"}.intersection(roles) or not has_permission(roles, "resource.inventory:read"): - return None - targets = request.query_params.getlist("tenant_id") - if shared and len(roles) > 1 and not any(targets): - return None - if not tenant_id or any(target and target != tenant_id for target in targets): - raise HTTPException(status_code=403, detail="Resource reads are limited to your tenant") - return tenant_id - - -def tenant_scope(user: CurrentUser, requested_tenant: str | None = None) -> str | None: - """Resolve a resource tenant with the same rule for every account.""" - if "SU" in user.effective_roles: - return requested_tenant - if not user.tenant_id or (requested_tenant is not None and requested_tenant != user.tenant_id): - raise HTTPException(status_code=403, detail="Resource reads are limited to your tenant") - return user.tenant_id 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/tenant_scope.py b/backend/permissions/tenant_scope.py index 9adb00e089..bb00277892 100644 --- a/backend/permissions/tenant_scope.py +++ b/backend/permissions/tenant_scope.py @@ -8,22 +8,26 @@ 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'}) +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 not has_any_role(current_user.effective_roles, _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/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml index d4af1cc61a..746d3d2aa3 100644 --- a/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml +++ b/test-e2e/changes/requirements/AUTH-MULTI-ROLE-20261031.yaml @@ -65,6 +65,8 @@ change: - test/backend/permissions/test_rbac.py - test/backend/permissions/test_dac.py - test/backend/permissions/test_operations.py + - test/backend/permissions/test_resource_access.py + - test/backend/permissions/test_tenant_scope.py - test/backend/services/test_model_health_outlet_routing.py - test/backend/management/test_identity_import_architecture.py - test/backend/services/test_role_assignment.py diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index 678bf0f376..7d9fa293cf 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -328,7 +328,7 @@ async def test_workbench_bootstrap_is_unavailable_when_disabled(mocker): @pytest.fixture(autouse=True) def creator_history_boundary(mocker): - mocker.patch("permissions.operations.get_current_user_context", return_value=("test_user", "test_tenant", ("DEV",))) + mocker.patch("permissions.resource_access.get_current_user_context", return_value=("test_user", "test_tenant", ("DEV",))) from contracts.agent import SystemAgentRef from management.services.conversation import creator_execution mocker.patch.object(creator_execution.system_agent_provider, "get_creator_ref", return_value=SystemAgentRef("shared", "nl2agent_main", 77, 0)) diff --git a/test/backend/app/test_skill_app.py b/test/backend/app/test_skill_app.py index e3f79d7e0b..a6bc8b6001 100644 --- a/test/backend/app/test_skill_app.py +++ b/test/backend/app/test_skill_app.py @@ -133,14 +133,14 @@ def __init__(self, local_skills_dir=None, **kwargs): permissions_mock = types.ModuleType("permissions") permissions_depends_mock = types.ModuleType("permissions.depends") permissions_models_mock = types.ModuleType("permissions.models") -permissions_operations_mock = types.ModuleType("permissions.operations") -permissions_operations_mock.resource_read_tenant = lambda request, *, shared=False: None +permissions_resource_access_mock = types.ModuleType("permissions.resource_access") +permissions_resource_access_mock.resource_read_tenant = lambda request, *, shared=False: None permissions_depends_mock.require = lambda _permission: (lambda: None) permissions_models_mock.CurrentUser = object sys.modules["permissions"] = permissions_mock sys.modules["permissions.depends"] = permissions_depends_mock sys.modules["permissions.models"] = permissions_models_mock -sys.modules["permissions.operations"] = permissions_operations_mock +sys.modules["permissions.resource_access"] = permissions_resource_access_mock class SkillException(Exception): pass diff --git a/test/backend/ops/test_resource_inventory.py b/test/backend/ops/test_resource_inventory.py index 5d77113749..16afbd4bf2 100644 --- a/test/backend/ops/test_resource_inventory.py +++ b/test/backend/ops/test_resource_inventory.py @@ -51,7 +51,7 @@ def inventory(mocker): with Session(engine.execution_options(schema_translate_map=schema_map)) as session: db = mocker.patch.object(access, "get_db_session", side_effect=lambda: nullcontext(session)) mocker.patch( - "permissions.operations.has_permission", + "permissions.resource_access.has_permission", side_effect=lambda roles, action: action == "resource.inventory:read" and bool(set(roles) & {"OPS", "SU"}), ) yield db diff --git a/test/backend/permissions/test_operations.py b/test/backend/permissions/test_operations.py index d78cd86250..2bbe4a4be3 100644 --- a/test/backend/permissions/test_operations.py +++ b/test/backend/permissions/test_operations.py @@ -2,11 +2,11 @@ """Operations grants and scope are independent of business permissions.""" import pytest -from fastapi import HTTPException, Request +from fastapi import HTTPException from contracts.configuration import validate_deployment_version from permissions.models import CurrentUser -from permissions.operations import authorization_metadata, operations_scope, resource_read_tenant +from permissions.operations import authorization_metadata, operations_scope @pytest.fixture(autouse=True) @@ -45,29 +45,3 @@ def test_retired_deployment_mode_is_not_silently_elevated(): with pytest.raises(ValueError): validate_deployment_version("speed") assert validate_deployment_version("full") == "full" - - -@pytest.mark.parametrize( - "roles, query, tenant", - [ - (("OPS",), b"", "a"), - (("OPS",), b"tenant_id=a", "a"), - (("DEV", "OPS"), b"", None), - (("DEV", "OPS"), b"tenant_id=a", "a"), - (("ADMIN", "OPS"), b"tenant_id=a", None), - (("DEV",), b"tenant_id=a", None), - ], -) -def test_resource_reads_use_the_inventory_grant(mocker, roles, query, tenant): - mocker.patch("permissions.operations.get_current_user_context", return_value=("actor", "a", roles)) - request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) - assert resource_read_tenant(request, shared=True) == tenant - - -@pytest.mark.parametrize("query", [b"tenant_id=b", b"tenant_id=b&tenant_id=a"]) -def test_resource_inventory_rejects_foreign_tenant_targets(mocker, query): - mocker.patch("permissions.operations.get_current_user_context", return_value=("actor", "a", ("OPS",))) - request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) - with pytest.raises(HTTPException) as error: - resource_read_tenant(request) - assert error.value.status_code == 403 diff --git a/test/backend/permissions/test_resource_access.py b/test/backend/permissions/test_resource_access.py new file mode 100644 index 0000000000..6c5974d3aa --- /dev/null +++ b/test/backend/permissions/test_resource_access.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Tenant-scoped reads for routes that expose resource inventory.""" + +import pytest +from fastapi import HTTPException, Request + +from permissions.models import CurrentUser +from permissions.resource_access import resource_read_tenant + + +@pytest.fixture(autouse=True) +def inventory_grant(mocker): + mocker.patch("permissions.resource_access.has_permission", side_effect=lambda roles, permission: "OPS" in roles) + + +@pytest.mark.parametrize( + "roles, query, tenant", + [ + (("OPS",), b"", "a"), + (("OPS",), b"tenant_id=a", "a"), + (("DEV", "OPS"), b"", None), + (("DEV", "OPS"), b"tenant_id=a", "a"), + (("ADMIN", "OPS"), b"tenant_id=a", None), + (("DEV",), b"tenant_id=a", None), + ], +) +def test_resource_reads_use_the_inventory_grant(mocker, roles, query, tenant): + mocker.patch("permissions.resource_access.get_current_user_context", return_value=("actor", "a", roles)) + request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) + assert resource_read_tenant(request, shared=True) == tenant + + +@pytest.mark.parametrize("query", [b"tenant_id=b", b"tenant_id=b&tenant_id=a"]) +def test_resource_inventory_rejects_foreign_tenant_targets(mocker, query): + mocker.patch("permissions.resource_access.get_current_user_context", return_value=("actor", "a", ("OPS",))) + request = Request({"type": "http", "headers": [(b"authorization", b"Bearer token")], "query_string": query}) + with pytest.raises(HTTPException) as error: + resource_read_tenant(request) + assert error.value.status_code == 403 + + +def test_resource_read_reuses_authenticated_user(mocker): + lookup = mocker.patch("permissions.resource_access.get_current_user_context") + assert resource_read_tenant(None, user=CurrentUser("actor", "a", "OPS")) == "a" + lookup.assert_not_called() From 6d20ea6150242231b869b792d287a353b4256ff1 Mon Sep 17 00:00:00 2001 From: WMC001 <46217886+WMC001@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:57:24 +0800 Subject: [PATCH 8/8] fix(resources): show API keys last for super admins --- .../services/permission/api_keys.py | 12 ++++---- .../components/UserManageComp.tsx | 30 ++++++++++++------- .../cases/AUTH-MULTI-ROLE-D2-001/case.yaml | 12 +++++--- test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py | 6 ++++ .../cases/AUTH-MULTI-ROLE-D4-001/case.yaml | 11 +++---- .../cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts | 18 +++++++++++ test-e2e/infra/automation/ops_roles_http.py | 12 ++++++++ test/backend/services/test_api_key_service.py | 6 ++-- 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/backend/management/services/permission/api_keys.py b/backend/management/services/permission/api_keys.py index 61bf178947..167f899b85 100644 --- a/backend/management/services/permission/api_keys.py +++ b/backend/management/services/permission/api_keys.py @@ -186,18 +186,20 @@ def list_tenant_api_keys( page_size: int = 20, sort_order: str = "desc", ) -> Dict[str, Any]: - """List active keys, redacting credentials for operations observers.""" - read_only = has_any_role(actor_role, {"OPS"}) and not has_any_role(actor_role, {"ADMIN", "SU"}) - if not read_only: + """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) - if tenant_id != actor_tenant_id: + 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) enrich_cas_usernames(result.get("items", []), { "owner_cas_username": "user_id", "creator_cas_username": "created_by", }) - if read_only: + if is_observer or cross_tenant: for item in result.get("items", []): item.pop("access_key", None) return result diff --git a/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx b/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx index 57607274ff..ebaaaba4f4 100644 --- a/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx +++ b/frontend/app/[locale]/resource-manage/components/UserManageComp.tsx @@ -1361,17 +1361,6 @@ export default function UserManageComp() { /> ), }, - ...(hasRole(user, "ADMIN") || readOnly - ? [ - { - key: "apiKeys", - label: - t("tenantResources.tabs.apiKeys") || - "API Key", - children: , - }, - ] - : []), { key: "groups", label: t("tenantResources.tabs.groups") || "Groups", @@ -1428,6 +1417,25 @@ export default function UserManageComp() { /> ), }, + ...(hasRole(user, "ADMIN", "SU") || readOnly + ? [ + { + key: "apiKeys", + label: + t("tenantResources.tabs.apiKeys") || + "API Key", + children: ( + + ), + }, + ] + : []), ]} /> ) : ( diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml index 48a7a53b7c..062d40ed76 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/case.yaml @@ -41,9 +41,10 @@ case: - order: 2 action: Assign OPS only, ADMIN plus OPS, and SU fixtures in turn; read permission metadata and use resource read/write routes including the shared resource page lists, group members and model creation. Check the public authentication probe and the existing self-service token list with an OPS session. + Check the selected tenant's API Key list and attempt revocation as SU. expected: Pure OPS receives exactly the three approved entry grants and credential-free tenant resource responses. The authentication probe and self-service token list accept the OPS session. Foreign tenant resource reads and model writes are denied; ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is - own tenant. + own tenant. For another tenant, SU can list API Key metadata and revocation is denied. - order: 3 action: Submit old role-only update for a single ordinary-role target, then repeat against a multi-role target and submit role with roles together. @@ -59,7 +60,8 @@ case: - Response retains its original envelope, includes both roles once, and permissions and accessibleRoutes contain both grants without duplicate entries. - Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, business writes - with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. + with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. SU receives API Key metadata for the selected tenant without credentials or + cross-tenant revocation. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - Single-role compatibility succeeds, multi-role target returns 409 without truncation, ambiguous input is rejected by the documented validation contract; unknown and empty role sets are rejected. - SPEED grant inputs are rejected with no persisted change; missing credentials return 401 instead of an implicit @@ -77,7 +79,8 @@ case: - 1 - id: AC-02 description: Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, - business writes with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS + business writes with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. SU receives API Key metadata for the selected tenant without credentials or + cross-tenant revocation. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. steps: - 2 @@ -114,7 +117,8 @@ case: - Response retains its original envelope, includes both roles once, and permissions and accessibleRoutes contain both grants without duplicate entries. - Pure OPS receives exactly the three approved entry grants and permitted tenant resource reads, business writes - with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. + with a missing action grant return 403. The authentication probe and self-service token list retain their ordinary behavior. SU receives API Key metadata for the selected tenant without credentials or + cross-tenant revocation. ADMIN plus OPS retains ADMIN grants; SU operations scope is all tenants and OPS scope is own tenant. - Single-role compatibility succeeds, multi-role target returns 409 without truncation, ambiguous input is rejected by the documented validation contract; unknown and empty role sets are rejected. - SPEED grant inputs are rejected with no persisted change; missing credentials return 401 instead of an implicit diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py index 6190345444..34356a69b4 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D2-001/test.py @@ -37,6 +37,12 @@ def test_roles_union_compatibility_and_operations_boundary(role_runtime): assert r.client.get(f"/groups/{r.foreign_group}/members", headers=ops_headers).status_code == 404 assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "a"}).status_code == 200 assert r.client.get("/api-keys", headers=ops_headers, params={"tenant_id": "b"}).status_code == 404 + su_headers = r.headers("su") + tenant_keys = r.client.get("/api-keys", headers=su_headers, params={"tenant_id": "b"}) + assert tenant_keys.status_code == 200 + [key] = tenant_keys.json()["data"]["items"] + assert key["user_id"] == r.users["foreign"] and "access_key" not in key + assert r.client.delete("/api-keys", headers=su_headers, params={"user_id": r.users["foreign"]}).status_code == 403 assert r.client.post("/model/create", headers=ops_headers, json={}).status_code == 403 public_probe = r.client.get("/user/service_health") ops_probe = r.client.get("/user/service_health", headers=ops_headers) diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml index 1706f7f651..3b8bd5e07e 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/case.yaml @@ -43,17 +43,18 @@ case: expected: Invitation has both roles and no role-edit control; pure OPS sees only operations dashboard, system configuration and the shared resource page. User search and model browsing work, with business mutation controls absent. - order: 3 - action: Use owned DEV plus OPS and SU sessions to inspect entries and tenant scope, then remove OPS from the - active target in a second admin session and refresh its authorization. The ADMIN viewer then adds OPS to + action: Use owned DEV plus OPS and SU sessions to inspect entries and tenant scope. As SU select another + tenant in resource management and open the API Key tab. Remove OPS from the active target in a second + admin session and refresh its authorization. The ADMIN viewer then adds OPS to their own roles through the editor without reloading the page. - expected: Combined roles retain development entries; SU has cross-tenant operations selection; the revoked target + expected: Combined roles retain development entries; SU can select another tenant and see API Key metadata in the last tab; the revoked target loses OPS entries and direct protected navigation is denied or redirected without executing a business write. Saving the viewer's own roles refreshes the navigation immediately while retaining ADMIN. expected_results: - Two role labels are displayed, the target appears once, and the saved selection survives reopening. - Invitation has both roles and no role-edit control; pure OPS sees only operations dashboard, system configuration and the shared resource page with working user search and model browsing. Business mutation controls are absent. - - Combined roles retain development entries; SU has cross-tenant operations selection; the revoked target loses + - Combined roles retain development entries; SU can select another tenant and see API Key metadata in the last tab; the revoked target loses OPS entries and direct protected navigation is denied or redirected without executing a business write. Saving the viewer's own roles refreshes navigation immediately while retaining ADMIN. forbidden_side_effects: @@ -74,7 +75,7 @@ case: expected_results: - 2 - id: AC-03 - description: Combined roles retain development entries; SU has cross-tenant operations selection; the revoked + description: Combined roles retain development entries; SU can select another tenant and see API Key metadata in the last tab; the revoked target loses OPS entries and direct protected navigation is denied or redirected without executing a business write. Saving the viewer's own roles refreshes navigation immediately while retaining ADMIN. steps: diff --git a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts index 708d96dd32..7fca3fdba7 100644 --- a/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts +++ b/test-e2e/cases/AUTH-MULTI-ROLE-D4-001/test.spec.ts @@ -241,6 +241,24 @@ journey("AUTH-MULTI-ROLE-D4-001", async ({ page, contract }) => { const scopedResponse = await selectedScope; expect(scopedResponse.status()).toBe(200); selectedTenant = (await scopedResponse.json()).data.tenant_id; + await su.page.setViewportSize({ width: 1600, height: 900 }); + await su.page.goto(`${baseURL}/en/resource-manage`); + await su.page.getByText("Owned b", { exact: true }).click(); + const apiKeyTab = su.page.getByRole("tab", { name: /^API Keys?$/ }); + await expect( + su.page.getByRole("tablist").getByRole("tab").last(), + ).toHaveAccessibleName(/^API Keys?$/); + await apiKeyTab.click(); + const apiKeyPanel = su.page.getByRole("tabpanel", { name: /^API Keys?$/ }); + await expect( + apiKeyPanel.getByRole("row").filter({ hasText: fixture.emails.foreign }), + ).toBeVisible(); + await expect( + apiKeyPanel.getByText("fixture-only-api-key", { exact: true }), + ).toHaveCount(0); + await expect( + apiKeyPanel.getByRole("columnheader", { name: "Actions" }), + ).toHaveCount(0); const revoked = await admin.context.request.put( `${baseURL}/api/users/${fixture.users.user}`, { data: { roles: ["DEV"] } }, diff --git a/test-e2e/infra/automation/ops_roles_http.py b/test-e2e/infra/automation/ops_roles_http.py index 93b00dd410..0228dbf616 100644 --- a/test-e2e/infra/automation/ops_roles_http.py +++ b/test-e2e/infra/automation/ops_roles_http.py @@ -106,6 +106,14 @@ def isolated_runtime(): permission_subtype="READ", ) ) + connection.execute( + models.RolePermission.__table__.insert().values( + user_role="SU", + permission_category="RESOURCE", + permission_type="TENANT.LIST", + permission_subtype="READ", + ) + ) # Fixture setup applies the current additive role upgrade, never modifies historical SQL. upgrades = sorted((ROOT / "deploy/sql/migrations").glob("*_ops_roles.sql")) if len(upgrades) != 1: @@ -170,6 +178,10 @@ def seed(self): account["user_id"], tenant, roles[0], account["email"], db_session=session, roles=roles ) self.users[logical] = account["user_id"] + session.add(self.models.UserTokenInfo( + access_key="fixture-only-api-key", user_id=self.users["foreign"], + created_by=self.users["foreign"], updated_by=self.users["foreign"], delete_flag="N", + )) for tenant in ("a", "b"): group = self.models.TenantGroupInfo( tenant_id=tenant, group_name="Owned group", created_by=self.users["su"], delete_flag="N" diff --git a/test/backend/services/test_api_key_service.py b/test/backend/services/test_api_key_service.py index e4b58dd2d7..5246dfbe74 100644 --- a/test/backend/services/test_api_key_service.py +++ b/test/backend/services/test_api_key_service.py @@ -184,15 +184,17 @@ def test_list_tenant_api_keys_allows_superuser_and_forwards_pagination(mock_list mock_list.assert_called_once_with("tenant-a", 2, 10, "asc") +@pytest.mark.parametrize(("role", "tenant"), [("OPS", "tenant-a"), ("SU", "tenant-b")]) @patch("management.services.permission.api_keys.list_active_tokens_by_tenant") -def test_operations_api_key_list_keeps_metadata_without_the_key(mock_list): +def test_api_key_observers_receive_metadata_without_the_key(mock_list, role, tenant): mock_list.return_value = {"items": [{"token_id": 1, "access_key": "secret", "total_usage_count": 5}], "total": 1} result = api_key_service.list_tenant_api_keys( - actor_tenant_id="tenant-a", actor_role=("OPS",), tenant_id="tenant-a" + actor_tenant_id="tenant-a", actor_role=(role,), tenant_id=tenant ) assert result == {"items": [{"token_id": 1, "total_usage_count": 5}], "total": 1} + mock_list.assert_called_once_with(tenant, 1, 20, "desc") def test_list_tenant_api_keys_rejects_other_tenant():