From 92294f90fb2402022dc11c9f37ae1747100e888e Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 3 May 2026 05:27:31 +0200 Subject: [PATCH] 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. --- roboco/api/routes/v2/_role_dep.py | 34 ++++++++ roboco/api/routes/v2/flow_auditor.py | 7 +- roboco/api/routes/v2/flow_board.py | 7 +- roboco/api/routes/v2/flow_cell_pm.py | 7 +- roboco/api/routes/v2/flow_dev.py | 7 +- roboco/api/routes/v2/flow_doc.py | 7 +- roboco/api/routes/v2/flow_main_pm.py | 7 +- roboco/api/routes/v2/flow_qa.py | 7 +- tests/unit/api/routes/v2/test_flow_auditor.py | 2 +- tests/unit/api/routes/v2/test_flow_board.py | 2 +- tests/unit/api/routes/v2/test_flow_cell_pm.py | 2 +- tests/unit/api/routes/v2/test_flow_dev.py | 2 +- tests/unit/api/routes/v2/test_flow_doc.py | 2 +- tests/unit/api/routes/v2/test_flow_main_pm.py | 2 +- tests/unit/api/routes/v2/test_flow_qa.py | 2 +- tests/unit/api/test_v2_role_dep.py | 84 +++++++++++++++++++ 16 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 roboco/api/routes/v2/_role_dep.py create mode 100644 tests/unit/api/test_v2_role_dep.py diff --git a/roboco/api/routes/v2/_role_dep.py b/roboco/api/routes/v2/_role_dep.py new file mode 100644 index 00000000..d9fe091c --- /dev/null +++ b/roboco/api/routes/v2/_role_dep.py @@ -0,0 +1,34 @@ +"""Role-asserting dependencies for v2 flow routers. + +Every router gets one of these as a dependency so the role check happens +before the choreographer body even runs. Defense in depth — the +choreographer also re-checks role internally for verbs that branch on it. +""" + +from __future__ import annotations + +from typing import Annotated, cast + +from fastapi import Depends, Header, HTTPException, params, status + + +def _require_roles(allowed: frozenset[str]) -> params.Depends: + def _check( + x_agent_role: Annotated[str, Header(alias="X-Agent-Role")], + ) -> None: + if x_agent_role.lower() not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"role '{x_agent_role}' not allowed for this endpoint group", + ) + + return cast("params.Depends", Depends(_check)) + + +require_dev = _require_roles(frozenset({"developer"})) +require_qa = _require_roles(frozenset({"qa"})) +require_doc = _require_roles(frozenset({"documenter"})) +require_cell_pm = _require_roles(frozenset({"cell_pm"})) +require_main_pm = _require_roles(frozenset({"main_pm"})) +require_board = _require_roles(frozenset({"product_owner", "head_marketing"})) +require_auditor = _require_roles(frozenset({"auditor"})) diff --git a/roboco/api/routes/v2/flow_auditor.py b/roboco/api/routes/v2/flow_auditor.py index 7d880c9c..98a61080 100644 --- a/roboco/api/routes/v2/flow_auditor.py +++ b/roboco/api/routes/v2/flow_auditor.py @@ -9,10 +9,15 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_auditor from roboco.api.schemas.v2.flow import IAmIdleRequest, TriageRequest from roboco.services.gateway.choreographer import Choreographer -router = APIRouter(prefix="/api/v2/flow/auditor", tags=["v2-flow-auditor"]) +router = APIRouter( + prefix="/api/v2/flow/auditor", + tags=["v2-flow-auditor"], + dependencies=[require_auditor], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_board.py b/roboco/api/routes/v2/flow_board.py index b2579d02..db2e0350 100644 --- a/roboco/api/routes/v2/flow_board.py +++ b/roboco/api/routes/v2/flow_board.py @@ -9,6 +9,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_board from roboco.api.schemas.v2.flow import ( EscalateToCeoRequest, IAmIdleRequest, @@ -16,7 +17,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer -router = APIRouter(prefix="/api/v2/flow/board", tags=["v2-flow-board"]) +router = APIRouter( + prefix="/api/v2/flow/board", + tags=["v2-flow-board"], + dependencies=[require_board], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_cell_pm.py b/roboco/api/routes/v2/flow_cell_pm.py index d0748b71..8757afd5 100644 --- a/roboco/api/routes/v2/flow_cell_pm.py +++ b/roboco/api/routes/v2/flow_cell_pm.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_cell_pm from roboco.api.schemas.v2.flow import ( CompleteRequest, DelegateRequest, @@ -19,7 +20,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer, DelegateInputs -router = APIRouter(prefix="/api/v2/flow/cell_pm", tags=["v2-flow-cell-pm"]) +router = APIRouter( + prefix="/api/v2/flow/cell_pm", + tags=["v2-flow-cell-pm"], + dependencies=[require_cell_pm], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_dev.py b/roboco/api/routes/v2/flow_dev.py index 75e04a35..7ab49964 100644 --- a/roboco/api/routes/v2/flow_dev.py +++ b/roboco/api/routes/v2/flow_dev.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_dev from roboco.api.schemas.v2.flow import ( GiveMeWorkRequest, IAmBlockedRequest, @@ -17,7 +18,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer -router = APIRouter(prefix="/api/v2/flow/dev", tags=["v2-flow-dev"]) +router = APIRouter( + prefix="/api/v2/flow/dev", + tags=["v2-flow-dev"], + dependencies=[require_dev], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_doc.py b/roboco/api/routes/v2/flow_doc.py index f396e6db..cf45c642 100644 --- a/roboco/api/routes/v2/flow_doc.py +++ b/roboco/api/routes/v2/flow_doc.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_doc from roboco.api.schemas.v2.flow import ( ClaimDocTaskRequest, GiveMeWorkRequest, @@ -14,7 +15,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer -router = APIRouter(prefix="/api/v2/flow/documenter", tags=["v2-flow-documenter"]) +router = APIRouter( + prefix="/api/v2/flow/documenter", + tags=["v2-flow-documenter"], + dependencies=[require_doc], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_main_pm.py b/roboco/api/routes/v2/flow_main_pm.py index 25d29b09..eaf15003 100644 --- a/roboco/api/routes/v2/flow_main_pm.py +++ b/roboco/api/routes/v2/flow_main_pm.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_main_pm from roboco.api.schemas.v2.flow import ( CompleteRequest, DelegateRequest, @@ -19,7 +20,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer, DelegateInputs -router = APIRouter(prefix="/api/v2/flow/main_pm", tags=["v2-flow-main-pm"]) +router = APIRouter( + prefix="/api/v2/flow/main_pm", + tags=["v2-flow-main-pm"], + dependencies=[require_main_pm], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/roboco/api/routes/v2/flow_qa.py b/roboco/api/routes/v2/flow_qa.py index 24988bdc..01b56562 100644 --- a/roboco/api/routes/v2/flow_qa.py +++ b/roboco/api/routes/v2/flow_qa.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header from roboco.api.deps import get_choreographer +from roboco.api.routes.v2._role_dep import require_qa from roboco.api.schemas.v2.flow import ( ClaimReviewRequest, FailReviewRequest, @@ -15,7 +16,11 @@ from roboco.api.schemas.v2.flow import ( ) from roboco.services.gateway.choreographer import Choreographer -router = APIRouter(prefix="/api/v2/flow/qa", tags=["v2-flow-qa"]) +router = APIRouter( + prefix="/api/v2/flow/qa", + tags=["v2-flow-qa"], + dependencies=[require_qa], +) _AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")] diff --git a/tests/unit/api/routes/v2/test_flow_auditor.py b/tests/unit/api/routes/v2/test_flow_auditor.py index d32df35b..8233ec9a 100644 --- a/tests/unit/api/routes/v2/test_flow_auditor.py +++ b/tests/unit/api/routes/v2/test_flow_auditor.py @@ -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( diff --git a/tests/unit/api/routes/v2/test_flow_board.py b/tests/unit/api/routes/v2/test_flow_board.py index f1dda6b0..e0ffdef9 100644 --- a/tests/unit/api/routes/v2/test_flow_board.py +++ b/tests/unit/api/routes/v2/test_flow_board.py @@ -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( diff --git a/tests/unit/api/routes/v2/test_flow_cell_pm.py b/tests/unit/api/routes/v2/test_flow_cell_pm.py index 6e6e80d2..a9bcc20d 100644 --- a/tests/unit/api/routes/v2/test_flow_cell_pm.py +++ b/tests/unit/api/routes/v2/test_flow_cell_pm.py @@ -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( diff --git a/tests/unit/api/routes/v2/test_flow_dev.py b/tests/unit/api/routes/v2/test_flow_dev.py index e916b39c..b2f55d7a 100644 --- a/tests/unit/api/routes/v2/test_flow_dev.py +++ b/tests/unit/api/routes/v2/test_flow_dev.py @@ -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: diff --git a/tests/unit/api/routes/v2/test_flow_doc.py b/tests/unit/api/routes/v2/test_flow_doc.py index acd5375c..8c9eb174 100644 --- a/tests/unit/api/routes/v2/test_flow_doc.py +++ b/tests/unit/api/routes/v2/test_flow_doc.py @@ -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( diff --git a/tests/unit/api/routes/v2/test_flow_main_pm.py b/tests/unit/api/routes/v2/test_flow_main_pm.py index 727904ef..6b616036 100644 --- a/tests/unit/api/routes/v2/test_flow_main_pm.py +++ b/tests/unit/api/routes/v2/test_flow_main_pm.py @@ -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( diff --git a/tests/unit/api/routes/v2/test_flow_qa.py b/tests/unit/api/routes/v2/test_flow_qa.py index 97cff202..494f29cb 100644 --- a/tests/unit/api/routes/v2/test_flow_qa.py +++ b/tests/unit/api/routes/v2/test_flow_qa.py @@ -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( diff --git a/tests/unit/api/test_v2_role_dep.py b/tests/unit/api/test_v2_role_dep.py new file mode 100644 index 00000000..a2c67b60 --- /dev/null +++ b/tests/unit/api/test_v2_role_dep.py @@ -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