Skip to content

security(users): /user-management/users/search answers anonymous callers with names from every organisation #16279

Closed

Description

@mrveiss

Finding

GET /api/user-management/users/search (autobot-backend/api/user_management/users.py:83–:146) takes no auth dependency. It queries through TenantContext(org_id=None, user_id=None, is_platform_admin=True) (users.py:115) and returns id and name=user.full_name for each match (:126–:127). So an anonymous caller can enumerate user ids and full names across every organisation.

Evidence

  • Open posture is recorded; the cross-org scope is not. user_management_route_posture_test.py:62 pins the route as _OPEN (search_users_for_sharing, Bug: ShareKnowledgeDialog uses hardcoded mock data instead of API for user/group search #2072). I found no record of it being meant to search across organisations.
  • Live: an unauthenticated GET against the deployed backend returned 200. The control, /api/auth/me with the same empty credentials, returned 401. I didn't inspect the response body.
  • No shipped caller: the only frontend caller is ShareKnowledgeDialog.vue:270, apart from the generated API types. That dialog is imported only by its Storybook story, ShareKnowledgeDialog.stories.ts, so no shipped view depends on the anonymous or cross-org behaviour today.

Decision needed

Is #2072's open posture still wanted? And whatever the answer, should the search be confined to the caller's organisation? My recommendation: require authentication and scope the search to the caller's org, since sharing only makes sense within an org and the dialog is not mounted anywhere.

Acceptance criteria

  • The posture is decided on this issue, and user_management_route_posture_test.py:62 is updated to match.
  • The search is scoped to the caller's organisation, with no platform-admin context built for an anonymous or ordinary caller.
  • A test asserts that a user in org A gets no results from org B.
  • After deploy, the deployed backend's behaviour matches the ruled posture. This needs host evidence.

Found during the #16270 permission-vocabulary investigation.

Activity

  1. added
    needs-decisionBlocked on an owner decision; options and a recommendation are on the issue
    area: auth-rbacWave 2 · cluster E — Auth, identity & RBAC canon
    on Sep 11, 2026
  2. mrveiss commented on Sep 11, 2026

    @mrveiss
    OwnerAuthor

    Owner ruling (2026-09-11): require login, and scope results to the caller's own organisation. Nothing shipped depends on the open or cross-org behaviour: the only caller, ShareKnowledgeDialog.vue, is mounted by nothing but its Storybook story. This rides the #16278 batch PR (#16287), since it is the same file (users.py) and the same posture table as #15738.

  3. removed
    needs-decisionBlocked on an owner decision; options and a recommendation are on the issue
    on Sep 11, 2026
  4. added this to the v0.9.0 milestone on Sep 12, 2026
  5. mrveiss commented on Sep 12, 2026

    @mrveiss
    OwnerAuthor

    Progress after PR #16240 (f5fe688): 3 of 4 criteria met, including live host evidence. This stays open for one test gap; the follow-up PR is next.

    The ruling above planned for this to ride #16287. That PR closed unmerged, and the work landed in #16240 instead. The deployed code contains f5fe688.

    Acceptance criteria (verbatim):

    • "The posture is decided on this issue, and user_management_route_posture_test.py:62 is updated to match." ✅ The owner ruled on this issue on 2026-09-11. The posture table now records ("GET", "/user-management/users/search"): _AUTHENTICATED, and test_no_route_is_open asserts that no user-management route is open.
    • "The search is scoped to the caller's organisation, with no platform-admin context built for an anonymous or ordinary caller." ✅ search_users_for_sharing depends on require_org_context, which needs a logged-in caller with an org and answers 400 otherwise. It also depends on get_user_service, whose apply_tenant_filter adds org_id == context.org_id. No hand-built context remains.
    • "A test asserts that a user in org A gets no results from org B." ❌ Not met as worded. The existing tests check that the service context carries the caller's org, and that the org is a bound query parameter, but no test seeds an org-B user and checks it is absent. The follow-up adds one.
    • "After deploy, the deployed backend's behaviour matches the ruled posture. This needs host evidence." ✅ Host evidence, 2026-09-12, on the local install, through the product's own admin API (owner-approved probe; ids redacted):
    request status result
    unauthenticated GET /api/user-management/users/search 401 refused
    POST /api/user-management/organizations, throwaway orgs A and B, as platform admin 201, 201 created
    POST /api/user-management/users, one user in each org 201, 201 created
    POST /api/auth/login as each throwaway user 200, 200 logged in
    org A user: GET .../users/search?q=<org B username> 200 available: true, 0 results
    org B user: GET .../users/search?q=<org A username> 200 available: true, 0 results
    control, org A user searching their own username 200 available: true, 1 result (themselves)
    control, org B user searching their own username 200 available: true, 1 result (themselves)

    available: true matters: on a database error the search returns an empty list with available: false. The two controls show the search itself works, so the empty cross-org results come from scoping, not from a failure.

    Cleanup did not complete as planned. All four hard deletes (?hard_delete=true) returned 500. The organisation deletes fail on relation "terminal_activities" does not exist, the missing table tracked in #16464. I soft-deleted all four instead (?hard_delete=false, 200 each), and they no longer appear in listings. Hard-deleting them waits on #16464.

  6. mrveiss commented on Sep 12, 2026

    @mrveiss
    OwnerAuthor

    Stale-issue sweep (never auto-closed — Dev_new_gui wasn't GitHub's default branch at merge time). Verified against current origin/main; leaving open.

    3 of 4 criteria MET: posture decided and recorded — users.py:83-127 search_users_for_sharing now depends on require_org_context (:107) instead of a hand-built platform-admin TenantContext; search is scoped to the caller's org via apply_tenant_filter, no anonymous/platform-admin context remains. A prior comment on the issue (2026-09-12) also records an end-to-end probe with two throwaway orgs/users showing 401 unauthenticated and 0 cross-org results with a working-search control.

    NOT MET: AC3 as literally stated — user_management_gates_test.py only has test_the_user_query_is_confined_to_the_callers_organisation (checks the compiled SQL binds _ORG as a parameter, unit-level) and test_the_search_runs_in_the_callers_own_organisation (checks a mocked context) — no test seeds two real users in two real orgs and asserts cross-org exclusion end-to-end in one place.

    Delivered by merged PR #16240 (same batch as #16278) — partial; a follow-up for the test gap hasn't merged yet.

  7. mrveiss commented on Sep 13, 2026

    @mrveiss
    OwnerAuthor

    Closure audit (batch 2c): reopened

    Checked against origin/main (PR #16506, abf8432). 3 of 4 ACs verified from code and ticked. AC4 needs host evidence, so under the audit rule this is reopened.

    Met

    • AC1: the owner's ruling is on this issue; autobot-backend/api/user_management/user_management_route_posture_test.py:79 ("GET", "/user-management/users/search"): _AUTHENTICATED.
    • AC2: api/user_management/users.py:105 _org: TenantContext = Depends(require_org_context). require_org_context answers 400 without org_id. Platform-admin is set only if _is_platform_admin_claim(current_user), behind get_current_user.
    • AC3: autobot-backend/user_management/services/user_service_org_scoping_test.py::test_a_user_in_org_a_finds_no_results_from_org_b asserts [u.username for u in users] == ["alice"] across two orgs. It tests the service, not the route.

    Remaining

    • AC4 (host evidence): the probe comment on this issue (2026-09-12, 13:14 Riga time) records anonymous → 401, cross-org → 0 results both ways, own-org → 1, against a deploy containing the fix. test(user-management): a user in org A gets no results from org B (#16279) #16506 changed tests only. If the owner accepts that comment as the AC4 evidence, tick AC4 and re-close. This audit did not re-run the probe.
  8. mrveiss commented on Sep 13, 2026

    @mrveiss
    OwnerAuthor

    Re-closed: all 4 criteria are verified. ACs 1–3 have code evidence on origin/main (audit comment above). AC4 has the live host probe recorded on this issue on 2026-09-12: anonymous callers get 401, cross-org searches return 0 results both ways, and own-org searches return 1. That ran against a deploy containing the fix (f5fe688). #16506, which completed AC3, changed tests only, so the deployed behaviour it probed is unchanged.

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

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions