mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(api/v2): enforce X-Agent-Role on every flow router
Route layer now rejects 403 if the role doesn't match the router's allowed set. Choreographer still re-checks role per verb where needed, but defense in depth means a future verb that forgets the role check doesn't leak. Auditor router also gated.
This commit is contained in:
@@ -19,7 +19,7 @@ _HTTP_200 = 200
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "auditor"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "product_owner"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "cell_pm"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "developer"}
|
||||
|
||||
|
||||
def _make_envelope(status: str = "ok", task_id: str | None = None) -> MagicMock:
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "documenter"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "main_pm"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
||||
|
||||
_AGENT_ID = str(uuid4())
|
||||
_TASK_ID = str(uuid4())
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID}
|
||||
_HEADERS = {"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "qa"}
|
||||
|
||||
|
||||
def _make_envelope(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""v2 flow routes reject requests with the wrong X-Agent-Role.
|
||||
|
||||
Defense-in-depth check: every v2 flow router declares router-level
|
||||
dependencies that 403 if `X-Agent-Role` doesn't match the router's role.
|
||||
We verify that gate by mounting only the dev router on a minimal app
|
||||
with the choreographer mocked — no DB / lifespan needed because the
|
||||
role check fires BEFORE any body validation or choreographer call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from roboco.api.deps import get_choreographer
|
||||
from roboco.api.routes.v2.flow_dev import router as flow_dev_router
|
||||
|
||||
_HTTP_200 = 200
|
||||
_HTTP_403 = 403
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(flow_dev_router)
|
||||
mock_chore = MagicMock()
|
||||
mock_envelope = MagicMock()
|
||||
mock_envelope.as_dict.return_value = {"status": "idle", "next": "..."}
|
||||
mock_chore.give_me_work = AsyncMock(return_value=mock_envelope)
|
||||
app.dependency_overrides[get_choreographer] = lambda: mock_chore
|
||||
return app
|
||||
|
||||
|
||||
def test_dev_route_rejects_qa_role() -> None:
|
||||
client = TestClient(_build_app())
|
||||
r = client.post(
|
||||
"/api/v2/flow/dev/give_me_work",
|
||||
json={},
|
||||
headers={
|
||||
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
|
||||
"X-Agent-Role": "qa",
|
||||
},
|
||||
)
|
||||
assert r.status_code == _HTTP_403
|
||||
assert "role" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_dev_route_accepts_developer_role() -> None:
|
||||
client = TestClient(_build_app())
|
||||
r = client.post(
|
||||
"/api/v2/flow/dev/give_me_work",
|
||||
json={},
|
||||
headers={
|
||||
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
|
||||
"X-Agent-Role": "developer",
|
||||
},
|
||||
)
|
||||
# Role gate passes through; mocked choreographer returns 200.
|
||||
assert r.status_code != _HTTP_403
|
||||
|
||||
|
||||
def test_dev_route_accepts_developer_role_case_insensitive() -> None:
|
||||
client = TestClient(_build_app())
|
||||
r = client.post(
|
||||
"/api/v2/flow/dev/give_me_work",
|
||||
json={},
|
||||
headers={
|
||||
"X-Agent-ID": "00000000-0000-0000-0000-000000000001",
|
||||
"X-Agent-Role": "DEVELOPER",
|
||||
},
|
||||
)
|
||||
assert r.status_code != _HTTP_403
|
||||
|
||||
|
||||
def test_dev_route_rejects_missing_role_header() -> None:
|
||||
client = TestClient(_build_app())
|
||||
r = client.post(
|
||||
"/api/v2/flow/dev/give_me_work",
|
||||
json={},
|
||||
headers={"X-Agent-ID": "00000000-0000-0000-0000-000000000001"},
|
||||
)
|
||||
# Missing X-Agent-Role => FastAPI 422 from header validation.
|
||||
# We just need it not to silently pass as 200.
|
||||
assert r.status_code != _HTTP_200
|
||||
Reference in New Issue
Block a user