diff --git a/changes/48594-recovery-lock-byod-personal-enrollment b/changes/48594-recovery-lock-byod-personal-enrollment new file mode 100644 index 00000000000..9bf192a57b1 --- /dev/null +++ b/changes/48594-recovery-lock-byod-personal-enrollment @@ -0,0 +1 @@ +- Fixed recovery lock password being enforced on personally-owned (BYOD) macOS hosts, where it would always fail because personal enrollments have device lock rights stripped. These hosts are now skipped. diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 81e38a334b8..036e25f9b2f 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -6953,6 +6953,9 @@ func (ds *Datastore) GetHostsForRecoveryLockAction(ctx context.Context) ([]strin // - Have enable_recovery_lock_password = true (from team config or appconfig for no-team hosts) // - Are Apple Silicon (ARM CPU) // - Are MDM enrolled (enabled = 1 and device enrollment type) + // - Are NOT personally-owned (BYOD) enrollments. Personal enrollments have the + // DeviceLock/DeviceErase access rights stripped (see AppleEnrollmentAccessRights), + // so SetRecoveryLock is rejected by the device. Skip them instead of enforcing. // - Have no recovery lock password record OR have a password with NULL status (command not yet enqueued) // Note: hosts with status pending, verified, or failed are NOT included // Note: hosts with operation_type='remove' are handled by RestoreRecoveryLockForReenabledHosts @@ -6969,6 +6972,7 @@ func (ds *Datastore) GetHostsForRecoveryLockAction(ctx context.Context) ([]strin AND ne.enabled = 1 AND ne.type IN ('Device', 'User Enrollment (Device)') AND hm.enrolled = 1 + AND hm.is_personal_enrollment = 0 AND ( -- Team hosts: check team config (h.team_id IS NOT NULL AND JSON_EXTRACT(t.config, '$.mdm.enable_recovery_lock_password') = true) @@ -7112,6 +7116,7 @@ func (ds *Datastore) ClaimHostsForRecoveryLockClear(ctx context.Context) ([]stri AND ne.enabled = 1 AND ne.type IN ('Device', 'User Enrollment (Device)') AND hm.enrolled = 1 + AND hm.is_personal_enrollment = 0 AND ( (rkp.operation_type = '%s' AND rkp.status = '%s') OR diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index f9be3ea79a8..cf8650094a7 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -10910,6 +10910,20 @@ func testGetHostsForRecoveryLockAction(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.False(t, slices.Contains(hosts, hostVerified.UUID), "verified host should NOT be eligible") + // Create BYOD (personally-owned) enrolled host. Personal enrollments have the + // DeviceLock/DeviceErase rights stripped, so SetRecoveryLock would fail on them. + teamPersonal := createTeamWithRecoveryLock("team-personal", true) + hostPersonal := test.NewHost(t, ds, "personal-host", "1.2.5.11", "perskey", "persuuid", time.Now(), + test.WithPlatform("darwin"), test.WithTeamID(teamPersonal.ID)) + setHostCPUType(hostPersonal.ID, "arm64e") + nanoEnroll(t, ds, hostPersonal, false) + err = ds.SetOrUpdateMDMData(ctx, hostPersonal.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", true) + require.NoError(t, err) + + hosts, err = ds.GetHostsForRecoveryLockAction(ctx) + require.NoError(t, err) + assert.False(t, slices.Contains(hosts, hostPersonal.UUID), "personally-owned (BYOD) host should NOT be eligible") + // Test no-team host with app config recovery lock enabled setAppConfigRecoveryLock(true) hostNoTeam := test.NewHost(t, ds, "no-team-host", "1.2.5.9", "ntkey", "ntuuid", time.Now(), @@ -11167,6 +11181,34 @@ func testClaimHostsForRecoveryLockClear(t *testing.T, ds *Datastore) { assert.Equal(t, "pending", status) }) + t.Run("does not claim personally-owned (BYOD) host", func(t *testing.T) { + // Personal enrollments have DeviceLock/DeviceErase rights stripped, so + // recovery lock commands (including clear) are rejected by the device. + team := createTeamWithRecoveryLock(t, "personal-clear-team", true) + host := test.NewHost(t, ds, "personal-clear-host", "1.2.6.8", "perscleerkey", "perscleeruuid", time.Now(), + test.WithPlatform("darwin"), test.WithTeamID(team.ID)) + setHostCPUType(t, host.ID, "arm64") + nanoEnroll(t, ds, host, false) + err := ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", true) + require.NoError(t, err) + + // Give it a verified password record that would otherwise be claimed for clear. + pw := apple_mdm.GenerateRecoveryLockPassword() + err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}}) + require.NoError(t, err) + err = ds.SetRecoveryLockVerified(ctx, host.UUID) + require.NoError(t, err) + + // Disable recovery lock for team to trigger clear. + team.Config.MDM.EnableRecoveryLockPassword = false + _, err = ds.SaveTeam(ctx, team) + require.NoError(t, err) + + uuids, err := ds.ClaimHostsForRecoveryLockClear(ctx) + require.NoError(t, err) + assert.NotContains(t, uuids, host.UUID, "personally-owned (BYOD) host should NOT be claimed for clear") + }) + t.Run("clears stale auto_rotate_at when flipping to remove", func(t *testing.T) { team := createTeamWithRecoveryLock(t, "stale-rotation-team", true) host := test.NewHost(t, ds, "stale-rotation-host", "1.2.6.7", "stalerotkey", "stalerotuuid", time.Now(),