From 19abd1f42c7a2abe1c5b1b5ddeba36348fa09c4d Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Tue, 7 Jul 2026 12:01:44 -0700 Subject: [PATCH 1/3] Filter cross-team memberships from user list responses 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 --- .../16691-cross-team-user-list-teams-authz | 1 + server/service/users.go | 34 ++++++++- server/service/users_test.go | 71 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 changes/16691-cross-team-user-list-teams-authz diff --git a/changes/16691-cross-team-user-list-teams-authz b/changes/16691-cross-team-user-list-teams-authz new file mode 100644 index 00000000000..ccab9092f2a --- /dev/null +++ b/changes/16691-cross-team-user-list-teams-authz @@ -0,0 +1 @@ +- Fixed team-scoped admins seeing team memberships (IDs, names, and roles) for teams they do not administer when listing users shared across multiple teams via `GET /api/latest/fleet/users`. diff --git a/server/service/users.go b/server/service/users.go index 51b38312daa..cb05a02dd59 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -469,13 +469,45 @@ func listUsersEndpoint(ctx context.Context, request interface{}, svc fleet.Servi return listUsersResponse{Err: err}, nil } + // The datastore loads each user's full team membership. A team-scoped + // requester is only authorized to list users of a team they administer, so + // strip any team memberships (IDs/names/roles) for teams the requester has + // no role in before returning them. Global roles are authorized to see all + // teams and are left untouched. + var requester *fleet.User + if vc, ok := viewer.FromContext(ctx); ok { + requester = vc.User + } + resp := listUsersResponse{Users: []fleet.User{}} for _, user := range users { - resp.Users = append(resp.Users, *user) + u := *user + u.Teams = filterUserTeamsToRequesterScope(u.Teams, requester) + resp.Users = append(resp.Users, u) } return resp, nil } +// filterUserTeamsToRequesterScope returns the subset of teams that the +// requester is permitted to see. A requester with any global role sees all +// teams unchanged; a team-scoped requester sees only teams they have a role in. +// A nil requester (should not happen on authenticated endpoints) is treated as +// having no scope, so nothing is disclosed. +func filterUserTeamsToRequesterScope(teams []fleet.UserTeam, requester *fleet.User) []fleet.UserTeam { + if requester != nil && requester.HasAnyGlobalRole() { + return teams + } + filtered := make([]fleet.UserTeam, 0, len(teams)) + if requester != nil { + for _, t := range teams { + if requester.HasAnyRoleInTeam(t.ID) { + filtered = append(filtered, t) + } + } + } + return filtered +} + func (svc *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) { user := &fleet.User{} if opt.TeamID != 0 { diff --git a/server/service/users_test.go b/server/service/users_test.go index 76bb14823d9..b81e90208fa 100644 --- a/server/service/users_test.go +++ b/server/service/users_test.go @@ -1907,3 +1907,74 @@ func TestPasswordChangeClearsTokensAndSessions(t *testing.T) { assert.Equal(t, []uint{otherSession.ID}, destroyedSessionIDs, "should only destroy the other session, not the current one") }) } + +// TestListUsersFiltersTeamsToRequesterScope verifies that a team-scoped admin +// listing users does not receive team memberships (IDs/names/roles) for teams +// the requester has no role in. Regression test for the cross-team data +// exposure on GET /api/latest/fleet/users for shared multi-team users. +func TestListUsersFiltersTeamsToRequesterScope(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + // A user shared across team 1 and team 2, as returned by the datastore + // (ds.ListUsers always loads the user's full team list). + sharedUserTeams := []fleet.UserTeam{ + {Team: fleet.Team{ID: 1, Name: "Team 1"}, Role: fleet.RoleObserver}, + {Team: fleet.Team{ID: 2, Name: "Team 2"}, Role: fleet.RoleObserver}, + } + ds.ListUsersFunc = func(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) { + return []*fleet.User{{ + ID: 10, + Teams: append([]fleet.UserTeam{}, sharedUserTeams...), + }}, nil + } + + // Requester is an admin of team 1 only, listing users of team 1. + teamOneAdmin := &fleet.User{ + ID: 1, + Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}, + } + ctx = viewer.NewContext(ctx, viewer.Viewer{User: teamOneAdmin}) + + resp, err := listUsersEndpoint(ctx, &listUsersRequest{ + ListOptions: fleet.UserListOptions{TeamID: 1}, + }, svc) + require.NoError(t, err) + + lr, ok := resp.(listUsersResponse) + require.True(t, ok) + require.NoError(t, lr.Err) + require.Len(t, lr.Users, 1) + + // The requester must only see team 1 (in scope), never team 2. + require.Len(t, lr.Users[0].Teams, 1) + require.Equal(t, uint(1), lr.Users[0].Teams[0].ID) +} + +// TestListUsersGlobalRequesterSeesAllTeams verifies the filter does not strip +// teams for a global-role requester, who is authorized to see all teams. +func TestListUsersGlobalRequesterSeesAllTeams(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil) + + ds.ListUsersFunc = func(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) { + return []*fleet.User{{ + ID: 10, + Teams: []fleet.UserTeam{ + {Team: fleet.Team{ID: 1, Name: "Team 1"}, Role: fleet.RoleObserver}, + {Team: fleet.Team{ID: 2, Name: "Team 2"}, Role: fleet.RoleObserver}, + }, + }}, nil + } + + ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin}) + + resp, err := listUsersEndpoint(ctx, &listUsersRequest{}, svc) + require.NoError(t, err) + + lr, ok := resp.(listUsersResponse) + require.True(t, ok) + require.NoError(t, lr.Err) + require.Len(t, lr.Users, 1) + require.Len(t, lr.Users[0].Teams, 2) +} From 4c6e0a7b200b47aefe486ff6e18bf0f4c71e2750 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Tue, 7 Jul 2026 16:59:22 -0700 Subject: [PATCH 2/3] Apply suggestion from @lukeheath --- changes/16691-cross-team-user-list-teams-authz | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/16691-cross-team-user-list-teams-authz b/changes/16691-cross-team-user-list-teams-authz index ccab9092f2a..0ebb6e36070 100644 --- a/changes/16691-cross-team-user-list-teams-authz +++ b/changes/16691-cross-team-user-list-teams-authz @@ -1 +1 @@ -- Fixed team-scoped admins seeing team memberships (IDs, names, and roles) for teams they do not administer when listing users shared across multiple teams via `GET /api/latest/fleet/users`. +- Fixed fleet-scoped context when retrieving a list of users in a fleet. From ad9877cf8344f1ba2ec1611f4f508b4bb800ef34 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Wed, 8 Jul 2026 10:58:44 -0300 Subject: [PATCH 3/3] Move check to svc.ListUsers --- server/service/users.go | 47 ++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/server/service/users.go b/server/service/users.go index cb05a02dd59..d54b3f66e8e 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -469,21 +469,9 @@ func listUsersEndpoint(ctx context.Context, request interface{}, svc fleet.Servi return listUsersResponse{Err: err}, nil } - // The datastore loads each user's full team membership. A team-scoped - // requester is only authorized to list users of a team they administer, so - // strip any team memberships (IDs/names/roles) for teams the requester has - // no role in before returning them. Global roles are authorized to see all - // teams and are left untouched. - var requester *fleet.User - if vc, ok := viewer.FromContext(ctx); ok { - requester = vc.User - } - resp := listUsersResponse{Users: []fleet.User{}} for _, user := range users { - u := *user - u.Teams = filterUserTeamsToRequesterScope(u.Teams, requester) - resp.Users = append(resp.Users, u) + resp.Users = append(resp.Users, *user) } return resp, nil } @@ -491,18 +479,14 @@ func listUsersEndpoint(ctx context.Context, request interface{}, svc fleet.Servi // filterUserTeamsToRequesterScope returns the subset of teams that the // requester is permitted to see. A requester with any global role sees all // teams unchanged; a team-scoped requester sees only teams they have a role in. -// A nil requester (should not happen on authenticated endpoints) is treated as -// having no scope, so nothing is disclosed. func filterUserTeamsToRequesterScope(teams []fleet.UserTeam, requester *fleet.User) []fleet.UserTeam { - if requester != nil && requester.HasAnyGlobalRole() { + if requester.HasAnyGlobalRole() { return teams } filtered := make([]fleet.UserTeam, 0, len(teams)) - if requester != nil { - for _, t := range teams { - if requester.HasAnyRoleInTeam(t.ID) { - filtered = append(filtered, t) - } + for _, t := range teams { + if requester.HasAnyRoleInTeam(t.ID) { + filtered = append(filtered, t) } } return filtered @@ -517,7 +501,26 @@ func (svc *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([ return nil, err } - return svc.ds.ListUsers(ctx, opt) + vc, ok := viewer.FromContext(ctx) + if !ok { + return nil, fleet.ErrNoContext + } + + users, err := svc.ds.ListUsers(ctx, opt) + if err != nil { + return nil, err + } + + // The datastore loads each user's full team membership. A team-scoped + // requester is only authorized to list users of a team they administer, so + // strip any team memberships (IDs/names/roles) for teams the requester has + // no role in before returning them. Global roles are authorized to see all + // teams and are left untouched. + for _, user := range users { + user.Teams = filterUserTeamsToRequesterScope(user.Teams, vc.User) + } + + return users, nil } func (svc *Service) UsersByIDs(ctx context.Context, ids []uint) ([]*fleet.UserSummary, error) {