Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: filter host software by label scoping #24801

Merged
merged 14 commits into from
Dec 16, 2024
1 change: 1 addition & 0 deletions changes/24534-hide-software-2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add functionality to filter host software based on label scoping.
54 changes: 53 additions & 1 deletion server/datastore/mysql/software.go
Original file line number Diff line number Diff line change
Expand Up @@ -2375,7 +2375,59 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
hvsi.removed = 0
) AND
-- either the software installer or the vpp app exists for the host's team
( si.id IS NOT NULL OR vat.platform = :host_platform )
( si.id IS NOT NULL OR vat.platform = :host_platform ) AND
-- label membership check
(
-- do the label membership check only for software installers
CASE WHEN si.ID IS NOT NULL THEN
(
EXISTS (

SELECT 1 FROM (

-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
)

UNION

-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0

UNION

-- exclude any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_host_labels = 0
Copy link
Member

Choose a reason for hiding this comment

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

One thing I did not think of mentioning earlier, the exclude_any case will suffer from the same issue as for the configuration profiles - for dynamic labels (based on a query result), all hosts appear to not be members until we ingest the first result for that host. So this could show an installer as "available" for a host even though once we ingest the dynamic label query results, it should've been excluded. This was fixed with timestamp checks for config profiles.

I think it'd be fine to merge as-is and worry about that exclude-any corner case later on, maybe as a "holistic" approach for the whole story? Would you mind adding it to the catch-all ticket if that approach make sense to you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

aha, that's a good point. I can add it to the catch all.

) t
)
)
-- it's some other type of software that has been checked above
ELSE true END
)
%s %s
`, onlySelfServiceClause, excludeVPPAppsClause)

Expand Down
156 changes: 156 additions & 0 deletions server/datastore/mysql/software_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func TestSoftware(t *testing.T) {
{"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam},
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
{"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Expand Down Expand Up @@ -5246,3 +5247,158 @@ func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) {
})
}
}

func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
ctx := context.Background()

// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)

// create some software: custom installers and FMA
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi1",
Platform: "darwin",
})
require.NoError(t, err)

// we should see installer1, since it has no label associated yet
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
IncludeAvailableForInstall: true,
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "file1", software[0].SoftwarePackage.Name)

label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
require.NoError(t, err)

// assign the label to the host
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))

// assign the label to the software installer
// TODO(JVE): update this once the DS method exists
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}
updateInstallerLabel(installerID1, label1.ID, true)

// should be empty as the installer label is "exclude any"
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, software)

// Update the label to be "include any"
updateInstallerLabel(installerID1, label1.ID, false)

software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "file1", software[0].SoftwarePackage.Name)

// Add an installer. No label yet.
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage2",
Filename: "file2",
Title: "file2",
Version: "2.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi2",
Platform: "darwin",
})
require.NoError(t, err)

// There's 2 installers now: installerID1 and installerID2 (because it has no labels associated)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 2)

// Add "exclude any" labels to installer2
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()})
require.NoError(t, err)

label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()})
require.NoError(t, err)

updateInstallerLabel(installerID2, label2.ID, true)
updateInstallerLabel(installerID2, label3.ID, true)

// Now host has label1, label2
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID}))

// List should be back to 1
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)

// Add an installer. No label yet.
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage3",
Filename: "file3",
Title: "file3",
Version: "3.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi3",
Platform: "darwin",
})
require.NoError(t, err)

// Add a new label and apply it to the installer. There are no hosts with this label.
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()})
require.NoError(t, err)

updateInstallerLabel(installerID3, label4.ID, true)

// We should have [installerID1, installerID3]
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 2)

// Now include hosts with label4. No host has this label, so we shouldn't see installerID3 anymore.
updateInstallerLabel(installerID3, label4.ID, false)

// We should have [installerID1]
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

here and elsewhere I could probably tighten up the checks by validating the IDs returned. Since there is other testing fixup to do, I will tackle this in a separate PR.

Copy link
Member

Choose a reason for hiding this comment

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

Probably best to add it to the catch-all ticket too so we don't forget about this.

}
63 changes: 63 additions & 0 deletions server/service/integration_enterprise_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10360,6 +10360,69 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version)

// =========================================
// test label scoping
// =========================================

// TODO(JVE): remove/update this once the API is in place
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}

var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
})
require.NotEmpty(t, installerID)

// create some labels
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label1",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)

s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label2",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)

// Set to "exclude any". Installer should be missing from the response for both host details and
// for self service
updateInstallerLabel(installerID, labelResp.Label.ID, true)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Empty(t, getHostSw.Software)

res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Empty(t, getDeviceSw.Software)

// Set to "include any". Installer should be in response.
updateInstallerLabel(installerID, labelResp.Label.ID, false)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")

res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1)
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")

// request installation on the host
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
Expand Down
Loading