109 lines
4.4 KiB
Python
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"
|