Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/contracts/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class AuthenticatedIdentity:
language: str = "zh"
token_id: Optional[int] = None
kind: CredentialKind = CredentialKind.SESSION
roles: tuple[str, ...] = ()


@dataclass
Expand Down
7 changes: 7 additions & 0 deletions backend/contracts/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/contracts/constants/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
Expand Down
8 changes: 3 additions & 5 deletions backend/contracts/constants/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -350,7 +349,6 @@
DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0)



# Telemetry and Monitoring Configuration (OTLP Protocol)
MONITORING_PROVIDER = os.getenv('MONITORING_PROVIDER', '')
ENABLE_TELEMETRY_RAW = os.getenv('ENABLE_TELEMETRY')
Expand Down Expand Up @@ -383,7 +381,7 @@
MONITORING_EXCLUDED_SPAN_NAMES = os.getenv('MONITORING_EXCLUDED_SPAN_NAMES', 'knowledge.process.submit,knowledge.upload.batch')
MONITORING_PROJECT_NAME = os.getenv('MONITORING_PROJECT_NAME', '')
MONITORING_DASHBOARD_URL = os.getenv('MONITORING_DASHBOARD_URL', '')
MONITORING_DASHBOARD_ALLOWED_ROLES = os.getenv('MONITORING_DASHBOARD_ALLOWED_ROLES', 'SU,SPEED')
MONITORING_DASHBOARD_ALLOWED_ROLES = os.getenv('MONITORING_DASHBOARD_ALLOWED_ROLES', 'SU')
MONITORING_TRACE_CONTENT_MODE = os.getenv('MONITORING_TRACE_CONTENT_MODE', 'summary')
MONITORING_TRACE_MAX_CHARS = os.getenv('MONITORING_TRACE_MAX_CHARS', '4000')
MONITORING_TRACE_MAX_ITEMS = os.getenv('MONITORING_TRACE_MAX_ITEMS', '20')
Expand Down
4 changes: 4 additions & 0 deletions backend/contracts/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
33 changes: 29 additions & 4 deletions backend/contracts/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -263,16 +274,23 @@ 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)"
)
group_ids: Optional[List[int]] = Field(None, description="Associated group IDs")
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"""
Expand All @@ -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"""
Expand Down
69 changes: 69 additions & 0 deletions backend/contracts/roles.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 7 additions & 8 deletions backend/ext_components/aidp/apps/aidp_mgmt_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -32,16 +32,16 @@
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,
AidpKbNotFoundError,
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")

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading