fix: retrigger automatic installations after label scope changes (#25163)

> Related issue: #25071

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Jahziel Villasana-Espinoza
2025-01-06 12:18:16 -05:00
committed by GitHub
parent 8449879c81
commit 80520db199
9 changed files with 523 additions and 1 deletions
+28
View File
@@ -416,6 +416,34 @@ func (ds *Datastore) RecordPolicyQueryExecutions(ctx context.Context, host *flee
return nil
}
func (ds *Datastore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error {
if len(hostIDs) == 0 {
return nil
}
stmt := `
UPDATE
policies p
JOIN policy_membership pm ON pm.policy_id = p.id
SET
passes = NULL
WHERE
p.software_installer_id = ?
AND pm.host_id IN (?)
`
stmt, args, err := sqlx.In(stmt, installerID, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "building in statement for clearing auto install policy status")
}
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "clearing auto install policy status")
}
return nil
}
func (ds *Datastore) ListGlobalPolicies(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) {
return listPoliciesDB(ctx, ds.reader(ctx), nil, opts)
}
+81
View File
@@ -72,6 +72,7 @@ func TestPolicies(t *testing.T) {
{"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript},
{"TeamPoliciesNoTeam", testTeamPoliciesNoTeam},
{"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID},
{"TestClearAutoInstallPolicyStatusForHost", testClearAutoInstallPolicyStatusForHost},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -5371,3 +5372,83 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Len(t, policies, 0)
}
func testClearAutoInstallPolicyStatusForHost(t *testing.T, ds *Datastore) {
ctx := context.Background()
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1" + t.Name()})
require.NoError(t, err)
// create a regular policy
policy1 := newTestPolicy(t, ds, user1, "policy 1"+t.Name(), "darwin", &team1.ID)
// create an automatic install policy
policy2 := newTestPolicy(t, ds, user1, "policy 2"+t.Name(), "darwin", &team1.ID)
installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installer1ID, _, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
InstallerFile: installer,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy2.SoftwareInstallerID = ptr.Uint(installer1ID)
err = ds.SavePolicy(context.Background(), policy2, false, false)
require.NoError(t, err)
// create a host
host, err := ds.NewHost(ctx, &fleet.Host{
OsqueryHostID: ptr.String(uuid.New().String()),
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String(uuid.New().String()),
UUID: uuid.New().String(),
Hostname: "host" + t.Name(),
TeamID: &team1.ID,
Platform: "darwin",
})
require.NoError(t, err)
// record a policy run for both policies
err = ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{
policy1.ID: ptr.Bool(true),
policy2.ID: ptr.Bool(false), // software isn't installed on host, so Fleet should install it
}, time.Now(), false)
require.NoError(t, err)
hostPolicies, err := ds.ListPoliciesForHost(ctx, host)
require.NoError(t, err)
require.Len(t, hostPolicies, 2)
sort.Slice(hostPolicies, func(i, j int) bool {
return hostPolicies[i].ID < hostPolicies[j].ID
})
require.Equal(t, hostPolicies[0].Response, "pass")
require.Equal(t, hostPolicies[1].Response, "fail")
// clear status for automatic install policy
err = ds.ClearAutoInstallPolicyStatusForHosts(ctx, installer1ID, []uint{host.ID})
require.NoError(t, err)
// the status should be NULL for the automatic install policy but not the "regular" one
hostPolicies, err = ds.ListPoliciesForHost(ctx, host)
require.NoError(t, err)
require.Len(t, hostPolicies, 2)
sort.Slice(hostPolicies, func(i, j int) bool {
return hostPolicies[i].ID < hostPolicies[j].ID
})
require.Equal(t, hostPolicies[0].Response, "pass")
require.Empty(t, hostPolicies[1].Response)
}
@@ -1755,3 +1755,109 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install
return res, nil
}
const labelScopedFilter = `
SELECT
1
FROM (
-- no labels
SELECT
0 AS count_installer_labels,
0 AS count_host_labels,
0 AS count_host_updated_after_labels
WHERE NOT EXISTS ( SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = ?)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 AS count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = h.id
WHERE
sil.software_installer_id = ?
AND sil.exclude = 0
HAVING
count_installer_labels > 0
AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(
CASE WHEN lbl.created_at IS NOT NULL
AND(
SELECT
label_updated_at FROM hosts
WHERE
id = 1) >= lbl.created_at THEN
1
ELSE
0
END) AS count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = h.id
WHERE
sil.software_installer_id = ?
AND sil.exclude = 1
HAVING
count_installer_labels > 0
AND count_installer_labels = count_host_updated_after_labels
AND count_host_labels = 0) t`
func (ds *Datastore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
stmt := fmt.Sprintf(`SELECT
h.id
FROM
hosts h
WHERE
EXISTS (%s)
`, labelScopedFilter)
var hostIDs []uint
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software installer scope")
}
res := make(map[uint]struct{}, len(hostIDs))
for _, id := range hostIDs {
res[id] = struct{}{}
}
return res, nil
}
func (ds *Datastore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
stmt := fmt.Sprintf(`SELECT
h.id
FROM
hosts h
WHERE
NOT EXISTS (%s)
`, labelScopedFilter)
var hostIDs []uint
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing hosts excluded from software installer scope")
}
res := make(map[uint]struct{}, len(hostIDs))
for _, id := range hostIDs {
res[id] = struct{}{}
}
return res, nil
}
+8
View File
@@ -5321,6 +5321,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, scoped)
hostsInScope, err := ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, installerID1)
require.NoError(t, err)
require.Contains(t, hostsInScope, host.ID)
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
require.NoError(t, err)
@@ -5343,6 +5347,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Empty(t, software)
hostsNotInScope, err := ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, installerID1)
require.NoError(t, err)
require.Contains(t, hostsNotInScope, host.ID)
// installer1 should be out of scope since the label is "exclude any"
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
+12
View File
@@ -1706,6 +1706,18 @@ type Datastore interface {
// Software installers
//
// GetIncludedHostIDMapForSoftwareInstaller gets the set of hosts that are targeted/in scope for the
// given software installer, based label membership.
GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error)
// GetExcludedHostIDMapForSoftwareInstaller gets the set of hosts that are NOT targeted/in scope for the
// given software installer, based label membership.
GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error)
// ClearAutoInstallPolicyStatusForHosts clears out the status of the policy related to the given
// software installer for all the given hosts.
ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error
// GetSoftwareInstallDetails returns details required to fetch and
// run software installers
GetSoftwareInstallDetails(ctx context.Context, executionId string) (*SoftwareInstallDetails, error)
+36
View File
@@ -1077,6 +1077,12 @@ type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd *
type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error
type GetIncludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error)
type GetExcludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error)
type ClearAutoInstallPolicyStatusForHostsFunc func(ctx context.Context, installerID uint, hostIDs []uint) error
type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error)
type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error)
@@ -2772,6 +2778,15 @@ type DataStore struct {
UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc
UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool
GetIncludedHostIDMapForSoftwareInstallerFunc GetIncludedHostIDMapForSoftwareInstallerFunc
GetIncludedHostIDMapForSoftwareInstallerFuncInvoked bool
GetExcludedHostIDMapForSoftwareInstallerFunc GetExcludedHostIDMapForSoftwareInstallerFunc
GetExcludedHostIDMapForSoftwareInstallerFuncInvoked bool
ClearAutoInstallPolicyStatusForHostsFunc ClearAutoInstallPolicyStatusForHostsFunc
ClearAutoInstallPolicyStatusForHostsFuncInvoked bool
GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFunc
GetSoftwareInstallDetailsFuncInvoked bool
@@ -6636,6 +6651,27 @@ func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Conte
return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded)
}
func (s *DataStore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
s.mu.Lock()
s.GetIncludedHostIDMapForSoftwareInstallerFuncInvoked = true
s.mu.Unlock()
return s.GetIncludedHostIDMapForSoftwareInstallerFunc(ctx, installerID)
}
func (s *DataStore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
s.mu.Lock()
s.GetExcludedHostIDMapForSoftwareInstallerFuncInvoked = true
s.mu.Unlock()
return s.GetExcludedHostIDMapForSoftwareInstallerFunc(ctx, installerID)
}
func (s *DataStore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error {
s.mu.Lock()
s.ClearAutoInstallPolicyStatusForHostsFuncInvoked = true
s.mu.Unlock()
return s.ClearAutoInstallPolicyStatusForHostsFunc(ctx, installerID, hostIDs)
}
func (s *DataStore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) {
s.mu.Lock()
s.GetSoftwareInstallDetailsFuncInvoked = true
@@ -15306,6 +15306,215 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationLabelScopingRetrigger() {
t := s.T()
ctx := context.Background()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
// Create a few labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 3",
}, http.StatusOK, &newLabelResp)
lbl3 := newLabelResp.Label
// Add label1 and label2 to the host
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// upload software. Add label1 and label3 as "exclude any" labels.
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "ruby.deb",
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl3.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
var rubyDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail)
require.NotNil(t, rubyDetail.SoftwareTitle)
require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage)
rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy1",
Query: "SELECT 1;",
Platform: "linux",
})
require.NoError(t, err)
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
// No install attempt yet
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Send back a failed result for the policy.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy is failing
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We've triggered an installation attempt
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
// Update the installer's labels to "exclude any". This de-scopes the software.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsExcludeAny: []string{lbl1.Name, lbl3.Name},
}, http.StatusOK, "")
// The update should clear out the installation attempt
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Update the installer's labels to be "include any" again. The software is now back in scope.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl3.Name},
}, http.StatusOK, "")
// Simulate a failure of the policy
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy should be failing again
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We have an installation attempt again; the policy automation has been re-triggered
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
// Update the include any labels. The host doesn't have label2, so this means that the software
// moved out of scope.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsIncludeAny: []string{lbl2.Name},
}, http.StatusOK, "")
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Update to exclude any with label 2. This moves the software back into scope. The policy
// automation should re-trigger.
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
InstallScript: ptr.String("some install script"),
PreInstallQuery: ptr.String("some pre install query"),
PostInstallScript: ptr.String("some post install script"),
Filename: "ruby.deb",
TitleID: rubyDebTitleID,
TeamID: nil,
LabelsExcludeAny: []string{lbl2.Name},
}, http.StatusOK, "")
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is in scope, the policy should be failing again.
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(1), policy1.FailingHostCount)
// We have an installation attempt again.
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
t := s.T()
ctx := context.Background()
+10
View File
@@ -692,6 +692,16 @@ func (ts *withServer) updateSoftwareInstaller(
require.NoError(t, w.WriteField("self_service", "false"))
}
}
if payload.LabelsIncludeAny != nil {
for _, l := range payload.LabelsIncludeAny {
require.NoError(t, w.WriteField("labels_include_any", l))
}
}
if payload.LabelsExcludeAny != nil {
for _, l := range payload.LabelsExcludeAny {
require.NoError(t, w.WriteField("labels_exclude_any", l))
}
}
w.Close()