fleet/server/mdm/apple/profile_processor.go
Magnus Jensen a8c9e261d7
speed up macOS profile delivery for initial enrollments (#41960)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-19 14:58:10 -05:00

834 lines
35 KiB
Go

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_<NAME>) 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
}