mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-10 18:57:33 +08:00
9964f284e1
- Move the operation-traceability service and its HTTP routes into a standalone pkg/operation_trace package; the Core controller package no longer depends on it (routes stay auto-discovered by the controller package scan). - Gate the hot path on one module-level boolean exposed as ap.operation_trace_active: while no Workspace has tracing enabled the route wrapper performs zero trace work and zero database round trips. The gate opens when a Workspace enables tracing and is primed once at startup for already-enabled Workspaces. - Record the installed extension identity for GitHub / marketplace / local / upload installs (owner/repo, author/name, filename) so the trace no longer shows a nameless "a plugin was installed" event and the card names the extension. - Merge migrations 0032 + 0033 into a single 0032_operation_traceability revision carrying the full table shape and an index set that matches the ORM. Tests: unit 5165 passed / integration 358 passed; ruff check + format clean.
750 lines
34 KiB
Python
750 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import abc
|
|
import typing
|
|
import enum
|
|
import quart
|
|
import time
|
|
import traceback
|
|
import inspect
|
|
import uuid
|
|
from quart.typing import RouteCallable
|
|
|
|
from ....utils import constants
|
|
from ....utils import bounded_executor
|
|
from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
|
|
from ....workspace.errors import WorkspaceNotFoundError
|
|
from ....cloud.entitlements import EntitlementUnavailableError
|
|
from ....core.errors import TaskCapacityError
|
|
from ....provider.modelmgr.codex_errors import CodexProviderError
|
|
from ..authz import (
|
|
AuthenticationDeniedError,
|
|
AuthorizationError,
|
|
Permission,
|
|
PermissionDeniedError,
|
|
WorkspaceRequiredError,
|
|
permissions_for_role,
|
|
require_permission,
|
|
)
|
|
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
|
from ....cloud.support_admin import SupportAdminSessionError
|
|
from ... import management_diagnostics as diagnostics
|
|
|
|
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'
|
|
|
|
|
|
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
|
{
|
|
Permission.MEMBER_VIEW.value,
|
|
Permission.MEMBER_INVITE.value,
|
|
Permission.MEMBER_UPDATE_ROLE.value,
|
|
Permission.MEMBER_REMOVE.value,
|
|
}
|
|
)
|
|
|
|
|
|
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,
|
|
operation_log_meta: dict[str, typing.Any] | None = None,
|
|
**options: typing.Any,
|
|
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
|
|
"""Register a route.
|
|
|
|
``operation_log_meta`` optionally enriches the traceability record for
|
|
this route with trusted server-side facts. Supported keys:
|
|
|
|
* ``resource_param``: handler kwarg holding the target resource id,
|
|
* ``changes``: a pre-computed before/after diff to persist,
|
|
* ``detail``: extra structured context (sensitive keys are redacted).
|
|
"""
|
|
|
|
if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
|
|
raise ValueError('Account-token routes cannot declare Workspace permissions')
|
|
if operation_log_meta is not None and not isinstance(operation_log_meta, dict):
|
|
raise ValueError('operation_log_meta must be a dict')
|
|
|
|
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:
|
|
if self._is_support_admin_token(token):
|
|
raise AuthenticationDeniedError(
|
|
'Support admin tokens cannot be refreshed or used on account endpoints'
|
|
)
|
|
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:
|
|
request_context = await self._authenticate_support_admin(token, auth_type)
|
|
if request_context is not None:
|
|
self._require_support_admin_route_allowed(rule, f, permission)
|
|
user_email = None
|
|
else:
|
|
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:
|
|
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
|
if token and self._is_support_admin_token(token):
|
|
try:
|
|
request_context = await self._authenticate_support_admin(token, auth_type)
|
|
if request_context is None:
|
|
raise AuthenticationDeniedError('Invalid support admin token')
|
|
self._require_support_admin_route_allowed(rule, f, permission)
|
|
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 API key first (check X-API-Key header)
|
|
elif api_key := quart.request.headers.get('X-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)
|
|
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)
|
|
|
|
observed_at = time.monotonic()
|
|
try:
|
|
if request_context is not None:
|
|
diagnostics.workspace(request_context)
|
|
with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
|
|
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 UoW.
|
|
async with persistence_mgr.tenant_scope(request_context.workspace_uuid):
|
|
response = await f(*args, **kwargs)
|
|
else:
|
|
response = await f(*args, **kwargs)
|
|
else:
|
|
response = await f(*args, **kwargs)
|
|
|
|
if request_context is not None:
|
|
# Trace after the handler released its tenant scope so
|
|
# auditing never extends a business transaction. The
|
|
# call is a no-op (and costs no database round trip)
|
|
# when tracing is off for this Workspace.
|
|
await self._record_operation(
|
|
request_context,
|
|
rule,
|
|
options,
|
|
kwargs,
|
|
observed_at,
|
|
response,
|
|
meta=operation_log_meta,
|
|
)
|
|
return response
|
|
|
|
except Exception as e: # 自动 500
|
|
if request_context is not None:
|
|
await self._record_operation(
|
|
request_context,
|
|
rule,
|
|
options,
|
|
kwargs,
|
|
observed_at,
|
|
None,
|
|
failure=e,
|
|
meta=operation_log_meta,
|
|
)
|
|
if isinstance(e, CodexProviderError):
|
|
return self.http_status(e.status_code, e.error_code, str(e))
|
|
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))
|
|
if isinstance(e, TaskCapacityError):
|
|
return self.http_status(429, 'task_capacity_exceeded', str(e))
|
|
if isinstance(
|
|
e,
|
|
bounded_executor.BlockingWorkCapacityError,
|
|
):
|
|
return self.http_status(
|
|
429,
|
|
'blocking_work_capacity_exceeded',
|
|
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)
|
|
|
|
# Observe outside authentication, using the registered Core handler
|
|
# identity rather than the URL (which can contain user identifiers).
|
|
new_f = diagnostics.observe(
|
|
diagnostics.operation_id('http', f, rule=rule, methods=options.get('methods')),
|
|
source='http',
|
|
ap=self.ap,
|
|
http=True,
|
|
)(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 _record_operation(
|
|
self,
|
|
ctx: RequestContext,
|
|
route: str,
|
|
options: dict[str, typing.Any],
|
|
kwargs: dict[str, typing.Any],
|
|
observed_at: float,
|
|
response: typing.Any,
|
|
*,
|
|
failure: BaseException | None = None,
|
|
meta: dict[str, typing.Any] | None = None,
|
|
) -> None:
|
|
"""Best-effort traceability for one finished request.
|
|
|
|
This runs after the handler released its tenant scope. It never raises:
|
|
an audit failure must not turn a successful business response into a
|
|
500. The service performs its own cheap level check before touching the
|
|
database, so a disabled Workspace pays nothing at steady state.
|
|
"""
|
|
|
|
# Cheap global gate: a single attribute read on the application object.
|
|
# While no Workspace has enabled traceability this is ``False`` and the
|
|
# isolated subsystem stays entirely off the hot request path, with no
|
|
# import, service lookup, JSON parse or database round trip.
|
|
if not getattr(self.ap, 'operation_trace_active', False):
|
|
return
|
|
|
|
service = getattr(self.ap, 'workspace_settings_service', None)
|
|
if service is None:
|
|
return
|
|
|
|
try:
|
|
method = str(quart.request.method or options.get('methods', ['GET'])[0])
|
|
status_code = self._extract_status_code(response, failure)
|
|
if failure is not None:
|
|
outcome = 'denied' if isinstance(failure, AuthorizationError) else 'error'
|
|
elif status_code >= 500:
|
|
outcome = 'error'
|
|
elif status_code >= 400:
|
|
outcome = 'denied'
|
|
else:
|
|
outcome = 'ok'
|
|
|
|
resolved_meta = meta or {}
|
|
resource_id = None
|
|
resource_param = resolved_meta.get('resource_param')
|
|
if isinstance(resource_param, str):
|
|
candidate = kwargs.get(resource_param)
|
|
if candidate is not None:
|
|
resource_id = str(candidate)
|
|
|
|
# Routes without an explicit resource_param still name the resource
|
|
# they act on, either as a URL parameter or inside the payload of an
|
|
# install-style endpoint. Resolving that identity here gives every
|
|
# resource family a "which plugin/skill/knowledge base" trace without
|
|
# touching each handler. The body is read defensively: an audit path
|
|
# must never fail (or block) because a request carries no JSON.
|
|
try:
|
|
# Identity resolution lives in the isolated traceability service
|
|
# and is reached through the handle, so the Core controller never
|
|
# imports that subsystem.
|
|
request_body: dict[str, typing.Any] | None = None
|
|
if str(quart.request.method or '').upper() not in ('GET', 'HEAD', 'OPTIONS'):
|
|
raw_body = await quart.request.get_json(silent=True)
|
|
if isinstance(raw_body, dict):
|
|
request_body = raw_body
|
|
identity = service.resolve_resource_identity(kwargs, request_body)
|
|
if identity:
|
|
resource_id = identity
|
|
except Exception: # pragma: no cover - identity resolution is best effort
|
|
pass
|
|
|
|
# Handlers may publish request-local traceability facts when a
|
|
# static route declaration cannot describe the runtime diff, e.g.
|
|
# the previous and the new role of a member. ``quart.g`` is
|
|
# request-local, so concurrent requests never share these values.
|
|
# The stored summary is derived by the service from ``changes`` and
|
|
# the classified action, so handlers only publish the diff itself.
|
|
changes = resolved_meta.get('changes')
|
|
detail = resolved_meta.get('detail')
|
|
request_changes = getattr(quart.g, 'operation_log_changes', None)
|
|
request_detail = getattr(quart.g, 'operation_log_detail', None)
|
|
request_resource_id = getattr(quart.g, 'operation_log_resource_id', None)
|
|
if request_changes:
|
|
changes = request_changes
|
|
if request_detail:
|
|
detail = request_detail
|
|
if request_resource_id:
|
|
resource_id = str(request_resource_id)
|
|
|
|
await service.record_request(
|
|
ctx,
|
|
method=method,
|
|
route=route,
|
|
status_code=status_code,
|
|
outcome=outcome,
|
|
duration_ms=int(max(time.monotonic() - observed_at, 0.0) * 1000),
|
|
resource_id=resource_id,
|
|
changes=changes or None,
|
|
detail=detail or None,
|
|
)
|
|
except Exception as exc: # pragma: no cover - auditing is best effort
|
|
logger = getattr(self.ap, 'logger', self.quart_app.logger)
|
|
logger.debug(f'Operation trace skipped for {route}: {exc}')
|
|
|
|
@staticmethod
|
|
def _extract_status_code(response: typing.Any, failure: BaseException | None) -> int:
|
|
"""Resolve the HTTP status of a handler result without consuming it."""
|
|
|
|
if failure is not None:
|
|
status = getattr(failure, 'status_code', None)
|
|
if isinstance(status, int):
|
|
return status
|
|
return 500
|
|
if isinstance(response, tuple) and len(response) >= 2 and isinstance(response[1], int):
|
|
return response[1]
|
|
status = getattr(response, 'status_code', None)
|
|
if isinstance(status, int):
|
|
return status
|
|
return 200
|
|
|
|
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
|
|
|
|
def _is_support_admin_token(self, token: str) -> bool:
|
|
service = getattr(self.ap, 'support_admin_session_service', None)
|
|
detector = getattr(service, 'is_support_admin_token', None)
|
|
return callable(detector) and detector(token) is True
|
|
|
|
async def _authenticate_support_admin(
|
|
self,
|
|
token: str,
|
|
auth_type: AuthType,
|
|
*,
|
|
workspace_uuid: str | None = None,
|
|
request_id: str | None = None,
|
|
) -> RequestContext | None:
|
|
service = getattr(self.ap, 'support_admin_session_service', None)
|
|
detector = getattr(service, 'is_support_admin_token', None)
|
|
if service is None or not callable(detector) or detector(token) is not True:
|
|
return None
|
|
|
|
requested_workspace_uuid = (
|
|
workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id')
|
|
)
|
|
if not requested_workspace_uuid:
|
|
raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector')
|
|
try:
|
|
identity = await service.authenticate_token(
|
|
token,
|
|
requested_workspace_uuid=requested_workspace_uuid,
|
|
)
|
|
except SupportAdminSessionError as exc:
|
|
raise AuthenticationDeniedError(str(exc)) from exc
|
|
|
|
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=request_id or self.request_id(),
|
|
auth_type=auth_type.value,
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.SUPPORT_ADMIN,
|
|
actor_account_uuid=identity.actor_account_uuid,
|
|
support_session_id=identity.grant_jti_hash,
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid=identity.workspace_uuid,
|
|
membership_uuid=None,
|
|
role='owner',
|
|
permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS,
|
|
membership_revision=0,
|
|
),
|
|
entitlement_revision=entitlement_revision,
|
|
)
|
|
quart.g.request_context = request_context
|
|
quart.g.workspace_membership = None
|
|
return request_context
|
|
|
|
@staticmethod
|
|
def _require_support_admin_route_allowed(
|
|
rule: str,
|
|
handler: RouteCallable,
|
|
permission: Permission | str | None,
|
|
) -> None:
|
|
parameters = inspect.signature(handler).parameters
|
|
if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters:
|
|
raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints')
|
|
permission_value = permission.value if isinstance(permission, Permission) else permission
|
|
if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS:
|
|
raise PermissionDeniedError(permission_value)
|
|
|
|
async def _resolve_account_context(
|
|
self,
|
|
account: typing.Any,
|
|
auth_type: AuthType,
|
|
*,
|
|
token: str | None = None,
|
|
) -> 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"""
|
|
|
|
diagnostics.outcome('failed')
|
|
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)
|