from __future__ import annotations import abc import typing import enum import quart import traceback import inspect import uuid from quart.typing import RouteCallable from ....utils import constants from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext if typing.TYPE_CHECKING: from ....core.app import Application # Maximum file upload size limit (10MB) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB preregistered_groups: list[type[RouterGroup]] = [] """Pre-registered list of RouterGroup""" def group_class(name: str, path: str) -> typing.Callable[[typing.Type[RouterGroup]], typing.Type[RouterGroup]]: """注册一个 RouterGroup""" def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]: cls.name = name cls.path = path preregistered_groups.append(cls) return cls return decorator class AuthType(enum.Enum): """Authentication type""" NONE = 'none' ACCOUNT_TOKEN = 'account-token' USER_TOKEN = 'user-token' API_KEY = 'api-key' USER_TOKEN_OR_API_KEY = 'user-token-or-api-key' class RouterGroup(abc.ABC): name: str path: str ap: Application quart_app: quart.Quart def __init__(self, ap: Application, quart_app: quart.Quart) -> None: self.ap = ap self.quart_app = quart_app @abc.abstractmethod async def initialize(self) -> None: pass def route( self, rule: str, auth_type: AuthType = AuthType.USER_TOKEN, permission: Permission | str | None = None, **options: typing.Any, ) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator """Register a route""" if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None: raise ValueError('Account-token routes cannot declare Workspace permissions') def decorator(f: RouteCallable) -> RouteCallable: nonlocal rule rule = self.path + rule async def handler_error(*args, **kwargs): request_context: RequestContext | None = None if auth_type == AuthType.ACCOUNT_TOKEN: authorization = quart.request.headers.get('Authorization', '') if not authorization.startswith('Bearer '): return self.http_status(401, -1, 'No valid user token provided') token = authorization.removeprefix('Bearer ') if not token: return self.http_status(401, -1, 'No valid user token provided') try: account, user_email = await self._authenticate_account(token) # Account-token routes deliberately stop before Workspace # selection. They may bootstrap a selector, but cannot # receive RequestContext or enforce Workspace permissions. self._inject_handler_context(f, kwargs, user_email, None, account) except Exception as e: return self._auth_error_response(e) elif auth_type == AuthType.USER_TOKEN: # get token from Authorization header token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') if not token: return self.http_status(401, -1, 'No valid user token provided') try: account, user_email = await self._authenticate_account(token) request_context = await self._resolve_account_context(account, auth_type) if permission is not None: if request_context is None: raise AuthorizationError('Workspace authorization is unavailable') require_permission(request_context, permission) self._inject_handler_context(f, kwargs, user_email, request_context) except Exception as e: return self._auth_error_response(e) elif auth_type == AuthType.API_KEY: # get API key from Authorization header or X-API-Key header api_key = quart.request.headers.get('X-API-Key', '') if not api_key: auth_header = quart.request.headers.get('Authorization', '') if auth_header.startswith('Bearer '): api_key = auth_header.replace('Bearer ', '') if not api_key: return self.http_status(401, -1, 'No valid API key provided') try: request_context = await self._authenticate_api_key(api_key, auth_type) if permission is not None: require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: return self._auth_error_response(e) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: # Try API key first (check X-API-Key header) api_key = quart.request.headers.get('X-API-Key', '') if api_key: # API key authentication try: request_context = await self._authenticate_api_key(api_key, auth_type) if permission is not None: require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: return self._auth_error_response(e) else: # Try user token authentication (Authorization header) token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') if not token: return self.http_status( 401, -1, 'No valid authentication provided (user token or API key required)' ) try: account, user_email = await self._authenticate_account(token) request_context = await self._resolve_account_context(account, auth_type) if permission is not None: if request_context is None: raise AuthorizationError('Workspace authorization is unavailable') require_permission(request_context, permission) self._inject_handler_context(f, kwargs, user_email, request_context) except (AuthorizationError, WorkspaceNotFoundError, MembershipPermissionError) as e: # Authentication succeeded and authorization was # evaluated. Do not reinterpret a denied user token # as an API key, which would mask the stable 403/404. return self._auth_error_response(e) except Exception: # If user token fails, maybe it's an API key in Authorization header try: request_context = await self._authenticate_api_key(token, auth_type) if permission is not None: require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: return self._auth_error_response(e) try: if request_context is not None: persistence_mgr = getattr(self.ap, 'persistence_mgr', None) tenant_scope_descriptor = getattr(type(persistence_mgr), 'tenant_scope', None) if callable(tenant_scope_descriptor): # Authorization discovery is complete. Carry the # trusted Workspace identity across the handler, but # do not reserve a database connection while it waits # on providers, runtimes, uploads, or streamed clients. # Services that need atomic writes open a short UoW. async with persistence_mgr.tenant_scope(request_context.workspace_uuid): return await f(*args, **kwargs) return await f(*args, **kwargs) except Exception as e: # 自动 500 if isinstance(e, AuthorizationError): return self.http_status(e.status_code, e.error_code, str(e)) if isinstance(e, WorkspaceNotFoundError): return self.http_status(404, 'resource_not_found', 'Resource not found') if isinstance(e, MembershipPermissionError): return self.http_status(403, e.code, str(e)) if isinstance(e, WorkspaceCollaborationError): return self.http_status(400, e.code, str(e)) request_id = self.request_id() logger = getattr(self.ap, 'logger', self.quart_app.logger) logger.error( f'Unhandled HTTP error request_id={request_id} ' f'method={quart.request.method} path={quart.request.path}\n{traceback.format_exc()}' ) return self.internal_error_response(request_id) new_f = handler_error # Quart/Flask requires a unique endpoint name even when the same URL # intentionally has separate handlers for different HTTP methods. # Include the method set so CRUD routes can declare distinct # permissions without colliding during application startup. methods = options.get('methods') or ['GET'] method_suffix = '__'.join(sorted(str(method).upper() for method in methods)) new_f.__name__ = (self.name + rule + '__' + method_suffix).replace('/', '__') new_f.__doc__ = f.__doc__ self.quart_app.route(rule, **options)(new_f) return f return decorator async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]: account: typing.Any = None resolver = getattr(self.ap.user_service, 'get_authenticated_account', None) if callable(resolver): resolved = resolver(token) if inspect.isawaitable(resolved): account = await resolved if isinstance(account, str) or account is None: user_email = account or await self.ap.user_service.verify_jwt_token(token) account = await self.ap.user_service.get_user_by_email(user_email) if account is None: raise ValueError('User not found') return account, account.user async def _resolve_account_context( self, account: typing.Any, auth_type: AuthType, ) -> RequestContext | None: collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None) account_uuid = getattr(account, 'uuid', None) # Compatibility for isolated controller tests that do not wire the tenancy kernel. if collaboration_service is None or not isinstance(account_uuid, str): return None requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id') access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid) entitlement_revision = await self._resolve_entitlement_revision( access.execution.instance_uuid, access.workspace.uuid, ) request_context = RequestContext( instance_uuid=access.execution.instance_uuid, placement_generation=access.execution.placement_generation, request_id=self.request_id(), auth_type=auth_type.value, principal=PrincipalContext( principal_type=PrincipalType.ACCOUNT, account_uuid=account_uuid, ), workspace=WorkspaceContext( workspace_uuid=access.workspace.uuid, membership_uuid=access.membership.uuid, role=access.membership.role, permissions=permissions_for_role(access.membership.role), membership_revision=access.membership.projection_revision, ), entitlement_revision=entitlement_revision, ) quart.g.request_context = request_context quart.g.workspace_membership = access.membership return request_context async def _authenticate_api_key(self, api_key: str, auth_type: AuthType) -> RequestContext: authenticator = getattr(self.ap.apikey_service, 'authenticate_api_key', None) if callable(authenticator): authenticated = authenticator(api_key) if inspect.isawaitable(authenticated): identity = await authenticated if identity is not None: entitlement_revision = await self._resolve_entitlement_revision( identity.instance_uuid, identity.workspace_uuid, ) request_context = RequestContext( instance_uuid=identity.instance_uuid, placement_generation=identity.placement_generation, request_id=self.request_id(), auth_type=auth_type.value, principal=PrincipalContext( principal_type=PrincipalType.API_KEY, api_key_uuid=identity.api_key_uuid, ), workspace=WorkspaceContext( workspace_uuid=identity.workspace_uuid, membership_uuid=None, role=None, permissions=identity.permissions, ), entitlement_revision=entitlement_revision, ) quart.g.request_context = request_context return request_context if not await self.ap.apikey_service.verify_api_key(api_key): raise ValueError('Invalid API key') workspace_service = getattr(self.ap, 'workspace_service', None) if workspace_service is None: raise ValueError('API key Workspace binding is unavailable') binding = await workspace_service.get_local_execution_binding() request_context = RequestContext( instance_uuid=binding.instance_uuid or constants.instance_id, placement_generation=binding.placement_generation, request_id=self.request_id(), auth_type=auth_type.value, principal=PrincipalContext( principal_type=PrincipalType.API_KEY, api_key_uuid='legacy-oss-api-key', ), workspace=WorkspaceContext( workspace_uuid=binding.workspace_uuid, membership_uuid=None, role=None, permissions=frozenset(item.value for item in Permission), ), ) quart.g.request_context = request_context return request_context async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int: deployment = getattr(self.ap, 'deployment', None) if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False): return 0 resolver = getattr(self.ap, 'entitlement_resolver', None) if resolver is None: raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable') if instance_uuid != resolver.instance_uuid: raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance') snapshot = await resolver.resolve(workspace_uuid) return snapshot.entitlement_revision @staticmethod def _inject_handler_context( handler: RouteCallable, kwargs: dict[str, typing.Any], user_email: str | None, request_context: RequestContext | None, account: typing.Any = None, ) -> None: parameters = inspect.signature(handler).parameters if user_email is not None and 'user_email' in parameters: kwargs['user_email'] = user_email if account is not None and 'account' in parameters: kwargs['account'] = account if request_context is not None: if 'request_context' in parameters: kwargs['request_context'] = request_context elif 'ctx' in parameters: kwargs['ctx'] = request_context def _auth_error_response(self, error: Exception) -> typing.Any: if isinstance(error, AuthorizationError): return self.http_status(error.status_code, error.error_code, str(error)) if isinstance(error, WorkspaceNotFoundError): return self.http_status(404, 'resource_not_found', 'Resource not found') if isinstance(error, MembershipPermissionError): return self.http_status(403, error.code, str(error)) if isinstance(error, EntitlementUnavailableError): return self.http_status(403, 'entitlement_unavailable', str(error)) request_id = self.request_id() logger = getattr(self.ap, 'logger', self.quart_app.logger) logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}') return self.http_status( 401, 'invalid_authentication', 'Invalid authentication credentials', ) def request_id(self) -> str: """Return one stable request ID for authentication, logs, and errors.""" request_context = getattr(quart.g, 'request_context', None) request_id = getattr(request_context, 'request_id', None) or getattr(quart.g, 'request_id', None) if not request_id: candidate = str(quart.request.headers.get('X-Request-Id') or '').strip() if not candidate or len(candidate) > 128 or any(ord(char) < 32 for char in candidate): candidate = str(uuid.uuid4()) request_id = candidate quart.g.request_id = request_id return str(request_id) def internal_error_response(self, request_id: str | None = None) -> typing.Tuple[quart.Response, int]: """Return a stable 500 response without exposing the underlying exception.""" resolved_request_id = request_id or self.request_id() response = quart.jsonify( { 'code': 'internal_error', 'msg': 'Internal server error', 'request_id': resolved_request_id, } ) response.headers['X-Request-Id'] = resolved_request_id return response, 500 def success(self, data: typing.Any = None) -> quart.Response: """Return a 200 response""" return quart.jsonify( { 'code': 0, 'msg': 'ok', 'data': data, } ) def fail(self, code: int | str, msg: str) -> quart.Response: """Return an error response""" return quart.jsonify( { 'code': code, 'msg': msg, } ) def http_status(self, status: int, code: int | str, msg: str) -> typing.Tuple[quart.Response, int]: """返回一个指定状态码的响应""" return (self.fail(code, msg), status)