always update Apple enrollment type correctly (#50689)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45217

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Fixed Apple device re-enrollment so enrollment types and statuses
update correctly when switching between manual, automated, and personal
enrollment.
- Improved handling of Apple Business Manager assignment changes during
enrollment.
- Preserved existing third-party MDM information while updating
enrollment details.
- Correctly records devices installed through automated enrollment and
keeps enrollment state consistent across check-ins, restores, and
over-the-air enrollment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen
2026-08-07 19:01:45 +02:00
committed by GitHub
parent d9a2445832
commit dd3fda3f3c
3 changed files with 267 additions and 13 deletions
@@ -0,0 +1 @@
- Fixed an issue where re-enrolling an Apple device with a different type, e.g. Manual -> ADE, would not update the enrollment type correctly.
+73 -13
View File
@@ -1607,7 +1607,7 @@ func updateMDMAppleHostDB(
}
}
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg, false, fromPersonalEnrollment, hostID); err != nil {
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg, appleMDMInfoFromCheckin, fromPersonalEnrollment, hostID); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
}
@@ -1688,7 +1688,7 @@ func insertMDMAppleHostDB(
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership")
}
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg, false, fromPersonalEnrollment, mdmHost.ID); err != nil {
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg, appleMDMInfoFromCheckin, fromPersonalEnrollment, mdmHost.ID); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
}
return nil
@@ -1718,7 +1718,7 @@ func createHostFromMDMDB(
tx sqlx.ExtContext,
logger *slog.Logger,
devices []hostToCreateFromMDM,
fromADE bool,
source appleMDMInfoSource,
macOSTeam, iosTeam, ipadTeam *uint,
) (int64, []fleet.Host, error) {
// NOTE: order of arguments for teams is important, see statement.
@@ -1858,7 +1858,7 @@ func createHostFromMDMDB(
ctx,
tx,
appCfg,
fromADE,
source,
false,
unmanagedHostIDs...,
); err != nil {
@@ -1883,7 +1883,7 @@ func (ds *Datastore) IngestMDMAppleDeviceFromOTAEnrollment(
UUID: &deviceInfo.UDID,
},
}
_, hosts, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, false, teamID, teamID, teamID)
_, hosts, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, appleMDMInfoFromOTAEnrollment, teamID, teamID, teamID)
if idpUUID != "" && len(hosts) > 0 {
host := hosts[0]
ds.logger.InfoContext(ctx, fmt.Sprintf("associating host %s with idp account %s", host.UUID, idpUUID))
@@ -1981,7 +1981,7 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(
tx,
ds.logger,
htc,
true,
appleMDMInfoFromDEPSync,
teamIDs[0], teamIDs[1], teamIDs[2],
)
if err != nil {
@@ -2031,7 +2031,8 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
mdm_migration_deadline = VALUES(mdm_migration_deadline),
hardware_serial = VALUES(hardware_serial)`
args := []interface{}{}
hostIDs := []uint{}
args := []any{}
values := []string{}
for _, host := range hosts {
var deadline *time.Time
@@ -2040,6 +2041,8 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
}
args = append(args, host.ID, abmTokenID, deadline, host.HardwareSerial)
values = append(values, "(?, ?, ?, ?)")
hostIDs = append(hostIDs, host.ID)
}
_, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, strings.Join(values, ",")), args...)
@@ -2047,6 +2050,22 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
return ctxerr.Wrap(ctx, err, "upsert host dep assignments")
}
// Cover a case where an ADE enrolled host enrolls before the DEP sync comes in and fix previous installed_from_dep=0 if set.
stmt, args, err = sqlx.In(`UPDATE host_mdm hm
JOIN mobile_device_management_solutions mdms ON mdms.id = hm.mdm_id
SET hm.installed_from_dep = 1
WHERE hm.host_id IN (?)
AND hm.enrolled = 1
AND hm.is_personal_enrollment = 0
AND mdms.name = 'Fleet'`, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "upsert host dep assignments update installed_from_dep")
}
_, err = tx.ExecContext(ctx, stmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "upsert host dep assignments update installed_from_dep")
}
return nil
}
@@ -2099,7 +2118,15 @@ func insertHostDisplayNamesIfAbsent(ctx context.Context, tx sqlx.ExtContext, hos
return nil
}
func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, appCfg *fleet.AppConfig, fromSync, fromPersonalEnrollment bool, hostIDs ...uint) error {
type appleMDMInfoSource int
const (
appleMDMInfoFromDEPSync appleMDMInfoSource = iota // enrolled=0, from_dep=1, narrow ON DUPLICATE
appleMDMInfoFromOTAEnrollment // enrolled=1, from_dep=0, narrow ON DUPLICATE
appleMDMInfoFromCheckin // enrolled=1, from_dep=derived, wide ON DUPLICATE
)
func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, appCfg *fleet.AppConfig, source appleMDMInfoSource, fromPersonalEnrollment bool, hostIDs ...uint) error {
if len(hostIDs) == 0 {
return nil
}
@@ -2111,7 +2138,7 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, appCfg
// if the device is coming from the DEP sync, we don't consider it
// enrolled yet.
enrolled := !fromSync
enrolled := source != appleMDMInfoFromDEPSync
result, err := tx.ExecContext(ctx, `
INSERT INTO mobile_device_management_solutions (name, server_url) VALUES (?, ?)
@@ -2131,16 +2158,49 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, appCfg
}
}
depAssignedSet := map[uint]struct{}{}
if source == appleMDMInfoFromCheckin && !fromPersonalEnrollment {
stmt, args, err := sqlx.In(`
SELECT host_id FROM host_dep_assignments
WHERE host_id IN (?) AND deleted_at IS NULL`, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "query dep assigned hosts")
}
var depAssigned []uint
if err := sqlx.SelectContext(ctx, tx, &depAssigned, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "query dep assigned hosts")
}
for _, id := range depAssigned {
depAssignedSet[id] = struct{}{}
}
}
args := []interface{}{}
parts := []string{}
for _, id := range hostIDs {
args = append(args, enrolled, serverURL, fromSync, mdmID, false, id, fromPersonalEnrollment)
var isDepAssigned bool
switch source {
case appleMDMInfoFromCheckin:
_, isDepAssigned = depAssignedSet[id]
case appleMDMInfoFromDEPSync:
isDepAssigned = true
default:
isDepAssigned = false
}
args = append(args, enrolled, serverURL, isDepAssigned, mdmID, false, id, fromPersonalEnrollment)
parts = append(parts, "(?, ?, ?, ?, ?, ?, ?)")
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
stmt := fmt.Sprintf(`
INSERT INTO host_mdm (enrolled, server_url, installed_from_dep, mdm_id, is_server, host_id, is_personal_enrollment) VALUES %s
ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled), is_personal_enrollment = VALUES(is_personal_enrollment)`, strings.Join(parts, ",")), args...)
ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled), is_personal_enrollment = VALUES(is_personal_enrollment)`, strings.Join(parts, ","))
if source == appleMDMInfoFromCheckin {
stmt += `, installed_from_dep = VALUES(installed_from_dep)`
}
_, err = tx.ExecContext(ctx, stmt, args...)
return ctxerr.Wrap(ctx, err, "upsert host mdm info")
}
@@ -2625,7 +2685,7 @@ INSERT INTO hosts (
if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, ds.logger, *host); err != nil {
return ctxerr.Wrap(ctx, err, "restore pending dep host label membership")
}
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, ac, true, false, host.ID); err != nil {
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, ac, appleMDMInfoFromDEPSync, false, host.ID); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
}
+193
View File
@@ -109,6 +109,7 @@ func TestMDMApple(t *testing.T) {
{"MDMAppleUpsertHostPersonalEnrollment", testMDMAppleUpsertHostPersonalEnrollment},
{"MDMAppleUpsertHostPersonalEnrollmentClearsStaleVitals", testMDMAppleUpsertHostPersonalEnrollmentClearsStaleVitals},
{"MDMAppleUpsertHostPersonalEnrollmentClearsStaleVitalsUUIDChange", testMDMAppleUpsertHostPersonalEnrollmentClearsStaleVitalsUUIDChange},
{"MDMAppleUpsertHostEnrollmentTypeOnReenrollment", testMDMAppleUpsertHostEnrollmentTypeOnReenrollment},
{"IngestMDMAppleDevicesFromDEPSyncIOSIPadOS", testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS},
{"MDMAppleProfilesOnIOSIPadOS", testMDMAppleProfilesOnIOSIPadOS},
{"ReconcileAppleProfilesDuplicateHostUUID", testReconcileAppleProfilesDuplicateHostUUID},
@@ -8313,6 +8314,198 @@ func testMDMAppleUpsertHostPersonalEnrollmentClearsStaleVitalsUUIDChange(t *test
"service subscriptions keyed by the host's previous UUID must be cleared on transition to BYOD")
}
// Tests that we upsert the correct enrollment type on re-enrollment sync etc. Check-in becomes an authoritative source
// of truth to set all values, and sets installed_from_dep to true if a host_dep_assignment row exists.
func testMDMAppleUpsertHostEnrollmentTypeOnReenrollment(t *testing.T, ds *Datastore) {
ctx := t.Context()
createBuiltinLabels(t, ds)
abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{
OrganizationName: "unused",
EncryptedToken: []byte(uuid.NewString()),
RenewAt: time.Now().Add(365 * 24 * time.Hour),
})
require.NoError(t, err)
enrollmentStatus := func(t *testing.T, hostID uint) *string {
t.Helper()
var status *string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &status,
`SELECT enrollment_status FROM host_mdm WHERE host_id = ?`, hostID)
})
return status
}
requireEnrollment := func(t *testing.T, hostID uint, wantFromDEP, wantPersonal bool, wantStatus string) {
t.Helper()
hmdm, err := ds.GetHostMDM(ctx, hostID)
require.NoError(t, err)
require.True(t, hmdm.Enrolled, "host should be enrolled")
require.Equal(t, wantFromDEP, hmdm.InstalledFromDep, "installed_from_dep")
require.Equal(t, wantPersonal, hmdm.IsPersonalEnrollment, "is_personal_enrollment")
status := enrollmentStatus(t, hostID)
require.NotNil(t, status, "enrollment_status must not be NULL")
require.Equal(t, wantStatus, *status)
}
// checkin simulates the Apple Authenticate flow
// (resetApple -> MDMAppleUpsertHost) for the device with this serial.
checkin := func(t *testing.T, serial, hostUUID string, personal bool) uint {
t.Helper()
require.NoError(t, ds.MDMAppleUpsertHost(ctx, &fleet.Host{
UUID: hostUUID,
HardwareSerial: serial,
HardwareModel: "iPhone14,2",
Platform: "ios",
}, personal))
h, err := ds.HostByIdentifier(ctx, hostUUID)
require.NoError(t, err)
return h.ID
}
// assignInABM is the "existing host newly assigned in ABM" path that
// DEPService.RunAssigner takes for serials Fleet already knows about.
assignInABM := func(t *testing.T, hostID uint, serial string) {
t.Helper()
require.NoError(t, ds.UpsertMDMAppleHostDEPAssignments(ctx,
[]fleet.Host{{ID: hostID, HardwareSerial: serial}},
abmToken.ID, make(map[uint]time.Time)))
}
// depSync is the "serial appeared in ABM for the first time" path, which
// creates the host row up front in the Pending state.
depSync := func(t *testing.T, serial string) uint {
t.Helper()
_, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx,
[]godep.Device{{SerialNumber: serial, DeviceFamily: "iPhone", OpType: "added"}},
abmToken.ID, nil, nil, nil)
require.NoError(t, err)
h, err := ds.HostByIdentifier(ctx, serial)
require.NoError(t, err)
return h.ID
}
t.Run("manual enrollment then ADE after a wipe", func(t *testing.T) {
const serial = "REENROLL-MANUAL-TO-ADE"
hostID := checkin(t, serial, "uuid-manual-to-ade", false)
requireEnrollment(t, hostID, false, false, fleet.MDMEnrollmentStatusManual)
// Device is wiped locally: Fleet gets no CheckOut, so host_mdm keeps
// saying "enrolled, manual". IT then assigns it in ABM.
assignInABM(t, hostID, serial)
// It comes back through Setup Assistant as an ADE device.
require.Equal(t, hostID, checkin(t, serial, "uuid-manual-to-ade", false))
requireEnrollment(t, hostID, true, false, fleet.MDMEnrollmentStatusAutomatic)
})
t.Run("ADE enrollment then removed from AB then manual", func(t *testing.T) {
// The reverse transition: once the AB assignment is gone the host is
// no longer company-owned and must stop reporting as such.
// Side note: only happens if released in AB (and we don't get the op_type=removed), not via the new release from AB in fleet as that deletes the host_dep_assignment row.
const serial = "REENROLL-ADE-TO-MANUAL"
hostID := depSync(t, serial)
pending := enrollmentStatus(t, hostID)
require.NotNil(t, pending)
require.Equal(t, fleet.MDMEnrollmentStatusPending, *pending)
require.Equal(t, hostID, checkin(t, serial, "uuid-ade-to-manual", false))
requireEnrollment(t, hostID, true, false, fleet.MDMEnrollmentStatusAutomatic)
require.NoError(t, ds.DeleteHostDEPAssignments(ctx, abmToken.ID, []string{serial}))
require.Equal(t, hostID, checkin(t, serial, "uuid-ade-to-manual", false))
requireEnrollment(t, hostID, false, false, fleet.MDMEnrollmentStatusManual)
})
t.Run("ADE enrollment then personal re-enrollment", func(t *testing.T) {
// installed_from_dep and is_personal_enrollment must never both be set:
// the generated column has no CASE arm for that pair, so the host would
// drop out of every enrollment-status filter with a NULL status.
const serial = "REENROLL-ADE-TO-PERSONAL"
hostID := depSync(t, serial)
require.Equal(t, hostID, checkin(t, serial, "uuid-ade-to-personal", false))
requireEnrollment(t, hostID, true, false, fleet.MDMEnrollmentStatusAutomatic)
// Re-enrolls as BYOD while the ABM assignment is still live. Personal
// wins over the DEP assignment.
require.Equal(t, hostID, checkin(t, serial, "uuid-ade-to-personal", true))
requireEnrollment(t, hostID, false, true, fleet.MDMEnrollmentStatusPersonal)
})
t.Run("ADE check-in lands before the AB sync records the assignment", func(t *testing.T) {
// Opposite ordering, same wrong outcome: the check-in creates the host
// row before host_dep_assignments exists, and the later sync skips it
// because unmanagedHostIDs only covers hosts with enrolled = 0.
const serial = "REENROLL-CHECKIN-FIRST"
hostID := checkin(t, serial, "uuid-checkin-first", false)
requireEnrollment(t, hostID, false, false, fleet.MDMEnrollmentStatusManual)
assignInABM(t, hostID, serial)
requireEnrollment(t, hostID, true, false, fleet.MDMEnrollmentStatusAutomatic)
})
t.Run("Fleet-enrolled host whose serial first appears in a full ABM sync", func(t *testing.T) {
// Same promote step as the subtest above, reached through the other
// caller: IngestMDMAppleDevicesFromDEPSync rather than
// UpsertMDMAppleHostDEPAssignments. createHostFromMDMDB skips the host
// (unmanagedHostIDs excludes enrolled = 1), so only the assignment
// upsert can put it right.
const serial = "REENROLL-SYNC-AFTER-MANUAL"
hostID := checkin(t, serial, "uuid-sync-after-manual", false)
requireEnrollment(t, hostID, false, false, fleet.MDMEnrollmentStatusManual)
require.Equal(t, hostID, depSync(t, serial))
requireEnrollment(t, hostID, true, false, fleet.MDMEnrollmentStatusAutomatic)
})
t.Run("host enrolled in a third-party MDM is left alone", func(t *testing.T) {
// Guards the reason the narrow ON DUPLICATE existed in the first place:
// a host being migrated from another MDM shows up in ABM before it ever
// talks to Fleet, and neither the sync nor the assignment upsert may
// rewrite its MDM info.
const serial = "REENROLL-THIRD-PARTY"
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "third-party-mdm-host",
OsqueryHostID: new(serial),
NodeKey: new(serial),
UUID: "uuid-third-party",
HardwareSerial: serial,
Platform: "darwin",
})
require.NoError(t, err)
require.NoError(t, ds.SetOrUpdateMDMData(ctx, host.ID,
false, // isServer
true, // enrolled
"https://test.jamfcloud.com/mdm",
false, // installedFromDep
fleet.WellKnownMDMJamf,
"", // fleetEnrollmentRef
false, // isPersonalEnrollment
))
assignInABM(t, host.ID, serial)
_, err = ds.IngestMDMAppleDevicesFromDEPSync(ctx,
[]godep.Device{{SerialNumber: serial, DeviceFamily: "Mac", OpType: "added"}},
abmToken.ID, nil, nil, nil)
require.NoError(t, err)
hmdm, err := ds.GetHostMDM(ctx, host.ID)
require.NoError(t, err)
require.Equal(t, fleet.WellKnownMDMJamf, hmdm.Name, "third-party MDM solution must not be rewritten to Fleet")
require.Equal(t, "https://test.jamfcloud.com/mdm", hmdm.ServerURL)
require.False(t, hmdm.InstalledFromDep, "ABM assignment alone must not mark a third-party-enrolled host as ADE")
})
}
func testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS(t *testing.T, ds *Datastore) {
ctx := t.Context()