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:
@@ -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"}))
|
||||||
@@ -9,10 +9,15 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
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.api.schemas.v2.flow import IAmIdleRequest, TriageRequest
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
from roboco.api.deps import get_choreographer
|
||||||
|
from roboco.api.routes.v2._role_dep import require_board
|
||||||
from roboco.api.schemas.v2.flow import (
|
from roboco.api.schemas.v2.flow import (
|
||||||
EscalateToCeoRequest,
|
EscalateToCeoRequest,
|
||||||
IAmIdleRequest,
|
IAmIdleRequest,
|
||||||
@@ -16,7 +17,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
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 (
|
from roboco.api.schemas.v2.flow import (
|
||||||
CompleteRequest,
|
CompleteRequest,
|
||||||
DelegateRequest,
|
DelegateRequest,
|
||||||
@@ -19,7 +20,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer, DelegateInputs
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
from roboco.api.deps import get_choreographer
|
||||||
|
from roboco.api.routes.v2._role_dep import require_dev
|
||||||
from roboco.api.schemas.v2.flow import (
|
from roboco.api.schemas.v2.flow import (
|
||||||
GiveMeWorkRequest,
|
GiveMeWorkRequest,
|
||||||
IAmBlockedRequest,
|
IAmBlockedRequest,
|
||||||
@@ -17,7 +18,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
from roboco.api.deps import get_choreographer
|
||||||
|
from roboco.api.routes.v2._role_dep import require_doc
|
||||||
from roboco.api.schemas.v2.flow import (
|
from roboco.api.schemas.v2.flow import (
|
||||||
ClaimDocTaskRequest,
|
ClaimDocTaskRequest,
|
||||||
GiveMeWorkRequest,
|
GiveMeWorkRequest,
|
||||||
@@ -14,7 +15,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
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 (
|
from roboco.api.schemas.v2.flow import (
|
||||||
CompleteRequest,
|
CompleteRequest,
|
||||||
DelegateRequest,
|
DelegateRequest,
|
||||||
@@ -19,7 +20,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer, DelegateInputs
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
from roboco.api.deps import get_choreographer
|
from roboco.api.deps import get_choreographer
|
||||||
|
from roboco.api.routes.v2._role_dep import require_qa
|
||||||
from roboco.api.schemas.v2.flow import (
|
from roboco.api.schemas.v2.flow import (
|
||||||
ClaimReviewRequest,
|
ClaimReviewRequest,
|
||||||
FailReviewRequest,
|
FailReviewRequest,
|
||||||
@@ -15,7 +16,11 @@ from roboco.api.schemas.v2.flow import (
|
|||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import Choreographer
|
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")]
|
_AgentIdHeader = Annotated[UUID, Header(alias="X-Agent-ID")]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ _HTTP_200 = 200
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
def _make_envelope(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
def _make_envelope(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
def _make_envelope(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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:
|
def _make_envelope(status: str = "ok", task_id: str | None = None) -> MagicMock:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
def _make_envelope(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
def _make_envelope(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ _HTTP_422 = 422
|
|||||||
|
|
||||||
_AGENT_ID = str(uuid4())
|
_AGENT_ID = str(uuid4())
|
||||||
_TASK_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(
|
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