mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user