[F022][F023][F024][F025][F026] api: scrub secrets from 422 log, gate a2a/dashboard/orchestrator routes, SSE session-per-query

- middleware: redact known credential fields (git_token/api_key/token/...)
  from the 422 request-validation log line; response body unchanged
- a2a: require_any_authenticated_agent on /message/send + /message/stream;
  subscribe_to_task opens a short-lived session per poll instead of holding
  one asyncpg connection for the full SSE lifetime (pool exhaustion) + auth
- dashboard: gate auditor flag/report mutating routes to Auditor or CEO
- orchestrator: router-level CEO gate on all control routes (spawn/stop/...)

TDD; ruff/mypy clean; 449 unit/api tests green; no type:ignore/noqa.
This commit is contained in:
Renn F
2026-06-28 17:45:35 +02:00
parent 4da0245dac
commit 0dcb195bbd
9 changed files with 1068 additions and 16 deletions
+190
View File
@@ -0,0 +1,190 @@
"""F023: POST /api/a2a/message/send and /message/stream must enforce the same
HMAC agent-token gate as the /api/v1/do/* router (F003).
Both routes previously took only ``request: SendMessageRequest, db: DbSession``
— no auth dependency. The sender was self-declared in the request body
(``metadata.from_agent``), so any caller could impersonate any agent and
inject A2A notifications that the orchestrator dispatcher picks up to spawn
target agents. The fix reuses F003's ``require_any_authenticated_agent``
(token-only, DB-free, no role assertion — the a2a router serves every role).
In dev (header-trust) mode a missing token is a no-op; a presented-but-forged
token is still rejected, exactly as the do router does.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.agents_config import issue_agent_token
from roboco.api.routes.a2a import router as a2a_router
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_SECRET = "test-secret-for-a2a-auth"
_AGENT_ID = "00000000-0000-0000-0000-000000000002"
_HTTP_200 = 200
_HTTP_400 = 400
_HTTP_401 = 401
def _message_body() -> dict:
"""A minimal valid SendMessageRequest body.
``message.task_id`` defaults to None, so the send route raises
TASK_ID_REQUIRED (400) AFTER the gate passes — proving the gate let the
request through without touching the DB. The stream route takes the
``else`` (new-task) branch and returns 200 with no DB access.
"""
return {"message": {"role": "user", "parts": [{"type": "text", "text": "x"}]}}
@pytest.fixture
async def a2a_client() -> AsyncIterator[AsyncClient]:
app = FastAPI()
app.include_router(a2a_router, prefix="/api/a2a")
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# /message/send
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_rejects_missing_token_when_required(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict mode + no X-Agent-Token => 401, never reaches the handler."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
r = await a2a_client.post(
"/api/a2a/message/send",
json=_message_body(),
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "developer"},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_send_rejects_forged_token_even_in_dev(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A presented-but-forged token is rejected even in header-trust mode."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
r = await a2a_client.post(
"/api/a2a/message/send",
json=_message_body(),
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": "forged-not-a-real-hmac",
},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_send_accepts_valid_token(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A valid token passes the gate; the route body then raises
TASK_ID_REQUIRED (400) because message.task_id is None — proving the
gate let the request through (401 would mean the gate rejected it)."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
token = issue_agent_token(_AGENT_ID, "developer")
r = await a2a_client.post(
"/api/a2a/message/send",
json=_message_body(),
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_400 # TASK_ID_REQUIRED — gate passed
@pytest.mark.asyncio
async def test_send_dev_mode_missing_token_still_succeeds_gate(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Dev mode + no token => no-op, route body runs (400 TASK_ID_REQUIRED).
Preserves the agent/panel flow in dev exactly as F003/F004 did."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
r = await a2a_client.post(
"/api/a2a/message/send",
json=_message_body(),
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "developer"},
)
assert r.status_code == _HTTP_400 # gate passed; route raised TASK_ID_REQUIRED
# ---------------------------------------------------------------------------
# /message/stream
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stream_rejects_missing_token_when_required(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict mode + no X-Agent-Token => 401 on the stream route too."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
r = await a2a_client.post(
"/api/a2a/message/stream",
json=_message_body(),
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "developer"},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_stream_rejects_forged_token_even_in_dev(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A presented-but-forged token is rejected even in header-trust mode."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
r = await a2a_client.post(
"/api/a2a/message/stream",
json=_message_body(),
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": "forged-not-a-real-hmac",
},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_stream_accepts_valid_token(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A valid token passes the gate; the stream route returns 200 (SSE) on
the new-task branch (message.task_id is None -> no DB access)."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
token = issue_agent_token(_AGENT_ID, "developer")
r = await a2a_client.post(
"/api/a2a/message/stream",
json=_message_body(),
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_200
+226
View File
@@ -0,0 +1,226 @@
"""F024: the SSE ``subscribe_to_task`` endpoint must (a) be authenticated
like the rest of the a2a message surface (F023) and (b) acquire a SHORT-LIVED
DB session per poll iteration instead of holding the request-scoped
``db: DbSession`` for the full SSE lifetime (up to 1 hour / 720 polls), which
exhausted the asyncpg pool one connection per connected client.
The fix mirrors F003's ``require_any_authenticated_agent`` for auth and uses
``get_session_factory()`` inside the generator so each poll opens, queries,
and closes its own session — no connection is held across ``asyncio.sleep``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.agents_config import issue_agent_token
from roboco.api.routes import a2a as a2a_module
from roboco.api.routes.a2a import router as a2a_router
from roboco.db.base import get_db
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from fastapi.routing import APIRoute
_SECRET = "test-secret-for-a2a-subscribe"
_AGENT_ID = "00000000-0000-0000-0000-000000000003"
_HTTP_200 = 200
_HTTP_401 = 401
_HTTP_404 = 404
@pytest.fixture
async def a2a_client() -> AsyncIterator[AsyncClient]:
app = FastAPI()
app.include_router(a2a_router, prefix="/api/a2a")
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Auth gate (F023 parity)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_subscribe_rejects_missing_token_when_required(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strict mode + no X-Agent-Token => 401, never reaches the generator."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
r = await a2a_client.get(
"/api/a2a/tasks/some-task/subscribe",
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "developer"},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_subscribe_rejects_forged_token_even_in_dev(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A presented-but-forged token is rejected even in header-trust mode."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
r = await a2a_client.get(
"/api/a2a/tasks/some-task/subscribe",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": "forged-not-a-real-hmac",
},
)
assert r.status_code == _HTTP_401
@pytest.mark.asyncio
async def test_subscribe_accepts_valid_token_then_404s_unknown_task(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A valid token passes the gate; the route then 404s on the initial
task-existence check (no DB seeded). 404 (not 401) proves the gate let
the request through."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
token = issue_agent_token(_AGENT_ID, "developer")
# get_task returns None -> 404. Patch A2AService.get_task to return None
# so the route doesn't need a real DB.
monkeypatch.setattr(a2a_module.A2AService, "get_task", AsyncMock(return_value=None))
r = await a2a_client.get(
"/api/a2a/tasks/some-task/subscribe",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_404
# ---------------------------------------------------------------------------
# Session-per-query: structural + behavioral
# ---------------------------------------------------------------------------
def test_subscribe_route_does_not_hold_request_scoped_db() -> None:
"""F024: the route must NOT depend on ``get_db`` — the request-scoped
session would be held for the full SSE lifetime (up to 1 hour). Each
poll must open its own short-lived session via ``get_session_factory``.
"""
subscribe_route = cast(
"APIRoute",
next(
r
for r in a2a_router.routes
if getattr(r, "path", "") == "/tasks/{task_id}/subscribe"
),
)
# Walk the route's dependency tree; get_db must not appear anywhere.
deps = [subscribe_route.dependant]
seen: set[int] = set()
found_get_db = False
while deps:
d = deps.pop()
if id(d) in seen:
continue
seen.add(id(d))
if d.call is get_db:
found_get_db = True
deps.extend(d.dependencies)
assert not found_get_db, (
"subscribe_to_task still depends on get_db — the request-scoped "
"session is held for the full SSE lifetime (pool-exhaustion vector)."
)
@pytest.mark.asyncio
async def test_subscribe_opens_a_short_lived_session_per_poll(
a2a_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""F024: each poll iteration opens its own session and closes it before
the next ``asyncio.sleep`` — never holding one connection across the full
SSE lifetime. We patch ``get_session_factory`` to count session opens,
patch ``A2AService.get_task`` to return a non-terminal task, patch
``asyncio.sleep`` to no-op, and make ``request.is_disconnected`` return
True after a few polls to terminate the stream quickly. The count of
session opens must exceed 1 (one per poll, not one for the lifetime)."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
token = issue_agent_token(_AGENT_ID, "developer")
# Count session opens across the SSE lifetime.
open_count = {"n": 0}
def _factory() -> Any:
open_count["n"] += 1
class _Ctx:
async def __aenter__(self) -> MagicMock:
return MagicMock()
async def __aexit__(self, *exc: object) -> None:
return None
return _Ctx()
monkeypatch.setattr(a2a_module, "get_session_factory", lambda: _factory)
# Non-terminal fake task so the loop keeps polling.
fake_task = MagicMock()
fake_task.status.state = "in_progress"
fake_task.model_dump_json = MagicMock(return_value="{}")
monkeypatch.setattr(
a2a_module.A2AService, "get_task", AsyncMock(return_value=fake_task)
)
# No sleeping — drain the generator as fast as possible.
monkeypatch.setattr(a2a_module.asyncio, "sleep", AsyncMock(return_value=None))
# Disconnect after 3 polls so the stream terminates.
disconnect_after = {"remaining": 3}
async def _fake_is_disconnected() -> bool:
if disconnect_after["remaining"] <= 0:
return True
disconnect_after["remaining"] -= 1
return False
# The route reads request.is_disconnected(); patch it on the request via
# the Starlette request. We patch the Request.is_disconnected property.
monkeypatch.setattr(
"fastapi.Request.is_disconnected",
lambda _self: _fake_is_disconnected(),
)
r = await a2a_client.get(
"/api/a2a/tasks/some-task/subscribe",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "developer",
"X-Agent-Token": token,
},
)
# Drain the SSE stream so the generator runs to completion.
assert r.status_code == _HTTP_200
# Consume the body (the SSE stream finishes once is_disconnected returns
# True on the 4th check).
_ = await r.aread()
# 3 polls + 1 initial validation = 4 session opens (one per query, none
# held across the lifetime). The key assertion: more than one session
# was opened — proving the request-scoped session is gone.
assert open_count["n"] > 1, (
f"only {open_count['n']} session open(s) — the route is holding a "
"single request-scoped session for the full SSE lifetime (pool "
"exhaustion vector)."
)
@@ -0,0 +1,200 @@
"""F025: dashboard auditor flag/report mutating routes must be gated to the
Auditor or CEO.
``create_auditor_flag`` / ``resolve_auditor_flag`` / ``create_auditor_report``
/ ``send_auditor_report`` previously took only ``db: DbSession`` — no
``CurrentAgentContext``, no role check — so any unauthenticated caller could
create/resolve flags and mark reports as sent to the CEO. The fix mirrors
``roboco/api/routes/playbooks.py::_require_curator``: a ``CurrentAgentContext``
dependency plus a coarse role gate that admits only ``AUDITOR`` and ``CEO``.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.dashboard import router as dashboard_router
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.dashboard import reset_storage
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
def _override_agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
@pytest_asyncio.fixture
async def auditor_client(
db_session: AsyncSession,
) -> AsyncIterator[AsyncClient]:
"""A client authenticated as the Auditor (the legitimate caller)."""
reset_storage()
app = FastAPI()
app.include_router(dashboard_router, prefix="/api/dashboard")
async def _override_db() -> AsyncGenerator[AsyncSession]:
yield db_session
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = lambda: _override_agent(
AgentRole.AUDITOR
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def dev_client(
db_session: AsyncSession,
) -> AsyncIterator[AsyncClient]:
"""A client authenticated as a Developer — must NOT be able to mutate
auditor flags/reports."""
reset_storage()
app = FastAPI()
app.include_router(dashboard_router, prefix="/api/dashboard")
async def _override_db() -> AsyncGenerator[AsyncSession]:
yield db_session
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = lambda: _override_agent(
AgentRole.DEVELOPER
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Legitimate caller (Auditor) succeeds
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_auditor_can_create_flag(auditor_client: AsyncClient) -> None:
response = await auditor_client.post(
"/api/dashboard/auditor/flags",
json={
"severity": "warning",
"category": "quality",
"title": "Flag",
"description": "x",
},
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_auditor_can_create_report(auditor_client: AsyncClient) -> None:
response = await auditor_client.post(
"/api/dashboard/auditor/reports",
json={
"report_type": "weekly",
"title": "T",
"summary": "s",
"sections": [],
},
)
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_auditor_can_send_report(auditor_client: AsyncClient) -> None:
create = await auditor_client.post(
"/api/dashboard/auditor/reports",
json={
"report_type": "weekly",
"title": "T",
"summary": "s",
"sections": [],
},
)
rid = create.json()["id"]
response = await auditor_client.post(f"/api/dashboard/auditor/reports/{rid}/send")
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_auditor_can_resolve_flag(auditor_client: AsyncClient) -> None:
create = await auditor_client.post(
"/api/dashboard/auditor/flags",
json={
"severity": "warning",
"category": "quality",
"title": "F",
"description": "x",
},
)
flag_id = create.json()["id"]
response = await auditor_client.put(
f"/api/dashboard/auditor/flags/{flag_id}/resolve",
params={"notes": "fixed"},
)
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Forged caller (Developer) is rejected with 403
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_developer_cannot_create_flag(dev_client: AsyncClient) -> None:
response = await dev_client.post(
"/api/dashboard/auditor/flags",
json={
"severity": "warning",
"category": "quality",
"title": "F",
"description": "x",
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_developer_cannot_resolve_flag(dev_client: AsyncClient) -> None:
# The role gate fires before the route checks flag existence, so a random
# UUID is enough to prove the dev is rejected at the gate.
response = await dev_client.put(
f"/api/dashboard/auditor/flags/{uuid4()}/resolve",
params={"notes": "fixed"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_developer_cannot_create_report(dev_client: AsyncClient) -> None:
response = await dev_client.post(
"/api/dashboard/auditor/reports",
json={
"report_type": "weekly",
"title": "T",
"summary": "s",
"sections": [],
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_developer_cannot_send_report(dev_client: AsyncClient) -> None:
response = await dev_client.post(
f"/api/dashboard/auditor/reports/{uuid4()}/send",
)
assert response.status_code == HTTPStatus.FORBIDDEN
+105
View File
@@ -38,6 +38,7 @@ from roboco.services.base import (
from roboco.services.base import (
ValidationError as ServiceValidationError,
)
from structlog.testing import capture_logs
# ---------------------------------------------------------------------------
# get_status_code
@@ -275,3 +276,107 @@ def test_request_validation_handler_returns_422_with_details() -> None:
body = response.json()
assert "detail" in body
assert "body" in body
# ---------------------------------------------------------------------------
# F022: secret scrubbing in the 422 log line
# ---------------------------------------------------------------------------
class _SecretBody(BaseModel):
"""Module-level model so FastAPI can resolve the annotation under
`from __future__ import annotations` (function-local classes with complex
field types aren't resolvable from the function's module globals)."""
name: str
git_token: str | None = None
api_key: str | None = None
auth_token: str | None = None
nested: dict[str, Any] | None = None
def test_request_validation_handler_scrubs_secrets_from_log() -> None:
"""F022: a 422 on a secret-bearing request must not dump the plaintext
secret into the log line — only the redacted placeholder. The 422
response body is unchanged (the client sent those values; the server
only redacts its own log)."""
app = FastAPI()
setup_middleware(app)
@app.post("/project")
async def _create(_data: _SecretBody) -> Any:
return {"ok": True}
secret_pat = "ghp_livesecret_123456"
secret_key = "ollama-key-do-not-log"
secret_token = "bearer-should-not-leak"
payload = {
# Missing required `name` -> 422, but the secret fields are still
# parsed into rve.body and would be logged verbatim without the scrub.
"git_token": secret_pat,
"api_key": secret_key,
"auth_token": secret_token,
"nested": {"git_token": "nested-secret-abc", "safe": "keep"},
}
client = TestClient(app, raise_server_exceptions=False)
with capture_logs() as logs:
response = client.post("/project", json=payload)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# The response body is NOT scrubbed (the client sent these values).
resp_body = response.json()
assert resp_body["body"]["git_token"] == secret_pat
assert resp_body["body"]["api_key"] == secret_key
# Exactly one "Request validation failed" warning was emitted.
fails = [e for e in logs if e["event"] == "Request validation failed"]
assert len(fails) == 1
logged_body = fails[0]["body"]
# The log line must not contain any of the plaintext secrets.
assert secret_pat not in str(logged_body)
assert secret_key not in str(logged_body)
assert secret_token not in str(logged_body)
assert "nested-secret-abc" not in str(logged_body)
# The redaction placeholder appears for each secret field (so ops can see
# WHICH secret field was present), and the per-field errors are still
# logged (they don't carry secrets).
assert logged_body["git_token"] == "***REDACTED***"
assert logged_body["api_key"] == "***REDACTED***"
assert logged_body["auth_token"] == "***REDACTED***"
assert logged_body["nested"]["git_token"] == "***REDACTED***"
assert logged_body["nested"]["safe"] == "keep" # non-secret preserved
assert "errors" in fails[0]
def test_request_validation_handler_log_preserves_non_secret_fields() -> None:
"""F022: non-secret fields in the body are still logged in full — only
the known credential-looking field names are redacted."""
app = FastAPI()
setup_middleware(app)
@app.post("/project")
async def _create(_data: _SecretBody) -> Any:
return {"ok": True}
client = TestClient(app, raise_server_exceptions=False)
# `title` is not a field on _SecretBody -> 422, and `title` is non-secret
# so it should still appear in the log; `git_token` is secret and must be
# redacted.
with capture_logs() as logs:
response = client.post(
"/project",
json={"title": "visible-title", "git_token": "ghp_secret_xyz"},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
fails = [e for e in logs if e["event"] == "Request validation failed"]
assert len(fails) == 1
logged_body = fails[0]["body"]
assert logged_body["title"] == "visible-title" # non-secret preserved
assert logged_body["git_token"] == "***REDACTED***" # secret redacted
assert "ghp_secret_xyz" not in str(logged_body)
+204
View File
@@ -0,0 +1,204 @@
"""F026: orchestrator control routes (/api/orchestrator/*) must be gated to
the CEO/operator identity.
``spawn_agent`` / ``stop_agent`` / ``resolve_wait`` / ``mark_waiting`` previously
took no auth dependency at all — any client that could reach the API could
spawn, stop, mark-waiting, or resolve-wait any agent. The fix mirrors the
F004 panel-token guard (DB-free): bind the presented ``X-Agent-ID`` to a
verified HMAC token and assert the role is CEO. In dev (header-trust) mode a
missing token is a no-op (the panel/operator flow keeps working), but a
presented-but-forged token is still rejected — same contract as the v1 flow
role guards and the do router (F003).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.agents_config import issue_agent_token
from roboco.api.deps import _ServiceHolder, set_orchestrator
from roboco.api.routes.orchestrator import router as orch_router
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_SECRET = "test-secret-for-orch-auth"
_AGENT_ID = "00000000-0000-0000-0000-000000000001"
_HTTP_201 = 201
_HTTP_204 = 204
_HTTP_401 = 401
_HTTP_403 = 403
def _mock_orchestrator() -> MagicMock:
orch = MagicMock()
orch.spawn_agent = AsyncMock(
return_value=MagicMock(
agent_id=_AGENT_ID,
state=MagicMock(value="starting"),
current_task_id=None,
error_count=0,
started_at=None,
waiting_for=None,
)
)
orch.stop_agent = AsyncMock(return_value=None)
return orch
@pytest_asyncio.fixture
async def orch_client() -> AsyncIterator[tuple[AsyncClient, MagicMock]]:
app = FastAPI()
app.include_router(orch_router, prefix="/api/orchestrator")
orch = _mock_orchestrator()
set_orchestrator(orch)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
yield client, orch
_ServiceHolder.orchestrator = None
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Strict mode: token required
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_rejects_missing_token_when_required(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Strict mode + no X-Agent-Token => 401, never reaches the orchestrator."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
client, orch = orch_client
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "ceo"},
)
assert r.status_code == _HTTP_401
orch.spawn_agent.assert_not_awaited()
# ---------------------------------------------------------------------------
# Dev mode: forged token rejected, missing token is a no-op
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_rejects_forged_token_even_in_dev(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A presented-but-forged token is rejected even in header-trust mode."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
client, orch = orch_client
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "ceo",
"X-Agent-Token": "forged-not-a-real-hmac",
},
)
assert r.status_code == _HTTP_401
orch.spawn_agent.assert_not_awaited()
@pytest.mark.asyncio
async def test_spawn_rejects_non_ceo_role(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A developer (even with a validly-issued token) must not spawn/stop agents."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
client, orch = orch_client
dev_id = str(uuid4())
token = issue_agent_token(dev_id, "developer")
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
headers={
"X-Agent-ID": dev_id,
"X-Agent-Role": "developer",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_403
orch.spawn_agent.assert_not_awaited()
# ---------------------------------------------------------------------------
# Legitimate CEO caller succeeds
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_accepts_valid_ceo_token(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A valid CEO token passes the gate and reaches the orchestrator."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
client, orch = orch_client
token = issue_agent_token(_AGENT_ID, "ceo")
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "ceo",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_201
orch.spawn_agent.assert_awaited_once()
@pytest.mark.asyncio
async def test_stop_accepts_valid_ceo_token(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The gate is wired into stop_agent too."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
client, orch = orch_client
token = issue_agent_token(_AGENT_ID, "ceo")
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/stop",
headers={
"X-Agent-ID": _AGENT_ID,
"X-Agent-Role": "ceo",
"X-Agent-Token": token,
},
)
assert r.status_code == _HTTP_204
orch.stop_agent.assert_awaited_once()
@pytest.mark.asyncio
async def test_dev_mode_missing_token_still_succeeds(
orch_client: tuple[AsyncClient, MagicMock],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Dev mode (no ROBOCO_AGENT_AUTH_REQUIRED) + no token => no-op, route runs.
Preserves the panel/operator flow in dev exactly as F003/F004 did."""
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
client, orch = orch_client
r = await client.post(
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "ceo"},
)
assert r.status_code == _HTTP_201
orch.spawn_agent.assert_awaited_once()