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:
@@ -0,0 +1 @@
|
||||
- Added activity when hosts fail enrollment profile renewal.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user