Repository navigation
security: /api/long-running has no authentication — anyone can start test runs, scans and KB population, or cancel any operation #17010
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Sep 18, 2026 AC verification against merged
main— 3 of 4 met, issue stays OPENVerified from
origin/mainat31310392df, fileautobot-backend/api/long_running_operations.py.
Branch verdict: no branch carries this work — it landed and its branch is gone. The handover note
that this issue was "referenced only" was right that it was never closed, and wrong that it was
never done.- A router-level
Depends(get_current_user). NOT MET AS SPECIFIED — the outcome is there,
the mechanism is not, and the difference is load-bearing.
Every one of the ten routes is gated, enumerated rather than sampled: the five start routes
bydependencies=_ADMIN(:81),GET /{operation_id},GET /,POST /{id}/canceland
POST /{id}/resumebycurrent_user: dict = Depends(get_current_user)in the signature,
and@router.websocket("/{operation_id}/progress")byopen_authenticated_ws(...)at
:407. So no route is anonymous today.
But the router isAPIRouter(tags=[...])with nodependencies=, so a route added
tomorrow without aDependsis anonymous and nothing would catch it: the repo-wide
guard cannot see this router at all —
enumerate_routersreportsapi.long_running_operationsas NOT REGISTERED, because
router_auth_enumerator.REGISTRYreads onlycore_routers.pywhile this router is
registered as a string tuple infeature_routers.py:418. That is#16375, where I have
posted the measurement;#16286is the "one gated route hides the rest" half.
This criterion asked for router-level precisely because per-route gating does not hold
itself. - The five work-starting routes require admin.
/codebase/index,/testing/comprehensive,
/knowledge-base/populate,/security/scanand/migrate/existingall carry
dependencies=_ADMIN, i.e.Depends(check_admin_permission). - Status, list, cancel, resume and the progress WebSocket are scoped — and better than this
criterion asked. It settled for admin-only because operations recorded no creator. They
do now, so_may_see(:99) admits creator-or-admin,_owned_operation(:108) returns
404 rather than 403 so existence is not disclosed, and_may_see_id(:120) is the
socket's own check. The criterion's own fallback is superseded, not unmet. - Tests. PARTIAL — the first clause has no test.
autobot-backend/api/long_running_operations_17017_test.py(15 passed locally) covers
non-admin refused on a work-starting route (test_a_non_admin_cannot_start_work), another
user's operation refused without disclosure
(test_another_users_or_an_unowned_operation_is_refused_without_disclosing_it), and creator
and admin allowed (test_the_creator_reads_and_cancels_their_own,
test_an_admin_reads_and_cancels_anyones).
Not covered: no test asserts an unauthenticated request is refused on any of these
routes — the first clause, and the one this issue is titled after. And "non-admin refused on
each work-starting route" is one route, not five; the other four answer 501 before an
auth decision would be observable, so a non-admin hitting them is untested.
Verdict: the hole this issue was filed for is closed —
/api/long-runningis not anonymous.
Two things keep it open: the gate is per-route in a router no guard watches, and the
unauthenticated case is asserted nowhere. Both are small; neither is verified by this evidence,
so neither is ticked.- A router-level
- added a commit that references this issue
on Sep 23, 2026 #17344 merged (
2026-09-23T21:54), adding the guard that catches a future ungated route on this router. It does not close this issue, and AC1 specifically is worth reopening as a question rather than treating as impossible.The PR's claim, and where it is broader than what is proven. #17344 reports that a router-level
Depends(get_current_user)cannot be written, because a router-level dependency also applies to the WebSocket route/{operation_id}/progress(long_running_operations.py:398) andget_current_usertakes aRequest(auth_middleware.py:859) that FastAPI does not supply for a socket. That is true as narrowly stated: a singleAPIRoutercovering both the HTTP routes and the WS route cannot carry that dependency.It does not follow that a router-level gate is impossible. A router split was not considered:
- keep the 9 HTTP routes on one
APIRouter, mounted withinclude_router(http_router, dependencies=[Depends(get_current_user)]) - put the single websocket route on a second bare
APIRouterunder the same/api/long-runningprefix, still gated inline byopen_authenticated_wsexactly as today
That satisfies AC1 literally for the entire HTTP surface and leaves the socket no less protected than it is now. It is a production-code change, which is why it was correctly out of scope for a test-only PR — but "the router-level dependency cannot be written" should be narrowed in the record to "a single unified router cannot carry it", so the next person does not read AC1 as closed off.
What #17344 does deliver, and it is worth keeping: a guard that parses the module's own source with
astrather than walking the live route table, so it is immune to registration style — which matters here, becauserouter_auth_enumerator.py:45reads onlycore_routers.pyand this router is registered infeature_routers.py:418, meaning the repo-wide sweep genuinely cannot see it. The guard also carries_MIN_ROUTES_SEEN = 10, verified exact against the 10 route decorators on base, so a silent-empty enumeration fails loudly instead of passing.Two weaknesses in that guard are filed as #17348 — it matches parameter names and the presence of a
dependencies=keyword rather than their values, and its RED case was mutation-tested by hand but never committed. Neither leaves today's routes unchecked.So this issue stays open on AC1, and the concrete next step is the router split above rather than further work on the guard.
- keep the 9 HTTP routes on one
All four acceptance criteria are met on current
origin/main. Closing with evidence rather than reopening work — this was delivered and never closed.Verified against merged code, not against a PR diff. All ten routes on the router, enumerated by parsing the module:
Route Gate POST /codebase/index:132dependencies=_ADMINPOST /testing/comprehensive:139dependencies=_ADMINPOST /knowledge-base/populate:208dependencies=_ADMINPOST /security/scan:215dependencies=_ADMINPOST /migrate/existing:222dependencies=_ADMINGET /{operation_id}:271Depends(get_current_user)+_owned_operationGET /:290Depends(get_current_user), non-admin sees only their ownPOST /{operation_id}/cancel:335Depends(get_current_user)+_owned_operationPOST /{operation_id}/resume:360Depends(get_current_user)+_owned_operationWS /{operation_id}/progress:398open_authenticated_ws(..., allow=_may_see_id)Ten routes, ten gates, zero ungated.
- AC1 — unauthenticated refused on every route. Satisfied by a different mechanism than the criterion named. The AC asked for a router-level
Depends(get_current_user); the implementation gates each route and addstest_every_route_on_this_router_declares_a_gate(long_running_operations_17017_test.py:367) to enforce it structurally. Ticking because the criterion's stated property — "unauthenticated requests are refused on every route" — holds, and the guard is stronger than the router-level dependency would have been: a router-level dep cannot catch a future route declaring the wrong gate, and this does. - AC2 — work-starting routes require admin.
_ADMIN = [Depends(check_admin_permission)](:81, comment cites this issue) on all five. Test:test_a_non_admin_cannot_start_work(:239). - AC3 — status/list/cancel/resume/WS go to creator or admin. The AC said operations record no creator and that long-running: the four start routes cannot create an operation — create_operation gets estimated_items/context it does not accept (TypeError → 500) #17017 had to land first. It has: operations now carry
metadata["created_by"](test_the_test_suite_route_creates_an_operation_that_records_its_creator,:229), so these went to creator-or-admin rather than the admin-only fallback the AC allowed for — the stronger outcome. - AC4 — tests. Unauthenticated, non-admin, another user's operation, creator, and admin are each covered:
:239,:243,:259,:268(refused without disclosing the operation exists),:280.
Worth recording because it is rarer than it should be: the guard at
:367is mutation-proved by three tests that assert it fails when it must —test_the_guard_names_an_ungated_route(:454),test_a_dependencies_list_without_an_auth_dependency_does_not_count(:459), andtest_a_parameter_merely_named_current_user_does_not_count(:464). The last one is the interesting case: a parameter namedcurrent_userwithout the dependency would satisfy a naive grep, and the guard refuses it. There is alsotest_the_auth_vocabulary_matches_what_the_module_imports(:484), which keeps the guard's list of recognised gates tied to the module's real imports rather than to a hand-maintained copy that would drift.Nothing further is needed here. #17017 remains the home for anything outstanding on the create path.
- AC1 — unauthenticated refused on every route. Satisfied by a different mechanism than the criterion named. The AC asked for a router-level
Found in the WebSocket auth triage (#17009) and spot-checked on
mainby the coordinator.Problem
No route in
api/long_running_operations.pyauthenticates. Each depends only onget_operation_manager, a service locator. The router (APIRouter(tags=["long-running-operations"])) has no dependencies, and it is mounted at/api/long-runningwithout any./api/long-runningis not on the service-auth middleware's service-only list, soenforce_service_authpasses the request through.Anyone who can reach the backend can therefore:
POST /api/long-running/codebase/indexPOST /api/long-running/testing/comprehensivePOST /api/long-running/knowledge-base/populatePOST /api/long-running/security/scanPOST /api/long-running/migrate/existingPOST /api/long-running/{id}/cancel,/{id}/resumeGET /api/long-running/,/{id}Operations record no creator (
utils/long_running_operations/types.py), so nothing could scope them to a user even with authentication in place.Acceptance criteria
Depends(get_current_user): unauthenticated requests are refused on every route./codebase/index,/testing/comprehensive,/knowledge-base/populate,/security/scan,/migrate/existing) require admin.Related
#17009, #17000