diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index ba12717e25..138102044b 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -1052,9 +1052,8 @@ const androidApplicableProfilesQuery = ` UNION - -- label-based profiles where the host is a member of all the labels (include-all). - -- by design, "include" labels cannot match if they are broken (the host cannot be - -- a member of a deleted label). + -- include-all only (no exclude labels): host must be a member of every include label. + -- broken include labels disqualify the profile. SELECT macp.profile_uuid, macp.name, @@ -1077,6 +1076,10 @@ const androidApplicableProfilesQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 1 + ) AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id @@ -1085,11 +1088,8 @@ const androidApplicableProfilesQuery = ` UNION - -- label-based entities where the host is NOT a member of any of the labels (exclude-any). - -- explicitly ignore profiles with broken excluded labels so that they are never applied, - -- and ignore profiles that depend 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). + -- exclude-any only (no include labels): host must NOT be a member of any exclude label. + -- broken or not-yet-scanned dynamic exclude labels disqualify the profile. SELECT macp.profile_uuid, macp.name, @@ -1099,10 +1099,6 @@ const androidApplicableProfilesQuery = ` COUNT(*) as count_profile_labels, 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. 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 @@ -1121,20 +1117,21 @@ const androidApplicableProfilesQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 0 + ) AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id HAVING - -- considers only the profiles with labels, without any broken label, with results reported after all labels were - -- created and with the host not in any label count_profile_labels > 0 AND count_profile_labels = count_non_broken_labels AND count_profile_labels = count_host_updated_after_labels AND count_host_labels = 0 UNION - -- label-based profiles where the host is a member of any of the labels (include-any). - -- by design, "include" labels cannot match if they are broken (the host cannot be - -- a member of a deleted label). + -- include-any only (no exclude labels): host must be a member of at least one include label. + -- broken include labels are skipped (host can't be a member of a deleted label). SELECT macp.profile_uuid, macp.name, @@ -1157,11 +1154,115 @@ const androidApplicableProfilesQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 1 + ) AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id HAVING count_profile_labels > 0 AND count_host_labels >= 1 + + UNION + + -- include-all + exclude-any: host must be in ALL include labels AND NOT in ANY exclude label. + -- broken include labels or broken/not-yet-scanned dynamic exclude labels disqualify the profile. + SELECT + macp.profile_uuid, + macp.name, + macp.checksum, + h.uuid as host_uuid, + h.id as host_id, + SUM(CASE WHEN mcpl.exclude = 0 THEN 1 ELSE 0 END) as count_profile_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND mcpl.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_non_broken_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND lm_inc.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_host_labels, + SUM(CASE WHEN mcpl.exclude = 1 AND lm_exc.label_id IS NOT NULL THEN 1 + WHEN mcpl.exclude = 1 AND (lbl.label_membership_type = 0 AND lbl.created_at IS NOT NULL AND h.label_updated_at < lbl.created_at) THEN 1 + WHEN mcpl.exclude = 1 AND mcpl.label_id IS NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels + FROM + mdm_android_configuration_profiles macp + JOIN hosts h + ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) + JOIN android_devices ad + ON ad.host_id = h.id + JOIN mdm_configuration_profile_labels mcpl + ON mcpl.android_profile_uuid = macp.profile_uuid + LEFT OUTER JOIN labels lbl + ON lbl.id = mcpl.label_id + LEFT OUTER JOIN label_membership lm_inc + ON lm_inc.label_id = mcpl.label_id AND lm_inc.host_id = h.id AND mcpl.exclude = 0 + LEFT OUTER JOIN label_membership lm_exc + ON lm_exc.label_id = mcpl.label_id AND lm_exc.host_id = h.id AND mcpl.exclude = 1 + WHERE + h.platform = 'android' AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 0 AND require_all = 1 + ) AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 1 + ) AND + ( %s ) + GROUP BY + macp.profile_uuid, macp.name, h.uuid, h.id + HAVING + -- include gate: host in all include labels (no broken include labels) + count_profile_labels > 0 AND count_non_broken_labels = count_profile_labels AND count_host_labels = count_profile_labels AND + -- exclude gate: host not in any exclude label, no broken/unscanned exclude labels + count_host_updated_after_labels = 0 + + UNION + + -- include-any + exclude-any: host must be in AT LEAST ONE include label AND NOT in ANY exclude label. + -- broken/not-yet-scanned dynamic exclude labels disqualify the profile. + SELECT + macp.profile_uuid, + macp.name, + macp.checksum, + h.uuid as host_uuid, + h.id as host_id, + SUM(CASE WHEN mcpl.exclude = 0 THEN 1 ELSE 0 END) as count_profile_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND mcpl.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_non_broken_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND lm_inc.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_host_labels, + SUM(CASE WHEN mcpl.exclude = 1 AND lm_exc.label_id IS NOT NULL THEN 1 + WHEN mcpl.exclude = 1 AND (lbl.label_membership_type = 0 AND lbl.created_at IS NOT NULL AND h.label_updated_at < lbl.created_at) THEN 1 + WHEN mcpl.exclude = 1 AND mcpl.label_id IS NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels + FROM + mdm_android_configuration_profiles macp + JOIN hosts h + ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) + JOIN android_devices ad + ON ad.host_id = h.id + JOIN mdm_configuration_profile_labels mcpl + ON mcpl.android_profile_uuid = macp.profile_uuid + LEFT OUTER JOIN labels lbl + ON lbl.id = mcpl.label_id + LEFT OUTER JOIN label_membership lm_inc + ON lm_inc.label_id = mcpl.label_id AND lm_inc.host_id = h.id AND mcpl.exclude = 0 + LEFT OUTER JOIN label_membership lm_exc + ON lm_exc.label_id = mcpl.label_id AND lm_exc.host_id = h.id AND mcpl.exclude = 1 + WHERE + h.platform = 'android' AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 0 AND require_all = 0 + ) AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE android_profile_uuid = macp.profile_uuid AND exclude = 1 + ) AND + ( %s ) + GROUP BY + macp.profile_uuid, macp.name, h.uuid, h.id + HAVING + -- include gate: host in at least one include label + count_host_labels >= 1 AND + -- exclude gate: host not in any exclude label, no broken/unscanned exclude labels + count_host_updated_after_labels = 0 ` // ListMDMAndroidProfilesToSend is the android platform equivalent to @@ -1233,7 +1334,7 @@ func (ds *Datastore) ListMDMAndroidProfilesToSend(ctx context.Context) ([]*fleet ds.host_uuid IS NULL AND -- and it is not in pending remove status (in which case it was processed) ( hmap.operation_type != ? OR COALESCE(hmap.status, '') <> ? ) -`, fmt.Sprintf(androidApplicableProfilesQuery, "TRUE", "TRUE", "TRUE", "TRUE")) +`, fmt.Sprintf(androidApplicableProfilesQuery, "TRUE", "TRUE", "TRUE", "TRUE", "TRUE", "TRUE")) // NOTE: we explicitly don't "ignore" profiles to remove based on broken labels, // because of how Android profiles are applied vs other platforms (ignoring @@ -1271,9 +1372,9 @@ func (ds *Datastore) ListMDMAndroidProfilesToSend(ctx context.Context) ([]*fleet ON hmap.host_uuid = ds.host_uuid AND hmap.profile_uuid = ds.profile_uuid LEFT OUTER JOIN android_policy_requests apr ON apr.request_uuid = hmap.policy_request_uuid -`, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) +`, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) - query, args, err := sqlx.In(listToInstallProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) + query, args, err := sqlx.In(listToInstallProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) if err != nil { return ctxerr.Wrap(ctx, err, "building list android host applicable profiles query") } @@ -1297,9 +1398,9 @@ func (ds *Datastore) ListMDMAndroidProfilesToSend(ctx context.Context) ([]*fleet WHERE hmap.host_uuid IN (?) AND ds.host_uuid IS NULL -`, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) +`, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) - query, args, err = sqlx.In(listToRemoveProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) + query, args, err = sqlx.In(listToRemoveProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) if err != nil { return ctxerr.Wrap(ctx, err, "building list android host to remove profiles query") } diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 4cd224484a..086a87ee5a 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -38,6 +38,7 @@ func TestAndroid(t *testing.T) { {"GetMDMAndroidProfilesSummary", testMDMAndroidProfilesSummary}, {"ListMDMAndroidProfilesToSend", testListMDMAndroidProfilesToSend}, {"ListMDMAndroidProfilesToSend_WithExcludeAny", testListMDMAndroidProfilesToSendWithExcludeAny}, + {"ListMDMAndroidProfilesToSend_WithCombinedLabels", testListMDMAndroidProfilesToSendWithCombinedLabels}, {"GetMDMAndroidProfilesContents", testGetMDMAndroidProfilesContents}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles}, {"BulkUpsertMDMAndroidHostProfiles", testBulkUpsertMDMAndroidHostProfiles2}, @@ -1752,6 +1753,91 @@ func testListMDMAndroidProfilesToSendWithExcludeAny(t *testing.T, ds *Datastore) }, profs) } +func testListMDMAndroidProfilesToSendWithCombinedLabels(t *testing.T, ds *Datastore) { + test.AddBuiltinLabels(t, ds) + ctx := t.Context() + + host := createAndroidHost("enterprise-id-combined") + newHost, err := ds.NewAndroidHost(ctx, host, false) + require.NoError(t, err) + h := newHost.Host + + // advance label_updated_at so dynamic labels are immediately evaluated + h.LabelUpdatedAt = time.Now().UTC().Add(time.Second) + h.PolicyUpdatedAt = time.Now().UTC() + err = ds.UpdateHost(ctx, h) + require.NoError(t, err) + + inclAllLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "incl-all-1", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + inclAllLbl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "incl-all-2", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + inclAnyLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "inclany-any-1", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + exclLbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-1", LabelMembershipType: fleet.LabelMembershipTypeManual}) + require.NoError(t, err) + + // include-all + exclude-any profile (requires both incl-all-1 and incl-all-2) + pCombinedAll, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-incl-all", inclAllLbl, inclAllLbl2, exclLbl)) + require.NoError(t, err) + // include-any + exclude-any profile + pCombinedAny, err := ds.NewMDMAndroidConfigProfile(ctx, *androidProfileForTest("combined-incl-any", inclAnyLbl, exclLbl)) + require.NoError(t, err) + + profChecksum := getAndroidProfileChecksum(t, ds, pCombinedAll.ProfileUUID) + + // host is not a member of any label → neither profile applies + profs, toRemove, err := ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemove) + require.Empty(t, profs) + + // host joins include labels but not exclude → both profiles apply + err = ds.AddLabelsToHost(ctx, h.ID, []uint{inclAllLbl.ID, inclAllLbl2.ID, inclAnyLbl.ID}) + require.NoError(t, err) + + profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemove) + require.Len(t, profs, 2) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: pCombinedAll.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAll.Name, Checksum: profChecksum}, + {ProfileUUID: pCombinedAny.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAny.Name, Checksum: profChecksum}, + }, profs) + + // host also joins exclude label → neither profile applies + err = ds.AddLabelsToHost(ctx, h.ID, []uint{exclLbl.ID}) + require.NoError(t, err) + + profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemove) + require.Empty(t, profs) + + // host leaves exclude label → both profiles apply again + err = ds.RemoveLabelsFromHost(ctx, h.ID, []uint{exclLbl.ID}) + require.NoError(t, err) + + profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemove) + require.Len(t, profs, 2) + require.ElementsMatch(t, []*fleet.MDMAndroidProfilePayload{ + {ProfileUUID: pCombinedAll.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAll.Name, Checksum: profChecksum}, + {ProfileUUID: pCombinedAny.ProfileUUID, HostUUID: h.UUID, ProfileName: pCombinedAny.Name, Checksum: profChecksum}, + }, profs) + + // remove host from one include-all label → include-all profile no longer applies, include-any still does + err = ds.RemoveLabelsFromHost(ctx, h.ID, []uint{inclAllLbl2.ID}) + require.NoError(t, err) + + profs, toRemove, err = ds.ListMDMAndroidProfilesToSend(ctx) + require.NoError(t, err) + require.Empty(t, toRemove) + require.Len(t, profs, 1) + require.Equal(t, pCombinedAny.ProfileUUID, profs[0].ProfileUUID) +} + func testGetMDMAndroidProfilesContents(t *testing.T, ds *Datastore) { ctx := t.Context() p1 := androidProfileForTest("p1") diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 1ac0eb5537..1c4941d8ca 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -2464,9 +2464,8 @@ const windowsMDMProfilesDesiredStateQuery = ` UNION - -- label-based profiles where the host is a member of all the labels (include-all). - -- by design, "include" labels cannot match if they are broken (the host cannot be - -- a member of a deleted label). + -- include-all only (no exclude labels): host must be a member of every include label. + -- broken include labels disqualify the profile. SELECT mwcp.profile_uuid, mwcp.name, @@ -2491,6 +2490,10 @@ const windowsMDMProfilesDesiredStateQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'windows' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 1 + ) AND ( %s ) GROUP BY mwcp.profile_uuid, mwcp.name, h.uuid @@ -2499,11 +2502,8 @@ const windowsMDMProfilesDesiredStateQuery = ` UNION - -- label-based entities where the host is NOT a member of any of the labels (exclude-any). - -- explicitly ignore profiles with broken excluded labels so that they are never applied, - -- and ignore profiles that depend 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). + -- exclude-any only (no include labels): host must NOT be a member of any exclude label. + -- broken or not-yet-scanned dynamic exclude labels disqualify the profile. SELECT mwcp.profile_uuid, mwcp.name, @@ -2513,10 +2513,6 @@ const windowsMDMProfilesDesiredStateQuery = ` COUNT(*) as count_profile_labels, 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. 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 @@ -2537,18 +2533,20 @@ const windowsMDMProfilesDesiredStateQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'windows' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 0 + ) AND ( %s ) GROUP BY mwcp.profile_uuid, mwcp.name, h.uuid HAVING - -- considers only the profiles with labels, without any broken label, with results reported after all labels were created and with the host not in any label count_profile_labels > 0 AND count_profile_labels = count_non_broken_labels AND count_profile_labels = count_host_updated_after_labels AND count_host_labels = 0 UNION - -- label-based profiles where the host is a member of any of the labels (include-any). - -- by design, "include" labels cannot match if they are broken (the host cannot be - -- a member of a deleted label). + -- include-any only (no exclude labels): host must be a member of at least one include label. + -- broken include labels are skipped (host can't be a member of a deleted label). SELECT mwcp.profile_uuid, mwcp.name, @@ -2573,11 +2571,120 @@ const windowsMDMProfilesDesiredStateQuery = ` ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'windows' AND + NOT EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 1 + ) AND ( %s ) GROUP BY mwcp.profile_uuid, mwcp.name, h.uuid HAVING count_profile_labels > 0 AND count_host_labels >= 1 + + UNION + + -- include-all + exclude-any: host must be in ALL include labels AND NOT in ANY exclude label. + -- the include and exclude rows are counted separately via conditional aggregation. + -- broken include labels or broken/not-yet-scanned dynamic exclude labels disqualify the profile. + SELECT + mwcp.profile_uuid, + mwcp.name, + mwcp.checksum, + mwcp.secrets_updated_at, + h.uuid as host_uuid, + SUM(CASE WHEN mcpl.exclude = 0 THEN 1 ELSE 0 END) as count_profile_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND mcpl.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_non_broken_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND lm_inc.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_host_labels, + SUM(CASE WHEN mcpl.exclude = 1 AND lm_exc.label_id IS NOT NULL THEN 1 + WHEN mcpl.exclude = 1 AND (lbl.label_membership_type = 0 AND lbl.created_at IS NOT NULL AND h.label_updated_at < lbl.created_at) THEN 1 + WHEN mcpl.exclude = 1 AND mcpl.label_id IS NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels + FROM + mdm_windows_configuration_profiles mwcp + JOIN hosts h + ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0) + JOIN mdm_windows_enrollments mwe + ON mwe.host_uuid = h.uuid + JOIN host_mdm hmdm + ON hmdm.host_id = h.id AND hmdm.enrolled = 1 + JOIN mdm_configuration_profile_labels mcpl + ON mcpl.windows_profile_uuid = mwcp.profile_uuid + LEFT OUTER JOIN labels lbl + ON lbl.id = mcpl.label_id + LEFT OUTER JOIN label_membership lm_inc + ON lm_inc.label_id = mcpl.label_id AND lm_inc.host_id = h.id AND mcpl.exclude = 0 + LEFT OUTER JOIN label_membership lm_exc + ON lm_exc.label_id = mcpl.label_id AND lm_exc.host_id = h.id AND mcpl.exclude = 1 + WHERE + h.platform = 'windows' AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 0 AND require_all = 1 + ) AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 1 + ) AND + ( %s ) + GROUP BY + mwcp.profile_uuid, mwcp.name, h.uuid + HAVING + -- include gate: host in all include labels (no broken include labels) + count_profile_labels > 0 AND count_non_broken_labels = count_profile_labels AND count_host_labels = count_profile_labels AND + -- exclude gate: host not in any exclude label, no broken/unscanned exclude labels (reusing count_host_updated_after_labels) + count_host_updated_after_labels = 0 + + UNION + + -- include-any + exclude-any: host must be in AT LEAST ONE include label AND NOT in ANY exclude label. + -- broken/not-yet-scanned dynamic exclude labels disqualify the profile. + SELECT + mwcp.profile_uuid, + mwcp.name, + mwcp.checksum, + mwcp.secrets_updated_at, + h.uuid as host_uuid, + SUM(CASE WHEN mcpl.exclude = 0 THEN 1 ELSE 0 END) as count_profile_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND mcpl.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_non_broken_labels, + SUM(CASE WHEN mcpl.exclude = 0 AND lm_inc.label_id IS NOT NULL THEN 1 ELSE 0 END) as count_host_labels, + SUM(CASE WHEN mcpl.exclude = 1 AND lm_exc.label_id IS NOT NULL THEN 1 + WHEN mcpl.exclude = 1 AND (lbl.label_membership_type = 0 AND lbl.created_at IS NOT NULL AND h.label_updated_at < lbl.created_at) THEN 1 + WHEN mcpl.exclude = 1 AND mcpl.label_id IS NULL THEN 1 + ELSE 0 END) as count_host_updated_after_labels + FROM + mdm_windows_configuration_profiles mwcp + JOIN hosts h + ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0) + JOIN mdm_windows_enrollments mwe + ON mwe.host_uuid = h.uuid + JOIN host_mdm hmdm + ON hmdm.host_id = h.id AND hmdm.enrolled = 1 + JOIN mdm_configuration_profile_labels mcpl + ON mcpl.windows_profile_uuid = mwcp.profile_uuid + LEFT OUTER JOIN labels lbl + ON lbl.id = mcpl.label_id + LEFT OUTER JOIN label_membership lm_inc + ON lm_inc.label_id = mcpl.label_id AND lm_inc.host_id = h.id AND mcpl.exclude = 0 + LEFT OUTER JOIN label_membership lm_exc + ON lm_exc.label_id = mcpl.label_id AND lm_exc.host_id = h.id AND mcpl.exclude = 1 + WHERE + h.platform = 'windows' AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 0 AND require_all = 0 + ) AND + EXISTS ( + SELECT 1 FROM mdm_configuration_profile_labels + WHERE windows_profile_uuid = mwcp.profile_uuid AND exclude = 1 + ) AND + ( %s ) + GROUP BY + mwcp.profile_uuid, mwcp.name, h.uuid + HAVING + -- include gate: host in at least one include label + count_host_labels >= 1 AND + -- exclude gate: host not in any exclude label, no broken/unscanned exclude labels + count_host_updated_after_labels = 0 ` func (ds *Datastore) ListMDMWindowsProfilesToInstall(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) { @@ -2640,7 +2747,7 @@ const windowsProfilesToInstallQuery = ` func (ds *Datastore) listAllMDMWindowsProfilesToInstallDB(ctx context.Context, tx sqlx.ExtContext) ([]*fleet.MDMWindowsProfilePayload, error) { var profiles []*fleet.MDMWindowsProfilePayload - err := sqlx.SelectContext(ctx, tx, &profiles, fmt.Sprintf(windowsProfilesToInstallQuery, "TRUE", "TRUE", "TRUE", "TRUE"), fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove) + err := sqlx.SelectContext(ctx, tx, &profiles, fmt.Sprintf(windowsProfilesToInstallQuery, "TRUE", "TRUE", "TRUE", "TRUE", "TRUE", "TRUE"), fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove) if err != nil { return nil, ctxerr.Wrapf(ctx, err, "selecting windows MDM profiles to install") } @@ -2663,7 +2770,7 @@ func (ds *Datastore) listMDMWindowsProfilesToInstallDB( hostFilter = "mwcp.profile_uuid IN (?) AND h.uuid IN (?)" } - toInstallQuery := fmt.Sprintf(windowsProfilesToInstallQuery, hostFilter, hostFilter, hostFilter, hostFilter) + toInstallQuery := fmt.Sprintf(windowsProfilesToInstallQuery, hostFilter, hostFilter, hostFilter, hostFilter, hostFilter, hostFilter) // use a 10k host batch size to match what we do on the macOS side. selectProfilesBatchSize := 10_000 @@ -2687,10 +2794,12 @@ func (ds *Datastore) listMDMWindowsProfilesToInstallDB( onlyProfileUUIDs, batchUUIDs, onlyProfileUUIDs, batchUUIDs, onlyProfileUUIDs, batchUUIDs, + onlyProfileUUIDs, batchUUIDs, + onlyProfileUUIDs, batchUUIDs, fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove, ) } else { - stmt, args, err = sqlx.In(toInstallQuery, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove) + stmt, args, err = sqlx.In(toInstallQuery, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove) } if err != nil { return nil, ctxerr.Wrapf(ctx, err, "building sqlx.In for list MDM windows profiles to install, batch %d of %d", i, selectProfilesTotalBatches) @@ -2758,8 +2867,8 @@ func (ds *Datastore) ListNextPendingMDMWindowsHostUUIDs(ctx context.Context, aft // UNION arms so the optimizer filters early. The remove query also // gets the cursor in its 5th slot (outer WHERE on hmwp.host_uuid) // for a clean PK range scan on host_mdm_windows_profiles. - toInstall := fmt.Sprintf(windowsProfilesToInstallQuery, "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?") - toRemove := fmt.Sprintf(windowsProfilesToRemoveQuery, "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "hmwp.host_uuid > ?") + toInstall := fmt.Sprintf(windowsProfilesToInstallQuery, "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?") + toRemove := fmt.Sprintf(windowsProfilesToRemoveQuery, "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "h.uuid > ?", "hmwp.host_uuid > ?") stmt := fmt.Sprintf(` SELECT host_uuid FROM ( @@ -2772,13 +2881,13 @@ func (ds *Datastore) ListNextPendingMDMWindowsHostUUIDs(ctx context.Context, aft `, toInstall, toRemove, batchSize) // Placeholder order in stmt: - // install branches: 4 cursor (h.uuid > ?), 2 op-type (install, remove) - // remove branches: 4 cursor (h.uuid > ?) for desired-state arms, 1 cursor (hmwp.host_uuid > ?) for outer WHERE + // install branches: 6 cursor (h.uuid > ?), 2 op-type (install, remove) + // remove branches: 6 cursor (h.uuid > ?) for desired-state arms, 1 cursor (hmwp.host_uuid > ?) for outer WHERE var hostUUIDs []string if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostUUIDs, stmt, - afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, + afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, fleet.MDMOperationTypeInstall, fleet.MDMOperationTypeRemove, - afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, + afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, afterHostUUID, ); err != nil { return nil, ctxerr.Wrap(ctx, err, "listing next pending MDM windows host UUIDs") @@ -2863,7 +2972,7 @@ const windowsProfilesToRemoveQuery = ` ` func (ds *Datastore) listAllMDMWindowsProfilesToRemoveDB(ctx context.Context, tx sqlx.ExtContext) (profiles []*fleet.MDMWindowsProfilePayload, err error) { - err = sqlx.SelectContext(ctx, tx, &profiles, fmt.Sprintf(windowsProfilesToRemoveQuery, "TRUE", "TRUE", "TRUE", "TRUE", "TRUE")) + err = sqlx.SelectContext(ctx, tx, &profiles, fmt.Sprintf(windowsProfilesToRemoveQuery, "TRUE", "TRUE", "TRUE", "TRUE", "TRUE", "TRUE", "TRUE")) if err != nil { return nil, ctxerr.Wrapf(ctx, err, "selecting windows MDM profiles to remove") } @@ -2889,7 +2998,7 @@ func (ds *Datastore) listMDMWindowsProfilesToRemoveDB( } toRemoveQuery := fmt.Sprintf(windowsProfilesToRemoveQuery, - desiredStateFilter, desiredStateFilter, desiredStateFilter, desiredStateFilter, + desiredStateFilter, desiredStateFilter, desiredStateFilter, desiredStateFilter, desiredStateFilter, desiredStateFilter, outerFilter, ) @@ -2916,6 +3025,8 @@ func (ds *Datastore) listMDMWindowsProfilesToRemoveDB( onlyProfileUUIDs, batchUUIDs, onlyProfileUUIDs, batchUUIDs, onlyProfileUUIDs, batchUUIDs, + onlyProfileUUIDs, batchUUIDs, + onlyProfileUUIDs, batchUUIDs, ) } else { stmt, args, err = sqlx.In(toRemoveQuery, @@ -2924,6 +3035,8 @@ func (ds *Datastore) listMDMWindowsProfilesToRemoveDB( batchUUIDs, batchUUIDs, batchUUIDs, + batchUUIDs, + batchUUIDs, ) } if err != nil { diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 7acedd26a0..689a7d6c6e 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -56,6 +56,7 @@ func TestMDMWindows(t *testing.T) { {"TestBatchSetMDMWindowsProfiles", testBatchSetMDMWindowsProfiles}, {"TestMDMWindowsProfileLabels", testMDMWindowsProfileLabels}, {"NewMDMWindowsConfigProfileSoftwareUpdateTracking", testNewMDMWindowsConfigProfileSoftwareUpdateTracking}, + {"TestMDMWindowsProfileLabelsCombined", testMDMWindowsProfileLabelsCombined}, {"TestMDMWindowsSaveResponse", testSaveResponse}, {"TestSetMDMWindowsProfilesWithVariables", testSetMDMWindowsProfilesWithVariables}, {"TestWindowsMDMManagedSCEPCertificates", testWindowsMDMManagedSCEPCertificates}, @@ -3550,6 +3551,114 @@ func testMDMWindowsProfileLabels(t *testing.T, ds *Datastore) { }, profilesToInstall) } +func testMDMWindowsProfileLabelsCombined(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Use a dedicated team to isolate from other tests in the same suite. + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "combined-label-test-team"}) + require.NoError(t, err) + + u := uuid.New().String() + host, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now().Add(-5 * time.Second), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: &u, + UUID: u, + Hostname: u, + Platform: "windows", + TeamID: &tm.ID, + }) + require.NoError(t, err) + windowsEnroll(t, ds, host) + + incLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-combined", Query: "select 1;"}) + require.NoError(t, err) + excLabel, err := ds.NewLabel(ctx, &fleet.Label{ + Name: "exclude-combined", + LabelMembershipType: fleet.LabelMembershipTypeManual, + }) + require.NoError(t, err) + incAllLabel1, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-all-combined-1", Query: "select 1;"}) + require.NoError(t, err) + incAllLabel2, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-all-combined-2", Query: "select 1;"}) + require.NoError(t, err) + + // Profile: include-any + exclude-any (scoped to the test team) + profAnyExcl := windowsConfigProfileForTest(t, "prof-include-any-exclude", "./combined/any", incLabel, excLabel) + profAnyExcl.TeamID = &tm.ID + inclAnyExclProf, err := ds.NewMDMWindowsConfigProfile(ctx, *profAnyExcl, nil) + require.NoError(t, err) + sumAny := md5.Sum(inclAnyExclProf.SyncML) //nolint:gosec + checksumAnyExcl := append([]byte{}, sumAny[:]...) + + // Profile: include-all + exclude-any (scoped to the test team) + profAllExcl := windowsConfigProfileForTest(t, "prof-include-all-exclude", "./combined/all", incAllLabel1, incAllLabel2, excLabel) + profAllExcl.TeamID = &tm.ID + inclAllExclProf, err := ds.NewMDMWindowsConfigProfile(ctx, *profAllExcl, nil) + require.NoError(t, err) + sumAll := md5.Sum(inclAllExclProf.SyncML) //nolint:gosec + checksumAllExcl := append([]byte{}, sumAll[:]...) + + // Host in include label, not in exclude -> both profiles should apply + err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{ + {incLabel.ID, host.ID}, + {incAllLabel1.ID, host.ID}, + {incAllLabel2.ID, host.ID}, + }) + require.NoError(t, err) + + // Update label_updated_at so dynamic labels are considered + host.LabelUpdatedAt = time.Now().Add(1 * time.Second) + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + + profilesToInstall, err := ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ + {ProfileUUID: inclAnyExclProf.ProfileUUID, ProfileName: inclAnyExclProf.Name, HostUUID: host.UUID, Checksum: checksumAnyExcl}, + {ProfileUUID: inclAllExclProf.ProfileUUID, ProfileName: inclAllExclProf.Name, HostUUID: host.UUID, Checksum: checksumAllExcl}, + }, profilesToInstall) + + // Add host to exclude label -> neither profile should apply + err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{{excLabel.ID, host.ID}}) + require.NoError(t, err) + + profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.Empty(t, profilesToInstall) + + // Remove host from exclude label -> both profiles should apply again + err = ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{{excLabel.ID, host.ID}}) + require.NoError(t, err) + + profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ + {ProfileUUID: inclAnyExclProf.ProfileUUID, ProfileName: inclAnyExclProf.Name, HostUUID: host.UUID, Checksum: checksumAnyExcl}, + {ProfileUUID: inclAllExclProf.ProfileUUID, ProfileName: inclAllExclProf.Name, HostUUID: host.UUID, Checksum: checksumAllExcl}, + }, profilesToInstall) + + // Remove host from one include-all label -> include-all profile should no longer apply + err = ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{{incAllLabel1.ID, host.ID}}) + require.NoError(t, err) + + profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []*fleet.MDMWindowsProfilePayload{ + {ProfileUUID: inclAnyExclProf.ProfileUUID, ProfileName: inclAnyExclProf.Name, HostUUID: host.UUID, Checksum: checksumAnyExcl}, + }, profilesToInstall) + + // Remove host from include-any label -> include-any profile should no longer apply either + err = ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{{incLabel.ID, host.ID}}) + require.NoError(t, err) + + profilesToInstall, err = ds.ListMDMWindowsProfilesToInstall(ctx) + require.NoError(t, err) + require.Empty(t, profilesToInstall) +} + func expectWindowsProfiles( t *testing.T, ds *Datastore, diff --git a/server/mdm/apple/reconcile_test.go b/server/mdm/apple/reconcile_test.go index ceb3d63872..1d4663e121 100644 --- a/server/mdm/apple/reconcile_test.go +++ b/server/mdm/apple/reconcile_test.go @@ -220,6 +220,107 @@ func TestEntityAppliesToHost_DeclarationsShareSameDispatcher(t *testing.T) { } } +// TestEntityAppliesToHost_CombinedIncludeExclude covers the combined +// include+exclude targeting cases for both include modes. +func TestEntityAppliesToHost_CombinedIncludeExclude(t *testing.T) { + host := &fleet.AppleHostReconcileInfo{ + HostID: 1, UUID: "h1", TeamID: nil, Platform: "darwin", + LabelUpdatedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + } + excLabel := fleet.AppleProfileLabelRef{ + LabelID: new(uint(9)), + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } + + t.Run("include_any + exclude_any", func(t *testing.T) { + p := &fleet.AppleProfileForReconcile{ + TeamID: 0, + IncludeMode: fleet.AppleProfileIncludeAny, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(1))}, {LabelID: new(uint(2))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{excLabel}, + } + // in include label, not in exclude -> applies + require.True(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}})) + // in include label, also in exclude -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}, 9: {}})) + // not in any include label -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{9: {}})) + // not in any label -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{})) + }) + + t.Run("include_all + exclude_any", func(t *testing.T) { + p := &fleet.AppleProfileForReconcile{ + TeamID: 0, + IncludeMode: fleet.AppleProfileIncludeAll, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(1))}, {LabelID: new(uint(2))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{excLabel}, + } + // in all include labels, not in exclude -> applies + require.True(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}, 2: {}})) + // in all include labels, also in exclude -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}, 2: {}, 9: {}})) + // missing one include label -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}})) + }) + + t.Run("no include mode + exclude_any (pure exclude)", func(t *testing.T) { + p := &fleet.AppleProfileForReconcile{ + TeamID: 0, + IncludeMode: fleet.AppleProfileIncludeNone, + ExcludeLabels: []fleet.AppleProfileLabelRef{excLabel}, + } + // not in exclude label -> applies + require.True(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}})) + // in exclude label -> does not apply + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{9: {}})) + }) + + t.Run("dynamic exclude label created after host.LabelUpdatedAt disqualifies until host rescans", func(t *testing.T) { + // host.LabelUpdatedAt is 2026-05-01; dynamic exclude label created after that + dynamicExcLabel := fleet.AppleProfileLabelRef{ + LabelID: new(uint(99)), + LabelMembershipType: int(fleet.LabelMembershipTypeDynamic), + CreatedAt: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + } + p := &fleet.AppleProfileForReconcile{ + TeamID: 0, + IncludeMode: fleet.AppleProfileIncludeAny, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(1))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{dynamicExcLabel}, + } + // host is in include label and NOT a member of the exclude label, but the + // dynamic exclude label was created after label_updated_at -> disqualified + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}})) + + // once host.LabelUpdatedAt advances past the label's CreatedAt, the timing + // gate clears and the profile applies (host is not a member of the exclude label) + advancedHost := &fleet.AppleHostReconcileInfo{ + HostID: 1, UUID: "h1", TeamID: nil, Platform: "darwin", + LabelUpdatedAt: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + } + require.True(t, EntityAppliesToHost(p, advancedHost, map[uint]struct{}{1: {}})) + }) +} + +// TestEntityAppliesToHost_PureExcludeNoInclude confirms that a profile with +// only exclude labels (no include mode) applies to all hosts that are NOT +// members of any exclude label. +func TestEntityAppliesToHost_PureExcludeNoInclude(t *testing.T) { + host := &fleet.AppleHostReconcileInfo{ + HostID: 1, UUID: "h1", TeamID: nil, Platform: "darwin", + LabelUpdatedAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + } + p := &fleet.AppleProfileForReconcile{ + TeamID: 0, + IncludeMode: fleet.AppleProfileIncludeNone, + ExcludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(5)), CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } + require.True(t, EntityAppliesToHost(p, host, map[uint]struct{}{})) + require.True(t, EntityAppliesToHost(p, host, map[uint]struct{}{1: {}})) + require.False(t, EntityAppliesToHost(p, host, map[uint]struct{}{5: {}})) +} + func TestComputeReconcileDeltas(t *testing.T) { hostA := &fleet.AppleHostReconcileInfo{ HostID: 1, UUID: "uuid-A", TeamID: nil, Platform: "darwin", @@ -300,6 +401,89 @@ func TestComputeReconcileDeltas(t *testing.T) { require.Equal(t, "aDeletedProfile", toRemove[0].ProfileUUID) }) + t.Run("combined include_all + exclude_any: host in include but also in exclude -> not desired", func(t *testing.T) { + pCombined := &fleet.AppleProfileForReconcile{ + ProfileUUID: "aCombined", + ProfileIdentifier: "com.example.combined", + ProfileName: "Combined", + TeamID: 0, + Checksum: []byte("cccc"), + IncludeMode: fleet.AppleProfileIncludeAll, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(10))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(20)), CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } + profByTeam := map[uint][]*fleet.AppleProfileForReconcile{0: {pGlobal, pCombined}} + + // Host in include label but also in exclude label -> combined profile not desired + hostLabels := map[uint]map[uint]struct{}{ + hostA.HostID: {10: {}, 20: {}}, + } + toInstall, toRemove := ComputeReconcileDeltas( + []*fleet.AppleHostReconcileInfo{hostA}, hostLabels, nil, profByTeam, profilesWithBrokenLabel, + ) + require.Empty(t, toRemove) + require.Len(t, toInstall, 1) + require.Equal(t, "aProfileGlobal", toInstall[0].ProfileUUID) + }) + + t.Run("combined include_all + exclude_any: host in include not in exclude -> desired", func(t *testing.T) { + pCombined := &fleet.AppleProfileForReconcile{ + ProfileUUID: "aCombined", + ProfileIdentifier: "com.example.combined", + ProfileName: "Combined", + TeamID: 0, + Checksum: []byte("cccc"), + IncludeMode: fleet.AppleProfileIncludeAll, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(10))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(20)), CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } + profByTeam := map[uint][]*fleet.AppleProfileForReconcile{0: {pGlobal, pCombined}} + + // Host in include label, not in exclude -> both profiles desired + hostLabels := map[uint]map[uint]struct{}{ + hostA.HostID: {10: {}}, + } + toInstall, toRemove := ComputeReconcileDeltas( + []*fleet.AppleHostReconcileInfo{hostA}, hostLabels, nil, profByTeam, profilesWithBrokenLabel, + ) + require.Empty(t, toRemove) + require.Len(t, toInstall, 2) + }) + + t.Run("combined include_any + exclude_any: host in include but also in exclude -> not desired, removed if present", func(t *testing.T) { + pCombined := &fleet.AppleProfileForReconcile{ + ProfileUUID: "aCombined", + ProfileIdentifier: "com.example.combined", + ProfileName: "Combined", + TeamID: 0, + Checksum: []byte("cccc"), + IncludeMode: fleet.AppleProfileIncludeAny, + IncludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(10))}, {LabelID: new(uint(11))}}, + ExcludeLabels: []fleet.AppleProfileLabelRef{{LabelID: new(uint(20)), CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } + profByTeam := map[uint][]*fleet.AppleProfileForReconcile{0: {pGlobal, pCombined}} + + // Host in one include label AND in exclude label -> combined profile no longer desired, should be removed + hostLabels := map[uint]map[uint]struct{}{ + hostA.HostID: {10: {}, 20: {}}, + } + current := map[string][]*fleet.MDMAppleProfilePayload{ + "uuid-A": {{ + ProfileUUID: "aCombined", + HostUUID: "uuid-A", + Checksum: []byte("cccc"), + OperationType: fleet.MDMOperationTypeInstall, + Status: new(fleet.MDMDeliveryVerified), + }}, + } + toInstall, toRemove := ComputeReconcileDeltas( + []*fleet.AppleHostReconcileInfo{hostA}, hostLabels, current, profByTeam, profilesWithBrokenLabel, + ) + require.Len(t, toInstall, 1) // pGlobal + require.Len(t, toRemove, 1) + require.Equal(t, "aCombined", toRemove[0].ProfileUUID) + }) + t.Run("broken label profile is not removed", func(t *testing.T) { brokenProf := &fleet.AppleProfileForReconcile{ ProfileUUID: "aBrokenLabel",