Files
LangBot/src/langbot/pkg/api/http/controller/group.py
T
Hyu 404e3466d9 feat(cloud): add scoped support admin sessions (#2369)
* feat(cloud): add scoped support admin sessions

* style(web): format support admin session changes

* fix(cloud): isolate support adapter sessions

* fix(cloud): authenticate plugin assets and report workspace resources

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-31 17:41:55 +08:00

575 lines
26 KiB
Python

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 ....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 ..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
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.OWNER_TRANSFER.value,
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,
**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:
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)
try:
if request_context is not None:
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):
return await f(*args, **kwargs)
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))
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)
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
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"""
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)