Skip to content

tech-debt(guard): the LRO route-auth guard matches names, not semantics, and its RED case is not committed #17348

Description

@mrveiss

What

The route-auth guard added in #17344 (autobot-backend/api/long_running_operations_17017_test.py, test_every_route_on_this_router_declares_a_gate) is sound in its enumeration but weak in two ways that let it degrade silently in future. Found during the pre-merge review of #17344; not blocking then, because neither leaves today's routes unchecked.

The guard's enumeration is deliberately good and should be preserved: it parses the module's own source with ast rather than walking FastAPI's live route table, which makes it immune to registration style (decorator, include_router, string tuple in feature_routers.py:418, package __init__). It also carries _MIN_ROUTES_SEEN = 10 so a silent-empty enumeration fails loudly — verified exact, not padded: long_running_operations.py has exactly 10 route decorators on origin/main.

Both problems below are about the guard's resistance to regression of itself.

1. _declares_a_gate matches names and keywords, not semantics

A route passes if either:

  • any decorator carries a dependencies= keyword at all — the list contents are never inspected, so dependencies=[Depends(some_unrelated_thing)] satisfies it; or
  • the handler has a parameter literally named current_user — its default is never inspected, so current_user: str = Query(...) satisfies it.

So a future route can look gated to this guard while being anonymous. The guard's own docstring discloses that it is "a structural check... not that any gate actually refuses anyone", so this is a stated limitation rather than a hidden one — but it is the single way the guard can pass on an ungated route, which makes it worth closing.

2. The RED case is not committed

#17344's body describes a manual mutation test — append an ungated route, watch it fail by name, revert. That procedure is not in the suite. Nothing in CI proves _declares_a_gate still fires: loosening the current_user name check, or a typo in _ROUTE_DECORATORS, would leave the guard green and blind, and only a re-run of the manual steps would catch it.

Worth contrasting with #17347, which committed its mutation proof as two tests driving the real collisions() against a synthetic checkout, plus a mutation of its own assertion. That is the shape this one needs.

Acceptance criteria

  • _declares_a_gate checks dependency and parameter values, not just presence: a dependencies= list satisfies it only if it contains an auth dependency, and a current_user parameter only if its default is Depends(get_current_user) (or the equivalent admin gate).
  • A synthetic route with dependencies=[Depends(unrelated)] is asserted to FAIL the guard, and one with current_user bound to a non-auth default is asserted to FAIL.
  • A committed meta-test injects a synthetic ungated function and asserts the guard fails naming that route, so the detection logic is regression-tested rather than re-verified by hand.
  • _MIN_ROUTES_SEEN is left intact and still exact — raising it is correct when routes are added, lowering it to clear a red is not.

Activity

  1. mrveiss commented on Sep 24, 2026

    @mrveiss
    OwnerAuthor

    All four criteria met. PR #17350 merged a42b2980e6 at 00:01. Verified against merged code, not the PR's diff.

    • Values, not names or presence. _AUTH_DEPENDENCIES = {"get_current_user", "check_admin_permission"} (:47); _is_auth_depends (:54) requires a Depends(...) call whose argument is in that set; _parameter_gate (:102) keys on the parameter's default expression, so the spelling of the parameter name is irrelevant. _module_level_lists (:63) resolves dependencies=_ADMIN to its current contents rather than trusting the keyword's presence.
    • Both non-auth shapes assert FAIL, by name. assert "dependencies_but_not_auth" in _synthetic_ungated() (:461) and assert "named_but_not_bound" in _synthetic_ungated() (:466) — the two exact bypasses this issue described, each asserted to be caught.
    • The RED case is committed and drives the real guard. _synthetic_ungated() calls the same _ungated_routes() the production test calls on the real module's AST — one function, both sides, not a reimplementation in the test body. assert "anonymous" in _synthetic_ungated() (:456) names the route.
    • _MIN_ROUTES_SEEN intact at 10 and still exact (:38, asserted at :387). Re-counted on origin/main's long_running_operations.py: 5 @router.post + 2 @router.get + 2 @router.post + 1 @router.websocket = 10. Not lowered to accommodate the change, which was the thing most worth checking.

    A bypass was actively attempted and not found. dependencies=[Depends(unrelated_fn)], current_user: str = Query(...), and _ADMIN rebound to a non-auth dependency are all correctly caught. The gaps that do exist — aliased imports (Depends(_gcu)), keyword-form Depends(dependency=...), tuple dependencies=, Annotated[...] DI — are false negatives: they would flag a genuinely gated route as ungated. Over-strict, never silently permissive, which is the correct direction for this guard to fail. None applies to the module today.

    One limitation worth recording rather than leaving implicit: name-shadowing a local get_current_user would defeat an AST identifier match. That is a property of any name-based check and is outside what this issue asked for (value vs. presence, not symbol resolution). The PR discloses it — the guard is structural, and it does not prove any gate refuses anyone.

    Also durable: test_the_auth_vocabulary_matches_what_the_module_imports asserts _AUTH_DEPENDENCIES against the module's real imports, so renaming an auth dependency breaks loudly instead of silently emptying the allowlist. That is the failure mode that would otherwise turn this guard back into the one it replaced.

    Filed from the #17344 review, delivered and closed the same session.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions