From ba15654498405b7784b373b1a9f7931998573431 Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Fri, 24 Oct 2025 10:10:58 -0300 Subject: [PATCH] DCSW: Support all IDP variables in Windows config profiles (#34707) **Related issue:** Resolves https://fleetdm.slack.com/archives/C03C41L5YEL/p1761232938314509, but also https://github.com/fleetdm/fleet/issues/34363, as it would have been stupid to only add IDP_USERNAME (to allow for an email) when they are so close together and no extra effort to support the rest. Also does another fix to ensure fleet variables are correctly ordered by longest name to shortest, to ensure variables that are used in longer format (USERNAME, USERNAME_LOCAL_PART) that the LOCAL_PART one gets processed first. # Checklist for submitter If some of the following don't apply, delete the relevant line. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually --- ee/server/service/certificate_authorities.go | 6 +- server/fleet/hosts.go | 55 ++++ server/fleet/mdm.go | 25 +- server/mdm/microsoft/profile_verifier.go | 39 ++- server/mdm/microsoft/profile_verifier_test.go | 80 +++--- server/mdm/profiles/profile_variables.go | 106 +++++++- server/service/apple_mdm.go | 253 +++++------------- server/service/apple_mdm_test.go | 6 +- server/service/hosts.go | 57 +--- server/service/mdm.go | 12 +- server/service/microsoft_mdm.go | 6 +- server/variables/variables.go | 20 +- server/variables/variables_test.go | 30 +-- 13 files changed, 343 insertions(+), 352 deletions(-) diff --git a/ee/server/service/certificate_authorities.go b/ee/server/service/certificate_authorities.go index 973580b89c..508a20fae9 100644 --- a/ee/server/service/certificate_authorities.go +++ b/ee/server/service/certificate_authorities.go @@ -240,7 +240,7 @@ func validateDigicertCACN(cn string, errPrefix string) error { return fleet.NewInvalidArgumentError("certificate_common_name", fmt.Sprintf("%sCA Common Name (CN) cannot be empty", errPrefix)) } fleetVars := variables.Find(cn) - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { switch fleetVar { case string(fleet.FleetVarHostEndUserEmailIDP), string(fleet.FleetVarHostHardwareSerial): // ok @@ -262,7 +262,7 @@ func validateDigicertSeatID(seatID string, errPrefix string) error { return fleet.NewInvalidArgumentError("certificate_seat_id", fmt.Sprintf("%sCA Seat ID cannot be empty", errPrefix)) } fleetVars := variables.Find(seatID) - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { switch fleetVar { case string(fleet.FleetVarHostEndUserEmailIDP), string(fleet.FleetVarHostHardwareSerial): // ok @@ -286,7 +286,7 @@ func validateDigicertUserPrincipalNames(userPrincipalNames []string, errPrefix s fmt.Sprintf("%sDigiCert certificate_user_principal_name cannot be empty if specified", errPrefix)) } fleetVars := variables.Find(userPrincipalNames[0]) - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { switch fleetVar { case string(fleet.FleetVarHostEndUserEmailIDP), string(fleet.FleetVarHostHardwareSerial): // ok diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 5e3b70f7e2..97b3a11606 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1557,3 +1557,58 @@ func (params *AddHostsToTeamParams) WithBatchSize(batchSize uint) *AddHostsToTea params.BatchSize = batchSize return params } + +func GetEndUsers(ctx context.Context, ds Datastore, hostID uint) ([]HostEndUser, error) { + scimUser, err := ds.ScimUserByHostID(ctx, hostID) + if err != nil && !IsNotFound(err) { + return nil, fmt.Errorf("get scim user by host id: %w", err) + } + + var endUsers []HostEndUser + if scimUser != nil { + endUser := HostEndUser{ + IdpUserName: scimUser.UserName, + IdpFullName: scimUser.DisplayName(), + IdpInfoUpdatedAt: ptr.Time(scimUser.UpdatedAt), + } + + if scimUser.ExternalID != nil { + endUser.IdpID = *scimUser.ExternalID + } + for _, group := range scimUser.Groups { + endUser.IdpGroups = append(endUser.IdpGroups, group.DisplayName) + } + if scimUser.Department != nil { + endUser.Department = *scimUser.Department + } + endUsers = append(endUsers, endUser) + } + + deviceMapping, err := ds.ListHostDeviceMapping(ctx, hostID) + if err != nil { + return nil, fmt.Errorf("get host device mapping: %w", err) + } + + if len(deviceMapping) > 0 { + endUser := HostEndUser{} + for _, email := range deviceMapping { + switch { + case (email.Source == DeviceMappingMDMIdpAccounts || email.Source == DeviceMappingIDP) && len(endUsers) == 0: + // If SCIM data is missing, we still populate IdpUserName if present. + // For DeviceMappingIDP source, this is the user-provided IDP username. + // Note: Username and email is the same thing here until we split them with https://github.com/fleetdm/fleet/issues/27952 + endUser.IdpUserName = email.Email + case email.Source != DeviceMappingMDMIdpAccounts && email.Source != DeviceMappingIDP: + // Only add to OtherEmails if it's not an IDP source + endUser.OtherEmails = append(endUser.OtherEmails, *email) + } + } + if len(endUsers) > 0 { + endUsers[0].OtherEmails = endUser.OtherEmails + } else { + endUsers = append(endUsers, endUser) + } + } + + return endUsers, nil +} diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 3a8a7a2db3..239e6ef203 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -46,7 +46,7 @@ const ( // (not doing it now because of time constraints to finish the story for the release). // Host variables - FleetVarHostEndUserEmailIDP FleetVarName = "HOST_END_USER_EMAIL_IDP" + FleetVarHostEndUserEmailIDP FleetVarName = "HOST_END_USER_EMAIL_IDP" // legacy variable, avoid to use in new replacements FleetVarHostHardwareSerial FleetVarName = "HOST_HARDWARE_SERIAL" FleetVarHostEndUserIDPUsername FleetVarName = "HOST_END_USER_IDP_USERNAME" FleetVarHostEndUserIDPUsernameLocalPart FleetVarName = "HOST_END_USER_IDP_USERNAME_LOCAL_PART" @@ -73,7 +73,6 @@ const ( var ( // Fleet variable regexp patterns - FleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserEmailIDP)) FleetVarHostHardwareSerialRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostHardwareSerial)) FleetVarHostEndUserIDPUsernameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPUsername)) FleetVarHostEndUserIDPDepartmentRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPDepartment)) @@ -90,6 +89,14 @@ var ( HostEndUserEmailIDPVariableReplacementFailedError = 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)", FleetVarHostEndUserEmailIDP) + + IDPFleetVariables = []FleetVarName{ + FleetVarHostEndUserIDPUsername, + FleetVarHostEndUserIDPUsernameLocalPart, + FleetVarHostEndUserIDPGroups, + FleetVarHostEndUserIDPDepartment, + FleetVarHostEndUserIDPFullname, + } ) type AppleMDM struct { @@ -1123,17 +1130,3 @@ type MDMCommandResults interface { } type MDMCommandResultsHandler func(ctx context.Context, results MDMCommandResults) error - -// Helper function for variable replacement in MDM profiles -func GetFirstIDPEmail(ctx context.Context, ds Datastore, hostUUID string) (email *string, err error) { - // TODO: Should we check on another type of device mapping? Instead of mdm_idp_accounts source. - emails, err := ds.GetHostEmails(ctx, hostUUID, DeviceMappingMDMIdpAccounts) - if err != nil { - // This is a server error, so we exit. - return nil, err - } - if len(emails) == 0 { - return nil, nil - } - return &emails[0], nil -} diff --git a/server/mdm/microsoft/profile_verifier.go b/server/mdm/microsoft/profile_verifier.go index bb67cb4f99..3f13ca0f40 100644 --- a/server/mdm/microsoft/profile_verifier.go +++ b/server/mdm/microsoft/profile_verifier.go @@ -40,6 +40,11 @@ func LoopOverExpectedHostProfiles( if err != nil { return fmt.Errorf("getting host profiles for verification: %w", err) } + + params := PreprocessingParameters{ + HostIDForUUIDCache: make(map[string]uint), + } + for _, expectedProf := range profileMap { expanded, err := ds.ExpandEmbeddedSecrets(ctx, string(expectedProf.RawProfile)) if err != nil { @@ -48,7 +53,7 @@ func LoopOverExpectedHostProfiles( // Process Fleet variables if present (similar to how it's done during profile deployment) // This ensures we compare what was actually sent to the device - processedContent := PreprocessWindowsProfileContentsForVerification(ctx, logger, ds, host.UUID, expectedProf.ProfileUUID, expanded) + processedContent := PreprocessWindowsProfileContentsForVerification(ctx, logger, ds, host.UUID, expectedProf.ProfileUUID, expanded, params) expectedProf.RawProfile = []byte(processedContent) var prof fleet.SyncMLCmd @@ -280,13 +285,20 @@ func IsWin32OrDesktopBridgeADMXCSP(locURI string) bool { return false } +// PreprocessingParameters holds parameters needed for preprocessing Windows profiles, for both verification and deployment only. +// It should only contain helper stuff, and not core values such as hostUUID, profileUUID, etc. +type PreprocessingParameters struct { + // a lookup map to avoid repeated datastore calls for hostID from hostUUID + HostIDForUUIDCache map[string]uint +} + // PreprocessWindowsProfileContentsForVerification processes Windows configuration profiles to replace Fleet variables // with the given host UUID for verification purposes. // // This function is similar to PreprocessWindowsProfileContentsForDeployment, but it does not require // a datastore or logger since it only replaces certain fleet variables to avoid datastore unnecessary work. -func PreprocessWindowsProfileContentsForVerification(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, hostUUID string, profileUUID string, profileContents string) string { - replacedContents, _ := preprocessWindowsProfileContents(ctx, logger, ds, nil, true, hostUUID, "", profileUUID, nil, profileContents) +func PreprocessWindowsProfileContentsForVerification(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, hostUUID string, profileUUID string, profileContents string, params PreprocessingParameters) string { + replacedContents, _ := preprocessWindowsProfileContents(ctx, logger, ds, nil, true, hostUUID, "", profileUUID, nil, profileContents, params) // ^ We ignore the error here, and rely on the fact that the function will return the original contents if no replacements were made. // So verification fails on individual profile level, instead of entire verification failing. return replacedContents @@ -294,14 +306,18 @@ func PreprocessWindowsProfileContentsForVerification(ctx context.Context, logger // PreprocessWindowsProfileContentsForDeployment processes Windows configuration profiles to replace Fleet variables // with their actual values for each host during profile deployment. -func PreprocessWindowsProfileContentsForDeployment(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig, hostUUID string, hostCmdUUID string, profileUUID string, groupedCAs *fleet.GroupedCertificateAuthorities, profileContents string) (string, error) { +func PreprocessWindowsProfileContentsForDeployment(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, + appConfig *fleet.AppConfig, hostUUID string, hostCmdUUID string, profileUUID string, + groupedCAs *fleet.GroupedCertificateAuthorities, profileContents string, + params PreprocessingParameters, +) (string, error) { // TODO: Should we avoid iterating this list for every profile? customSCEPCAs := make(map[string]*fleet.CustomSCEPProxyCA, len(groupedCAs.CustomScepProxy)) for _, ca := range groupedCAs.CustomScepProxy { customSCEPCAs[ca.Name] = &ca } - return preprocessWindowsProfileContents(ctx, logger, ds, appConfig, false, hostUUID, hostCmdUUID, profileUUID, customSCEPCAs, profileContents) + return preprocessWindowsProfileContents(ctx, logger, ds, appConfig, false, hostUUID, hostCmdUUID, profileUUID, customSCEPCAs, profileContents, params) } // This error type is used to indicate errors during Microsoft profile processing, such as variable replacement failures. @@ -339,6 +355,7 @@ func (e *MicrosoftProfileProcessingError) Error() string { func preprocessWindowsProfileContents(ctx context.Context, logger kitlog.Logger, ds fleet.Datastore, appConfig *fleet.AppConfig, isVerifying bool, hostUUID string, hostCmdUUID string, profileUUID string, customSCEPCAs map[string]*fleet.CustomSCEPProxyCA, profileContents string, + params PreprocessingParameters, ) (string, error) { // Check if Fleet variables are present fleetVars := variables.Find(profileContents) @@ -349,16 +366,18 @@ func preprocessWindowsProfileContents(ctx context.Context, logger kitlog.Logger, // Process each Fleet variable result := profileContents - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { if fleetVar == string(fleet.FleetVarHostUUID) { result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostUUIDRegexp, result, hostUUID) - } else if fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) { - replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserEmailIDPVariable(ctx, ds, result, hostUUID) + } else if slices.Contains(fleet.IDPFleetVariables, fleet.FleetVarName(fleetVar)) { + replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, result, hostUUID, params.HostIDForUUIDCache, func(errMsg string) error { + return &MicrosoftProfileProcessingError{message: errMsg} + }) if err != nil { - return profileContents, ctxerr.Wrap(ctx, err, "replacing host end user email IDP variable") + return profileContents, err } if !replacedVariable { - return profileContents, &MicrosoftProfileProcessingError{message: fleet.HostEndUserEmailIDPVariableReplacementFailedError} + return profileContents, ctxerr.Wrap(ctx, err, "host end user IDP variable replacement failed for variable") } result = replacedContents } diff --git a/server/mdm/microsoft/profile_verifier_test.go b/server/mdm/microsoft/profile_verifier_test.go index 3157d145b2..3570615da5 100644 --- a/server/mdm/microsoft/profile_verifier_test.go +++ b/server/mdm/microsoft/profile_verifier_test.go @@ -3,6 +3,7 @@ package microsoft_mdm import ( "context" "encoding/xml" + "fmt" "io" "strings" "testing" @@ -825,13 +826,6 @@ type hostProfile struct { func TestPreprocessWindowsProfileContentsForVerification(t *testing.T) { ds := new(mock.Store) - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID, source string) ([]string, error) { - if source == fleet.DeviceMappingMDMIdpAccounts && strings.Contains(hostUUID, "end-user-email") { - return []string{"test@idp.com"}, nil - } - return nil, nil - } - tests := []struct { name string hostUUID string @@ -892,12 +886,6 @@ func TestPreprocessWindowsProfileContentsForVerification(t *testing.T) { profileContents: `ID1: $FLEET_VAR_HOST_UUID, ID2: ${FLEET_VAR_HOST_UUID}`, expectedContents: `ID1: test-host-1234-uuid, ID2: test-host-1234-uuid`, }, - { - name: "fleet variable with db access", - hostUUID: "test-host-end-user-email", - profileContents: `ID: $FLEET_VAR_HOST_UUID, Other: $FLEET_VAR_HOST_END_USER_EMAIL_IDP`, - expectedContents: `ID: test-host-end-user-email, Other: test@idp.com`, - }, { name: "skips scep windows id var", hostUUID: "test-host-1234-uuid", @@ -906,9 +894,13 @@ func TestPreprocessWindowsProfileContentsForVerification(t *testing.T) { }, } + params := PreprocessingParameters{ + HostIDForUUIDCache: make(map[string]uint), + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := PreprocessWindowsProfileContentsForVerification(t.Context(), log.NewNopLogger(), ds, tt.hostUUID, uuid.NewString(), tt.profileContents) + result := PreprocessWindowsProfileContentsForVerification(t.Context(), log.NewNopLogger(), ds, tt.hostUUID, uuid.NewString(), tt.profileContents, params) require.Equal(t, tt.expectedContents, result) }) } @@ -918,12 +910,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { ds := new(mock.Store) baseSetup := func() { - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID, source string) ([]string, error) { - if source == fleet.DeviceMappingMDMIdpAccounts && strings.Contains(hostUUID, "end-user-email") { - return []string{"test@idp.com"}, nil - } - return nil, nil - } ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { if ds.GetAllCertificateAuthoritiesFunc == nil { return &fleet.GroupedCertificateAuthorities{ @@ -945,6 +931,34 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { }, }, nil } + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) { + return []uint{42}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + if hostID == 42 { + return &fleet.ScimUser{ + UserName: "test@idp.com", + GivenName: ptr.String("First"), + FamilyName: ptr.String("Last"), + Department: ptr.String("Department"), + Groups: []fleet.ScimUserGroup{ + { + ID: 1, + DisplayName: "Group One", + }, + { + ID: 2, + DisplayName: "Group Two", + }, + }, + }, nil + } + + return nil, fmt.Errorf("no scim user for host id %d", hostID) + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{}, nil + } } // use the same uuid for all profile UUID actions @@ -973,19 +987,6 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { profileContents: `./Device/TestDevice ID: $FLEET_VAR_HOST_UUID`, expectedContents: `./Device/TestDevice ID: test-uuid-456`, }, - { - name: "host end user email idp", - hostUUID: "test-uuid-end-user-email", - profileContents: `Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP`, - expectedContents: `Email: test@idp.com`, - }, - { - name: "no host end user email idp found", - hostUUID: "test-uuid-no-end-user", - profileContents: `Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP`, - expectError: true, - processingError: fleet.HostEndUserEmailIDPVariableReplacementFailedError, - }, { name: "scep windows certificate id", hostUUID: "test-host-1234-uuid", @@ -1073,6 +1074,17 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { } }, }, + { + name: "all idp variables", + hostUUID: "idp-host-uuid", + hostCmdUUID: "cmd-uuid-5678", + profileContents: `./Device/TestUser: $FLEET_VAR_HOST_END_USER_IDP_USERNAME - $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART - $FLEET_VAR_HOST_END_USER_IDP_GROUPS - $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT - $FLEET_VAR_HOST_END_USER_IDP_FULL_NAME`, + expectedContents: `./Device/TestUser: test@idp.com - test - Group One,Group Two - Department - First Last`, + }, + } + + params := PreprocessingParameters{ + HostIDForUUIDCache: make(map[string]uint), } for _, tt := range tests { @@ -1100,7 +1112,7 @@ func TestPreprocessWindowsProfileContentsForDeployment(t *testing.T) { groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) require.NoError(t, err) - result, err := PreprocessWindowsProfileContentsForDeployment(ctx, log.NewNopLogger(), ds, appConfig, tt.hostUUID, tt.hostCmdUUID, profileUUID, groupedCAs, tt.profileContents) + result, err := PreprocessWindowsProfileContentsForDeployment(ctx, log.NewNopLogger(), ds, appConfig, tt.hostUUID, tt.hostCmdUUID, profileUUID, groupedCAs, tt.profileContents, params) if tt.expectError { require.Error(t, err) if tt.processingError != "" { diff --git a/server/mdm/profiles/profile_variables.go b/server/mdm/profiles/profile_variables.go index 03998cf589..fc82f8e288 100644 --- a/server/mdm/profiles/profile_variables.go +++ b/server/mdm/profiles/profile_variables.go @@ -13,6 +13,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/ptr" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" ) @@ -79,18 +80,109 @@ func ReplaceCustomSCEPProxyURLVariable(ctx context.Context, logger kitlog.Logger return contents, managedCertificate, true, nil } -// ! Important if we add new replacedVariable=false cases, that we verify the caller functions still behave correctly, as some run actions based on whether a variable was replaced or not. -func ReplaceHostEndUserEmailIDPVariable(ctx context.Context, ds fleet.Datastore, profileContents string, hostUUID string) (contents string, replacedVariable bool, err error) { - email, err := fleet.GetFirstIDPEmail(ctx, ds, hostUUID) +func ReplaceHostEndUserIDPVariables(ctx context.Context, ds fleet.Datastore, + fleetVar string, profileContents string, hostUUID string, + hostIDForUUIDCache map[string]uint, + onError func(errMsg string) error, +) (replacedContents string, replacedVariable bool, err error) { + user, ok, err := getHostEndUserIDPUser(ctx, ds, hostUUID, fleetVar, hostIDForUUIDCache, onError) if err != nil { - return "", false, ctxerr.Wrap(ctx, err, "getting IDP email") + return "", false, err } - if email == nil { + if !ok { return "", false, nil } - contents = ReplaceFleetVariableInXML(fleet.FleetVarHostEndUserEmailIDPRegexp, profileContents, *email) - return contents, true, nil + var rx *regexp.Regexp + var value string + switch fleetVar { + case string(fleet.FleetVarHostEndUserIDPUsername): + rx = fleet.FleetVarHostEndUserIDPUsernameRegexp + value = user.IdpUserName + case string(fleet.FleetVarHostEndUserIDPUsernameLocalPart): + rx = fleet.FleetVarHostEndUserIDPUsernameLocalPartRegexp + value = getEmailLocalPart(user.IdpUserName) + case string(fleet.FleetVarHostEndUserIDPGroups): + rx = fleet.FleetVarHostEndUserIDPGroupsRegexp + value = strings.Join(user.IdpGroups, ",") + case string(fleet.FleetVarHostEndUserIDPDepartment): + rx = fleet.FleetVarHostEndUserIDPDepartmentRegexp + value = user.Department + case string(fleet.FleetVarHostEndUserIDPFullname): + rx = fleet.FleetVarHostEndUserIDPFullnameRegexp + value = strings.TrimSpace(user.IdpFullName) + } + replacedContents = ReplaceFleetVariableInXML(rx, profileContents, value) + + return replacedContents, true, nil +} + +func getHostEndUserIDPUser(ctx context.Context, ds fleet.Datastore, + hostUUID, fleetVar string, hostIDForUUIDCache map[string]uint, + onError func(errMsg string) error, +) (*fleet.HostEndUser, bool, error) { + hostID, ok := hostIDForUUIDCache[hostUUID] + if !ok { + filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + ids, err := ds.HostIDsByIdentifier(ctx, filter, []string{hostUUID}) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "get host id from uuid") + } + + if len(ids) != 1 { + // Something went wrong. Maybe host was deleted, or we have multiple + // hosts with the same UUID. + return nil, false, onError(fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s. ", len(ids), hostUUID)) + } + hostID = ids[0] + hostIDForUUIDCache[hostUUID] = hostID + } + + users, err := fleet.GetEndUsers(ctx, ds, hostID) + if err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "get end users for host") + } + + noGroupsErr := fmt.Sprintf("There is no IdP groups for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPGroups) + noDepartmentErr := fmt.Sprintf("There is no IdP department for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPDepartment) + noFullnameErr := fmt.Sprintf("There is no IdP full name for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname) + if len(users) > 0 && users[0].IdpUserName != "" { + idpUser := users[0] + + if fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) && len(idpUser.IdpGroups) == 0 { + return nil, false, onError(noGroupsErr) + } + if fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) && idpUser.Department == "" { + return nil, false, onError(noDepartmentErr) + } + if fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) && strings.TrimSpace(idpUser.IdpFullName) == "" { + return nil, false, onError(noFullnameErr) + } + + return &idpUser, true, nil + } + + // otherwise there's no IdP user, mark the profile as failed with the + // appropriate detail message. + var detail string + switch fleetVar { + case string(fleet.FleetVarHostEndUserIDPUsername), string(fleet.FleetVarHostEndUserIDPUsernameLocalPart): + detail = fmt.Sprintf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) + case string(fleet.FleetVarHostEndUserIDPGroups): + detail = noGroupsErr + case string(fleet.FleetVarHostEndUserIDPDepartment): + detail = noDepartmentErr + case string(fleet.FleetVarHostEndUserIDPFullname): + detail = noFullnameErr + } + return nil, false, onError(detail) +} + +func getEmailLocalPart(email string) string { + // if there is a "@" in the email, return the part before that "@", otherwise + // return the string unchanged. + local, _, _ := strings.Cut(email, "@") + return local } func ReplaceExactFleetPrefixVariableInXML(prefix string, suffix string, contents string, replacement string) (string, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 5c32bb4303..064ed76bf2 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -70,16 +70,11 @@ const ( ) var ( - fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) - fleetVarHostHardwareSerialRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostHardwareSerial)) - fleetVarHostEndUserIDPUsernameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserIDPUsername)) - fleetVarHostEndUserIDPDepartmentRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserIDPDepartment)) - fleetVarHostEndUserIDPUsernameLocalPartRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserIDPUsernameLocalPart)) - fleetVarHostEndUserIDPGroupsRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserIDPGroups)) - fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPChallenge)) - fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPProxyURL)) - fleetVarHostEndUserIDPFullnameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserIDPFullname)) - fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID)) + fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) + fleetVarHostHardwareSerialRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostHardwareSerial)) + fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPChallenge)) + fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPProxyURL)) + fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID)) // 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) @@ -456,7 +451,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, d // Convert profile variable names to FleetVarName type varNames := make([]fleet.FleetVarName, 0, len(profileVars)) - for varName := range profileVars { + for _, varName := range profileVars { varNames = append(varNames, fleet.FleetVarName(varName)) } newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp, varNames) @@ -512,7 +507,7 @@ func CheckProfileIsNotSigned(data []byte) error { return nil } -func validateConfigProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) (map[string]struct{}, error) { +func validateConfigProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) ([]string, error) { fleetVars := variables.FindKeepDuplicates(contents) if len(fleetVars) == 0 { return nil, nil @@ -673,12 +668,8 @@ func validateConfigProfileFleetVariables(contents string, lic *fleet.LicenseInfo return nil, err } } - // Convert slice to map for deduplication - result := make(map[string]struct{}, len(fleetVars)) - for _, v := range fleetVars { - result[v] = struct{}{} - } - return result, nil + + return variables.Dedupe(fleetVars), nil } // additionalDigiCertValidation checks that Password/ContentType fields match DigiCert Fleet variables exactly, @@ -5097,7 +5088,7 @@ func preprocessProfileContents( // validation works as expected // In the future we should expand variablesUpdatedAt logic to include non-CA variables as // well - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { if fleetVar == string(fleet.FleetVarSCEPRenewalID) || fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) || @@ -5110,7 +5101,7 @@ func preprocessProfileContents( } initialFleetVarLoop: - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { switch { case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): configured, err := isNDESSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target) @@ -5233,7 +5224,7 @@ func preprocessProfileContents( hostContents := contentsStr failed := false fleetVarLoop: - for fleetVar := range fleetVars { + for _, fleetVar := range fleetVars { var err error switch { case fleetVar == string(fleet.FleetVarNDESSCEPChallenge): @@ -5371,26 +5362,15 @@ func preprocessProfileContents( } case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP): - replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserEmailIDPVariable(ctx, ds, hostContents, hostUUID) + email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID) if err != nil { - return ctxerr.Wrap(ctx, err, "replacing host end user email IDP variable") + return ctxerr.Wrap(ctx, err, "getting IDP email") } - if !replacedVariable { - // 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: fleet.HostEndUserEmailIDPVariableReplacementFailedError, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for host end user email IDP") - } + if !ok { failed = true break fleetVarLoop } - hostContents = replacedContents + hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email) case fleetVar == string(fleet.FleetVarHostHardwareSerial): hardwareSerial, ok, err := getHostHardwareSerial(ctx, ds, target, hostUUID) @@ -5406,35 +5386,25 @@ func preprocessProfileContents( case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarHostEndUserIDPFullname): - user, ok, err := getHostEndUserIDPUser(ctx, ds, target, hostUUID, fleetVar, hostIDForUUIDCache) + 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, "getting host end user IDP username") + return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables") } - if !ok { + if !replacedVariable { failed = true break fleetVarLoop } - var rx *regexp.Regexp - var value string - switch fleetVar { - case string(fleet.FleetVarHostEndUserIDPUsername): - rx = fleetVarHostEndUserIDPUsernameRegexp - value = user.IdpUserName - case string(fleet.FleetVarHostEndUserIDPUsernameLocalPart): - rx = fleetVarHostEndUserIDPUsernameLocalPartRegexp - value = getEmailLocalPart(user.IdpUserName) - case string(fleet.FleetVarHostEndUserIDPGroups): - rx = fleetVarHostEndUserIDPGroupsRegexp - value = strings.Join(user.IdpGroups, ",") - case string(fleet.FleetVarHostEndUserIDPDepartment): - rx = fleetVarHostEndUserIDPDepartmentRegexp - value = user.Department - case string(fleet.FleetVarHostEndUserIDPFullname): - rx = fleetVarHostEndUserIDPFullnameRegexp - value = strings.TrimSpace(user.IdpFullName) - } - hostContents = profiles.ReplaceFleetVariableInXML(rx, hostContents, value) + hostContents = replacedContents case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)): // We will replace the password when we populate the certificate data @@ -5554,35 +5524,50 @@ func preprocessProfileContents( 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, hostUUID string, caVarsCache map[string]string, item *string, ) (bool, error) { caFleetVars := variables.Find(*item) - for caVar := range caFleetVars { + for _, caVar := range caFleetVars { switch caVar { case string(fleet.FleetVarHostEndUserEmailIDP): email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] if !ok { var err error - foundEmail, err := fleet.GetFirstIDPEmail(ctx, ds, hostUUID) + email, ok, err = getFirstIDPEmail(ctx, ds, target, hostUUID) if err != nil { return false, ctxerr.Wrap(ctx, err, "getting IDP email") } - if foundEmail == nil { - // 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: fleet.HostEndUserEmailIDPVariableReplacementFailedError, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return false, err - } + if !ok { return false, nil } - caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = *foundEmail - email = *foundEmail + caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email } *item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email) case string(fleet.FleetVarHostHardwareSerial): @@ -5606,124 +5591,6 @@ func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdT return true, nil } -func getHostEndUserIDPUser(ctx context.Context, ds fleet.Datastore, target *cmdTarget, - hostUUID, fleetVar string, hostIDForUUIDCache map[string]uint, -) (*fleet.HostEndUser, bool, error) { - hostID, ok := hostIDForUUIDCache[hostUUID] - if !ok { - filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} - ids, err := ds.HostIDsByIdentifier(ctx, filter, []string{hostUUID}) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "get host id from uuid") - } - - if len(ids) != 1 { - // Something went wrong. Maybe host was deleted, or we have multiple - // hosts with the same UUID. 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("Unexpected number of hosts (%d) for UUID %s. ", len(ids), hostUUID), - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user IDP") - } - return nil, false, nil - } - hostID = ids[0] - hostIDForUUIDCache[hostUUID] = hostID - } - - users, err := getEndUsers(ctx, ds, hostID) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "get end users for host") - } - - noGroupsErr := fmt.Sprintf("There is no IdP groups for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPGroups) - noDepartmentErr := fmt.Sprintf("There is no IdP department for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPDepartment) - noFullnameErr := fmt.Sprintf("There is no IdP full name for this host. Fleet couldn’t populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname) - if len(users) > 0 && users[0].IdpUserName != "" { - idpUser := users[0] - - if fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) && len(idpUser.IdpGroups) == 0 { - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: noGroupsErr, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user IDP (no groups)") - } - return nil, false, nil - } - if fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) && idpUser.Department == "" { - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: noDepartmentErr, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user IDP (no department)") - } - return nil, false, nil - } - if fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) && strings.TrimSpace(idpUser.IdpFullName) == "" { - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: noFullnameErr, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user IDP (no fullname)") - } - return nil, false, nil - } - - return &idpUser, true, nil - } - - // otherwise there's no IdP user, mark the profile as failed with the - // appropriate detail message. - var detail string - switch fleetVar { - case string(fleet.FleetVarHostEndUserIDPUsername), string(fleet.FleetVarHostEndUserIDPUsernameLocalPart): - detail = fmt.Sprintf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar) - case string(fleet.FleetVarHostEndUserIDPGroups): - detail = noGroupsErr - case string(fleet.FleetVarHostEndUserIDPDepartment): - detail = noDepartmentErr - case string(fleet.FleetVarHostEndUserIDPFullname): - detail = noFullnameErr - } - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return nil, false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user IDP") - } - return nil, false, nil -} - -func getEmailLocalPart(email string) string { - // if there is a "@" in the email, return the part before that "@", otherwise - // return the string unchanged. - local, _, _ := strings.Cut(email, "@") - return local -} - func getHostHardwareSerial(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) { hosts, err := ds.ListHostsLiteByUUIDs(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, []string{hostUUID}) if err != nil { diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index c09deb7f9e..8e81323487 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -5848,11 +5848,7 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) { assert.Empty(t, vars) } else { assert.NoError(t, err) - gotVars := make([]string, 0, len(vars)) - for v := range vars { - gotVars = append(gotVars, v) - } - assert.ElementsMatch(t, tc.vars, gotVars) + assert.ElementsMatch(t, tc.vars, vars) } }) } diff --git a/server/service/hosts.go b/server/service/hosts.go index 004d250acf..c2f02cb5fb 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1385,7 +1385,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f host.Policies = policies - endUsers, err := getEndUsers(ctx, svc.ds, host.ID) + endUsers, err := fleet.GetEndUsers(ctx, svc.ds, host.ID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get end users for host") } @@ -1402,61 +1402,6 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f }, nil } -func getEndUsers(ctx context.Context, ds fleet.Datastore, hostID uint) ([]fleet.HostEndUser, error) { - scimUser, err := ds.ScimUserByHostID(ctx, hostID) - if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get scim user by host id") - } - - var endUsers []fleet.HostEndUser - if scimUser != nil { - endUser := fleet.HostEndUser{ - IdpUserName: scimUser.UserName, - IdpFullName: scimUser.DisplayName(), - IdpInfoUpdatedAt: ptr.Time(scimUser.UpdatedAt), - } - - if scimUser.ExternalID != nil { - endUser.IdpID = *scimUser.ExternalID - } - for _, group := range scimUser.Groups { - endUser.IdpGroups = append(endUser.IdpGroups, group.DisplayName) - } - if scimUser.Department != nil { - endUser.Department = *scimUser.Department - } - endUsers = append(endUsers, endUser) - } - - deviceMapping, err := ds.ListHostDeviceMapping(ctx, hostID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get host device mapping") - } - - if len(deviceMapping) > 0 { - endUser := fleet.HostEndUser{} - for _, email := range deviceMapping { - switch { - case (email.Source == fleet.DeviceMappingMDMIdpAccounts || email.Source == fleet.DeviceMappingIDP) && len(endUsers) == 0: - // If SCIM data is missing, we still populate IdpUserName if present. - // For DeviceMappingIDP source, this is the user-provided IDP username. - // Note: Username and email is the same thing here until we split them with https://github.com/fleetdm/fleet/issues/27952 - endUser.IdpUserName = email.Email - case email.Source != fleet.DeviceMappingMDMIdpAccounts && email.Source != fleet.DeviceMappingIDP: - // Only add to OtherEmails if it's not an IDP source - endUser.OtherEmails = append(endUser.OtherEmails, *email) - } - } - if len(endUsers) > 0 { - endUsers[0].OtherEmails = endUser.OtherEmails - } else { - endUsers = append(endUsers, endUser) - } - } - - return endUsers, nil -} - //////////////////////////////////////////////////////////////////////////////// // Get Host Query Report //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/mdm.go b/server/service/mdm.go index ee00d096c3..2c932904b7 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1756,7 +1756,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, // Collect Fleet variables used in the profile var usesFleetVars []fleet.FleetVarName - for varName := range foundVars { + for _, varName := range foundVars { usesFleetVars = append(usesFleetVars, fleet.FleetVarName(varName)) } @@ -1800,7 +1800,7 @@ var fleetVarsSupportedInWindowsProfiles = []fleet.FleetVarName{ fleet.FleetVarHostUUID, } -func validateWindowsProfileFleetVariables(contents string, lic *fleet.LicenseInfo) (map[string]struct{}, error) { +func validateWindowsProfileFleetVariables(contents string, lic *fleet.LicenseInfo) ([]string, error) { foundVars := variables.Find(contents) if len(foundVars) == 0 { return nil, nil @@ -1812,7 +1812,7 @@ func validateWindowsProfileFleetVariables(contents string, lic *fleet.LicenseInf } // Check if all found variables are supported - for varName := range foundVars { + for _, varName := range foundVars { if !slices.Contains(fleetVarsSupportedInWindowsProfiles, fleet.FleetVarName(varName)) { return nil, fleet.NewInvalidArgumentError("profile", fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in Windows profiles.", varName)) } @@ -2081,7 +2081,7 @@ func (svc *Service) BatchSetMDMProfiles( profilesVariablesByIdentifier := make([]fleet.MDMProfileIdentifierFleetVariables, 0, len(profilesVariablesByIdentifierMap)) for identifier, variables := range profilesVariablesByIdentifierMap { varNames := make([]fleet.FleetVarName, 0, len(variables)) - for varName := range variables { + for _, varName := range variables { varNames = append(varNames, fleet.FleetVarName(varName)) } profilesVariablesByIdentifier = append(profilesVariablesByIdentifier, fleet.MDMProfileIdentifierFleetVariables{ @@ -2207,7 +2207,7 @@ func (svc *Service) BatchSetMDMProfiles( func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig *fleet.AppConfig, lic *fleet.LicenseInfo, appleProfiles map[int]*fleet.MDMAppleConfigProfile, windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration, -) (map[string]map[string]struct{}, error) { +) (map[string][]string, error) { var err error groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) @@ -2215,7 +2215,7 @@ func validateFleetVariables(ctx context.Context, ds fleet.Datastore, appConfig * return nil, ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") } - profileVarsByProfIdentifier := make(map[string]map[string]struct{}) + profileVarsByProfIdentifier := make(map[string][]string) for _, p := range appleProfiles { profileVars, err := validateConfigProfileFleetVariables(string(p.Mobileconfig), lic, groupedCAs) if err != nil { diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 1420ee9504..787eed0ff4 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -2315,6 +2315,10 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") } + params := microsoft_mdm.PreprocessingParameters{ + HostIDForUUIDCache: make(map[string]uint), + } + for profUUID, target := range installTargets { p, ok := profileContents[profUUID] if !ok { @@ -2347,7 +2351,7 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki hostCmdUUID := uuid.New().String() // Preprocess the profile content for this specific host - processedContent, err := microsoft_mdm.PreprocessWindowsProfileContentsForDeployment(ctx, logger, ds, appConfig, hostUUID, hostCmdUUID, profUUID, groupedCAs, string(p.SyncML)) + processedContent, err := microsoft_mdm.PreprocessWindowsProfileContentsForDeployment(ctx, logger, ds, appConfig, hostUUID, hostCmdUUID, profUUID, groupedCAs, string(p.SyncML), params) var profileProcessingError *microsoft_mdm.MicrosoftProfileProcessingError if err != nil && !errors.As(err, &profileProcessingError) { return ctxerr.Wrapf(ctx, err, "preprocessing profile contents for host %s and profile %s", hostUUID, profUUID) diff --git a/server/variables/variables.go b/server/variables/variables.go index 3757330cc0..c4e1a3a24a 100644 --- a/server/variables/variables.go +++ b/server/variables/variables.go @@ -26,12 +26,12 @@ var ProfileDataVariableRegex = regexp.MustCompile(`(\$FLEET_VAR_DIGICERT_DATA_(? // // For example, if the content contains "$FLEET_VAR_HOST_UUID" and "${FLEET_VAR_HOST_EMAIL}", // this function will return map[string]struct{}{"HOST_UUID": {}, "HOST_EMAIL": {}}. -func Find(contents string) map[string]struct{} { +func Find(contents string) []string { resultSlice := FindKeepDuplicates(contents) if len(resultSlice) == 0 { return nil } - return dedupe(resultSlice) + return Dedupe(resultSlice) } // FindKeepDuplicates finds all Fleet variables in the given content and returns them @@ -70,11 +70,19 @@ func FindKeepDuplicates(contents string) []string { return sortedResults } -// dedupe removes duplicates from the slice and returns a map for O(1) lookups. -func dedupe(varsWithDupes []string) map[string]struct{} { - result := make(map[string]struct{}, len(varsWithDupes)) +// Dedupe removes duplicates from the slice and returns a slice to keep order of variables +func Dedupe(varsWithDupes []string) []string { + if len(varsWithDupes) == 0 { + return []string{} + } + + seenMap := make(map[string]bool, len(varsWithDupes)) + var result []string for _, v := range varsWithDupes { - result[v] = struct{}{} + if !seenMap[v] { + result = append(result, v) + seenMap[v] = true + } } return result } diff --git a/server/variables/variables_test.go b/server/variables/variables_test.go index f83e621d81..45aa162107 100644 --- a/server/variables/variables_test.go +++ b/server/variables/variables_test.go @@ -10,7 +10,7 @@ func TestFind(t *testing.T) { tests := []struct { name string content string - expected map[string]struct{} + expected []string }{ { name: "no variables", @@ -20,45 +20,45 @@ func TestFind(t *testing.T) { { name: "single variable without braces", content: "Device ID: $FLEET_VAR_HOST_UUID", - expected: map[string]struct{}{ - "HOST_UUID": {}, + expected: []string{ + "HOST_UUID", }, }, { name: "single variable with braces", content: "Device ID: ${FLEET_VAR_HOST_UUID}", - expected: map[string]struct{}{ - "HOST_UUID": {}, + expected: []string{ + "HOST_UUID", }, }, { name: "multiple different variables", content: "Host: $FLEET_VAR_HOST_UUID, Email: ${FLEET_VAR_HOST_EMAIL}, Serial: $FLEET_VAR_HOST_SERIAL", - expected: map[string]struct{}{ - "HOST_UUID": {}, - "HOST_EMAIL": {}, - "HOST_SERIAL": {}, + expected: []string{ + "HOST_SERIAL", + "HOST_EMAIL", + "HOST_UUID", }, }, { name: "duplicate variables", content: "ID1: $FLEET_VAR_HOST_UUID, ID2: ${FLEET_VAR_HOST_UUID}, ID3: $FLEET_VAR_HOST_UUID", - expected: map[string]struct{}{ - "HOST_UUID": {}, + expected: []string{ + "HOST_UUID", }, }, { name: "variables in XML content", content: `Device: $FLEET_VAR_HOST_UUID`, - expected: map[string]struct{}{ - "HOST_UUID": {}, + expected: []string{ + "HOST_UUID", }, }, { name: "mixed case sensitivity", content: "Valid: $FLEET_VAR_HOST_UUID, Invalid: $fleet_var_host_uuid", - expected: map[string]struct{}{ - "HOST_UUID": {}, + expected: []string{ + "HOST_UUID", }, }, }