Install setup-experience VPP apps on manually-enrolled iOS/iPadOS devices (#35906)

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

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

## Testing

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

Tested on iPad and iOS.

Full disclosure, VPP installs on my devices seemed to sometimes (not not
always) fail silently the first time I tried them, with no `error`
showing in the setup experience results. This could be due to the
vagaries of user-based vpp licensing vs. device-based, which is perhaps
not a real-world situation, or something else I'm not following with
[how VPP license assignments
work](https://github.com/fleetdm/fleet/blob/10889199a1754011119bb19df022b545e352c77d/ee/server/service/software_installers.go#L1299-L1310).
I'll continue trying to reproduce it but it's difficult since it only
seems to happen once per app at most, and I can't remove the user
licenses from a device without wiping it (I don't have any physical
devices I can do this on).
This commit is contained in:
Scott Gress
2025-11-19 13:34:10 -06:00
committed by GitHub
parent b5286a3047
commit 06d7169acb
4 changed files with 195 additions and 10 deletions
@@ -0,0 +1 @@
- Support installation of setup-experience VPP apps on manual-enrolled iOS/iPadOS devices
+25 -9
View File
@@ -3518,17 +3518,33 @@ func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.
}
var hasSetupExpItems bool
enqueueSetupExperienceItems := false
if m.AwaitingConfiguration {
// Always run setup experience on non-macOS hosts(i.e. iOS/iPadOS), only run it on macOS if
// this is not an ABM MDM migration
if info.Platform != "darwin" || !info.MigrationInProgress {
// Enqueue setup experience items and mark the host as being in setup experience
hasSetupExpItems, err = svc.ds.EnqueueSetupExperienceItems(r.Context, info.Platform, r.ID, info.TeamID)
if err != nil {
return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks")
}
} else {
if info.MigrationInProgress {
svc.logger.Log("info", "skipping setup experience enqueueing because DEP migration is in progress", "host_uuid", r.ID)
} else {
enqueueSetupExperienceItems = true
}
} else if info.Platform != "darwin" && r.Type == mdm.Device && !info.InstalledFromDEP {
// For manual iOS/iPadOS device enrollments, check the `TokenUpdateTally` so that
// we only run the setup experience enqueueing once per device.
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID)
if err != nil {
return ctxerr.Wrap(r.Context, err, "getting nanomdm enrollment")
}
if nanoEnroll != nil && nanoEnroll.TokenUpdateTally == 1 {
enqueueSetupExperienceItems = true
}
}
// TODO -- See if there's a way to check license here to avoid unnecessary work.
// We do check the license before actually _running_ setup experience items.
if enqueueSetupExperienceItems {
// Enqueue setup experience items and mark the host as being in setup experience
hasSetupExpItems, err = svc.ds.EnqueueSetupExperienceItems(r.Context, info.Platform, r.ID, info.TeamID)
if err != nil {
return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks")
}
}
+159 -1
View File
@@ -1437,7 +1437,7 @@ func TestMDMUnenrollment(t *testing.T) {
}
func TestMDMTokenUpdate(t *testing.T) {
ctx := context.Background()
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
ds := new(mock.Store)
mdmStorage := &mdmmock.MDMAppleStore{}
pushFactory, _ := newMockAPNSPushProviderFactory()
@@ -1600,6 +1600,164 @@ func TestMDMTokenUpdate(t *testing.T) {
require.True(t, ds.SetHostMDMMigrationCompletedFuncInvoked)
}
func TestMDMTokenUpdateIOS(t *testing.T) {
ctx := context.Background()
ds := new(mock.Store)
mdmStorage := &mdmmock.MDMAppleStore{}
pushFactory, _ := newMockAPNSPushProviderFactory()
pusher := nanomdm_pushsvc.New(
mdmStorage,
mdmStorage,
pushFactory,
NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
)
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger(), newActivity)
svc := MDMAppleCheckinAndCommandService{
ds: ds,
mdmLifecycle: mdmLifecycle,
commander: cmdr,
logger: kitlog.NewNopLogger(),
}
uuid, serial, model, wantTeamID := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1", uint(12)
ds.GetMDMIdPAccountByHostUUIDFunc = func(ctx context.Context, hostUUID string) (*fleet.MDMIdPAccount, error) {
require.Equal(t, uuid, hostUUID)
return &fleet.MDMIdPAccount{
UUID: "some-uuid",
Username: "some-user",
Email: "some-user@example.com",
Fullname: "Some User",
}, nil
}
ds.NewJobFunc = func(ctx context.Context, j *fleet.Job) (*fleet.Job, error) {
return j, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
require.Equal(t, uuid, hostUUID)
return &fleet.HostMDMCheckinInfo{
HostID: 1337,
HardwareSerial: serial,
DisplayName: model,
InstalledFromDEP: true,
TeamID: wantTeamID,
DEPAssignedToFleet: true,
Platform: "ios",
}, nil
}
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
}
ds.EnqueueSetupExperienceItemsFunc = func(ctx context.Context, hostPlatformLike string, hostUUID string, teamID uint) (bool, error) {
require.Equal(t, "ios", hostPlatformLike)
require.Equal(t, uuid, hostUUID)
require.Equal(t, wantTeamID, teamID)
return true, nil
}
// DEP-installed without AwaitingConfiguration - should not enqueue SetupExperience items
err := svc.TokenUpdate(
&mdm.Request{
Context: ctx,
EnrollID: &mdm.EnrollID{ID: uuid, Type: mdm.Device},
Params: map[string]string{"enroll_reference": "abcd"},
},
&mdm.TokenUpdate{
Enrollment: mdm.Enrollment{
UDID: uuid,
},
},
)
require.NoError(t, err)
require.False(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
// Non-DEP-installed, non device-type enrollment should not enqueue SetupExperience items
err = svc.TokenUpdate(
&mdm.Request{
Context: ctx,
EnrollID: &mdm.EnrollID{ID: uuid, Type: mdm.User},
Params: map[string]string{"enroll_reference": "abcd"},
},
&mdm.TokenUpdate{
Enrollment: mdm.Enrollment{
UDID: uuid,
},
},
)
require.NoError(t, err)
require.False(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
// Non-DEP-installed without AwaitingConfiguration - should not enqueue SetupExperience items if token count is > 1
ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
require.Equal(t, uuid, hostUUID)
return &fleet.HostMDMCheckinInfo{
HostID: 1337,
HardwareSerial: serial,
DisplayName: model,
InstalledFromDEP: false,
TeamID: wantTeamID,
DEPAssignedToFleet: true,
Platform: "ios",
}, nil
}
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 2}, nil
}
err = svc.TokenUpdate(
&mdm.Request{
Context: ctx,
EnrollID: &mdm.EnrollID{ID: uuid, Type: mdm.Device},
Params: map[string]string{"enroll_reference": "abcd"},
},
&mdm.TokenUpdate{
Enrollment: mdm.Enrollment{
UDID: uuid,
},
},
)
require.NoError(t, err)
require.False(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
// Non-DEP-installed without AwaitingConfiguration - should enqueue SetupExperience items if token count is 1
ds.GetHostMDMCheckinInfoFunc = func(ct context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
require.Equal(t, uuid, hostUUID)
return &fleet.HostMDMCheckinInfo{
HostID: 1337,
HardwareSerial: serial,
DisplayName: model,
InstalledFromDEP: false,
TeamID: wantTeamID,
DEPAssignedToFleet: true,
Platform: "ios",
}, nil
}
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
}
err = svc.TokenUpdate(
&mdm.Request{
Context: ctx,
EnrollID: &mdm.EnrollID{ID: uuid, Type: mdm.Device},
Params: map[string]string{"enroll_reference": "abcd"},
},
&mdm.TokenUpdate{
Enrollment: mdm.Enrollment{
UDID: uuid,
},
},
)
require.NoError(t, err)
require.True(t, ds.EnqueueSetupExperienceItemsFuncInvoked)
}
func TestMDMCheckout(t *testing.T) {
ds := new(mock.Store)
mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger(), newActivity)
+10
View File
@@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
@@ -114,6 +115,15 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg
if _, err := a.installFleetd(ctx, args.HostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
}
} else {
// We shouldn't have any setup experience steps if we're not on a premium license,
// but best to check anyway plus it saves some db queries.
if license.IsPremium(ctx) {
_, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS")
}
}
}
return nil