Files
Rajendra KadamandMagnus Jensen 0504e5949e Add host_id and host_serial to Apple mdm_enrolled activity (#49969)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49777

## Description

Adds `host_id` and `host_serial` to the Apple `mdm_enrolled` activity so
IT admins can build automations on top of it, and surfaces the activity
on the individual host's activity timeline.

- **`server/fleet/activities.go`** — added `HostID` to
`ActivityTypeMDMEnrolled` and a `HostIDs()` method (mirrors the existing
`ActivityTypeMDMUnenrolled` pattern), so the activity is linked to the
host and appears on its timeline.
- **`server/mdm/lifecycle/lifecycle.go`** — populate `host_id` for
macOS/iOS/iPadOS enrollments. Account-driven user (BYOD) enrollments
have no hardware serial, so they report the enrollment ID as
`host_serial` too, keeping `host_serial` populated for automations
regardless of enrollment type.
- **Frontend** — new `MdmEnrolledActivityItem` component, registered in
the host past-activity component map (and the `IHostPastActivityType`
union), renders the now-host-linked `mdm_enrolled` activity on the host
details **Activity** card. There's no Figma, so the copy mirrors the
sibling `mdm_unenrolled` item (e.g. "Mobile device management (MDM) was
turned on for this host").

`host_id` uses `omitempty`, so Windows (`microsoft_mdm.go`) enrollments
keep their existing activity payload unchanged — Windows is
intentionally out of scope, handled in #47874, which also owns the
audit-log documentation update for the shared field.

> **For reviewer:** the ADUE `host_serial = enrollment_id` behavior
comes from the issue's test plan. It means `host_serial` and
`enrollment_id` carry the same value for BYOD. Flagging in case Product
would rather leave `host_serial` empty for ADUE and have automations
read `enrollment_id`.

## Testing

- **Automated:** `TestMDMEnrolledActivityHostIDAndSerial`
(`server/mdm/lifecycle`) covers device enrollment (`host_serial` =
hardware serial) and ADUE (`host_serial` = enrollment ID), both
asserting `host_id`/`HostIDs()`. Also verified `server/datastore/mysql`
`TestMDMEnrollment`, `server/activity/internal/mysql`
`TestListActivities`, and `server/service` `TestMDMTokenUpdate*` pass.
- **Live (simulated) manual macOS enrollment** via `osquery-perf`: the
`mdm_enrolled` activity recorded `host_id` + `host_serial`, and an
`activity_host_past` row linked it to the host (confirmed it shows on
the host timeline).
- **Frontend:** `MdmEnrolledActivityItem.tests.tsx` covers the rendered
copy for macOS/iOS/Android and the actor/no-actor variants; also
visually confirmed the activity renders on a host's Activity card in the
running app. `yarn jest`, `eslint`, and `tsc` pass.
- Updated the MDM integration tests (`integration_mdm_test.go`,
`integration_mdm_dep_test.go`, `integration_vpp_install_test.go`) whose
activity-detail and host-feed assertions changed now that `mdm_enrolled`
carries `host_id` and appears on the host timeline (feed assertions now
filter by activity type).
- **Pending on-device QA (next week):** DEP/ADE macOS and account-driven
user enrollment (iOS/iPadOS) on real hardware, per the issue's test
plan.
- Regression: Windows `mdm_enrolled` payload is unchanged (`host_id` is
omitted when zero); both platforms' `mdm_unenrolled` are unaffected.

# Screenshot for the frontend change

<img width="706" height="382" alt="Screenshot 2026-07-28 at 11 16 57 AM"
src="https://github.com/user-attachments/assets/8f57d129-f819-4399-8754-18b397a49db8"
/>

# Checklist for submitter

- [x] Changes file added for user-visible changes in `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
- [ ] QA'd all new/changed functionality manually <!-- manual macOS
verified via simulator; DEP + real-device ADUE pending next week -->


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

## Summary by CodeRabbit

* **New Features**
* Added support for rendering “MDM enrolled” in the host activity feed
with platform- and actor-aware messaging.

* **Bug Fixes**
* Updated Apple “MDM enrolled” activity details to include the correct
host identifier and serial/enrollment identifiers.
* Ensured host-scoped activity behavior applies only when the host is
known (host id present).

* **Tests**
* Expanded regression and integration coverage for “MDM enrolled”
activity details and feed contents, including VPP-related assertion
stability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
2026-07-28 14:27:42 +05:30

294 lines
12 KiB
Go

package mdmlifecycle
import (
"context"
"log/slog"
"testing"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/stretchr/testify/require"
)
func nopNewActivity(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error {
return nil
}
func TestDoUnsupportedParams(t *testing.T) {
ds := new(mock.Store)
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
err := lc.Do(context.Background(), HostOptions{})
require.ErrorContains(t, err, "unsupported platform")
err = lc.Do(context.Background(), HostOptions{Platform: "linux"})
require.ErrorContains(t, err, "unsupported platform")
err = lc.Do(context.Background(), HostOptions{Platform: "darwin", Action: "invalid"})
require.ErrorContains(t, err, "unknown action")
err = lc.Do(context.Background(), HostOptions{Platform: "windows", Action: "invalid"})
require.ErrorContains(t, err, "unknown action")
}
func TestDoParamValidation(t *testing.T) {
ds := new(mock.Store)
lf := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
ctx := context.Background()
cases := []struct {
platform string
action HostAction
wantErr bool
}{
{"darwin", HostActionTurnOn, true},
{"darwin", HostActionTurnOff, true},
{"darwin", HostActionReset, true},
{"darwin", HostActionDelete, true},
{"windows", HostActionTurnOn, true},
{"windows", HostActionTurnOff, true},
{"windows", HostActionReset, true},
{"windows", HostActionDelete, false},
}
for _, tc := range cases {
err := lf.Do(ctx, HostOptions{
Action: tc.action,
Platform: tc.platform,
})
if tc.wantErr {
require.ErrorContains(t, err, "required")
} else {
require.NoError(t, err)
}
}
}
// TestReconcileHostNameEnforcementOnEnrollment verifies that both Apple
// enrollment lifecycle branches — turn-on (TokenUpdate) and reset (Authenticate,
// covering re-enrollment) — reconcile the host's host-name template enforcement,
// so a host enrolling into a team with a template gets a queued row.
func TestReconcileHostNameEnforcementOnEnrollment(t *testing.T) {
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
const hostID = uint(99)
t.Run("turn-on reconciles with the enrolled host id", func(t *testing.T) {
ds := new(mock.Store)
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: "darwin"}, nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
var gotIDs []uint
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
gotIDs = hostIDs
return nil
}
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
require.Equal(t, []uint{hostID}, gotIDs)
})
t.Run("turn-on reconciles on the DEP branch before its early return", func(t *testing.T) {
ds := new(mock.Store)
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
}
// DEPAssignedToFleet takes the DEP branch, which queues a job and returns
// early — the reconcile must run before that branch.
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: "darwin", DEPAssignedToFleet: true}, nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
var gotIDs []uint
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
gotIDs = hostIDs
return nil
}
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
require.Equal(t, []uint{hostID}, gotIDs)
require.True(t, ds.NewJobFuncInvoked, "DEP branch should have been taken")
})
t.Run("turn-on skips reconcile when the enrollment is not ready", func(t *testing.T) {
ds := new(mock.Store)
// TokenUpdateTally != 1 makes turnOnApple short-circuit before reconciling.
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 2}, nil
}
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
require.False(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
})
t.Run("reset reconciles with the upserted host id", func(t *testing.T) {
ds := new(mock.Store)
ds.MDMAppleUpsertHostFunc = func(ctx context.Context, mdmHost *fleet.Host, fromPersonalEnrollment bool) error {
mdmHost.ID = hostID
return nil
}
ds.MDMResetEnrollmentFunc = func(ctx context.Context, uuid string, scepRenewalInProgress bool) error { return nil }
var gotIDs []uint
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
gotIDs = hostIDs
return nil
}
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
require.NoError(t, lc.Do(ctx, HostOptions{
Action: HostActionReset, Platform: "darwin",
UUID: "host-uuid", HardwareSerial: "serial", HardwareModel: "MacBookPro",
}))
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
require.Equal(t, []uint{hostID}, gotIDs)
})
t.Run("reset skips reconcile during SCEP renewal", func(t *testing.T) {
ds := new(mock.Store)
ds.MDMResetEnrollmentFunc = func(ctx context.Context, uuid string, scepRenewalInProgress bool) error { return nil }
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
require.NoError(t, lc.Do(ctx, HostOptions{
Action: HostActionReset, Platform: "darwin",
UUID: "host-uuid", HardwareSerial: "serial", HardwareModel: "MacBookPro",
SCEPRenewalInProgress: true,
}))
require.False(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
})
}
// TestDeleteAppleDuplicateDEPHost verifies that deleting one of a set of
// duplicate DEP hosts (same serial) does not recreate a pending "ghost" host
// when another DEP-assigned host with that serial still exists, while a host
// with no duplicate is still restored as before.
// TestMDMEnrolledActivityHostIDAndSerial verifies the Apple mdm_enrolled activity
// carries host_id (so it lands on the host's activity timeline via HostIDs) and a
// populated host_serial for both device and account-driven user (BYOD)
// enrollments. Regression coverage for #49777.
func TestMDMEnrolledActivityHostIDAndSerial(t *testing.T) {
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
const hostID = uint(99)
// runTurnOn drives an Apple turn-on enrollment of the given nano enrollment
// type and returns the mdm_enrolled activity that was recorded.
runTurnOn := func(t *testing.T, enrollType, hardwareSerial string, opts HostOptions) *fleet.ActivityTypeMDMEnrolled {
ds := new(mock.Store)
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: true, Type: enrollType, TokenUpdateTally: 1}, nil
}
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: opts.Platform, HardwareSerial: hardwareSerial}, nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
var captured *fleet.ActivityTypeMDMEnrolled
newAct := func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error {
if a, ok := details.(*fleet.ActivityTypeMDMEnrolled); ok {
captured = a
}
return nil
}
lc := New(ds, slog.New(slog.DiscardHandler), newAct)
require.NoError(t, lc.Do(ctx, opts))
require.NotNil(t, captured, "an mdm_enrolled activity should have been recorded")
return captured
}
t.Run("device enrollment: host_id set, host_serial is the hardware serial", func(t *testing.T) {
a := runTurnOn(t, mdm.EnrollType(mdm.Device).String(), "C08VQ2AXHT96",
HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"})
require.Equal(t, hostID, a.HostID)
require.Equal(t, []uint{hostID}, a.HostIDs())
require.NotNil(t, a.HostSerial)
require.Equal(t, "C08VQ2AXHT96", *a.HostSerial)
require.Nil(t, a.EnrollmentID)
})
t.Run("account-driven user enrollment: host_id set, host_serial is the enrollment id", func(t *testing.T) {
a := runTurnOn(t, mdm.EnrollType(mdm.UserEnrollmentDevice).String(), "",
HostOptions{Action: HostActionTurnOn, Platform: "ios", UUID: "ADUE-ENROLL-ID", UserEnrollmentID: "ADUE-ENROLL-ID"})
require.Equal(t, hostID, a.HostID)
require.Equal(t, []uint{hostID}, a.HostIDs())
require.NotNil(t, a.EnrollmentID)
require.Equal(t, "ADUE-ENROLL-ID", *a.EnrollmentID)
require.NotNil(t, a.HostSerial)
require.Equal(t, "ADUE-ENROLL-ID", *a.HostSerial)
})
}
func TestDeleteAppleDuplicateDEPHost(t *testing.T) {
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
const serial = "ABC123XYZ"
host := &fleet.Host{ID: 1, HardwareSerial: serial, Platform: "darwin"}
newDS := func(dupExists bool) *mock.Store {
ds := new(mock.Store)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
ac := &fleet.AppConfig{}
ac.MDM.AppleBMEnabledAndConfigured = true
return ac, nil
}
abmTokenID := uint(7)
ds.GetHostDEPAssignmentFunc = func(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, error) {
return &fleet.HostDEPAssignment{HostID: hostID, ABMTokenID: &abmTokenID}, nil
}
ds.ReconcileDuplicateDEPHostOnDeleteFunc = func(ctx context.Context, s, platform string, deletedHostID uint) (bool, error) {
require.Equal(t, serial, s)
require.Equal(t, host.Platform, platform)
require.Equal(t, host.ID, deletedHostID)
return dupExists, nil
}
ds.RestoreMDMApplePendingDEPHostFunc = func(ctx context.Context, h *fleet.Host) error {
return nil
}
return ds
}
t.Run("duplicate exists, ghost host not restored", func(t *testing.T) {
ds := newDS(true)
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
err := lc.Do(ctx, HostOptions{Action: HostActionDelete, Platform: "darwin", Host: host})
require.NoError(t, err)
require.True(t, ds.ReconcileDuplicateDEPHostOnDeleteFuncInvoked)
require.False(t, ds.RestoreMDMApplePendingDEPHostFuncInvoked)
})
t.Run("no duplicate, ghost host restored", func(t *testing.T) {
ds := newDS(false)
// "No team" default keeps getDefaultTeamForABMToken from needing TeamExists.
ds.GetABMTokenByIDFunc = func(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) {
return &fleet.ABMToken{ID: tokenID}, nil
}
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
return job, nil
}
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
err := lc.Do(ctx, HostOptions{Action: HostActionDelete, Platform: "darwin", Host: host})
require.NoError(t, err)
require.True(t, ds.ReconcileDuplicateDEPHostOnDeleteFuncInvoked)
require.True(t, ds.RestoreMDMApplePendingDEPHostFuncInvoked)
})
}