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", }, }, }