Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/48594-recovery-lock-byod-personal-enrollment
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions server/datastore/mysql/apple_mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions server/datastore/mysql/apple_mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Loading