From dd3885ac54a8186b38cdfdb848e06466165e60a1 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 9 Mar 2026 23:15:43 +0100 Subject: [PATCH] Fix MS365 attributes handling Signed-off-by: Bryan Frimin --- go.mod | 4 +- go.sum | 4 +- pkg/coredata/membership_profile.go | 344 ++++++- pkg/coredata/membership_profile_kind.go | 47 +- pkg/coredata/migrations/20260309T150000Z.sql | 6 + pkg/coredata/migrations/20260309T160000Z.sql | 14 + pkg/iam/scim/schema.go | 139 ++- pkg/iam/scim/service.go | 886 +++++++++++++++++-- pkg/server/api/connect/v1/scim_handler.go | 8 +- 9 files changed, 1280 insertions(+), 172 deletions(-) create mode 100644 pkg/coredata/migrations/20260309T150000Z.sql create mode 100644 pkg/coredata/migrations/20260309T160000Z.sql diff --git a/go.mod b/go.mod index bd5bf14b1..db949b1cf 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/crewjam/saml v0.5.1 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea - github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/jackc/pgx/v5 v5.8.0 @@ -70,6 +69,7 @@ require ( github.com/di-wu/xsd-datetime v1.0.0 // indirect github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect github.com/dnephin/pflag v1.0.7 // indirect + github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -155,3 +155,5 @@ tool ( ) replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5 + +replace github.com/elimity-com/scim => github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 diff --git a/go.sum b/go.sum index 21dcf50b8..70bde12f7 100644 --- a/go.sum +++ b/go.sum @@ -83,14 +83,14 @@ github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= -github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ= -github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 h1:bn2ml0JxH4DtQqi+CZ+ZPUBo1i3K+4xD0rHczpvpqFk= +github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3/go.mod h1:njybYNBd7EDyRMan05ticVQjPP6ThiuyCDbtRh9+e8A= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index ed92d30d5..fc16fda86 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -46,6 +46,23 @@ type ( OrganizationName string `db:"organization_name"` UserName *string `db:"user_name"` ExternalID *string `db:"external_id"` + NickName *string `db:"nick_name"` + Locale *string `db:"locale"` + Timezone *string `db:"timezone"` + ProfileUrl *string `db:"profile_url"` + PreferredLanguage *string `db:"preferred_language"` + GivenName *string `db:"given_name"` + FamilyName *string `db:"family_name"` + FormattedName *string `db:"formatted_name"` + MiddleName *string `db:"middle_name"` + HonorificPrefix *string `db:"honorific_prefix"` + HonorificSuffix *string `db:"honorific_suffix"` + EmployeeNumber *string `db:"employee_number"` + Department *string `db:"department"` + CostCenter *string `db:"cost_center"` + EnterpriseOrganization *string `db:"enterprise_organization"` + Division *string `db:"division"` + ManagerValue *string `db:"manager_value"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -111,6 +128,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -171,6 +205,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -234,6 +285,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -290,6 +358,23 @@ WITH profiles AS ( p.contract_end_date, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -316,6 +401,23 @@ SELECT '' AS organization_name, user_name, external_id, + nick_name, + locale, + timezone, + profile_url, + preferred_language, + given_name, + family_name, + formatted_name, + middle_name, + honorific_prefix, + honorific_suffix, + employee_number, + department, + cost_center, + enterprise_organization, + division, + manager_value, created_at, updated_at FROM profiles @@ -369,6 +471,23 @@ WITH profiles AS ( p.contract_end_date, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -394,6 +513,23 @@ SELECT o.name AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM profiles p @@ -446,6 +582,23 @@ WITH profiles AS ( mp.contract_end_date, mp.user_name, mp.external_id, + mp.nick_name, + mp.locale, + mp.timezone, + mp.profile_url, + mp.preferred_language, + mp.given_name, + mp.family_name, + mp.formatted_name, + mp.middle_name, + mp.honorific_prefix, + mp.honorific_suffix, + mp.employee_number, + mp.department, + mp.cost_center, + mp.enterprise_organization, + mp.division, + mp.manager_value, mp.created_at, mp.updated_at FROM @@ -475,6 +628,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM profiles p @@ -556,6 +726,23 @@ WITH profiles AS ( mp.contract_end_date, mp.user_name, mp.external_id, + mp.nick_name, + mp.locale, + mp.timezone, + mp.profile_url, + mp.preferred_language, + mp.given_name, + mp.family_name, + mp.formatted_name, + mp.middle_name, + mp.honorific_prefix, + mp.honorific_suffix, + mp.employee_number, + mp.department, + mp.cost_center, + mp.enterprise_organization, + mp.division, + mp.manager_value, mp.created_at, mp.updated_at FROM @@ -585,6 +772,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM profiles p @@ -667,6 +871,23 @@ WITH attendees AS ( p.contract_end_date, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at, ma.created_at AS attendee_created_at @@ -695,6 +916,23 @@ SELECT '' AS organization_name, user_name, external_id, + nick_name, + locale, + timezone, + profile_url, + preferred_language, + given_name, + family_name, + formatted_name, + middle_name, + honorific_prefix, + honorific_suffix, + employee_number, + department, + cost_center, + enterprise_organization, + division, + manager_value, created_at, updated_at FROM @@ -758,6 +996,23 @@ SELECT '' AS organization_name, p.user_name, p.external_id, + p.nick_name, + p.locale, + p.timezone, + p.profile_url, + p.preferred_language, + p.given_name, + p.family_name, + p.formatted_name, + p.middle_name, + p.honorific_prefix, + p.honorific_suffix, + p.employee_number, + p.department, + p.cost_center, + p.enterprise_organization, + p.division, + p.manager_value, p.created_at, p.updated_at FROM @@ -913,6 +1168,23 @@ INSERT INTO contract_end_date, user_name, external_id, + nick_name, + locale, + timezone, + profile_url, + preferred_language, + given_name, + family_name, + formatted_name, + middle_name, + honorific_prefix, + honorific_suffix, + employee_number, + department, + cost_center, + enterprise_organization, + division, + manager_value, created_at, updated_at ) @@ -931,6 +1203,23 @@ VALUES ( @contract_end_date, @user_name, @external_id, + @nick_name, + @locale, + @timezone, + @profile_url, + @preferred_language, + @given_name, + @family_name, + @formatted_name, + @middle_name, + @honorific_prefix, + @honorific_suffix, + @employee_number, + @department, + @cost_center, + @enterprise_organization, + @division, + @manager_value, @created_at, @updated_at ) @@ -950,7 +1239,24 @@ VALUES ( "contract_start_date": p.ContractStartDate, "contract_end_date": p.ContractEndDate, "user_name": p.UserName, - "external_id": p.ExternalID, + "external_id": p.ExternalID, + "nick_name": p.NickName, + "locale": p.Locale, + "timezone": p.Timezone, + "profile_url": p.ProfileUrl, + "preferred_language": p.PreferredLanguage, + "given_name": p.GivenName, + "family_name": p.FamilyName, + "formatted_name": p.FormattedName, + "middle_name": p.MiddleName, + "honorific_prefix": p.HonorificPrefix, + "honorific_suffix": p.HonorificSuffix, + "employee_number": p.EmployeeNumber, + "department": p.Department, + "cost_center": p.CostCenter, + "enterprise_organization": p.EnterpriseOrganization, + "division": p.Division, + "manager_value": p.ManagerValue, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt, } @@ -987,6 +1293,23 @@ SET contract_end_date = @contract_end_date, user_name = @user_name, external_id = @external_id, + nick_name = @nick_name, + locale = @locale, + timezone = @timezone, + profile_url = @profile_url, + preferred_language = @preferred_language, + given_name = @given_name, + family_name = @family_name, + formatted_name = @formatted_name, + middle_name = @middle_name, + honorific_prefix = @honorific_prefix, + honorific_suffix = @honorific_suffix, + employee_number = @employee_number, + department = @department, + cost_center = @cost_center, + enterprise_organization = @enterprise_organization, + division = @division, + manager_value = @manager_value, updated_at = @updated_at WHERE id = @id @@ -1006,7 +1329,24 @@ WHERE "contract_start_date": p.ContractStartDate, "contract_end_date": p.ContractEndDate, "user_name": p.UserName, - "external_id": p.ExternalID, + "external_id": p.ExternalID, + "nick_name": p.NickName, + "locale": p.Locale, + "timezone": p.Timezone, + "profile_url": p.ProfileUrl, + "preferred_language": p.PreferredLanguage, + "given_name": p.GivenName, + "family_name": p.FamilyName, + "formatted_name": p.FormattedName, + "middle_name": p.MiddleName, + "honorific_prefix": p.HonorificPrefix, + "honorific_suffix": p.HonorificSuffix, + "employee_number": p.EmployeeNumber, + "department": p.Department, + "cost_center": p.CostCenter, + "enterprise_organization": p.EnterpriseOrganization, + "division": p.Division, + "manager_value": p.ManagerValue, "updated_at": p.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) diff --git a/pkg/coredata/membership_profile_kind.go b/pkg/coredata/membership_profile_kind.go index 803086726..e76dde339 100644 --- a/pkg/coredata/membership_profile_kind.go +++ b/pkg/coredata/membership_profile_kind.go @@ -20,13 +20,13 @@ import ( ) type ( - MembershipProfileKind uint8 + MembershipProfileKind string ) const ( - MembershipProfileKindEmployee MembershipProfileKind = iota - MembershipProfileKindContractor - MembershipProfileKindServiceAccount + MembershipProfileKindEmployee MembershipProfileKind = "EMPLOYEE" + MembershipProfileKindContractor MembershipProfileKind = "CONTRACTOR" + MembershipProfileKindServiceAccount MembershipProfileKind = "SERVICE_ACCOUNT" ) func MembershipProfileKinds() []MembershipProfileKind { @@ -38,55 +38,30 @@ func MembershipProfileKinds() []MembershipProfileKind { } func (mpk MembershipProfileKind) MarshalText() ([]byte, error) { - return []byte(mpk.String()), nil + return []byte(mpk), nil } func (mpk *MembershipProfileKind) UnmarshalText(data []byte) error { - val := string(data) - - switch val { - case MembershipProfileKindEmployee.String(): - *mpk = MembershipProfileKindEmployee - case MembershipProfileKindContractor.String(): - *mpk = MembershipProfileKindContractor - case MembershipProfileKindServiceAccount.String(): - *mpk = MembershipProfileKindServiceAccount - default: - return fmt.Errorf("invalid MembershipProfileKind value: %q", val) - } - + *mpk = MembershipProfileKind(data) return nil } func (mpk MembershipProfileKind) String() string { - var val string - - switch mpk { - case MembershipProfileKindEmployee: - val = "EMPLOYEE" - case MembershipProfileKindContractor: - val = "CONTRACTOR" - case MembershipProfileKindServiceAccount: - val = "SERVICE_ACCOUNT" - } - - return val + return string(mpk) } func (mpk *MembershipProfileKind) Scan(value any) error { - var val string switch v := value.(type) { case string: - val = v + *mpk = MembershipProfileKind(v) case []byte: - val = string(v) + *mpk = MembershipProfileKind(v) default: return fmt.Errorf("unsupported type for MembershipProfileKind: %T", value) } - - return mpk.UnmarshalText([]byte(val)) + return nil } func (mpk MembershipProfileKind) Value() (driver.Value, error) { - return mpk.String(), nil + return string(mpk), nil } diff --git a/pkg/coredata/migrations/20260309T150000Z.sql b/pkg/coredata/migrations/20260309T150000Z.sql new file mode 100644 index 000000000..bf74ab6df --- /dev/null +++ b/pkg/coredata/migrations/20260309T150000Z.sql @@ -0,0 +1,6 @@ +ALTER TABLE iam_membership_profiles + ALTER COLUMN kind TYPE TEXT USING kind::TEXT, + ADD COLUMN nick_name TEXT, + ADD COLUMN locale TEXT, + ADD COLUMN timezone TEXT, + ADD COLUMN profile_url TEXT; diff --git a/pkg/coredata/migrations/20260309T160000Z.sql b/pkg/coredata/migrations/20260309T160000Z.sql new file mode 100644 index 000000000..1380526c0 --- /dev/null +++ b/pkg/coredata/migrations/20260309T160000Z.sql @@ -0,0 +1,14 @@ +ALTER TABLE iam_membership_profiles + ADD COLUMN preferred_language TEXT, + ADD COLUMN given_name TEXT, + ADD COLUMN family_name TEXT, + ADD COLUMN formatted_name TEXT, + ADD COLUMN middle_name TEXT, + ADD COLUMN honorific_prefix TEXT, + ADD COLUMN honorific_suffix TEXT, + ADD COLUMN employee_number TEXT, + ADD COLUMN department TEXT, + ADD COLUMN cost_center TEXT, + ADD COLUMN enterprise_organization TEXT, + ADD COLUMN division TEXT, + ADD COLUMN manager_value TEXT; diff --git a/pkg/iam/scim/schema.go b/pkg/iam/scim/schema.go index f3719040e..bbc517829 100644 --- a/pkg/iam/scim/schema.go +++ b/pkg/iam/scim/schema.go @@ -45,21 +45,12 @@ func UserSchema() schema.Schema { schema.ComplexParams{ Name: "name", SubAttributes: []schema.SimpleParams{ - schema.SimpleStringParams( - schema.StringParams{ - Name: "formatted", - }, - ), - schema.SimpleStringParams( - schema.StringParams{ - Name: "familyName", - }, - ), - schema.SimpleStringParams( - schema.StringParams{ - Name: "givenName", - }, - ), + schema.SimpleStringParams(schema.StringParams{Name: "formatted"}), + schema.SimpleStringParams(schema.StringParams{Name: "familyName"}), + schema.SimpleStringParams(schema.StringParams{Name: "givenName"}), + schema.SimpleStringParams(schema.StringParams{Name: "middleName"}), + schema.SimpleStringParams(schema.StringParams{Name: "honorificPrefix"}), + schema.SimpleStringParams(schema.StringParams{Name: "honorificSuffix"}), }, }, ), @@ -75,30 +66,108 @@ func UserSchema() schema.Schema { Name: "emails", MultiValued: true, SubAttributes: []schema.SimpleParams{ - schema.SimpleStringParams( - schema.StringParams{ - Name: "value", - }, - ), - schema.SimpleStringParams( - schema.StringParams{ - Name: "type", - }, - ), - schema.SimpleBooleanParams( - schema.BooleanParams{ - Name: "primary", - }, - ), + schema.SimpleStringParams(schema.StringParams{Name: "value"}), + schema.SimpleStringParams(schema.StringParams{Name: "type"}), + schema.SimpleBooleanParams(schema.BooleanParams{Name: "primary"}), + schema.SimpleStringParams(schema.StringParams{Name: "display"}), + }, + }, + ), + schema.ComplexCoreAttribute( + schema.ComplexParams{ + Name: "phoneNumbers", + MultiValued: true, + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{Name: "value"}), + schema.SimpleStringParams(schema.StringParams{Name: "type"}), + schema.SimpleBooleanParams(schema.BooleanParams{Name: "primary"}), + schema.SimpleStringParams(schema.StringParams{Name: "display"}), + }, + }, + ), + schema.ComplexCoreAttribute( + schema.ComplexParams{ + Name: "addresses", + MultiValued: true, + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{Name: "formatted"}), + schema.SimpleStringParams(schema.StringParams{Name: "streetAddress"}), + schema.SimpleStringParams(schema.StringParams{Name: "locality"}), + schema.SimpleStringParams(schema.StringParams{Name: "region"}), + schema.SimpleStringParams(schema.StringParams{Name: "postalCode"}), + schema.SimpleStringParams(schema.StringParams{Name: "country"}), + schema.SimpleStringParams(schema.StringParams{Name: "type"}), + schema.SimpleBooleanParams(schema.BooleanParams{Name: "primary"}), + }, + }, + ), + schema.ComplexCoreAttribute( + schema.ComplexParams{ + Name: "roles", + MultiValued: true, + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{Name: "value"}), + schema.SimpleStringParams(schema.StringParams{Name: "display"}), + schema.SimpleStringParams(schema.StringParams{Name: "type"}), + schema.SimpleBooleanParams(schema.BooleanParams{Name: "primary"}), }, }, ), schema.SimpleCoreAttribute( - schema.SimpleStringParams( - schema.StringParams{ - Name: "title", - }, - ), + schema.SimpleStringParams(schema.StringParams{Name: "title"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "userType"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "nickName"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "preferredLanguage"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "locale"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "timezone"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleReferenceParams(schema.ReferenceParams{Name: "profileUrl"}), + ), + }, + } +} + +func EnterpriseUserSchema() schema.Schema { + return schema.Schema{ + ID: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + Name: optional.NewString("EnterpriseUser"), + Description: optional.NewString("Enterprise User Extension"), + Attributes: []schema.CoreAttribute{ + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "employeeNumber"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "costCenter"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "organization"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "division"}), + ), + schema.SimpleCoreAttribute( + schema.SimpleStringParams(schema.StringParams{Name: "department"}), + ), + schema.ComplexCoreAttribute( + schema.ComplexParams{ + Name: "manager", + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{Name: "value"}), + schema.SimpleReferenceParams(schema.ReferenceParams{Name: "$ref"}), + schema.SimpleStringParams(schema.StringParams{Name: "displayName"}), + }, + }, ), }, } diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index bb54ba52c..a3209df24 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -32,6 +32,7 @@ import ( scimfilter "github.com/scim2/filter-parser/v2" "go.gearno.de/kit/log" "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" @@ -130,28 +131,28 @@ func (s *Service) CreateUser( config *coredata.SCIMConfiguration, attributes scim.ResourceAttributes, ) (scim.Resource, error) { - userName, email, fullName, active, title, externalId := ParseUserFromAttributes(attributes) - if userName == "" { + attrs := ParseUserFromAttributes(attributes) + if attrs.UserName == "" { return scim.Resource{}, scimerrors.ScimErrorBadRequest("userName is required") } - if email == "" { + if attrs.Email == "" { return scim.Resource{}, scimerrors.ScimErrorBadRequest("a valid email is required (via emails array or userName)") } - emailAddr, err := mail.ParseAddr(email) + emailAddr, err := mail.ParseAddr(attrs.Email) if err != nil { return scim.Resource{}, scimerrors.ScimErrorBadRequest("invalid email format") } now := time.Now() profileState := coredata.ProfileStateActive - if !active { + if !attrs.Active { profileState = coredata.ProfileStateInactive } var externalIdPtr *string - if externalId != "" { - externalIdPtr = &externalId + if attrs.ExternalID != "" { + externalIdPtr = &attrs.ExternalID } var membership *coredata.Membership @@ -166,7 +167,7 @@ func (s *Service) CreateUser( identity = &coredata.Identity{ ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), EmailAddress: emailAddr, - FullName: fullName, + FullName: attrs.FullName, HashedPassword: nil, EmailAddressVerified: false, CreatedAt: now, @@ -192,18 +193,39 @@ func (s *Service) CreateUser( ); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { profile = &coredata.MembershipProfile{ - ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipProfileEntityType), - IdentityID: identity.ID, - OrganizationID: config.OrganizationID, - EmailAddress: emailAddr, - Source: coredata.ProfileSourceSCIM, - State: profileState, - FullName: fullName, - Position: &title, - UserName: &userName, - ExternalID: externalIdPtr, - CreatedAt: now, - UpdatedAt: now, + ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipProfileEntityType), + IdentityID: identity.ID, + OrganizationID: config.OrganizationID, + EmailAddress: emailAddr, + Source: coredata.ProfileSourceSCIM, + State: profileState, + FullName: attrs.FullName, + Position: &attrs.Title, + UserName: &attrs.UserName, + ExternalID: externalIdPtr, + NickName: ref.RefOrNil(attrs.NickName), + Locale: ref.RefOrNil(attrs.Locale), + Timezone: ref.RefOrNil(attrs.Timezone), + ProfileUrl: ref.RefOrNil(attrs.ProfileUrl), + PreferredLanguage: ref.RefOrNil(attrs.PreferredLanguage), + GivenName: ref.RefOrNil(attrs.GivenName), + FamilyName: ref.RefOrNil(attrs.FamilyName), + FormattedName: ref.RefOrNil(attrs.FormattedName), + MiddleName: ref.RefOrNil(attrs.MiddleName), + HonorificPrefix: ref.RefOrNil(attrs.HonorificPrefix), + HonorificSuffix: ref.RefOrNil(attrs.HonorificSuffix), + EmployeeNumber: ref.RefOrNil(attrs.EmployeeNumber), + Department: ref.RefOrNil(attrs.Department), + CostCenter: ref.RefOrNil(attrs.CostCenter), + EnterpriseOrganization: ref.RefOrNil(attrs.EnterpriseOrganization), + Division: ref.RefOrNil(attrs.Division), + ManagerValue: ref.RefOrNil(attrs.ManagerValue), + CreatedAt: now, + UpdatedAt: now, + } + if attrs.UserType != "" { + kind := coredata.MembershipProfileKind(attrs.UserType) + profile.Kind = &kind } err = profile.Insert(ctx, tx) @@ -224,17 +246,38 @@ func (s *Service) CreateUser( profile.Source = coredata.ProfileSourceSCIM profile.State = profileState - profile.FullName = fullName - profile.Position = &title - profile.UserName = &userName + profile.FullName = attrs.FullName + profile.Position = &attrs.Title + profile.UserName = &attrs.UserName profile.ExternalID = externalIdPtr + profile.NickName = ref.RefOrNil(attrs.NickName) + profile.Locale = ref.RefOrNil(attrs.Locale) + profile.Timezone = ref.RefOrNil(attrs.Timezone) + profile.ProfileUrl = ref.RefOrNil(attrs.ProfileUrl) + profile.PreferredLanguage = ref.RefOrNil(attrs.PreferredLanguage) + profile.GivenName = ref.RefOrNil(attrs.GivenName) + profile.FamilyName = ref.RefOrNil(attrs.FamilyName) + profile.FormattedName = ref.RefOrNil(attrs.FormattedName) + profile.MiddleName = ref.RefOrNil(attrs.MiddleName) + profile.HonorificPrefix = ref.RefOrNil(attrs.HonorificPrefix) + profile.HonorificSuffix = ref.RefOrNil(attrs.HonorificSuffix) + profile.EmployeeNumber = ref.RefOrNil(attrs.EmployeeNumber) + profile.Department = ref.RefOrNil(attrs.Department) + profile.CostCenter = ref.RefOrNil(attrs.CostCenter) + profile.EnterpriseOrganization = ref.RefOrNil(attrs.EnterpriseOrganization) + profile.Division = ref.RefOrNil(attrs.Division) + profile.ManagerValue = ref.RefOrNil(attrs.ManagerValue) profile.UpdatedAt = now + if attrs.UserType != "" { + kind := coredata.MembershipProfileKind(attrs.UserType) + profile.Kind = &kind + } if err := profile.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update profile: %w", err) } } - if !active { + if !attrs.Active { invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) if err := invitations.ExpireByUserID( @@ -390,8 +433,8 @@ func (s *Service) ReplaceUser( profileID gid.GID, attributes scim.ResourceAttributes, ) (scim.Resource, error) { - fullName, active, title, userName, externalId := ParseUserFromReplaceAttributes(attributes) - profile, err := s.updateUser(ctx, config, profileID, fullName, active, title, userName, externalId) + attrs := ParseUserFromReplaceAttributes(attributes) + profile, err := s.updateUser(ctx, config, profileID, attrs) if err != nil { return scim.Resource{}, err } @@ -405,8 +448,31 @@ func (s *Service) PatchUser( profileID gid.GID, operations []scim.PatchOperation, ) (scim.Resource, error) { - fullName, active, title, userName, externalId := ParseUserFromPatchOperations(operations) - profile, err := s.updateUser(ctx, config, profileID, fullName, active, title, userName, externalId) + for i, op := range operations { + path := "" + if op.Path != nil { + path = op.Path.String() + } + s.logger.InfoCtx(ctx, "SCIM PATCH operation", + log.String("op_index", fmt.Sprintf("%d", i)), + log.String("op", op.Op), + log.String("path", path), + log.String("value_type", fmt.Sprintf("%T", op.Value)), + log.String("value", fmt.Sprintf("%v", op.Value)), + ) + } + + attrs := ParseUserFromPatchOperations(operations) + + s.logger.InfoCtx(ctx, "SCIM PATCH parsed attributes", + log.String("given_name", fmt.Sprintf("%v", attrs.GivenName)), + log.String("family_name", fmt.Sprintf("%v", attrs.FamilyName)), + log.String("formatted_name", fmt.Sprintf("%v", attrs.FormattedName)), + log.String("employee_number", fmt.Sprintf("%v", attrs.EmployeeNumber)), + log.String("department", fmt.Sprintf("%v", attrs.Department)), + ) + + profile, err := s.updateUser(ctx, config, profileID, attrs) if err != nil { return scim.Resource{}, err } @@ -418,11 +484,7 @@ func (s *Service) updateUser( ctx context.Context, config *coredata.SCIMConfiguration, profileID gid.GID, - fullName string, - active *bool, - title string, - userName *string, - externalId *string, + attrs scimReplaceAttributes, ) (*coredata.MembershipProfile, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) now := time.Now() @@ -453,29 +515,198 @@ func (s *Service) updateUser( return fmt.Errorf("cannot load membership: %w", err) } - shouldReactivate := active != nil && *active && profile.State == coredata.ProfileStateInactive - shouldDeactivate := active != nil && !*active && profile.State == coredata.ProfileStateActive + shouldReactivate := attrs.Active != nil && *attrs.Active && profile.State == coredata.ProfileStateInactive + shouldDeactivate := attrs.Active != nil && !*attrs.Active && profile.State == coredata.ProfileStateActive - if fullName != "" { - profile.FullName = fullName + if attrs.FullName != "" { + profile.FullName = attrs.FullName profile.UpdatedAt = now } - if title == "" { + if attrs.Title != nil { + if *attrs.Title == "" { profile.Position = nil } else { - profile.Position = &title + profile.Position = attrs.Title } + } - if userName != nil { - profile.UserName = userName + if attrs.UserName != nil { + profile.UserName = attrs.UserName profile.UpdatedAt = now } - if externalId != nil { - profile.ExternalID = externalId - profile.UpdatedAt = now + if attrs.ExternalID != nil { + if *attrs.ExternalID == "" { + profile.ExternalID = nil + } else { + profile.ExternalID = attrs.ExternalID } + profile.UpdatedAt = now + } + + if attrs.UserType != nil { + if *attrs.UserType == "" { + profile.Kind = nil + } else { + kind := coredata.MembershipProfileKind(*attrs.UserType) + profile.Kind = &kind + } + profile.UpdatedAt = now + } + + if attrs.NickName != nil { + if *attrs.NickName == "" { + profile.NickName = nil + } else { + profile.NickName = attrs.NickName + } + profile.UpdatedAt = now + } + + if attrs.Locale != nil { + if *attrs.Locale == "" { + profile.Locale = nil + } else { + profile.Locale = attrs.Locale + } + profile.UpdatedAt = now + } + + if attrs.Timezone != nil { + if *attrs.Timezone == "" { + profile.Timezone = nil + } else { + profile.Timezone = attrs.Timezone + } + profile.UpdatedAt = now + } + + if attrs.ProfileUrl != nil { + if *attrs.ProfileUrl == "" { + profile.ProfileUrl = nil + } else { + profile.ProfileUrl = attrs.ProfileUrl + } + profile.UpdatedAt = now + } + + if attrs.PreferredLanguage != nil { + if *attrs.PreferredLanguage == "" { + profile.PreferredLanguage = nil + } else { + profile.PreferredLanguage = attrs.PreferredLanguage + } + profile.UpdatedAt = now + } + + if attrs.GivenName != nil { + if *attrs.GivenName == "" { + profile.GivenName = nil + } else { + profile.GivenName = attrs.GivenName + } + profile.UpdatedAt = now + } + + if attrs.FamilyName != nil { + if *attrs.FamilyName == "" { + profile.FamilyName = nil + } else { + profile.FamilyName = attrs.FamilyName + } + profile.UpdatedAt = now + } + + if attrs.FormattedName != nil { + if *attrs.FormattedName == "" { + profile.FormattedName = nil + } else { + profile.FormattedName = attrs.FormattedName + } + profile.UpdatedAt = now + } + + if attrs.MiddleName != nil { + if *attrs.MiddleName == "" { + profile.MiddleName = nil + } else { + profile.MiddleName = attrs.MiddleName + } + profile.UpdatedAt = now + } + + if attrs.HonorificPrefix != nil { + if *attrs.HonorificPrefix == "" { + profile.HonorificPrefix = nil + } else { + profile.HonorificPrefix = attrs.HonorificPrefix + } + profile.UpdatedAt = now + } + + if attrs.HonorificSuffix != nil { + if *attrs.HonorificSuffix == "" { + profile.HonorificSuffix = nil + } else { + profile.HonorificSuffix = attrs.HonorificSuffix + } + profile.UpdatedAt = now + } + + if attrs.EmployeeNumber != nil { + if *attrs.EmployeeNumber == "" { + profile.EmployeeNumber = nil + } else { + profile.EmployeeNumber = attrs.EmployeeNumber + } + profile.UpdatedAt = now + } + + if attrs.Department != nil { + if *attrs.Department == "" { + profile.Department = nil + } else { + profile.Department = attrs.Department + } + profile.UpdatedAt = now + } + + if attrs.CostCenter != nil { + if *attrs.CostCenter == "" { + profile.CostCenter = nil + } else { + profile.CostCenter = attrs.CostCenter + } + profile.UpdatedAt = now + } + + if attrs.EnterpriseOrganization != nil { + if *attrs.EnterpriseOrganization == "" { + profile.EnterpriseOrganization = nil + } else { + profile.EnterpriseOrganization = attrs.EnterpriseOrganization + } + profile.UpdatedAt = now + } + + if attrs.Division != nil { + if *attrs.Division == "" { + profile.Division = nil + } else { + profile.Division = attrs.Division + } + profile.UpdatedAt = now + } + + if attrs.ManagerValue != nil { + if *attrs.ManagerValue == "" { + profile.ManagerValue = nil + } else { + profile.ManagerValue = attrs.ManagerValue + } + profile.UpdatedAt = now + } if shouldReactivate { profile.State = coredata.ProfileStateActive @@ -495,7 +726,6 @@ func (s *Service) updateUser( } if shouldDeactivate { - // Expire pending invitations for user invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) if err := invitations.ExpireByUserID( @@ -511,7 +741,7 @@ func (s *Service) updateUser( needsUpdate := shouldReactivate || shouldDeactivate - if active != nil { + if attrs.Active != nil { identity := &coredata.Identity{} if err := identity.LoadByID(ctx, tx, membership.IdentityID); err != nil { return fmt.Errorf("cannot load identity: %w", err) @@ -659,103 +889,302 @@ func (s *Service) createEvent( return event } -func ParseUserFromAttributes(attributes scim.ResourceAttributes) (userName, email, fullName string, active bool, title, externalId string) { - userName, _ = attributes["userName"].(string) - displayName, _ := attributes["displayName"].(string) - externalId, _ = attributes["externalId"].(string) +type scimUserAttributes struct { + UserName string + Email string + FullName string + Active bool + Title string + ExternalID string + UserType string + NickName string + Locale string + Timezone string + ProfileUrl string + PreferredLanguage string + GivenName string + FamilyName string + FormattedName string + MiddleName string + HonorificPrefix string + HonorificSuffix string + EmployeeNumber string + Department string + CostCenter string + EnterpriseOrganization string + Division string + ManagerValue string +} - active = true +func ParseUserFromAttributes(attributes scim.ResourceAttributes) scimUserAttributes { + var attrs scimUserAttributes + + attrs.UserName, _ = attributes["userName"].(string) + displayName, _ := attributes["displayName"].(string) + attrs.ExternalID, _ = attributes["externalId"].(string) + + attrs.Active = true if a, ok := attributes["active"].(bool); ok { - active = a + attrs.Active = a } var givenName, familyName string if name, ok := attributes["name"].(map[string]any); ok { givenName, _ = name["givenName"].(string) familyName, _ = name["familyName"].(string) + attrs.FormattedName, _ = name["formatted"].(string) + attrs.MiddleName, _ = name["middleName"].(string) + attrs.HonorificPrefix, _ = name["honorificPrefix"].(string) + attrs.HonorificSuffix, _ = name["honorificSuffix"].(string) } + attrs.GivenName = givenName + attrs.FamilyName = familyName - // Get email from emails array first if emails, ok := attributes["emails"].([]any); ok && len(emails) > 0 { for _, e := range emails { if emailMap, ok := e.(map[string]any); ok { if primary, _ := emailMap["primary"].(bool); primary { if value, ok := emailMap["value"].(string); ok { - email = value + attrs.Email = value break } } } } - if email == "" { + if attrs.Email == "" { if emailMap, ok := emails[0].(map[string]any); ok { if value, ok := emailMap["value"].(string); ok { - email = value + attrs.Email = value } } } } - // Fall back to userName only if it parses as a valid email - if email == "" { - if _, err := mail.ParseAddr(userName); err == nil { - email = userName + if attrs.Email == "" { + if _, err := mail.ParseAddr(attrs.UserName); err == nil { + attrs.Email = attrs.UserName } } - fullName = displayName - if fullName == "" { - fullName = strings.TrimSpace(givenName + " " + familyName) + attrs.FullName = displayName + if attrs.FullName == "" { + attrs.FullName = strings.TrimSpace(givenName + " " + familyName) } - if fullName == "" { - fullName = userName + if attrs.FullName == "" { + attrs.FullName = attrs.UserName } - if t, ok := attributes["title"].(string); ok { - title = t + attrs.Title, _ = attributes["title"].(string) + attrs.UserType, _ = attributes["userType"].(string) + attrs.NickName, _ = attributes["nickName"].(string) + attrs.Locale, _ = attributes["locale"].(string) + attrs.Timezone, _ = attributes["timezone"].(string) + attrs.ProfileUrl, _ = attributes["profileUrl"].(string) + attrs.PreferredLanguage, _ = attributes["preferredLanguage"].(string) + + if enterprise, ok := attributes["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].(map[string]any); ok { + attrs.EmployeeNumber, _ = enterprise["employeeNumber"].(string) + attrs.Department, _ = enterprise["department"].(string) + attrs.CostCenter, _ = enterprise["costCenter"].(string) + attrs.EnterpriseOrganization, _ = enterprise["organization"].(string) + attrs.Division, _ = enterprise["division"].(string) + if manager, ok := enterprise["manager"].(map[string]any); ok { + attrs.ManagerValue, _ = manager["value"].(string) + } } - return userName, email, fullName, active, title, externalId + return attrs } -func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool, title string, userName *string, externalId *string) { +type scimReplaceAttributes struct { + FullName string + Active *bool + Title *string + UserName *string + ExternalID *string + UserType *string + NickName *string + Locale *string + Timezone *string + ProfileUrl *string + PreferredLanguage *string + GivenName *string + FamilyName *string + FormattedName *string + MiddleName *string + HonorificPrefix *string + HonorificSuffix *string + EmployeeNumber *string + Department *string + CostCenter *string + EnterpriseOrganization *string + Division *string + ManagerValue *string +} + +func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) scimReplaceAttributes { + var attrs scimReplaceAttributes displayName, _ := attributes["displayName"].(string) var givenName, familyName string if name, ok := attributes["name"].(map[string]any); ok { givenName, _ = name["givenName"].(string) familyName, _ = name["familyName"].(string) + if fn, ok := name["formatted"].(string); ok { + attrs.FormattedName = &fn + } + if mn, ok := name["middleName"].(string); ok { + attrs.MiddleName = &mn + } + if hp, ok := name["honorificPrefix"].(string); ok { + attrs.HonorificPrefix = &hp + } + if hs, ok := name["honorificSuffix"].(string); ok { + attrs.HonorificSuffix = &hs + } } + attrs.GivenName = &givenName + attrs.FamilyName = &familyName - fullName = displayName - if fullName == "" { - fullName = strings.TrimSpace(givenName + " " + familyName) + attrs.FullName = displayName + if attrs.FullName == "" { + attrs.FullName = strings.TrimSpace(givenName + " " + familyName) } activeVal := true if a, ok := attributes["active"].(bool); ok { activeVal = a } + attrs.Active = &activeVal - if t, ok := attributes["title"].(string); ok { - title = t - } + t, _ := attributes["title"].(string) + attrs.Title = &t if un, ok := attributes["userName"].(string); ok && un != "" { - userName = &un + attrs.UserName = &un } if eid, ok := attributes["externalId"].(string); ok && eid != "" { - externalId = &eid + attrs.ExternalID = &eid } - return fullName, &activeVal, title, userName, externalId + if ut, ok := attributes["userType"].(string); ok { + attrs.UserType = &ut + } + if nn, ok := attributes["nickName"].(string); ok { + attrs.NickName = &nn + } + if l, ok := attributes["locale"].(string); ok { + attrs.Locale = &l + } + if tz, ok := attributes["timezone"].(string); ok { + attrs.Timezone = &tz + } + if pu, ok := attributes["profileUrl"].(string); ok { + attrs.ProfileUrl = &pu + } + if pl, ok := attributes["preferredLanguage"].(string); ok { + attrs.PreferredLanguage = &pl + } + + if enterprise, ok := attributes["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].(map[string]any); ok { + if en, ok := enterprise["employeeNumber"].(string); ok { + attrs.EmployeeNumber = &en + } + if dept, ok := enterprise["department"].(string); ok { + attrs.Department = &dept + } + if cc, ok := enterprise["costCenter"].(string); ok { + attrs.CostCenter = &cc + } + if org, ok := enterprise["organization"].(string); ok { + attrs.EnterpriseOrganization = &org + } + if div, ok := enterprise["division"].(string); ok { + attrs.Division = &div + } + if manager, ok := enterprise["manager"].(map[string]any); ok { + if mv, ok := manager["value"].(string); ok { + attrs.ManagerValue = &mv + } + } + } + + return attrs } -func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool, title string, userName *string, externalId *string) { +func ParseUserFromPatchOperations(operations []scim.PatchOperation) scimReplaceAttributes { + var attrs scimReplaceAttributes var givenName, familyName string + empty := "" for _, op := range operations { + if strings.EqualFold(op.Op, "remove") { + path := "" + if op.Path != nil { + path = op.Path.String() + } + + switch strings.ToLower(path) { + case "title": + attrs.Title = &empty + case "usertype": + attrs.UserType = &empty + case "nickname": + attrs.NickName = &empty + case "locale": + attrs.Locale = &empty + case "timezone": + attrs.Timezone = &empty + case "profileurl": + attrs.ProfileUrl = &empty + case "externalid": + attrs.ExternalID = &empty + case "preferredlanguage": + attrs.PreferredLanguage = &empty + case "name": + attrs.GivenName = &empty + attrs.FamilyName = &empty + attrs.FormattedName = &empty + attrs.MiddleName = &empty + attrs.HonorificPrefix = &empty + attrs.HonorificSuffix = &empty + case "name.givenname": + attrs.GivenName = &empty + case "name.familyname": + attrs.FamilyName = &empty + case "name.formatted": + attrs.FormattedName = &empty + case "name.middlename": + attrs.MiddleName = &empty + case "name.honorificprefix": + attrs.HonorificPrefix = &empty + case "name.honorificsuffix": + attrs.HonorificSuffix = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user": + attrs.EmployeeNumber = &empty + attrs.Department = &empty + attrs.CostCenter = &empty + attrs.EnterpriseOrganization = &empty + attrs.Division = &empty + attrs.ManagerValue = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:employeenumber": + attrs.EmployeeNumber = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": + attrs.Department = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:costcenter": + attrs.CostCenter = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:organization": + attrs.EnterpriseOrganization = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:division": + attrs.Division = &empty + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:manager", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:manager.value": + attrs.ManagerValue = &empty + } + continue + } + if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") { path := "" if op.Path != nil { @@ -765,10 +1194,10 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st if path == "" { if valueMap, ok := op.Value.(map[string]any); ok { if a, ok := valueMap["active"].(bool); ok { - active = &a + attrs.Active = &a } if name, ok := valueMap["displayName"].(string); ok { - fullName = name + attrs.FullName = name } if nameMap, ok := valueMap["name"].(map[string]any); ok { if gn, ok := nameMap["givenName"].(string); ok { @@ -777,12 +1206,122 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st if fn, ok := nameMap["familyName"].(string); ok { familyName = fn } + if fm, ok := nameMap["formatted"].(string); ok { + attrs.FormattedName = &fm + } + if mn, ok := nameMap["middleName"].(string); ok { + attrs.MiddleName = &mn + } + if hp, ok := nameMap["honorificPrefix"].(string); ok { + attrs.HonorificPrefix = &hp + } + if hs, ok := nameMap["honorificSuffix"].(string); ok { + attrs.HonorificSuffix = &hs + } } if un, ok := valueMap["userName"].(string); ok && un != "" { - userName = &un + attrs.UserName = &un } if eid, ok := valueMap["externalId"].(string); ok && eid != "" { - externalId = &eid + attrs.ExternalID = &eid + } + if t, ok := valueMap["title"].(string); ok { + attrs.Title = &t + } + if ut, ok := valueMap["userType"].(string); ok { + attrs.UserType = &ut + } + if nn, ok := valueMap["nickName"].(string); ok { + attrs.NickName = &nn + } + if l, ok := valueMap["locale"].(string); ok { + attrs.Locale = &l + } + if tz, ok := valueMap["timezone"].(string); ok { + attrs.Timezone = &tz + } + if pu, ok := valueMap["profileUrl"].(string); ok { + attrs.ProfileUrl = &pu + } + if pl, ok := valueMap["preferredLanguage"].(string); ok { + attrs.PreferredLanguage = &pl + } + if enterprise, ok := valueMap["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].(map[string]any); ok { + if en, ok := enterprise["employeeNumber"].(string); ok { + attrs.EmployeeNumber = &en + } + if dept, ok := enterprise["department"].(string); ok { + attrs.Department = &dept + } + if cc, ok := enterprise["costCenter"].(string); ok { + attrs.CostCenter = &cc + } + if org, ok := enterprise["organization"].(string); ok { + attrs.EnterpriseOrganization = &org + } + if div, ok := enterprise["division"].(string); ok { + attrs.Division = &div + } + if manager, ok := enterprise["manager"].(map[string]any); ok { + if mv, ok := manager["value"].(string); ok { + attrs.ManagerValue = &mv + } + } + } + + for key, val := range valueMap { + switch strings.ToLower(key) { + case "name.givenname": + if s, ok := val.(string); ok { + givenName = s + attrs.GivenName = &givenName + } + case "name.familyname": + if s, ok := val.(string); ok { + familyName = s + attrs.FamilyName = &familyName + } + case "name.formatted": + if s, ok := val.(string); ok { + attrs.FormattedName = &s + } + case "name.middlename": + if s, ok := val.(string); ok { + attrs.MiddleName = &s + } + case "name.honorificprefix": + if s, ok := val.(string); ok { + attrs.HonorificPrefix = &s + } + case "name.honorificsuffix": + if s, ok := val.(string); ok { + attrs.HonorificSuffix = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:employeenumber": + if s, ok := val.(string); ok { + attrs.EmployeeNumber = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": + if s, ok := val.(string); ok { + attrs.Department = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:costcenter": + if s, ok := val.(string); ok { + attrs.CostCenter = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:organization": + if s, ok := val.(string); ok { + attrs.EnterpriseOrganization = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:division": + if s, ok := val.(string); ok { + attrs.Division = &s + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:manager.value": + if s, ok := val.(string); ok { + attrs.ManagerValue = &s + } + } } } continue @@ -791,41 +1330,168 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st switch strings.ToLower(path) { case "active": if a, ok := op.Value.(bool); ok { - active = &a + attrs.Active = &a } case "displayname": if name, ok := op.Value.(string); ok { - fullName = name + attrs.FullName = name + } + case "name": + if nameMap, ok := op.Value.(map[string]any); ok { + if gn, ok := nameMap["givenName"].(string); ok { + givenName = gn + attrs.GivenName = &givenName + } + if fn, ok := nameMap["familyName"].(string); ok { + familyName = fn + attrs.FamilyName = &familyName + } + if fm, ok := nameMap["formatted"].(string); ok { + attrs.FormattedName = &fm + } + if mn, ok := nameMap["middleName"].(string); ok { + attrs.MiddleName = &mn + } + if hp, ok := nameMap["honorificPrefix"].(string); ok { + attrs.HonorificPrefix = &hp + } + if hs, ok := nameMap["honorificSuffix"].(string); ok { + attrs.HonorificSuffix = &hs + } } case "name.givenname": if name, ok := op.Value.(string); ok { givenName = name + attrs.GivenName = &givenName } case "name.familyname": if name, ok := op.Value.(string); ok { familyName = name + attrs.FamilyName = &familyName + } + case "name.formatted": + if fm, ok := op.Value.(string); ok { + attrs.FormattedName = &fm + } + case "name.middlename": + if mn, ok := op.Value.(string); ok { + attrs.MiddleName = &mn + } + case "name.honorificprefix": + if hp, ok := op.Value.(string); ok { + attrs.HonorificPrefix = &hp + } + case "name.honorificsuffix": + if hs, ok := op.Value.(string); ok { + attrs.HonorificSuffix = &hs } case "title": if t, ok := op.Value.(string); ok { - title = t + attrs.Title = &t } case "username": if un, ok := op.Value.(string); ok && un != "" { - userName = &un + attrs.UserName = &un } case "externalid": if eid, ok := op.Value.(string); ok && eid != "" { - externalId = &eid + attrs.ExternalID = &eid + } + case "usertype": + if ut, ok := op.Value.(string); ok { + attrs.UserType = &ut + } + case "nickname": + if nn, ok := op.Value.(string); ok { + attrs.NickName = &nn + } + case "locale": + if l, ok := op.Value.(string); ok { + attrs.Locale = &l + } + case "timezone": + if tz, ok := op.Value.(string); ok { + attrs.Timezone = &tz + } + case "profileurl": + if pu, ok := op.Value.(string); ok { + attrs.ProfileUrl = &pu + } + case "preferredlanguage": + if pl, ok := op.Value.(string); ok { + attrs.PreferredLanguage = &pl + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user": + if enterprise, ok := op.Value.(map[string]any); ok { + if en, ok := enterprise["employeeNumber"].(string); ok { + attrs.EmployeeNumber = &en + } + if dept, ok := enterprise["department"].(string); ok { + attrs.Department = &dept + } + if cc, ok := enterprise["costCenter"].(string); ok { + attrs.CostCenter = &cc + } + if org, ok := enterprise["organization"].(string); ok { + attrs.EnterpriseOrganization = &org + } + if div, ok := enterprise["division"].(string); ok { + attrs.Division = &div + } + if manager, ok := enterprise["manager"].(map[string]any); ok { + if mv, ok := manager["value"].(string); ok { + attrs.ManagerValue = &mv + } + } + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:employeenumber": + if en, ok := op.Value.(string); ok { + attrs.EmployeeNumber = &en + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": + if dept, ok := op.Value.(string); ok { + attrs.Department = &dept + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:costcenter": + if cc, ok := op.Value.(string); ok { + attrs.CostCenter = &cc + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:organization": + if org, ok := op.Value.(string); ok { + attrs.EnterpriseOrganization = &org + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:division": + if div, ok := op.Value.(string); ok { + attrs.Division = &div + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:manager": + if manager, ok := op.Value.(map[string]any); ok { + if mv, ok := manager["value"].(string); ok { + attrs.ManagerValue = &mv + } + } else if mv, ok := op.Value.(string); ok { + attrs.ManagerValue = &mv + } + case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:manager.value": + if mv, ok := op.Value.(string); ok { + attrs.ManagerValue = &mv } } } } - if fullName == "" && (givenName != "" || familyName != "") { - fullName = strings.TrimSpace(givenName + " " + familyName) + if attrs.FullName == "" && (givenName != "" || familyName != "") { + attrs.FullName = strings.TrimSpace(givenName + " " + familyName) } - return fullName, active, title, userName, externalId + if givenName != "" && attrs.GivenName == nil { + attrs.GivenName = &givenName + } + if familyName != "" && attrs.FamilyName == nil { + attrs.FamilyName = &familyName + } + + return attrs } func userToResource(p *coredata.MembershipProfile) scim.Resource { @@ -834,6 +1500,31 @@ func userToResource(p *coredata.MembershipProfile) scim.Resource { externalID = optional.NewString(*p.ExternalID) } + formattedName := p.FullName + if p.FormattedName != nil { + formattedName = *p.FormattedName + } + + nameMap := map[string]any{ + "formatted": formattedName, + "givenName": ref.UnrefOrZero(p.GivenName), + "familyName": ref.UnrefOrZero(p.FamilyName), + "middleName": ref.UnrefOrZero(p.MiddleName), + "honorificPrefix": ref.UnrefOrZero(p.HonorificPrefix), + "honorificSuffix": ref.UnrefOrZero(p.HonorificSuffix), + } + + enterpriseAttrs := map[string]any{ + "employeeNumber": ref.UnrefOrZero(p.EmployeeNumber), + "department": ref.UnrefOrZero(p.Department), + "costCenter": ref.UnrefOrZero(p.CostCenter), + "organization": ref.UnrefOrZero(p.EnterpriseOrganization), + "division": ref.UnrefOrZero(p.Division), + "manager": map[string]any{ + "value": ref.UnrefOrZero(p.ManagerValue), + }, + } + return scim.Resource{ ID: p.ID.String(), ExternalID: externalID, @@ -841,9 +1532,7 @@ func userToResource(p *coredata.MembershipProfile) scim.Resource { "userName": *p.UserName, "displayName": p.FullName, "active": p.State == coredata.ProfileStateActive, - "name": map[string]any{ - "formatted": p.FullName, - }, + "name": nameMap, "emails": []map[string]any{ { "value": p.EmailAddress.String(), @@ -851,7 +1540,14 @@ func userToResource(p *coredata.MembershipProfile) scim.Resource { "primary": true, }, }, - "title": p.Position, + "title": p.Position, + "userType": string(ref.UnrefOrZero(p.Kind)), + "nickName": ref.UnrefOrZero(p.NickName), + "locale": ref.UnrefOrZero(p.Locale), + "timezone": ref.UnrefOrZero(p.Timezone), + "profileUrl": ref.UnrefOrZero(p.ProfileUrl), + "preferredLanguage": ref.UnrefOrZero(p.PreferredLanguage), + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": enterpriseAttrs, }, Meta: scim.Meta{ Created: &p.CreatedAt, diff --git a/pkg/server/api/connect/v1/scim_handler.go b/pkg/server/api/connect/v1/scim_handler.go index 44428bf71..15bdfcebb 100644 --- a/pkg/server/api/connect/v1/scim_handler.go +++ b/pkg/server/api/connect/v1/scim_handler.go @@ -23,6 +23,7 @@ import ( "github.com/elimity-com/scim" scimerrors "github.com/elimity-com/scim/errors" "github.com/elimity-com/scim/optional" + "github.com/elimity-com/scim/schema" scimfilter "github.com/scim2/filter-parser/v2" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" @@ -71,6 +72,8 @@ func scimConfigFromContext(ctx context.Context) *coredata.SCIMConfiguration { // NewSCIMServer creates a new SCIM server using elimity-com/scim func NewSCIMServer(h *SCIMHandler) http.Handler { + schema.SetAllowStringValues(true) + resourceTypes := []scim.ResourceType{ { ID: optional.NewString("User"), @@ -78,7 +81,10 @@ func NewSCIMServer(h *SCIMHandler) http.Handler { Endpoint: "/Users", Description: optional.NewString("User Account"), Schema: scimservice.UserSchema(), - Handler: &scimResourceHandler{handler: h}, + SchemaExtensions: []scim.SchemaExtension{ + {Schema: scimservice.EnterpriseUserSchema()}, + }, + Handler: &scimResourceHandler{handler: h}, }, }