produce failed enrollment renewal activity (#44511)

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

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

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

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

To manually QA, I put an early return with `msg.Fail` in the
`mdm_scep.go` file under PKIOperation method, and then triggered a SCEP
renewal.

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

* **New Features**
* Activity logging for Apple MDM enrollment profile renewal failures to
improve auditing and diagnostics.
* Host display enhancements: include computer name and hardware model to
improve host identification in activities and UI.

* **Tests**
* Integration tests verifying enrollment renewal failure activity
creation, association to the correct host, and activity payload
contents.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen
2026-05-01 11:38:08 -06:00
committed by GitHub
parent 00e10f8dbb
commit 275b266ca1
12 changed files with 191 additions and 6 deletions
+1
View File
@@ -0,0 +1 @@
- Added activity when hosts fail enrollment profile renewal.
+14
View File
@@ -8347,3 +8347,17 @@ func (ds *Datastore) GetHostsForAutoRotation(ctx context.Context) ([]fleet.HostA
return hosts, nil
}
func (ds *Datastore) IsAppleEnrollmentRenewalCommand(ctx context.Context, commandUUID, hostUUID string) (bool, error) {
const stmt = `SELECT EXISTS(SELECT 1 FROM nano_cert_auth_associations WHERE renew_command_uuid = ? AND id = ? ORDER BY created_at DESC LIMIT 1)`
var exists bool
if err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, stmt, commandUUID, hostUUID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "check if command is apple enrollment renewal")
}
return exists, nil
}
+2
View File
@@ -6253,8 +6253,10 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str
h.team_id,
h.osquery_host_id,
COALESCE(h.node_key, '') AS node_key,
h.computer_name,
h.hostname,
h.uuid,
h.hardware_model,
h.hardware_serial,
h.distributed_interval,
h.config_tls_refresh,
+18
View File
@@ -1800,6 +1800,24 @@ func (a ActivityTypeCanceledSetupExperience) WasFromAutomation() bool {
return true
}
type ActivityTypeFailedEnrollmentProfileRenewal struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
CommandUUID string `json:"command_uuid"`
}
func (a ActivityTypeFailedEnrollmentProfileRenewal) ActivityName() string {
return "failed_enrollment_profile_renewal"
}
func (a ActivityTypeFailedEnrollmentProfileRenewal) WasFromAutomation() bool {
return true
}
func (a ActivityTypeFailedEnrollmentProfileRenewal) HostIDs() []uint {
return []uint{a.HostID}
}
type ActivityTypeCreatedLabel struct {
ID uint `json:"label_id"`
Name string `json:"label_name"`
+7
View File
@@ -3008,6 +3008,9 @@ type Datastore interface {
MDMWindowsUpdateEnrolledDeviceCredentials(ctx context.Context, deviceId string, credentialsHash []byte) error
// MDMWindowsAcknowledgeEnrolledDeviceCredentials marks the enrolled Windows device credentials as acknowledged.
MDMWindowsAcknowledgeEnrolledDeviceCredentials(ctx context.Context, deviceId string) error
// IsAppleEnrollmentRenewalCommand checks if the given command UUID corresponds to an Apple enrollment renewal command (SCEP/ACME) for the host with the given UUID.
IsAppleEnrollmentRenewalCommand(ctx context.Context, commandUUID, hostUUID string) (bool, error)
}
type AndroidDatastore interface {
@@ -3136,6 +3139,10 @@ type ProfileVerificationStore interface {
// GetHostMDMWindowsProfiles returns the current MDM profile status for the given
// Windows host
GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]HostMDMWindowsProfile, error)
HostLiteByIdentifier(ctx context.Context, identifier string) (*HostLite, error)
// IsAppleEnrollmentRenewalCommand checks if the given command UUID corresponds to an Apple enrollment renewal command (SCEP/ACME) for the host with the given UUID.
IsAppleEnrollmentRenewalCommand(ctx context.Context, commandUUID, hostUUID string) (bool, error)
}
var _ ProfileVerificationStore = (Datastore)(nil)
+6
View File
@@ -947,6 +947,10 @@ func (h *Host) DisplayName() string {
return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
}
func (h *HostLite) DisplayName() string {
return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
}
type HostIssues struct {
FailingPoliciesCount uint64 `json:"failing_policies_count" db:"failing_policies_count" csv:"-"`
CriticalVulnerabilitiesCount *uint64 `json:"critical_vulnerabilities_count,omitempty" db:"critical_vulnerabilities_count" csv:"-"` // We set it to nil if the license is not premium
@@ -1590,10 +1594,12 @@ type HostMacOSProfile struct {
type HostLite struct {
ID uint `db:"id"`
TeamID *uint `db:"team_id"`
ComputerName string `db:"computer_name"`
Hostname string `db:"hostname"`
OsqueryHostID *string `db:"osquery_host_id"`
NodeKey string `db:"node_key"`
UUID string `db:"uuid"`
HardwareModel string `db:"hardware_model"`
HardwareSerial string `db:"hardware_serial"`
SeenTime time.Time `db:"seen_time"`
DistributedInterval uint `db:"distributed_interval"`
+29 -1
View File
@@ -110,13 +110,41 @@ func VerifyHostMDMProfiles(ctx context.Context, ds fleet.ProfileVerificationStor
// HandleHostMDMProfileInstallResult ingests the result of an install profile command reported via
// the MDM protocol and updates the verification status in the datastore. It is intended to be
// called by the Fleet MDM checkin and command service install profile request handler.
func HandleHostMDMProfileInstallResult(ctx context.Context, ds fleet.ProfileVerificationStore, hostUUID string, cmdUUID string, status *fleet.MDMDeliveryStatus, detail string) error {
func HandleHostMDMProfileInstallResult(ctx context.Context, ds fleet.ProfileVerificationStore, hostUUID string, cmdUUID string, status *fleet.MDMDeliveryStatus, detail string, newActivityFn fleet.NewActivityFunc) error {
if status != nil && *status == fleet.MDMDeliveryFailed {
// Here we set the host.Platform to "darwin" but it applies to iOS/iPadOS too.
// The logic in GetHostMDMProfileRetryCountByCommandUUID and UpdateHostMDMProfilesVerification
// is the exact same when platform is "darwin", "ios" or "ipados".
host := &fleet.Host{UUID: hostUUID, Platform: "darwin"}
m, err := ds.GetHostMDMProfileRetryCountByCommandUUID(ctx, host, cmdUUID)
if fleet.IsNotFound(err) {
// Check if the cmdUUID is an enrollment renewal
isEnrollmentRenewalCmd, enrollmentRenewalError := ds.IsAppleEnrollmentRenewalCommand(ctx, cmdUUID, hostUUID)
if enrollmentRenewalError != nil {
return ctxerr.Wrap(ctx, enrollmentRenewalError, "checking if command is Apple enrollment renewal command")
}
if isEnrollmentRenewalCmd {
hostLite, err := ds.HostLiteByIdentifier(ctx, hostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetching host details for Apple enrollment renewal command")
}
// Generate a new activity, to mark that we failed to install the profile for the enrollment renewal.
// This won't trigger on manually enrolled devices, since they will break the connection and send a CheckOut,
// without responding to the command with an error.
activityErr := newActivityFn(ctx, nil, &fleet.ActivityTypeFailedEnrollmentProfileRenewal{
CommandUUID: cmdUUID,
HostID: hostLite.ID,
HostDisplayName: hostLite.DisplayName(),
})
if activityErr != nil {
return ctxerr.Wrap(ctx, activityErr, "creating activity for failed enrollment profile renewal")
}
// Stop returning an error here, since we handled the path.
return nil
}
}
if err != nil {
// FIXME: In cases where the command is superseded before the host reports the results,
// for example, when the scep proxy profile is resent due to challenge expiration, we
+1 -1
View File
@@ -58,7 +58,7 @@ type HostLifecycle struct {
// NewActivityFunc is the signature type of the service-layer function that can
// create activities and handle the webhook notification and all other
// mechanisms required when creating an activity.
type NewActivityFunc func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error
type NewActivityFunc = fleet.NewActivityFunc
// New creates a new HostLifecycle struct
func New(ds fleet.Datastore, logger *slog.Logger, newActivityFn NewActivityFunc) *HostLifecycle {
+12
View File
@@ -1901,6 +1901,8 @@ type MDMWindowsUpdateEnrolledDeviceCredentialsFunc func(ctx context.Context, dev
type MDMWindowsAcknowledgeEnrolledDeviceCredentialsFunc func(ctx context.Context, deviceId string) error
type IsAppleEnrollmentRenewalCommandFunc func(ctx context.Context, commandUUID string, hostUUID string) (bool, error)
type DataStore struct {
AppConfigFunc AppConfigFunc
AppConfigFuncInvoked bool
@@ -4719,6 +4721,9 @@ type DataStore struct {
MDMWindowsAcknowledgeEnrolledDeviceCredentialsFunc MDMWindowsAcknowledgeEnrolledDeviceCredentialsFunc
MDMWindowsAcknowledgeEnrolledDeviceCredentialsFuncInvoked bool
IsAppleEnrollmentRenewalCommandFunc IsAppleEnrollmentRenewalCommandFunc
IsAppleEnrollmentRenewalCommandFuncInvoked bool
mu sync.Mutex
}
@@ -11294,3 +11299,10 @@ func (s *DataStore) MDMWindowsAcknowledgeEnrolledDeviceCredentials(ctx context.C
s.mu.Unlock()
return s.MDMWindowsAcknowledgeEnrolledDeviceCredentialsFunc(ctx, deviceId)
}
func (s *DataStore) IsAppleEnrollmentRenewalCommand(ctx context.Context, commandUUID string, hostUUID string) (bool, error) {
s.mu.Lock()
s.IsAppleEnrollmentRenewalCommandFuncInvoked = true
s.mu.Unlock()
return s.IsAppleEnrollmentRenewalCommandFunc(ctx, commandUUID, hostUUID)
}
+2 -1
View File
@@ -4081,6 +4081,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
cmdResult.CommandUUID,
mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status),
apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
svc.newActivityFn,
)
case "RemoveProfile":
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
@@ -4177,7 +4178,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
HostUUID: cmdResult.Identifier(),
CommandUUID: cmdResult.CommandUUID,
CommandStatus: cmdResult.Status,
}, fleet.NewActivityFunc(svc.newActivityFn)); err != nil {
}, svc.newActivityFn); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result")
} else if updated {
// TODO: call next step of setup experience?
+2 -3
View File
@@ -10,7 +10,6 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/micromdm/plist"
@@ -65,7 +64,7 @@ func NewInstalledApplicationListResultsHandler(
commander *apple_mdm.MDMAppleCommander,
logger *slog.Logger,
verifyTimeout, verifyRequestDelay time.Duration,
newActivityFn mdmlifecycle.NewActivityFunc,
newActivityFn fleet.NewActivityFunc,
) fleet.MDMCommandResultsHandler {
return func(ctx context.Context, commandResults fleet.MDMCommandResults) error {
installedAppResult, ok := commandResults.(InstalledApplicationListResult)
@@ -171,7 +170,7 @@ func NewInstalledApplicationListResultsHandler(
HostUUID: installedAppResult.HostUUID(),
CommandUUID: expectedInstall.InstallCommandUUID,
CommandStatus: terminalStatus,
}, fleet.NewActivityFunc(newActivityFn)); err != nil {
}, newActivityFn); err != nil {
return ctxerr.Wrap(ctx, err, "updating setup experience status from VPP install result")
} else if updated {
fromSetupExperience = true
+97
View File
@@ -24153,3 +24153,100 @@ func (s *integrationMDMTestSuite) TestManagedLocalAccount() {
expectedDetail, 0), lastActivityID)
})
}
func (s *integrationMDMTestSuite) TestErrorOnEnrollmentInstallProfileProducesActivity() {
t := s.T()
ctx := t.Context()
// Enrolling the host populates nano_cert_auth_associations for it.
host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setRenewCommandUUID := func(cmdUUID *string) {
t.Helper()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`UPDATE nano_cert_auth_associations SET renew_command_uuid = ? WHERE id = ?`,
cmdUUID, host.UUID,
)
return err
})
}
// nano_cert_auth_associations.renew_command_uuid references nano_commands,
// so we have to insert a placeholder command before pointing at it.
insertNanoCommand := func(cmdUUID string) {
t.Helper()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO nano_commands (command_uuid, request_type, command) VALUES (?, 'InstallProfile', '<?xml version="1.0"?>')`,
cmdUUID,
)
return err
})
}
activityName := fleet.ActivityTypeFailedEnrollmentProfileRenewal{}.ActivityName()
countRenewalActivitiesForCmd := func(cmdUUID string) int {
t.Helper()
var n int
for _, act := range s.listActivities() {
if act.Type != activityName || act.Details == nil {
continue
}
var d struct {
CommandUUID string `json:"command_uuid"`
}
require.NoError(t, json.Unmarshal(*act.Details, &d))
if d.CommandUUID == cmdUUID {
n++
}
}
return n
}
failed := fleet.MDMDeliveryFailed
verifying := fleet.MDMDeliveryVerifying
// Case 1: failure for a command that has no host_mdm_apple_profiles row and
// no renew_command_uuid pointing at it — no activity should be produced.
// HandleHostMDMProfileInstallResult will surface the underlying not-found
// error from the retry-count lookup; we deliberately ignore it.
setRenewCommandUUID(nil)
case1Cmd := uuid.NewString()
_ = apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case1Cmd, &failed, "boom", s.fleetSvc.NewActivity)
require.Zero(t, countRenewalActivitiesForCmd(case1Cmd))
// Case 2: failure with a renew_command_uuid set on the host, but the
// failing command's UUID does not match it — no activity.
case2RenewCmd := uuid.NewString()
insertNanoCommand(case2RenewCmd)
setRenewCommandUUID(&case2RenewCmd)
case2OtherCmd := uuid.NewString()
_ = apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case2OtherCmd, &failed, "boom", s.fleetSvc.NewActivity)
require.Zero(t, countRenewalActivitiesForCmd(case2OtherCmd))
require.Zero(t, countRenewalActivitiesForCmd(case2RenewCmd))
// Case 3: failure where the command UUID matches the renew_command_uuid —
// activity is produced.
case3RenewCmd := uuid.NewString()
insertNanoCommand(case3RenewCmd)
setRenewCommandUUID(&case3RenewCmd)
require.NoError(t, apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case3RenewCmd, &failed, "boom", s.fleetSvc.NewActivity))
require.Equal(t, 1, countRenewalActivitiesForCmd(case3RenewCmd))
hostLite, err := s.ds.HostLiteByIdentifier(ctx, host.UUID)
require.NoError(t, err)
expected := fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "command_uuid": %q}`,
hostLite.ID, hostLite.DisplayName(), case3RenewCmd)
s.lastActivityOfTypeMatches(activityName, expected, 0)
// Case 4: a successful (verifying) install profile result whose command
// UUID matches the renew_command_uuid — no activity, because only failures
// take the renewal path.
case4RenewCmd := uuid.NewString()
insertNanoCommand(case4RenewCmd)
setRenewCommandUUID(&case4RenewCmd)
require.NoError(t, apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case4RenewCmd, &verifying, "", s.fleetSvc.NewActivity))
require.Zero(t, countRenewalActivitiesForCmd(case4RenewCmd))
}