Repository navigation
security(api): gate the chat-knowledge router, which was reachable anonymously (#16375) - #17724
Conversation
…onymously (#16375) Eleven routes, eight of them mutating, and no credential required for any. It is the first tranche of #16375 because it is the one module in the ungated population with a CONFIRMED side effect an anonymous caller could trigger: `/files/upload/{chat_id}` writes uploaded bytes to disk at `aiofiles.open(file_path, "wb")`. WHY THIS ONE FIRST, measured rather than assumed. #16375's own body derived 44 ungated routers by text scan; the guard's number is 27 with a shrink-only cap, and the core-router set is already empty. I triaged all 27 by the issue's four tiers, then scanned tier 2 -- execute-type capability -- from AST imports: imports LLM infrastructure 0 of 27 subprocess / shell 0 of 27 dynamic exec / eval 0 of 27 session.commit/add/delete 0 of 27 at call sites So the population is not execute-capable, nothing reorders on that axis, and `api.chat_knowledge` tops both the mutating count (8) and the side-effect axis (the only confirmed write). It is alone in first place. THE GATE IS `get_current_user`, NOT `check_admin_permission`, and the choice is stated because it is the part no scan answers. Every route here is scoped to a `{chat_id}` or `{session_id}` and is an ordinary user operating on their own conversation; an admin gate would lock users out of their own chat knowledge. `api.prometheus_endpoint` is the reminder in the other direction -- gating a metrics scrape as admin breaks the scrape -- and it is not in this tranche. AT THE ROUTER, NOT PER ROUTE, so a route added later inherits the gate instead of needing to remember. One of the new tests pins that specifically: per-route gating is one forgotten decorator away from a hole. THIS DOES NOT CLOSE CROSS-USER ACCESS AND NOTHING HERE CLAIMS IT DOES. None of the eleven routes checks that the caller owns the id it is handed -- measured, 0 hits for owner/ownership predicates across the module. So the exposure narrows from anonymous to the user table, and an authenticated caller can still address any chat_id they can name. That is #17722, filed before this commit referenced it, and the module docstring now says so at the top rather than leaving a reader to infer it from a gate's presence. Three behavioural tests, because the existing guard is a STATIC scan: it reads the AST and checks KNOWN_UNGATED, and cannot see whether the dependency actually rejects anyone. A declared gate that admits everyone is a shape this repository keeps finding. So: an anonymous request is refused, an authenticated one still reaches the handler (the contrast case -- a gate refusing everyone would be a broken module, not a secured one), and the dependency is on the router object. KNOWN_UNGATED 27 -> 26 and its cap with it. `_EVER_UNGATED` keeps its entry, as that set's own comment requires -- it is the historical record that makes "the list only shrinks" enforceable, and removing from it would let the same router be re-parked later as a new finding. Refs #16375 Refs #17722
|
Warning Review limit reachedNext included review available in 42 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (4)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
mrveiss
left a comment
There was a problem hiding this comment.
Review — the gating is right and test_the_gate_is_on_the_router is the model assertion. Do not merge: test_an_unauthenticated_request_is_refused cannot pass in this harness, and CI was cancelled so nobody has found out.
Traced from source at origin/main 80b6200d4a; nothing run. Every link is cited so the chain is checkable — and falsifiable by one CI run, which is the thing that has not happened.
The unauthenticated test resolves through the stub that authenticates everyone
_make_app(authenticated=False) omits the FastAPI override, so get_current_user runs for real. It is not self-contained:
api/user_management/dependencies.py:18 from auth_middleware import get_auth_middleware <- binds a name at import
api/user_management/dependencies.py:66 user_data = get_auth_middleware().get_user_from_request(request)
auth_middleware.py:856 get_auth_middleware = lazy_singleton(AuthenticationMiddleware)
And in the backend test process that name is not the real one:
autobot-backend/conftest.py:744 install_auth_middleware_stub() <- module scope, runs at collection
testkit/auth_middleware_stub.py:191 if "auth_middleware" not in sys.modules: sys.modules["auth_middleware"] = build_auth_middleware_stub()
testkit/auth_middleware_stub.py:146 ...get_user_from_request.side_effect = lambda *_a, **_k: _current_user_payload()
testkit/auth_middleware_stub.py:53-56 return {"username": "test-user", "user_id": "test-user", "role": "admin", ...}
The guard is if "auth_middleware" not in sys.modules, and nothing puts the real module there first. I checked the three candidates: conftest imports no api.*, user_management or auth_middleware at module scope before :744; its _SIMPLE_STUBS loop at :92 imports third-party leaves only (torch, asyncpg, alembic…); and testkit/auth_middleware_stub.py imports just sys, types, MagicMock and fastapi.Request, so it does not defeat its own guard.
So the stub is installed, get_user_from_request returns a truthy admin payload, get_current_user raises nothing, the route runs, and the assertion gets 200 where it requires 401 or 403.
This is #17343 exactly — "the auth_middleware stub refuses nothing, so no backend test can assert an unauthenticated request is rejected". Its evidence is the sibling case (check_admin_permission, where the stub takes 0 args and the route returned 501); yours is the get_current_user case, where the stub hands back a user.
The pin has to target the bound name, not the module
The obvious patch does nothing:
monkeypatch.setattr("auth_middleware.get_auth_middleware", ...) # no effectdependencies.py:18 is a from … import, so the reference was copied at import time. The pin must land on the copy:
def test_an_unauthenticated_request_is_refused(self, stub_manager, monkeypatch):
# `get_current_user` resolves through `auth_middleware.get_auth_middleware()`,
# which conftest.py:744 replaces with a stub that authenticates everyone
# (testkit/auth_middleware_stub.py:146). Pinned here for this test only,
# on the name `dependencies.py:18` bound -- patching the module would miss
# the copy. #17343 owns the shared fix.
import api.user_management.dependencies as deps
monkeypatch.setattr(deps, "get_auth_middleware",
lambda: SimpleNamespace(get_user_from_request=lambda _r: None))There is no anonymous mode in the testkit to reuse — _current_user_payload() (:44) returns an admin unconditionally, with no parameter. That absence is #17343's subject, so pin locally now and let #17343 provide the shared helper rather than inventing a second one here.
The mutation analysis, since it was asked
Mutation remove dependencies=[Depends(get_current_user)]:
| test | killed by it? | why |
|---|---|---|
test_an_unauthenticated_request_is_refused |
no — cannot, it does not pass today | resolves through the stub, gets 200 either way |
test_an_authenticated_request_still_reaches_the_handler |
no | passes with or without the gate; correctly labelled a contrast case in its own docstring |
test_the_gate_is_on_the_router_so_a_new_route_inherits_it |
yes | reads the router's declared dependencies |
So the mutation is the right one and only the structural test kills it. The behavioural claim — "the gate refuses" — is currently unproven rather than proven, which is the opposite of what the class docstring says it establishes. The docstring is the best thing in the PR ("a static scan sees a dependencies=[Depends(...)] and cannot see whether the dependency actually rejects anyone") and right now the test under it cannot see that either.
What is right, and worth copying
test_the_gate_is_on_the_router_so_a_new_route_inherits_it asserts identity:
declared = [getattr(d, "dependency", None) for d in (chat_knowledge_router.dependencies or [])]
assert get_current_user in declaredThat is the correct form, and it is the form #17729's replacement test is missing — that one compares __name__ to the string "check_admin_permission", and this repository has three functions with that name (auth_middleware.py:962, api/audit.py:48, api/redis_service.py:74). Same tranche, same author, both forms present: worth making this one the pattern.
Also right: get_current_user over an admin gate for user-owned conversations, gating at the router so a later route inherits it, and the module docstring stating plainly that ownership is not checked with #17722 named as that half. A gate that authenticates and does not authorise is exactly the thing that gets mistaken for complete.
_MAX_KNOWN_UNGATED 27 -> 26 matches the single KNOWN_UNGATED removal.
CI
No verdict: the suite was cancelled at 18:13 Riga. Nothing here has run, including the three new tests. Red CI never merges and a cancelled suite is not a passing one — and in this case the run is also the cheapest way to settle whether my trace above is right. Re-run, fix the pin in the same PR, and the rest I would land.
…in the gate by identity (#16375) Tranche 3, appended to the tranche-2 branch rather than opened as a third PR: it edits repo_tests/config_router_auth_coverage_test.py, which tranche 2 already edits, and a same-file split across two PRs from one base conflicts. api.realtime_session is the reason this tranche is not last. POST /tools/call routes an arbitrary named tool through the MCP bridge -- the registry is built from the configured MCP servers, an unknown name is refused and a known one executes -- and the route passes no user_id, so the bridge's audit record of the invocation cannot say who made it. That falsifies the "not execute-capable" headline posted on #16375, which is retracted there. Gated as authentication rather than admin because the browser genuinely calls these routes; it already sends credentials via fetchWithAuth, so no client changes. PATCH /providers additionally requires admin: set_active_provider mutates the in-process selection for every session, not the caller's own. llm_awareness, project_state and state_tracking take admin. None has a frontend caller outside generated types, none holds per-user data, and each mutates something shared -- a persisted development phase via save_state(), the one project tracker, or a report written to disk whose absolute path comes back in the response. Also fixes the tranche-2 test this branch already carried. It asserted the gate by NAME, and three distinct functions in this repo are called check_admin_permission (auth_middleware.py:962, api/audit.py:48, api/redis_service.py:74) which do not even agree on a return type -- so the assertion would have passed on the wrong gate. Both forms are now identity assertions. The larger finding underneath it: api/api_endpoint_migrations_test.py is PARKED at module scope (#15173, #5359 Option C), so that assertion has never executed, in CI or locally. It is kept correct for whoever unparks the file, but it was not proof. api/router_gate_identity_16375_test.py is new and runs: it pins each gated router's dependency by identity, pins the three-way name collision that makes identity necessary, and carries vacuity floors for an emptied table and a routerless router. Verified to kill a mutant -- removing the llm_awareness dependency fails exactly one parametrised case. KNOWN_UNGATED 25 -> 21, _MAX_KNOWN_UNGATED lowered to match. #17724 removes api.chat_knowledge from the same set on its own branch; when it merges first, this branch rebases to 20. Refs #16375
…tually fail (#16375) Review found the pushed diff did not carry this pin, and it was right: the change was written and run locally but left STAGED and never committed, so the branch -- the only record that matters -- had the earlier version. What the earlier version claimed and could not prove: `_make_app( authenticated=False)` merely omits the FastAPI override, so the real `get_current_user` runs and resolves through whatever `get_auth_middleware` the process holds. Under this suite that is testkit/auth_middleware_stub.py, whose `get_user_from_request` returns an admin payload unconditionally (auth_middleware_stub.py:146 -> :53), so the anonymous request got 200 and the test's own docstring was inverted -- measured directly: authed=False -> 200. The pin must land on `api.user_management.dependencies.get_auth_middleware`, not on `auth_middleware.get_auth_middleware`: dependencies.py does a `from`-import, so the name is already copied and patching the source module would have changed nothing. Mutation-checked both ways. Before: removing the router dependency killed only test_the_gate_is_on_the_router. Now it kills that and the refusal test, which is the difference between a proven and an asserted claim. Refs #16375
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
mrveiss
left a comment
There was a problem hiding this comment.
Second look at 5d57ea6e1d — the pin is on the right name, the refusal test can now fail in both directions, and the rename is the honest one. I would land this.
The pin targets the bound name, which is the whole point:
monkeypatch.setattr("api.user_management.dependencies.get_auth_middleware", lambda: _NoUser())Not auth_middleware.get_auth_middleware. dependencies.py:18 is a from … import, so the module holds its own reference, and get_current_user (:66) looks the name up on that module at call time rather than capturing it — so this patch reaches the call and the module-level one would not have.
_NoUser.get_user_from_request(self, request) takes the request, matching the real sync signature. None → get_current_user raises 401.
Sensitive in both directions, which is what makes it a test: drop the router dependency and the route answers 200 → fails. Remove the pin and the ambient stub returns an admin → 200 → fails. So the mutation now kills the refusal test and the structural one, where before it killed only the structural one.
The rename is the best part. test_an_unauthenticated_request_is_refused → test_a_request_whose_user_does_not_resolve_is_refused. The old name promised credential handling; the test pins resolution. Naming it for what it actually establishes is the fix that keeps the next reader from over-reading it, and the docstring draws the line explicitly — "the only input under which 'the route refuses' is a claim about THIS router's wiring rather than about the middleware's own logic, which has its own tests."
And the docstring's evidence is real — I checked the citation rather than taking it. chat_shared_links_tracking_16861_test.py does define _FakeAuthMiddleware returning a canned user from get_user_from_request, so "other tests in this suite stub it" is accurate, not a plausible reason. That the first version of this test "read a real middleware whose dev-header and JWT paths were already primed by an earlier test, and got 200 from a correctly gated route" is the measurement I asked for, and it is recorded where the next person will hit the same wall.
One question, not an objection
TestClient(app, raise_server_exceptions=False) is not needed for this assertion: an HTTPException(401) is handled by FastAPI and returned as a response either way. With the flag, a dependency that crashed for an unrelated reason returns 500 instead of raising — and 500 not in (401, 403), so it still fails rather than passing falsely. Harmless, then, but unexplained: either say what it is guarding against or drop it, because a reader will assume it was needed and copy it.
Worth recording on #17343
This test is now the worked example of how to assert a refusal despite the harness stub: pin the resolution on the importing module, not the stubbed module. #17343's shared fix should produce a fixture that does exactly this, and can cite this test as the shape. Until it does, every router gated by #16375 needs its own copy of these eight lines — which is the argument for #17343 being scheduled rather than left open.
|
CI at Mechanism: the bare mount at Two fixes, owner of this PR decides: (a) the test mounts the sub-router with the same dependency it has in production (and overrides Same class will hit #17729 (tranche 2) if |
#16375) python-suite shard 3/12 at 5d57ea6, `router_mount_parity_test:: test_no_router_is_mounted_past_a_gate_its_siblings_carry`. The bare `app.include_router(chat_knowledge_delete.router)` in the test is pre-existing on main and became a parity violation only because this PR put the gate on the parent -- so the guard is right that the PR created the disagreement, and the fix belongs here rather than in a follow-up. Mounts `api.chat_knowledge:router` instead, which is how production reaches these routes (`chat_knowledge.py:96` does `router.include_router(_delete_router)`). The alternative -- re-declaring the dependency on the bare sub-router in the test -- would work and would put a second description of the gate in a test file, where it can drift from the real one without anything noticing. The delete routes keep their own per-route `check_admin_permission`, which these tests already override; the parent's authentication dependency is overridden alongside it, because these tests are about deletion and the orphan sweep and not about who may call them. Who the gate admits is asserted in `chat_knowledge_wiring_test.py::TestTheRouterIsGated`. Verified: `router_mount_parity_test` 9 passed, `chat_knowledge_delete_test` 12 passed, `chat_knowledge_wiring_test` 32 passed. Mutation-checked -- restoring the bare sub-router import fails exactly `test_no_router_is_mounted_past_a_gate_its_siblings_carry`, so the guard is what this satisfies rather than a coincidence. Refs #16375
mrveiss
left a comment
There was a problem hiding this comment.
Review of the delta 5d57ea6e1d → 6b7b041f18 — land it. One commit, one file, +14/−1, and the change is exactly the parity fix. One evidence point to tighten, no blocker.
The delta is what it claims
6b7b041f1 fix(api): mount the delete tests through the gated parent, not past it (#16375)
autobot-backend/api/chat_knowledge_delete_test.py 14+ 1-
Nothing else moved.
Verified rather than read past
The citation is exact. The comment says production reaches the delete routes only through the parent, "chat_knowledge.py:96 does router.include_router(_delete_router)" — and at this head line 96 is precisely that call, with the import of _delete_router two lines above it. A line-numbered citation that is right is worth noting, because the same comment style is what went wrong in chromadb_client.py.
The import that stayed is still live. from auth_middleware import check_admin_permission remains used at :183 (dependency_overrides[check_admin_permission] = lambda: True) and :229 (_raise_403) — so the file still asserts the admin gate in both directions, admitted and refused, and mounting the parent has not quietly replaced an authorization test with an authentication one.
The override is scoped and points at where the claim lives. "These tests are about deletion and the orphan sweep, not about who may call them. Who the gate admits is asserted in chat_knowledge_wiring_test.py::TestTheRouterIsGated." That is the right division: one description of the gate, referenced, instead of a second one restated here — and it is the same principle #17732 is being fixed for, applied before anyone asked.
The one thing to tighten: the guard is credited without being observed
"Mounting the sub-router bare gave these tests a route reachable past a gate its siblings carry, which is what
router_mount_parity_testexists to catch."
repo_tests/router_mount_parity_test.py does exist, its named assertion is test_no_router_is_mounted_past_a_gate_its_siblings_carry, it compares guard sets across every path reaching a router, and chat_knowledge_delete:router is not in EXEMPT_ROUTERS. So the attribution is plausible.
It is also unverified, and I could not verify it either way:
- every
python-suitecheck run on5d57ea6e1d— the head that had the gate and the bare mount — is cancelled, so that guard never reported on the one tree where it could have fired; - and its green on
origin/mainproves nothing, because main'sapi.chat_knowledgeis not yet gated — the gate arrives with this PR. With no gate on the parent there is no protection for the bare mount to skip, so the guard has nothing to flag there. (I nearly published the opposite conclusion from main's green before noticing the revision was wrong for the question.)
So one of two things is true and the comment asserts the first: either the guard would have failed on 5d57ea6e1d and nobody saw it because the suite was cancelled — which is an argument against cancelling the suite on a PR that changes gating — or it cannot see a mount made inside a test file, in which case nothing catches this class and the next bare mount past a gated parent lands silently.
Either way the wording should say what was observed. "Which is what router_mount_parity_test exists to catch" reads as "the guard caught this", and a comment that credits a guard nobody watched fire is the failure that cost three sessions a wrong diagnosis yesterday. Two words fix it — "which router_mount_parity_test is written to catch" — or, better, one line stating the guard was seen failing, if it was.
And if it is the second case, it is worth its own issue rather than a note: the class is "a test mounts a sub-router bare, past the gate its production parent carries", which #16375's tranches will keep generating for every router that gains a router-level dependency. EXEMPT_ROUTERS exists, so the guard already has a place to record a decision about it.
Verdict
Land it. The parity fix is right, minimal, and reaches the routes the way production does; the only change I would make is to the strength of one sentence, and it does not need to hold the merge.
Slice S1 of #16375: analytics_reporting, project, registry and self_capabilities. Each has only GET routes and was reachable anonymously. Gated at the router with `get_current_user`, following the tranche-1 template (#17724), so a route added later inherits the gate. No admin classification: every route here is a read-only view any signed-in user may see. KNOWN_UNGATED loses the four entries and `_MAX_KNOWN_UNGATED` moves with it, 26 -> 22. `_EVER_UNGATED` keeps them as its comment requires. `zero_mutator_routers_gate_16375_test.py` adds, per router, the behavioural half the static guard cannot see: the gate is on the router object, a request whose user does not resolve is refused, and the router's own gate admits an authenticated caller. The refusal is pinned to a middleware that resolves nobody rather than left to whatever the process holds. The contrast mounts the router's own dependency list on a probe route, because `/summary` fetches over HTTP and a handler that needs a live service is not the subject. Mount parity: `self_capabilities_integration_test.py:32` mounts the router bare. The gate is on the router object itself, not on a parent, so that mount carries it and no mount skips it. Refs #16375 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0142SgyQbX5eopTT1vn3PTRw
…iteral posing as a credential (#16375) Tranche 2 of #16375. Two modules, each gate reasoned, plus one defect found in the second that is worse than the missing gate. api.anti_pattern -> get_current_user. Nine routes, seven POST, all anonymous. They analyse the codebase -- god classes, circular dependencies, dead code, health score -- so they are developer operations on shared code rather than on a user's own data, and they neither execute anything nor write shared state. An admin gate would stop ordinary developers reading analysis of their own repo. api.error_monitoring -> check_admin_permission. Nine routes, anonymous, including POST /clear which wipes the shared error store (Redis keys matching autobot:errors:* plus in-memory reports) and POST /test-error which injects one. This is operational diagnostics over the whole deployment, not a user's own data, and two routes mutate state every other user depends on. Admin is the level; get_current_user would not be. A LITERAL POSING AS A CREDENTIAL, which is why this module needed more than a gate. `clear_error_history` compared the Authorization header against a string defined in this file, under a comment saying authentication "would typically" be required -- a placeholder never replaced. A literal is not a credential: it ships in the repository, so it authenticates anyone who can read the source while presenting as a gate to anyone who reads the route. That is worse than no gate. An ungated route is found by #16375's sweep; this one was not, because it HAS an authorization check. Filed as #17727 with the criterion that the sweep should stop counting "has an authorization check" as gated unless the check resolves to a real dependency. AND A TEST PINNED IT. api_endpoint_migrations_test.py's test_batch_147_migration_preserves_auth_check asserted the literal was present in the function's source. Its intent was right -- "verify migration preserves authorization check" -- and its implementation pinned the defect: a refactor replacing the placeholder with real authentication FAILED it. It now asserts the router's real dependency. Third instance today of a test locking in the thing it was written to protect against. The literal is deliberately not repeated in the replacement docstring, because a secrets scan reads docstrings too -- #17571's shape, and my first draft of that docstring carried it. api.code_search IS NOT IN THIS TRANCHE and the reason is worth recording: it is 924 lines against a grandfathered ceiling of exactly 924. Zero slack. Adding the auth import needs one line, so **a file at its size ceiling cannot receive a one-line security gate** without a split first. That is a real interaction between two rules and it is not a reason to raise a ceiling. KNOWN_UNGATED 27 -> 25 and its cap with it. _EVER_UNGATED keeps both entries, as that set's comment requires. SEQUENCING: this edits repo_tests/config_router_auth_coverage_test.py, which baseline and therefore exempt from the same-file rule, but the two WILL conflict textually. This branch's numbers assume #17724 has not landed; merge Refs #16375 Refs #17727
…in the gate by identity (#16375) Tranche 3, appended to the tranche-2 branch rather than opened as a third PR: it edits repo_tests/config_router_auth_coverage_test.py, which tranche 2 already edits, and a same-file split across two PRs from one base conflicts. api.realtime_session is the reason this tranche is not last. POST /tools/call routes an arbitrary named tool through the MCP bridge -- the registry is built from the configured MCP servers, an unknown name is refused and a known one executes -- and the route passes no user_id, so the bridge's audit record of the invocation cannot say who made it. That falsifies the "not execute-capable" headline posted on #16375, which is retracted there. Gated as authentication rather than admin because the browser genuinely calls these routes; it already sends credentials via fetchWithAuth, so no client changes. PATCH /providers additionally requires admin: set_active_provider mutates the in-process selection for every session, not the caller's own. llm_awareness, project_state and state_tracking take admin. None has a frontend caller outside generated types, none holds per-user data, and each mutates something shared -- a persisted development phase via save_state(), the one project tracker, or a report written to disk whose absolute path comes back in the response. Also fixes the tranche-2 test this branch already carried. It asserted the gate by NAME, and three distinct functions in this repo are called check_admin_permission (auth_middleware.py:962, api/audit.py:48, api/redis_service.py:74) which do not even agree on a return type -- so the assertion would have passed on the wrong gate. Both forms are now identity assertions. The larger finding underneath it: api/api_endpoint_migrations_test.py is PARKED at module scope (#15173, #5359 Option C), so that assertion has never executed, in CI or locally. It is kept correct for whoever unparks the file, but it was not proof. api/router_gate_identity_16375_test.py is new and runs: it pins each gated router's dependency by identity, pins the three-way name collision that makes identity necessary, and carries vacuity floors for an emptied table and a routerless router. Verified to kill a mutant -- removing the llm_awareness dependency fails exactly one parametrised case. KNOWN_UNGATED 25 -> 21, _MAX_KNOWN_UNGATED lowered to match. #17724 removes api.chat_knowledge from the same set on its own branch; when it merges first, this branch rebases to 20. Refs #16375
Slice S1 of #16375: analytics_reporting, project, registry and self_capabilities. Each has only GET routes and was reachable anonymously. Gated at the router with `get_current_user`, following the tranche-1 template (#17724), so a route added later inherits the gate. No admin classification: every route here is a read-only view any signed-in user may see. KNOWN_UNGATED loses the four entries and `_MAX_KNOWN_UNGATED` moves with it, 26 -> 22. `_EVER_UNGATED` keeps them as its comment requires. `zero_mutator_routers_gate_16375_test.py` adds, per router, the behavioural half the static guard cannot see: the gate is on the router object, a request whose user does not resolve is refused, and the router's own gate admits an authenticated caller. The refusal is pinned to a middleware that resolves nobody rather than left to whatever the process holds. The contrast mounts the router's own dependency list on a probe route, because `/summary` fetches over HTTP and a handler that needs a live service is not the subject. Mount parity: the bare mount in `self_capabilities_integration_test` is not a violation, and this is now measured rather than reasoned. The mount graph reports all four routers with own_guards `{get_current_user}` and every mount's parent as the app root, so there is no parent gate for any mount to skip and each router's own gate travels with it. A bare mount would only matter if the router had a gated parent. Refs #16375
Thinking Path
First tranche of #16375, and the module was chosen by measurement rather than by reading the issue's table — because that table was stale.
The issue says 44 ungated routers, derived by text scan. The guard's number is 27, with core routers already at zero and the set made shrink-only by #16955. I reconciled that on the issue and put a banner on its body so nobody works from 44.
Then I triaged all 27 by the issue's own four tiers, and tier 2 — execute-type capability — is the scan that settled the order. Resolved from AST imports, because a module cannot invoke what it does not import:
The ungated population is not execute-capable. No anonymous caller can make one of these run a shell command, drive an agent loop or spend model tokens. So nothing reorders on that axis, and
api.chat_knowledgeis alone in first place: highest mutating count (8 of 11 routes) and the only confirmed side effect —An anonymous caller could write uploaded bytes to disk.
My first attempt at that scan was wrong and it matters that it was. It reported ten modules with LLM capability and six reaching an orchestrator — every one a regex matching prose:
"Anti-pattern analysis complete","Failed to generate project report", andschemas_workflowson the substringworkflow. Re-running it would have produced ten every time; spot-checking two hits found it. The AST version is the instrument, and its zeros are informative where the first pass's tens were not. Both passes are on the issue.What Changed
APIRouter(tags=["chat_knowledge"], dependencies=[Depends(get_current_user)])— at the router, so a route added later inherits the gate rather than needing to remember. One of the new tests pins that specifically: per-route gating is one forgotten decorator away from a hole.get_current_user, notcheck_admin_permission, and the choice is stated because no scan answers it. Every route here is scoped to a{chat_id}or{session_id}and is an ordinary user acting on their own conversation; an admin gate would lock users out of their own chat knowledge.api.prometheus_endpointis the reminder in the other direction — gating a metrics scrape as admin breaks the scrape — and it is deliberately not in this tranche.KNOWN_UNGATED27 → 26, and its cap with it._EVER_UNGATEDkeeps its entry, as that set's own comment requires: it is the historical record that makes "the list only shrinks" enforceable, and removing from it would let the same router be re-parked later as a fresh finding.This does not close cross-user access
None of the eleven routes checks that the caller owns the id it is handed — measured, zero hits for ownership predicates across the module. So this narrows the exposure from anonymous to the user table and no further: an authenticated caller can still address any
chat_idthey can name.Filed as #17722 before this branch referenced it, and the module docstring now says so at the top, so a reader cannot infer closure from a gate's presence. Same seam as #17014's remaining gap and #17054's desktop socket: a route takes a resource id, authenticates the caller, and never joins the two.
Verification
Pre-push ran the backend suite and reported
[pre-push OK] pytest: all relevant tests pass— including the three new behavioural tests.Three behavioural tests, because the existing guard is a static scan. It reads the AST and checks
KNOWN_UNGATED; it cannot see whether the dependency actually rejects anyone, and a declared gate that admits everyone is a shape this repository keeps finding. So: a request with no resolvable user is refused; an authenticated one still reaches the handler (the contrast case — a gate refusing everyone would be a broken module, not a secured one); and the dependency is on the router object.The first version of that gate test failed, and the gate was fine. It asserted an anonymous request is refused and got 200 from a correctly gated route.
get_auth_middlewareis alazy_singleton— process-global — and other tests in this suite stub it (chat_shared_links_tracking_16861_test.py:37returns a canned user), so the test read whatever middleware state the pytest process happened to hold. Order-dependent, which is the flake class that costs a queue an hour. It is now pinned to a no-user middleware, which makes "the route refuses" a claim about this router's wiring rather than about the middleware's own resolution logic — that has its own tests.And diagnosing it cost a lesson worth recording: importing
auth_middlewareto inspect its state wrote an RS256 private key into the worktree (data/service-keys/jwt_rsa_private.pem— gitignored, tree stayed clean, removed). That is the never-run-repo-code rule earning itself in a single command, and I finished the diagnosis by reading the source instead.Not claimed
The remaining 26 are untouched. 22 of the 27 were tier 1 (mutating, no public-by-design evidence) and tier 4 is empty — not one of the 27 carries any public-by-design language, including
prometheus_endpoint, so every one is ungated without a recorded posture rather than by one. That reframes the rest of #16375 as a posture decision per module before it is a code change, and it is why this PR gates one router rather than twenty-six.Refs #16375
Refs #17722
Model Used
Claude Opus 5 (1M context)