fix(gateway): docs/optimal RBAC denials return Envelope with remediate

The docs and knowledge-base HTTP routes enforced authorization inline and
raised raw HTTPException(403) with no recovery hint, so agents received
remediate=null and an un-actionable error, and the optimal route held the
RBAC decision itself (a layer-separation violation).

Move the knowledge-base authorization decision into a gateway module
(services/gateway/kb_authz) that returns an Envelope.not_authorized with a
non-null remediate naming the roles allowed to perform the action. The docs
RBAC already lives in DocsService; render its UnauthorizedError through the
same gateway helper. Both route groups now return the Envelope wire-dict at
top level (HTTP 403) instead of a bare detail string, keeping the routes
thin (HTTP translation only). Permitted callers are unaffected.
This commit is contained in:
Renn F
2026-06-03 19:41:10 +02:00
parent 16c425799c
commit 45836b4d6e
5 changed files with 256 additions and 83 deletions
+33
View File
@@ -110,6 +110,39 @@ async def test_write_doc_unauthorized(docs_client: AsyncClient) -> None:
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_write_doc_unauthorized_envelope_remediate(
docs_client: AsyncClient,
) -> None:
"""A denied write returns an Envelope-shaped body with a real remediate.
The docs RBAC decision lives in the service (UnauthorizedError); the
route must surface it as the gateway Envelope contract — error +
non-null remediate — not a bare {"detail": ...} HTTPException body.
"""
with patch("roboco.api.routes.docs.get_docs_service") as mock_get:
mock_service = AsyncMock()
mock_service.write_doc = AsyncMock(side_effect=UnauthorizedError("write_doc"))
mock_get.return_value = mock_service
response = await docs_client.post(
"/api/docs/write",
json={
"task_id": str(uuid4()),
"filename": "test.md",
"doc_type": "api",
"title": "Test",
"content": "Some content",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
body = response.json()
assert body["error"] == "not_authorized"
assert body["message"]
assert body["remediate"] is not None
assert body["remediate"].strip()
@pytest.mark.asyncio
async def test_write_doc_not_found(docs_client: AsyncClient) -> None:
"""Service raises NotFoundError → 404."""
+39 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING
@@ -10,7 +11,8 @@ from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context
from roboco.api.routes.optimal import check_staleness
@@ -84,6 +86,29 @@ async def test_index_code_forbidden(dev_optimal_client: AsyncClient) -> None:
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_index_code_forbidden_envelope_remediate(
dev_optimal_client: AsyncClient,
) -> None:
"""A denied KB call returns an Envelope-shaped body with a real remediate.
Agents go through the gateway Envelope contract: a denial must carry
error="not_authorized" plus a non-null remediate hint so the agent can
recover, not a bare {"detail": ...} HTTPException body.
"""
response = await dev_optimal_client.post(
"/api/optimal/kb/index/code",
json={"sources": ["a.py"]},
headers=_DEV_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
body = response.json()
assert body["error"] == "not_authorized"
assert body["message"]
assert body["remediate"] is not None
assert body["remediate"].strip()
@pytest.mark.asyncio
async def test_index_code_success(optimal_client: AsyncClient) -> None:
with patch("roboco.api.routes.optimal.get_optimal_service") as mock_get:
@@ -369,15 +394,24 @@ async def test_check_staleness_helper_authorized() -> None:
@pytest.mark.asyncio
async def test_check_staleness_helper_unauthorized() -> None:
"""Lines 479-482: kb VIEW_STATS denied → HTTPException 403."""
"""KB VIEW_STATS denied → gateway Envelope JSONResponse (HTTP 403).
The authorization decision now lives in the gateway; the route returns
the Envelope wire-dict (with a non-null remediate) at top level instead
of raising a bare HTTPException.
"""
agent = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER, team=Team.BACKEND)
fake_perm = MagicMock()
fake_perm.can_perform_kb_action.return_value = False
with pytest.raises(HTTPException) as exc:
await check_staleness(agent=agent, permissions=fake_perm)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
result = await check_staleness(agent=agent, permissions=fake_perm)
assert isinstance(result, JSONResponse)
assert result.status_code == HTTPStatus.FORBIDDEN
body = json.loads(bytes(result.body))
assert body["error"] == "not_authorized"
assert body["remediate"] is not None
assert body["remediate"].strip()
@pytest.mark.asyncio