Fix VPP/in-house app install on manual-profile BYOD iOS hosts (#48879) (#48916)

**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

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

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
George Karr
2026-07-09 07:32:37 -05:00
committed by GitHub
parent 9dd76b5b96
commit 69fa5ca435
6 changed files with 186 additions and 31 deletions
@@ -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.
@@ -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) {
+21 -6
View File
@@ -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}
+18 -8
View File
@@ -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)
+18 -8
View File
@@ -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)
+70
View File
@@ -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, "<key>iTunesStoreID</key>")
}
// 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, "<key>ChangeManagementState</key>",
"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, "<key>ChangeManagementState</key>",
"account-driven user enrollment must omit ChangeManagementState")
})
}
func testHasVPPAppConfigurationChanged(t *testing.T, ds *Datastore) {
ctx := context.Background()
const adamID = "1234567890"