fix: enforce agent run API permissions

This commit is contained in:
huanghuoguoguo
2026-05-30 20:14:06 +08:00
parent bbe7666642
commit 93cd852061
12 changed files with 522 additions and 166 deletions

View File

@@ -89,13 +89,13 @@ class ResourcePolicy(pydantic.BaseModel):
"""
allowed_model_uuids: list[str] | None = None
"""Allowed model UUIDs. None means all authorized."""
"""Additional model UUID grants. None means no additional model grants."""
allowed_tool_names: list[str] | None = None
"""Allowed tool names. None means all authorized."""
"""Additional tool name grants. None means no additional tool grants."""
allowed_kb_uuids: list[str] | None = None
"""Allowed knowledge base UUIDs. None means all authorized."""
"""Additional knowledge base UUID grants. None means no additional KB grants."""
allow_plugin_storage: bool = True
"""Whether plugin storage is allowed."""

View File

@@ -176,6 +176,13 @@ class AgentRunOrchestrator:
runner_id=descriptor.id,
)
# Register incoming attachments so input/transcript artifact_refs are resolvable.
await self._register_input_artifacts(
event=event,
run_id=run_id,
runner_id=descriptor.id,
)
# Write user message to Transcript if message.received
if event.event_type == 'message.received' and event.conversation_id:
await self._write_user_transcript(
@@ -526,6 +533,97 @@ class AgentRunOrchestrator:
event_time=datetime.datetime.fromtimestamp(event.event_time) if event.event_time else None,
)
async def _register_input_artifacts(
self,
event: AgentEventEnvelope,
run_id: str,
runner_id: str,
) -> None:
"""Register current-event attachments referenced by AgentInput."""
if not event.input or not event.input.attachments:
return
from .artifact_store import ArtifactStore
store = ArtifactStore(self.ap.persistence_mgr.get_db_engine())
for attachment in event.input.attachments:
data = attachment.model_dump(mode='json') if hasattr(attachment, 'model_dump') else attachment
if not isinstance(data, dict):
continue
artifact_id = data.get('artifact_id')
artifact_type = data.get('artifact_type') or 'file'
if not artifact_id:
continue
content, parsed_mime_type = self._decode_attachment_content(data.get('content'))
url = data.get('url')
platform_ref_id = data.get('id')
storage_key = None
storage_type = 'metadata_only'
if content is None:
if url:
storage_key = url
storage_type = 'url'
elif platform_ref_id:
storage_key = platform_ref_id
storage_type = 'platform_ref'
metadata = {
'input_attachment': True,
'input_source': data.get('source') or 'platform',
}
if url:
metadata['url'] = url
if platform_ref_id:
metadata['platform_ref_id'] = platform_ref_id
try:
await store.register_artifact(
artifact_id=artifact_id,
artifact_type=artifact_type,
source='platform',
storage_key=storage_key,
storage_type=storage_type,
mime_type=data.get('mime_type') or parsed_mime_type,
name=data.get('name'),
size_bytes=data.get('size') or (len(content) if content is not None else None),
conversation_id=event.conversation_id,
run_id=run_id,
runner_id=runner_id,
bot_id=event.bot_id,
workspace_id=event.workspace_id,
metadata=metadata,
content=content,
)
except Exception as e:
self.ap.logger.warning(
f'Failed to register input artifact {artifact_id}: {e}'
)
def _decode_attachment_content(
self,
content: typing.Any,
) -> tuple[bytes | None, str | None]:
"""Decode base64 attachment content, including data URLs."""
if not isinstance(content, str) or not content:
return None, None
import base64
import binascii
mime_type = None
payload = content
if content.startswith('data:') and ',' in content:
header, payload = content.split(',', 1)
if ';base64' in header:
mime_type = header[5:].split(';', 1)[0] or None
try:
return base64.b64decode(payload, validate=False), mime_type
except (binascii.Error, ValueError):
return None, mime_type
async def _write_user_transcript(
self,
event: AgentEventEnvelope,

View File

@@ -250,6 +250,8 @@ class PersistentStateStore:
Used by State API handlers.
"""
state_key = normalize_state_key(state_key)
async with self._db_engine.connect() as conn:
result = await conn.execute(
select(AgentRunnerState.value_json)
@@ -282,6 +284,8 @@ class PersistentStateStore:
Used by State API handlers.
Context contains optional fields like bot_id, conversation_id, etc.
"""
state_key = normalize_state_key(state_key)
# Validate and serialize value
value_json, error = self._validate_json_value(value, logger)
if error:
@@ -344,6 +348,8 @@ class PersistentStateStore:
Returns True if deleted, False if not found.
"""
state_key = normalize_state_key(state_key)
async with self._db_engine.begin() as conn:
result = await conn.execute(
delete(AgentRunnerState)
@@ -376,6 +382,7 @@ class PersistentStateStore:
)
if prefix:
prefix = normalize_state_key(prefix)
query = query.where(
AgentRunnerState.state_key.like(f'{prefix}%')
)

View File

@@ -103,12 +103,13 @@ class AgentResourceBuilder:
models: list[ModelResource] = []
seen_model_ids: set[str] = set()
# Check manifest permission
model_perms = manifest_perms.get('models', [])
if 'invoke' not in model_perms and 'stream' not in model_perms:
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
allow_rerank = 'rerank' in model_perms
if not allow_llm and not allow_rerank:
return models
# Get model UUIDs from resource policy
# Get additional model UUID grants from resource policy.
allowed_uuids = resource_policy.allowed_model_uuids
# Add model resources from binding config schema
@@ -117,10 +118,12 @@ class AgentResourceBuilder:
seen_model_ids=seen_model_ids,
descriptor=descriptor,
runner_config=runner_config,
include_llm=allow_llm,
include_rerank=allow_rerank,
)
# Add explicitly allowed models
if allowed_uuids:
if allowed_uuids and allow_llm:
for model_uuid in allowed_uuids:
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
@@ -168,13 +171,13 @@ class AgentResourceBuilder:
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
return kb_resources
# Get KB UUIDs from schema-defined config fields
# Get KB UUID grants from schema-defined config fields.
kb_uuids = config_schema.extract_knowledge_base_uuids(descriptor, runner_config)
# Also check resource policy
# Also include resource policy grants.
allowed_uuids = resource_policy.allowed_kb_uuids
if allowed_uuids:
kb_uuids = allowed_uuids
kb_uuids = list(dict.fromkeys([*kb_uuids, *allowed_uuids]))
for kb_uuid in kb_uuids:
try:
@@ -210,12 +213,14 @@ class AgentResourceBuilder:
seen_model_ids: set[str],
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
include_llm: bool,
include_rerank: bool,
) -> None:
"""Authorize model-like values selected through DynamicForm fields."""
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
if model_type == 'llm':
if model_type == 'llm' and include_llm:
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
elif model_type == 'rerank':
elif model_type == 'rerank' and include_rerank:
await self._append_rerank_model_resource(models, seen_model_ids, model_uuid)
async def _append_llm_model_resource(

View File

@@ -73,9 +73,6 @@ class TranscriptStore:
if content and len(content) > self.MAX_CONTENT_LENGTH:
content = content[:self.MAX_CONTENT_LENGTH - 3] + "..."
# Get next sequence number for this conversation
seq = await self._get_next_seq(conversation_id)
async with self._session_factory() as session:
item = Transcript(
transcript_id=transcript_id,
@@ -87,13 +84,15 @@ class TranscriptStore:
content=content,
content_json=json.dumps(content_json) if content_json else None,
artifact_refs_json=json.dumps(artifact_refs) if artifact_refs else None,
seq=seq,
seq=0,
run_id=run_id,
runner_id=runner_id,
created_at=datetime.datetime.utcnow(),
metadata_json=json.dumps(metadata) if metadata else None,
)
session.add(item)
await session.flush()
item.seq = item.id or await self._get_next_seq(conversation_id)
await session.commit()
return transcript_id
@@ -253,7 +252,7 @@ class TranscriptStore:
return count > 0
async def _get_next_seq(self, conversation_id: str) -> int:
"""Get the next sequence number for a conversation."""
"""Fallback next sequence number for stores that cannot expose autoincrement IDs."""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(sqlalchemy.func.max(Transcript.seq))

View File

@@ -50,7 +50,7 @@ class Transcript(Base):
# Sequence for cursor-based pagination
seq = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, index=True)
"""Sequence number within conversation (auto-increment per conversation)."""
"""Monotonic cursor sequence for pagination."""
# Context
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)

View File

@@ -141,6 +141,70 @@ def _validate_artifact_access(
return False, f'Artifact {operation} access denied: artifact not in session conversation and not created by this run'
async def _validate_agent_run_session(
run_id: str,
caller_plugin_identity: str | None,
ap: app.Application,
api_name: str,
permission_group: str | None = None,
permission_operation: str | None = None,
) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
"""Validate an AgentRunner pull API run session and optional manifest permission."""
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return None, handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return None, handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
ap.logger.warning(
f'{api_name}: caller_plugin_identity {caller_plugin_identity} '
f'does not match session plugin_identity {session_plugin_identity}'
)
return None, handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
if permission_group and permission_operation:
permissions = session.get('permissions', {})
allowed_operations = permissions.get(permission_group, [])
if permission_operation not in allowed_operations:
return None, handler.ActionResponse.error(
message=f'{api_name} access not authorized'
)
return session, None
def _resolve_run_conversation(
session: dict[str, Any],
requested_conversation_id: str | None,
api_name: str,
) -> tuple[str | None, handler.ActionResponse | None]:
"""Resolve and enforce current-run conversation scope."""
session_conversation_id = session.get('conversation_id')
if requested_conversation_id:
if not session_conversation_id:
return None, handler.ActionResponse.error(
message=f'{api_name} is not available without a run conversation'
)
if requested_conversation_id != session_conversation_id:
return None, handler.ActionResponse.error(
message=f'Conversation {requested_conversation_id} is not accessible by this run'
)
return requested_conversation_id, None
return session_conversation_id, None
def _normalize_uuid_list(values: Any) -> list[str]:
"""Normalize a user/config supplied UUID list while preserving order."""
if not isinstance(values, list):
@@ -1197,7 +1261,7 @@ class RuntimeConnectionHandler(handler.Handler):
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
filters = data.get('filters') or {}
run_id = data.get('run_id') # Optional: present for AgentRunner calls
caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation
@@ -1271,7 +1335,7 @@ class RuntimeConnectionHandler(handler.Handler):
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
filters = data.get('filters') or {}
run_id = data.get('run_id') # Optional: present for AgentRunner calls
caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation
@@ -1342,29 +1406,24 @@ class RuntimeConnectionHandler(handler.Handler):
if not run_id:
return handler.ActionResponse.error(message='run_id is required')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'History page',
permission_group='history',
permission_operation='page',
)
if error:
return error
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
# Get conversation from session if not provided
if not conversation_id:
conversation_id = session.get('conversation_id')
conversation_id, scope_error = _resolve_run_conversation(
session,
conversation_id,
'History page',
)
if scope_error:
return scope_error
if not conversation_id:
return handler.ActionResponse.success(data={
@@ -1411,35 +1470,32 @@ class RuntimeConnectionHandler(handler.Handler):
"""
run_id = data.get('run_id')
query_text = data.get('query', '')
filters = data.get('filters', {})
filters = data.get('filters') or {}
top_k = data.get('top_k', 10)
caller_plugin_identity = data.get('caller_plugin_identity')
if not run_id:
return handler.ActionResponse.error(message='run_id is required')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'History search',
permission_group='history',
permission_operation='search',
)
if error:
return error
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
# Get conversation from session or filters
conversation_id = filters.get('conversation_id') or session.get('conversation_id')
requested_conversation_id = filters.get('conversation_id')
conversation_id, scope_error = _resolve_run_conversation(
session,
requested_conversation_id,
'History search',
)
if scope_error:
return scope_error
if not conversation_id:
return handler.ActionResponse.success(data={
@@ -1453,10 +1509,11 @@ class RuntimeConnectionHandler(handler.Handler):
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
try:
safe_filters = {k: v for k, v in filters.items() if k != 'conversation_id'}
items = await store.search_transcript(
conversation_id=conversation_id,
query_text=query_text,
filters=filters,
filters=safe_filters,
top_k=top_k,
)
@@ -1485,25 +1542,16 @@ class RuntimeConnectionHandler(handler.Handler):
if not event_id:
return handler.ActionResponse.error(message='event_id is required')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'Event get',
permission_group='events',
permission_operation='get',
)
if error:
return error
# Get event
from ..agent.runner.event_log_store import EventLogStore
@@ -1516,9 +1564,12 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'Event {event_id} not found'
)
# Validate event is in the same conversation as the run
# Validate event is in the same conversation as the run, or was created by the same run.
session_conversation_id = session.get('conversation_id')
if session_conversation_id and event.get('conversation_id') != session_conversation_id:
event_run_id = event.get('run_id')
if event_run_id and event_run_id == run_id:
return handler.ActionResponse.success(data=event)
if not session_conversation_id or event.get('conversation_id') != session_conversation_id:
return handler.ActionResponse.error(
message=f'Event {event_id} is not accessible by this run'
)
@@ -1544,29 +1595,24 @@ class RuntimeConnectionHandler(handler.Handler):
if not run_id:
return handler.ActionResponse.error(message='run_id is required')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'Event page',
permission_group='events',
permission_operation='page',
)
if error:
return error
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
# Get conversation from session if not provided
if not conversation_id:
conversation_id = session.get('conversation_id')
conversation_id, scope_error = _resolve_run_conversation(
session,
conversation_id,
'Event page',
)
if scope_error:
return scope_error
if not conversation_id:
return handler.ActionResponse.success(data={
@@ -1620,33 +1666,16 @@ class RuntimeConnectionHandler(handler.Handler):
if not artifact_id:
return handler.ActionResponse.error(message='artifact_id is required')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
# Check artifact permission from session.permissions (from descriptor.permissions)
permissions = session.get('permissions', {})
artifact_permissions = permissions.get('artifacts', [])
if 'metadata' not in artifact_permissions:
return handler.ActionResponse.error(
message='Artifact metadata access not authorized'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'Artifact metadata',
permission_group='artifacts',
permission_operation='metadata',
)
if error:
return error
# Get artifact metadata
from ..agent.runner.artifact_store import ArtifactStore
@@ -1708,33 +1737,16 @@ class RuntimeConnectionHandler(handler.Handler):
if limit <= 0:
return handler.ActionResponse.error(message='limit must be > 0')
# Validate run session
session_registry = get_session_registry()
session = await session_registry.get(run_id)
if not session:
return handler.ActionResponse.error(
message=f'Run session {run_id} not found or expired'
)
# Validate caller plugin identity (strict: required when session has plugin_identity)
session_plugin_identity = session.get('plugin_identity')
if session_plugin_identity:
if not caller_plugin_identity:
return handler.ActionResponse.error(
message=f'caller_plugin_identity is required for run_id {run_id}'
)
if caller_plugin_identity != session_plugin_identity:
return handler.ActionResponse.error(
message=f'Plugin identity mismatch for run_id {run_id}'
)
# Check artifact permission from session.permissions (from descriptor.permissions)
permissions = session.get('permissions', {})
artifact_permissions = permissions.get('artifacts', [])
if 'read' not in artifact_permissions:
return handler.ActionResponse.error(
message='Artifact read access not authorized'
)
session, error = await _validate_agent_run_session(
run_id,
caller_plugin_identity,
self.ap,
'Artifact read',
permission_group='artifacts',
permission_operation='read',
)
if error:
return error
# Get artifact metadata first to validate access
from ..agent.runner.artifact_store import ArtifactStore