From 69fa5ca435476104d423590f41ff246c16959e0c Mon Sep 17 00:00:00 2001 From: George Karr Date: Thu, 9 Jul 2026 07:32:37 -0500 Subject: [PATCH] Fix VPP/in-house app install on manual-profile BYOD iOS hosts (#48879) (#48916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #48879 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (parameterized queries only). - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes — N/A, no endpoint/path changes. ## Summary Installing an App Store (VPP) or in-house app on an iOS/iPadOS host enrolled via the **manual (profile-driven) BYOD** enrollment profile failed: Fleet routed the install down the **Account-Driven User Enrollment (user-scoped)** licensing path, tried to look up/register a VPP user keyed on the host's Managed Apple ID, and returned _"Fleet hasn't received a Managed Apple ID for this host yet."_ — which never resolves, because a device-channel host has no Managed Apple ID. ### Root cause The device-vs-user licensing decision keyed off `host_mdm.is_personal_enrollment`. That flag is set for **both**: - **Account-Driven User Enrollment** — user channel, backed by a Managed Apple ID → user-scoped licensing (correct). - **Manual-profile BYOD** — device channel, no Managed Apple ID → must install **device-scoped**, exactly like company-owned manual enrollment. ### Fix Branch on the actual enrollment **channel** — the presence of a user-channel `nano_enrollments` row (`type='User' AND enabled=1`), the same signal the MDM profile reconcile cron already uses (`GetNanoMDMUserEnrollment`). This is timing-robust: the user nano-enrollment exists from enrollment time, whereas the Managed Apple ID only arrives minutes later via `TokenUpdate` (so `managed_apple_id` emptiness is deliberately **not** used as the discriminator). Three sites updated: | File | Change | |---|---| | `ee/server/service/software_installers.go` | `InstallVPPAppPostValidation` routes on `GetNanoMDMUserEnrollment` instead of `is_personal_enrollment` | | `server/datastore/mysql/vpp.go` | InstallApplication builder derives `IsUserEnrollment` (ChangeManagementState omission) from a user-channel `nano_enrollments` row | | `server/datastore/mysql/activities.go` | same, for in-house `.ipa` installs | ## Testing - [x] Added/updated automated tests: - `ee/server/service`: `TestInstallVPPAppPostValidation_AssociateAssetsRouting` — added a regression subtest asserting manual-profile BYOD (personal flag set, device channel) routes via `serialNumbers` and performs **no** VPP user lookup; repointed routing to the user-channel signal. - `server/datastore/mysql`: new `TestVPP/VPPInstallEnrollmentChannelRouting` — manual BYOD includes `ChangeManagementState` despite `is_personal_enrollment=1`; account-driven User Enrollment omits it. - [x] Automated tests simulate multiple hosts and test for host isolation (two distinct hosts, device- vs user-channel). - [ ] QA'd all new/changed functionality manually — pending (draft). For unreleased bug fixes in a release candidate: - [x] Confirmed that the fix is not expected to adversely impact load test results (adds one indexed lookup per install enqueue; removes a `host_mdm` join). ## Database migrations - N/A — no schema changes. The fix reads existing `nano_enrollments` rows. ## fleetd/orbit/Fleet Desktop - N/A ## Summary by CodeRabbit * **Bug Fixes** * Fixed app installation for manually enrolled BYOD iPhone and iPad devices so App Store and in-house apps install correctly on the device. * Improved enrollment handling so device-scoped installs no longer fail when a device is marked personal in one place but uses device-channel enrollment. * Account-Driven User Enrollment continues to use user-scoped licensing and installs. --- changes/48879-vpp-manual-byod-device-install | 1 + .../service/install_vpp_associate_test.go | 67 +++++++++++++++--- ee/server/service/software_installers.go | 27 +++++-- server/datastore/mysql/activities.go | 26 ++++--- server/datastore/mysql/vpp.go | 26 ++++--- server/datastore/mysql/vpp_test.go | 70 +++++++++++++++++++ 6 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 changes/48879-vpp-manual-byod-device-install diff --git a/changes/48879-vpp-manual-byod-device-install b/changes/48879-vpp-manual-byod-device-install new file mode 100644 index 0000000000..eb15e4eb3e --- /dev/null +++ b/changes/48879-vpp-manual-byod-device-install @@ -0,0 +1 @@ +* Fixed a bug where installing App Store (VPP) or in-house apps on an iOS/iPadOS host enrolled with the manual (profile-driven) BYOD enrollment profile failed while trying to look up a VPP user. These device-channel hosts now install apps to the device, the same as company-owned manual enrollment; user-scoped licensing is reserved for Account-Driven User Enrollment. diff --git a/ee/server/service/install_vpp_associate_test.go b/ee/server/service/install_vpp_associate_test.go index a9ed976aa6..6a56ebea81 100644 --- a/ee/server/service/install_vpp_associate_test.go +++ b/ee/server/service/install_vpp_associate_test.go @@ -77,13 +77,24 @@ func TestInstallVPPAppPostValidation_AssociateAssetsRouting(t *testing.T) { }) } - // Common datastore mock setup, parameterized by isPersonalEnrollment. - setupDS := func(t *testing.T, isPersonal bool) *mock.Store { + // Common datastore mock setup, parameterized by whether the host is enrolled + // via Account-Driven User Enrollment (ADUE). The routing decision keys off + // the host's primary nano_enrollments row (id = host UUID): ADUE devices + // enroll as "User Enrollment (Device)", every other device-channel + // enrollment (including manual-profile BYOD) is "Device". It does NOT key + // off host_mdm.is_personal_enrollment (see #48879), so this drives + // GetNanoMDMEnrollment rather than GetHostMDM. + setupDS := func(t *testing.T, isUserEnrollment bool) *mock.Store { t.Helper() ds := new(mock.Store) - ds.GetHostMDMFunc = func(_ context.Context, id uint) (*fleet.HostMDM, error) { - require.Equal(t, hostID, id) - return &fleet.HostMDM{HostID: id, Enrolled: true, IsPersonalEnrollment: isPersonal}, nil + ds.GetNanoMDMEnrollmentFunc = func(_ context.Context, id string) (*fleet.NanoEnrollment, error) { + require.Equal(t, hostUUID, id) + if isUserEnrollment { + return &fleet.NanoEnrollment{ID: hostUUID, DeviceID: hostUUID, Type: "User Enrollment (Device)", Enabled: true}, nil + } + // Device-channel enrollment (company-owned manual OR manual-profile + // BYOD): the primary row is type "Device". + return &fleet.NanoEnrollment{ID: hostUUID, DeviceID: hostUUID, Type: "Device", Enabled: true}, nil } ds.GetVPPTokenByTeamIDFunc = func(_ context.Context, _ *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ID: 99, Token: bearerToken, RenewDate: time.Now().Add(24 * time.Hour)}, nil @@ -136,7 +147,7 @@ func TestInstallVPPAppPostValidation_AssociateAssetsRouting(t *testing.T) { require.True(t, ds.InsertVPPClientUserFuncInvoked) }) - t.Run("non-personal enrollment keeps SerialNumbers", func(t *testing.T) { + t.Run("device-channel enrollment keeps SerialNumbers", func(t *testing.T) { var capt captured setupServer(t, &capt) @@ -152,15 +163,53 @@ func TestInstallVPPAppPostValidation_AssociateAssetsRouting(t *testing.T) { SerialNumbers []string `json:"serialNumbers"` } require.NoError(t, json.Unmarshal(capt.body, &got)) - require.Equal(t, []string{hostSerial}, got.SerialNumbers, "manually-enrolled hosts must use serialNumbers") - require.Empty(t, got.ClientUserIds, "manually-enrolled hosts must not send clientUserIds") + require.Equal(t, []string{hostSerial}, got.SerialNumbers, "device-channel hosts must use serialNumbers") + require.Empty(t, got.ClientUserIds, "device-channel hosts must not send clientUserIds") - // User-provisioning datastore writes must NOT happen on the manual path. + // User-provisioning datastore writes must NOT happen on the device path. require.False(t, ds.GetVPPClientUserFuncInvoked) require.False(t, ds.InsertVPPClientUserFuncInvoked) require.False(t, ds.GetHostManagedAppleIDFuncInvoked) }) + // Regression test for #48879: a manual-profile BYOD host carries + // host_mdm.is_personal_enrollment=1 but is a DEVICE-channel enrollment (its + // primary nano_enrollments row is type "Device", not "User Enrollment + // (Device)", and it has no Managed Apple ID). It must install device-scoped + // (serialNumbers), exactly like company-owned manual — and must NOT attempt + // user provisioning, which previously failed with errMissingManagedAppleID. + t.Run("manual-profile BYOD (personal flag, device channel) routes via serialNumbers", func(t *testing.T) { + var capt captured + setupServer(t, &capt) + + // isUserEnrollment=false → the primary enrollment row is type "Device" + // even though this host would have is_personal_enrollment=1 in host_mdm. + ds := setupDS(t, false) + // Make it explicit that the Managed Apple ID is absent for this host, so a + // regression that re-introduces the user path would fail loudly here. + ds.GetHostManagedAppleIDFunc = func(_ context.Context, _ uint) (string, error) { + return "", nil + } + svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)} + + _, err := svc.InstallVPPAppPostValidation(context.Background(), host, vppApp, bearerToken, fleet.HostSoftwareInstallOptions{}) + require.NoError(t, err) + + require.NotEmpty(t, capt.body) + var got struct { + ClientUserIds []string `json:"clientUserIds"` + SerialNumbers []string `json:"serialNumbers"` + } + require.NoError(t, json.Unmarshal(capt.body, &got)) + require.Equal(t, []string{hostSerial}, got.SerialNumbers, "manual-profile BYOD must use serialNumbers (device-scoped)") + require.Empty(t, got.ClientUserIds, "manual-profile BYOD must not send clientUserIds") + + // The whole point of #48879: no VPP user lookup/registration for device-channel BYOD. + require.False(t, ds.GetHostManagedAppleIDFuncInvoked, "must not look up a Managed Apple ID for device-channel BYOD") + require.False(t, ds.GetVPPClientUserFuncInvoked) + require.False(t, ds.InsertVPPClientUserFuncInvoked) + }) + t.Run("personal enrollment queries assignments by clientUserId", func(t *testing.T) { var assignmentsQuery string setupFakeVPPServer(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 820bccf4ba..49a4f304cc 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -34,6 +34,7 @@ import ( apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps" + nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/worker" @@ -1657,14 +1658,28 @@ func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet // makes Fleet enter the AvailableCount check on every retry and produces // false-positive "no available licenses" errors when the user is just // adding their Nth (≤5) device under one Managed Apple ID. - hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) + // Device-vs-user VPP licensing must key off the actual MDM enrollment + // channel, not host_mdm.is_personal_enrollment. is_personal_enrollment is + // set for BOTH Account-Driven User Enrollment (ADUE, user-scoped, backed by + // a Managed Apple ID) and manual-profile BYOD (device channel, no Managed + // Apple ID). Only ADUE gets user-scoped licensing; manual-profile BYOD + // installs device-scoped, exactly like company-owned manual enrollment. + // + // The host's primary nano_enrollments row (id = host UUID) tells us the + // channel: ADUE devices enroll as "User Enrollment (Device)", while every + // other device-channel enrollment (ADE, manual, manual-profile BYOD) is + // "Device". Note the "User" type is the separate macOS user channel and is + // NOT what we want here. This row exists from enrollment time, whereas the + // Managed Apple ID only arrives minutes later via TokenUpdate, so this is + // the correct, timing-robust signal. See #48879. + nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID) if err != nil { - return "", ctxerr.Wrap(ctx, err, "looking up host MDM info for VPP install") + return "", ctxerr.Wrap(ctx, err, "looking up enrollment for VPP install") } - isPersonal := hostMDM != nil && hostMDM.IsPersonalEnrollment + isUserEnrollment := nanoEnroll != nil && nanoEnroll.Type == nanomdm.EnrollType(nanomdm.UserEnrollmentDevice).String() var clientUserID string - if isPersonal { + if isUserEnrollment { // Token-selection policy (per #44009): use the team's default token — // `GetVPPTokenByTeamID` already returns the first token for the team // (existing behavior). Multi-location support is deferred unless a @@ -1680,7 +1695,7 @@ func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet } assignmentFilter := &vpp.AssignmentFilter{AdamID: vppApp.AdamID} - if isPersonal { + if isUserEnrollment { assignmentFilter.ClientUserID = clientUserID } else { assignmentFilter.SerialNumber = host.HardwareSerial @@ -1735,7 +1750,7 @@ func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet } req := &vpp.AssociateAssetsRequest{Assets: assets} - if isPersonal { + if isUserEnrollment { req.ClientUserIds = []string{clientUserID} } else { req.SerialNumbers = []string{host.HardwareSerial} diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 3fe6cc9053..3b661809e8 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -1402,16 +1402,26 @@ ORDER BY ua.priority DESC, ua.created_at ASC ` + // is_user_enrollment must reflect the actual MDM enrollment channel, NOT + // host_mdm.is_personal_enrollment: the latter is also set for + // manual-profile BYOD, which is device-channel and must install + // device-scoped like company-owned manual. Only Account-Driven User + // Enrollment (ADUE) is user-scoped, and its primary enrollment row + // (id = host UUID) has type 'User Enrollment (Device)' — every other + // device-channel enrollment is 'Device'. See #48879. const getHostStmt = ` SELECT h.uuid, h.team_id, h.platform, h.hardware_serial, - COALESCE(hm.is_personal_enrollment, 0) AS is_personal_enrollment + COALESCE(( + SELECT 1 FROM nano_enrollments ne + WHERE ne.id = h.uuid AND ne.type = 'User Enrollment (Device)' AND ne.enabled = 1 + LIMIT 1 + ), 0) AS is_user_enrollment FROM hosts h - LEFT JOIN host_mdm hm ON hm.host_id = h.id WHERE h.id = ? ` @@ -1439,11 +1449,11 @@ ORDER BY } var hostData struct { - UUID string `db:"uuid"` - TeamID *uint `db:"team_id"` - Platform string `db:"platform"` - HardwareSerial string `db:"hardware_serial"` - IsPersonalEnrollment bool `db:"is_personal_enrollment"` + UUID string `db:"uuid"` + TeamID *uint `db:"team_id"` + Platform string `db:"platform"` + HardwareSerial string `db:"hardware_serial"` + IsUserEnrollment bool `db:"is_user_enrollment"` } if err := sqlx.GetContext(ctx, tx, &hostData, getHostStmt, hostID); err != nil { return ctxerr.Wrap(ctx, err, "get host info for in-house install") @@ -1546,7 +1556,7 @@ WHERE HostPlatform: hostData.Platform, ManifestURL: manifestURL, Configuration: cfg, - IsUserEnrollment: hostData.IsPersonalEnrollment, + IsUserEnrollment: hostData.IsUserEnrollment, }) insValues = append(insValues, "(?, 'InstallApplication', ?, ?)") insArgs = append(insArgs, p.ExecutionID, string(cmdBytes), mdm.CommandSubtypeNone) diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 74624e36f1..0ebe562211 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -3107,25 +3107,35 @@ func (ds *Datastore) nanoEnqueueVPPInstall(ctx context.Context, tx sqlx.ExtConte return nil } + // is_user_enrollment must reflect the actual MDM enrollment channel, NOT + // host_mdm.is_personal_enrollment: the latter is also set for + // manual-profile BYOD, which is device-channel and must install + // device-scoped like company-owned manual. Only Account-Driven User + // Enrollment (ADUE) is user-scoped, and its primary enrollment row + // (id = host UUID) has type 'User Enrollment (Device)' — every other + // device-channel enrollment is 'Device'. See #48879. const getHostUUIDStmt = ` SELECT h.uuid, h.platform, h.team_id, h.hardware_serial, - COALESCE(hm.is_personal_enrollment, 0) AS is_personal_enrollment + COALESCE(( + SELECT 1 FROM nano_enrollments ne + WHERE ne.id = h.uuid AND ne.type = 'User Enrollment (Device)' AND ne.enabled = 1 + LIMIT 1 + ), 0) AS is_user_enrollment FROM hosts h - LEFT JOIN host_mdm hm ON hm.host_id = h.id WHERE h.id = ? ` var hostData struct { - UUID string `db:"uuid"` - Platform string `db:"platform"` - TeamID *uint `db:"team_id"` - HardwareSerial string `db:"hardware_serial"` - IsPersonalEnrollment bool `db:"is_personal_enrollment"` + UUID string `db:"uuid"` + Platform string `db:"platform"` + TeamID *uint `db:"team_id"` + HardwareSerial string `db:"hardware_serial"` + IsUserEnrollment bool `db:"is_user_enrollment"` } if err := sqlx.GetContext(ctx, tx, &hostData, getHostUUIDStmt, hostID); err != nil { return ctxerr.Wrap(ctx, err, "get host info for vpp install") @@ -3216,7 +3226,7 @@ WHERE HostPlatform: hostData.Platform, ITunesStoreID: p.AdamID, Configuration: cfg, - IsUserEnrollment: hostData.IsPersonalEnrollment, + IsUserEnrollment: hostData.IsUserEnrollment, }) insValues = append(insValues, "(?, 'InstallApplication', ?, ?)") insArgs = append(insArgs, p.ExecutionID, string(cmdBytes), mdm.CommandSubtypeNone) diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index c61968dd01..a2306de872 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -50,6 +50,7 @@ func TestVPP(t *testing.T) { {"VPPAppConfigDeletedOnTeamDelete", testVPPAppConfigDeletedOnTeamDelete}, {"VPPInstallEnqueuesConfigurationDict", testVPPInstallEnqueuesConfigurationDict}, {"VPPInstallOmitsConfigurationOnMacOS", testVPPInstallOmitsConfigurationOnMacOS}, + {"VPPInstallEnrollmentChannelRouting", testVPPInstallEnrollmentChannelRouting}, {"MapAdamIDsPendingInstallVerification", testMapAdamIDsPendingInstallVerification}, {"MapAdamIDsRecentInstalls", testMapAdamIDsRecentInstalls}, {"MapAdamIDsRecentlyVerifiedInstalls", testMapAdamIDsRecentlyVerifiedInstalls}, @@ -3444,6 +3445,75 @@ func testVPPInstallOmitsConfigurationOnMacOS(t *testing.T, ds *Datastore) { require.Contains(t, commandXML, "iTunesStoreID") } +// testVPPInstallEnrollmentChannelRouting is the #48879 regression at the +// enqueue layer. The InstallApplication command's IsUserEnrollment flag (which +// omits ChangeManagementState, valid only on Apple's Account-Driven User +// Enrollment channel) must be driven by the actual enrollment CHANNEL — the +// host's primary nano_enrollments row (id = host UUID) being type +// "User Enrollment (Device)" — NOT by host_mdm.is_personal_enrollment. A +// manual-profile BYOD host has is_personal_enrollment=1 but is device-channel +// (primary row type "Device"), so its command must include ChangeManagementState +// exactly like company-owned manual. +func testVPPInstallEnrollmentChannelRouting(t *testing.T, ds *Datastore) { + ctx := t.Context() + test.CreateInsertGlobalVPPToken(t, ds) + + const adamID = "77778888" + setupTestVPPApp(t, ds, adamID, fleet.IOSPlatform) + vppApp := &fleet.VPPApp{ + Name: "ChannelApp", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: adamID, Platform: fleet.IOSPlatform}}, + BundleIdentifier: adamID, + } + _, err := ds.InsertVPPAppWithTeam(ctx, vppApp, nil) + require.NoError(t, err) + + enqueueAndReadCommand := func(t *testing.T, host *fleet.Host, cmdUUID string) string { + require.NoError(t, ds.InsertHostVPPSoftwareInstall(ctx, host.ID, vppApp.VPPAppID, cmdUUID, "evt-"+cmdUUID, fleet.HostSoftwareInstallOptions{})) + var commandXML string + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &commandXML, "SELECT command FROM nano_commands WHERE command_uuid = ?", cmdUUID) + }) + return commandXML + } + + t.Run("manual-profile BYOD (personal flag, device channel) includes ChangeManagementState", func(t *testing.T) { + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "byod-manual-ios", + UUID: "byod-manual-uuid", + Platform: string(fleet.IOSPlatform), + HardwareSerial: "BYOD-SERIAL", + }) + require.NoError(t, err) + // Device-channel enrollment (primary row type "Device") ... + nanoEnroll(t, ds, host, false) + // ... but flagged personal in host_mdm, like a manual BYOD profile. + require.NoError(t, ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", true)) + + commandXML := enqueueAndReadCommand(t, host, "byod-manual-cmd") + require.Contains(t, commandXML, "ChangeManagementState", + "manual-profile BYOD is device-channel and must include ChangeManagementState despite is_personal_enrollment=1 (#48879)") + }) + + t.Run("account-driven user enrollment omits ChangeManagementState", func(t *testing.T) { + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "adue-ios", + UUID: "adue-uuid", + Platform: string(fleet.IOSPlatform), + HardwareSerial: "ADUE-SERIAL", + }) + require.NoError(t, err) + // Account-Driven User Enrollment: the primary enrollment row (id = host + // UUID) is type "User Enrollment (Device)". + nanoEnrollUserDevice(t, ds, host) + require.NoError(t, ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://fleetdm.com", false, fleet.WellKnownMDMFleet, "", true)) + + commandXML := enqueueAndReadCommand(t, host, "adue-cmd") + require.NotContains(t, commandXML, "ChangeManagementState", + "account-driven user enrollment must omit ChangeManagementState") + }) +} + func testHasVPPAppConfigurationChanged(t *testing.T, ds *Datastore) { ctx := context.Background() const adamID = "1234567890"