diff --git a/changes/29867-block-profile-scope-changes b/changes/29867-block-profile-scope-changes new file mode 100644 index 0000000000..8b9c7c341b --- /dev/null +++ b/changes/29867-block-profile-scope-changes @@ -0,0 +1 @@ +* Updated Apple profile verification code to disallow uploading profiles with the same identifier but differing PayloadScopes diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.tsx index e7da4ea1fd..399eb735eb 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/helpers.tsx @@ -37,7 +37,7 @@ const generateUnsupportedVariableErrMsg = (errMsg: string) => { : DEFAULT_ERROR_MESSAGE; }; -const generateLearnMoreErrMsg = (errMsg: string, learnMoreUrl: string) => { +const generateSCEPLearnMoreErrMsg = (errMsg: string, learnMoreUrl: string) => { return ( <> Couldn't add. {errMsg}{" "} @@ -51,6 +51,29 @@ const generateLearnMoreErrMsg = (errMsg: string, learnMoreUrl: string) => { ); }; +const generateUserChannelLearnMoreErrMsg = (errMsg: string) => { + // The errors from the API for these errors contain couldn't add/couldn't edit + // depending on context so no need to include it here but we do want to remove + // the learn more link from the actual error since we will add a nicely formatted + // link to the error message. + if (errMsg.includes(" Learn more: https://")) { + errMsg = errMsg.substring(0, errMsg.indexOf(" Learn more: https://")); + } + return ( + <> + {errMsg}{" "} + + + ); +}; + /** We want to add some additional messageing to some of the error messages so * we add them in this function. Otherwise, we'll just return the error message from the * API. @@ -115,7 +138,7 @@ export const getErrorMessage = (err: AxiosResponse) => { "can't be used if variables for SCEP URL and Challenge are not specified" ) ) { - return generateLearnMoreErrMsg( + return generateSCEPLearnMoreErrMsg( apiReason, "https://fleetdm.com/learn-more-about/certificate-authorities" ); @@ -126,7 +149,7 @@ export const getErrorMessage = (err: AxiosResponse) => { "SCEP profile for custom SCEP certificate authority requires" ) ) { - return generateLearnMoreErrMsg( + return generateSCEPLearnMoreErrMsg( apiReason, "https://fleetdm.com/learn-more-about/custom-scep-configuration-profile" ); @@ -137,11 +160,15 @@ export const getErrorMessage = (err: AxiosResponse) => { "SCEP profile for NDES certificate authority requires: $FLEET_VAR_NDES_SCEP_CHALLENGE" ) ) { - return generateLearnMoreErrMsg( + return generateSCEPLearnMoreErrMsg( apiReason, "https://fleetdm.com/learn-more-about/ndes-scep-configuration-profile" ); } + if (apiReason.includes('"PayloadScope"')) { + return generateUserChannelLearnMoreErrMsg(apiReason); + } + return `${apiReason}` || DEFAULT_ERROR_MESSAGE; }; diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index d8af59bae7..665a5f6d38 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -5,6 +5,7 @@ import ( "context" "crypto/aes" "crypto/cipher" + "crypto/md5" // nolint:gosec // used only to hash for efficient comparisons "crypto/rand" "database/sql" "encoding/hex" @@ -62,6 +63,122 @@ func isAppleHostConnectedToFleetMDM(ctx context.Context, q sqlx.QueryerContext, return true, nil } +// Checks scopes against existing profiles across the entire DB to ensure there are no conflicts +// where an existing profile with the same identifier has a different scope than the incoming +// profile. If we don't do this we must implement some sort of "move" semantics to allow for scope +// changes when a host switches teams or when a profile is updated. +func (ds *Datastore) verifyAppleConfigProfileScopesDoNotConflict(ctx context.Context, tx sqlx.ExtContext, cps []*fleet.MDMAppleConfigProfile) error { + if len(cps) == 0 { + return nil + } + incomingProfileIdentifiers := make([]string, 0, len(cps)) + for i := 0; i < len(cps); i++ { + incomingProfileIdentifiers = append(incomingProfileIdentifiers, cps[i].Identifier) + } + stmt := ` + SELECT + profile_uuid, + profile_id, + team_id, + name, + scope, + identifier, + mobileconfig, + created_at, + uploaded_at, + checksum + FROM + mdm_apple_configuration_profiles + WHERE + identifier IN (?) + ` + stmt, args, err := sqlx.In(stmt, incomingProfileIdentifiers) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In verifyAppleConfigProfileScopesDoNotConflict") + } + + var existingProfiles []*fleet.MDMAppleConfigProfile + if err = sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "querying existing apple config profiles by identifier") + } + + existingProfilesByIdentifier := make(map[string][]*fleet.MDMAppleConfigProfile) + for _, existingProfile := range existingProfiles { + existingProfilesByIdentifier[existingProfile.Identifier] = append(existingProfilesByIdentifier[existingProfile.Identifier], existingProfile) + } + + for _, cp := range cps { + existingProfiles := existingProfilesByIdentifier[cp.Identifier] + isEdit := false + scopeImplicitlyChanged := false + var conflictingProfile *fleet.MDMAppleConfigProfile + + // We have to look through all profiles with the same identifier(which is potentially number + // of teams + 1, for no team) in most cases but even in the case of a large number of teams + for _, existingProfile := range existingProfiles { + var incomingProfileTeamID, existingProfileTeamID uint + if existingProfile.TeamID != nil { + existingProfileTeamID = *existingProfile.TeamID + } + if cp.TeamID != nil { + incomingProfileTeamID = *cp.TeamID + } + if incomingProfileTeamID == existingProfileTeamID { + isEdit = true + } + + if existingProfile.Scope != cp.Scope { + if cp.Checksum == nil { + checksum := md5.Sum(cp.Mobileconfig) // nolint:gosec // Dismiss G401, we are not using this for secret/security reasons + cp.Checksum = checksum[:] + } + + // If the existing profile is marked as system scope, the new profile is user scope + // but the checksums match, this is a profile that existed prior to User Channel + // support being added and is unmodified, so allow the existing behavior to continue + if existingProfile.Scope == fleet.PayloadScopeSystem && cp.Scope == fleet.PayloadScopeUser && bytes.Equal(existingProfile.Checksum, cp.Checksum) { + cp.Scope = existingProfile.Scope + conflictingProfile = nil + break + } + + parsedConflictingMobileConfig, err := existingProfile.Mobileconfig.ParseConfigProfile() + if err != nil { + level.Debug(ds.logger).Log("msg", "error parsing existing profile mobileconfig while checking for scope conflicts", + "profile_uuid", existingProfile.ProfileUUID, + "err", err, + ) + } + // The existing profile has a different scope in the XML than in Fleet's DB, meaning + // it existed prior to User channel profiles support being added and this is an + // implicit change the user may not be aware of. + if err == nil && fleet.PayloadScope(parsedConflictingMobileConfig.PayloadScope) != existingProfile.Scope { + scopeImplicitlyChanged = true + } + + conflictingProfile = existingProfile + } + } + if conflictingProfile != nil { + var errorMessage string + // If you change this URL you may need to change the frontend code as well which adds a + // nicely formatted link to the error message. + const learnMoreUrl = "https://fleetdm.com/learn-more-about/configuration-profiles-user-channel" + if isEdit { + if scopeImplicitlyChanged { + errorMessage = fmt.Sprintf(`Couldn't edit configuration profile (%s) because it was previously delivered to some hosts on the device channel. Change "PayloadScope" to "System" to keep existing behavior. Alternatively, if you want this profile to be delivered on the user channel, please specify a new identifier for this profile and delete the old profile. Learn more: %s`, cp.Identifier, learnMoreUrl) + } else { + errorMessage = fmt.Sprintf(`Couldn't edit configuration profile (%s) because the profile's "PayloadScope" has changed. To change the “PayloadScope” of an existing profile, add a new profile with a new identifier with the desired scope and delete the old profile. Learn more: %s`, cp.Identifier, learnMoreUrl) + } + } else { + errorMessage = fmt.Sprintf(`Couldn't add configuration profile (%s) because "PayloadScope" conflicts with another profile with same identifier on a different team. Please use different identifier and try again. Learn more: %s`, cp.Identifier, learnMoreUrl) + } + return &fleet.BadRequestError{Message: errorMessage} + } + } + return nil +} + func (ds *Datastore) NewMDMAppleConfigProfile(ctx context.Context, cp fleet.MDMAppleConfigProfile, usesFleetVars []string) (*fleet.MDMAppleConfigProfile, error) { profUUID := "a" + uuid.New().String() stmt := ` @@ -82,6 +199,10 @@ INSERT INTO var profileID int64 err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + err := ds.verifyAppleConfigProfileScopesDoNotConflict(ctx, tx, []*fleet.MDMAppleConfigProfile{&cp}) + if err != nil { + return err + } res, err := tx.ExecContext(ctx, stmt, profUUID, teamID, cp.Identifier, cp.Name, cp.Scope, cp.Mobileconfig, cp.Mobileconfig, cp.SecretsUpdatedAt, cp.Name, teamID, cp.Name, teamID) @@ -2067,8 +2188,9 @@ func (ds *Datastore) GetNanoMDMEnrollment(ctx context.Context, id string) (*flee func (ds *Datastore) GetNanoMDMUserEnrollment(ctx context.Context, deviceId string) (*fleet.NanoEnrollment, error) { var nanoEnroll fleet.NanoEnrollment // use writer as it is used just after creation in some cases + // Note that we only ever return the first active user enrollment from the device err := sqlx.GetContext(ctx, ds.writer(ctx), &nanoEnroll, `SELECT id, device_id, type, enabled, token_update_tally - FROM nano_enrollments WHERE type = 'User' AND enabled = 1 AND device_id = ? LIMIT 1`, deviceId) + FROM nano_enrollments WHERE type = 'User' AND enabled = 1 AND device_id = ? ORDER BY created_at ASC LIMIT 1`, deviceId) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -2081,6 +2203,7 @@ func (ds *Datastore) GetNanoMDMUserEnrollment(ctx context.Context, deviceId stri func (ds *Datastore) GetNanoMDMUserEnrollmentUsername(ctx context.Context, deviceID string) (string, error) { var username string + // Note that we only ever return the first active user enrollment from the device err := sqlx.GetContext(ctx, ds.reader(ctx), &username, ` SELECT COALESCE(nu.user_short_name, '') as user_short_name @@ -2091,6 +2214,7 @@ func (ds *Datastore) GetNanoMDMUserEnrollmentUsername(ctx context.Context, devic ne.type = 'User' AND ne.enabled = 1 AND ne.device_id = ? + ORDER BY ne.created_at ASC LIMIT 1`, deviceID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -2161,7 +2285,6 @@ VALUES ON DUPLICATE KEY UPDATE uploaded_at = IF(checksum = VALUES(checksum) AND name = VALUES(name), uploaded_at, CURRENT_TIMESTAMP(6)), secrets_updated_at = VALUES(secrets_updated_at), - scope = VALUES(scope), checksum = VALUES(checksum), name = VALUES(name), mobileconfig = VALUES(mobileconfig) @@ -2173,6 +2296,11 @@ ON DUPLICATE KEY UPDATE profTeamID = *tmID } + err = ds.verifyAppleConfigProfileScopesDoNotConflict(ctx, tx, profiles) + if err != nil { + return false, err + } + // build a list of identifiers for the incoming profiles, will keep the // existing ones if there's a match and no change incomingIdents := make([]string, len(profiles)) @@ -3763,6 +3891,9 @@ WHERE // profiles (called in service.ensureFleetProfiles with the Fleet configuration // profiles). Those are known not to use any Fleet variables. This method must // not be used for any profile that uses Fleet variables. +// Also note this method does not implement the "do not allow profile scope to change" +// logic that other methods do and if it is ever used for non-fleet-controlled profiles +// it will need to. func (ds *Datastore) BulkUpsertMDMAppleConfigProfiles(ctx context.Context, payload []*fleet.MDMAppleConfigProfile) error { if len(payload) == 0 { return nil @@ -3788,7 +3919,6 @@ func (ds *Datastore) BulkUpsertMDMAppleConfigProfiles(ctx context.Context, paylo ON DUPLICATE KEY UPDATE uploaded_at = IF(checksum = VALUES(checksum) AND name = VALUES(name), uploaded_at, CURRENT_TIMESTAMP()), mobileconfig = VALUES(mobileconfig), - scope = VALUES(scope), checksum = VALUES(checksum), secrets_updated_at = VALUES(secrets_updated_at) `, strings.TrimSuffix(sb.String(), ",")) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 85a40011ff..553b2f4701 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -4234,6 +4234,7 @@ func ReconcileAppleProfiles( // and the checksums match (the profiles are exactly // the same) we don't send another InstallProfile // command. + if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) { hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ ProfileUUID: p.ProfileUUID, @@ -4288,25 +4289,36 @@ func ReconcileAppleProfiles( installTargets[p.ProfileUUID] = target } - sentToUserChannel := false if p.Scope == fleet.PayloadScopeUser { userEnrollmentID, err := getHostUserEnrollmentID(p.HostUUID) if err != nil { return err } if userEnrollmentID == "" { - level.Warn(logger).Log("msg", "host does not have a user enrollment, falling back to system enrollment for user scoped profile", + level.Warn(logger).Log("msg", "host does not have a user enrollment, failing profile installation", "host_uuid", p.HostUUID, "profile_uuid", p.ProfileUUID, "profile_identifier", p.ProfileIdentifier) - } else { - sentToUserChannel = true - target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: p.ProfileUUID, + HostUUID: p.HostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryFailed, + Detail: "This setting couldn't be enforced because the user channel doesn't exist for this host. Currently, Fleet creates the user channel for hosts that automatically enroll.", + CommandUUID: "", + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + Checksum: p.Checksum, + SecretsUpdatedAt: p.SecretsUpdatedAt, + Scope: p.Scope, + } + hostProfiles = append(hostProfiles, hostProfile) + continue } - } - if !sentToUserChannel { - p.Scope = fleet.PayloadScopeSystem + target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + } else { target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) } + toGetContents[p.ProfileUUID] = true hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ ProfileUUID: p.ProfileUUID, @@ -4362,8 +4374,10 @@ func ReconcileAppleProfiles( if userEnrollmentID == "" { level.Warn(logger).Log("msg", "host does not have a user enrollment, cannot remove user scoped profile", "host_uuid", p.HostUUID, "profile_uuid", p.ProfileUUID, "profile_identifier", p.ProfileIdentifier) + hostProfilesToCleanup = append(hostProfilesToCleanup, p) continue } + target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) } else { target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) @@ -4393,11 +4407,16 @@ func ReconcileAppleProfiles( // Create a map of command UUIDs to host IDs commandUUIDToHostIDsCleanupMap := make(map[string][]string) for _, hp := range hostProfilesToCleanup { - commandUUIDToHostIDsCleanupMap[hp.CommandUUID] = append(commandUUIDToHostIDsCleanupMap[hp.CommandUUID], hp.HostUUID) + // Certain failure scenarios may leave the profile without a command UUID, so skip those + if hp.CommandUUID != "" { + commandUUIDToHostIDsCleanupMap[hp.CommandUUID] = append(commandUUIDToHostIDsCleanupMap[hp.CommandUUID], hp.HostUUID) + } } // We need to delete commands from the nano queue so they don't get sent to device. - if err := commander.BulkDeleteHostUserCommandsWithoutResults(ctx, commandUUIDToHostIDsCleanupMap); err != nil { - return ctxerr.Wrap(ctx, err, "deleting nano commands without results") + if len(commandUUIDToHostIDsCleanupMap) > 0 { + if err := commander.BulkDeleteHostUserCommandsWithoutResults(ctx, commandUUIDToHostIDsCleanupMap); err != nil { + return ctxerr.Wrap(ctx, err, "deleting nano commands without results") + } } if err := ds.BulkDeleteMDMAppleHostsConfigProfiles(ctx, hostProfilesToCleanup); err != nil { return ctxerr.Wrap(ctx, err, "deleting profiles that didn't change") diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 3f9b6ba00a..8bfc1646eb 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -2370,7 +2370,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { } ds.BulkDeleteMDMAppleHostsConfigProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleProfilePayload) error { - require.Empty(t, payload) + require.ElementsMatch(t, payload, []*fleet.MDMAppleProfilePayload{{ProfileUUID: p6, ProfileIdentifier: "com.remove.profile.six", HostUUID: hostUUID2, Scope: fleet.PayloadScopeUser}}) return nil } @@ -2493,6 +2493,17 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { cmdUUIDByProfileUUIDRemove := make(map[string]string) copies := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(payload)) for i, p := range payload { + // clear the command UUID (in a copy so that it does not affect the + // pointed-to struct) from the payload for the subsequent checks + copyp := *p + copyp.CommandUUID = "" + copies[i] = ©p + + // Host with no user enrollment, so install fails + if p.HostUUID == hostUUID2 && p.ProfileUUID == p5 { + continue + } + if p.OperationType == fleet.MDMOperationTypeInstall { existing, ok := cmdUUIDByProfileUUIDInstall[p.ProfileUUID] if ok { @@ -2510,11 +2521,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { } } - // clear the command UUID (in a copy so that it does not affect the - // pointed-to struct) from the payload for the subsequent checks - copyp := *p - copyp.CommandUUID = "" - copies[i] = ©p } require.ElementsMatch(t, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -2575,14 +2581,15 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { Status: &fleet.MDMDeliveryPending, Scope: fleet.PayloadScopeUser, }, - // This host has no user enrollment so the profile is sent to the device enrollment + // This host has no user enrollment so the profile is errored { ProfileUUID: p5, ProfileIdentifier: "com.add.profile.five", HostUUID: hostUUID2, OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - Scope: fleet.PayloadScopeSystem, + Detail: "This setting couldn't be enforced because the user channel doesn't exist for this host. Currently, Fleet creates the user channel for hosts that automatically enroll.", + Status: &fleet.MDMDeliveryFailed, + Scope: fleet.PayloadScopeUser, }, // This host has a user enrollment so the profile is removed from it { @@ -2593,6 +2600,8 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { Status: &fleet.MDMDeliveryPending, Scope: fleet.PayloadScopeUser, }, + // Note that host2 has no user enrollment so the profile is not marked for removal + // from it }, copies) return nil } @@ -2692,7 +2701,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { failedCount++ - require.Len(t, payload, 6) // the 6 install ops + require.Len(t, payload, 5) // the 5 install ops require.ElementsMatch(t, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ { ProfileUUID: p1, @@ -2737,15 +2746,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { CommandUUID: "", Scope: fleet.PayloadScopeUser, }, - { - ProfileUUID: p5, - ProfileIdentifier: "com.add.profile.five", - HostUUID: hostUUID2, - OperationType: fleet.MDMOperationTypeInstall, - Status: nil, - CommandUUID: "", - Scope: fleet.PayloadScopeSystem, - }, }, payload) } @@ -2764,6 +2764,10 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { ds.ListMDMAppleProfilesToRemoveFunc = func(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) { return nil, nil } + ds.BulkDeleteMDMAppleHostsConfigProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleProfilePayload) error { + require.Empty(t, payload) + return nil + } ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { if failedCall { failedCheck(payload) @@ -2779,6 +2783,17 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { cmdUUIDByProfileUUIDRemove := make(map[string]string) copies := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(payload)) for i, p := range payload { + // clear the command UUID (in a copy so that it does not affect the + // pointed-to struct) from the payload for the subsequent checks + copyp := *p + copyp.CommandUUID = "" + copies[i] = ©p + + // Host with no user enrollment, so install fails + if p.HostUUID == hostUUID2 && p.ProfileUUID == p5 { + continue + } + if p.OperationType == fleet.MDMOperationTypeInstall { existing, ok := cmdUUIDByProfileUUIDInstall[p.ProfileUUID] if ok { @@ -2795,12 +2810,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { cmdUUIDByProfileUUIDRemove[p.ProfileUUID] = p.CommandUUID } } - - // clear the command UUID (in a copy so that it does not affect the - // pointed-to struct) from the payload for the subsequent checks - copyp := *p - copyp.CommandUUID = "" - copies[i] = ©p } require.ElementsMatch(t, []*fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -2850,8 +2859,9 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { ProfileIdentifier: "com.add.profile.five", HostUUID: hostUUID2, OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - Scope: fleet.PayloadScopeSystem, + Status: &fleet.MDMDeliveryFailed, + Detail: "This setting couldn't be enforced because the user channel doesn't exist for this host. Currently, Fleet creates the user channel for hosts that automatically enroll.", + Scope: fleet.PayloadScopeUser, }, }, copies) return nil @@ -2971,6 +2981,10 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { profilesToInstall, _ := ds.ListMDMAppleProfilesToInstallFunc(ctx) hostUUIDs = make([]string, 0, len(profilesToInstall)) for _, p := range profilesToInstall { + // This host will error before this point - should not be updated by the variable failure + if p.HostUUID == hostUUID2 && p.ProfileUUID == p5 { + continue + } hostUUIDs = append(hostUUIDs, p.HostUUID) } @@ -3721,7 +3735,9 @@ func TestMDMApplePreassignEndpoints(t *testing.T) { } // Helper for creating scoped mobileconfigs. scope is optional and if set to nil is not included in -// the mobileconfig so that default behavior is used. +// the mobileconfig so that default behavior is used. Note that because Fleet enforces that all +// profiles sharing a given identifier have the same scope, it's a good idea to use a unique +// identifier in your test or perhaps one with the scope in its name func scopedMobileconfigForTest(name, identifier string, scope *fleet.PayloadScope, vars ...string) []byte { var varsStr strings.Builder for i, v := range vars { diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index c71febf6d6..7958907e17 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -2458,7 +2458,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { globalProfiles := [][]byte{ mobileconfigForTest("G1", "G1"), scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), - scopedMobileconfigForTest("G3", "G3", &payloadScopeUser), + scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) @@ -2478,7 +2478,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { tm1Profiles := [][]byte{ mobileconfigForTest("T1.1", "T1.1"), scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem), - scopedMobileconfigForTest("T1.3", "T1.3", &payloadScopeUser), + scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, @@ -2510,20 +2510,20 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { s.awaitTriggerProfileSchedule(t) // G3 is user-scoped and the h2 host doesn't have a user-channel yet (and - // enrolled just now, so the minimum delay to give up and send the - // user-scoped profiles to the device channel is not reached) + // enrolled just now, so the minimum delay to give up and fail the profile + // delivery is not reached) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, @@ -2554,14 +2554,14 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, @@ -2585,7 +2585,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h1: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2593,7 +2593,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, @@ -2613,7 +2613,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2621,7 +2621,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, @@ -2634,7 +2634,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2642,10 +2642,10 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // still pending install due to cron not having run + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // still pending install due to cron not having run {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, @@ -2662,7 +2662,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h2: { // still no user channel {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, @@ -2670,7 +2670,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, @@ -2717,7 +2717,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2725,7 +2725,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2788,7 +2788,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4: { {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, + {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, @@ -2844,7 +2844,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ - mobileconfigForTest("T1.3", "T1.3"), + scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), }, }, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ @@ -3011,7 +3011,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { @@ -3042,7 +3042,7 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, - {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, h2: { @@ -6540,8 +6540,8 @@ func (s *integrationMDMTestSuite) TestVerifyUserScopedProfiles() { payloadScopeUser := fleet.PayloadScopeUser profiles := []fleet.MDMProfileBatchPayload{ {Name: "A1", Contents: scopedMobileconfigForTest("A1", "A1", &payloadScopeSystem)}, - {Name: "A2", Contents: scopedMobileconfigForTest("A2", "A2", &payloadScopeUser)}, - {Name: "A3", Contents: scopedMobileconfigForTest("A3", "A3", &payloadScopeUser)}, + {Name: "A2", Contents: scopedMobileconfigForTest("A2", "A2.user", &payloadScopeUser)}, + {Name: "A3", Contents: scopedMobileconfigForTest("A3", "A3.user", &payloadScopeUser)}, } s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent) @@ -6858,3 +6858,150 @@ func (s *integrationMDMTestSuite) TestVerifyUserScopedProfiles() { }, }) } + +func (s *integrationMDMTestSuite) TestMDMAppleProfileScopeChanges() { + t := s.T() + ctx := context.Background() + + // add a couple global profiles + payloadScopeSystem := fleet.PayloadScopeSystem + payloadScopeUser := fleet.PayloadScopeUser + globalProfiles := [][]byte{ + mobileconfigForTest("G1", "G1"), + scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), + scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser), + scopedMobileconfigForTest("G4", "G4.user-but-actually-system", &payloadScopeUser), + } + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + + // Create a profile with a scope that is System in the DB but User in the XML. This mimics + // our upgrade behavior from versions prior to 4.71 to 4.71+ when we added support for User + // scoped profiles + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + stmt := `UPDATE mdm_apple_configuration_profiles SET scope=? WHERE identifier=?;` + _, err := q.ExecContext(context.Background(), stmt, fleet.PayloadScopeSystem, "G4.user-but-actually-system") + return err + }) + + // create a team with a couple profiles + tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_1"}) + require.NoError(t, err) + tm1Profiles := [][]byte{ + mobileconfigForTest("T1.1", "T1.1"), + scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem), + scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), + } + + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, + "team_id", fmt.Sprint(tm1.ID)) + + // create a second team with different profiles + tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_2"}) + require.NoError(t, err) + tm2Profiles := [][]byte{ + mobileconfigForTest("T2.1", "T2.1"), + scopedMobileconfigForTest("T2.2", "T2.2.user", &payloadScopeSystem), + scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), + } + + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent, + "team_id", fmt.Sprint(tm2.ID)) + + // Do a no-op update of each team's profiles, verify no errors are returned + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, + "team_id", fmt.Sprint(tm1.ID)) + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent, + "team_id", fmt.Sprint(tm2.ID)) + + // Test a modification of an existing global profile with an implicit scope change + newGlobalProfiles := [][]byte{ + globalProfiles[0], + globalProfiles[1], + globalProfiles[2], + scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser), + } + response := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newGlobalProfiles}, http.StatusBadRequest) + errMsg := extractServerErrorText(response.Body) + require.Contains(t, errMsg, "Couldn't edit configuration profile (G4.user-but-actually-system) because it was previously delivered to some hosts on the device channel") + + // Test a conflict of a profile on a team with an existing global profile and an implicit scope change + // Should error because "G4.user-but-actually-system" conflicts with global + // "G4.user-but-actually-system" profile scope + newTm1Profiles := [][]byte{ + tm1Profiles[0], // T1.1 + tm1Profiles[1], // T1.2 + tm1Profiles[2], // T1.3.user + scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser), + } + response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, + "team_id", fmt.Sprint(tm1.ID)) + + errMsg = extractServerErrorText(response.Body) + require.Contains(t, errMsg, "Couldn't add configuration profile (G4.user-but-actually-system) because \"PayloadScope\" conflicts") + + // Test a conflict of a profile on a team with an existing global profile + // Should error because "G2" conflicts with global "G2" profile + newTm1Profiles = [][]byte{ + tm1Profiles[0], // T1.1 + tm1Profiles[1], // T1.2 + tm1Profiles[2], // T1.3.user + scopedMobileconfigForTest("G2", "G2", &payloadScopeUser), + } + response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, + "team_id", fmt.Sprint(tm1.ID)) + + errMsg = extractServerErrorText(response.Body) + require.Contains(t, errMsg, "Couldn't add configuration profile (G2) because \"PayloadScope\" conflicts") + + // Test a conflict of a profile on a team versus one with the same identifier but different + // scope on a different team. + // Should error because "T2.3.user" system-scoped profile conflicts with team2 "T2.3.user" user-scoped profile + newTm1Profiles = [][]byte{ + tm1Profiles[0], // T1.1 + tm1Profiles[1], // T1.2 + tm1Profiles[2], // T1.3.user + scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeSystem), // T2.3.user changed to system scope + } + response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, + "team_id", fmt.Sprint(tm1.ID)) + + errMsg = extractServerErrorText(response.Body) + require.Contains(t, errMsg, "Couldn't add configuration profile (T2.3.user) because \"PayloadScope\" conflicts") + + // Profile edit of existing profile on team1 with a new scope + newTm1Profiles = [][]byte{ + tm1Profiles[0], // T1.1 + tm1Profiles[1], // T1.2 + scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeSystem), // T1.3.user changed to system scope + } + response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, + "team_id", fmt.Sprint(tm1.ID)) + + errMsg = extractServerErrorText(response.Body) + require.Contains(t, errMsg, "Couldn't edit configuration profile (T1.3.user) because the profile's \"PayloadScope\" has changed") + + // Should be able to add these profiles to team1 with the proper scopes + newTm1Profiles = [][]byte{ + tm1Profiles[0], // T1.1 + tm1Profiles[1], // T1.2 + tm1Profiles[2], // T1.3.user + scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), + scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), // T2.3.user changed to system scope + } + + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusNoContent, + "team_id", fmt.Sprint(tm1.ID)) +}