From c123a0da794fb6c04f31da4f5551c72f041c0f65 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 31 Oct 2026 18:20:47 +0800 Subject: [PATCH] feat(api-tools): support processor principal and operation whitelist enforcement LANG-230 Co-authored-by: multica-agent --- src/langbot/pkg/api/application/api.py | 30 +++++++- .../pkg/api/application/processor_context.py | 77 +++++++++++++++++++ src/langbot/pkg/api/context.py | 6 ++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/langbot/pkg/api/application/processor_context.py diff --git a/src/langbot/pkg/api/application/api.py b/src/langbot/pkg/api/application/api.py index 37f81253f..a46116fce 100644 --- a/src/langbot/pkg/api/application/api.py +++ b/src/langbot/pkg/api/application/api.py @@ -10,7 +10,7 @@ import logging from pydantic import TypeAdapter, ValidationError -from ..authz import AuthenticationDeniedError, require_permission +from ..authz import AuthenticationDeniedError, PermissionDeniedError, require_permission from ..context import PrincipalType, RequestContext from ...i18n import language_scope from ...i18n.workspace import get_workspace_language @@ -67,10 +67,15 @@ class ApplicationAPI: PrincipalType.ACCOUNT, PrincipalType.API_KEY, PrincipalType.SUPPORT_ADMIN, + PrincipalType.PROCESSOR, }: raise AuthenticationDeniedError('This principal cannot invoke Workspace management operations') if spec.principals is not None and context.principal.principal_type not in spec.principals: raise AuthenticationDeniedError('This operation requires a different principal') + if context.principal.principal_type == PrincipalType.PROCESSOR and context.allowed_operation_ids is None: + raise PermissionDeniedError('allowed_operation_ids') + if context.allowed_operation_ids is not None and spec.operation_id not in context.allowed_operation_ids: + raise PermissionDeniedError(spec.operation_id) require_permission(context, spec.permission) try: payload = spec.input_type.model_validate(input) @@ -183,6 +188,7 @@ class ApplicationAPI: PrincipalType.ACCOUNT, PrincipalType.API_KEY, PrincipalType.SUPPORT_ADMIN, + PrincipalType.PROCESSOR, } ): return @@ -191,6 +197,8 @@ class ApplicationAPI: if context.principal.principal_type == PrincipalType.API_KEY else context.principal.actor_account_uuid if context.principal.principal_type == PrincipalType.SUPPORT_ADMIN + else context.principal.processor_uuid + if context.principal.principal_type == PrincipalType.PROCESSOR else context.account_uuid ) if not isinstance(principal_id, str) or not principal_id.strip(): @@ -255,11 +263,29 @@ class ApplicationAPI: break if resource_id is None and identity: resource_id = next(iter(identity.values())) + detail = {'operation': spec.operation_id, 'version': spec.version} + actor_name = None + if context.principal.principal_type == PrincipalType.PROCESSOR: + actor_name = ( + f'{context.principal.processor_kind}:{context.principal.processor_uuid}' + if context.principal.processor_kind + else context.principal.processor_uuid + ) + detail.update( + { + 'processor_kind': context.principal.processor_kind, + 'processor_uuid': context.principal.processor_uuid, + 'run_id': context.principal.run_id, + } + ) + if context.principal.trigger_actor: + detail['trigger_actor'] = context.principal.trigger_actor await service.record( context.workspace_uuid, rule=ActionRule(action, 'resource', bucket, parts[0]), level=level, actor_account_uuid=context.principal.actor_account_uuid or context.account_uuid, + actor_name=actor_name, actor_role=context.workspace.role, principal_type=context.principal.principal_type.value, api_key_uuid=context.principal.api_key_uuid, @@ -269,7 +295,7 @@ class ApplicationAPI: resource_id=resource_id, outcome=outcome, status_code=status, - detail={'operation': spec.operation_id, 'version': spec.version}, + detail=detail, duration_ms=int((time.monotonic() - observed_at) * 1000), ) except Exception: diff --git a/src/langbot/pkg/api/application/processor_context.py b/src/langbot/pkg/api/application/processor_context.py new file mode 100644 index 000000000..bb29312c2 --- /dev/null +++ b/src/langbot/pkg/api/application/processor_context.py @@ -0,0 +1,77 @@ +"""Processor authorization context factory for ApplicationAPI invocation.""" + +from __future__ import annotations + +import typing +import uuid +from collections.abc import Iterable + +from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext + + +async def build_processor_request_context( + ap: typing.Any, + *, + workspace_uuid: str, + processor_kind: str, + processor_uuid: str, + run_id: str, + operation_ids: Iterable[str], + trigger_actor: str | None = None, +) -> RequestContext: + """Build a trusted RequestContext for internal processor invocations.""" + workspace_service = getattr(ap, 'workspace_service', None) + if workspace_service is None and hasattr(ap, 'ap'): + workspace_service = getattr(ap.ap, 'workspace_service', None) + if workspace_service is None: + raise RuntimeError('Workspace service is required to resolve execution binding') + + binding = await workspace_service.get_execution_binding(workspace_uuid) + + app_api = getattr(ap, 'application_api', None) + if app_api is None and hasattr(ap, '_operations'): + app_api = ap + if app_api is None and hasattr(ap, 'ap'): + app_api = getattr(ap.ap, 'application_api', None) + + allowed_ids = frozenset(operation_ids) + permissions: set[str] = set() + if app_api is not None: + operations = getattr(app_api, '_operations', {}) + for op_id in allowed_ids: + spec = operations.get(op_id) + if spec is not None and getattr(spec, 'permission', None) is not None: + perm = spec.permission + permissions.add(perm.value if hasattr(perm, 'value') else str(perm)) + + principal = PrincipalContext( + principal_type=PrincipalType.PROCESSOR, + account_uuid=None, + actor_account_uuid=None, + api_key_uuid=None, + support_session_id=None, + processor_kind=processor_kind, + processor_uuid=processor_uuid, + run_id=run_id, + trigger_actor=trigger_actor, + ) + workspace = WorkspaceContext( + workspace_uuid=workspace_uuid, + membership_uuid=None, + role=None, + permissions=frozenset(permissions), + membership_revision=0, + ) + return RequestContext( + instance_uuid=binding.instance_uuid, + placement_generation=binding.placement_generation, + request_id=str(uuid.uuid4()), + auth_type='processor', + principal=principal, + workspace=workspace, + entitlement_revision=0, + allowed_operation_ids=allowed_ids, + ) + + +__all__ = ['build_processor_request_context'] diff --git a/src/langbot/pkg/api/context.py b/src/langbot/pkg/api/context.py index 254e6ba97..8f9c849bb 100644 --- a/src/langbot/pkg/api/context.py +++ b/src/langbot/pkg/api/context.py @@ -19,6 +19,7 @@ class PrincipalType(enum.StrEnum): SUPPORT_ADMIN = 'support_admin' SYSTEM = 'system' PUBLIC_BOT = 'public_bot' + PROCESSOR = 'processor' @dataclasses.dataclass(frozen=True, slots=True) @@ -30,6 +31,10 @@ class PrincipalContext: actor_account_uuid: str | None = None api_key_uuid: str | None = None support_session_id: str | None = None + processor_kind: str | None = None + processor_uuid: str | None = None + run_id: str | None = None + trigger_actor: str | None = None @dataclasses.dataclass(frozen=True, slots=True) @@ -54,6 +59,7 @@ class RequestContext: principal: PrincipalContext workspace: WorkspaceContext entitlement_revision: int = 0 + allowed_operation_ids: frozenset[str] | None = None @property def workspace_uuid(self) -> str: