diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index 00cc7e199b..ede9ac75b1 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -171,6 +171,7 @@ const CustomSettings = ({ const hasLabels = !!profileLabelsModalData?.labels_include_all?.length || + !!profileLabelsModalData?.labels_include_any?.length || !!profileLabelsModalData?.labels_exclude_any?.length; return ( diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileLabelsModal/ProfileLabelsModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileLabelsModal/ProfileLabelsModal.tsx index f5590c83af..28eb07ad96 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileLabelsModal/ProfileLabelsModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileLabelsModal/ProfileLabelsModal.tsx @@ -70,7 +70,7 @@ const ProfileLabelsModal = ({ if (labels_include_all) { targetTypeText = have all; } else if (labels_include_any) { - targetTypeText = have all; + targetTypeText = have any; } else { targetTypeText = don't have any; } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 84fdfbaff7..5188e3e9b1 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -247,9 +247,19 @@ WHERE return nil, err } for _, lbl := range labels { - if lbl.Exclude { + switch { + case lbl.Exclude && lbl.RequireAll: + // this should never happen so log it for debugging + level.Debug(ds.logger).Log("msg", "unsupported profile label: cannot be both exclude and require all", + "profile_uuid", lbl.ProfileUUID, + "label_name", lbl.LabelName, + ) + case lbl.Exclude && !lbl.RequireAll: res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl) - } else { + case !lbl.Exclude && !lbl.RequireAll: + res.LabelsIncludeAny = append(res.LabelsIncludeAny, lbl) + default: + // default include all res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl) } } @@ -289,9 +299,19 @@ WHERE return nil, err } for _, lbl := range labels { - if lbl.Exclude { + switch { + case lbl.Exclude && lbl.RequireAll: + // this should never happen so log it for debugging + level.Debug(ds.logger).Log("msg", "unsupported profile label: cannot be both exclude and require all", + "profile_uuid", lbl.ProfileUUID, + "label_name", lbl.LabelName, + ) + case lbl.Exclude && !lbl.RequireAll: res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl) - } else { + case !lbl.Exclude && !lbl.RequireAll: + res.LabelsIncludeAny = append(res.LabelsIncludeAny, lbl) + default: + // default include all res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl) } } @@ -1828,16 +1848,28 @@ ON DUPLICATE KEY UPDATE for _, label := range incomingProf.LabelsIncludeAll { label.ProfileUUID = newlyInsertedProf.ProfileUUID + label.Exclude = false + label.RequireAll = true + incomingLabels = append(incomingLabels, label) + } + for _, label := range incomingProf.LabelsIncludeAny { + label.ProfileUUID = newlyInsertedProf.ProfileUUID + label.Exclude = false + label.RequireAll = false incomingLabels = append(incomingLabels, label) } for _, label := range incomingProf.LabelsExcludeAny { label.ProfileUUID = newlyInsertedProf.ProfileUUID label.Exclude = true + label.RequireAll = false incomingLabels = append(incomingLabels, label) } } } + // FIXME: At what point are we deleting label associations for existing profiles (e.g. if the user + // removes all labels from a profile in gitops, shouldn't we remove the old associations)? + // insert label associations var updatedLabels bool if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, @@ -4289,11 +4321,20 @@ WHERE for _, label := range incomingDecl.LabelsIncludeAll { label.ProfileUUID = newlyInsertedDecl.DeclarationUUID + label.Exclude = false + label.RequireAll = true + incomingLabels = append(incomingLabels, label) + } + for _, label := range incomingDecl.LabelsIncludeAny { + label.ProfileUUID = newlyInsertedDecl.DeclarationUUID + label.Exclude = false + label.RequireAll = false incomingLabels = append(incomingLabels, label) } for _, label := range incomingDecl.LabelsExcludeAny { label.ProfileUUID = newlyInsertedDecl.DeclarationUUID label.Exclude = true + label.RequireAll = false incomingLabels = append(incomingLabels, label) } } diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index d01bad72bd..66f90c06f6 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -274,9 +274,19 @@ FROM ( } for _, label := range labels { if prof, ok := profMap[label.ProfileUUID]; ok { - if label.Exclude { + switch { + case label.Exclude && label.RequireAll: + // this should never happen so log it for debugging + level.Debug(ds.logger).Log("msg", "unsupported profile label: cannot be both exclude and require all", + "profile_uuid", label.ProfileUUID, + "label_name", label.LabelName, + ) + case label.Exclude && !label.RequireAll: prof.LabelsExcludeAny = append(prof.LabelsExcludeAny, label) - } else { + case !label.Exclude && !label.RequireAll: + prof.LabelsIncludeAny = append(prof.LabelsIncludeAny, label) + default: + // default include all prof.LabelsIncludeAll = append(prof.LabelsIncludeAll, label) } } @@ -293,7 +303,8 @@ SELECT label_name, COALESCE(label_id, 0) as label_id, IF(label_id IS NULL, 1, 0) as broken, - exclude + exclude, + require_all FROM mdm_configuration_profile_labels mcpl WHERE @@ -305,7 +316,8 @@ SELECT label_name, COALESCE(label_id, 0) as label_id, IF(label_id IS NULL, 1, 0) as broken, - exclude + exclude, + require_all FROM mdm_declaration_labels mdl WHERE @@ -997,6 +1009,8 @@ func batchSetProfileLabelAssociationsDB( platform string, ) (updatedDB bool, err error) { if len(profileLabels) == 0 { + // FIXME: At what point are we deleting all labels for a profile (e.g., the user might + // remove all labels from an existing profile)? return false, nil } diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 43e94d71df..7e41ce03d2 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -699,20 +699,20 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { require.NoError(t, ds.DeleteLabel(ctx, labels[4].Name)) profLabels := map[string][]fleet.ConfigurationProfileLabel{ "C": { - {LabelName: labels[0].Name, LabelID: labels[0].ID}, - {LabelName: labels[1].Name, LabelID: labels[1].ID}, + {LabelName: labels[0].Name, LabelID: labels[0].ID, RequireAll: true}, + {LabelName: labels[1].Name, LabelID: labels[1].ID, RequireAll: true}, }, "D": { - {LabelName: labels[2].Name, LabelID: labels[2].ID}, - {LabelName: labels[3].Name, LabelID: 0, Broken: true}, + {LabelName: labels[2].Name, LabelID: labels[2].ID, RequireAll: true}, + {LabelName: labels[3].Name, LabelID: 0, Broken: true, RequireAll: true}, }, "E": { - {LabelName: labels[4].Name, LabelID: 0, Broken: true}, - {LabelName: labels[5].Name, LabelID: labels[5].ID}, + {LabelName: labels[4].Name, LabelID: 0, Broken: true, RequireAll: true}, + {LabelName: labels[5].Name, LabelID: labels[5].ID, RequireAll: true}, }, "F": { - {LabelName: labels[6].Name, LabelID: labels[6].ID}, - {LabelName: labels[7].Name, LabelID: labels[7].ID}, + {LabelName: labels[6].Name, LabelID: labels[6].ID, RequireAll: true}, + {LabelName: labels[7].Name, LabelID: labels[7].ID, RequireAll: true}, }, } diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index 961a6c1355..524888cf8d 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -745,9 +745,19 @@ WHERE return nil, err } for _, lbl := range labels { - if lbl.Exclude { + switch { + case lbl.Exclude && lbl.RequireAll: + // this should never happen so log it for debugging + level.Debug(ds.logger).Log("msg", "unsupported profile label: cannot be both exclude and require all", + "profile_uuid", lbl.ProfileUUID, + "label_name", lbl.LabelName, + ) + case lbl.Exclude && !lbl.RequireAll: res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl) - } else { + case !lbl.Exclude && !lbl.RequireAll: + res.LabelsIncludeAny = append(res.LabelsIncludeAny, lbl) + default: + // default include all res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl) } } @@ -1872,11 +1882,20 @@ ON DUPLICATE KEY UPDATE for _, label := range incomingProf.LabelsIncludeAll { label.ProfileUUID = newlyInsertedProf.ProfileUUID + label.Exclude = false + label.RequireAll = true + incomingLabels = append(incomingLabels, label) + } + for _, label := range incomingProf.LabelsIncludeAny { + label.ProfileUUID = newlyInsertedProf.ProfileUUID + label.Exclude = false + label.RequireAll = false incomingLabels = append(incomingLabels, label) } for _, label := range incomingProf.LabelsExcludeAny { label.ProfileUUID = newlyInsertedProf.ProfileUUID label.Exclude = true + label.RequireAll = false incomingLabels = append(incomingLabels, label) } } diff --git a/server/fleet/app.go b/server/fleet/app.go index 59c5ef24f4..3806e79c7d 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -385,6 +385,7 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro spec.Labels = extractLabelField(m, "labels") spec.LabelsIncludeAll = extractLabelField(m, "labels_include_all") spec.LabelsExcludeAny = extractLabelField(m, "labels_exclude_any") + spec.LabelsIncludeAny = extractLabelField(m, "labels_include_any") csSpecs = append(csSpecs, spec) } else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 00f98633a7..7f717eeb14 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -525,26 +525,30 @@ func (p *MDMProfileSpec) UnmarshalJSON(data []byte) error { if len(data) == 0 { return nil } - if lookAhead := bytes.TrimSpace(data); len(lookAhead) > 0 && lookAhead[0] == '"' { var backwardsCompat string if err := json.Unmarshal(data, &backwardsCompat); err != nil { return fmt.Errorf("unmarshal profile spec. Error using old format: %w", err) } p.Path = backwardsCompat + + // FIXME: equivalent of no label condition, should clear all labels slice? + // p.Labels = nil + // p.LabelsIncludeAll = nil + // p.LabelsIncludeAny = nil + // p.LabelsExcludeAny = nil return nil } // use an alias type to avoid recursively calling this function forever. type Alias MDMProfileSpec - aliasData := struct { - *Alias - }{ - Alias: (*Alias)(p), - } + var aliasData Alias if err := json.Unmarshal(data, &aliasData); err != nil { return fmt.Errorf("unmarshal profile spec. Error using new format: %w", err) } + // NOTE: we always want the newly unmarshaled profile spec to completely replace the old one + // (rather than merging the new data into the old one). + *p = MDMProfileSpec(aliasData) return nil } diff --git a/server/fleet/mdm_test.go b/server/fleet/mdm_test.go index 256b65be64..3ea7f0b36a 100644 --- a/server/fleet/mdm_test.go +++ b/server/fleet/mdm_test.go @@ -2,6 +2,7 @@ package fleet_test import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -122,72 +123,106 @@ func TestDEPClient(t *testing.T) { wantToksTermsFlags map[string]bool }{ // use a valid token, appconfig should not be updated (already unflagged) - {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // use a valid token without org, nothing is checked - {token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // use an invalid token without org, call fails but nothing is checked because this is an unsaved token - {token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // use an invalid token, appconfig should not even be read (not a terms error) - {token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // terms changed for org1 during the auth request - {token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}}, + { + token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}, + }, // use of an invalid token does not update the flag - {token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}}, + { + token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}, + }, // use of a valid token for org1 resets the flags - {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // use of a valid token again with org2 does not update anything - {token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, // terms changed for org2 during the actual account request, after auth - {token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + { + token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}, + }, // again terms changed after auth for org2, doesn't update appConfig - {token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + { + token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}, + }, // terms changed during auth for org2, doesn't update appConfig - {token: termsChangedToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + { + token: termsChangedToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}, + }, // terms changed during auth for org1, now both tokens have the flag, doesn't update appConfig - {token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + { + token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}, + }, // use a valid token without org, nothing is checked - {token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + { + token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}, + }, // use an invalid token without org, call fails but nothing is checked because this is an unsaved token - {token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + { + token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}, + }, // valid token for org1, resets that token's flag but not appConfig - {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + { + token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}, + }, // valid token again for org1, still no write to appConfig - {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + { + token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}, + }, // valid token again for org2, this time resets appConfig - {token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: true, - writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + { + token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}, + }, } // order of calls is important, and test must not be parallelized as it would @@ -333,6 +368,67 @@ func TestMDMProfileSpecUnmarshalJSON(t *testing.T) { require.Equal(t, "oldpath", p.Path) require.Empty(t, p.Labels) }) + + t.Run("changing labels", func(t *testing.T) { + // When updating AppConfig, we unmarshal the incoming JSON into the existing AppConfig + // struct, see + // https://github.com/fleetdm/fleet/blob/d1144df1318b50482cbd9eb996b863443975f138/server/service/appconfig.go#L334-L335 + // + // But we found there were issues unmarshaling the slice of profile specs where if a key is present in an old + // element but not in the new element (e.g. element[0] of the old slice and element[0] of the + // new slice), both keys were preserved. This test is designed to cover that issue, which + // was addressed in the unmarshal function, see + // https://github.com/fleetdm/fleet/blob/1042702def54f095335d8b42ed5fdcc90468fa0d/server/fleet/mdm.go#L551-L552 + + storedConfig := fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgName: "Test", + }, + MDM: fleet.MDM{ + MacOSSettings: fleet.MacOSSettings{ + CustomSettings: []fleet.MDMProfileSpec{ + { + Path: "some-profile-2", + LabelsExcludeAny: []string{"bar"}, + }, + { + Path: "some-profile-1", + LabelsIncludeAll: []string{"foo"}, + }, + }, + }, + }, + } + + incomingConfig := fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgName: "Test", + }, + MDM: fleet.MDM{ + MacOSSettings: fleet.MacOSSettings{ + CustomSettings: []fleet.MDMProfileSpec{ + { + Path: "some-profile-1", + LabelsIncludeAll: []string{"foo"}, + }, + { + Path: "some-profile-2", + LabelsIncludeAny: []string{"bar"}, + }, + }, + }, + }, + } + b, err := json.Marshal(incomingConfig) + require.NoError(t, err) + + err = json.Unmarshal(b, &storedConfig) + require.NoError(t, err) + + require.Equal(t, storedConfig.MDM.MacOSSettings.CustomSettings, incomingConfig.MDM.MacOSSettings.CustomSettings) + require.Nil(t, storedConfig.MDM.MacOSSettings.CustomSettings[0].LabelsExcludeAny) // old key should be removed + require.Nil(t, storedConfig.MDM.MacOSSettings.CustomSettings[1].LabelsIncludeAll) // old key should be removed + }) } func TestMDMProfileSpecsMatch(t *testing.T) { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 87081047c6..1e601997c4 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1511,7 +1511,8 @@ func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { svc, ctx = newTestServiceWithConfig(t, ds, fleetConfig, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, - createdAt time.Time) error { + createdAt time.Time, + ) error { assert.IsType(t, fleet.ActivityAddedNDESSCEPProxy{}, activity) return nil } @@ -1560,7 +1561,8 @@ func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { scepURL = "https://new.com/mscep/mscep.dll" jsonPayload = fmt.Sprintf(jsonPayloadBase, scepURL, adminURL, username, "") ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, - createdAt time.Time) error { + createdAt time.Time, + ) error { assert.IsType(t, fleet.ActivityEditedNDESSCEPProxy{}, activity) return nil } @@ -1644,7 +1646,8 @@ func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { // Second, real run. appConfig.Integrations.NDESSCEPProxy.Valid = true ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, - createdAt time.Time) error { + createdAt time.Time, + ) error { assert.IsType(t, fleet.ActivityDeletedNDESSCEPProxy{}, activity) return nil } @@ -1682,5 +1685,4 @@ func TestModifyAppConfigForNDESSCEPProxy(t *testing.T) { ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) _, err = svc.ModifyAppConfig(ctx, []byte(jsonPayload), fleet.ApplySpecOptions{}) assert.ErrorContains(t, err, "private key") - } diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index c36c3fe798..f1e2794fcf 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -2650,15 +2650,20 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { // NOTE: label names starting with "-" are sent as "labels_excluding_any" // (and the leading "-" is removed from the name). Names starting with // "!" are sent as the deprecated "labels" field (and the "!" is removed). + // Names starting with a "~" prefix are sent as "labels_include_any" + // (and the leading "~" is removed. addLabelsFields := func(labelNames []string) map[string][]string { - var deprLabels, inclLabels, exclLabels []string + var deprLabels, inclAllLabels, inclAnyLabels, exclLabels []string for _, lbl := range labelNames { - if strings.HasPrefix(lbl, "-") { //nolint:gocritic // ignore ifElseChain + switch { + case strings.HasPrefix(lbl, "~"): + inclAnyLabels = append(inclAnyLabels, strings.TrimPrefix(lbl, "~")) + case strings.HasPrefix(lbl, "-"): exclLabels = append(exclLabels, strings.TrimPrefix(lbl, "-")) - } else if strings.HasPrefix(lbl, "!") { + case strings.HasPrefix(lbl, "!"): deprLabels = append(deprLabels, strings.TrimPrefix(lbl, "!")) - } else { - inclLabels = append(inclLabels, lbl) + default: + inclAllLabels = append(inclAllLabels, lbl) } } @@ -2666,12 +2671,15 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { if len(deprLabels) > 0 { fields["labels"] = deprLabels } - if len(inclLabels) > 0 { - fields["labels_include_all"] = inclLabels + if len(inclAllLabels) > 0 { + fields["labels_include_all"] = inclAllLabels } if len(exclLabels) > 0 { fields["labels_exclude_any"] = exclLabels } + if len(inclAnyLabels) > 0 { + fields["labels_include_any"] = inclAnyLabels + } return fields } @@ -2875,15 +2883,20 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { // profiles with invalid mix of labels assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) + assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "-bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) + assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) + assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) // profiles with valid labels uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"!foo"}, http.StatusOK, "") + uuidAppleWithInclAnyLabel := assertAppleProfile("apple-profile-with-incl-any-labels.mobileconfig", "apple-profile-with-incl-any-labels", "ident-with-incl-any-labels", 0, []string{"~foo", "~bar"}, http.StatusOK, "") uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"}) uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"-foo", "-bar"}, http.StatusOK, "") uuidAppleDDMTeamWithLabel := createAppleDeclaration("apple-team-decl-with-labels", "ident-team-decl-with-labels", testTeam.ID, []string{"-foo"}) uuidWindowsTeamWithLabel := assertWindowsProfile("win-team-profile-with-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "") + uuidWindowsTeamWithInclAnyLabel := assertWindowsProfile("win-team-profile-with-incl-any-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "") // Windows invalid content body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil) @@ -2923,6 +2936,13 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, + { + ProfileUUID: uuidAppleWithInclAnyLabel, Platform: "darwin", Name: "apple-profile-with-incl-any-labels", Identifier: "ident-with-incl-any-labels", TeamID: nil, + LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ + {LabelID: labelBar.ID, LabelName: labelBar.Name}, + {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, + }, + }, { ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ @@ -2943,6 +2963,13 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, + { + ProfileUUID: uuidWindowsTeamWithInclAnyLabel, Platform: "windows", Name: "win-team-profile-with-incl-any-labels", TeamID: &testTeam.ID, + LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ + {LabelID: labelBar.ID, LabelName: labelBar.Name}, + {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, + }, + }, } for _, prof := range expectedProfiles { var getResp getMDMConfigProfileResponse @@ -2963,6 +2990,9 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { sort.Slice(getResp.LabelsExcludeAny, func(i, j int) bool { return getResp.LabelsExcludeAny[i].LabelName < getResp.LabelsExcludeAny[j].LabelName }) + sort.Slice(getResp.LabelsIncludeAny, func(i, j int) bool { + return getResp.LabelsIncludeAny[i].LabelName < getResp.LabelsIncludeAny[j].LabelName + }) require.Equal(t, prof, *getResp.MDMConfigProfilePayload) resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media") @@ -3104,6 +3134,8 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { require.NoError(t, err) lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"}) require.NoError(t, err) + lblBaz, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "baz", Query: "select 1"}) + require.NoError(t, err) // create a couple profiles (Win and mac) for team 2, and none for team 3 tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil) @@ -3131,19 +3163,33 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { }, }) require.NoError(t, err) + + // make tm2ProfH a "include-any" label-based profile + tm2ProfH, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ + Name: "tH", + TeamID: &tm2.ID, + SyncML: []byte(``), + LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ + {LabelID: lblBar.ID, LabelName: lblBar.Name}, + {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, + }, + }) + require.NoError(t, err) + // break lblFoo by deleting it require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name)) // test that all fields are correctly returned with team 2 var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID)) - require.Len(t, listResp.Profiles, 2) + require.Len(t, listResp.Profiles, 3) require.NotZero(t, listResp.Profiles[0].CreatedAt) require.NotZero(t, listResp.Profiles[0].UploadedAt) require.NotZero(t, listResp.Profiles[1].CreatedAt) require.NotZero(t, listResp.Profiles[1].UploadedAt) listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{} listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{} + listResp.Profiles[2].CreatedAt, listResp.Profiles[2].UploadedAt = time.Time{}, time.Time{} require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfF.ProfileUUID, TeamID: tm2ProfF.TeamID, @@ -3168,6 +3214,17 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { {LabelID: 0, LabelName: lblFoo.Name, Broken: true}, }, }, listResp.Profiles[1]) + require.Equal(t, &fleet.MDMConfigProfilePayload{ + ProfileUUID: tm2ProfH.ProfileUUID, + TeamID: tm2ProfH.TeamID, + Name: tm2ProfH.Name, + Platform: "windows", + // labels are ordered by name + LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ + {LabelID: lblBar.ID, LabelName: lblBar.Name}, + {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, + }, + }, listResp.Profiles[2]) // get the specific include-all label-based profile returns the information var getProfResp getMDMConfigProfileResponse @@ -3203,6 +3260,21 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { }, }, getProfResp.MDMConfigProfilePayload) + // get the specific include-any label-based profile returns the information + getProfResp = getMDMConfigProfileResponse{} + s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfH.ProfileUUID, nil, http.StatusOK, &getProfResp) + getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{} + require.Equal(t, &fleet.MDMConfigProfilePayload{ + ProfileUUID: tm2ProfH.ProfileUUID, + TeamID: tm2ProfH.TeamID, + Name: tm2ProfH.Name, + Platform: "windows", + // labels are ordered by name + LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ + {LabelID: lblBar.ID, LabelName: lblBar.Name}, + {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, + }, + }, getProfResp.MDMConfigProfilePayload) // list for a non-existing team returns 404 s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999") @@ -3252,7 +3324,7 @@ func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { { queries: []string{"per_page", "3"}, teamID: &tm2.ID, - wantNames: []string{"tF", "tG"}, + wantNames: []string{"tF", "tG", "tH"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, }, { @@ -4909,6 +4981,134 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() { }) } +func (s *integrationMDMTestSuite) TestMDMProfilesIncludeAnyLabels() { + t := s.T() + ctx := context.Background() + + triggerReconcileProfiles := func() { + s.awaitTriggerProfileSchedule(t) + // this will only mark them as "pending", as the response to confirm + // profile deployment is asynchronous, so we simulate it here by + // updating any "pending" (not NULL) profiles to "verifying" + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { + return err + } + if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { + return err + } + if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { + return err + } + return nil + }) + } + + // run the crons immediately, will create the Fleet-controlled profiles that + // will then be expected to be applied (e.g. com.fleetdm.fleetd.config and + // com.fleetdm.caroot) + // first create the no-team enroll secret (required to create the fleet profiles) + var applyResp applyEnrollSecretSpecResponse + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", + applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}}, + }, http.StatusOK, &applyResp) + s.awaitTriggerProfileSchedule(t) + + // create an Apple and a Windows host + appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + + // create a few labels, we'll use the first five for "exclude any" profiles and the remaining for "include any" + labels := make([]*fleet.Label, 10) + for i := 0; i < len(labels); i++ { + label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"}) + require.NoError(t, err) + labels[i] = label + } + // simulate reporting label results for those hosts + appleHost.LabelUpdatedAt = time.Now() + windowsHost.LabelUpdatedAt = time.Now() + err := s.ds.UpdateHost(ctx, appleHost) + require.NoError(t, err) + err = s.ds.UpdateHost(ctx, windowsHost) + require.NoError(t, err) + + // set up some Apple profiles and declarations and Windows profiles + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsIncludeAny: []string{labels[0].Name, labels[1].Name}}, + {Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsIncludeAny: []string{labels[2].Name, labels[3].Name}}, + {Name: "D3", Contents: declarationForTest("D3"), LabelsIncludeAny: []string{labels[4].Name}}, + }}, http.StatusNoContent) + + // hosts are not members of any label yet, so running the cron applies no labels + s.awaitTriggerProfileSchedule(t) + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + appleHost: { + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + windowsHost: {}, + }) + + // make hosts members of labels [1], [2], [3] and [4], meaning that each of the "include any" + // labels will now match at least one host + err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{ + {labels[0].ID, appleHost.ID}, + {labels[1].ID, appleHost.ID}, + {labels[2].ID, appleHost.ID}, + {labels[3].ID, appleHost.ID}, + {labels[4].ID, appleHost.ID}, + {labels[1].ID, windowsHost.ID}, + {labels[2].ID, windowsHost.ID}, + {labels[3].ID, windowsHost.ID}, + {labels[4].ID, windowsHost.ID}, + }) + require.NoError(t, err) + + triggerReconcileProfiles() + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + appleHost: { + {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + windowsHost: { + {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + }, + }) + + // remove membership of labels [2] for Windows, and [1] and [4] for Apple, meaning + // that D3 will be removed on Apple, A1 will remain on Apple because the host is still a member + // of [0], and W2 will remain on Windows because the host is still a member of [3] + err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{ + {labels[1].ID, appleHost.ID}, + {labels[4].ID, appleHost.ID}, + {labels[2].ID, windowsHost.ID}, + }) + require.NoError(t, err) + + s.awaitTriggerProfileSchedule(t) + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + appleHost: { + {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + windowsHost: { + {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + }, + }) +} + func (s *integrationMDMTestSuite) TestOTAProfile() { t := s.T() ctx := context.Background() diff --git a/server/service/mdm.go b/server/service/mdm.go index ced2a997f2..92524ba1f1 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1304,7 +1304,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f labels = req.LabelsExcludeAny labelsMode = fleet.LabelsExcludeAny default: - // TODO: should this be the default? + // default include all labels = req.LabelsIncludeAll labelsMode = fleet.LabelsIncludeAll } @@ -1407,14 +1407,13 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, return nil, ctxerr.Wrap(ctx, err, "validating labels") } switch labelsMembershipMode { - case fleet.LabelsIncludeAll: - cp.LabelsIncludeAll = labelMap case fleet.LabelsIncludeAny: cp.LabelsIncludeAny = labelMap case fleet.LabelsExcludeAny: cp.LabelsExcludeAny = labelMap default: - // TODO what happens if mode is not set?s + // default include all + cp.LabelsIncludeAll = labelMap } err = validateWindowsProfileFleetVariables(string(cp.SyncML)) @@ -1593,7 +1592,7 @@ func (svc *Service) BatchSetMDMProfiles( labels := []string{} for i := range profiles { - // from this point on (after this condition), only LabelsIncludeAll or + // from this point on (after this condition), only LabelsIncludeAll, LabelsIncludeAny or // LabelsExcludeAny need to be checked. if len(profiles[i].Labels) > 0 { // must update the struct in the slice directly, because we don't have a