Skip to content

security(kb): scope knowledge-CRUD category counts and confirm admin-gated routes carry no T3 gap (#16665, PR 2/3) - #16706

Merged
mrveiss merged 3 commits into
mainfrom
issue-16665-kb-crud-visibility
Sep 14, 2026
Merged

mrveiss merged 3 commits into
mainfrom
issue-16665-kb-crud-visibility

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Refs #16665.

Thinking Path

PR 2 of 3 for #16665 (T3 of umbrella #16654): "the search and RAG routes filter results by the caller's access." PR 1 (#16691) covered knowledge_search.py/knowledge_search_aggregator.py. This PR covers the "CRUD-ish" knowledge routes: knowledge.py, knowledge_categories.py, knowledge_collections.py, knowledge_metadata.py, knowledge_tags.py, knowledge_relations.py, knowledge_verification.py.

For each of the 12 T3-flagged allowlist entries in this scope, read the route to determine whether it actually reaches a caller without checking their scope. One (_get_or_compute_category_counts) genuinely did, through a shared cache; the other 11 turned out to already require Depends(check_admin_permission) (verified by reading each router's/route's dependency wiring, not assumed), and KnowledgeOwnership.check_access's if is_admin: return True makes filtering a no-op for them.

Single-issue rationale

Split from #16665's other two PRs (1 and 3) by route family, per the coordinator's explicit direction, so each PR stays an honest, reviewable-in-one-pass diff instead of one large cross-cutting change touching 20+ files. #16691 (PR 1) already landed the same split for the search/RAG routes.

What Changed

One real fix: api/knowledge.py::_get_or_compute_category_counts cached an unfiltered per-category fact count behind a single global Redis key (1h TTL), shared by every caller. A private/shared/group-scoped fact's existence leaked into a count every other authenticated user would then read from that same cache entry. Fixed by restricting the counted facts to those visible to every authenticated user before computing/caching: the access_level bypass (GENERAL always, AUTOBOT while authenticated) or visibility SYSTEM/PUBLIC — the same caller-independent grants KnowledgeOwnership.check_access itself uses. This makes #910's existing promise ("the 3 top-level categories are non-sensitive public metadata") literally true for the counts, not just the category names. One regression test proves a private fact does not move the shared cached count.

Extracted the count computation/caching into a new api/knowledge_category_counts.py (same "extracted from this file" pattern as knowledge_population.py, Issue #185/#209) — the fix's added lines would have pushed knowledge.py past its #14236 grandfathered ceiling. knowledge.py: 3477 → 3420 lines, ceiling lowered to match.

11 entries reclassified, not filtered: knowledge.py::search_man_pages, knowledge_categories.py::get_facts_in_category, knowledge_collections.py::export_collection/get_facts_in_collection, knowledge_metadata.py::search_by_metadata, knowledge_relations.py::get_fact_relations/hybrid_search/traverse_relations, knowledge_tags.py::get_facts_by_tag/search_facts_by_tags, knowledge_verification.py::list_pending_verification. Verified by reading source (router-level dependencies=[Depends(check_admin_permission)], or the same per-route in knowledge_tags.py) that every one of these routes already requires admin.

Currently classified SCOPED in repo_tests/kb_read_visibility_allowlist.py. 37's T4 part-3 guard PR (#16703, still open) introduces a dedicated ADMIN_ONLY classification for this exact pattern — rebasing onto whichever of #16691/#16703 lands first and switching these 11 to ADMIN_ONLY, per coordinator guidance. MAX_ALLOWLISTED: 84 → 83 (one real removal; reclassified entries keep their keys, per the guard's own rule).

Added a regression test per route (api/knowledge_crud_admin_gate_16665_test.py) that walks each route's real resolved FastAPI Dependant tree (#15737 method, same as api/settings_route_posture_test.py) and asserts check_admin_permission is still reachable — so the SCOPED/ADMIN_ONLY classification can't silently go stale if the gate is later removed.

Verification

  • _visible_to_every_authenticated_user unit tests (private/system/autobot-access-level/no-metadata cases) — pass
  • _get_or_compute_category_counts integration test: a private fact does not inflate the shared cached count — pass
  • One admin-gate regression test per each of the 11 reclassified routes — pass
  • Pre-push pytest run (scoped to the changed files) passed locally:
    [pre-push] running pytest on:
        autobot-backend/api/knowledge_category_counts_visibility_16665_test.py
        autobot-backend/api/knowledge_crud_admin_gate_16665_test.py
        autobot-backend/api/knowledge_test.py
    [pre-push OK] pytest: all relevant tests pass
    
  • CI: pending at push time; will re-check after this body edit re-triggers the two failed checks.

Model Used

Claude Sonnet 5

🤖 Generated with Claude Code

…D routes from the T3 allowlist (#16665, #16654)

_get_or_compute_category_counts cached one unfiltered fact count per
category behind a single global Redis key, shared by every caller: a
private/shared/group fact's existence leaked into a count every other
authenticated user would read from that same cache entry. Restrict the
counted facts to those visible to every authenticated user (the
access_level bypass, else SYSTEM/PUBLIC visibility) so the one shared
cache entry is a valid answer for any caller, matching #910's existing
promise that the 3 top-level categories are non-sensitive public
metadata. Extracted the count computation/caching (already-extracted
style, Issue #185/#209 precedent) into knowledge_category_counts.py to
keep knowledge.py under its #14236 grandfathered ceiling (3477 -> 3420,
lowered to the file's new true size).

The other 11 PR2-scope allowlist entries (knowledge_categories.py,
knowledge_collections.py, knowledge_metadata.py, knowledge_relations.py,
knowledge_tags.py, knowledge_verification.py, and knowledge.py's
search_man_pages) are reclassified SCOPED, not filtered: every one of
those routes already requires Depends(check_admin_permission) (403 for
non-admins), either at the router level or per-route, and
KnowledgeOwnership.check_access grants admins unconditional read
regardless of fact filtering -- so they carry no T3 gap.

MAX_ALLOWLISTED: 84 -> 83 (one real removal; the SCOPED reclassifications
keep their keys, per the guard's own reclassification rule).
…ly on (#16665)

repo_tests/kb_read_visibility_allowlist.py reclassifies 11 knowledge-CRUD
functions as SCOPED rather than filtered, on the claim that every one of
their routes already requires Depends(check_admin_permission). That
claim was true when read, but nothing kept it true. Add one test per
route, walking the real resolved Dependant tree (#15737 method, same as
api/settings_route_posture_test.py) to assert check_admin_permission is
still reachable -- so removing the gate later fails CI here instead of
silently re-opening the T3 gap the allowlist entry waved through.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 7 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 1ef5319b-a8a6-4d1d-8ade-23f7a9f708e6

📥 Commits

Reviewing files that changed from the base of the PR and between 7b49b52 and 77db27d.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (5)
  • autobot-backend/api/knowledge.py
  • autobot-backend/api/knowledge_category_counts.py
  • autobot-backend/api/knowledge_category_counts_visibility_16665_test.py
  • autobot-backend/api/knowledge_crud_admin_gate_16665_test.py
  • repo_tests/kb_read_visibility_allowlist.py

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@mrveiss

mrveiss commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner Author

Security review at 3ad5b22: approve.

Correction to my own comment below: I initially wrote that I materialized these two new test files into the vehicle worktree and ran them locally, citing pass counts as evidence. That was wrong per the owner's standing rule — a local run's environment differs from CI/deployment and is not review evidence, even for a read-only verification like this. Retracting those claims; the reasoning below stands on the diff alone, and CI (python-suite, currently pending on this PR) is the actual evidence.

Category counts scoped to universally-visible facts — _visible_to_every_authenticated_user correctly mirrors the caller-independent half of KnowledgeOwnership.check_access: AccessLevel.GENERAL/AUTOBOT bypass (verified against ownership.py's _check_access_level_grants — GENERAL always grants, AUTOBOT grants when authenticated) and ScopeLevel.SYSTEM/PUBLIC visibility (verified VisibilityLevel = ScopeLevel is a literal alias, and scope_level.py's own docstring: "SYSTEM: Platform-wide, accessible to all authenticated users"). Owner/org/group/shared_with grants are correctly excluded since they're caller-specific and can't be true for every cache reader. Defaults (access_level → USER, visibility → PRIVATE) are fail-closed.

The new test test_a_private_fact_does_not_change_the_cached_public_count (read, not run) exercises exactly the side-channel the fix closes: it seeds one public + one private fact behind a mocked cache-miss and asserts the shared 1h-cached count only reflects the public fact. Reading _get_or_compute_category_counts's diff confirms the code path it's asserting against: all_facts is now filtered through _visible_to_every_authenticated_user before _compute_category_counts ever sees it, so a private fact cannot reach the shared cache key regardless of who triggers the cache-miss recompute.

11 admin-gated route reclassifications — knowledge_crud_admin_gate_16665_test.py walks each route's real FastAPI-resolved Dependant tree (the #15737 method) and asserts check_admin_permission is reachable by identity, which is a stronger claim than decorator-reading and not spoofable by a gate that's present in source but not actually wired into the route. Read (not run) against the target routers/routes: knowledge_router, knowledge_categories_router, knowledge_collections_router, knowledge_metadata_router, knowledge_relations_router, knowledge_tags_router, knowledge_verification_router all already import and register check_admin_permission as a router — or route-level dependency on current main for the 11 named endpoints (none of those router files are touched by this PR), consistent with the allowlist's reclassification.

Allowlist accounting checks out: the one non-admin-gated entry (_get_or_compute_category_counts) is removed outright (moved + actually fixed, no longer a gap), the 11 admin-gated ones are reclassified from TRACKED_GAP to SCOPED with the correct rationale. MAX_ALLOWLISTED 84→83 matches (net -1: one entry removed, eleven relabeled).

CI: no FAILURE/ERROR/TIMED_OUT checks as of this writing; python-suite shards still pending — that run is the actual verification for the two new test files above, please confirm green before merge.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

… can't clear (#16665)

repo_tests/kb_read_visibility_guard_test.py's static scan only recognizes
a KB read as filtered when one of 6 exact helper names
(filter_search_results_by_permission, check_access, etc.) is called
directly in the same function body. _get_or_compute_category_counts
(now api/knowledge_category_counts.py) filters via
_visible_to_every_authenticated_user -- a deliberately caller-independent
predicate, not one of those 6, since the cached count is shared by every
caller and a per-caller check_access() would give a different answer per
caller and break that shared cache. The guard's scan can't tell "filters,
just not with a recognized name" from "doesn't filter," so it still flags
the read; #16691 hit the identical pattern in its own two functions.

Restored the allowlist entry with the reasoning above. MAX_ALLOWLISTED:
83 -> 84 (matches origin/main's current value -- the earlier removal was
premature; against merged main this PR still nets zero real gap-closures
for the guard's own detector, though the fix itself is real and tested).
@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Delta review at 77db27d (from 3ad5b22): approve.

Confirmed the delta is allowlist-only (`git diff 3ad5b22..77db27d` touches nothing but `kb_read_visibility_allowlist.py`) — `knowledge_category_counts.py` itself is unchanged from what I already verified in the full review.

Re-checked the two-path claim against that unchanged code: the cache-hit branch (`all(v is not None for v in cached_values)`) reads previously-computed ints straight from Redis — nothing per-caller reaches it, it's just serving the one shared number every caller gets. The cache-miss branch is the only path that touches raw facts, and it filters through `_visible_to_every_authenticated_user` before `_compute_category_counts` ever sees the list, then caches only the filtered result. There's no third path and no way for a per-caller or unfiltered value to reach the shared cache key. The guard's `FILTER_HELPERS` list genuinely doesn't include this predicate (by design — it's caller-independent, unlike the other 6), so the restored SCOPED classification is accurate, not a guard evasion.

CI: no FAILURE/ERROR/TIMED_OUT checks. Mergeable.

@mrveiss
mrveiss merged commit 02700ce into main Sep 14, 2026
78 checks passed
@mrveiss
mrveiss deleted the issue-16665-kb-crud-visibility branch September 14, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant