From a8c9e261d73c3b7cd0e23fd9596efbf62d4c990c Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Thu, 19 Mar 2026 14:58:10 -0500 Subject: [PATCH] speed up macOS profile delivery for initial enrollments (#41960) **Related issue:** Resolves #34433 It speeds up the cron, meaning fleetd, bootstrap and now profiles should be sent within 10 seconds of being known to fleet, compared to the previous 1 minute. It's heavily based on my last PR, so the structure and changes are close to identical, with some small differences. **I did not do the redis key part in this PR, as I think that should come in it's own PR, to avoid overlooking logic bugs with that code, and since this one is already quite sized since we're moving core pieces of code around.** # 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 ## Summary by CodeRabbit * **New Features** * Faster macOS onboarding: device profiles are delivered and installed as part of DEP enrollment, shortening initial setup. * Improved profile handling: per-host profile preprocessing, secret detection, and clearer failure marking. * **Improvements** * Consolidated SCEP/NDES error messaging for clearer diagnostics. * Cron/work scheduling tuned to prioritize Apple MDM profile delivery. * **Tests** * Expanded MDM unit and integration tests, including DeclarativeManagement handling. --- changes/34433-speedup-macos-profile-delivery | 1 + cmd/fleet/cron.go | 59 +- cmd/fleet/serve.go | 14 +- .../gitops_enterprise_integration_test.go | 4 +- ee/server/service/certificate_authorities.go | 9 +- .../service/certificate_authorities_test.go | 13 +- ee/server/service/errors.go | 36 - ee/server/service/{ => scep}/scep_proxy.go | 57 +- .../service/{ => scep}/scep_proxy_test.go | 2 +- .../testdata/mscep_admin_cache_full.html | 0 .../mscep_admin_insufficient_permissions.html | 0 .../testdata/mscep_admin_password.html | 0 .../service/{ => scep}/testdata/testca/ca.key | 0 .../service/{ => scep}/testdata/testca/ca.pem | 0 ee/server/service/{ => scep}/testing_utils.go | 2 +- server/fleet/apple_profiles.go | 94 ++ server/fleet/cron_schedules.go | 1 + server/mdm/apple/profile_processor.go | 834 +++++++++++++ server/mdm/apple/profile_processor_test.go | 999 +++++++++++++++ server/ptr/ptr.go | 8 +- server/service/apple_mdm.go | 959 +-------------- server/service/apple_mdm_test.go | 1083 +---------------- server/service/handler.go | 4 +- ...ntegration_certificate_authorities_test.go | 8 +- server/service/integration_mdm_dep_test.go | 61 +- .../service/integration_mdm_lifecycle_test.go | 7 + .../integration_mdm_release_worker_test.go | 100 +- .../integration_mdm_setup_experience_test.go | 59 + server/service/integration_mdm_test.go | 26 +- server/service/microsoft_mdm.go | 25 +- server/service/testing_utils.go | 5 +- server/worker/apple_mdm.go | 120 ++ server/worker/apple_mdm_test.go | 54 + 33 files changed, 2512 insertions(+), 2132 deletions(-) create mode 100644 changes/34433-speedup-macos-profile-delivery rename ee/server/service/{ => scep}/scep_proxy.go (92%) rename ee/server/service/{ => scep}/scep_proxy_test.go (99%) rename ee/server/service/{ => scep}/testdata/mscep_admin_cache_full.html (100%) rename ee/server/service/{ => scep}/testdata/mscep_admin_insufficient_permissions.html (100%) rename ee/server/service/{ => scep}/testdata/mscep_admin_password.html (100%) rename ee/server/service/{ => scep}/testdata/testca/ca.key (100%) rename ee/server/service/{ => scep}/testdata/testca/ca.pem (100%) rename ee/server/service/{ => scep}/testing_utils.go (99%) create mode 100644 server/fleet/apple_profiles.go create mode 100644 server/mdm/apple/profile_processor.go create mode 100644 server/mdm/apple/profile_processor_test.go diff --git a/changes/34433-speedup-macos-profile-delivery b/changes/34433-speedup-macos-profile-delivery new file mode 100644 index 0000000000..b322b8ed9d --- /dev/null +++ b/changes/34433-speedup-macos-profile-delivery @@ -0,0 +1 @@ +- Moved Apple MDM worker to a faster cron, and started sending profiles on Post DEP enrollment job, to speed up initial macOS setup. \ No newline at end of file diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index edc4c7a0dc..9c94ed8791 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -732,8 +732,6 @@ func newWorkerIntegrationsSchedule( logger *slog.Logger, depStorage *mysql.NanoDEPStorage, commander *apple_mdm.MDMAppleCommander, - bootstrapPackageStore fleet.MDMBootstrapPackageStore, - vppInstaller fleet.AppleMDMVPPInstaller, androidModule android.Service, ) (*schedule.Schedule, error) { const ( @@ -782,13 +780,7 @@ func newWorkerIntegrationsSchedule( DEPService: depSvc, DEPClient: depCli, } - appleMDM := &worker.AppleMDM{ - Datastore: ds, - Log: logger, - Commander: commander, - BootstrapPackageStore: bootstrapPackageStore, - VPPInstaller: vppInstaller, - } + vppVerify := &worker.AppleSoftware{ Datastore: ds, Log: logger, @@ -803,7 +795,7 @@ func newWorkerIntegrationsSchedule( Log: logger, AndroidModule: androidModule, } - w.Register(jira, zendesk, macosSetupAsst, appleMDM, dbMigrate, vppVerify, softwareWorker) + w.Register(jira, zendesk, macosSetupAsst, dbMigrate, vppVerify, softwareWorker) // Read app config a first time before starting, to clear up any failer client // configuration if we're not on a fleet-owned server. Technically, the ServerURL @@ -904,6 +896,53 @@ func newFailerClient(forcedFailures string) *worker.TestAutomationFailer { return failerClient } +func newAppleMDMWorkerSchedule( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + logger *slog.Logger, + commander *apple_mdm.MDMAppleCommander, + bootstrapPackageStore fleet.MDMBootstrapPackageStore, + vppInstaller fleet.AppleMDMVPPInstaller, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronAppleMDMWorker) + scheduleInterval = 10 * time.Second // schedule a worker to run every 10 seconds if none is running + maxRunTime = 10 * time.Minute // allow the worker to run for 10 minutes + ) + + logger = logger.With("cron", name) + + w := worker.NewWorker(ds, logger) + + appleMDM := &worker.AppleMDM{ + Datastore: ds, + Log: logger, + Commander: commander, + BootstrapPackageStore: bootstrapPackageStore, + VPPInstaller: vppInstaller, + } + + w.Register(appleMDM) + + s := schedule.New( + ctx, name, instanceID, scheduleInterval, ds, ds, + schedule.WithAltLockID("apple_mdm"), + schedule.WithLogger(logger), + schedule.WithJob("apple_mdm_worker", func(ctx context.Context) error { + workCtx, cancel := context.WithTimeout(ctx, maxRunTime) + defer cancel() + + if err := w.ProcessJobs(workCtx); err != nil { + return fmt.Errorf("processing apple mdm jobs: %w", err) + } + return nil + }), + ) + + return s, nil +} + func newCleanupsAndAggregationSchedule( ctx context.Context, instanceID string, diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 07f3c7cbc2..1ac08456e3 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -30,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/pkg/str" @@ -872,7 +873,7 @@ the way that the Fleet server works. } eh := errorstore.NewHandler(ctx, redisPool, logger, config.Logging.ErrorRetentionPeriod) - scepConfigMgr := eeservice.NewSCEPConfigService(logger, nil) + scepConfigMgr := scep.NewSCEPConfigService(logger, nil) digiCertService := digicert.NewService(digicert.WithLogger(logger)) ctx = ctxerr.NewContext(ctx, eh) @@ -1176,12 +1177,19 @@ the way that the Fleet server works. if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) - vppInstaller := svc.(fleet.AppleMDMVPPInstaller) - return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, bootstrapPackageStore, vppInstaller, androidSvc) + return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, androidSvc) }); err != nil { initFatal(err, "failed to register worker integrations schedule") } + if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { + commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) + vppInstaller := svc.(fleet.AppleMDMVPPInstaller) + return newAppleMDMWorkerSchedule(ctx, instanceID, ds, logger, commander, bootstrapPackageStore, vppInstaller) + }); err != nil { + initFatal(err, "failed to register apple_mdm_worker schedule") + } + if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger) }); err != nil { diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 5471c3c5db..9d8c9a90cb 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -22,8 +22,8 @@ import ( "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils" "github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest" ma "github.com/fleetdm/fleet/v4/ee/maintained-apps" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/filesystem" "github.com/fleetdm/fleet/v4/server/datastore/mysql" @@ -111,7 +111,7 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() { SCEPStorage: scepStorage, Pool: redisPool, APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", - SCEPConfigService: eeservice.NewSCEPConfigService(slog.New(slog.NewTextHandler(os.Stdout, nil)), nil), + SCEPConfigService: scep.NewSCEPConfigService(slog.New(slog.NewTextHandler(os.Stdout, nil)), nil), DigiCertService: digicert.NewService(), SoftwareTitleIconStore: softwareTitleIconStore, } diff --git a/ee/server/service/certificate_authorities.go b/ee/server/service/certificate_authorities.go index f67a2cc3d6..0620511a69 100644 --- a/ee/server/service/certificate_authorities.go +++ b/ee/server/service/certificate_authorities.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -380,9 +381,9 @@ func (svc *Service) validateNDESSCEPProxy(ctx context.Context, ndesSCEP *fleet.N if err := svc.scepConfigService.ValidateNDESSCEPAdminURL(ctx, *ndesSCEP); err != nil { svc.logger.ErrorContext(ctx, "Failed to validate NDES SCEP admin URL", "err", err) switch { - case errors.As(err, &NDESPasswordCacheFullError{}): + case errors.As(err, &scep.NDESPasswordCacheFullError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sThe NDES password cache is full. Please increase the number of cached passwords in NDES and try again.", errPrefix)} - case errors.As(err, &NDESInsufficientPermissionsError{}): + case errors.As(err, &scep.NDESInsufficientPermissionsError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sInsufficient permissions for NDES SCEP admin URL. Please correct and try again.", errPrefix)} default: return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid NDES SCEP admin URL or credentials. Please correct and try again.", errPrefix)} @@ -1441,9 +1442,9 @@ func (svc *Service) validateNDESSCEPProxyUpdate(ctx context.Context, ndesSCEP *f if err := svc.scepConfigService.ValidateNDESSCEPAdminURL(ctx, NDESProxy); err != nil { svc.logger.ErrorContext(ctx, "Failed to validate NDES SCEP admin URL", "err", err) switch { - case errors.As(err, &NDESPasswordCacheFullError{}): + case errors.As(err, &scep.NDESPasswordCacheFullError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sThe NDES password cache is full. Please increase the number of cached passwords in NDES and try again.", errPrefix)} - case errors.As(err, &NDESInsufficientPermissionsError{}): + case errors.As(err, &scep.NDESInsufficientPermissionsError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sInsufficient permissions for NDES SCEP admin URL. Please correct and try again.", errPrefix)} default: return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid NDES SCEP admin URL or credentials. Please correct and try again.", errPrefix)} diff --git a/ee/server/service/certificate_authorities_test.go b/ee/server/service/certificate_authorities_test.go index 1c3a4d2902..26eed78352 100644 --- a/ee/server/service/certificate_authorities_test.go +++ b/ee/server/service/certificate_authorities_test.go @@ -14,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/ee/server/service/est" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -877,7 +878,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInvalidError("some error") + return scep.NewNDESInvalidError("some error") }, } @@ -902,7 +903,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESPasswordCacheFullError("mock error") + return scep.NewNDESPasswordCacheFullError("mock error") }, } @@ -927,7 +928,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInsufficientPermissionsError("mock error") + return scep.NewNDESInsufficientPermissionsError("mock error") }, } @@ -1710,7 +1711,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInvalidError("some error") + return scep.NewNDESInvalidError("some error") }, } @@ -1730,7 +1731,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESPasswordCacheFullError("some error") + return scep.NewNDESPasswordCacheFullError("some error") }, } @@ -1750,7 +1751,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInsufficientPermissionsError("some error") + return scep.NewNDESInsufficientPermissionsError("some error") }, } diff --git a/ee/server/service/errors.go b/ee/server/service/errors.go index 7885f6df8e..da40f5e0df 100644 --- a/ee/server/service/errors.go +++ b/ee/server/service/errors.go @@ -20,42 +20,6 @@ func (e *notFoundError) IsNotFound() bool { return true } -type NDESInvalidError struct { - msg string -} - -func (e NDESInvalidError) Error() string { - return e.msg -} - -func NewNDESInvalidError(msg string) NDESInvalidError { - return NDESInvalidError{msg: msg} -} - -type NDESPasswordCacheFullError struct { - msg string -} - -func (e NDESPasswordCacheFullError) Error() string { - return e.msg -} - -func NewNDESPasswordCacheFullError(msg string) NDESPasswordCacheFullError { - return NDESPasswordCacheFullError{msg: msg} -} - -type NDESInsufficientPermissionsError struct { - msg string -} - -func (e NDESInsufficientPermissionsError) Error() string { - return e.msg -} - -func NewNDESInsufficientPermissionsError(msg string) NDESInsufficientPermissionsError { - return NDESInsufficientPermissionsError{msg: msg} -} - type InvalidIDPTokenError struct{} func (e InvalidIDPTokenError) Error() string { diff --git a/ee/server/service/scep_proxy.go b/ee/server/service/scep/scep_proxy.go similarity index 92% rename from ee/server/service/scep_proxy.go rename to ee/server/service/scep/scep_proxy.go index 29fafd3861..35a1545ec2 100644 --- a/ee/server/service/scep_proxy.go +++ b/ee/server/service/scep/scep_proxy.go @@ -1,4 +1,4 @@ -package service +package scep import ( "bytes" @@ -636,3 +636,58 @@ func (s *SCEPConfigService) GetSmallstepSCEPChallenge(ctx context.Context, ca fl return string(b), nil } + +type NDESInvalidError struct { + msg string +} + +func (e NDESInvalidError) Error() string { + return e.msg +} + +func NewNDESInvalidError(msg string) NDESInvalidError { + return NDESInvalidError{msg: msg} +} + +type NDESPasswordCacheFullError struct { + msg string +} + +func (e NDESPasswordCacheFullError) Error() string { + return e.msg +} + +func NewNDESPasswordCacheFullError(msg string) NDESPasswordCacheFullError { + return NDESPasswordCacheFullError{msg: msg} +} + +type NDESInsufficientPermissionsError struct { + msg string +} + +func (e NDESInsufficientPermissionsError) Error() string { + return e.msg +} + +func NewNDESInsufficientPermissionsError(msg string) NDESInsufficientPermissionsError { + return NDESInsufficientPermissionsError{msg: msg} +} + +// NDESChallengeErrorToDetail translates NDES-specific error types into user-friendly messages +// for profile failure details. Used by both Apple and Windows NDES profile processing. +func NDESChallengeErrorToDetail(err error) string { + varName := fleet.FleetVarNDESSCEPChallenge.WithPrefix() + switch { + case errors.As(err, &NDESInvalidError{}): + return fmt.Sprintf("Invalid NDES admin credentials. Fleet couldn't populate %s. "+ + "Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.", varName) + case errors.As(err, &NDESPasswordCacheFullError{}): + return fmt.Sprintf("The NDES password cache is full. Fleet couldn't populate %s. "+ + "Please increase the number of cached passwords in NDES and try again.", varName) + case errors.As(err, &NDESInsufficientPermissionsError{}): + return fmt.Sprintf("This account does not have sufficient permissions to enroll with SCEP. Fleet couldn't populate %s. "+ + "Please update the account with NDES SCEP enroll permissions and try again.", varName) + default: + return fmt.Sprintf("Fleet couldn't populate %s. %s", varName, err.Error()) + } +} diff --git a/ee/server/service/scep_proxy_test.go b/ee/server/service/scep/scep_proxy_test.go similarity index 99% rename from ee/server/service/scep_proxy_test.go rename to ee/server/service/scep/scep_proxy_test.go index 6ce2517e57..e83ddb22c0 100644 --- a/ee/server/service/scep_proxy_test.go +++ b/ee/server/service/scep/scep_proxy_test.go @@ -1,4 +1,4 @@ -package service +package scep import ( "context" diff --git a/ee/server/service/testdata/mscep_admin_cache_full.html b/ee/server/service/scep/testdata/mscep_admin_cache_full.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_cache_full.html rename to ee/server/service/scep/testdata/mscep_admin_cache_full.html diff --git a/ee/server/service/testdata/mscep_admin_insufficient_permissions.html b/ee/server/service/scep/testdata/mscep_admin_insufficient_permissions.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_insufficient_permissions.html rename to ee/server/service/scep/testdata/mscep_admin_insufficient_permissions.html diff --git a/ee/server/service/testdata/mscep_admin_password.html b/ee/server/service/scep/testdata/mscep_admin_password.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_password.html rename to ee/server/service/scep/testdata/mscep_admin_password.html diff --git a/ee/server/service/testdata/testca/ca.key b/ee/server/service/scep/testdata/testca/ca.key similarity index 100% rename from ee/server/service/testdata/testca/ca.key rename to ee/server/service/scep/testdata/testca/ca.key diff --git a/ee/server/service/testdata/testca/ca.pem b/ee/server/service/scep/testdata/testca/ca.pem similarity index 100% rename from ee/server/service/testdata/testca/ca.pem rename to ee/server/service/scep/testdata/testca/ca.pem diff --git a/ee/server/service/testing_utils.go b/ee/server/service/scep/testing_utils.go similarity index 99% rename from ee/server/service/testing_utils.go rename to ee/server/service/scep/testing_utils.go index 6395a35d0f..2bfbaee980 100644 --- a/ee/server/service/testing_utils.go +++ b/ee/server/service/scep/testing_utils.go @@ -1,4 +1,4 @@ -package service +package scep import ( "crypto/x509" diff --git a/server/fleet/apple_profiles.go b/server/fleet/apple_profiles.go new file mode 100644 index 0000000000..9853dc8cdb --- /dev/null +++ b/server/fleet/apple_profiles.go @@ -0,0 +1,94 @@ +package fleet + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" +) + +// install/removeTargets are maps from profileUUID -> command uuid and host +// UUIDs as the underlying MDM services are optimized to send one command to +// multiple hosts at the same time. Note that the same command uuid is used +// for all hosts in a given install/remove target operation. +type CmdTarget struct { + CmdUUID string + ProfileIdentifier string + EnrollmentIDs []string +} + +type HostProfileUUID struct { + HostUUID string + ProfileUUID string +} + +func FindProfilesWithSecrets( + ctx context.Context, + logger *slog.Logger, + installTargets map[string]*CmdTarget, + profileContents map[string]mobileconfig.Mobileconfig, +) (map[string]struct{}, error) { + profilesWithSecrets := make(map[string]struct{}) + for profUUID := range installTargets { + p, ok := profileContents[profUUID] + if !ok { // Should never happen + logger.ErrorContext(ctx, "profile content not found in FindProfilesWithSecrets", "profile_uuid", profUUID) + continue + } + profileStr := string(p) + vars := ContainsPrefixVars(profileStr, ServerSecretPrefix) + if len(vars) > 0 { + profilesWithSecrets[profUUID] = struct{}{} + } + } + return profilesWithSecrets, nil +} + +func MarkProfilesFailed( + ctx context.Context, + ds Datastore, + logger *slog.Logger, + target *CmdTarget, + hostProfilesToInstallMap map[HostProfileUUID]*MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + profUUID string, + detail string, + variablesUpdatedAt *time.Time, +) (bool, error) { + profilesToUpdate := make([]*MDMAppleBulkUpsertHostProfilePayload, 0, len(target.EnrollmentIDs)) + for _, enrollmentID := range target.EnrollmentIDs { + profile, ok := GetHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) + if !ok || profile == nil { + logger.ErrorContext(ctx, "failed to find profile to install by enrollment id, when marking as failed", "enrollment_id", enrollmentID, "profile_uuid", profUUID) + // Should never happen + continue + } + profile.Status = &MDMDeliveryFailed + profile.Detail = detail + profile.VariablesUpdatedAt = variablesUpdatedAt + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return false, fmt.Errorf("marking host profiles failed: %w", err) + } + return false, nil +} + +func GetHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[HostProfileUUID]*MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + enrollmentID, + profUUID string, +) (*MDMAppleBulkUpsertHostProfilePayload, bool) { + profile, ok := hostProfilesToInstallMap[HostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] + if !ok { + var hostUUID string + // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. + hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] + if ok { + profile, ok = hostProfilesToInstallMap[HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + } + } + return profile, ok +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index a9f3c8c445..dff478e449 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -52,6 +52,7 @@ const ( // CronSendRecoveryLockCommands sends SetRecoveryLock MDM commands to macOS devices. // Runs every 5 minutes. CronSendRecoveryLockCommands CronScheduleName = "send_recovery_lock_commands" + CronAppleMDMWorker CronScheduleName = "apple_mdm_worker" ) type CronSchedulesService interface { diff --git a/server/mdm/apple/profile_processor.go b/server/mdm/apple/profile_processor.go new file mode 100644 index 0000000000..b7e082158b --- /dev/null +++ b/server/mdm/apple/profile_processor.go @@ -0,0 +1,834 @@ +package apple_mdm + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "maps" + "net/url" + "regexp" + "slices" + "strings" + "sync" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/profiles" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/variables" + "github.com/google/uuid" +) + +// LEGACY VARIABLE +var fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) + +// EnqueueResult holds the results of profile enqueue operations. +type EnqueueResult struct { + // FailedCmdUUIDs maps command UUIDs that failed to enqueue to their errors. + FailedCmdUUIDs map[string]error + // SucceededCmdUUIDs contains the command UUIDs that were enqueued successfully. + SucceededCmdUUIDs []string +} + +func ProcessAndEnqueueProfiles(ctx context.Context, + ds fleet.Datastore, + logger *slog.Logger, + appConfig *fleet.AppConfig, + commander *MDMAppleCommander, + installTargets, removeTargets map[string]*fleet.CmdTarget, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, +) (*EnqueueResult, error) { + // Grab the contents of all the profiles we need to install + profileUUIDs := make([]string, 0, len(installTargets)) + for pUUID := range installTargets { + profileUUIDs = append(profileUUIDs, pUUID) + } + + profileContents, err := ds.GetMDMAppleProfilesContents(ctx, profileUUIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get profile contents") + } + + groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") + } + + // Insert variables into profile contents of install targets. Variables may be host-specific. + err = preprocessProfileContents(ctx, appConfig, ds, + scep.NewSCEPConfigService(logger, nil), + digicert.NewService(digicert.WithLogger(logger)), + logger, installTargets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + if err != nil { + return nil, err + } + + // Find the profiles containing secret variables. + profilesWithSecrets, err := fleet.FindProfilesWithSecrets(ctx, logger, installTargets, profileContents) + if err != nil { + return nil, err + } + + type remoteResult struct { + Err error + CmdUUID string + } + + // Send the install/remove commands for each profile. + var wgProd, wgCons sync.WaitGroup + ch := make(chan remoteResult) + + execCmd := func(profUUID string, target *fleet.CmdTarget, op fleet.MDMOperationType) { + defer wgProd.Done() + + var err error + switch op { + case fleet.MDMOperationTypeInstall: + if _, ok := profilesWithSecrets[profUUID]; ok { + err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID) + } else { + err = commander.InstallProfile(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID) + } + case fleet.MDMOperationTypeRemove: + err = commander.RemoveProfile(ctx, target.EnrollmentIDs, target.ProfileIdentifier, target.CmdUUID) + } + + var e *APNSDeliveryError + switch { + case errors.As(err, &e): + logger.DebugContext(ctx, "failed sending push notifications, profiles still enqueued", "details", err) + ch <- remoteResult{nil, target.CmdUUID} + // this is fine to pass as success here, since we have sent the command but just didn't notify the client, but when the client checks back in it will process this profile. + case err != nil: + logger.ErrorContext(ctx, fmt.Sprintf("enqueue command to %s profiles", op), "details", err) + ch <- remoteResult{err, target.CmdUUID} + default: + ch <- remoteResult{nil, target.CmdUUID} + } + } + for profUUID, target := range installTargets { + wgProd.Add(1) + go execCmd(profUUID, target, fleet.MDMOperationTypeInstall) + } + for profUUID, target := range removeTargets { + wgProd.Add(1) + go execCmd(profUUID, target, fleet.MDMOperationTypeRemove) + } + + result := &EnqueueResult{ + FailedCmdUUIDs: make(map[string]error), + SucceededCmdUUIDs: []string{}, + } + + wgCons.Go(func() { + for resp := range ch { + if resp.Err == nil { + result.SucceededCmdUUIDs = append(result.SucceededCmdUUIDs, resp.CmdUUID) + } else { + result.FailedCmdUUIDs[resp.CmdUUID] = resp.Err + } + } + }) + + wgProd.Wait() + close(ch) // done sending at this point, this triggers end of for loop in consumer + wgCons.Wait() + return result, nil +} + +func preprocessProfileContents( + ctx context.Context, + appConfig *fleet.AppConfig, + ds fleet.Datastore, + scepConfig fleet.SCEPConfigService, + digiCertService fleet.DigiCertService, + logger *slog.Logger, + targets map[string]*fleet.CmdTarget, + profileContents map[string]mobileconfig.Mobileconfig, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + groupedCAs *fleet.GroupedCertificateAuthorities, +) error { + // This method replaces Fleet variables ($FLEET_VAR_) in the profile + // contents, generating a unique profile for each host. For a 2KB profile and + // 30K hosts, this method may generate ~60MB of profile data in memory. + + var ( + // Copy of NDES SCEP config which will contain unencrypted password, if needed + ndesConfig *fleet.NDESSCEPProxyCA + digiCertCAs map[string]*fleet.DigiCertCA + customSCEPCAs map[string]*fleet.CustomSCEPProxyCA + smallstepCAs map[string]*fleet.SmallstepSCEPProxyCA + ) + + // this is used to cache the host ID corresponding to the UUID, so we don't + // need to look it up more than once per host. + hostIDForUUIDCache := make(map[string]uint) + + var addedTargets map[string]*fleet.CmdTarget + for profUUID, target := range targets { + contents, ok := profileContents[profUUID] + if !ok { + // This should never happen + continue + } + + // Check if Fleet variables are present. + contentsStr := string(contents) + fleetVars := variables.Find(contentsStr) + if len(fleetVars) == 0 { + continue + } + + var variablesUpdatedAt *time.Time + + // Do common validation that applies to all hosts in the target + valid := true + // Check if there are any CA variables first so that if a non-CA variable causes + // preprocessing to fail, we still set the variablesUpdatedAt timestamp so that + // validation works as expected + // In the future we should expand variablesUpdatedAt logic to include non-CA variables as + // well + for _, fleetVar := range fleetVars { + if fleetVar == string(fleet.FleetVarSCEPRenewalID) || + fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) { + // Give a few minutes leeway to account for clock skew + variablesUpdatedAt = ptr.Time(time.Now().UTC().Add(-3 * time.Minute)) + break + } + } + + initialFleetVarLoop: + for _, fleetVar := range fleetVars { + switch { + case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): + configured, err := isNDESSCEPConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) || + fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || + fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) || + fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID): + // No extra validation needed for these variables + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) + } + + if digiCertCAs == nil { + digiCertCAs = make(map[string]*fleet.DigiCertCA) + } + configured, err := isDigiCertConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, digiCertCAs, profUUID, target, caName, fleetVar) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking DigiCert configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) + } + + if customSCEPCAs == nil { + customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA) + if groupedCAs != nil { + for _, ca := range groupedCAs.CustomScepProxy { + customSCEPCAs[ca.Name] = &ca + } + } + } + err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error { + _, err := fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC())) + return err + }) + if err != nil { + valid = false + break initialFleetVarLoop + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): + if smallstepCAs == nil { + smallstepCAs = make(map[string]*fleet.SmallstepSCEPProxyCA) + } + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) + } + + configured, err := isSmallstepSCEPConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, smallstepCAs, profUUID, target, caName, + fleetVar) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking Smallstep SCEP configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + default: + // Otherwise, error out since this variable is unknown + detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", + fleetVar) + _, err := fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, detail, variablesUpdatedAt) + if err != nil { + return err + } + valid = false + } + } + if !valid { + // We marked the profile as failed, so we will not do any additional processing on it + delete(targets, profUUID) + continue + } + + // Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles. + // We generate a new temporary profileUUID which is currently only used to install the profile. + // The profileUUID in host_mdm_apple_profiles is still the original profileUUID. + // We also generate a new commandUUID which is used to install the profile via nano_commands table. + if addedTargets == nil { + addedTargets = make(map[string]*fleet.CmdTarget, 1) + } + // We store the timestamp when the challenge was retrieved to know if it has expired. + var managedCertificatePayloads []*fleet.MDMManagedCertificate + // We need to update the profiles of each host with the new command UUID + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.EnrollmentIDs)) + for _, enrollmentID := range target.EnrollmentIDs { + tempProfUUID := uuid.NewString() + // Use the same UUID for command UUID, which will be the primary key for nano_commands + tempCmdUUID := tempProfUUID + profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) + if !ok || profile == nil { // Should never happen + continue + } + // Fetch the host UUID, which may not be the same as the Enrollment ID, from the profile + hostUUID := profile.HostUUID + + // some variables need more information about the host; build a skeleton host and hydrate if we need more info + hostLite := fleet.Host{UUID: hostUUID} + onMismatchedHostCount := func(hostCount int) error { + return ctxerr.Wrap(ctx, ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostLite.UUID, + Status: &fleet.MDMDeliveryFailed, + Detail: fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s.", hostCount, hostLite.UUID), + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }), "could not retrieve host by UUID for profile variable substitution") + } + + profile.CommandUUID = tempCmdUUID + profile.VariablesUpdatedAt = variablesUpdatedAt + + hostContents := contentsStr + failed := false + + fleetVarLoop: + for _, fleetVar := range fleetVars { + var err error + switch { + case fleetVar == string(fleet.FleetVarNDESSCEPChallenge): + if ndesConfig == nil { + if groupedCAs == nil || groupedCAs.NDESSCEP == nil { + logger.ErrorContext(ctx, "missing NDES CA configuration for profile with NDES variables", "host_uuid", hostUUID, "profile_uuid", profUUID) + continue + } + ndesConfig = groupedCAs.NDESSCEP + } + logger.DebugContext(ctx, "fetching NDES challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) + // Insert the SCEP challenge into the profile contents + challenge, err := scepConfig.GetNDESSCEPChallenge(ctx, *ndesConfig) + if err != nil { + detail := scep.NDESChallengeErrorToDetail(err) + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge") + } + failed = true + break fleetVarLoop + } + payload := &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + ChallengeRetrievedAt: ptr.Time(time.Now()), + Type: fleet.CAConfigNDES, + CAName: "NDES", + } + managedCertificatePayloads = append(managedCertificatePayloads, payload) + + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarNDESSCEPChallengeRegexp, hostContents, challenge) + + case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): + // Insert the SCEP URL into the profile contents + hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents) + + case fleetVar == string(fleet.FleetVarSCEPRenewalID): + // Insert the SCEP renewal ID into the SCEP Payload CN or OU + fleetRenewalID := "fleet-" + profUUID + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)): + replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable") + } + if !replacedVariable { + continue + } + hostContents = replacedContents + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): + replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable") + } + if !replacedVariable { + continue + } + hostContents = replacedContents + managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)): + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) + ca, ok := smallstepCAs[caName] + if !ok { + logger.ErrorContext(ctx, "Smallstep SCEP CA not found. "+ + "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) + continue + } + logger.DebugContext(ctx, "fetching Smallstep SCEP challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) + challenge, err := scepConfig.GetSmallstepSCEPChallenge(ctx, *ca) + if err != nil { + detail := fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarSmallstepSCEPChallengePrefix, err.Error()) + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for Smallstep SCEP challenge") + } + failed = true + break fleetVarLoop + } + logger.InfoContext(ctx, "retrieved SCEP challenge from Smallstep", "host_uuid", hostUUID, "profile_uuid", profUUID) + + payload := &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + ChallengeRetrievedAt: ptr.Time(time.Now()), + Type: fleet.CAConfigSmallstep, + CAName: caName, + } + managedCertificatePayloads = append(managedCertificatePayloads, payload) + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable") + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): + // Insert the SCEP URL into the profile contents + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) + proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), SCEPProxyPath, + url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName))) + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable") + } + + case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP): + // FIXME: if this is used together with a CA, and fail inside getFirstIDPEmail, the profile will fail, but not get the correct variablesUpdatedAt var. + email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting IDP email") + } + if !ok { + failed = true + break fleetVarLoop + } + hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email) + + case fleetVar == string(fleet.FleetVarHostHardwareSerial): + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + failed = true + break fleetVarLoop + } + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, hostContents, hostLite.HardwareSerial) + case fleetVar == string(fleet.FleetVarHostPlatform): + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting host platform") + } + if !ok { + failed = true + break fleetVarLoop + } + platform := hostLite.Platform + if platform == "darwin" { + platform = "macos" + } + + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, hostContents, platform) + case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || + fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || + fleetVar == string(fleet.FleetVarHostEndUserIDPFullname): + replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, hostContents, hostUUID, hostIDForUUIDCache, func(errMsg string) error { + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: errMsg, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + return err + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables") + } + if !replacedVariable { + failed = true + break fleetVarLoop + } + + hostContents = replacedContents + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)): + // We will replace the password when we populate the certificate data + + case fleetVar == string(fleet.FleetVarHostUUID): + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostUUIDRegexp, hostContents, hostUUID) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) + ca, ok := digiCertCAs[caName] + if !ok { + logger.ErrorContext(ctx, "Custom DigiCert CA not found. "+ + "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) + continue + } + caCopy := *ca + // Deep copy the UPN slice to prevent cross-host contamination: a + // shallow copy shares the backing array, so in-place substitutions for + // one host would corrupt the cached CA used by subsequent hosts. + caCopy.CertificateUserPrincipalNames = slices.Clone(ca.CertificateUserPrincipalNames) + + // Populate Fleet vars in the CA fields + caVarsCache := make(map[string]string) + + ok, err := replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateCommonName, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateSeatID, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + if len(caCopy.CertificateUserPrincipalNames) > 0 { + for i := range caCopy.CertificateUserPrincipalNames { + ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateUserPrincipalNames[i], onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + } + } + + cert, err := digiCertService.GetCertificate(ctx, caCopy) + if err != nil { + detail := fmt.Sprintf("Couldn't get certificate from DigiCert for %s. %s", caCopy.Name, err) + err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert") + } + failed = true + break fleetVarLoop + } + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents, + base64.StdEncoding.EncodeToString(cert.PfxData)) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data") + } + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password") + } + managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + NotValidBefore: &cert.NotValidBefore, + NotValidAfter: &cert.NotValidAfter, + Type: fleet.CAConfigDigiCert, + CAName: caName, + Serial: &cert.SerialNumber, + }) + + default: + // This was handled in the above switch statement, so we should never reach this case + } + } + if !failed { + addedTargets[tempProfUUID] = &fleet.CmdTarget{ + CmdUUID: tempCmdUUID, + ProfileIdentifier: target.ProfileIdentifier, + EnrollmentIDs: []string{enrollmentID}, + } + profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents) + profilesToUpdate = append(profilesToUpdate, profile) + } + } + // Update profiles with the new command UUID + if len(profilesToUpdate) > 0 { + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return ctxerr.Wrap(ctx, err, "updating host profiles") + } + } + if len(managedCertificatePayloads) != 0 { + // TODO: We could filter out failed profiles, but at the moment we don't, see Windows impl. for how it's done there. + err := ds.BulkUpsertMDMManagedCertificates(ctx, managedCertificatePayloads) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating managed certificates") + } + } + // Remove the parent target, since we will use host-specific targets + delete(targets, profUUID) + } + if len(addedTargets) > 0 { + // Add the new host-specific targets to the original targets map + maps.Copy(targets, addedTargets) + } + return nil +} + +func getFirstIDPEmail(ctx context.Context, ds fleet.Datastore, target *fleet.CmdTarget, hostUUID string) (string, bool, error) { + // Insert the end user email IDP into the profile contents + emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) + if err != nil { + // This is a server error, so we exit. + return "", false, ctxerr.Wrap(ctx, err, "getting host emails") + } + if len(emails) == 0 { + // We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail. + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: fmt.Sprintf("There is no IdP email for this host. "+ + "Fleet couldn't populate $FLEET_VAR_%s. "+ + "[Learn more](https://fleetdm.com/learn-more-about/idp-email)", + fleet.FleetVarHostEndUserEmailIDP), + OperationType: fleet.MDMOperationTypeInstall, + }) + if err != nil { + return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP") + } + return "", false, nil + } + return emails[0], true, nil +} + +func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *fleet.CmdTarget, hostLite fleet.Host, caVarsCache map[string]string, item *string, onMismatchedHostCount func(int) error) (bool, error) { + caFleetVars := variables.Find(*item) + for _, caVar := range caFleetVars { + switch caVar { + case string(fleet.FleetVarHostEndUserEmailIDP): + email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] + if !ok { + var err error + email, ok, err = getFirstIDPEmail(ctx, ds, target, hostLite.UUID) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting IDP email") + } + if !ok { + return false, nil + } + caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email + } + *item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email) + case string(fleet.FleetVarHostHardwareSerial): + hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)] + if !ok { + var err error + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + return false, nil + } + hardwareSerial = hostLite.HardwareSerial + caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hostLite.HardwareSerial + } + *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, *item, hardwareSerial) + case string(fleet.FleetVarHostPlatform): + platform, ok := caVarsCache[string(fleet.FleetVarHostPlatform)] + if !ok { + var err error + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + return false, nil + } + platform = hostLite.Platform + if platform == "darwin" { + platform = "macos" + } + + caVarsCache[string(fleet.FleetVarHostPlatform)] = platform + } + *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, *item, platform) + default: + // We should not reach this since we validated the variables when saving app config + } + } + return true, nil +} + +func isDigiCertConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + existingDigiCertCAs map[string]*fleet.DigiCertCA, profUUID string, target *fleet.CmdTarget, caName string, fleetVar string, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "DigiCert integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if _, ok := existingDigiCertCAs[caName]; ok { + return true, nil + } + configured := false + var digiCertCA *fleet.DigiCertCA + if groupedCAs != nil && len(groupedCAs.DigiCert) > 0 { + for _, ca := range groupedCAs.DigiCert { + if ca.Name == caName { + digiCertCA = &ca + configured = true + break + } + } + } + if !configured || digiCertCA == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) + } + + existingDigiCertCAs[caName] = digiCertCA + return true, nil +} + +func isNDESSCEPConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, userEnrollmentsToHostUUIDsMap map[string]string, profUUID string, target *fleet.CmdTarget, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if groupedCAs == nil || groupedCAs.NDESSCEP == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + "NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.", ptr.Time(time.Now().UTC())) + } + return true, nil +} + +func isSmallstepSCEPConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + existingSmallstepSCEPCAs map[string]*fleet.SmallstepSCEPProxyCA, profUUID string, target *fleet.CmdTarget, caName string, fleetVar string, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Smallstep SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if _, ok := existingSmallstepSCEPCAs[caName]; ok { + return true, nil + } + configured := false + var scepCA *fleet.SmallstepSCEPProxyCA + if groupedCAs != nil && len(groupedCAs.Smallstep) > 0 { + for _, ca := range groupedCAs.Smallstep { + if ca.Name == caName { + scepCA = &ca + configured = true + break + } + } + } + if !configured || scepCA == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) + } + + existingSmallstepSCEPCAs[caName] = scepCA + return true, nil +} + +func getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + enrollmentID, + profUUID string, +) (*fleet.MDMAppleBulkUpsertHostProfilePayload, bool) { + profile, ok := hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] + if !ok { + var hostUUID string + // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. + hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] + if ok { + profile, ok = hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + } + } + return profile, ok +} diff --git a/server/mdm/apple/profile_processor_test.go b/server/mdm/apple/profile_processor_test.go new file mode 100644 index 0000000000..82323f5870 --- /dev/null +++ b/server/mdm/apple/profile_processor_test.go @@ -0,0 +1,999 @@ +package apple_mdm + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mock" + digicert_mock "github.com/fleetdm/fleet/v4/server/mock/digicert" + scep_mock "github.com/fleetdm/fleet/v4/server/mock/scep" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreprocessProfileContents(t *testing.T) { + ctx := context.Background() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + // No-op + svc := scep.NewSCEPConfigService(logger, nil) + digiCertService := digicert.NewService(digicert.WithLogger(logger)) + err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, nil, nil, nil, nil, nil) + require.NoError(t, err) + + hostUUID := "host-1" + cmdUUID := "cmd-1" + var targets map[string]*fleet.CmdTarget + populateTargets := func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID}}, + } + } + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, 1) + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: "p1"}] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: "p1", + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + } + userEnrollmentsToHostUUIDsMap := make(map[string]string) + populateTargets() + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + } + + var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + require.Len(t, payload, 1) + updatedPayload = payload[0] + for _, p := range payload { + require.NotNil(t, p.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) + assert.Equal(t, cmdUUID, p.CommandUUID) + assert.Equal(t, hostUUID, p.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + assert.Equal(t, fleet.PayloadScopeSystem, p.Scope) + } + return nil + } + // Can't use NDES SCEP proxy with free tier + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "Premium license") + assert.Empty(t, targets) + + // Can't use NDES SCEP proxy without it being configured + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, &fleet.GroupedCertificateAuthorities{}) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "not configured") + assert.NotNil(t, updatedPayload.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Unknown variable + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_BOZO"), + } + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") + assert.Empty(t, targets) + + ndesPassword := "test-password" + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, + assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetNDESPassword: {Value: []byte(ndesPassword)}, + }, nil + } + + ds.BulkUpsertMDMAppleHostProfilesFunc = nil + var updatedProfile *fleet.HostMDMAppleProfile + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) + assert.Equal(t, hostUUID, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + assert.Empty(t, payload) + return nil + } + + adminUrl := "https://example.com" + username := "admin" + password := "test-password" + groupedCAs := &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{ + URL: "https://test-example.com", + AdminURL: adminUrl, + Username: username, + Password: password, + }, + } + + // Could not get NDES SCEP challenge + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPChallenge), + } + scepConfig := &scep_mock.SCEPConfigService{} + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESInvalidError("NDES error") + } + updatedProfile = nil + populateTargets() + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "update credentials") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Password cache full + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESPasswordCacheFullError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "cached passwords") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Insufficient permissions + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESInsufficientPermissionsError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "does not have sufficient permissions") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Other NDES challenge error + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", errors.New("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.NotContains(t, updatedProfile.Detail, "cached passwords") + assert.NotContains(t, updatedProfile.Detail, "update credentials") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // NDES challenge + challenge := "ndes-challenge" + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return challenge, nil + } + updatedProfile = nil + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + for _, p := range payload { + assert.NotEqual(t, cmdUUID, p.CommandUUID) + } + return nil + } + populateTargets() + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + require.Len(t, payload, 1) + assert.NotNil(t, payload[0].ChallengeRetrievedAt) + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, challenge, string(profileContents[profUUID])) + } + + // NDES SCEP proxy URL + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + } + expectedURL := "https://test.example.com" + SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, "p1")) + updatedProfile = nil + populateTargets() + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + assert.Empty(t, payload) + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, expectedURL, string(profileContents[profUUID])) + } + + // No IdP email found + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return nil, nil + } + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP) + assert.Contains(t, updatedProfile.Detail, "no IdP email") + assert.Empty(t, targets) + + // IdP email found + email := "user@example.com" + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return []string{email}, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, email, string(profileContents[profUUID])) + } + + // Hardware serial + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + assert.Equal(t, []string{hostUUID}, uuids) + return []*fleet.Host{ + {HardwareSerial: "serial1"}, + }, nil + } + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostHardwareSerial), + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, "serial1", string(profileContents[profUUID])) + } + + // Hardware serial fail + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + assert.Equal(t, []string{hostUUID}, uuids) + return nil, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") + assert.Empty(t, targets) + + // multiple profiles, multiple hosts + populateTargets = func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID, "host-2"}}, // fails + "p2": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile2", EnrollmentIDs: []string{hostUUID, "host-3"}}, // works + "p3": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile3", EnrollmentIDs: []string{hostUUID, "host-4"}}, // no variables + } + } + populateTargets() + groupedCAs.NDESSCEP = nil + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + "p2": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), + "p3": []byte("no variables"), + } + addProfileToInstall := func(hostUUID, profileUUID, profileIdentifier string) { + hostProfilesToInstallMap[fleet.HostProfileUUID{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + }] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: profileUUID, + ProfileIdentifier: profileIdentifier, + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + } + } + addProfileToInstall(hostUUID, "p1", "com.add.profile") + addProfileToInstall("host-2", "p1", "com.add.profile") + addProfileToInstall(hostUUID, "p2", "com.add.profile2") + addProfileToInstall("host-3", "p2", "com.add.profile2") + addProfileToInstall(hostUUID, "p3", "com.add.profile3") + addProfileToInstall("host-4", "p3", "com.add.profile3") + expectedHostsToFail := []string{hostUUID, "host-2", "host-3"} + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.NotEqual(t, cmdUUID, updatedProfile.CommandUUID) + assert.Contains(t, expectedHostsToFail, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + for _, p := range payload { + require.NotNil(t, p.Status) + if fleet.MDMDeliveryFailed == *p.Status { + assert.Equal(t, cmdUUID, p.CommandUUID) + } else { + assert.NotEqual(t, cmdUUID, p.CommandUUID) + } + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + } + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotEmpty(t, targets) + assert.Len(t, targets, 3) + assert.Nil(t, targets["p1"]) // error + assert.Nil(t, targets["p2"]) // renamed + assert.NotNil(t, targets["p3"]) // normal, no variables + for profUUID, target := range targets { + assert.Contains(t, [][]string{{hostUUID}, {"host-3"}, {hostUUID, "host-4"}}, target.EnrollmentIDs) + if profUUID == "p3" { + assert.Equal(t, cmdUUID, target.CmdUUID) + } else { + assert.NotEqual(t, cmdUUID, target.CmdUUID) + } + assert.Contains(t, []string{email, "no variables"}, string(profileContents[profUUID])) + } +} + +// TestPreprocessProfileContentsDigiCertUPNMultiHost is a regression test for +// https://github.com/fleetdm/fleet/issues/39324. When the same DigiCert CA is +// used for multiple hosts in a single batch, the CertificateUserPrincipalNames +// slice was shared via a shallow copy. In-place variable substitution for Host 1 +// corrupted the cached CA entry, so Host 2 and later hosts received Host 1's +// substituted UPN instead of their own. +func TestPreprocessProfileContentsDigiCertUPNIsUniqueForMultipleHosts(t *testing.T) { + ctx := context.Background() + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + logger := slog.New(slog.DiscardHandler) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + svc := scep.NewSCEPConfigService(logger, nil) + + const caName = "myCA" + const host1UUID = "host-uuid-1" + const host2UUID = "host-uuid-2" + const host1Serial = "SERIAL-AAA" + const host2Serial = "SERIAL-BBB" + const cmdUUID = "cmd-uuid-1" + + // Track which UPNs were sent to GetCertificate per host. + upnByHostUUID := make(map[string]string) + + mockDigiCert := &digicert_mock.Service{} + mockDigiCert.GetCertificateFunc = func(ctx context.Context, config fleet.DigiCertCA) (*fleet.DigiCertCertificate, error) { + // The UPN in config should have been substituted with each host's own + // hardware serial. Record it so we can assert correctness later. + require.Len(t, config.CertificateUserPrincipalNames, 1) + upn := config.CertificateUserPrincipalNames[0] + // Determine which host this call is for by checking which serial is in the UPN. + switch { + case strings.Contains(upn, host1Serial): + upnByHostUUID[host1UUID] = upn + case strings.Contains(upn, host2Serial): + upnByHostUUID[host2UUID] = upn + default: + t.Errorf("GetCertificate called with unexpected UPN %q", upn) + } + now := time.Now() + return &fleet.DigiCertCertificate{ + PfxData: []byte("fake-pfx"), + Password: "fake-password", + NotValidBefore: now, + NotValidAfter: now.Add(365 * 24 * time.Hour), + SerialNumber: upn, // reuse upn as serial for easy tracing + }, nil + } + + // Both hosts share the same profile UUID but are separate enrollment IDs. + targets := map[string]*fleet.CmdTarget{ + "p1": { + CmdUUID: cmdUUID, + ProfileIdentifier: "com.apple.security.pkcs12", + EnrollmentIDs: []string{host1UUID, host2UUID}, + }, + } + + pending := fleet.MDMDeliveryPending + hostProfilesToInstallMap := map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ + {HostUUID: host1UUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.apple.security.pkcs12", + HostUUID: host1UUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &pending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + }, + {HostUUID: host2UUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.apple.security.pkcs12", + HostUUID: host2UUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &pending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + }, + } + + // Profile contains both DigiCert fleet variables. + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix) + caName + " $FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix) + caName), + } + + // DigiCert CA whose UPN contains the hardware serial variable. + groupedCAs := &fleet.GroupedCertificateAuthorities{ + DigiCert: []fleet.DigiCertCA{ + { + Name: caName, + URL: "https://digicert.example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"$FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "@example.com"}, + CertificateSeatID: "seat_id", + }, + }, + } + + // Mock datastore: return each host's own hardware serial when queried. + hostsByUUID := map[string]*fleet.Host{ + host1UUID: {ID: 1, UUID: host1UUID, HardwareSerial: host1Serial, Platform: "darwin"}, + host2UUID: {ID: 2, UUID: host2UUID, HardwareSerial: host2Serial, Platform: "darwin"}, + } + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + var hosts []*fleet.Host + for _, uuid := range uuids { + if h, ok := hostsByUUID[uuid]; ok { + hosts = append(hosts, h) + } + } + return hosts, nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + return nil + } + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + return nil + } + + err := preprocessProfileContents(ctx, appCfg, ds, svc, mockDigiCert, logger, targets, profileContents, hostProfilesToInstallMap, make(map[string]string), groupedCAs) + require.NoError(t, err) + + // Both hosts must have received GetCertificate calls with their own serial. + require.True(t, mockDigiCert.GetCertificateFuncInvoked, "GetCertificate was never called") + require.Contains(t, upnByHostUUID, host1UUID, "GetCertificate was not called for host 1") + require.Contains(t, upnByHostUUID, host2UUID, "GetCertificate was not called for host 2") + assert.Equal(t, host1Serial+"@example.com", upnByHostUUID[host1UUID], "host 1 UPN should contain its own serial") + assert.Equal(t, host2Serial+"@example.com", upnByHostUUID[host2UUID], "host 2 UPN should contain its own serial") +} + +func TestPreprocessProfileContentsEndUserIDP(t *testing.T) { + ctx := context.Background() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + svc := scep.NewSCEPConfigService(logger, nil) + digiCertService := digicert.NewService(digicert.WithLogger(logger)) + + hostUUID := "host-1" + cmdUUID := "cmd-1" + var targets map[string]*fleet.CmdTarget + // this is a func to re-create it each time because calling the preprocess function modifies this + populateTargets := func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID}}, + } + } + hostProfilesToInstallMap := map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ + {HostUUID: hostUUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + }, + } + + userEnrollmentsToHostUUIDsMap := make(map[string]string) + + var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload + var expectedStatus fleet.MDMDeliveryStatus + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + require.Len(t, payload, 1) + updatedPayload = payload[0] + require.NotNil(t, updatedPayload.Status) + assert.Equal(t, expectedStatus, *updatedPayload.Status) + // cmdUUID was replaced by a new unique command on success + assert.NotEqual(t, cmdUUID, updatedPayload.CommandUUID) + assert.Equal(t, hostUUID, updatedPayload.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedPayload.OperationType) + return nil + } + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, idents []string) ([]uint, error) { + require.Len(t, idents, 1) + require.Equal(t, hostUUID, idents[0]) + return []uint{1}, nil + } + var updatedProfile *fleet.HostMDMAppleProfile + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, profile.Status) + assert.Equal(t, expectedStatus, *profile.Status) + return nil + } + ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { + return []*fleet.CertificateAuthority{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + + cases := []struct { + desc string + profileContent string + expectedStatus fleet.MDMDeliveryStatus + setup func() + assert func(output string) + }{ + { + desc: "username only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.com", output) + }, + }, + { + desc: "username local part only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1", output) + }, + }, + { + desc: "groups only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "a,b", output) + }, + }, + { + desc: "multiple times username only scim", + profileContent: strings.Repeat("${FLEET_VAR_"+string(fleet.FleetVarHostEndUserIDPUsername)+"}", 3), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.comuser1@example.comuser1@example.com", output) + }, + }, + { + desc: "all 3 vars with scim", + profileContent: "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups) + "}", + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.comuser1a,b", output) + }, + }, + { + desc: "username no scim, with idp", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "idp@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "idp@example.com", output) + }, + }, + { + desc: "username scim and idp", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.com", output) + }, + }, + { + desc: "username, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.") + }, + }, + { + desc: "username local part, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART.") + }, + }, + { + desc: "groups, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") + }, + }, + { + desc: "department, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") + }, + }, + { + desc: "groups with user groups, user has no groups", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") + }, + }, + { + desc: "profile with department, user has department", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Department: ptr.String("Engineering"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "Engineering", output) + }, + }, + { + desc: "profile with department, user has no department", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") + }, + }, + { + desc: "profile with full name, user has full name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + GivenName: ptr.String("First"), + FamilyName: ptr.String("Last"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "First Last", output) + }, + }, + { + desc: "profile with full name, user only has given name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + GivenName: ptr.String("First"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "First", output) + }, + }, + { + desc: "profile with full name, user only has family name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + FamilyName: ptr.String("Last"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "Last", output) + }, + }, + { + desc: "profile with full name, user has no full name value", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Contains(t, updatedProfile.Detail, fmt.Sprintf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname)) + assert.Len(t, targets, 0) + }, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + c.setup() + + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte(c.profileContent), + } + populateTargets() + expectedStatus = c.expectedStatus + updatedPayload = nil + updatedProfile = nil + + err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + var output string + if expectedStatus == fleet.MDMDeliveryFailed { + require.Nil(t, updatedPayload) + require.NotNil(t, updatedProfile) + } else { + require.NotNil(t, updatedPayload) + require.Nil(t, updatedProfile) + output = string(profileContents[updatedPayload.CommandUUID]) + } + + c.assert(output) + }) + } +} diff --git a/server/ptr/ptr.go b/server/ptr/ptr.go index 22b7734c27..ecc564c9f1 100644 --- a/server/ptr/ptr.go +++ b/server/ptr/ptr.go @@ -8,7 +8,9 @@ import ( // String returns a pointer to the provided string. func String(x string) *string { - return &x + val := new(string) + *val = x + return val } // Int returns a pointer to the provided int. @@ -49,7 +51,9 @@ func StringPtr(x string) **string { // Time returns a pointer to the provided time.Time. func Time(x time.Time) *time.Time { - return &x + val := new(time.Time) + *val = x + return val } // TimePtr returns a *time.Time Pointer (**time.Time) for the provided time. diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 67615dfe26..538a6925ee 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -22,11 +22,8 @@ import ( "sort" "strconv" "strings" - "sync" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/file" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -36,7 +33,6 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -54,7 +50,6 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" - "github.com/fleetdm/fleet/v4/server/mdm/profiles" "github.com/fleetdm/fleet/v4/server/platform/endpointer" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/variables" @@ -69,24 +64,13 @@ const ( limit10KiB = 10 * 1024 ) -var ( - fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) - fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID)) - fleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostUUID)) - - // TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported, - // but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's) - fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{ - fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP, - fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, - fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID, - fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform, - } -) - -type hostProfileUUID struct { - HostUUID string - ProfileUUID string +// TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported, +// but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's) +var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{ + fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP, + fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID, + fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform, } type getMDMAppleCommandResultsRequest struct { @@ -646,7 +630,7 @@ func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPV } foundCAs = append(foundCAs, ca) } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } if len(foundCAs) < len(customSCEPVars.CAs()) { @@ -699,7 +683,7 @@ func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVars } foundCAs = append(foundCAs, ca) } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } if len(foundCAs) < len(smallstepVars.CAs()) { @@ -821,7 +805,7 @@ func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error { return err } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } @@ -4902,16 +4886,6 @@ func ReconcileAppleDeclarations( return nil } -// install/removeTargets are maps from profileUUID -> command uuid and host -// UUIDs as the underlying MDM services are optimized to send one command to -// multiple hosts at the same time. Note that the same command uuid is used -// for all hosts in a given install/remove target operation. -type cmdTarget struct { - cmdUUID string - profIdent string - enrollmentIDs []string -} - // Number of hours to wait for a user enrollment to exist for a host after its // device enrollment. After that duration, the user-scoped profiles will be // delivered to the device-channel. @@ -5024,9 +4998,9 @@ func ReconcileAppleProfiles( hostProfilesToCleanup := []*fleet.MDMAppleProfilePayload{} // Index host profiles to install by host and profile UUID, for easier bulk error processing - hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall)) + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall)) - installTargets, removeTargets := make(map[string]*cmdTarget), make(map[string]*cmdTarget) + installTargets, removeTargets := make(map[string]*fleet.CmdTarget), make(map[string]*fleet.CmdTarget) for _, p := range toInstall { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { // if the profile was in any other status than `failed` @@ -5049,7 +5023,7 @@ func ReconcileAppleProfiles( Scope: pp.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile continue } } @@ -5073,7 +5047,7 @@ func ReconcileAppleProfiles( Scope: p.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile continue } @@ -5081,9 +5055,9 @@ func ReconcileAppleProfiles( target := installTargets[p.ProfileUUID] if target == nil { - target = &cmdTarget{ - cmdUUID: uuid.New().String(), - profIdent: p.ProfileIdentifier, + target = &fleet.CmdTarget{ + CmdUUID: uuid.New().String(), + ProfileIdentifier: p.ProfileIdentifier, } installTargets[p.ProfileUUID] = target } @@ -5120,9 +5094,9 @@ func ReconcileAppleProfiles( continue } - target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + target.EnrollmentIDs = append(target.EnrollmentIDs, userEnrollmentID) } else { - target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) + target.EnrollmentIDs = append(target.EnrollmentIDs, p.HostUUID) } toGetContents[p.ProfileUUID] = true @@ -5131,7 +5105,7 @@ func ReconcileAppleProfiles( HostUUID: p.HostUUID, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending, - CommandUUID: target.cmdUUID, + CommandUUID: target.CmdUUID, ProfileIdentifier: p.ProfileIdentifier, ProfileName: p.ProfileName, Checksum: p.Checksum, @@ -5139,7 +5113,7 @@ func ReconcileAppleProfiles( Scope: p.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile } for _, p := range toRemove { @@ -5165,9 +5139,9 @@ func ReconcileAppleProfiles( target := removeTargets[p.ProfileUUID] if target == nil { - target = &cmdTarget{ - cmdUUID: uuid.New().String(), - profIdent: p.ProfileIdentifier, + target = &fleet.CmdTarget{ + CmdUUID: uuid.New().String(), + ProfileIdentifier: p.ProfileIdentifier, } removeTargets[p.ProfileUUID] = target } @@ -5184,9 +5158,9 @@ func ReconcileAppleProfiles( continue } - target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + target.EnrollmentIDs = append(target.EnrollmentIDs, userEnrollmentID) } else { - target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) + target.EnrollmentIDs = append(target.EnrollmentIDs, p.HostUUID) } hostProfiles = append(hostProfiles, &fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -5194,7 +5168,7 @@ func ReconcileAppleProfiles( HostUUID: p.HostUUID, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending, - CommandUUID: target.cmdUUID, + CommandUUID: target.CmdUUID, ProfileIdentifier: p.ProfileIdentifier, ProfileName: p.ProfileName, Checksum: p.Checksum, @@ -5241,111 +5215,49 @@ func ReconcileAppleProfiles( return ctxerr.Wrap(ctx, err, "updating host profiles") } - // Grab the contents of all the profiles we need to install - profileUUIDs := make([]string, 0, len(toGetContents)) - for pUUID := range toGetContents { - profileUUIDs = append(profileUUIDs, pUUID) - } - profileContents, err := ds.GetMDMAppleProfilesContents(ctx, profileUUIDs) + enqueueResult, err := apple_mdm.ProcessAndEnqueueProfiles( + ctx, + ds, + logger, + appConfig, + commander, + installTargets, + removeTargets, + hostProfilesToInstallMap, + userEnrollmentsToHostUUIDsMap, + ) if err != nil { - return ctxerr.Wrap(ctx, err, "get profile contents") - } - - groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") - } - - // Insert variables into profile contents of install targets. Variables may be host-specific. - err = preprocessProfileContents(ctx, appConfig, ds, - eeservice.NewSCEPConfigService(logger, nil), - digicert.NewService(digicert.WithLogger(logger)), - logger, installTargets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - if err != nil { - return err - } - - // Find the profiles containing secret variables. - profilesWithSecrets, err := findProfilesWithSecrets(ctx, logger, installTargets, profileContents) - if err != nil { - return err - } - - type remoteResult struct { - Err error - CmdUUID string - } - - // Send the install/remove commands for each profile. - var wgProd, wgCons sync.WaitGroup - ch := make(chan remoteResult) - - execCmd := func(profUUID string, target *cmdTarget, op fleet.MDMOperationType) { - defer wgProd.Done() - - var err error - switch op { - case fleet.MDMOperationTypeInstall: - if _, ok := profilesWithSecrets[profUUID]; ok { - err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID) - } else { - err = commander.InstallProfile(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID) - } - case fleet.MDMOperationTypeRemove: - err = commander.RemoveProfile(ctx, target.enrollmentIDs, target.profIdent, target.cmdUUID) - } - - var e *apple_mdm.APNSDeliveryError - switch { - case errors.As(err, &e): - logger.DebugContext(ctx, "sending push notifications, profiles still enqueued", "details", err) - case err != nil: - logger.ErrorContext(ctx, fmt.Sprintf("enqueue command to %s profiles", op), "details", err) - ch <- remoteResult{err, target.cmdUUID} - } - } - for profUUID, target := range installTargets { - wgProd.Add(1) - go execCmd(profUUID, target, fleet.MDMOperationTypeInstall) - } - for profUUID, target := range removeTargets { - wgProd.Add(1) - go execCmd(profUUID, target, fleet.MDMOperationTypeRemove) - } - - // index the host profiles by cmdUUID, for ease of error processing in the - // consumer goroutine below. - hostProfsByCmdUUID := make(map[string][]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(installTargets)+len(removeTargets)) - for _, hp := range hostProfiles { - hostProfsByCmdUUID[hp.CommandUUID] = append(hostProfsByCmdUUID[hp.CommandUUID], hp) - } - - // Grab all the failed deliveries and update the status so they're picked up - // again in the next run. - // - // Note that if the APNs push failed we won't try again, as the command was - // successfully enqueued, this is only to account for internal errors like DB - // failures. - failed := []*fleet.MDMAppleBulkUpsertHostProfilePayload{} - wgCons.Add(1) - go func() { - defer wgCons.Done() - - for resp := range ch { - hostProfs := hostProfsByCmdUUID[resp.CmdUUID] - for _, hp := range hostProfs { - // clear the command as it failed to enqueue, will need to emit a new command - hp.CommandUUID = "" - // set status to nil so it is retried on the next cron run + // revert the status of all pending profiles to null so they get picked up again in the next cron run. + // this is fine to do as if we errored out, we only do that before sending a single command + for _, hp := range hostProfiles { + if hp.Status != nil && *hp.Status == fleet.MDMDeliveryPending { hp.Status = nil - failed = append(failed, hp) + hp.CommandUUID = "" } } - }() + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, hostProfiles); err != nil { + return ctxerr.Wrap(ctx, err, "reverting host profiles after failed enqueue") + } + return ctxerr.Wrap(ctx, err, "processing and enqueuing profiles") + } - wgProd.Wait() - close(ch) // done sending at this point, this triggers end of for loop in consumer - wgCons.Wait() + // Build cmdUUID→hostProfiles index AFTER preprocessing has rewritten CommandUUIDs. + hostProfsByCmdUUID := make(map[string][]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(hostProfiles)) + for _, hp := range hostProfiles { + if hp.CommandUUID != "" { + hostProfsByCmdUUID[hp.CommandUUID] = append(hostProfsByCmdUUID[hp.CommandUUID], hp) + } + } + + // Revert failed deliveries so they're retried on the next cron run. + var failed []*fleet.MDMAppleBulkUpsertHostProfilePayload + for cmdUUID := range enqueueResult.FailedCmdUUIDs { + for _, hp := range hostProfsByCmdUUID[cmdUUID] { + hp.CommandUUID = "" + hp.Status = nil + failed = append(failed, hp) + } + } if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, failed); err != nil { return ctxerr.Wrap(ctx, err, "reverting status of failed profiles") @@ -5354,747 +5266,6 @@ func ReconcileAppleProfiles( return nil } -func findProfilesWithSecrets( - ctx context.Context, - logger *slog.Logger, - installTargets map[string]*cmdTarget, - profileContents map[string]mobileconfig.Mobileconfig, -) (map[string]struct{}, error) { - profilesWithSecrets := make(map[string]struct{}) - for profUUID := range installTargets { - p, ok := profileContents[profUUID] - if !ok { // Should never happen - logger.ErrorContext(ctx, "profile content not found in ReconcileAppleProfiles", "profile_uuid", profUUID) - continue - } - profileStr := string(p) - vars := fleet.ContainsPrefixVars(profileStr, fleet.ServerSecretPrefix) - if len(vars) > 0 { - profilesWithSecrets[profUUID] = struct{}{} - } - } - return profilesWithSecrets, nil -} - -func preprocessProfileContents( - ctx context.Context, - appConfig *fleet.AppConfig, - ds fleet.Datastore, - scepConfig fleet.SCEPConfigService, - digiCertService fleet.DigiCertService, - logger *slog.Logger, - targets map[string]*cmdTarget, - profileContents map[string]mobileconfig.Mobileconfig, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - groupedCAs *fleet.GroupedCertificateAuthorities, -) error { - // This method replaces Fleet variables ($FLEET_VAR_) in the profile - // contents, generating a unique profile for each host. For a 2KB profile and - // 30K hosts, this method may generate ~60MB of profile data in memory. - - var ( - // Copy of NDES SCEP config which will contain unencrypted password, if needed - ndesConfig *fleet.NDESSCEPProxyCA - digiCertCAs map[string]*fleet.DigiCertCA - customSCEPCAs map[string]*fleet.CustomSCEPProxyCA - smallstepCAs map[string]*fleet.SmallstepSCEPProxyCA - ) - - // this is used to cache the host ID corresponding to the UUID, so we don't - // need to look it up more than once per host. - hostIDForUUIDCache := make(map[string]uint) - - var addedTargets map[string]*cmdTarget - for profUUID, target := range targets { - contents, ok := profileContents[profUUID] - if !ok { - // This should never happen - continue - } - - // Check if Fleet variables are present. - contentsStr := string(contents) - fleetVars := variables.Find(contentsStr) - if len(fleetVars) == 0 { - continue - } - - var variablesUpdatedAt *time.Time - - // Do common validation that applies to all hosts in the target - valid := true - // Check if there are any CA variables first so that if a non-CA variable causes - // preprocessing to fail, we still set the variablesUpdatedAt timestamp so that - // validation works as expected - // In the future we should expand variablesUpdatedAt logic to include non-CA variables as - // well - for _, fleetVar := range fleetVars { - if fleetVar == string(fleet.FleetVarSCEPRenewalID) || - fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) { - // Give a few minutes leeway to account for clock skew - variablesUpdatedAt = ptr.Time(time.Now().UTC().Add(-3 * time.Minute)) - break - } - } - - initialFleetVarLoop: - for _, fleetVar := range fleetVars { - switch { - case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): - configured, err := isNDESSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) || - fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || - fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) || - fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID): - // No extra validation needed for these variables - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) - } - if digiCertCAs == nil { - digiCertCAs = make(map[string]*fleet.DigiCertCA) - } - configured, err := isDigiCertConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, digiCertCAs, profUUID, target, caName, fleetVar) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking DigiCert configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) - } - if customSCEPCAs == nil { - customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA) - for _, ca := range groupedCAs.CustomScepProxy { - customSCEPCAs[ca.Name] = &ca - } - } - err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error { - _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC())) - return err - }) - if err != nil { - valid = false - break initialFleetVarLoop - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): - if smallstepCAs == nil { - smallstepCAs = make(map[string]*fleet.SmallstepSCEPProxyCA) - } - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) - } - configured, err := isSmallstepSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, smallstepCAs, profUUID, target, caName, - fleetVar) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking Smallstep SCEP configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - default: - // Otherwise, error out since this variable is unknown - detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", - fleetVar) - _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, detail, variablesUpdatedAt) - if err != nil { - return err - } - valid = false - } - } - if !valid { - // We marked the profile as failed, so we will not do any additional processing on it - delete(targets, profUUID) - continue - } - - // Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles. - // We generate a new temporary profileUUID which is currently only used to install the profile. - // The profileUUID in host_mdm_apple_profiles is still the original profileUUID. - // We also generate a new commandUUID which is used to install the profile via nano_commands table. - if addedTargets == nil { - addedTargets = make(map[string]*cmdTarget, 1) - } - // We store the timestamp when the challenge was retrieved to know if it has expired. - var managedCertificatePayloads []*fleet.MDMManagedCertificate - // We need to update the profiles of each host with the new command UUID - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs)) - for _, enrollmentID := range target.enrollmentIDs { - tempProfUUID := uuid.NewString() - // Use the same UUID for command UUID, which will be the primary key for nano_commands - tempCmdUUID := tempProfUUID - profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) - if !ok { // Should never happen - continue - } - // Fetch the host UUID, which may not be the same as the Enrollment ID, from the profile - hostUUID := profile.HostUUID - - // some variables need more information about the host; build a skeleton host and hydrate if we need more info - hostLite := fleet.Host{UUID: hostUUID} - onMismatchedHostCount := func(hostCount int) error { - return ctxerr.Wrap(ctx, ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostLite.UUID, - Status: &fleet.MDMDeliveryFailed, - Detail: fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s.", hostCount, hostLite.UUID), - OperationType: fleet.MDMOperationTypeInstall, - }), "could not retrieve host by UUID for profile variable substitution") - } - - profile.CommandUUID = tempCmdUUID - profile.VariablesUpdatedAt = variablesUpdatedAt - - hostContents := contentsStr - failed := false - - fleetVarLoop: - for _, fleetVar := range fleetVars { - var err error - switch { - case fleetVar == string(fleet.FleetVarNDESSCEPChallenge): - if ndesConfig == nil { - ndesConfig = groupedCAs.NDESSCEP - } - logger.DebugContext(ctx, "fetching NDES challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) - // Insert the SCEP challenge into the profile contents - challenge, err := scepConfig.GetNDESSCEPChallenge(ctx, *ndesConfig) - if err != nil { - detail := ndesChallengeErrorToDetail(err) - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge") - } - failed = true - break fleetVarLoop - } - payload := &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - ChallengeRetrievedAt: ptr.Time(time.Now()), - Type: fleet.CAConfigNDES, - CAName: "NDES", - } - managedCertificatePayloads = append(managedCertificatePayloads, payload) - - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarNDESSCEPChallengeRegexp, hostContents, challenge) - - case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): - // Insert the SCEP URL into the profile contents - hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents) - - case fleetVar == string(fleet.FleetVarSCEPRenewalID): - // Insert the SCEP renewal ID into the SCEP Payload CN or OU - fleetRenewalID := "fleet-" + profUUID - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)): - replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable") - } - if !replacedVariable { - continue - } - hostContents = replacedContents - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): - replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable") - } - if !replacedVariable { - continue - } - hostContents = replacedContents - managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)): - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) - ca, ok := smallstepCAs[caName] - if !ok { - logger.ErrorContext(ctx, "Smallstep SCEP CA not found. "+ - "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) - continue - } - logger.DebugContext(ctx, "fetching Smallstep SCEP challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) - challenge, err := scepConfig.GetSmallstepSCEPChallenge(ctx, *ca) - if err != nil { - detail := fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarSmallstepSCEPChallengePrefix, err.Error()) - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for Smallstep SCEP challenge") - } - failed = true - break fleetVarLoop - } - logger.InfoContext(ctx, "retrieved SCEP challenge from Smallstep", "host_uuid", hostUUID, "profile_uuid", profUUID) - - payload := &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - ChallengeRetrievedAt: ptr.Time(time.Now()), - Type: fleet.CAConfigSmallstep, - CAName: caName, - } - managedCertificatePayloads = append(managedCertificatePayloads, payload) - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable") - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): - // Insert the SCEP URL into the profile contents - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) - proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath, - url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName))) - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable") - } - - case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP): - email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting IDP email") - } - if !ok { - failed = true - break fleetVarLoop - } - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email) - - case fleetVar == string(fleet.FleetVarHostHardwareSerial): - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - failed = true - break fleetVarLoop - } - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, hostContents, hostLite.HardwareSerial) - case fleetVar == string(fleet.FleetVarHostPlatform): - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting host platform") - } - if !ok { - failed = true - break fleetVarLoop - } - platform := hostLite.Platform - if platform == "darwin" { - platform = "macos" - } - - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, hostContents, platform) - case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || - fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || - fleetVar == string(fleet.FleetVarHostEndUserIDPFullname): - replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, hostContents, hostUUID, hostIDForUUIDCache, func(errMsg string) error { - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: errMsg, - OperationType: fleet.MDMOperationTypeInstall, - }) - return err - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables") - } - if !replacedVariable { - failed = true - break fleetVarLoop - } - - hostContents = replacedContents - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)): - // We will replace the password when we populate the certificate data - - case fleetVar == string(fleet.FleetVarHostUUID): - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostUUIDRegexp, hostContents, hostUUID) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) - ca, ok := digiCertCAs[caName] - if !ok { - logger.ErrorContext(ctx, "Custom DigiCert CA not found. "+ - "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) - continue - } - caCopy := *ca - // Deep copy the UPN slice to prevent cross-host contamination: a - // shallow copy shares the backing array, so in-place substitutions for - // one host would corrupt the cached CA used by subsequent hosts. - caCopy.CertificateUserPrincipalNames = slices.Clone(ca.CertificateUserPrincipalNames) - - // Populate Fleet vars in the CA fields - caVarsCache := make(map[string]string) - - ok, err := replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateCommonName, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateSeatID, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - if len(caCopy.CertificateUserPrincipalNames) > 0 { - for i := range caCopy.CertificateUserPrincipalNames { - ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateUserPrincipalNames[i], onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - } - } - - cert, err := digiCertService.GetCertificate(ctx, caCopy) - if err != nil { - detail := fmt.Sprintf("Couldn't get certificate from DigiCert for %s. %s", caCopy.Name, err) - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert") - } - failed = true - break fleetVarLoop - } - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents, - base64.StdEncoding.EncodeToString(cert.PfxData)) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data") - } - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password") - } - managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - NotValidBefore: &cert.NotValidBefore, - NotValidAfter: &cert.NotValidAfter, - Type: fleet.CAConfigDigiCert, - CAName: caName, - Serial: &cert.SerialNumber, - }) - - default: - // This was handled in the above switch statement, so we should never reach this case - } - } - if !failed { - addedTargets[tempProfUUID] = &cmdTarget{ - cmdUUID: tempCmdUUID, - profIdent: target.profIdent, - enrollmentIDs: []string{enrollmentID}, - } - profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents) - profilesToUpdate = append(profilesToUpdate, profile) - } - } - // Update profiles with the new command UUID - if len(profilesToUpdate) > 0 { - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return ctxerr.Wrap(ctx, err, "updating host profiles") - } - } - if len(managedCertificatePayloads) != 0 { - // TODO: We could filter out failed profiles, but at the moment we don't, see Windows impl. for how it's done there. - err := ds.BulkUpsertMDMManagedCertificates(ctx, managedCertificatePayloads) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating managed certificates") - } - } - // Remove the parent target, since we will use host-specific targets - delete(targets, profUUID) - } - if len(addedTargets) > 0 { - // Add the new host-specific targets to the original targets map - for profUUID, target := range addedTargets { - targets[profUUID] = target - } - } - return nil -} - -func getFirstIDPEmail(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) { - // Insert the end user email IDP into the profile contents - emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) - if err != nil { - // This is a server error, so we exit. - return "", false, ctxerr.Wrap(ctx, err, "getting host emails") - } - if len(emails) == 0 { - // We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail. - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: fmt.Sprintf("There is no IdP email for this host. "+ - "Fleet couldn't populate $FLEET_VAR_%s. "+ - "[Learn more](https://fleetdm.com/learn-more-about/idp-email)", - fleet.FleetVarHostEndUserEmailIDP), - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP") - } - return "", false, nil - } - return emails[0], true, nil -} - -func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostLite fleet.Host, caVarsCache map[string]string, item *string, onMismatchedHostCount func(int) error) (bool, error) { - caFleetVars := variables.Find(*item) - for _, caVar := range caFleetVars { - switch caVar { - case string(fleet.FleetVarHostEndUserEmailIDP): - email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] - if !ok { - var err error - email, ok, err = getFirstIDPEmail(ctx, ds, target, hostLite.UUID) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting IDP email") - } - if !ok { - return false, nil - } - caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email - } - *item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email) - case string(fleet.FleetVarHostHardwareSerial): - hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)] - if !ok { - var err error - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - return false, nil - } - hardwareSerial = hostLite.HardwareSerial - caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hostLite.HardwareSerial - } - *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, *item, hardwareSerial) - case string(fleet.FleetVarHostPlatform): - platform, ok := caVarsCache[string(fleet.FleetVarHostPlatform)] - if !ok { - var err error - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - return false, nil - } - platform = hostLite.Platform - if platform == "darwin" { - platform = "macos" - } - - caVarsCache[string(fleet.FleetVarHostPlatform)] = platform - } - *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, *item, platform) - default: - // We should not reach this since we validated the variables when saving app config - } - } - return true, nil -} - -func isDigiCertConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - existingDigiCertCAs map[string]*fleet.DigiCertCA, profUUID string, target *cmdTarget, caName string, fleetVar string, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "DigiCert integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if _, ok := existingDigiCertCAs[caName]; ok { - return true, nil - } - configured := false - var digiCertCA *fleet.DigiCertCA - if len(groupedCAs.DigiCert) > 0 { - for _, ca := range groupedCAs.DigiCert { - if ca.Name == caName { - digiCertCA = &ca - configured = true - break - } - } - } - if !configured || digiCertCA == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) - } - - existingDigiCertCAs[caName] = digiCertCA - return true, nil -} - -func isNDESSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, userEnrollmentsToHostUUIDsMap map[string]string, profUUID string, target *cmdTarget, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if groupedCAs.NDESSCEP == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - "NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.", ptr.Time(time.Now().UTC())) - } - return true, nil -} - -func isSmallstepSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - existingSmallstepSCEPCAs map[string]*fleet.SmallstepSCEPProxyCA, profUUID string, target *cmdTarget, caName string, fleetVar string, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Smallstep SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if _, ok := existingSmallstepSCEPCAs[caName]; ok { - return true, nil - } - configured := false - var scepCA *fleet.SmallstepSCEPProxyCA - if len(groupedCAs.Smallstep) > 0 { - for _, ca := range groupedCAs.Smallstep { - if ca.Name == caName { - scepCA = &ca - configured = true - break - } - } - } - if !configured || scepCA == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) - } - - existingSmallstepSCEPCAs[caName] = scepCA - return true, nil -} - -func getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - enrollmentID, - profUUID string, -) (*fleet.MDMAppleBulkUpsertHostProfilePayload, bool) { - profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] - if !ok { - var hostUUID string - // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. - hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] - if ok { - profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - } - } - return profile, ok -} - -func markProfilesFailed( - ctx context.Context, - ds fleet.Datastore, - target *cmdTarget, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - profUUID string, - detail string, - variablesUpdatedAt *time.Time, -) (bool, error) { - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs)) - for _, enrollmentID := range target.enrollmentIDs { - profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) - if !ok { - // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. - hostUUID, ok := userEnrollmentsToHostUUIDsMap[enrollmentID] - if ok { - profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - } - if !ok { - continue - } - } - profile.Status = &fleet.MDMDeliveryFailed - profile.Detail = detail - profile.VariablesUpdatedAt = variablesUpdatedAt - profilesToUpdate = append(profilesToUpdate, profile) - } - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return false, ctxerr.Wrap(ctx, err, "marking host profiles failed") - } - return false, nil -} - // scepCertRenewalThresholdDays defines the number of days before a SCEP // certificate must be renewed. const scepCertRenewalThresholdDays = 180 diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index ea7ea5594f..cf7266e562 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -27,8 +27,6 @@ import ( "testing" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" @@ -47,10 +45,8 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" "github.com/fleetdm/fleet/v4/server/mock" - digicert_mock "github.com/fleetdm/fleet/v4/server/mock/digicert" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" - scep_mock "github.com/fleetdm/fleet/v4/server/mock/scep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/redis_key_value" "github.com/fleetdm/fleet/v4/server/test" @@ -3475,7 +3471,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { failedCall = false failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { failedCount++ - require.Len(t, payload, 0) + require.Len(t, payload, 8) } enqueueFailForOp = "" newContents := "$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP @@ -3487,7 +3483,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { return nil, errors.New("GetHostEmailsFuncError") } - err := ReconcileAppleProfiles(ctx, ds, cmdr, slog.New(slog.DiscardHandler)) + err := ReconcileAppleProfiles(ctx, ds, cmdr, slog.New(slog.Default().Handler())) assert.ErrorContains(t, err, "GetHostEmailsFuncError") // checkAndReset(t, true, &ds.GetAllCertificateAuthoritiesFuncInvoked) checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallAndRemoveFuncInvoked) @@ -3563,617 +3559,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { }) } -func TestPreprocessProfileContents(t *testing.T) { - ctx := context.Background() - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - // No-op - svc := eeservice.NewSCEPConfigService(logger, nil) - digiCertService := digicert.NewService(digicert.WithLogger(logger)) - err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, nil, nil, nil, nil, nil) - require.NoError(t, err) - - hostUUID := "host-1" - cmdUUID := "cmd-1" - var targets map[string]*cmdTarget - populateTargets := func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID}}, - } - } - hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, 1) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: "p1"}] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ - ProfileUUID: "p1", - ProfileIdentifier: "com.add.profile", - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - } - userEnrollmentsToHostUUIDsMap := make(map[string]string) - populateTargets() - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - } - - var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - require.Len(t, payload, 1) - updatedPayload = payload[0] - for _, p := range payload { - require.NotNil(t, p.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) - assert.Equal(t, cmdUUID, p.CommandUUID) - assert.Equal(t, hostUUID, p.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) - assert.Equal(t, fleet.PayloadScopeSystem, p.Scope) - } - return nil - } - // Can't use NDES SCEP proxy with free tier - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "Premium license") - assert.Empty(t, targets) - - // Can't use NDES SCEP proxy without it being configured - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) - updatedPayload = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, &fleet.GroupedCertificateAuthorities{}) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "not configured") - assert.NotNil(t, updatedPayload.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Unknown variable - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_BOZO"), - } - updatedPayload = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") - assert.Empty(t, targets) - - ndesPassword := "test-password" - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, - assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext, - ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { - return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ - fleet.MDMAssetNDESPassword: {Value: []byte(ndesPassword)}, - }, nil - } - - ds.BulkUpsertMDMAppleHostProfilesFunc = nil - var updatedProfile *fleet.HostMDMAppleProfile - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, updatedProfile.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) - assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) - assert.Equal(t, hostUUID, updatedProfile.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) - return nil - } - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - assert.Empty(t, payload) - return nil - } - - adminUrl := "https://example.com" - username := "admin" - password := "test-password" - groupedCAs := &fleet.GroupedCertificateAuthorities{ - NDESSCEP: &fleet.NDESSCEPProxyCA{ - URL: "https://test-example.com", - AdminURL: adminUrl, - Username: username, - Password: password, - }, - } - - // Could not get NDES SCEP challenge - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPChallenge), - } - scepConfig := &scep_mock.SCEPConfigService{} - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESInvalidError("NDES error") - } - updatedProfile = nil - populateTargets() - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "update credentials") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Password cache full - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESPasswordCacheFullError("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "cached passwords") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Insufficient permissions - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESInsufficientPermissionsError("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "does not have sufficient permissions") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Other NDES challenge error - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", errors.New("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.NotContains(t, updatedProfile.Detail, "cached passwords") - assert.NotContains(t, updatedProfile.Detail, "update credentials") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // NDES challenge - challenge := "ndes-challenge" - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return challenge, nil - } - updatedProfile = nil - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - for _, p := range payload { - assert.NotEqual(t, cmdUUID, p.CommandUUID) - } - return nil - } - populateTargets() - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - require.Len(t, payload, 1) - assert.NotNil(t, payload[0].ChallengeRetrievedAt) - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, challenge, string(profileContents[profUUID])) - } - - // NDES SCEP proxy URL - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - } - expectedURL := "https://test.example.com" + apple_mdm.SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, "p1")) - updatedProfile = nil - populateTargets() - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - assert.Empty(t, payload) - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, expectedURL, string(profileContents[profUUID])) - } - - // No IdP email found - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { - return nil, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP) - assert.Contains(t, updatedProfile.Detail, "no IdP email") - assert.Empty(t, targets) - - // IdP email found - email := "user@example.com" - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { - return []string{email}, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, email, string(profileContents[profUUID])) - } - - // Hardware serial - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {HardwareSerial: "serial1"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostHardwareSerial), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, "serial1", string(profileContents[profUUID])) - } - - // Hardware serial fail - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return nil, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") - assert.Empty(t, targets) - - // Host UUID - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostUUID), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, hostUUID, string(profileContents[profUUID])) - } - - // Host Platform - macOS (darwin -> macos) - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "darwin", HardwareSerial: "serial1"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_HOST_PLATFORM"), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "macos", string(profileContents[profUUID])) - } - - // Host Platform - iOS - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "ios", HardwareSerial: "serial1"}, - }, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "ios", string(profileContents[profUUID])) - } - - // Host Platform fail - host not found - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return nil, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") - assert.Empty(t, targets) - - // Host Platform with Hardware Serial - both variables in profile - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "darwin", HardwareSerial: "serial123"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_HOST_PLATFORM $FLEET_VAR_HOST_HARDWARE_SERIAL"), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "macos serial123", string(profileContents[profUUID])) - } - - // multiple profiles, multiple hosts - populateTargets = func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID, "host-2"}}, // fails - "p2": {cmdUUID: cmdUUID, profIdent: "com.add.profile2", enrollmentIDs: []string{hostUUID, "host-3"}}, // works - "p3": {cmdUUID: cmdUUID, profIdent: "com.add.profile3", enrollmentIDs: []string{hostUUID, "host-4"}}, // no variables - } - } - populateTargets() - groupedCAs.NDESSCEP = nil - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - "p2": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), - "p3": []byte("no variables"), - } - addProfileToInstall := func(hostUUID, profileUUID, profileIdentifier string) { - hostProfilesToInstallMap[hostProfileUUID{ - HostUUID: hostUUID, - ProfileUUID: profileUUID, - }] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ - ProfileUUID: profileUUID, - ProfileIdentifier: profileIdentifier, - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - } - } - addProfileToInstall(hostUUID, "p1", "com.add.profile") - addProfileToInstall("host-2", "p1", "com.add.profile") - addProfileToInstall(hostUUID, "p2", "com.add.profile2") - addProfileToInstall("host-3", "p2", "com.add.profile2") - addProfileToInstall(hostUUID, "p3", "com.add.profile3") - addProfileToInstall("host-4", "p3", "com.add.profile3") - expectedHostsToFail := []string{hostUUID, "host-2", "host-3"} - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, updatedProfile.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) - assert.NotEqual(t, cmdUUID, updatedProfile.CommandUUID) - assert.Contains(t, expectedHostsToFail, updatedProfile.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) - return nil - } - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - for _, p := range payload { - require.NotNil(t, p.Status) - if fleet.MDMDeliveryFailed == *p.Status { - assert.Equal(t, cmdUUID, p.CommandUUID) - } else { - assert.NotEqual(t, cmdUUID, p.CommandUUID) - } - assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) - } - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotEmpty(t, targets) - assert.Len(t, targets, 3) - assert.Nil(t, targets["p1"]) // error - assert.Nil(t, targets["p2"]) // renamed - assert.NotNil(t, targets["p3"]) // normal, no variables - for profUUID, target := range targets { - assert.Contains(t, [][]string{{hostUUID}, {"host-3"}, {hostUUID, "host-4"}}, target.enrollmentIDs) - if profUUID == "p3" { - assert.Equal(t, cmdUUID, target.cmdUUID) - } else { - assert.NotEqual(t, cmdUUID, target.cmdUUID) - } - assert.Contains(t, []string{email, "no variables"}, string(profileContents[profUUID])) - } -} - -// TestPreprocessProfileContentsDigiCertUPNMultiHost is a regression test for -// https://github.com/fleetdm/fleet/issues/39324. When the same DigiCert CA is -// used for multiple hosts in a single batch, the CertificateUserPrincipalNames -// slice was shared via a shallow copy. In-place variable substitution for Host 1 -// corrupted the cached CA entry, so Host 2 and later hosts received Host 1's -// substituted UPN instead of their own. -func TestPreprocessProfileContentsDigiCertUPNIsUniqueForMultipleHosts(t *testing.T) { - ctx := context.Background() - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - svc := eeservice.NewSCEPConfigService(logger, nil) - - const caName = "myCA" - const host1UUID = "host-uuid-1" - const host2UUID = "host-uuid-2" - const host1Serial = "SERIAL-AAA" - const host2Serial = "SERIAL-BBB" - const cmdUUID = "cmd-uuid-1" - - // Track which UPNs were sent to GetCertificate per host. - upnByHostUUID := make(map[string]string) - - mockDigiCert := &digicert_mock.Service{} - mockDigiCert.GetCertificateFunc = func(ctx context.Context, config fleet.DigiCertCA) (*fleet.DigiCertCertificate, error) { - // The UPN in config should have been substituted with each host's own - // hardware serial. Record it so we can assert correctness later. - require.Len(t, config.CertificateUserPrincipalNames, 1) - upn := config.CertificateUserPrincipalNames[0] - // Determine which host this call is for by checking which serial is in the UPN. - switch { - case strings.Contains(upn, host1Serial): - upnByHostUUID[host1UUID] = upn - case strings.Contains(upn, host2Serial): - upnByHostUUID[host2UUID] = upn - default: - t.Errorf("GetCertificate called with unexpected UPN %q", upn) - } - now := time.Now() - return &fleet.DigiCertCertificate{ - PfxData: []byte("fake-pfx"), - Password: "fake-password", - NotValidBefore: now, - NotValidAfter: now.Add(365 * 24 * time.Hour), - SerialNumber: upn, // reuse upn as serial for easy tracing - }, nil - } - - // Both hosts share the same profile UUID but are separate enrollment IDs. - targets := map[string]*cmdTarget{ - "p1": { - cmdUUID: cmdUUID, - profIdent: "com.apple.security.pkcs12", - enrollmentIDs: []string{host1UUID, host2UUID}, - }, - } - - pending := fleet.MDMDeliveryPending - hostProfilesToInstallMap := map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ - {HostUUID: host1UUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.apple.security.pkcs12", - HostUUID: host1UUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &pending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - }, - {HostUUID: host2UUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.apple.security.pkcs12", - HostUUID: host2UUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &pending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - }, - } - - // Profile contains both DigiCert fleet variables. - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix) + caName + " $FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix) + caName), - } - - // DigiCert CA whose UPN contains the hardware serial variable. - groupedCAs := &fleet.GroupedCertificateAuthorities{ - DigiCert: []fleet.DigiCertCA{ - { - Name: caName, - URL: "https://digicert.example.com", - APIToken: "api_token", - ProfileID: "profile_id", - CertificateCommonName: "common_name", - CertificateUserPrincipalNames: []string{"$FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "@example.com"}, - CertificateSeatID: "seat_id", - }, - }, - } - - // Mock datastore: return each host's own hardware serial when queried. - hostsByUUID := map[string]*fleet.Host{ - host1UUID: {ID: 1, UUID: host1UUID, HardwareSerial: host1Serial, Platform: "darwin"}, - host2UUID: {ID: 2, UUID: host2UUID, HardwareSerial: host2Serial, Platform: "darwin"}, - } - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - var hosts []*fleet.Host - for _, uuid := range uuids { - if h, ok := hostsByUUID[uuid]; ok { - hosts = append(hosts, h) - } - } - return hosts, nil - } - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - return nil - } - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - return nil - } - - err := preprocessProfileContents(ctx, appCfg, ds, svc, mockDigiCert, logger, targets, profileContents, hostProfilesToInstallMap, make(map[string]string), groupedCAs) - require.NoError(t, err) - - // Both hosts must have received GetCertificate calls with their own serial. - require.True(t, mockDigiCert.GetCertificateFuncInvoked, "GetCertificate was never called") - require.Contains(t, upnByHostUUID, host1UUID, "GetCertificate was not called for host 1") - require.Contains(t, upnByHostUUID, host2UUID, "GetCertificate was not called for host 2") - assert.Equal(t, host1Serial+"@example.com", upnByHostUUID[host1UUID], "host 1 UPN should contain its own serial") - assert.Equal(t, host2Serial+"@example.com", upnByHostUUID[host2UUID], "host 2 UPN should contain its own serial") -} - func TestAppleMDMFileVaultEscrowFunctions(t *testing.T) { svc := Service{} @@ -5823,470 +5208,6 @@ func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { }) } -func TestPreprocessProfileContentsEndUserIDP(t *testing.T) { - ctx := context.Background() - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - svc := eeservice.NewSCEPConfigService(logger, nil) - digiCertService := digicert.NewService(digicert.WithLogger(logger)) - - hostUUID := "host-1" - cmdUUID := "cmd-1" - var targets map[string]*cmdTarget - // this is a func to re-create it each time because calling the preprocess function modifies this - populateTargets := func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID}}, - } - } - hostProfilesToInstallMap := map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ - {HostUUID: hostUUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.add.profile", - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - }, - } - - userEnrollmentsToHostUUIDsMap := make(map[string]string) - - var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload - var expectedStatus fleet.MDMDeliveryStatus - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - require.Len(t, payload, 1) - updatedPayload = payload[0] - require.NotNil(t, updatedPayload.Status) - assert.Equal(t, expectedStatus, *updatedPayload.Status) - // cmdUUID was replaced by a new unique command on success - assert.NotEqual(t, cmdUUID, updatedPayload.CommandUUID) - assert.Equal(t, hostUUID, updatedPayload.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedPayload.OperationType) - return nil - } - ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, idents []string) ([]uint, error) { - require.Len(t, idents, 1) - require.Equal(t, hostUUID, idents[0]) - return []uint{1}, nil - } - var updatedProfile *fleet.HostMDMAppleProfile - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, profile.Status) - assert.Equal(t, expectedStatus, *profile.Status) - return nil - } - ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { - return []*fleet.CertificateAuthority{}, nil - } - - cases := []struct { - desc string - profileContent string - expectedStatus fleet.MDMDeliveryStatus - setup func() - assert func(output string) - }{ - { - desc: "username only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.com", output) - }, - }, - { - desc: "username local part only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1", output) - }, - }, - { - desc: "groups only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{ - {DisplayName: "a"}, - {DisplayName: "b"}, - }}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "a,b", output) - }, - }, - { - desc: "multiple times username only scim", - profileContent: strings.Repeat("${FLEET_VAR_"+string(fleet.FleetVarHostEndUserIDPUsername)+"}", 3), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.comuser1@example.comuser1@example.com", output) - }, - }, - { - desc: "all 3 vars with scim", - profileContent: "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups) + "}", - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{ - {DisplayName: "a"}, - {DisplayName: "b"}, - }}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.comuser1a,b", output) - }, - }, - { - desc: "username no scim, with idp", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "idp@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "idp@example.com", output) - }, - }, - { - desc: "username scim and idp", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "idp@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.com", output) - }, - }, - { - desc: "username, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.") - }, - }, - { - desc: "username local part, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART.") - }, - }, - { - desc: "groups, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") - }, - }, - { - desc: "department, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") - }, - }, - { - desc: "groups with scim user but no group", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") - }, - }, - { - desc: "profile with scim department", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "user1@example.com", - Groups: []fleet.ScimUserGroup{}, - Department: ptr.String("Engineering"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "Engineering", output) - }, - }, - { - desc: "profile with scim department, user has no department", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "user1@example.com", - Groups: []fleet.ScimUserGroup{}, - Department: nil, - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") - }, - }, - { - desc: "profile with scim full name, user has full name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - GivenName: ptr.String("First"), - FamilyName: ptr.String("Last"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "First Last", output) - }, - }, - { - desc: "profile with scim full name, user only has given name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - GivenName: ptr.String("First"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "First", output) - }, - }, - { - desc: "profile with scim full name, user only has family name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - FamilyName: ptr.String("Last"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "Last", output) - }, - }, - { - desc: "profile with scim full name, user has no full name value", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Contains(t, updatedProfile.Detail, fmt.Sprintf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname)) - assert.Len(t, targets, 0) - }, - }, - } - - for _, c := range cases { - t.Run(c.desc, func(t *testing.T) { - c.setup() - - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte(c.profileContent), - } - populateTargets() - expectedStatus = c.expectedStatus - updatedPayload = nil - updatedProfile = nil - - err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - var output string - if expectedStatus == fleet.MDMDeliveryFailed { - require.Nil(t, updatedPayload) - require.NotNil(t, updatedProfile) - } else { - require.NotNil(t, updatedPayload) - require.Nil(t, updatedProfile) - output = string(profileContents[updatedPayload.CommandUUID]) - } - - c.assert(output) - }) - } -} - func TestValidateConfigProfileFleetVariablesLicense(t *testing.T) { t.Parallel() profileWithVars := ` diff --git a/server/service/handler.go b/server/service/handler.go index e0584c18e7..9d9ad3cc22 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -12,7 +12,7 @@ import ( "strings" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/config" carvestorectx "github.com/fleetdm/fleet/v4/server/contexts/carvestore" "github.com/fleetdm/fleet/v4/server/contexts/publicip" @@ -1294,7 +1294,7 @@ func RegisterSCEPProxy( if fleetConfig == nil { return errors.New("fleet config is nil") } - scepService := eeservice.NewSCEPProxyService( + scepService := scep.NewSCEPProxyService( ds, logger.With("component", "scep-proxy-service"), timeout, diff --git a/server/service/integration_certificate_authorities_test.go b/server/service/integration_certificate_authorities_test.go index 3b184a9c1f..3a2842814e 100644 --- a/server/service/integration_certificate_authorities_test.go +++ b/server/service/integration_certificate_authorities_test.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "testing" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -43,9 +43,9 @@ func (s *integrationMDMTestSuite) TestBatchApplyCertificateAuthorities() { // TODO(hca): test free version disallows batch endpoint - ndesSCEPServer := eeservice.NewTestSCEPServer(t) - ndesAdminServer := eeservice.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK) - dynamicChallengeServer := eeservice.NewTestDynamicChallengeServer(t) + ndesSCEPServer := scep.NewTestSCEPServer(t) + ndesAdminServer := scep.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK) + dynamicChallengeServer := scep.NewTestDynamicChallengeServer(t) pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`) mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index c1a7baba96..2434aeda89 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -582,27 +582,35 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de // run the cron to assign configuration profiles s.awaitTriggerProfileSchedule(t) + var seenDeclarativeManagement bool var cmds []*micromdm.CommandPayload cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + seenDeclarativeManagement = true + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue // Do not add to commands as it's not a XML file, so we use a bool to see it once. + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) // Can be useful for debugging - // switch cmd.Command.RequestType { - // case "InstallProfile": - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) - // case "InstallEnterpriseApplication": - // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) - // } else { - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) - // } - // default: - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) - // } + /* switch cmd.Command.RequestType { + case "InstallProfile": + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) + case "InstallEnterpriseApplication": + if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) + } else { + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + } + default: + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + } */ cmds = append(cmds, &fullCmd) cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) @@ -613,6 +621,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de // expected commands: install CA, install profile (only the custom one), // not expected: account configuration, since enrollment_reference not set require.Len(t, cmds, 2) + require.True(t, seenDeclarativeManagement) } else { // expected commands: install fleetd, install bootstrap(if not migrating), // install CA, install profiles (custom one, fleetd configuration, FileVault) @@ -624,7 +633,18 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de if isMigrating { expectedCommands-- // no bootstrap package during migration } + /* t.Logf("received %d commands, expected %d", len(cmds), expectedCommands) + for _, cmd := range cmds { + if cmd.Command.RequestType == "InstallEnterpriseApplication" { + t.Logf("command install enterprise: manifest: %#v - manifest url: %v", cmd.Command.InstallEnterpriseApplication.Manifest, cmd.Command.InstallEnterpriseApplication.ManifestURL) + } else if cmd.Command.RequestType == "InstallProfile" { + t.Logf("command install profile: %s", string(cmd.Command.InstallProfile.Payload)) + } else { + t.Logf("command type: %s", cmd.Command.RequestType) + } + } */ assert.Len(t, cmds, expectedCommands) + assert.True(t, seenDeclarativeManagement) } var installProfileCount, installEnterpriseCount, otherCount int @@ -878,10 +898,18 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // run the worker to assign configuration profiles s.awaitTriggerProfileSchedule(t) + var seenDeclarativeManagement bool var fleetdCmd, installProfileCmd *micromdm.CommandPayload cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + seenDeclarativeManagement = true + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue // Do not add to commands as it's not a XML file, so we use a bool to see it once. + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) if fullCmd.Command.RequestType == "InstallEnterpriseApplication" && @@ -903,9 +931,12 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // received request to install the global configuration profile require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles") require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles") + + require.True(t, seenDeclarativeManagement) } else { require.Nil(t, fleetdCmd, "host got a command to install fleetd") require.Nil(t, installProfileCmd, "host got a command to install profiles") + require.False(t, seenDeclarativeManagement) } } @@ -2099,6 +2130,12 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) if fullCmd.Command.RequestType == "InstallEnterpriseApplication" && diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index f888d56899..19e651e2b1 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -866,6 +866,13 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + // skip declarative management commands + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) count++ diff --git a/server/service/integration_mdm_release_worker_test.go b/server/service/integration_mdm_release_worker_test.go index d4d6ebd87e..f58d00cb98 100644 --- a/server/service/integration_mdm_release_worker_test.go +++ b/server/service/integration_mdm_release_worker_test.go @@ -22,15 +22,26 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { ctx := context.Background() mysql.TruncateTables(t, s.ds, "nano_commands", "host_mdm_apple_profiles", "mdm_apple_configuration_profiles") // We truncate this table beforehand to avoid persistence from other tests. - expectMDMCommandsOfType := func(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, commandType string, count int) { + type mdmCommandOfType struct { + CommandType string + Count int + } + expectMDMCommandsOfType := func(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, commandTypes []mdmCommandOfType) { // Get the first command cmd, err := mdmDevice.Idle() - for range count { - require.NoError(t, err) - require.NotNil(t, cmd) - require.Equal(t, commandType, cmd.Command.RequestType) - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + for _, ct := range commandTypes { + commandType := ct.CommandType + count := ct.Count + // Acknowledge and get next command of the expected type, for the expected count + // of times. + + for range count { + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, commandType, cmd.Command.RequestType) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + } } // We do not expect any other commands @@ -136,6 +147,8 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { t.Run("waits for config profiles being installed", func(t *testing.T) { // Clean up mysql.TruncateTables(t, s.ds, "mdm_apple_configuration_profiles", "host_mdm_apple_profiles", "nano_commands") // Clean tables after use + // Simulate profile reconciler running at least once before enrollment, and adds fleet profiles to the team + s.awaitTriggerProfileSchedule(t) config := mobileconfigForTest("N1", "I1") s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{config}}, http.StatusNoContent) @@ -147,29 +160,39 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { speedUpQueuedAppleMdmJob(t) // Get install enterprise application command and acknowledge it - expectMDMCommandsOfType(t, mdmDevice, "InstallEnterpriseApplication", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "InstallEnterpriseApplication", + Count: 1, + }, + { + CommandType: "InstallProfile", + Count: 3, + }, + { + CommandType: "DeclarativeManagement", + Count: 1, + }, + }) s.runWorker() // Run after install enterprise command to install profiles. (Should requeue until we trigger profile schedule) - // Verify device was not released yet - expectDeviceConfiguredSent(t, false) - - // Trigger profiles scheduler to set which profiles should be installed on the host. - s.awaitTriggerProfileSchedule(t) - speedUpQueuedAppleMdmJob(t) - - // Verify install profiles three times due to the two default fleet profiles and our custom one added. - expectMDMCommandsOfType(t, mdmDevice, "InstallProfile", 3) - s.runWorker() // release device - - // See DeviceConfigured is in Database and next command for mdm device + // Since moving profile installation to POSTDepEnrollment worker, we can now release the device immediately, as we only wait for sending. + // Verify device was released expectDeviceConfiguredSent(t, true) - expectMDMCommandsOfType(t, mdmDevice, "DeviceConfigured", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "DeviceConfigured", + Count: 1, + }, + }) }) t.Run("ignores user scoped config profiles", func(t *testing.T) { mysql.TruncateTables(t, s.ds, "mdm_apple_configuration_profiles", "host_mdm_apple_profiles", "nano_commands") // Clean tables after use + // Simulate profile reconciler running at least once before enrollment, and adds fleet profiles to the team + s.awaitTriggerProfileSchedule(t) systemScopedConfig := mobileconfigForTest("N1", "I1") userScope := fleet.PayloadScopeUser userScopedConfig := scopedMobileconfigForTest("N-USER-SCOPED", "I-USER-SCOPED", &userScope) @@ -182,26 +205,31 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { s.runWorker() speedUpQueuedAppleMdmJob(t) - // Get install enterprise application command and acknowledge it - expectMDMCommandsOfType(t, mdmDevice, "InstallEnterpriseApplication", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "InstallEnterpriseApplication", + Count: 1, + }, + { + CommandType: "InstallProfile", + Count: 3, // Only the system scoped profile is installed + }, + { + CommandType: "DeclarativeManagement", + Count: 1, + }, + }) - s.runWorker() // Run after install enterprise command to install profiles. (Should requeue until we trigger profile schedule) + s.runWorker() // Run after post dep enrollment to release device. // Verify device was not released yet - expectDeviceConfiguredSent(t, false) - - // Trigger profiles scheduler to set which profiles should be installed on the host. - s.awaitTriggerProfileSchedule(t) - speedUpQueuedAppleMdmJob(t) - - // Verify install profiles three times due to the two default fleet profiles and our custom one added, and it ignores the user scope. - expectMDMCommandsOfType(t, mdmDevice, "InstallProfile", 3) - - s.runWorker() // release device - - // See DeviceConfigured is in Database and next command for mdm device expectDeviceConfiguredSent(t, true) - expectMDMCommandsOfType(t, mdmDevice, "DeviceConfigured", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "DeviceConfigured", + Count: 1, + }, + }) }) }) } diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index c62fec855a..6a9f595dbd 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -263,6 +263,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -784,6 +789,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) cmds = append(cmds, &fullCmd) @@ -946,6 +957,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1140,6 +1156,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1365,6 +1386,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowUpdateScript() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1557,6 +1584,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1878,6 +1911,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -2755,6 +2793,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseMobileDeviceWithVPPTest(t * // Can be useful for debugging logCommands := false for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3110,6 +3153,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3421,6 +3469,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3744,6 +3797,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceMacOSCustomDisplayNameIcon( cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index c10df8efba..b77275b84b 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -50,7 +50,7 @@ import ( "github.com/golang-jwt/jwt/v4" "google.golang.org/api/androidmanagement/v1" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + svc_scep "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" @@ -134,7 +134,7 @@ type integrationMDMTestSuite struct { appleVPPProxySrvData map[string]string appleGDMFSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata - scepConfig *eeservice.SCEPConfigService + scepConfig *svc_scep.SCEPConfigService androidAPIClient *android_mock.Client androidSvc android.Service proxyCallbackURL string @@ -295,7 +295,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { } s.softwareInstallerStore = softwareInstallerStore scepTimeout := ptr.Duration(10 * time.Second) - s.scepConfig = eeservice.NewSCEPConfigService(serverLogger, scepTimeout).(*eeservice.SCEPConfigService) + s.scepConfig = svc_scep.NewSCEPConfigService(serverLogger, scepTimeout).(*svc_scep.SCEPConfigService) // Create a software title icon store iconDir := s.T().TempDir() @@ -15989,18 +15989,18 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACaps") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetCACerts) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACert") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (PKIOperation) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "PKIOperation", "message", message) errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetNextCACert) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetNextCACert") errBody, err = io.ReadAll(res.Body) @@ -16140,7 +16140,7 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.NDESChallengeInvalidAfter)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.NDESChallengeInvalidAfter)), Type: fleet.CAConfigNDES, CAName: "NDES", }, @@ -16157,7 +16157,7 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.NDESChallengeInvalidAfter + time.Minute)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.NDESChallengeInvalidAfter + time.Minute)), Type: fleet.CAConfigNDES, CAName: "NDES", }, @@ -16281,18 +16281,18 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACaps") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetCACerts) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACert") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (PKIOperation) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "PKIOperation", "message", message) errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetNextCACert) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetNextCACert") errBody, err = io.ReadAll(res.Body) @@ -16432,7 +16432,7 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.SmallstepChallengeInvalidAfter)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.SmallstepChallengeInvalidAfter)), Type: fleet.CAConfigSmallstep, CAName: caName, }, @@ -16449,7 +16449,7 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.SmallstepChallengeInvalidAfter + time.Minute)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.SmallstepChallengeInvalidAfter + time.Minute)), Type: fleet.CAConfigSmallstep, CAName: caName, }, diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index f5d2ca545c..9a26d22412 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -23,7 +23,7 @@ import ( "strings" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -2523,25 +2523,6 @@ func (svc *Service) GetMDMWindowsProfilesSummary(ctx context.Context, teamID *ui return ps, nil } -// ndesChallengeErrorToDetail translates NDES-specific error types into user-friendly messages -// for profile failure details. Used by both Apple and Windows NDES profile processing. -func ndesChallengeErrorToDetail(err error) string { - varName := fleet.FleetVarNDESSCEPChallenge.WithPrefix() - switch { - case errors.As(err, &eeservice.NDESInvalidError{}): - return fmt.Sprintf("Invalid NDES admin credentials. Fleet couldn't populate %s. "+ - "Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.", varName) - case errors.As(err, &eeservice.NDESPasswordCacheFullError{}): - return fmt.Sprintf("The NDES password cache is full. Fleet couldn't populate %s. "+ - "Please increase the number of cached passwords in NDES and try again.", varName) - case errors.As(err, &eeservice.NDESInsufficientPermissionsError{}): - return fmt.Sprintf("This account does not have sufficient permissions to enroll with SCEP. Fleet couldn't populate %s. "+ - "Please update the account with NDES SCEP enroll permissions and try again.", varName) - default: - return fmt.Sprintf("Fleet couldn't populate %s. %s", varName, err.Error()) - } -} - func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *slog.Logger) error { appConfig, err := ds.AppConfig(ctx) if err != nil { @@ -2628,7 +2609,7 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *s return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") } - scepConfigSvc := eeservice.NewSCEPConfigService(logger, nil) + scepConfigSvc := scep.NewSCEPConfigService(logger, nil) managedCertificatePayloads := &[]*fleet.MDMManagedCertificate{} deps := microsoft_mdm.ProfilePreprocessDependencies{ Context: ctx, @@ -2640,7 +2621,7 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *s ManagedCertificatePayloads: managedCertificatePayloads, NDESConfig: groupedCAs.NDESSCEP, GetNDESSCEPChallenge: scepConfigSvc.GetNDESSCEPChallenge, - NDESChallengeErrorToDetail: ndesChallengeErrorToDetail, + NDESChallengeErrorToDetail: scep.NDESChallengeErrorToDetail, } for profUUID, target := range installTargets { diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index ef97f40f9a..8fd0fdc928 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -27,6 +27,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/acl/activityacl" activity_api "github.com/fleetdm/fleet/v4/server/activity/api" activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap" @@ -94,7 +95,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{} mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }} c clock.Clock = clock.C - scepConfigService = eeservice.NewSCEPConfigService(logger, nil) + scepConfigService = scep.NewSCEPConfigService(logger, nil) digiCertService = digicert.NewService(digicert.WithLogger(logger)) estCAService = est.NewService(est.WithLogger(logger)) conditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy @@ -550,7 +551,7 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl if opts[0].EnableSCEPProxy { var timeout *time.Duration if opts[0].SCEPConfigService != nil { - scepConfig, ok := opts[0].SCEPConfigService.(*eeservice.SCEPConfigService) + scepConfig, ok := opts[0].SCEPConfigService.(*scep.SCEPConfigService) if ok { // In tests, we share the same Timeout pointer between SCEPConfigService and SCEPProxy timeout = scepConfig.Timeout diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index b1074d8a08..4610db1bc3 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -189,6 +189,16 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) awaitCmdUUIDs = append(awaitCmdUUIDs, commandUUIDs...) } + cmdUUIDs, err := a.installProfilesForEnrollingHost(ctx, args.HostUUID) + if err != nil { + a.Log.ErrorContext(ctx, "error installing profiles for enrolling host", "host_uuid", args.HostUUID, "err", err) + // We do not return here, as we want to continue with the rest of the logic, and then the reconciler will just pick up the remaining work. + // We do this since this is a speed optimization and not critical to complete enrollment itself, as we have other backing logic. + cmdUUIDs = []string{} + } + + awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUIDs...) + if ref := args.EnrollReference; ref != "" { a.Log.InfoContext(ctx, "got an enroll_reference", "host_uuid", args.HostUUID, "ref", ref) if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil { @@ -676,6 +686,116 @@ func (a *AppleMDM) getSignedURL(ctx context.Context, meta *fleet.MDMAppleBootstr return url } +// installProfilesForEnrollingHost installs all configuration profiles for the host immediately after enrollment +// to speed up the setup experience process. This runs before the reconciler cycle. +func (a *AppleMDM) installProfilesForEnrollingHost(ctx context.Context, hostUUID string) ([]string, error) { + // Get all profiles that need to be installed for this host + profilesToInstall, err := a.Datastore.ListMDMAppleProfilesToInstall(ctx, hostUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing profiles to install for host") + } + + profilesToInstall = fleet.FilterMacOSOnlyProfilesFromIOSIPadOS(profilesToInstall) + + // Filter out user-scoped profiles as they require special handling + profilesToInstall = fleet.FilterOutUserScopedProfiles(profilesToInstall) + + if len(profilesToInstall) == 0 { + a.Log.InfoContext(ctx, "no profiles to install", "host_uuid", hostUUID) + return nil, nil + } + + a.Log.InfoContext(ctx, "installing profiles post-enrollment", "host_uuid", hostUUID, "profile_count", len(profilesToInstall)) + + appConfig, err := a.Datastore.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading app config") + } + + hostProfiles := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(profilesToInstall)) + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(profilesToInstall)) + installTargets := make(map[string]*fleet.CmdTarget, len(profilesToInstall)) + for _, profile := range profilesToInstall { + target := &fleet.CmdTarget{ + CmdUUID: uuid.NewString(), + ProfileIdentifier: profile.ProfileIdentifier, + EnrollmentIDs: []string{hostUUID}, + } + installTargets[profile.ProfileUUID] = target + hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: profile.ProfileUUID, + ProfileIdentifier: profile.ProfileIdentifier, + ProfileName: profile.ProfileName, + HostUUID: hostUUID, + CommandUUID: target.CmdUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: nil, // intentionally nil here, to avoid stuck pending, but we need to upsert before processing so inner code can match rows for failures + Checksum: profile.Checksum, + SecretsUpdatedAt: profile.SecretsUpdatedAt, + Scope: profile.Scope, + } + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profile.ProfileUUID}] = hostProfile + hostProfiles = append(hostProfiles, hostProfile) + } + + if err := a.Datastore.BulkUpsertMDMAppleHostProfiles(ctx, hostProfiles); err != nil { + return nil, ctxerr.Wrap(ctx, err, "bulk upsert host profiles before installation") + } + + enqueueResult, err := apple_mdm.ProcessAndEnqueueProfiles(ctx, a.Datastore, a.Log, appConfig, a.Commander, installTargets, nil, hostProfilesToInstallMap, map[string]string{}) + if err != nil { + return nil, err + } + + // Build cmdUUID→profile index AFTER preprocessing has rewritten CommandUUIDs. + profileByCmdUUID := make(map[string]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(hostProfilesToInstallMap)) + for _, hp := range hostProfilesToInstallMap { + if hp.CommandUUID != "" { + profileByCmdUUID[hp.CommandUUID] = hp + } + } + + // Log failures + for cmdUUID, enqErr := range enqueueResult.FailedCmdUUIDs { + if profile := profileByCmdUUID[cmdUUID]; profile != nil { + a.Log.ErrorContext(ctx, "failed to install profile", "host_uuid", hostUUID, "profile_uuid", profile.ProfileUUID, "error", enqErr) + } + } + + // Collect successes for bulk upsert + var cmdUUIDs []string + var bulkPayloads []*fleet.MDMAppleBulkUpsertHostProfilePayload + for _, cmdUUID := range enqueueResult.SucceededCmdUUIDs { + if profile := profileByCmdUUID[cmdUUID]; profile != nil { + profile.Status = &fleet.MDMDeliveryPending + cmdUUIDs = append(cmdUUIDs, cmdUUID) + bulkPayloads = append(bulkPayloads, profile) + } + } + + // Bulk update database to track all profile installations + if len(bulkPayloads) > 0 { + if err := a.Datastore.BulkUpsertMDMAppleHostProfiles(ctx, bulkPayloads); err != nil { + a.Log.ErrorContext(ctx, "failed to bulk update profile statuses", "host_uuid", hostUUID, "error", err) + // Continue even if database update fails - the commands were sent + } + } + + a.Log.InfoContext(ctx, "successfully queued profiles from apple mdm worker", "host_uuid", hostUUID, "profiles_sent", len(cmdUUIDs)) + + // send a DeclarativeManagement command to start a sync, we don't block on DDM missing, and the declarations might not have been reconciled + // We can come back to this if we want to include DDM declarations here in the future. + declarativeManagementCmdUUID := uuid.NewString() + if err := a.Commander.DeclarativeManagement(ctx, []string{hostUUID}, declarativeManagementCmdUUID); err != nil { + a.Log.ErrorContext(ctx, "failed to send DeclarativeManagement command after installing profiles for enrolling host", "host_uuid", hostUUID, "error", err) + // Make sure we return the profile commands even if DDM fails + return cmdUUIDs, nil + } + cmdUUIDs = append(cmdUUIDs, declarativeManagementCmdUUID) + + return cmdUUIDs, nil +} + // QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to // be processed asynchronously via the worker. func QueueAppleMDMJob( diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index 47028d8696..e297846e62 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -1413,6 +1413,60 @@ INSERT INTO setup_experience_status_results ( require.NoError(t, err) require.Len(t, jobs, 0) }) + + t.Run("installs profiles on post dep enrollment", func(t *testing.T) { + mysql.SetTestABMAssets(t, ds, testOrgName) + defer mysql.TruncateTables(t, ds) + + profile1 := []byte("profile1") + profile2 := []byte("profile2") + profile3 := []byte("profile3") + + _, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile1, + Identifier: "profile1", + Name: "Profile 1", + }, nil) + require.NoError(t, err) + + _, err = ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile2, + Identifier: "profile2", + Name: "Profile 2", + }, nil) + require.NoError(t, err) + + _, err = ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile3, + Identifier: "profile3", + Name: "Profile 3", + }, nil) + require.NoError(t, err) + + h := createEnrolledHost(t, 1, nil, true, "darwin") + + mdmWorker := &AppleMDM{ + Datastore: ds, + Log: slogLog, + Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}), + } + w := NewWorker(ds, slogLog) + w.Register(mdmWorker) + + err = QueueAppleMDMJob(ctx, ds, slogLog, AppleMDMPostDEPEnrollmentTask, h.UUID, "darwin", nil, "", true, false) + require.NoError(t, err) + + // run the worker, should send install profiles commands, and a ddm request + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + // ensure the job's not_before allows it to be returned if it were to run + // again + time.Sleep(time.Second) + + // check all commands that were enqueued + require.ElementsMatch(t, []string{"InstallProfile", "DeclarativeManagement", "InstallProfile", "InstallProfile", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) + }) } func TestGetSignedURL(t *testing.T) {