diff --git a/changes/40623-failed-enrollment-renewal b/changes/40623-failed-enrollment-renewal new file mode 100644 index 0000000000..36ce1a01e3 --- /dev/null +++ b/changes/40623-failed-enrollment-renewal @@ -0,0 +1 @@ +- Added activity when hosts fail enrollment profile renewal. \ No newline at end of file diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index cfd84ef3df..00583b1147 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -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 +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 65f6f1cb95..46ffb90400 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -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, diff --git a/server/fleet/activities.go b/server/fleet/activities.go index a2b857cae7..044ce91899 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -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"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 1f4d54b2c7..5d14ebba36 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -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) diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 2fb8cba6ef..760f97cdd2 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -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"` diff --git a/server/mdm/apple/profile_verifier.go b/server/mdm/apple/profile_verifier.go index 7029bdd653..3053488937 100644 --- a/server/mdm/apple/profile_verifier.go +++ b/server/mdm/apple/profile_verifier.go @@ -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 diff --git a/server/mdm/lifecycle/lifecycle.go b/server/mdm/lifecycle/lifecycle.go index 677e4d5fc6..a8a2951fa2 100644 --- a/server/mdm/lifecycle/lifecycle.go +++ b/server/mdm/lifecycle/lifecycle.go @@ -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 { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c5952fb532..5e0fa3ba43 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -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) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 4cde320dbd..6ad281f5b1 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -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? diff --git a/server/service/apple_mdm_cmd_results.go b/server/service/apple_mdm_cmd_results.go index 4136c40577..491d4fa921 100644 --- a/server/service/apple_mdm_cmd_results.go +++ b/server/service/apple_mdm_cmd_results.go @@ -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 diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index cb61ce6836..cfaf319b0e 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -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', '')`, + 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)) +}