feat(api-tools): support processor principal and operation whitelist enforcement

LANG-230

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-31 18:20:47 +08:00
co-authored by multica-agent
parent 8f2f537e34
commit c123a0da79
3 changed files with 111 additions and 2 deletions
+28 -2
View File
@@ -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:
@@ -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']
+6
View File
@@ -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: