Skip to content

Commit 488dfd8

Browse files
committed
chore(server): check task and context ownership for a2a proxy requests
Signed-off-by: Radek Ježek <[email protected]>
1 parent 86b4140 commit 488dfd8

File tree

26 files changed

+1621
-176
lines changed

26 files changed

+1621
-176
lines changed

apps/beeai-cli/src/beeai_cli/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncItera
112112
follow_redirects=True,
113113
timeout=timedelta(hours=1).total_seconds(),
114114
) as httpx_client:
115-
yield ClientFactory(ClientConfig(httpx_client=httpx_client)).create(card=agent_card)
115+
yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(card=agent_card)
116116

117117

118118
@asynccontextmanager

apps/beeai-cli/uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/beeai-sdk-py/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
authors = [{ name = "IBM Corp." }]
77
requires-python = ">=3.11,<3.14"
88
dependencies = [
9-
"a2a-sdk==0.3.7",
9+
"a2a-sdk==0.3.9",
1010
"objprint>=0.3.0",
1111
"uvicorn>=0.35.0",
1212
"asyncclick>=8.1.8",

apps/beeai-sdk-py/uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/beeai-server/pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
authors = [{ name = "IBM Corp." }]
77
requires-python = "==3.12.*"
88
dependencies = [
9-
"a2a-sdk~=0.3.5",
9+
"a2a-sdk~=0.3.9",
1010
"aiohttp>=3.11.11",
1111
"anyio>=4.9.0",
1212
"asgiref>=3.8.1",
@@ -42,6 +42,8 @@ dependencies = [
4242
"openai>=1.97.0",
4343
"authlib>=1.6.4",
4444
"async-lru>=2.0.5",
45+
"starlette>=0.48.0",
46+
"sse-starlette>=3.0.2",
4547
"exceptiongroup>=1.3.0",
4648
]
4749

apps/beeai-server/src/beeai_server/api/routes/a2a.py

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from typing import Annotated
5-
from urllib.parse import urljoin, urlparse
5+
from urllib.parse import urljoin
66
from uuid import UUID
77

88
import fastapi
99
import fastapi.responses
10-
from a2a.types import AgentCard, TransportProtocol
10+
from a2a.server.apps import A2AFastAPIApplication
11+
from a2a.server.apps.rest.rest_adapter import RESTAdapter
12+
from a2a.types import AgentCard, AgentInterface, TransportProtocol
1113
from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
12-
from fastapi import Depends, Request
14+
from fastapi import Depends, HTTPException, Request
1315

1416
from beeai_server.api.dependencies import (
1517
A2AProxyServiceDependency,
@@ -19,45 +21,21 @@
1921
from beeai_server.domain.models.permissions import AuthorizedUser
2022
from beeai_server.service_layer.services.a2a import A2AServerResponse
2123

22-
_SUPPORTED_TRANSPORTS = [TransportProtocol.jsonrpc, TransportProtocol.http_json]
23-
24-
2524
router = fastapi.APIRouter()
2625

2726

28-
def _create_proxy_url(url: str, *, proxy_base: str) -> str:
29-
return urljoin(proxy_base, urlparse(url).path.lstrip("/"))
30-
31-
3227
def create_proxy_agent_card(agent_card: AgentCard, *, provider_id: UUID, request: Request) -> AgentCard:
33-
proxy_base = str(request.url_for(proxy_request.__name__, provider_id=provider_id, path=""))
34-
proxy_interfaces = (
35-
[
36-
interface.model_copy(update={"url": _create_proxy_url(interface.url, proxy_base=proxy_base)})
37-
for interface in agent_card.additional_interfaces
38-
if interface.transport in _SUPPORTED_TRANSPORTS
39-
]
40-
if agent_card.additional_interfaces is not None
41-
else None
28+
proxy_base = str(request.url_for(a2a_proxy_jsonrpc_transport.__name__, provider_id=provider_id))
29+
return agent_card.model_copy(
30+
update={
31+
"preferred_transport": TransportProtocol.jsonrpc,
32+
"url": proxy_base,
33+
"additional_interfaces": [
34+
AgentInterface(transport=TransportProtocol.http_json, url=urljoin(proxy_base, "http")),
35+
AgentInterface(transport=TransportProtocol.jsonrpc, url=proxy_base),
36+
],
37+
}
4238
)
43-
if agent_card.preferred_transport in _SUPPORTED_TRANSPORTS:
44-
return agent_card.model_copy(
45-
update={
46-
"url": _create_proxy_url(agent_card.url, proxy_base=proxy_base),
47-
"additional_interfaces": proxy_interfaces,
48-
}
49-
)
50-
elif proxy_interfaces:
51-
interface = proxy_interfaces[0]
52-
return agent_card.model_copy(
53-
update={
54-
"url": interface.url,
55-
"preferred_transport": interface.transport,
56-
"additional_interfaces": proxy_interfaces,
57-
}
58-
)
59-
else:
60-
raise RuntimeError("Provider doesn't have any transport supported by the proxy.")
6139

6240

6341
def _to_fastapi(response: A2AServerResponse):
@@ -79,15 +57,45 @@ async def get_agent_card(
7957
return create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)
8058

8159

82-
@router.api_route("/{provider_id}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
83-
@router.api_route("/{provider_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
84-
async def proxy_request(
60+
@router.post("/{provider_id}")
61+
@router.post("/{provider_id}/")
62+
async def a2a_proxy_jsonrpc_transport(
63+
provider_id: UUID,
64+
request: fastapi.requests.Request,
65+
a2a_proxy: A2AProxyServiceDependency,
66+
provider_service: ProviderServiceDependency,
67+
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(a2a_proxy={"*"}))],
68+
):
69+
provider = await provider_service.get_provider(provider_id=provider_id)
70+
agent_card = create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)
71+
72+
handler = await a2a_proxy.get_request_handler(provider=provider, user=user.user)
73+
app = A2AFastAPIApplication(agent_card=agent_card, http_handler=handler)
74+
return await app._handle_requests(request)
75+
76+
77+
@router.api_route("/{provider_id}/http", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
78+
@router.api_route(
79+
"/{provider_id}/http/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
80+
)
81+
async def a2a_proxy_http_transport(
8582
provider_id: UUID,
8683
request: fastapi.requests.Request,
8784
a2a_proxy: A2AProxyServiceDependency,
88-
_: Annotated[AuthorizedUser, Depends(RequiresPermissions(a2a_proxy={"*"}))],
85+
provider_service: ProviderServiceDependency,
86+
user: Annotated[AuthorizedUser, Depends(RequiresPermissions(a2a_proxy={"*"}))],
8987
path: str = "",
9088
):
91-
client = await a2a_proxy.get_proxy_client(provider_id=provider_id)
92-
response = await client.send_request(method=request.method, url=f"/{path}", content=request.stream())
93-
return _to_fastapi(response)
89+
provider = await provider_service.get_provider(provider_id=provider_id)
90+
agent_card = create_proxy_agent_card(provider.agent_card, provider_id=provider.id, request=request)
91+
92+
handler = await a2a_proxy.get_request_handler(provider=provider, user=user.user)
93+
adapter = RESTAdapter(agent_card=agent_card, http_handler=handler)
94+
95+
if not (handler := adapter.routes().get((f"/{path.rstrip('/')}", request.method), None)):
96+
raise HTTPException(status_code=404, detail="Not found")
97+
98+
return await handler(request)
99+
100+
101+
# TODO: extra a2a routes are not supported

apps/beeai-server/src/beeai_server/configuration.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ class ContextConfiguration(BaseModel):
230230
resource_expire_after_days: int = 7 # Expires files and vector_stores attached to a context
231231

232232

233+
class A2AProxyConfiguration(BaseModel):
234+
# Expires a2a_request_tasks and a2a_request_contexts (WARNING: has security implications!)
235+
requests_expire_after_days: int = 14
236+
237+
233238
class FeatureConfiguration(BaseModel):
234239
generate_conversation_title: bool = True
235240
provider_builds: bool = True
@@ -265,6 +270,7 @@ class Configuration(BaseSettings):
265270
vector_stores: VectorStoresConfiguration = Field(default_factory=VectorStoresConfiguration)
266271
text_extraction: DoclingExtractionConfiguration = Field(default_factory=DoclingExtractionConfiguration)
267272
context: ContextConfiguration = Field(default_factory=ContextConfiguration)
273+
a2a_proxy: A2AProxyConfiguration = Field(default_factory=A2AProxyConfiguration)
268274
k8s_namespace: str | None = None
269275
k8s_kubeconfig: Path | None = None
270276

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from uuid import UUID
5+
6+
from pydantic import AwareDatetime, BaseModel
7+
8+
9+
class A2ARequestTask(BaseModel):
10+
task_id: str
11+
created_by: UUID
12+
provider_id: UUID
13+
created_at: AwareDatetime
14+
last_accessed_at: AwareDatetime
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
from datetime import timedelta
4+
from typing import Protocol, runtime_checkable
5+
from uuid import UUID
6+
7+
from beeai_server.domain.models.a2a_request import A2ARequestTask
8+
9+
10+
@runtime_checkable
11+
class IA2ARequestRepository(Protocol):
12+
async def track_request_ids_ownership(
13+
self,
14+
user_id: UUID,
15+
provider_id: UUID,
16+
task_id: str | None = None,
17+
context_id: str | None = None,
18+
allow_task_creation: bool = False,
19+
) -> None: ...
20+
21+
async def get_task(self, *, task_id: str, user_id: UUID) -> A2ARequestTask: ...
22+
23+
async def delete_tasks(self, *, older_than: timedelta) -> int: ...
24+
async def delete_contexts(self, *, older_than: timedelta) -> int: ...

apps/beeai-server/src/beeai_server/exceptions.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ def __init__(
5555
super().__init__(f"{entity} with {attribute} {id} not found", status_code)
5656

5757

58+
class ForbiddenUpdateError(PlatformError):
59+
entity: str
60+
id: UUID | str
61+
attribute: str
62+
63+
def __init__(
64+
self, entity: str, id: UUID | str, status_code: int = status.HTTP_404_NOT_FOUND, attribute: str = "id"
65+
):
66+
self.entity = entity
67+
self.id = id
68+
self.attribute = attribute
69+
super().__init__("Insufficient permissions", status_code)
70+
71+
5872
class InvalidVectorDimensionError(PlatformError): ...
5973

6074

0 commit comments

Comments
 (0)