mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -363,6 +363,47 @@ def _uuid_field_remediation(errors: Sequence[Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Credential-bearing request fields. A 422 on a secret-bearing request
|
||||||
|
# would otherwise dump the plaintext GitHub PAT / provider API key / bearer
|
||||||
|
# token into structlog output before the route ever encrypts it. The per-field
|
||||||
|
# ``errors`` carry only field names and types (never values), so they stay
|
||||||
|
# logged unchanged. Match by exact key name so a renamed secret field is
|
||||||
|
# caught by the next audit pass rather than silently leaking.
|
||||||
|
_SECRET_FIELD_NAMES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"git_token",
|
||||||
|
"api_key",
|
||||||
|
"auth_token",
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"client_secret",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_REDACTED = "***REDACTED***"
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_secrets(value: Any) -> Any:
|
||||||
|
"""Return a deep copy of ``value`` with known secret fields redacted.
|
||||||
|
|
||||||
|
Recurses into nested dicts and lists so a secret inside ``nested: {...}``
|
||||||
|
or a list element is also scrubbed. Non-secret fields are preserved so ops
|
||||||
|
can still see which field broke. The original ``rve.body`` is not mutated
|
||||||
|
(the 422 response body echoes the client's own submission unscrubbed).
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
k: (_REDACTED if k in _SECRET_FIELD_NAMES else _scrub_secrets(v))
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_scrub_secrets(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def request_validation_handler(request: Request, exc: Exception) -> JSONResponse:
|
async def request_validation_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""Log the rejected body before returning the standard 422 response.
|
"""Log the rejected body before returning the standard 422 response.
|
||||||
|
|
||||||
@@ -373,15 +414,21 @@ async def request_validation_handler(request: Request, exc: Exception) -> JSONRe
|
|||||||
|
|
||||||
When the failure is a truncated ``task_id`` (the recurring agent mistake),
|
When the failure is a truncated ``task_id`` (the recurring agent mistake),
|
||||||
add a ``remediate`` hint so the agent knows to retry with the full UUID.
|
add a ``remediate`` hint so the agent knows to retry with the full UUID.
|
||||||
|
|
||||||
|
F022: the log line scrubs known credential-bearing fields
|
||||||
|
(``git_token`` / ``api_key`` / ``auth_token`` / …) from the body before
|
||||||
|
logging. The 422 *response* body is unchanged — the client sent those
|
||||||
|
values, only the server's own log is redacted.
|
||||||
"""
|
"""
|
||||||
rve = cast("RequestValidationError", exc)
|
rve = cast("RequestValidationError", exc)
|
||||||
body = rve.body if isinstance(rve.body, str | bytes | dict | list) else None
|
body = rve.body if isinstance(rve.body, str | bytes | dict | list) else None
|
||||||
errors = rve.errors()
|
errors = rve.errors()
|
||||||
|
body_for_log = _scrub_secrets(body) if isinstance(body, dict | list) else body
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Request validation failed",
|
"Request validation failed",
|
||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
method=request.method,
|
method=request.method,
|
||||||
body=body,
|
body=body_for_log,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
content: dict[str, Any] = {"detail": errors, "body": body}
|
content: dict[str, Any] = {"detail": errors, "body": body}
|
||||||
|
|||||||
+31
-11
@@ -24,6 +24,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from sse_starlette import EventSourceResponse
|
from sse_starlette import EventSourceResponse
|
||||||
|
|
||||||
from roboco.api.deps import CurrentAgentSlug, DbSession
|
from roboco.api.deps import CurrentAgentSlug, DbSession
|
||||||
|
from roboco.api.routes.v1._role_dep import require_any_authenticated_agent
|
||||||
from roboco.api.schemas.a2a_chat import (
|
from roboco.api.schemas.a2a_chat import (
|
||||||
ConversationCloseRequest,
|
ConversationCloseRequest,
|
||||||
ConversationCreateRequest,
|
ConversationCreateRequest,
|
||||||
@@ -39,6 +40,7 @@ from roboco.api.schemas.a2a_chat import (
|
|||||||
PairListResponse,
|
PairListResponse,
|
||||||
PairResponse,
|
PairResponse,
|
||||||
)
|
)
|
||||||
|
from roboco.db.base import get_session_factory
|
||||||
from roboco.enforcement import A2AAccessDeniedError
|
from roboco.enforcement import A2AAccessDeniedError
|
||||||
from roboco.models.a2a import (
|
from roboco.models.a2a import (
|
||||||
A2AConversationStatus,
|
A2AConversationStatus,
|
||||||
@@ -107,7 +109,10 @@ async def get_agent_card(
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.post("/message/send")
|
@router.post(
|
||||||
|
"/message/send",
|
||||||
|
dependencies=[require_any_authenticated_agent],
|
||||||
|
)
|
||||||
async def send_message(
|
async def send_message(
|
||||||
request: SendMessageRequest,
|
request: SendMessageRequest,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
@@ -187,7 +192,10 @@ async def send_message(
|
|||||||
return {"status": "success", "a2a_request": result}
|
return {"status": "success", "a2a_request": result}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/message/stream")
|
@router.post(
|
||||||
|
"/message/stream",
|
||||||
|
dependencies=[require_any_authenticated_agent],
|
||||||
|
)
|
||||||
async def send_message_stream(
|
async def send_message_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
body: SendMessageRequest,
|
body: SendMessageRequest,
|
||||||
@@ -275,22 +283,31 @@ async def send_message_stream(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/subscribe")
|
@router.get(
|
||||||
|
"/tasks/{task_id}/subscribe",
|
||||||
|
dependencies=[require_any_authenticated_agent],
|
||||||
|
)
|
||||||
async def subscribe_to_task(
|
async def subscribe_to_task(
|
||||||
request: Request,
|
request: Request,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
db: DbSession,
|
|
||||||
) -> EventSourceResponse:
|
) -> EventSourceResponse:
|
||||||
"""
|
"""
|
||||||
Subscribe to task updates via SSE.
|
Subscribe to task updates via SSE.
|
||||||
|
|
||||||
Opens a persistent connection that streams task state changes
|
Opens a persistent connection that streams task state changes
|
||||||
until the task reaches a terminal state or client disconnects.
|
until the task reaches a terminal state or client disconnects.
|
||||||
"""
|
|
||||||
service = A2AService(db)
|
|
||||||
|
|
||||||
# Validate task exists
|
F024: each poll opens a SHORT-LIVED session via ``get_session_factory``
|
||||||
a2a_task = await service.get_task(task_id)
|
and closes it before the next ``asyncio.sleep`` — never holding one
|
||||||
|
asyncpg connection across the full SSE lifetime (up to 1 hour / 720
|
||||||
|
polls), which previously exhausted the pool one connection per connected
|
||||||
|
client. The route takes no ``db: DbSession`` for the same reason.
|
||||||
|
"""
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
|
||||||
|
# Validate task exists with a short-lived session (released immediately).
|
||||||
|
async with session_factory() as session:
|
||||||
|
a2a_task = await A2AService(session).get_task(task_id)
|
||||||
if a2a_task is None:
|
if a2a_task is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -298,7 +315,7 @@ async def subscribe_to_task(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def generate_updates() -> AsyncGenerator[dict[str, Any]]:
|
async def generate_updates() -> AsyncGenerator[dict[str, Any]]:
|
||||||
"""Stream task updates."""
|
"""Stream task updates — one short-lived session per poll."""
|
||||||
poll_count = 0
|
poll_count = 0
|
||||||
max_polls = 720 # 1 hour at 5s interval
|
max_polls = 720 # 1 hour at 5s interval
|
||||||
last_state = None
|
last_state = None
|
||||||
@@ -307,8 +324,11 @@ async def subscribe_to_task(
|
|||||||
if await request.is_disconnected():
|
if await request.is_disconnected():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Refresh task state from DB
|
# F024: refresh task state from a per-poll session that is
|
||||||
task = await service.get_task(task_id)
|
# released before the sleep below — never held across the poll
|
||||||
|
# interval, so the asyncpg pool is free between queries.
|
||||||
|
async with session_factory() as session:
|
||||||
|
task = await A2AService(session).get_task(task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
|
||||||
from roboco.api.deps import DbSession
|
from roboco.api.deps import CurrentAgentContext, DbSession
|
||||||
from roboco.api.schemas.dashboard import (
|
from roboco.api.schemas.dashboard import (
|
||||||
AuditorDashboard,
|
AuditorDashboard,
|
||||||
AuditorFlag,
|
AuditorFlag,
|
||||||
@@ -23,6 +23,7 @@ from roboco.api.schemas.dashboard import (
|
|||||||
TeamHealth,
|
TeamHealth,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
)
|
)
|
||||||
|
from roboco.models import AgentRole
|
||||||
from roboco.models.base import Team
|
from roboco.models.base import Team
|
||||||
from roboco.models.dashboard import CreateFlagParams
|
from roboco.models.dashboard import CreateFlagParams
|
||||||
from roboco.services.dashboard import get_dashboard_service
|
from roboco.services.dashboard import get_dashboard_service
|
||||||
@@ -32,6 +33,22 @@ from roboco.services.usage import get_usage_service
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# The auditor flag/report mutating routes are gated to the Auditor and the
|
||||||
|
# CEO. The Auditor is the silent-observer role whose flags/reports feed the
|
||||||
|
# CEO; the CEO overrides. Mirrors ``_require_curator`` in playbooks.py and
|
||||||
|
# ``_require_ceo`` in release.py. Read-only auditor views (``GET
|
||||||
|
# /auditor/flags``, ``GET /auditor/reports``, ``GET /auditor``) stay open —
|
||||||
|
# the dashboard is observable by any authenticated operator.
|
||||||
|
_AUDITOR_OR_CEO_ROLES = frozenset({AgentRole.AUDITOR, AgentRole.CEO})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auditor_or_ceo(agent: CurrentAgentContext) -> None:
|
||||||
|
if agent.role not in _AUDITOR_OR_CEO_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the Auditor or CEO may mutate auditor flags or reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUDITOR DASHBOARD
|
# AUDITOR DASHBOARD
|
||||||
@@ -154,8 +171,10 @@ async def get_auditor_flags(
|
|||||||
async def create_auditor_flag(
|
async def create_auditor_flag(
|
||||||
data: CreateFlagRequest,
|
data: CreateFlagRequest,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
) -> AuditorFlag:
|
) -> AuditorFlag:
|
||||||
"""Create a new auditor flag."""
|
"""Create a new auditor flag."""
|
||||||
|
_require_auditor_or_ceo(agent)
|
||||||
service = get_dashboard_service(db)
|
service = get_dashboard_service(db)
|
||||||
params = CreateFlagParams(
|
params = CreateFlagParams(
|
||||||
severity=data.severity.value,
|
severity=data.severity.value,
|
||||||
@@ -184,9 +203,11 @@ async def create_auditor_flag(
|
|||||||
async def resolve_auditor_flag(
|
async def resolve_auditor_flag(
|
||||||
flag_id: UUID,
|
flag_id: UUID,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
notes: str | None = None,
|
notes: str | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Resolve an auditor flag."""
|
"""Resolve an auditor flag."""
|
||||||
|
_require_auditor_or_ceo(agent)
|
||||||
service = get_dashboard_service(db)
|
service = get_dashboard_service(db)
|
||||||
if not service.resolve_flag(flag_id, notes):
|
if not service.resolve_flag(flag_id, notes):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -226,8 +247,10 @@ async def get_auditor_reports(
|
|||||||
async def create_auditor_report(
|
async def create_auditor_report(
|
||||||
data: CreateReportRequest,
|
data: CreateReportRequest,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
) -> AuditorReport:
|
) -> AuditorReport:
|
||||||
"""Create a new auditor report."""
|
"""Create a new auditor report."""
|
||||||
|
_require_auditor_or_ceo(agent)
|
||||||
service = get_dashboard_service(db)
|
service = get_dashboard_service(db)
|
||||||
report = service.create_report(
|
report = service.create_report(
|
||||||
report_type=data.report_type,
|
report_type=data.report_type,
|
||||||
@@ -250,8 +273,10 @@ async def create_auditor_report(
|
|||||||
async def send_auditor_report(
|
async def send_auditor_report(
|
||||||
report_id: UUID,
|
report_id: UUID,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Mark a report as sent to CEO."""
|
"""Mark a report as sent to CEO."""
|
||||||
|
_require_auditor_or_ceo(agent)
|
||||||
service = get_dashboard_service(db)
|
service = get_dashboard_service(db)
|
||||||
if not service.send_report(report_id):
|
if not service.send_report(report_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ API endpoints for managing the Agent Orchestrator.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
|
|
||||||
from roboco.api.deps import get_orchestrator, set_orchestrator
|
from roboco.api.deps import _check_agent_auth_token, get_orchestrator, set_orchestrator
|
||||||
from roboco.api.schemas.orchestrator import (
|
from roboco.api.schemas.orchestrator import (
|
||||||
AgentStatusResponse,
|
AgentStatusResponse,
|
||||||
OrchestratorStatusResponse,
|
OrchestratorStatusResponse,
|
||||||
@@ -16,8 +17,42 @@ from roboco.api.schemas.orchestrator import (
|
|||||||
SpawnAgentRequest,
|
SpawnAgentRequest,
|
||||||
WaitingAgentResponse,
|
WaitingAgentResponse,
|
||||||
)
|
)
|
||||||
|
from roboco.foundation.identity import Role
|
||||||
|
|
||||||
router = APIRouter()
|
# Orchestrator control routes (spawn / stop / resolve-wait / mark-waiting,
|
||||||
|
# plus the read-only status views) are operator/CEO control surfaces — any
|
||||||
|
# client that could reach the API could previously spawn, stop, or
|
||||||
|
# manipulate any agent's runtime state. The guard mirrors the panel-token
|
||||||
|
# approach used by the WebSocket streams (DB-free): it binds the presented
|
||||||
|
# ``X-Agent-ID`` to a verified HMAC token and asserts 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 —
|
||||||
|
# the same contract as the v1 flow role guards and the do router. CEO is the
|
||||||
|
# sole operator role; agents (developers/QA/PMs) drive the orchestrator via
|
||||||
|
# MCP verbs, not these HTTP routes, so a developer token is correctly 403'd
|
||||||
|
# here.
|
||||||
|
_CEO_ROLE = Role.CEO.value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ceo(
|
||||||
|
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
|
||||||
|
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
|
||||||
|
x_agent_team: Annotated[str | None, Header(alias="X-Agent-Team")] = None,
|
||||||
|
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
|
||||||
|
) -> None:
|
||||||
|
# Bind the role header to a verified token BEFORE trusting it (same
|
||||||
|
# defense-in-depth contract as the v1 flow role guards in _role_dep.py).
|
||||||
|
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
|
||||||
|
# ``Role`` is a StrEnum so the lowercase header string compares equal to
|
||||||
|
# its matching member.
|
||||||
|
if x_agent_role.lower() != _CEO_ROLE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the CEO/operator may control the orchestrator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(_require_ceo)])
|
||||||
|
|
||||||
# Re-export set_orchestrator for bootstrap code
|
# Re-export set_orchestrator for bootstrap code
|
||||||
__all__ = ["router", "set_orchestrator"]
|
__all__ = ["router", "set_orchestrator"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -38,6 +38,7 @@ from roboco.services.base import (
|
|||||||
from roboco.services.base import (
|
from roboco.services.base import (
|
||||||
ValidationError as ServiceValidationError,
|
ValidationError as ServiceValidationError,
|
||||||
)
|
)
|
||||||
|
from structlog.testing import capture_logs
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# get_status_code
|
# get_status_code
|
||||||
@@ -275,3 +276,107 @@ def test_request_validation_handler_returns_422_with_details() -> None:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert "detail" in body
|
assert "detail" in body
|
||||||
assert "body" 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user