Skip to content

Filter cross-team memberships from user list responses#48890

Open
lukeheath wants to merge 3 commits into
mainfrom
fix-list-users-cross-team-teams-leak
Open

Filter cross-team memberships from user list responses#48890
lukeheath wants to merge 3 commits into
mainfrom
fix-list-users-cross-team-teams-leak

Conversation

@lukeheath

@lukeheath lukeheath commented Jul 7, 2026

Copy link
Copy Markdown
Member

From Lucas:

  • QA'd all new/changed functionality manually

Summary

A team-scoped admin listing users of a team they administer (GET /api/latest/fleet/users?team_id=A) received the full team membership — team IDs, names, and roles — of any user also shared with other teams, disclosing teams the requester has no role in.

The single-user GET /users/{id} endpoint already blocks this: its authorization requires the requester to administer every team the target belongs to. The list endpoint authorizes against a synthetic single-team object (correct, so team admins can manage their members), but then returned each user's complete team list as loaded by the datastore.

This filters each returned user's teams down to the requester's scope at the response layer. Requesters with any global role are unchanged (they're authorized to see all teams).

Why the response layer, not Service.User

ModifyUser and the password-reset flow reuse Service.User and read user.Teams to compute write diffs. Filtering there would silently drop team memberships on edits, so the filter is applied in listUsersEndpoint only.

GET /users/{id} is intentionally not changed — it is not exploitable (authz already requires admin-of-all-the-target's-teams), and its legitimate readers should keep seeing the full team list.

Testing

  • TestListUsersFiltersTeamsToRequesterScope — team-1 admin listing team 1 sees only team 1 for a user shared with {1,2}.
  • TestListUsersGlobalRequesterSeesAllTeams — global admin sees all teams.
  • Existing TestUserAuth / TestAuthorizeUser pass unchanged (no authz regression).

Fixes fleetdm/confidential#16691

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed user listing so returned team membership details are scoped to the requesting user’s permissions, including fleet-scoped context.
    • Team-scoped requesters now only see memberships for teams they’re allowed to view; global-role requesters still see all memberships.
    • When scoped viewer context is missing, team membership details are no longer included in the response.

Copilot AI review requested due to automatic review settings July 7, 2026 19:09
@lukeheath lukeheath requested a review from a team as a code owner July 7, 2026 19:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

This PR fixes an information disclosure in the team-scoped GET /api/latest/fleet/users endpoint by ensuring that, for team-scoped requesters, each returned user’s teams list is filtered to only the teams the requester has a role in (while global-role requesters still see all teams).

Changes:

  • Filter user.Teams in listUsersEndpoint to the requester’s authorized team scope (global roles unchanged).
  • Add endpoint-level tests covering team-scoped filtering and global-role passthrough.
  • (Unreviewable here) Updates to handbook/company/product-groups.md and changes/16691-cross-team-user-list-teams-authz were excluded by policy.

Reviewed changes

Copilot reviewed 2 out of 4 changed files in this pull request and generated no comments.

File Description
server/service/users.go Adds response-layer filtering of user.Teams based on requester scope in listUsersEndpoint.
server/service/users_test.go Adds regression tests to ensure cross-team memberships aren’t disclosed to team-scoped admins, while global admins see all teams.
handbook/company/product-groups.md Content excluded from diff by policy (not reviewable).
changes/16691-cross-team-user-list-teams-authz Content excluded from diff by policy (not reviewable).
Files excluded by content exclusion policy (2)
  • changes/16691-cross-team-user-list-teams-authz
  • handbook/company/product-groups.md

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change updates ListUsers so returned user team memberships are filtered by the requesting viewer’s scope. A helper preserves all teams for global-role requesters and filters to only shared teams for team-scoped requesters. Two tests cover the scoped and global cases, and the changelog records the users list context fix.

Possibly related PRs

  • fleetdm/fleet#47423: Also changes server/service/users.go to scope returned user team data based on requester permissions.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: filtering cross-team memberships from user list responses.
Description check ✅ Passed The description covers the issue, rationale, testing, and fix details, though it does not fully mirror the template's checklist structure.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-list-users-cross-team-teams-leak

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/service/users_test.go (1)

1915-1952: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding a case for a requester with mixed team roles.

Neither test covers a requester who administers the queried team but also holds a non-admin role (e.g., observer) in another team the shared user belongs to. Adding this case would validate the "any role" vs "administers" concern raised in users.go's filterUserTeamsToRequesterScope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/users_test.go` around lines 1915 - 1952, Add a test in
TestListUsersFiltersTeamsToRequesterScope that covers a requester who
administers the queried team but also has a non-admin role in another team the
returned user belongs to, so you exercise the mixed-role edge case in
listUsersEndpoint and filterUserTeamsToRequesterScope. Reuse the existing
sharedUserTeams setup, but make the viewer user include team 1 as admin and team
2 as a non-admin role, then assert the listed user is still filtered to only the
requested team scope. This will verify the behavior around “any role” versus
“administers” using the existing listUsersResponse and fleet.UserTeam symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/service/users_test.go`:
- Around line 1915-1952: Add a test in TestListUsersFiltersTeamsToRequesterScope
that covers a requester who administers the queried team but also has a
non-admin role in another team the returned user belongs to, so you exercise the
mixed-role edge case in listUsersEndpoint and filterUserTeamsToRequesterScope.
Reuse the existing sharedUserTeams setup, but make the viewer user include team
1 as admin and team 2 as a non-admin role, then assert the listed user is still
filtered to only the requested team scope. This will verify the behavior around
“any role” versus “administers” using the existing listUsersResponse and
fleet.UserTeam symbols.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 89b53071-9b8b-44ae-979b-bbb82bd39c9d

📥 Commits

Reviewing files that changed from the base of the PR and between ceeaf9e and 32c433c.

⛔ Files ignored due to path filters (1)
  • handbook/company/product-groups.md is excluded by !**/*.md
📒 Files selected for processing (3)
  • changes/16691-cross-team-user-list-teams-authz
  • server/service/users.go
  • server/service/users_test.go

A team-scoped admin listing users of a team they administer received the
full team membership (IDs, names, roles) of any user shared with other
teams, disclosing teams the requester has no role in. The single-user
GET /users/{id} endpoint already blocks this via authz; the list endpoint
authorizes against a synthetic single-team object and returned unfiltered
teams loaded by the datastore.

Filter each returned user's teams to the requester's scope at the response
layer. Global roles are unchanged. Filtering is kept out of Service.User
because ModifyUser and password reset reuse it and read Teams to compute
write diffs.

For fleetdm/confidential#16691

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.23529% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.05%. Comparing base (667b371) to head (ad9877c).
⚠️ Report is 59 commits behind head on main.

Files with missing lines Patch % Lines
server/service/users.go 88.23% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #48890    +/-   ##
========================================
  Coverage   68.05%   68.05%            
========================================
  Files        3684     3690     +6     
  Lines      234062   234970   +908     
  Branches    12463    12463            
========================================
+ Hits       159294   159914   +620     
- Misses      60452    60658   +206     
- Partials    14316    14398    +82     
Flag Coverage Δ
backend 69.69% <88.23%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lukeheath lukeheath force-pushed the fix-list-users-cross-team-teams-leak branch from 32c433c to 19abd1f Compare July 7, 2026 19:41
Comment thread changes/16691-cross-team-user-list-teams-authz Outdated

@lucasmrod lucasmrod left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Left some comments.

Comment thread server/service/users.go Outdated
Comment thread server/service/users.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/service/users.go (1)

482-493: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard requester before dereferencing viewer.FromContext only guarantees a Viewer, not a populated Viewer.User, so this helper can still panic on requester.HasAnyGlobalRole(). Add a nil check before filtering teams.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/users.go` around lines 482 - 493, The helper
filterUserTeamsToRequesterScope can panic when requester is nil because it calls
requester.HasAnyGlobalRole() before any guard. Update this function to handle a
nil requester up front, returning the appropriate filtered result without
dereferencing requester, and keep the existing team-role filtering logic intact
for non-nil users.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/service/users.go`:
- Around line 482-493: The helper filterUserTeamsToRequesterScope can panic when
requester is nil because it calls requester.HasAnyGlobalRole() before any guard.
Update this function to handle a nil requester up front, returning the
appropriate filtered result without dereferencing requester, and keep the
existing team-role filtering logic intact for non-nil users.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e58f46c8-6852-4c6d-b9fc-ca90369bdb04

📥 Commits

Reviewing files that changed from the base of the PR and between 4c6e0a7 and ad9877c.

📒 Files selected for processing (1)
  • server/service/users.go

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.

4 participants