diff --git a/changes/33132-exclude-any-labels-mobile-devices b/changes/33132-exclude-any-labels-mobile-devices new file mode 100644 index 0000000000..5215a29c43 --- /dev/null +++ b/changes/33132-exclude-any-labels-mobile-devices @@ -0,0 +1 @@ +* Fixed an issue where "Exclude Any" label scoping did work properly for iOS, iPadOS and Android hosts diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index 6eb8eb4656..70174fd367 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -857,8 +857,13 @@ const androidApplicableProfilesQuery = ` COUNT(mcpl.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, -- this helps avoid the case where the host is not a member of a label - -- just because it hasn't reported results for that label yet. - SUM(CASE WHEN lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + -- just because it hasn't reported results for that label yet. But we + -- only need consider this for dynamic labels - manual(type=1) can be + -- considered at any time + SUM( + CASE WHEN lbl.label_membership_type <> 1 AND lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.label_membership_type = 1 AND lbl.created_at IS NOT NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels FROM mdm_android_configuration_profiles macp JOIN hosts h diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index d35d47eb3a..36f71889d9 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -36,6 +36,7 @@ func TestAndroid(t *testing.T) { {"DeleteMDMAndroidConfigProfile", testDeleteMDMAndroidConfigProfile}, {"GetMDMAndroidProfilesSummary", testMDMAndroidProfilesSummary}, {"ListMDMAndroidProfilesToSend", testListMDMAndroidProfilesToSend}, + {"ListMDMAndroidProfilesToSend_WithExcludeAny", testListMDMAndroidProfilesToSendWithExcludeAny}, {"GetMDMAndroidProfilesContents", testGetMDMAndroidProfilesContents}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles2}, @@ -1204,7 +1205,7 @@ func testListMDMAndroidProfilesToSend(t *testing.T, ds *Datastore) { // test the exclude any labels condition lblExclAny1, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", Query: "select 1"}) require.NoError(t, err) - lblExclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-2", Query: "select 1"}) + lblExclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-2", LabelMembershipType: fleet.LabelMembershipTypeManual}) require.NoError(t, err) p6, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-6", lblExclAny1, lblExclAny2)) require.NoError(t, err) @@ -1348,6 +1349,160 @@ func testListMDMAndroidProfilesToSend(t *testing.T, ds *Datastore) { require.Empty(t, toRemoveProfs) } +// Specific test for "exclude any" logic which can be tricky because manual +// labels apply immediately whereas dynamic labels only apply after label membership +// has been determined for the host(as signified by the LabelUpdatedAt timestamp). +// Base test covers some of this but it's a good area for extra testing in light of +// https://github.com/fleetdm/fleet/issues/33132 +func testListMDMAndroidProfilesToSendWithExcludeAny(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Create some hosts + hosts := make([]*fleet.Host, 2) + for i := range hosts { + androidHost := createAndroidHost(fmt.Sprintf("enterprise-id-%d", i)) + newHost, err := ds.NewAndroidHost(ctx, androidHost) + require.NoError(t, err) + hosts[i] = newHost.Host + } + + // without any profile, should return empty + profs, toRemoveProfs, err := ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, profs) + require.Empty(t, toRemoveProfs) + + // Create a team + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team"}) + require.NoError(t, err) + + // transfer host 1 to the team + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hosts[1].ID})) + require.NoError(t, err) + + // test the exclude any labels condition + lblExclAny1, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", Query: "select 1"}) + require.NoError(t, err) + lblExclAny2, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-2", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + + // Dynamic exclude-any label + p1, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-1", lblExclAny1)) + require.NoError(t, err) + // Manual exclude-any label only + p2, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-2", lblExclAny2)) + require.NoError(t, err) + // Both manual and dynamic label exclusion + p3, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("no-team-3", lblExclAny1, lblExclAny2)) + require.NoError(t, err) + + // p2 becomes immediately applicable because it only has a manual label + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 1) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, + }, profs) + + // update the timestamp of when host label membership was updated + hosts[0].LabelUpdatedAt = time.Now().UTC().Add(time.Second) // just to be extra safe in tests + hosts[0].PolicyUpdatedAt = time.Now().UTC() + err = ds.UpdateHost(ctx, hosts[0]) + require.NoError(t, err) + + // host 0 dynamic labels now apply, and this host is _not_ a member of the excluded labels, so p1, p2 and p3 are now applicable + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 3) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name}, + {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, + {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, + }, profs) + + tmP4 := androidProfileForTest("team-4", lblExclAny1) + tmP4.TeamID = &tm.ID + tmP5 := androidProfileForTest("team-5", lblExclAny2) + tmP5.TeamID = &tm.ID + tmP6 := androidProfileForTest("team-6", lblExclAny1, lblExclAny2) + tmP6.TeamID = &tm.ID + + // Dynamic exclude-any label + p4, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP4) + require.NoError(t, err) + + // Manual exclude-any label only + p5, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP5) + require.NoError(t, err) + + // Both manual and dynamic label exclusion + p6, err := ds.NewMDMAndroidConfigProfile(ctx, *tmP6) + require.NoError(t, err) + + // p5 becomes immediately applicable to host 1 because it only has a manual label + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 4) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name}, + {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, + {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, + {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, + }, profs) + + // Set the hosts label_updated_at causing p4-p6 to become applicable to host 1 + hosts[1].LabelUpdatedAt = time.Now().UTC().Add(time.Second) // just to be extra safe in tests + hosts[1].PolicyUpdatedAt = time.Now().UTC() + hosts[1].TeamID = &tm.ID + err = ds.UpdateHost(ctx, hosts[1]) + require.NoError(t, err) + + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 6) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name}, + {ProfileUUID: p2.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p2.Name}, + {ProfileUUID: p3.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p3.Name}, + {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name}, + {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, + {ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name}, + }, profs) + + // Make host 0 a member of labelExclAny2 which excludes everything except p1 for it + _, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, lblExclAny2.ID, []uint{hosts[0].ID}, fleet.TeamFilter{}) + require.NoError(t, err) + + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 4) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p1.ProfileUUID, HostUUID: hosts[0].UUID, ProfileName: p1.Name}, + {ProfileUUID: p4.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p4.Name}, + {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, + {ProfileUUID: p6.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p6.Name}, + }, profs) + + // Make hosts 0 and 1 members of labelExclAny1 which excludes everything except p5 for host p1. Android doesn't + // currently support dynamic labels but this ensures the datastore processes it right if somehow an Android host + // becomes a member of one + _, _, err = ds.UpdateLabelMembershipByHostIDs(ctx, lblExclAny1.ID, []uint{hosts[0].ID, hosts[1].ID}, fleet.TeamFilter{}) + require.NoError(t, err) + + profs, toRemoveProfs, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemoveProfs) + require.Len(t, profs, 1) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: p5.ProfileUUID, HostUUID: hosts[1].UUID, ProfileName: p5.Name}, + }, profs) +} + func testGetMDMAndroidProfilesContents(t *testing.T, ds *Datastore) { ctx := t.Context() p1 := androidProfileForTest("p1") diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 0fc8ed53cb..95f418cecd 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -3115,8 +3115,13 @@ func generateDesiredStateQuery(entityType string) string { COUNT(mel.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, -- this helps avoid the case where the host is not a member of a label - -- just because it hasn't reported results for that label yet. - SUM(CASE WHEN lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + -- just because it hasn't reported results for that label yet. But we + -- only need consider this for dynamic labels - manual(type=1) can be + -- considered at any time + SUM( + CASE WHEN lbl.label_membership_type <> 1 AND lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.label_membership_type = 1 AND lbl.created_at IS NOT NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels FROM ${mdmAppleEntityTable} mae JOIN hosts h diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index e5fb7276bd..e15efcfe34 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -8900,6 +8900,9 @@ func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) { func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { ctx := t.Context() + // Explicitly set the labelUpdatedAt very slightly in the past for testing dynamic label logic + fiveSecondsAgo := time.Now().Add(-5 * time.Second).UTC().Round(time.Microsecond) + matchProfiles := func(want, got []*fleet.MDMAppleProfilePayload) { // match only the fields we care about for _, p := range got { @@ -8926,12 +8929,13 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { require.Empty(t, profilesToInstall) host1, err := ds.NewHost(ctx, &fleet.Host{ - Hostname: "test-host1-name", - OsqueryHostID: ptr.String("1337"), - NodeKey: ptr.String("1337"), - UUID: "test-uuid-1", - TeamID: nil, - Platform: "darwin", + Hostname: "test-host1-name", + OsqueryHostID: ptr.String("1337"), + NodeKey: ptr.String("1337"), + UUID: "test-uuid-1", + TeamID: nil, + Platform: "darwin", + LabelUpdatedAt: fiveSecondsAgo, }) require.NoError(t, err) // add a user enrollment for this device, nothing else should be modified @@ -8939,24 +8943,26 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { // non-macOS hosts shouldn't modify any of the results below _, err = ds.NewHost(ctx, &fleet.Host{ - Hostname: "test-windows-host", - OsqueryHostID: ptr.String("4824"), - NodeKey: ptr.String("4824"), - UUID: "test-windows-host", - TeamID: nil, - Platform: "windows", + Hostname: "test-windows-host", + OsqueryHostID: ptr.String("4824"), + NodeKey: ptr.String("4824"), + UUID: "test-windows-host", + TeamID: nil, + Platform: "windows", + LabelUpdatedAt: fiveSecondsAgo, }) require.NoError(t, err) // a macOS host that's not MDM enrolled into Fleet shouldn't // modify any of the results below _, err = ds.NewHost(ctx, &fleet.Host{ - Hostname: "test-non-mdm-host", - OsqueryHostID: ptr.String("4825"), - NodeKey: ptr.String("4825"), - UUID: "test-non-mdm-host", - TeamID: nil, - Platform: "darwin", + Hostname: "test-non-mdm-host", + OsqueryHostID: ptr.String("4825"), + NodeKey: ptr.String("4825"), + UUID: "test-non-mdm-host", + TeamID: nil, + Platform: "darwin", + LabelUpdatedAt: fiveSecondsAgo, }) require.NoError(t, err) @@ -8969,12 +8975,13 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { }, profilesToInstall) hostLabel, err := ds.NewHost(ctx, &fleet.Host{ - Hostname: "test-host-name-label", - OsqueryHostID: ptr.String("1337_label"), - NodeKey: ptr.String("1337_label"), - UUID: "test-uuid-1-label", - TeamID: nil, - Platform: "darwin", + Hostname: "test-host-name-label", + OsqueryHostID: ptr.String("1337_label"), + NodeKey: ptr.String("1337_label"), + UUID: "test-uuid-1-label", + TeamID: nil, + Platform: "darwin", + LabelUpdatedAt: fiveSecondsAgo, }) require.NoError(t, err) // add a user enrollment for this device, nothing else should be modified @@ -9001,7 +9008,7 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { l6, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-any-6", Query: "select 1"}) require.NoError(t, err) - l7, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-any-7", Query: "select 1"}) + l7, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-any-7", LabelMembershipType: fleet.LabelMembershipTypeManual}) require.NoError(t, err) profIncludeAny, err := ds.NewMDMAppleConfigProfile(ctx, *configProfileForTest(t, "prof-include-any", "prof-include-any", "prof-include-any", l1, l2, l3), nil) @@ -9010,14 +9017,7 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { require.NoError(t, err) profExcludeAny, err := ds.NewMDMAppleConfigProfile(ctx, *configProfileForTest(t, "prof-exclude-any", "prof-exclude-any", "prof-exclude-any", l6, l7), nil) require.NoError(t, err) - - // Update hosts' labels updated at timestamp so that the exclude any profile shows up - hostLabel.LabelUpdatedAt = time.Now() - err = ds.UpdateHost(ctx, hostLabel) - require.NoError(t, err) - - host1.LabelUpdatedAt = time.Now() - err = ds.UpdateHost(ctx, host1) + profExcludeAnyManualOnly, err := ds.NewMDMAppleConfigProfile(ctx, *configProfileForTest(t, "prof-exclude-any-manual", "prof-exclude-any-manual", "prof-exclude-any-manual", l7), nil) require.NoError(t, err) // hostLabel is a member of l1, l4, l5 @@ -9026,22 +9026,47 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { globalPfs, err = ds.ListMDMAppleConfigProfiles(ctx, ptr.Uint(0)) require.NoError(t, err) - require.Len(t, globalPfs, 5) + require.Len(t, globalPfs, 6) // still the same profiles to assign (plus the one for hostLabel) as there are no profiles for team 1 profilesToInstall, err = ds.ListMDMAppleProfilesToInstall(ctx, "") + require.NoError(t, err) + matchProfiles([]*fleet.MDMAppleProfilePayload{ + {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + + {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profIncludeAny.ProfileUUID, ProfileIdentifier: profIncludeAny.Identifier, ProfileName: profIncludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profIncludeAll.ProfileUUID, ProfileIdentifier: profIncludeAll.Identifier, ProfileName: profIncludeAll.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + }, profilesToInstall) + + // Update hosts' labels updated at timestamp so that the exclude any profile with a dynamic label shows up + hostLabel.LabelUpdatedAt = time.Now().Add(1 * time.Second) + err = ds.UpdateHost(ctx, hostLabel) + require.NoError(t, err) + + host1.LabelUpdatedAt = time.Now().Add(1 * time.Second) + err = ds.UpdateHost(ctx, host1) + require.NoError(t, err) + + profilesToInstall, err = ds.ListMDMAppleProfilesToInstall(ctx, "") require.NoError(t, err) matchProfiles([]*fleet.MDMAppleProfilePayload{ {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profIncludeAny.ProfileUUID, ProfileIdentifier: profIncludeAny.Identifier, ProfileName: profIncludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profIncludeAll.ProfileUUID, ProfileIdentifier: profIncludeAll.Identifier, ProfileName: profIncludeAll.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, }, profilesToInstall) // Remove the l1<->hostLabel relationship, but add l2<->hostLabel. The profile should still show @@ -9059,12 +9084,14 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profIncludeAny.ProfileUUID, ProfileIdentifier: profIncludeAny.Identifier, ProfileName: profIncludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profIncludeAll.ProfileUUID, ProfileIdentifier: profIncludeAll.Identifier, ProfileName: profIncludeAll.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, }, profilesToInstall) // Remove the l2<->hostLabel relationship. The profie should no longer show up since it's @@ -9078,11 +9105,13 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profIncludeAll.ProfileUUID, ProfileIdentifier: profIncludeAll.Identifier, ProfileName: profIncludeAll.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, }, profilesToInstall) // Remove the l4<->hostLabel relationship. Since the profile is "include-all", it should no longer show @@ -9096,13 +9125,15 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, }, profilesToInstall) - // Add a l6<->host relationship. The exclude-any profile should no longer be assigned to hostLabel. + // Add a l6<->host relationship. The dynamic exclude-any profile should no longer be assigned to hostLabel but manual still will err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{l6.ID, hostLabel.ID}}) require.NoError(t, err) profilesToInstall, err = ds.ListMDMAppleProfilesToInstall(ctx, "") @@ -9112,9 +9143,11 @@ func testMDMAppleProfileLabels(t *testing.T, ds *Datastore) { {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: profExcludeAny.ProfileUUID, ProfileIdentifier: profExcludeAny.Identifier, ProfileName: profExcludeAny.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: host1.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf1.ProfileUUID, ProfileIdentifier: globProf1.Identifier, ProfileName: globProf1.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, {ProfileUUID: globProf2.ProfileUUID, ProfileIdentifier: globProf2.Identifier, ProfileName: globProf2.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, + {ProfileUUID: profExcludeAnyManualOnly.ProfileUUID, ProfileIdentifier: profExcludeAnyManualOnly.Identifier, ProfileName: profExcludeAnyManualOnly.Name, HostUUID: hostLabel.UUID, HostPlatform: "darwin", Scope: fleet.PayloadScopeSystem}, }, profilesToInstall) } diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index c346914182..d6f115e374 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -1322,8 +1322,13 @@ const windowsMDMProfilesDesiredStateQuery = ` COUNT(mcpl.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, -- this helps avoid the case where the host is not a member of a label - -- just because it hasn't reported results for that label yet. - SUM(CASE WHEN lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + -- just because it hasn't reported results for that label yet. But we + -- only need consider this for dynamic labels - manual(type=1) can be + -- considered at any time + SUM( + CASE WHEN lbl.label_membership_type <> 1 AND lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.label_membership_type = 1 AND lbl.created_at IS NOT NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels FROM mdm_windows_configuration_profiles mwcp JOIN hosts h diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index e343d6239f..c8e59457a0 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -2311,7 +2311,8 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { u := uuid.New().String() host, err := ds.NewHost(ctx, &fleet.Host{ DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), + // Set this slightly in the past to test dynamic label exclusion + LabelUpdatedAt: time.Now().Add(-5 * time.Second), PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), NodeKey: &u, @@ -2359,7 +2360,7 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - // exclude-all labels + // exclude-any labels l6, err := ds.NewLabel(ctx, &fleet.Label{ Name: "exclude-any-label6", Description: "desc", @@ -2368,9 +2369,9 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { require.NoError(t, err) l7, err := ds.NewLabel(ctx, &fleet.Label{ - Name: "exclude-any-label7", - Description: "desc", - Query: "select 1;", + Name: "exclude-any-label7", + Description: "desc", + LabelMembershipType: fleet.LabelMembershipTypeManual, }) require.NoError(t, err) @@ -2397,25 +2398,31 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { checksum = md5.Sum(includeAllProf.SyncML) // nolint:gosec // used only to hash for efficient comparisons profileChecksums[includeAllProf.ProfileUUID] = checksum[:] - // Create a profile with "exclude-all" with l6 and l7 - excludeAllProf, err := ds.NewMDMWindowsConfigProfile( + // Create a profile with "exclude-any" with l6 and l7 + excludeAnyProf, err := ds.NewMDMWindowsConfigProfile( ctx, *windowsConfigProfileForTest(t, "prof-exclude-any", "./Foo/Bar", l6, l7), nil, ) require.NoError(t, err) - checksum = md5.Sum(excludeAllProf.SyncML) // nolint:gosec // used only to hash for efficient comparisons - profileChecksums[excludeAllProf.ProfileUUID] = checksum[:] + checksum = md5.Sum(excludeAnyProf.SyncML) // nolint:gosec // used only to hash for efficient comparisons + profileChecksums[excludeAnyProf.ProfileUUID] = checksum[:] + + // Create a profile with "exclude-any" with l7 only since it is a manual label + excludeAnyManualProf, err := ds.NewMDMWindowsConfigProfile( + ctx, + *windowsConfigProfileForTest(t, "prof-exclude-any-manual", "./Foo/Bar", l7), + nil, + ) + require.NoError(t, err) + checksum = md5.Sum(excludeAnyManualProf.SyncML) // nolint:gosec // used only to hash for efficient comparisons + profileChecksums[excludeAnyManualProf.ProfileUUID] = checksum[:] // Connect the host and l1, l4, l5 err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{l1.ID, host.ID}, {l4.ID, host.ID}, {l5.ID, host.ID}}) require.NoError(t, err) - host.LabelUpdatedAt = time.Now() - err = ds.UpdateHost(ctx, host) - require.NoError(t, err) - - // We should see all 3 profiles in the "to install" list + // We should see 3 profiles in the "to install" list profilesToInstall, err := ds.ListMDMWindowsProfilesToInstall(ctx) require.NoError(t, err) require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ @@ -2428,8 +2435,34 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { Checksum: profileChecksums[includeAnyProf.ProfileUUID], }, { - ProfileUUID: excludeAllProf.ProfileUUID, ProfileName: excludeAllProf.Name, HostUUID: host.UUID, - Checksum: profileChecksums[excludeAllProf.ProfileUUID], + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], + }, + }, profilesToInstall) + + host.LabelUpdatedAt = time.Now().Add(1 * time.Second) + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + + // We should see all 4 profiles in the "to install" list + profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ + { + ProfileUUID: includeAllProf.ProfileUUID, ProfileName: includeAllProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[includeAllProf.ProfileUUID], + }, + { + ProfileUUID: includeAnyProf.ProfileUUID, ProfileName: includeAnyProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[includeAnyProf.ProfileUUID], + }, + { + ProfileUUID: excludeAnyProf.ProfileUUID, ProfileName: excludeAnyProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyProf.ProfileUUID], + }, + { + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], }, }, profilesToInstall) @@ -2453,8 +2486,12 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { Checksum: profileChecksums[includeAnyProf.ProfileUUID], }, { - ProfileUUID: excludeAllProf.ProfileUUID, ProfileName: excludeAllProf.Name, HostUUID: host.UUID, - Checksum: profileChecksums[excludeAllProf.ProfileUUID], + ProfileUUID: excludeAnyProf.ProfileUUID, ProfileName: excludeAnyProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyProf.ProfileUUID], + }, + { + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], }, }, profilesToInstall) @@ -2471,8 +2508,12 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { Checksum: profileChecksums[includeAllProf.ProfileUUID], }, { - ProfileUUID: excludeAllProf.ProfileUUID, ProfileName: excludeAllProf.Name, HostUUID: host.UUID, - Checksum: profileChecksums[excludeAllProf.ProfileUUID], + ProfileUUID: excludeAnyProf.ProfileUUID, ProfileName: excludeAnyProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyProf.ProfileUUID], + }, + { + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], }, }, profilesToInstall) @@ -2485,18 +2526,28 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { require.NoError(t, err) require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ { - ProfileUUID: excludeAllProf.ProfileUUID, ProfileName: excludeAllProf.Name, HostUUID: host.UUID, - Checksum: profileChecksums[excludeAllProf.ProfileUUID], + ProfileUUID: excludeAnyProf.ProfileUUID, ProfileName: excludeAnyProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyProf.ProfileUUID], + }, + { + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], }, }, profilesToInstall) - // Add a l6<->host relationship. The exclude-any profile should be gone now. + // Add a l6<->host relationship. The exclude-any profile with l6 and l7 should be gone now with only + // the exclude-any-manual profile remaining. err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{l6.ID, host.ID}}) require.NoError(t, err) profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) require.NoError(t, err) - require.Empty(t, profilesToInstall) + require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ + { + ProfileUUID: excludeAnyManualProf.ProfileUUID, ProfileName: excludeAnyManualProf.Name, HostUUID: host.UUID, + Checksum: profileChecksums[excludeAnyManualProf.ProfileUUID], + }, + }, profilesToInstall) } func expectWindowsProfiles( diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 6fcc78ace7..c1c12f509e 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -4078,7 +4078,10 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt SELECT COUNT(*) AS count_installer_labels, COUNT(lm.label_id) AS count_host_labels, - SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels + SUM( + CASE WHEN lbl.label_membership_type <> 1 AND lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 + WHEN lbl.label_membership_type = 1 AND lbl.created_at IS NOT NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels FROM software_installer_labels sil LEFT OUTER JOIN labels lbl diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index dbb1f0c465..4feeff2568 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -6873,8 +6873,8 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { err = ds.UpdateHost(ctx, anotherHost) require.NoError(t, err) require.NoError(t, ds.AddLabelsToHost(ctx, thirdHost.ID, []uint{label1.ID})) - anotherHost.LabelUpdatedAt = time.Now() - err = ds.UpdateHost(ctx, anotherHost) + thirdHost.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, thirdHost) require.NoError(t, err) time.Sleep(time.Second) @@ -6985,10 +6985,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { checkSoftware(software) // Add "exclude any" labels to installer2 - label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()}) + label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name(), Query: "select 1"}) require.NoError(t, err) - label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()}) + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual}) require.NoError(t, err) err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID2, fleet.LabelIdentsWithScope{ @@ -7118,6 +7118,46 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID) require.NoError(t, err) require.False(t, scoped) + + // Add yet another installer. No label yet. + installer4 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + UninstallScript: "goodbye", + InstallerFile: tfr1, + StorageID: "storage5", + Filename: "file5", + Title: "file5", + Version: "3.0", + Source: "apps", + UserID: user1.ID, + BundleIdentifier: "bi5", + Platform: "darwin", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + installerID4, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer4) + require.NoError(t, err) + + // No labels yet, so we should see it + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, installer2.Filename, installer3.Filename, installer4.Filename) + + // Create a new manual label and apply it to the new installer + label5, err := ds.NewLabel(ctx, &fleet.Label{Name: "label5" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID4, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label5.Name: {LabelName: label5.Name, LabelID: label5.ID}}, + }, softwareTypeInstaller) + require.NoError(t, err) + + // Installer4 is still listed and does not need the host's LabelUpdatedAt to be updated + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, installer2.Filename, installer3.Filename, installer4.Filename) } func testListHostSoftwareVulnerableAndVPP(t *testing.T, ds *Datastore) { @@ -8069,7 +8109,7 @@ func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) { }, softwareTypeVPP) require.NoError(t, err) - // intall vpp app on fourth host + // install vpp app on fourth host fourthHostVpp1CmdUUID := createVPPAppInstallRequest(t, ds, fourthHost, vppApp.AdamID, user1) _, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), fourthHost.ID, "") require.NoError(t, err) @@ -8217,7 +8257,6 @@ func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) { host.LabelUpdatedAt = time.Now() err = ds.UpdateHost(ctx, host) require.NoError(t, err) - time.Sleep(time.Second) software, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) @@ -8226,6 +8265,22 @@ func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) { scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) require.NoError(t, err) require.True(t, scoped) + + // Create a manual label (prior was dynamic) and set it instead as exclude-any for the VPP app + label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeExcludeAny, + ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}}, + }, softwareTypeVPP) + require.NoError(t, err) + + // The host need not update its LabelUpdatedAt because the label is manually scoped and not applied, + // so it is immediately resolvable (and the host doesn't have the excluded label applied to it) + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.True(t, scoped) } func testListHostSoftwareLastOpenedAt(t *testing.T, ds *Datastore) { diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index c419b4e68a..e66153ab05 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -387,7 +387,9 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De host.Host.CPUType = device.HardwareInfo.Hardware host.Host.HardwareModel = svc.getComputerName(device) host.Host.HardwareVendor = device.HardwareInfo.Brand - host.LabelUpdatedAt = time.Time{} + // Android hosts do not support dynamic labels so we should keep their labelUpdatedAt updated at every + // checkin to match platforms that do and make label logic simpler + host.LabelUpdatedAt = time.Now() if device.LastStatusReportTime != "" { lastStatusReportTime, err := time.Parse(time.RFC3339, device.LastStatusReportTime) if err != nil { @@ -443,7 +445,7 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De CPUType: device.HardwareInfo.Hardware, HardwareModel: svc.getComputerName(device), HardwareVendor: device.HardwareInfo.Brand, - LabelUpdatedAt: time.Time{}, + LabelUpdatedAt: time.Now(), DetailUpdatedAt: time.Time{}, UUID: device.HardwareInfo.EnterpriseSpecificId, }, diff --git a/server/mdm/android/service/pubsub_test.go b/server/mdm/android/service/pubsub_test.go index 3af17116e5..5de40fe175 100644 --- a/server/mdm/android/service/pubsub_test.go +++ b/server/mdm/android/service/pubsub_test.go @@ -8,6 +8,7 @@ import ( "os" "strings" "testing" + "time" "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -589,6 +590,8 @@ func TestUpdateHost(t *testing.T) { // verify UUID is set correctly var capturedHost *fleet.AndroidHost mockDS.UpdateAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, fromEnroll bool) error { + // Validate that the update always updates the label updated at value with a recent one + require.Greater(t, host.LabelUpdatedAt, time.Now().Add(-5*time.Second)) capturedHost = host return nil } diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 60baa8d6ad..564ad5c4f6 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -4003,6 +4003,9 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx cont host.PrimaryMac = wifiMac host.HardwareModel = productName host.DetailUpdatedAt = time.Now() + // iOS/iPadOS devices do not support dynamic labels at this time so we should update their LabelUpdatedAt timestamp + // on refetch similar to other platforms to simplify exclusion logic with dynamic labels + host.LabelUpdatedAt = time.Now() host.RefetchRequested = false if err := svc.ds.UpdateHost(ctx, host); err != nil { diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index febb62e42a..2afdc4da8e 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -4518,6 +4518,7 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) { require.Equal(t, "ff:ff:ff:ff:ff:ff", host.PrimaryMac) require.Equal(t, "iPad13,18", host.HardwareModel) require.WithinDuration(t, time.Now(), host.DetailUpdatedAt, 1*time.Minute) + require.WithinDuration(t, time.Now(), host.LabelUpdatedAt, 1*time.Minute) return nil } ds.SetOrUpdateHostDisksSpaceFunc = func(ctx context.Context, incomingHostID uint, gigsAvailable, percentAvailable, gigsTotal float64, gigsAll *float64) error {