honcho/tests/routes/test_auth_route_policy.py

109 lines
4.4 KiB
Python

"""Route-policy regression tests for auth scoping.
Two invariants this guards:
1. `allow_member_read=True` grants peer-scoped keys read access to sessions
their peer belongs to. It must appear ONLY on intended read routes — never on
a mutating route, where it would hand session members write access. HTTP
method is not a reliable read/write signal in this codebase (some read
endpoints use POST for a richer request body), so we assert against an
explicit allowlist instead of deriving from the method.
2. The messages router dropped its router-level auth dependency in favor of
per-route dependencies. Every route on it must still carry auth, or a future
route added without an explicit dependency would serve unauthenticated.
"""
from fastapi.routing import APIRoute
from src.main import app
# (method, path) pairs intentionally granting member peers read access. Adding a
# route here is a deliberate security decision: it must be read-only. Never add
# a mutating route. See CLAUDE.md "Auth scoping" for the rule.
EXPECTED_MEMBER_READ_ROUTES = {
("POST", "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list"),
(
"GET",
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}",
),
("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/context"),
("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries"),
("GET", "/v3/workspaces/{workspace_id}/sessions/{session_id}/peers"),
(
"GET",
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config",
),
("POST", "/v3/workspaces/{workspace_id}/sessions/{session_id}/search"),
}
# Unambiguously mutating methods. POST is intentionally excluded: this codebase
# uses POST for some read endpoints (`/messages/list`, `/search`) to take a
# richer request body, so POST is not a write signal. The allowlist test above
# is the real guard against a write route opting into member read; this test
# additionally catches the clear-cut PUT/PATCH/DELETE mistakes.
MUTATING_METHODS = {"PUT", "PATCH", "DELETE"}
def _auth_dependency_calls(route: APIRoute):
"""Yield the callables of every honcho auth dependency attached to a route.
`require_auth(...)` closures are tagged with `honcho_allow_member_read`, so a
dependency is a honcho auth dependency iff its callable has that attribute.
Walks the dependant tree to cover both `dependencies=[Depends(...)]` and
parameter-level `Depends(...)`.
"""
stack = list(route.dependant.dependencies)
while stack:
dep = stack.pop()
if hasattr(dep.call, "honcho_allow_member_read"):
yield dep.call
stack.extend(dep.dependencies)
def _method_path_pairs(route: APIRoute):
for method in route.methods or set():
if method in ("HEAD", "OPTIONS"):
continue
yield (method, route.path)
def test_member_read_allowlist_matches_routes():
"""Exactly the allowlisted routes opt into member read — no more, no less."""
actual: set[tuple[str, str]] = set()
for route in app.routes:
if not isinstance(route, APIRoute):
continue
if any(
getattr(call, "honcho_allow_member_read", False)
for call in _auth_dependency_calls(route)
):
actual.update(_method_path_pairs(route))
assert actual == EXPECTED_MEMBER_READ_ROUTES
def test_member_read_never_on_mutating_route():
"""A member-read route must never use a mutating HTTP method."""
for method, path in EXPECTED_MEMBER_READ_ROUTES:
assert method not in MUTATING_METHODS, (
f"{method} {path} grants member-read on a mutating method — "
"member peers would gain write access"
)
def test_every_message_route_requires_auth():
"""The messages router has no router-level auth dependency; assert each route
carries its own so a newly added route cannot be silently unauthenticated."""
prefix = "/v3/workspaces/{workspace_id}/sessions/{session_id}/messages"
message_routes = [
route
for route in app.routes
if isinstance(route, APIRoute) and route.path.startswith(prefix)
]
assert message_routes, "expected to find message routes mounted under the prefix"
for route in message_routes:
assert any(
_auth_dependency_calls(route)
), f"{route.methods} {route.path} has no auth dependency"