DCSW: Support all IDP variables in Windows config profiles (#34707)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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
This commit is contained in:
Magnus Jensen 2025-10-24 10:10:58 -03:00 committed by GitHub
parent b4aa28f7ee
commit ba15654498
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 343 additions and 352 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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: `<Replace><Data>ID1: $FLEET_VAR_HOST_UUID, ID2: ${FLEET_VAR_HOST_UUID}</Data></Replace>`,
expectedContents: `<Replace><Data>ID1: test-host-1234-uuid, ID2: test-host-1234-uuid</Data></Replace>`,
},
{
name: "fleet variable with db access",
hostUUID: "test-host-end-user-email",
profileContents: `<Replace><Data>ID: $FLEET_VAR_HOST_UUID, Other: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
expectedContents: `<Replace><Data>ID: test-host-end-user-email, Other: test@idp.com</Data></Replace>`,
},
{
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: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Device ID: $FLEET_VAR_HOST_UUID</Data></Item></Replace>`,
expectedContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>Device ID: test-uuid-456</Data></Item></Replace>`,
},
{
name: "host end user email idp",
hostUUID: "test-uuid-end-user-email",
profileContents: `<Replace><Data>Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
expectedContents: `<Replace><Data>Email: test@idp.com</Data></Replace>`,
},
{
name: "no host end user email idp found",
hostUUID: "test-uuid-no-end-user",
profileContents: `<Replace><Data>Email: $FLEET_VAR_HOST_END_USER_EMAIL_IDP</Data></Replace>`,
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: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>User: $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</Data></Item></Replace>`,
expectedContents: `<Replace><Item><Target><LocURI>./Device/Test</LocURI></Target><Data>User: test@idp.com - test - Group One,Group Two - Department - First Last</Data></Item></Replace>`,
},
}
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 != "" {

View file

@ -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 couldnt populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPGroups)
noDepartmentErr := fmt.Sprintf("There is no IdP department for this host. Fleet couldnt populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPDepartment)
noFullnameErr := fmt.Sprintf("There is no IdP full name for this host. Fleet couldnt 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) {

View file

@ -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 couldnt populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPGroups)
noDepartmentErr := fmt.Sprintf("There is no IdP department for this host. Fleet couldnt populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPDepartment)
noFullnameErr := fmt.Sprintf("There is no IdP full name for this host. Fleet couldnt 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 {

View file

@ -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)
}
})
}

View file

@ -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
////////////////////////////////////////////////////////////////////////////////

View file

@ -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 {

View file

@ -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)

View file

@ -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
}

View file

@ -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: `<Replace><Data>Device: $FLEET_VAR_HOST_UUID</Data></Replace>`,
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",
},
},
}