diff --git a/changes/29609-scim-add-department b/changes/29609-scim-add-department new file mode 100644 index 0000000000..a9ea64b3da --- /dev/null +++ b/changes/29609-scim-add-department @@ -0,0 +1 @@ +* Added support for ingesting user's "Department" via SCIM and added support to set the `FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT` variable on configuration profiles. diff --git a/ee/server/scim/scim.go b/ee/server/scim/scim.go index 57e67848c5..5cb3198b52 100644 --- a/ee/server/scim/scim.go +++ b/ee/server/scim/scim.go @@ -159,7 +159,23 @@ func RegisterSCIM( Endpoint: "/Users", Description: optional.NewString("User Account"), Schema: userSchema, - Handler: NewUserHandler(ds, scimLogger), + SchemaExtensions: []scim.SchemaExtension{ + { + Schema: schema.Schema{ + Attributes: []schema.CoreAttribute{ + schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{ + Name: "department", + Required: false, + })), + }, + Description: optional.NewString("Enterprise User"), + ID: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + Name: optional.NewString("Enterprise User"), + }, + Required: false, + }, + }, + Handler: NewUserHandler(ds, scimLogger), }, { ID: optional.NewString("Group"), diff --git a/ee/server/scim/users.go b/ee/server/scim/users.go index a74ab513ad..99b36e562f 100644 --- a/ee/server/scim/users.go +++ b/ee/server/scim/users.go @@ -34,6 +34,9 @@ const ( valueAttr = "value" typeAttr = "type" primaryAttr = "primary" + + extensionEnterpriseUserAttributes = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + departmentAttr = "department" ) type UserHandler struct { @@ -70,7 +73,7 @@ func (u *UserHandler) Create(r *http.Request, attributes scim.ResourceAttributes return scim.Resource{}, errors.ScimErrorUniqueness } - user, err := createUserFromAttributes(attributes) + user, err := u.createUserFromAttributes(attributes) if err != nil { level.Error(u.logger).Log("msg", "failed to create user from attributes", userNameAttr, userName, "err", err) return scim.Resource{}, err @@ -83,7 +86,7 @@ func (u *UserHandler) Create(r *http.Request, attributes scim.ResourceAttributes return createUserResource(user), nil } -func createUserFromAttributes(attributes scim.ResourceAttributes) (*fleet.ScimUser, error) { +func (u *UserHandler) createUserFromAttributes(attributes scim.ResourceAttributes) (*fleet.ScimUser, error) { user := fleet.ScimUser{} var err error user.UserName, err = getRequiredResource[string](attributes, userNameAttr) @@ -138,9 +141,48 @@ func createUserFromAttributes(attributes scim.ResourceAttributes) (*fleet.ScimUs userEmails = append(userEmails, userEmail) } user.Emails = userEmails + + // Attempt to get extension enterprise user attributes. + extendedAttributes := u.getExtensionEnterpriseUserAttributes(user.UserName, attributes) + user.Department = extendedAttributes.department + return &user, nil } +type extendedAttributes struct { + department *string +} + +func (u *UserHandler) getExtensionEnterpriseUserAttributes(userName string, attributes scim.ResourceAttributes) extendedAttributes { + var attrs extendedAttributes + m_, ok := attributes[extensionEnterpriseUserAttributes] + if !ok { + return attrs + } + m, ok := m_.(map[string]any) + if !ok { + level.Error(u.logger).Log( + "msg", fmt.Sprintf("unexpected type for %s: %T", extensionEnterpriseUserAttributes, m_), + userNameAttr, userName, + ) + return attrs + } + + // Attempt to get department attribute. + if department_, ok := m[departmentAttr]; ok { + if department, ok := department_.(string); ok { + attrs.department = &department + } else { + level.Error(u.logger).Log( + "msg", fmt.Sprintf("unexpected type for %s.department: %T", extensionEnterpriseUserAttributes, department_), + userNameAttr, userName, + ) + } + } + + return attrs +} + func getRequiredResource[T string | bool](attributes scim.ResourceAttributes, key string) (T, error) { var val T valIntf, ok := attributes[key] @@ -267,6 +309,11 @@ func createUserResource(user *fleet.ScimUser) scim.Resource { } userResource.Attributes[groupsAttr] = groups } + if user.Department != nil { + extensionEnterpriseUserAttributesMap := make(scim.ResourceAttributes) + extensionEnterpriseUserAttributesMap[departmentAttr] = *user.Department + userResource.Attributes[extensionEnterpriseUserAttributes] = extensionEnterpriseUserAttributesMap + } return userResource } @@ -354,7 +401,7 @@ func (u *UserHandler) Replace(r *http.Request, id string, attributes scim.Resour return scim.Resource{}, errors.ScimErrorResourceNotFound(id) } - user, err := createUserFromAttributes(attributes) + user, err := u.createUserFromAttributes(attributes) if err != nil { level.Error(u.logger).Log("msg", "failed to create user from attributes", "id", id, "err", err) return scim.Resource{}, err @@ -477,6 +524,11 @@ func (u *UserHandler) Patch(r *http.Request, id string, operations []scim.PatchO if err != nil { return scim.Resource{}, err } + case extensionEnterpriseUserAttributes + ":" + departmentAttr: + err = u.patchDepartment(op.Op, v, user) + if err != nil { + return scim.Resource{}, err + } default: level.Info(u.logger).Log("msg", "unsupported patch value field", "field", k) return scim.Resource{}, errors.ScimErrorBadParams([]string{fmt.Sprintf("%v", op)}) @@ -781,6 +833,19 @@ func (u *UserHandler) patchUserName(op string, v interface{}, user *fleet.ScimUs return nil } +func (u *UserHandler) patchDepartment(op string, v interface{}, user *fleet.ScimUser) error { + if op == scim.PatchOperationRemove || v == nil { + user.Department = nil + return nil + } + department, err := getConcreteType[string](u, v, departmentAttr) + if err != nil { + return err + } + user.Department = &department + return nil +} + func clearPrimaryFlagFromEmails(user *fleet.ScimUser) { for i, email := range user.Emails { if email.Primary != nil && *email.Primary { diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 3d0a560c9e..52ef9c900c 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -219,6 +219,7 @@ export interface IHostEndUser { idp_full_name?: string; idp_info_updated_at: string | null; idp_groups?: string[]; + idp_department?: string; other_emails?: Array<{ email: string; source: string; diff --git a/frontend/pages/hosts/details/cards/User/User.tsx b/frontend/pages/hosts/details/cards/User/User.tsx index ffea08778d..d1c2e7597e 100644 --- a/frontend/pages/hosts/details/cards/User/User.tsx +++ b/frontend/pages/hosts/details/cards/User/User.tsx @@ -55,6 +55,10 @@ const User = ({ const showGroups = showUsername && userNameDisplayValues.length > 0; const showChromeProfiles = chromeProfilesDisplayValues.length > 0; const showOtherEmails = otherEmailsDisplayValues.length > 0; + const userDepartment = []; + if (endUser?.idp_department) { + userDepartment.push(endUser.idp_department); + } return ( } /> )} + {showUsername && ( + + Department (IdP) + + } + value={} + /> + )} {showChromeProfiles && ( fleet.SCIMMaxFieldLength { return fmt.Errorf("family_name exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength) } + if user.Department != nil && len(*user.Department) > fleet.SCIMMaxFieldLength { + return fmt.Errorf("department exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength) + } return nil } @@ -1157,7 +1173,11 @@ func triggerResendProfilesForIDPUserChange(ctx context.Context, tx sqlx.ExtConte return err } return triggerResendProfilesUsingVariables(ctx, tx, hostIDs, - []string{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart}) + []string{ + fleet.FleetVarHostEndUserIDPUsername, + fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPDepartment, + }) } func triggerResendProfilesForIDPUserDeleted(ctx context.Context, tx sqlx.ExtContext, deletedScimUserID uint) error { @@ -1166,7 +1186,12 @@ func triggerResendProfilesForIDPUserDeleted(ctx context.Context, tx sqlx.ExtCont return err } return triggerResendProfilesUsingVariables(ctx, tx, hostIDs, - []string{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, fleet.FleetVarHostEndUserIDPGroups}) + []string{ + fleet.FleetVarHostEndUserIDPUsername, + fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPGroups, + fleet.FleetVarHostEndUserIDPDepartment, + }) } func triggerResendProfilesForIDPGroupChange(ctx context.Context, tx sqlx.ExtContext, updatedScimGroupID uint) error { @@ -1213,7 +1238,12 @@ func triggerResendProfilesForIDPUserAddedToHost(ctx context.Context, tx sqlx.Ext return nil } return triggerResendProfilesUsingVariables(ctx, tx, []uint{hostID}, - []string{fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, fleet.FleetVarHostEndUserIDPGroups}) + []string{ + fleet.FleetVarHostEndUserIDPUsername, + fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPDepartment, + fleet.FleetVarHostEndUserIDPGroups, + }) } func triggerResendProfilesUsingVariables(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint, affectedVars []string) error { diff --git a/server/datastore/mysql/scim_test.go b/server/datastore/mysql/scim_test.go index 7a537e9d62..a887bd8234 100644 --- a/server/datastore/mysql/scim_test.go +++ b/server/datastore/mysql/scim_test.go @@ -63,6 +63,7 @@ func testScimUserCreate(t *testing.T, ds *Datastore) { FamilyName: nil, Active: nil, Emails: []fleet.ScimUserEmail{}, + Department: nil, }, { UserName: "user2", @@ -77,6 +78,7 @@ func testScimUserCreate(t *testing.T, ds *Datastore) { Type: ptr.String("work"), }, }, + Department: ptr.String(""), }, { UserName: "user3", @@ -96,6 +98,7 @@ func testScimUserCreate(t *testing.T, ds *Datastore) { Type: ptr.String("work"), }, }, + Department: ptr.String("Development"), }, } @@ -114,6 +117,7 @@ func testScimUserCreate(t *testing.T, ds *Datastore) { assert.Equal(t, userCopy.GivenName, verify.GivenName) assert.Equal(t, userCopy.FamilyName, verify.FamilyName) assert.Equal(t, userCopy.Active, verify.Active) + assert.Equal(t, userCopy.Department, verify.Department) assert.False(t, verify.UpdatedAt.IsZero(), "UpdatedAt should not be zero") // Verify emails @@ -142,6 +146,7 @@ func testScimUserByID(t *testing.T, ds *Datastore) { assert.Equal(t, tt.GivenName, returned.GivenName) assert.Equal(t, tt.FamilyName, returned.FamilyName) assert.Equal(t, tt.Active, returned.Active) + assert.Equal(t, tt.Department, returned.Department) // Verify emails assert.Equal(t, len(tt.Emails), len(returned.Emails)) @@ -207,6 +212,7 @@ func testScimUserByUserName(t *testing.T, ds *Datastore) { assert.Equal(t, tt.GivenName, returned.GivenName) assert.Equal(t, tt.FamilyName, returned.FamilyName) assert.Equal(t, tt.Active, returned.Active) + assert.Equal(t, tt.Department, returned.Department) assert.False(t, returned.UpdatedAt.IsZero(), "UpdatedAt should not be zero") // Verify emails @@ -273,6 +279,7 @@ func createTestScimUsers(t *testing.T, ds *Datastore) []*fleet.ScimUser { Type: ptr.String("work"), }, }, + Department: nil, }, { UserName: "test-user2", @@ -292,6 +299,7 @@ func createTestScimUsers(t *testing.T, ds *Datastore) []*fleet.ScimUser { Type: ptr.String("work"), }, }, + Department: ptr.String("QA"), }, } @@ -1400,6 +1408,7 @@ func testScimUserByHostID(t *testing.T, ds *Datastore) { Type: ptr.String("work"), }, }, + Department: ptr.String("Engineering"), } var err error @@ -1415,7 +1424,7 @@ func testScimUserByHostID(t *testing.T, ds *Datastore) { group.ID, err = ds.CreateScimGroup(t.Context(), &group) require.NoError(t, err) - // Create a second test SCIM user without emails and without groups + // Create a second test SCIM user without emails, without groups nor department user2 := fleet.ScimUser{ UserName: "host-test-user2", ExternalID: ptr.String("ext-host-456"), @@ -1423,6 +1432,7 @@ func testScimUserByHostID(t *testing.T, ds *Datastore) { FamilyName: ptr.String("Emails"), Active: ptr.Bool(true), Emails: []fleet.ScimUserEmail{}, + Department: nil, } user2.ID, err = ds.CreateScimUser(t.Context(), &user2) require.Nil(t, err) @@ -1450,6 +1460,7 @@ func testScimUserByHostID(t *testing.T, ds *Datastore) { assert.Equal(t, user1.GivenName, result1.GivenName) assert.Equal(t, user1.FamilyName, result1.FamilyName) assert.Equal(t, user1.Active, result1.Active) + assert.Equal(t, user1.Department, result1.Department) assert.False(t, result1.UpdatedAt.IsZero(), "UpdatedAt should not be zero") // Verify emails @@ -1473,6 +1484,7 @@ func testScimUserByHostID(t *testing.T, ds *Datastore) { assert.Equal(t, user2.GivenName, result2.GivenName) assert.Equal(t, user2.FamilyName, result2.FamilyName) assert.Equal(t, user2.Active, result2.Active) + assert.Equal(t, user2.Department, result2.Department) assert.False(t, result2.UpdatedAt.IsZero(), "UpdatedAt should not be zero") // Verify no emails @@ -1616,6 +1628,7 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String("Original"), FamilyName: ptr.String("User"), Active: ptr.Bool(true), + Department: ptr.String("Customer support"), } var err error @@ -1633,6 +1646,7 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String("Valid"), FamilyName: ptr.String("Name"), Active: ptr.Bool(true), + Department: ptr.String("Customer support"), } err = ds.ReplaceScimUser(t.Context(), &userWithLongExternalID) assert.Error(t, err) @@ -1646,6 +1660,7 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String("Valid"), FamilyName: ptr.String("Name"), Active: ptr.Bool(true), + Department: ptr.String("Customer support"), } err = ds.ReplaceScimUser(t.Context(), &userWithLongUserName) assert.Error(t, err) @@ -1659,6 +1674,7 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String(longString), FamilyName: ptr.String("Name"), Active: ptr.Bool(true), + Department: ptr.String("Customer support"), } err = ds.ReplaceScimUser(t.Context(), &userWithLongGivenName) assert.Error(t, err) @@ -1672,11 +1688,26 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String("Valid"), FamilyName: ptr.String(longString), Active: ptr.Bool(true), + Department: ptr.String("Customer support"), } err = ds.ReplaceScimUser(t.Context(), &userWithLongFamilyName) assert.Error(t, err) assert.Contains(t, err.Error(), "family_name exceeds maximum length") + // Test Department validation + userWithLongDepartment := fleet.ScimUser{ + ID: user.ID, + UserName: "valid-username", + ExternalID: ptr.String("valid-external-id"), + GivenName: ptr.String("Valid"), + FamilyName: ptr.String("Valid"), + Active: ptr.Bool(true), + Department: ptr.String(longString), + } + err = ds.ReplaceScimUser(t.Context(), &userWithLongDepartment) + assert.Error(t, err) + assert.Contains(t, err.Error(), "department exceeds maximum length") + // Test with valid values validUser := fleet.ScimUser{ ID: user.ID, @@ -1685,9 +1716,22 @@ func testScimUserReplaceValidation(t *testing.T, ds *Datastore) { GivenName: ptr.String("Updated"), FamilyName: ptr.String("Name"), Active: ptr.Bool(true), + Department: ptr.String("Customer support updated"), } err = ds.ReplaceScimUser(t.Context(), &validUser) assert.NoError(t, err) + + updated, err := ds.ScimUserByID(t.Context(), user.ID) + assert.Nil(t, err) + assert.NotNil(t, updated) + assert.Equal(t, updated.ID, user.ID) + assert.Equal(t, updated.UserName, validUser.UserName) + assert.Equal(t, updated.ExternalID, validUser.ExternalID) + assert.Equal(t, updated.GivenName, validUser.GivenName) + assert.Equal(t, updated.FamilyName, validUser.FamilyName) + assert.Equal(t, updated.Active, validUser.Active) + assert.Equal(t, updated.Department, validUser.Department) + assert.Greater(t, updated.UpdatedAt, user.UpdatedAt) } func testScimLastRequest(t *testing.T, ds *Datastore) { diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 97db978ecf..25b66cb777 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -817,6 +817,7 @@ type HostEndUser struct { IdpUserName string `json:"idp_username,omitempty"` IdpFullName string `json:"idp_full_name,omitempty"` IdpGroups []string `json:"idp_groups,omitempty"` + Department string `json:"idp_department,omitempty"` IdpInfoUpdatedAt *time.Time `json:"idp_info_updated_at"` OtherEmails []HostDeviceMapping `json:"other_emails,omitempty"` } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 998498e049..b03008c069 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -39,6 +39,7 @@ const ( FleetVarHostEndUserIDPUsername = "HOST_END_USER_IDP_USERNAME" FleetVarHostEndUserIDPUsernameLocalPart = "HOST_END_USER_IDP_USERNAME_LOCAL_PART" FleetVarHostEndUserIDPGroups = "HOST_END_USER_IDP_GROUPS" + FleetVarHostEndUserIDPDepartment = "HOST_END_USER_IDP_DEPARTMENT" FleetVarSCEPRenewalID = "SCEP_RENEWAL_ID" FleetVarDigiCertDataPrefix = "DIGICERT_DATA_" diff --git a/server/fleet/scim.go b/server/fleet/scim.go index f64ff3291e..dab97f38d4 100644 --- a/server/fleet/scim.go +++ b/server/fleet/scim.go @@ -12,6 +12,7 @@ type ScimUser struct { UserName string `db:"user_name"` GivenName *string `db:"given_name"` FamilyName *string `db:"family_name"` + Department *string `db:"department"` Active *bool `db:"active"` UpdatedAt time.Time `db:"updated_at"` Emails []ScimUserEmail diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index b45dfe1f7f..88cfc3435a 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -73,6 +73,7 @@ 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)) fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID)) @@ -80,7 +81,7 @@ var ( fleetVarsSupportedInConfigProfiles = []string{ fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP, fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, - fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarSCEPRenewalID, + fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarSCEPRenewalID, } ) @@ -4575,7 +4576,7 @@ func preprocessProfileContents( case fleetVar == fleet.FleetVarHostEndUserEmailIDP || fleetVar == fleet.FleetVarHostHardwareSerial || fleetVar == fleet.FleetVarHostEndUserIDPUsername || fleetVar == fleet.FleetVarHostEndUserIDPUsernameLocalPart || - fleetVar == fleet.FleetVarHostEndUserIDPGroups || fleetVar == fleet.FleetVarSCEPRenewalID: + fleetVar == fleet.FleetVarHostEndUserIDPGroups || fleetVar == fleet.FleetVarHostEndUserIDPDepartment || fleetVar == fleet.FleetVarSCEPRenewalID: // No extra validation needed for these variables case strings.HasPrefix(fleetVar, fleet.FleetVarDigiCertPasswordPrefix) || strings.HasPrefix(fleetVar, fleet.FleetVarDigiCertDataPrefix): @@ -4799,7 +4800,7 @@ func preprocessProfileContents( hostContents = replaceFleetVariableInXML(fleetVarHostHardwareSerialRegexp, hostContents, hardwareSerial) case fleetVar == fleet.FleetVarHostEndUserIDPUsername || fleetVar == fleet.FleetVarHostEndUserIDPUsernameLocalPart || - fleetVar == fleet.FleetVarHostEndUserIDPGroups: + fleetVar == fleet.FleetVarHostEndUserIDPGroups || fleetVar == fleet.FleetVarHostEndUserIDPDepartment: user, ok, err := getHostEndUserIDPUser(ctx, ds, target, hostUUID, fleetVar, hostIDForUUIDCache) if err != nil { return ctxerr.Wrap(ctx, err, "getting host end user IDP username") @@ -4821,6 +4822,9 @@ func preprocessProfileContents( case fleet.FleetVarHostEndUserIDPGroups: rx = fleetVarHostEndUserIDPGroupsRegexp value = strings.Join(user.IdpGroups, ",") + case fleet.FleetVarHostEndUserIDPDepartment: + rx = fleetVarHostEndUserIDPDepartmentRegexp + value = user.Department } hostContents = replaceFleetVariableInXML(rx, hostContents, value) diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index de4e883ea6..8c0fa03f8b 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -4796,6 +4796,54 @@ func TestPreprocessProfileContentsEndUserIDP(t *testing.T) { assert.Contains(t, updatedProfile.Detail, "There is no IdP groups for this host. Fleet couldn’t populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") }, }, + { + desc: "profile with scim department", + profileContent: "$FLEET_VAR_" + fleet.FleetVarHostEndUserIDPDepartment, + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{}, + Department: ptr.String("Engineering"), + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "Engineering", output) + }, + }, + { + // Given department is not required, we don't make a profile fail if the user has no department, + // we replace by empty string instead. + desc: "profile with scim department, user has no department, replaces with empty string", + profileContent: "$FLEET_VAR_" + fleet.FleetVarHostEndUserIDPDepartment, + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{}, + Department: nil, + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Empty(t, output) // replaces to empty string + }, + }, } for _, c := range cases { @@ -5027,7 +5075,12 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) { name: "Custom profile with IdP variables happy path", profile: customProfileForValidation("value"), errMsg: "", - vars: []string{"HOST_END_USER_IDP_USERNAME", "HOST_END_USER_IDP_USERNAME_LOCAL_PART", "HOST_END_USER_IDP_GROUPS"}, + vars: []string{ + "HOST_END_USER_IDP_USERNAME", + "HOST_END_USER_IDP_USERNAME_LOCAL_PART", + "HOST_END_USER_IDP_GROUPS", + "HOST_END_USER_IDP_DEPARTMENT", + }, }, { name: "Custom SCEP and NDES 2 valid profiles should error", diff --git a/server/service/hosts.go b/server/service/hosts.go index ecb3c88fe0..cdf1f71aa5 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1378,12 +1378,16 @@ func getEndUsers(ctx context.Context, ds fleet.Datastore, hostID uint) ([]fleet. 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) } diff --git a/server/service/testdata/profiles/custom-profile-validation.mobileconfig b/server/service/testdata/profiles/custom-profile-validation.mobileconfig index 05c278456e..ed9f7fd54c 100644 --- a/server/service/testdata/profiles/custom-profile-validation.mobileconfig +++ b/server/service/testdata/profiles/custom-profile-validation.mobileconfig @@ -9,6 +9,8 @@ $FLEET_VAR_HOST_END_USER_IDP_USERNAME LocalPart ${FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART} + Department + ${FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT} Groups $FLEET_VAR_HOST_END_USER_IDP_GROUPS Other