Stop leaking cross-team software title names via the hosts endpoint (#49638)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves: N/A

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Corrected `software_title_id` filtering for hosts so software title
details are strictly scoped to the current team and never backfill
mismatched or out-of-scope data.
* Removed unintended debug output and ensured software title details
remain unset when the title isn’t accessible.
* **Tests**
* Added an enterprise integration test verifying cross-team
`software_title_id` behavior.
* Updated existing integration expectations for team-scoped visibility.
* Added datastore coverage for team-scoped software title name lookup
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Nico
2026-07-24 10:39:13 -03:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent aa572dcca4
commit 6bb0b1ea52
12 changed files with 283 additions and 74 deletions
@@ -0,0 +1 @@
* Fixed the hosts list endpoint sometimes returning software title details that didn't match the applied filter.
+51 -20
View File
@@ -132,31 +132,62 @@ GROUP BY
return &title, nil
}
// SoftwareTitleNameForHostFilter returns the name and display_name
// of a software title by ID without applying team-scoped inventory auth.
// This intentionally allows callers to discover the title name and display_name
// even if the title is not present on their team.
//
// Only use this for host list filters and similar UX helpers where
// exposing the existence of a title is acceptable. Not for endpoints
// that return team-scoped inventory data.
func (ds *Datastore) SoftwareTitleNameForHostFilter(
ctx context.Context,
id uint,
) (name, displayName string, err error) {
const stmt = `
// SoftwareTitleNameForHostFilter confirms a software title's presence via a
// live host/software join, instead of the software_titles_host_counts
// aggregate SoftwareTitleByID relies on. It returns either the team's
// display_name override or the title's name -- never both, the unused
// return is always "". A nil teamID is scoped to every team tmFilter's
// user can access (the same boundary whereFilterHostsByTeams applies
// elsewhere), never to any team at all, so it can't disclose a title
// outside that boundary; both branches return NotFound instead of
// revealing which team(s) hold the title.
func (ds *Datastore) SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (name, displayName string, err error) {
// "No team" hosts have hosts.team_id IS NULL, never a literal 0.
hostTeamFilter := "h.team_id IS NULL"
switch {
case teamID != nil && *teamID != 0:
hostTeamFilter = "h.team_id = ?"
case teamID == nil:
hostTeamFilter = ds.whereFilterHostsByTeams(tmFilter, "h")
}
// Display name is per-team; skip it entirely when no team is given.
displayNameJoinCond := "FALSE"
if teamID != nil {
displayNameJoinCond = "stdn.team_id = ?"
}
stmt := fmt.Sprintf(`
SELECT
name,
display_name
FROM software_titles
LEFT JOIN software_title_display_names ON software_titles.id = software_title_display_names.software_title_id
WHERE software_titles.id = ?
`
st.name,
stdn.display_name
FROM software_titles st
LEFT JOIN software_title_display_names stdn
ON stdn.software_title_id = st.id AND %s
WHERE st.id = ?
AND EXISTS (
SELECT 1
FROM host_software hs
INNER JOIN software sw ON sw.id = hs.software_id
INNER JOIN hosts h ON h.id = hs.host_id
WHERE sw.title_id = st.id AND %s
)
`, displayNameJoinCond, hostTeamFilter)
var allArgs []any
if teamID != nil {
allArgs = append(allArgs, *teamID)
}
allArgs = append(allArgs, id)
if teamID != nil && *teamID != 0 {
allArgs = append(allArgs, *teamID)
}
var results struct {
Name string `db:"name"`
DisplayName *string `db:"display_name"`
}
if err := sqlx.GetContext(ctx, ds.reader(ctx), &results, stmt, id); err != nil {
if err := sqlx.GetContext(ctx, ds.reader(ctx), &results, stmt, allArgs...); err != nil {
if err == sql.ErrNoRows {
return "", "", notFound("SoftwareTitle").WithID(id)
}
@@ -47,6 +47,7 @@ func TestSoftwareTitles(t *testing.T) {
{"ListSoftwareTitlesSortByDisplayName", testListSoftwareTitlesSortByDisplayName},
{"ListSoftwareTitlesMultiplePackages", testListSoftwareTitlesMultiplePackages},
{"ListSoftwareTitlesPolicyDispatchPerInstaller", testListSoftwareTitlesPolicyDispatchPerInstaller},
{"SoftwareTitleNameForHostFilter", testSoftwareTitleNameForHostFilter},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -2343,6 +2344,96 @@ func testSoftwareTitleHostCount(t *testing.T, ds *Datastore) {
require.Equal(t, ptr.Uint(1), title.Versions[0].HostsCount)
}
// testSoftwareTitleNameForHostFilter verifies SoftwareTitleNameForHostFilter's
// live-join lookup and its team/tmFilter scoping.
func testSoftwareTitleNameForHostFilter(t *testing.T, ds *Datastore) {
ctx := t.Context()
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithTeamID(team1.ID))
testSw := fleet.Software{Name: "UniqueDSTitleApp", Version: "1.0", Source: "apps", BundleIdentifier: "com.unique.dstitleapp"}
_, err = ds.UpdateHostSoftware(ctx, host1.ID, []fleet.Software{testSw})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host1, false))
require.Len(t, host1.Software, 1)
require.NotNil(t, host1.Software[0].TitleID)
titleID := *host1.Software[0].TitleID
// Scoped to team1 only: can't see team2 or "no team" hosts.
team1ScopedUser := &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: team1.ID}, Role: fleet.RoleObserver}}}
team1Filter := fleet.TeamFilter{User: team1ScopedUser, IncludeObserver: true}
globalAdminUser := &fleet.User{GlobalRole: new(fleet.RoleAdmin)}
globalAdminFilter := fleet.TeamFilter{User: globalAdminUser, IncludeObserver: true}
// No SyncHostsSoftwareTitles call: this must find titles via a live
// join, not the aggregate table sync populates.
// In-scope team: found immediately, pre-sync.
name, displayName, err := ds.SoftwareTitleNameForHostFilter(ctx, titleID, &team1.ID, team1Filter)
require.NoError(t, err)
assert.Equal(t, testSw.Name, name)
assert.Empty(t, displayName)
// A team's display_name override takes precedence over the title name.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return updateSoftwareTitleDisplayName(ctx, q, &team1.ID, titleID, "Team1 Custom Name")
})
name, displayName, err = ds.SoftwareTitleNameForHostFilter(ctx, titleID, &team1.ID, team1Filter)
require.NoError(t, err)
assert.Empty(t, name)
assert.Equal(t, "Team1 Custom Name", displayName)
// Out-of-scope team: NotFound, no data disclosed.
_, _, err = ds.SoftwareTitleNameForHostFilter(ctx, titleID, &team2.ID, team1Filter)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
// Nonexistent title ID: NotFound.
_, _, err = ds.SoftwareTitleNameForHostFilter(ctx, titleID+999999, &team1.ID, team1Filter)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
// "No team" (team_id=0).
noTeamHost := test.NewHost(t, ds, "no-team-host", "", "no-team-hostkey", "no-team-hostuuid", time.Now())
noTeamSw := fleet.Software{Name: "UniqueNoTeamTitleApp", Version: "1.0", Source: "apps", BundleIdentifier: "com.unique.noteamtitleapp"}
_, err = ds.UpdateHostSoftware(ctx, noTeamHost.ID, []fleet.Software{noTeamSw})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, noTeamHost, false))
require.Len(t, noTeamHost.Software, 1)
require.NotNil(t, noTeamHost.Software[0].TitleID)
noTeamTitleID := *noTeamHost.Software[0].TitleID
zero := uint(0)
name, displayName, err = ds.SoftwareTitleNameForHostFilter(ctx, noTeamTitleID, &zero, team1Filter)
require.NoError(t, err)
assert.Equal(t, noTeamSw.Name, name)
assert.Empty(t, displayName)
// nil teamID: scoped to every team the caller can access, and ignores
// team1's display_name override (set above) since no single team is in scope.
name, displayName, err = ds.SoftwareTitleNameForHostFilter(ctx, titleID, nil, team1Filter)
require.NoError(t, err)
assert.Equal(t, testSw.Name, name)
assert.Empty(t, displayName)
// ...but not "no team", which this caller can't see.
_, _, err = ds.SoftwareTitleNameForHostFilter(ctx, noTeamTitleID, nil, team1Filter)
require.Error(t, err)
assert.True(t, fleet.IsNotFound(err))
// A global admin can see "no team" too.
name, displayName, err = ds.SoftwareTitleNameForHostFilter(ctx, noTeamTitleID, nil, globalAdminFilter)
require.NoError(t, err)
assert.Equal(t, noTeamSw.Name, name)
assert.Empty(t, displayName)
}
func testListSoftwareTitlesInHouseApps(t *testing.T, ds *Datastore) {
ctx := t.Context()
+1 -1
View File
@@ -723,7 +723,7 @@ type Datastore interface {
ListSoftwareTitles(ctx context.Context, opt SoftwareTitleListOptions, tmFilter TeamFilter) ([]SoftwareTitleListResult, int, *PaginationMetadata, error)
SoftwareTitleByID(ctx context.Context, id uint, teamID *uint, tmFilter TeamFilter) (*SoftwareTitle, error)
SoftwareTitleNameForHostFilter(ctx context.Context, id uint) (name, displayName string, err error)
SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint, tmFilter TeamFilter) (name, displayName string, err error)
UpdateSoftwareTitleName(ctx context.Context, id uint, name string) error
UpdateSoftwareTitleAutoUpdateConfig(ctx context.Context, titleID uint, teamID uint, config SoftwareAutoUpdateConfig) error
ListSoftwareAutoUpdateSchedules(ctx context.Context, teamID uint, source string, optionalFilter ...SoftwareAutoUpdateScheduleFilter) ([]SoftwareAutoUpdateSchedule, error)
+1 -1
View File
@@ -774,7 +774,7 @@ type Service interface {
ListSoftwareTitles(ctx context.Context, opt SoftwareTitleListOptions) ([]SoftwareTitleListResult, int, *PaginationMetadata, error)
SoftwareTitleByID(ctx context.Context, id uint, teamID *uint) (*SoftwareTitle, error)
SoftwareTitleNameForHostFilter(ctx context.Context, id uint) (name, displayName string, err error)
SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint) (name, displayName string, err error)
// InstallSoftwareTitle installs a software title in the given host.
InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error
+3 -3
View File
@@ -520,7 +520,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis
type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error)
type SoftwareTitleNameForHostFilterFunc func(ctx context.Context, id uint) (name string, displayName string, err error)
type SoftwareTitleNameForHostFilterFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (name string, displayName string, err error)
type UpdateSoftwareTitleNameFunc func(ctx context.Context, id uint, name string) error
@@ -7314,11 +7314,11 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter)
}
func (s *DataStore) SoftwareTitleNameForHostFilter(ctx context.Context, id uint) (name string, displayName string, err error) {
func (s *DataStore) SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (name string, displayName string, err error) {
s.mu.Lock()
s.SoftwareTitleNameForHostFilterFuncInvoked = true
s.mu.Unlock()
return s.SoftwareTitleNameForHostFilterFunc(ctx, id)
return s.SoftwareTitleNameForHostFilterFunc(ctx, id, teamID, tmFilter)
}
func (s *DataStore) UpdateSoftwareTitleName(ctx context.Context, id uint, name string) error {
+3 -3
View File
@@ -486,7 +486,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis
type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint) (*fleet.SoftwareTitle, error)
type SoftwareTitleNameForHostFilterFunc func(ctx context.Context, id uint) (name string, displayName string, err error)
type SoftwareTitleNameForHostFilterFunc func(ctx context.Context, id uint, teamID *uint) (name string, displayName string, err error)
type InstallSoftwareTitleFunc func(ctx context.Context, hostID uint, softwareTitleID uint) error
@@ -4065,11 +4065,11 @@ func (s *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint)
return s.SoftwareTitleByIDFunc(ctx, id, teamID)
}
func (s *Service) SoftwareTitleNameForHostFilter(ctx context.Context, id uint) (name string, displayName string, err error) {
func (s *Service) SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint) (name string, displayName string, err error) {
s.mu.Lock()
s.SoftwareTitleNameForHostFilterFuncInvoked = true
s.mu.Unlock()
return s.SoftwareTitleNameForHostFilterFunc(ctx, id)
return s.SoftwareTitleNameForHostFilterFunc(ctx, id, teamID)
}
func (s *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
+7 -5
View File
@@ -301,21 +301,23 @@ func listHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Servi
titleID := *req.Opts.SoftwareTitleIDFilter
// 1. Try full title for this team.
// Needed in order to grab display_name if it exists
// Needed in order to grab display_name if it exists.
st, err := svc.SoftwareTitleByID(ctx, titleID, req.Opts.TeamFilter)
switch {
case err == nil:
fmt.Println("regular")
softwareTitle = st
case fleet.IsNotFound(err):
// Not found: only ID + Name as string from helper.
name, displayName, errName := svc.SoftwareTitleNameForHostFilter(ctx, titleID)
// SoftwareTitleByID depends on the software_titles_host_counts
// aggregate, populated only by the periodic
// SyncHostsSoftwareTitles job, so a title just installed on an
// in-scope host can be NotFound here until the next sync. Fall
// back to a live join instead of leaving softwareTitle unset.
name, displayName, errName := svc.SoftwareTitleNameForHostFilter(ctx, titleID, req.Opts.TeamFilter)
if errName != nil && !fleet.IsNotFound(errName) {
return listHostsResponse{Err: errName}, nil
}
if errName == nil {
fmt.Println("here")
softwareTitle = &fleet.SoftwareTitle{
ID: titleID,
}
+72 -4
View File
@@ -5929,6 +5929,70 @@ func (s *integrationEnterpriseTestSuite) TestListHostsSoftwareVersionOnDifferent
assert.Empty(t, resp.Software)
}
func (s *integrationEnterpriseTestSuite) TestListHostsSoftwareTitleOnDifferentTeam() {
t := s.T()
ctx := t.Context()
// create 2 teams
team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "_team1"})
require.NoError(t, err)
team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "_team2"})
require.NoError(t, err)
// create 1 host on team1
h1, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: new(t.Name() + "h1"),
NodeKey: new(t.Name() + "h1"),
UUID: uuid.New().String(),
Hostname: t.Name() + "h1.local",
Platform: "darwin",
TeamID: &team1.ID,
})
require.NoError(t, err)
// Install software only on h1 (team1).
testSw := fleet.Software{Name: "UniqueTitleApp", Version: "3.4.5", Source: "apps", BundleIdentifier: "com.unique.titleapp"}
_, err = s.ds.UpdateHostSoftware(ctx, h1.ID, []fleet.Software{testSw})
require.NoError(t, err)
require.NoError(t, s.ds.LoadHostSoftware(ctx, h1, false))
require.Len(t, h1.Software, 1)
require.NotNil(t, h1.Software[0].TitleID)
titleID := *h1.Software[0].TitleID
// Deliberately do NOT call SyncHostsSoftwareTitles here: the title's
// entry in software_titles_host_counts (which SoftwareTitleByID relies
// on) is only populated by that periodic sync, so skipping it
// reproduces the up-to-~1h window between a host reporting new
// software and the next sync run. The in-scope enrichment below must
// still succeed immediately via a live (non-aggregated) fallback.
// Filtering team1 (in-scope) by the software title returns the host and the title's name.
var resp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp,
"software_title_id", fmt.Sprint(titleID),
"team_id", fmt.Sprint(team1.ID),
)
require.Len(t, resp.Hosts, 1)
assert.Equal(t, h1.ID, resp.Hosts[0].ID)
require.NotNil(t, resp.SoftwareTitle)
assert.Equal(t, testSw.Name, resp.SoftwareTitle.Name)
// Filtering team2 (out-of-scope: the title isn't installed on any host on
// this team) must not leak the title's name/display_name — software_title
// should be omitted entirely, not backfilled from an unscoped lookup.
resp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &resp,
"software_title_id", fmt.Sprint(titleID),
"team_id", fmt.Sprint(team2.ID),
)
require.Empty(t, resp.Hosts)
assert.Nil(t, resp.SoftwareTitle)
}
func (s *integrationEnterpriseTestSuite) TestHostHealth() {
t := s.T()
@@ -11563,9 +11627,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareAuth() {
var resp listSoftwareVersionsResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareTitlesRequest{}, http.StatusForbidden, &resp)
// Get a global software title
// Get a global software title (only on the "no team" host, which
// no team-scoped user can see): NotFound, not Forbidden, so its
// existence can't be inferred from the response.
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareBar.ID), getSoftwareTitleRequest{}, http.StatusForbidden, &getSoftwareTitleResp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareBar.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &getSoftwareTitleResp)
// Get a global software version
var getSoftwareResp getSoftwareResponse
@@ -11627,9 +11693,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareAuth() {
var resp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/versions", listSoftwareRequest{SoftwareListOptions: fleet.SoftwareListOptions{TeamID: &team1.ID}}, http.StatusForbidden, &resp)
// Get a team software title
// Get a team software title (on team1 and "no team", neither
// visible to this team-2 user): NotFound, not Forbidden, so its
// existence can't be inferred from the response.
var getSoftwareTitleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareFoo.ID), getSoftwareTitleRequest{}, http.StatusForbidden, &getSoftwareTitleResp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", softwareFoo.ID), getSoftwareTitleRequest{}, http.StatusNotFound, &getSoftwareTitleResp)
// Get a team software version
var getSoftwareResp getSoftwareResponse
@@ -10,7 +10,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"time"
@@ -904,8 +903,9 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("team_" + t.Name())}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("team_2_" + t.Name())}}, http.StatusOK, &newTeamResp)
team2 := newTeamResp.Team
var newTeam2Resp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: new("team_2_" + t.Name())}}, http.StatusOK, &newTeam2Resp)
team2 := newTeam2Resp.Team
// Enroll a host
token := "good_token"
@@ -928,6 +928,7 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
s.Require().NoError(err)
s.Require().NoError(s.ds.SyncHostsSoftware(ctx, time.Now()))
s.Require().NoError(s.ds.SyncHostsSoftwareTitles(ctx, time.Now()))
sw, _, err := s.ds.ListHostSoftware(ctx, host, fleet.HostSoftwareTitleListOptions{})
s.Require().NoError(err)
@@ -957,7 +958,9 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
s.Assert().Equal(titleID, listResp.SoftwareTitle.ID)
s.Assert().Equal("bar", listResp.SoftwareTitle.Name)
// Use the other team ID, should still get a response with the name and title ID
// Use the other team ID: the title isn't installed on any host on this
// team, so no hosts should match and its name/display_name must not leak.
listResp = listHostsResponse{}
s.DoJSON(
"GET",
"/api/latest/fleet/hosts",
@@ -969,16 +972,8 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
"software_title_id",
fmt.Sprint(titleID),
)
s.Require().Len(listResp.Hosts, 1)
s.Assert().NotNil(listResp.SoftwareTitle)
s.Assert().Equal(titleID, listResp.SoftwareTitle.ID)
s.Assert().Equal("bar", listResp.SoftwareTitle.Name)
v := reflect.ValueOf(*listResp.SoftwareTitle)
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).Name != "ID" && v.Type().Field(i).Name != "Name" {
s.Assert().True(v.Field(i).IsZero())
}
}
s.Require().Empty(listResp.Hosts)
s.Nil(listResp.SoftwareTitle)
// Add a custom package and set a display name for the software title
payload := &fleet.UploadSoftwareInstallerPayload{
@@ -1056,8 +1051,10 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
s.token = s.getTestToken(*params.Email, *params.Password)
// Use the other team ID, should still get a response with the display name and title ID
fmt.Println("before final call")
// The observer is scoped to their own team (team1): querying it still
// returns the full title details for the custom package, including the
// custom display name.
listResp = listHostsResponse{}
s.DoJSON(
"GET",
"/api/latest/fleet/hosts",
@@ -1065,7 +1062,7 @@ func (s *integrationMDMTestSuite) TestListHostsSoftwareTitleIDFilter() {
http.StatusOK,
&listResp,
"team_id",
fmt.Sprint(team2.ID),
fmt.Sprint(team.ID),
"software_title_id",
fmt.Sprint(titleID),
)
+12 -20
View File
@@ -12,7 +12,6 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
)
/////////////////////////////////////////////////////////////////////////////////
@@ -172,16 +171,9 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
IncludeObserver: true,
})
if err != nil {
if fleet.IsNotFound(err) && teamID == nil {
// here we use a global admin as filter because we want to check if the software exists
filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
_, err = svc.ds.SoftwareTitleByID(ctx, id, nil, filter)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checked using a global admin")
}
return nil, fleet.NewPermissionError("Error: You don't have permission to view specified software. It is installed on hosts that belong to a fleet you don't have permissions to view.")
}
// A title that exists only on a team outside the caller's visibility
// must return the same NotFound as a title that doesn't exist at
// all, so existence elsewhere can't be inferred from the response.
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
}
@@ -334,21 +326,21 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
return software, nil
}
func (svc *Service) SoftwareTitleNameForHostFilter(
ctx context.Context,
id uint,
) (name, displayName string, err error) {
func (svc *Service) SoftwareTitleNameForHostFilter(ctx context.Context, id uint, teamID *uint) (name, displayName string, err error) {
// Intentionally skip team-scoped inventory auth: only minimal title name.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return "", "", err
}
name, displayName, err = svc.ds.SoftwareTitleNameForHostFilter(ctx, id)
if err != nil {
return "", "", err
vc, ok := viewer.FromContext(ctx)
if !ok {
return "", "", fleet.ErrNoContext
}
return name, displayName, nil
return svc.ds.SoftwareTitleNameForHostFilter(ctx, id, teamID, fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
})
}
/////////////////////////////////////////////////////////////////////////////////
+27
View File
@@ -307,3 +307,30 @@ func TestSoftwareTitleByIDTeamIDZero(t *testing.T) {
_, err = svc.SoftwareTitleByID(adminCtx, 1, teamIDZero)
checkAuthErr(t, false, err)
}
func TestSoftwareTitleByIDNilTeamIDExistsElsewhere(t *testing.T) {
ds := new(mock.Store)
var filtersUsed []fleet.TeamFilter
ds.SoftwareTitleByIDFunc = func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) {
filtersUsed = append(filtersUsed, tmFilter)
return nil, newNotFoundError()
}
svc, ctx := newTestService(t, ds, nil, nil)
teamUser := &fleet.User{
ID: 1,
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}},
}
ctx = viewer.NewContext(ctx, viewer.Viewer{User: teamUser})
_, err := svc.SoftwareTitleByID(ctx, 1, nil)
require.Error(t, err)
// A title's existence on a team the caller can't see must not be
// distinguishable (via status code) from it not existing at all.
require.True(t, fleet.IsNotFound(err), "expected NotFound, got: %v", err)
// Guard against reintroducing a secondary lookup: exactly one call, and
// never with a global-role filter standing in for the real caller.
require.Len(t, filtersUsed, 1)
require.Equal(t, teamUser, filtersUsed[0].User)
}