Skip to content

security(kb): only admins may make a fact platform-wide, and visibility writes keep the indexes current #16663

Description

@mrveiss

Parent: #16654.

Problem

The evidence is on main at e42be9cb6.

  • Any owner can make a fact platform-wide.
    • PUT /api/knowledge/collaboration/facts/{id}/permissions (api/knowledge_collaboration.py:505) lets any signed-in owner set SYSTEM. Only ORGANIZATION is restricted (:112-118).
    • SYSTEM and PUBLIC are visible to every signed-in user (autobot_shared/scoping/scope_level.py:44-45; autobot_shared/scoping/visibility.py:76-78).
    • The UI offers SYSTEM to admins only (autobot-frontend/src/components/knowledge/KnowledgeScopeSelector.vue:29).
  • The admin visibility write skips the indexes. PUT /api/knowledge/facts/{id}/visibility (api/knowledge_ownership.py:207) writes through update_fact (:250) without set_owner, so the Redis visibility indexes go stale.
  • Ingestion never writes the org or group indexes. Its set_owner call (knowledge/facts.py:599-608) never passes organization_id or group_ids, so get_all_accessible_facts misses those facts.

Owner decision (2026-09-13, on #16654)

Only admins may set SYSTEM, and PUBLIC too, since it behaves the same.

Acceptance criteria

  • A non-admin gets 403 when setting SYSTEM or PUBLIC through any endpoint, and an admin can set them. Tested.
  • Every visibility write goes through set_owner, so the indexes match the metadata. Tested.
  • Ingestion writes the org and group ownership when a fact carries them. Tested.

Refs #16654

Activity

  1. added this to the v0.11.0 milestone on Sep 14, 2026
  2. mrveiss commented on Sep 14, 2026

    @mrveiss
    OwnerAuthor

    Closure audit against origin/main at 7b49b52, after #16677 (squash 7b49b52). I'm reopening this: AC1 is not met as written. AC2 and AC3 are met and ticked.

    AC2: met. update_fact reindexes whenever ownership changes (knowledge/facts.py:1144, await reindex_ownership(...)), and index_ownership goes through ownership_manager.set_owner. Tested by test_a_permissions_change_is_written_through_update_fact_not_the_hash and test_update_fact_takes_a_demoted_fact_out_of_the_system_index.

    AC3: met. Ingestion calls await index_ownership(self.ownership_manager, fact_id, metadata) at knowledge/facts.py:602, and the forwarded keys include organization_id and group_ids. Tested by test_indexing_forwards_org_group_and_access_level and test_ingestion_files_the_fact_under_its_organization_and_group.

    AC1: not met. The criterion reads "a non-admin gets 403 when setting SYSTEM or PUBLIC through any endpoint, and an admin can set them".

    • The permissions route does this: api/knowledge_collaboration.py:115-116 raises 403 for a non-admin asking for a platform-wide visibility.
    • The MCP add route does not refuse. api/knowledge_mcp.py:514 passes the metadata through drop_ownership_unless_admin(request.metadata, current_user.get("role")), which silently removes a non-admin's SYSTEM/PUBLIC request and returns success. The caller is told the write worked, but the fact is not platform-wide.
    • Chat-derived ingestion at api/chat_knowledge_manager.py:331 calls the same helper with a role of None, so it strips visibility for every caller, admins included.

    The privilege escalation is closed on every path; the gap is the refusal contract. To meet AC1, make the MCP add route return 403 when a non-admin asks for SYSTEM or PUBLIC, and keep stripping only the owner fields the caller cannot set. Add a route-level test for both the non-admin 403 and the admin success. The chat path has no caller-supplied visibility to refuse, so it can keep stripping, but that should be stated here as an explicit scope note rather than left implicit.

  3. mrveiss commented on Sep 14, 2026

    @mrveiss
    OwnerAuthor

    Re-opened AC1: the refusal contract is being fixed in PR #16705 (batched with #16662's remainder).

    MCP add route (api/knowledge_mcp.py). A non-admin asking for platform-wide reach now gets 403 instead of a silent strip and a reported success. Platform-wide reach means visibility system or public, or an access_level of general or autobot: the owner's rule gives both every signed-in user. The check runs before the route's try, which would otherwise swallow the error. Otherwise only the owner fields (owner_id, user_id) are stripped, and the rest of the metadata is kept. An admin's request is stored as sent. There are route-level tests for the non-admin 403 on all three shapes, admin success, and the owner field being stripped.

    Scope note, the chat path (api/chat_knowledge_manager.py, ADD_TO_KB). It keeps drop_ownership_unless_admin(..., None), the full strip. That path has no caller role (the router is unauthenticated, tracked on #16375). The metadata it forwards is the item the chat flow stored, and the system then sets owner_id and visibility itself, so a caller-supplied visibility never needs a refusal there. AC1's "any endpoint" therefore means every endpoint where a caller can ask for a visibility. The collaboration permissions route already returns 403 (_apply_visibility_to_metadata).

  4. mrveiss commented on Sep 14, 2026

    @mrveiss
    OwnerAuthor

    Correction to my previous comment. The MCP add route does not keep a non-admin's non-owner fields.

    The delta code review of PR #16705 found that stripping only owner_id/user_id would let a non-admin keep visibility: organization with another org's organization_id, or group, shared, group_ids or shared_with values. That files a fact into orgs and groups the caller isn't in, because is_visible and the ChromaDB pre-filter grant org and group reach from the metadata alone, and the route has no identity binding to validate those fields.

    So the behaviour in #16705 is:

    • a non-admin asking for platform-wide reach gets 403, which is AC1's refusal contract. That means SYSTEM or PUBLIC visibility, or a general or autobot access level, compared case- and whitespace-insensitively;
    • all of a non-admin's other ownership fields are dropped (drop_ownership_unless_admin), as before;
    • an admin's request is stored as sent.

    A cross-tenant regression test pins it.

  5. mrveiss commented on Sep 14, 2026

    @mrveiss
    OwnerAuthor

    A correction to my audit comment above. "Keep stripping only the owner fields the caller cannot set" should not be read as "strip only owner_id/user_id".

    A non-admin cannot set any ownership field through MCP add. That covers organization_id, group_ids and shared_with as well, together with the visibility or scope that goes with them. Otherwise a non-admin could file a fact as readable by another org, group or set of users, because is_visible grants org and group access from the metadata alone, and the MCP route has no identity binding that could validate those fields.

    The required behaviour for AC1 on this route:

    • A non-admin requesting SYSTEM or PUBLIC visibility, or GENERAL or AUTOBOT access level, gets 403.
    • Every other ownership field a non-admin supplies is dropped, exactly as drop_ownership_unless_admin does.
    • A cross-tenant regression test pins that org, group and share fields never reach storage from a non-admin.

    #16705 is implementing this.

  6. mrveiss commented on Sep 14, 2026

    @mrveiss
    OwnerAuthor

    Closure audit against origin/main after today's merge of PR #16705 (vehicle #16746, merge commit 1d5a2d472), delivering the refusal contract discussed in the comments above.

    AC verdict evidence
    Non-admin gets 403 for SYSTEM/PUBLIC or GENERAL/AUTOBOT (case/whitespace-normalised) through any endpoint; admin can set them met api/knowledge_mcp.py:508-509 (mcp_add_to_knowledge_base): if refuses_platform_wide(request.metadata, current_user.get("role")): raise HTTPException(403, ...), checked before the try so it isn't swallowed; knowledge/ownership_index.py's requests_platform_wide/_norm cover visibility system/public and access_level general/autobot, .strip().lower()-normalised; drop_ownership_unless_admin strips every ownership field (OWNERSHIP_KEYS+_OWNER_KEYS), not just owner fields, for a non-admin; admin path calls dict(metadata or {}) unchanged. Tests: api/knowledge_mcp_add_policy_16663_test.py::test_a_non_admin_asking_for_platform_wide_reach_gets_403 (parametrized), test_an_admin_stores_a_system_fact_as_asked, test_a_non_admin_cannot_file_a_fact_into_another_org_group_or_share (cross-tenant regression test)
    Every visibility write goes through set_owner met (already ticked) unchanged from prior audit
    Ingestion writes org/group ownership met (already ticked) unchanged from prior audit

    3/3 met. Kept closed.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions