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"