Add userName and externalID support

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-09 16:06:32 +01:00
parent b2378a2d7a
commit c8250f6794
6 changed files with 223 additions and 51 deletions

View File

@@ -44,6 +44,8 @@ type (
ContractStartDate *time.Time `db:"contract_start_date"` ContractStartDate *time.Time `db:"contract_start_date"`
ContractEndDate *time.Time `db:"contract_end_date"` ContractEndDate *time.Time `db:"contract_end_date"`
OrganizationName string `db:"organization_name"` OrganizationName string `db:"organization_name"`
UserName *string `db:"user_name"`
ExternalID *string `db:"external_id"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -107,6 +109,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -165,6 +169,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -226,6 +232,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -280,6 +288,8 @@ WITH profiles AS (
p.position, p.position,
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -304,6 +314,8 @@ SELECT
contract_start_date, contract_start_date,
contract_end_date, contract_end_date,
'' AS organization_name, '' AS organization_name,
user_name,
external_id,
created_at, created_at,
updated_at updated_at
FROM profiles FROM profiles
@@ -355,6 +367,8 @@ WITH profiles AS (
p.position, p.position,
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -378,6 +392,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
o.name AS organization_name, o.name AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM profiles p FROM profiles p
@@ -428,6 +444,8 @@ WITH profiles AS (
mp.position, mp.position,
mp.contract_start_date, mp.contract_start_date,
mp.contract_end_date, mp.contract_end_date,
mp.user_name,
mp.external_id,
mp.created_at, mp.created_at,
mp.updated_at mp.updated_at
FROM FROM
@@ -455,6 +473,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM profiles p FROM profiles p
@@ -534,6 +554,8 @@ WITH profiles AS (
mp.position, mp.position,
mp.contract_start_date, mp.contract_start_date,
mp.contract_end_date, mp.contract_end_date,
mp.user_name,
mp.external_id,
mp.created_at, mp.created_at,
mp.updated_at mp.updated_at
FROM FROM
@@ -561,6 +583,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM profiles p FROM profiles p
@@ -641,6 +665,8 @@ WITH attendees AS (
p.position, p.position,
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
ma.created_at AS attendee_created_at ma.created_at AS attendee_created_at
@@ -667,6 +693,8 @@ SELECT
contract_start_date, contract_start_date,
contract_end_date, contract_end_date,
'' AS organization_name, '' AS organization_name,
user_name,
external_id,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -728,6 +756,8 @@ SELECT
p.contract_start_date, p.contract_start_date,
p.contract_end_date, p.contract_end_date,
'' AS organization_name, '' AS organization_name,
p.user_name,
p.external_id,
p.created_at, p.created_at,
p.updated_at p.updated_at
FROM FROM
@@ -881,6 +911,8 @@ INSERT INTO
position, position,
contract_start_date, contract_start_date,
contract_end_date, contract_end_date,
user_name,
external_id,
created_at, created_at,
updated_at updated_at
) )
@@ -897,6 +929,8 @@ VALUES (
@position, @position,
@contract_start_date, @contract_start_date,
@contract_end_date, @contract_end_date,
@user_name,
@external_id,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -915,6 +949,8 @@ VALUES (
"position": p.Position, "position": p.Position,
"contract_start_date": p.ContractStartDate, "contract_start_date": p.ContractStartDate,
"contract_end_date": p.ContractEndDate, "contract_end_date": p.ContractEndDate,
"user_name": p.UserName,
"external_id": p.ExternalID,
"created_at": p.CreatedAt, "created_at": p.CreatedAt,
"updated_at": p.UpdatedAt, "updated_at": p.UpdatedAt,
} }
@@ -949,6 +985,8 @@ SET
position = @position, position = @position,
contract_start_date = @contract_start_date, contract_start_date = @contract_start_date,
contract_end_date = @contract_end_date, contract_end_date = @contract_end_date,
user_name = @user_name,
external_id = @external_id,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
id = @id id = @id
@@ -967,6 +1005,8 @@ WHERE
"position": p.Position, "position": p.Position,
"contract_start_date": p.ContractStartDate, "contract_start_date": p.ContractStartDate,
"contract_end_date": p.ContractEndDate, "contract_end_date": p.ContractEndDate,
"user_name": p.UserName,
"external_id": p.ExternalID,
"updated_at": p.UpdatedAt, "updated_at": p.UpdatedAt,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())

View File

@@ -28,6 +28,8 @@ type (
excludeContractEnded *bool excludeContractEnded *bool
currentDate time.Time currentDate time.Time
email *mail.Addr email *mail.Addr
userName *string
externalID *string
state *ProfileState state *ProfileState
source *ProfileSource source *ProfileSource
} }
@@ -59,6 +61,16 @@ func (f *MembershipProfileFilter) Email() *mail.Addr {
return f.email return f.email
} }
func (f *MembershipProfileFilter) WithUserName(userName string) *MembershipProfileFilter {
f.userName = &userName
return f
}
func (f *MembershipProfileFilter) WithExternalID(externalID string) *MembershipProfileFilter {
f.externalID = &externalID
return f
}
func (f *MembershipProfileFilter) WithState(state ProfileState) *MembershipProfileFilter { func (f *MembershipProfileFilter) WithState(state ProfileState) *MembershipProfileFilter {
f.state = &state f.state = &state
return f return f
@@ -80,6 +92,8 @@ func (f *MembershipProfileFilter) Source() *ProfileSource {
func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs { func (f *MembershipProfileFilter) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{ return pgx.StrictNamedArgs{
"filter_email": f.email, "filter_email": f.email,
"filter_user_name": f.userName,
"filter_external_id": f.externalID,
"with_membership": f.withMembership, "with_membership": f.withMembership,
"with_trust_center_access": f.withTrustCenterAccess, "with_trust_center_access": f.withTrustCenterAccess,
"exclude_contract_ended": f.excludeContractEnded, "exclude_contract_ended": f.excludeContractEnded,
@@ -137,5 +151,19 @@ AND (
ELSE TRUE ELSE TRUE
END END
) )
AND (
CASE
WHEN @filter_user_name::text IS NOT NULL THEN
p.user_name = @filter_user_name::text
ELSE TRUE
END
)
AND (
CASE
WHEN @filter_external_id::text IS NOT NULL THEN
p.external_id = @filter_external_id::text
ELSE TRUE
END
)
` `
} }

View File

@@ -0,0 +1,16 @@
ALTER TABLE iam_membership_profiles
ADD COLUMN user_name TEXT,
ADD COLUMN external_id TEXT;
UPDATE iam_membership_profiles p
SET user_name = i.email_address
FROM identities i
WHERE i.id = p.identity_id AND p.source = 'SCIM';
CREATE UNIQUE INDEX idx_profiles_user_name_organization_id
ON iam_membership_profiles (user_name, organization_id)
WHERE user_name IS NOT NULL;
CREATE UNIQUE INDEX idx_profiles_external_id_organization_id
ON iam_membership_profiles (external_id, organization_id)
WHERE external_id IS NOT NULL;

View File

@@ -21,7 +21,6 @@ import (
scimerrors "github.com/elimity-com/scim/errors" scimerrors "github.com/elimity-com/scim/errors"
scimfilter "github.com/scim2/filter-parser/v2" scimfilter "github.com/scim2/filter-parser/v2"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/mail"
) )
func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipProfileFilter, error) { func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipProfileFilter, error) {
@@ -52,15 +51,12 @@ func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipProfileFil
attrName := strings.ToLower(e.AttributePath.AttributeName) attrName := strings.ToLower(e.AttributePath.AttributeName)
switch attrName { switch attrName {
case "username": case "username":
email, err := mail.ParseAddr(value) filter.WithUserName(value)
if err != nil { case "externalid":
return nil, scimerrors.ScimErrorBadRequest( filter.WithExternalID(value)
fmt.Sprintf("invalid email format for userName: %s", value))
}
filter.WithEmail(&email)
default: default:
return nil, scimerrors.ScimErrorBadRequest( return nil, scimerrors.ScimErrorBadRequest(
fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName)) fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' and 'externalId' are supported", e.AttributePath.AttributeName))
} }
case *scimfilter.LogicalExpression: case *scimfilter.LogicalExpression:

View File

@@ -30,15 +30,28 @@ func TestParseUserFilter(t *testing.T) {
assert.Nil(t, filter.Email()) assert.Nil(t, filter.Email())
}) })
t.Run("simple userName eq filter", func(t *testing.T) { t.Run("simple userName eq filter with email value", func(t *testing.T) {
expr, err := scimfilter.ParseFilter([]byte(`userName eq "test@example.com"`)) expr, err := scimfilter.ParseFilter([]byte(`userName eq "test@example.com"`))
require.NoError(t, err) require.NoError(t, err)
filter, err := ParseUserFilter(expr) filter, err := ParseUserFilter(expr)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, filter) require.NotNil(t, filter)
require.NotNil(t, filter.Email()) args := filter.SQLArguments()
assert.Equal(t, "test@example.com", filter.Email().String()) require.NotNil(t, args["filter_user_name"])
assert.Equal(t, "test@example.com", *args["filter_user_name"].(*string))
})
t.Run("userName eq filter with non-email UPN", func(t *testing.T) {
expr, err := scimfilter.ParseFilter([]byte(`userName eq "john.doe"`))
require.NoError(t, err)
filter, err := ParseUserFilter(expr)
require.NoError(t, err)
require.NotNil(t, filter)
args := filter.SQLArguments()
require.NotNil(t, args["filter_user_name"])
assert.Equal(t, "john.doe", *args["filter_user_name"].(*string))
}) })
t.Run("userName filter is case insensitive", func(t *testing.T) { t.Run("userName filter is case insensitive", func(t *testing.T) {
@@ -48,8 +61,33 @@ func TestParseUserFilter(t *testing.T) {
filter, err := ParseUserFilter(expr) filter, err := ParseUserFilter(expr)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, filter) require.NotNil(t, filter)
require.NotNil(t, filter.Email()) args := filter.SQLArguments()
assert.Equal(t, "test@example.com", filter.Email().String()) require.NotNil(t, args["filter_user_name"])
assert.Equal(t, "test@example.com", *args["filter_user_name"].(*string))
})
t.Run("externalId eq filter", func(t *testing.T) {
expr, err := scimfilter.ParseFilter([]byte(`externalId eq "some-azure-id"`))
require.NoError(t, err)
filter, err := ParseUserFilter(expr)
require.NoError(t, err)
require.NotNil(t, filter)
args := filter.SQLArguments()
require.NotNil(t, args["filter_external_id"])
assert.Equal(t, "some-azure-id", *args["filter_external_id"].(*string))
})
t.Run("externalId filter is case insensitive", func(t *testing.T) {
expr, err := scimfilter.ParseFilter([]byte(`ExternalId eq "azure-obj-id"`))
require.NoError(t, err)
filter, err := ParseUserFilter(expr)
require.NoError(t, err)
require.NotNil(t, filter)
args := filter.SQLArguments()
require.NotNil(t, args["filter_external_id"])
assert.Equal(t, "azure-obj-id", *args["filter_external_id"].(*string))
}) })
t.Run("logical AND expression", func(t *testing.T) { t.Run("logical AND expression", func(t *testing.T) {
@@ -59,8 +97,8 @@ func TestParseUserFilter(t *testing.T) {
filter, err := ParseUserFilter(expr) filter, err := ParseUserFilter(expr)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, filter) require.NotNil(t, filter)
// Last processed value wins args := filter.SQLArguments()
require.NotNil(t, filter.Email()) require.NotNil(t, args["filter_user_name"])
}) })
t.Run("unsupported operator returns error", func(t *testing.T) { t.Run("unsupported operator returns error", func(t *testing.T) {
@@ -113,16 +151,21 @@ func TestParseUserFilter(t *testing.T) {
filter, err := ParseUserFilter(expr) filter, err := ParseUserFilter(expr)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, filter) require.NotNil(t, filter)
require.NotNil(t, filter.Email()) args := filter.SQLArguments()
require.NotNil(t, args["filter_user_name"])
}) })
t.Run("invalid email format returns error", func(t *testing.T) { t.Run("userName and externalId combined", func(t *testing.T) {
expr, err := scimfilter.ParseFilter([]byte(`userName eq "not-an-email"`)) expr, err := scimfilter.ParseFilter([]byte(`userName eq "john@contoso.com" and externalId eq "abc-123"`))
require.NoError(t, err) require.NoError(t, err)
filter, err := ParseUserFilter(expr) filter, err := ParseUserFilter(expr)
assert.Error(t, err) require.NoError(t, err)
assert.Nil(t, filter) require.NotNil(t, filter)
assert.Contains(t, err.Error(), "invalid email format") args := filter.SQLArguments()
require.NotNil(t, args["filter_user_name"])
assert.Equal(t, "john@contoso.com", *args["filter_user_name"].(*string))
require.NotNil(t, args["filter_external_id"])
assert.Equal(t, "abc-123", *args["filter_external_id"].(*string))
}) })
} }

View File

@@ -130,9 +130,12 @@ func (s *Service) CreateUser(
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
attributes scim.ResourceAttributes, attributes scim.ResourceAttributes,
) (scim.Resource, error) { ) (scim.Resource, error) {
email, fullName, active, title := ParseUserFromAttributes(attributes) userName, email, fullName, active, title, externalId := ParseUserFromAttributes(attributes)
if userName == "" {
return scim.Resource{}, scimerrors.ScimErrorBadRequest("userName is required")
}
if email == "" { if email == "" {
return scim.Resource{}, scimerrors.ScimErrorBadRequest("userName or email is required") return scim.Resource{}, scimerrors.ScimErrorBadRequest("a valid email is required (via emails array or userName)")
} }
emailAddr, err := mail.ParseAddr(email) emailAddr, err := mail.ParseAddr(email)
@@ -146,17 +149,20 @@ func (s *Service) CreateUser(
profileState = coredata.ProfileStateInactive profileState = coredata.ProfileStateInactive
} }
var externalIdPtr *string
if externalId != "" {
externalIdPtr = &externalId
}
var membership *coredata.Membership var membership *coredata.Membership
var profile *coredata.MembershipProfile var profile *coredata.MembershipProfile
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
err = s.pg.WithTx(ctx, func(tx pg.Conn) error { err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
// Check if identity exists
identity := &coredata.Identity{} identity := &coredata.Identity{}
if err := identity.LoadByEmail(ctx, tx, emailAddr); err != nil { if err := identity.LoadByEmail(ctx, tx, emailAddr); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
// Create new identity
identity = &coredata.Identity{ identity = &coredata.Identity{
ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), ID: gid.New(gid.NilTenant, coredata.IdentityEntityType),
EmailAddress: emailAddr, EmailAddress: emailAddr,
@@ -175,7 +181,6 @@ func (s *Service) CreateUser(
} }
} }
// Check if profile exists
eventType := coredata.WebhookEventTypeUserUpdated eventType := coredata.WebhookEventTypeUserUpdated
profile = &coredata.MembershipProfile{} profile = &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID( if err := profile.LoadByIdentityIDAndOrganizationID(
@@ -195,12 +200,17 @@ func (s *Service) CreateUser(
State: profileState, State: profileState,
FullName: fullName, FullName: fullName,
Position: &title, Position: &title,
UserName: &userName,
ExternalID: externalIdPtr,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
err = profile.Insert(ctx, tx) err = profile.Insert(ctx, tx)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return scimerrors.ScimErrorUniqueness
}
return fmt.Errorf("cannot insert profile: %w", err) return fmt.Errorf("cannot insert profile: %w", err)
} }
eventType = coredata.WebhookEventTypeUserCreated eventType = coredata.WebhookEventTypeUserCreated
@@ -212,6 +222,8 @@ func (s *Service) CreateUser(
profile.State = profileState profile.State = profileState
profile.FullName = fullName profile.FullName = fullName
profile.Position = &title profile.Position = &title
profile.UserName = &userName
profile.ExternalID = externalIdPtr
profile.UpdatedAt = now profile.UpdatedAt = now
if err := profile.Update(ctx, tx, scope); err != nil { if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update profile: %w", err) return fmt.Errorf("cannot update profile: %w", err)
@@ -219,7 +231,6 @@ func (s *Service) CreateUser(
} }
if !active { if !active {
// Expire pending invitations for user
invitations := &coredata.Invitations{} invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByUserID( if err := invitations.ExpireByUserID(
@@ -233,7 +244,6 @@ func (s *Service) CreateUser(
} }
} }
// Check if membership exists
membership = &coredata.Membership{} membership = &coredata.Membership{}
if err := membership.LoadByIdentityIDAndOrganizationID( if err := membership.LoadByIdentityIDAndOrganizationID(
ctx, ctx,
@@ -243,7 +253,6 @@ func (s *Service) CreateUser(
config.OrganizationID, config.OrganizationID,
); err != nil { ); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
// Create new membership
membership = &coredata.Membership{ membership = &coredata.Membership{
ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType), ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType),
IdentityID: identity.ID, IdentityID: identity.ID,
@@ -391,8 +400,8 @@ func (s *Service) ReplaceUser(
profileID gid.GID, profileID gid.GID,
attributes scim.ResourceAttributes, attributes scim.ResourceAttributes,
) (scim.Resource, error) { ) (scim.Resource, error) {
fullName, active, title := ParseUserFromReplaceAttributes(attributes) fullName, active, title, userName, externalId := ParseUserFromReplaceAttributes(attributes)
profile, err := s.updateUser(ctx, config, profileID, fullName, active, title) profile, err := s.updateUser(ctx, config, profileID, fullName, active, title, userName, externalId)
if err != nil { if err != nil {
return scim.Resource{}, err return scim.Resource{}, err
} }
@@ -406,8 +415,8 @@ func (s *Service) PatchUser(
profileID gid.GID, profileID gid.GID,
operations []scim.PatchOperation, operations []scim.PatchOperation,
) (scim.Resource, error) { ) (scim.Resource, error) {
fullName, active, title := ParseUserFromPatchOperations(operations) fullName, active, title, userName, externalId := ParseUserFromPatchOperations(operations)
profile, err := s.updateUser(ctx, config, profileID, fullName, active, title) profile, err := s.updateUser(ctx, config, profileID, fullName, active, title, userName, externalId)
if err != nil { if err != nil {
return scim.Resource{}, err return scim.Resource{}, err
} }
@@ -422,6 +431,8 @@ func (s *Service) updateUser(
fullName string, fullName string,
active *bool, active *bool,
title string, title string,
userName *string,
externalId *string,
) (*coredata.MembershipProfile, error) { ) (*coredata.MembershipProfile, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
now := time.Now() now := time.Now()
@@ -466,6 +477,16 @@ func (s *Service) updateUser(
profile.Position = &title profile.Position = &title
} }
if userName != nil {
profile.UserName = userName
profile.UpdatedAt = now
}
if externalId != nil {
profile.ExternalID = externalId
profile.UpdatedAt = now
}
if shouldReactivate { if shouldReactivate {
profile.State = coredata.ProfileStateActive profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now profile.UpdatedAt = now
@@ -621,11 +642,11 @@ func (s *Service) createEvent(
return event return event
} }
func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, fullName string, active bool, title string) { func ParseUserFromAttributes(attributes scim.ResourceAttributes) (userName, email, fullName string, active bool, title, externalId string) {
userName, _ := attributes["userName"].(string) userName, _ = attributes["userName"].(string)
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
externalId, _ = attributes["externalId"].(string)
// Default to active if the attribute is not present.
active = true active = true
if a, ok := attributes["active"].(bool); ok { if a, ok := attributes["active"].(bool); ok {
active = a active = a
@@ -637,8 +658,7 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
familyName, _ = name["familyName"].(string) familyName, _ = name["familyName"].(string)
} }
// Get email from emails array or use userName // Get email from emails array first
email = userName
if emails, ok := attributes["emails"].([]any); ok && len(emails) > 0 { if emails, ok := attributes["emails"].([]any); ok && len(emails) > 0 {
for _, e := range emails { for _, e := range emails {
if emailMap, ok := e.(map[string]any); ok { if emailMap, ok := e.(map[string]any); ok {
@@ -650,8 +670,7 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
} }
} }
} }
// If no primary email found, use the first one if email == "" {
if email == userName {
if emailMap, ok := emails[0].(map[string]any); ok { if emailMap, ok := emails[0].(map[string]any); ok {
if value, ok := emailMap["value"].(string); ok { if value, ok := emailMap["value"].(string); ok {
email = value email = value
@@ -660,7 +679,13 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
} }
} }
// Build full name: prefer displayName, then given+family, then userName // Fall back to userName only if it parses as a valid email
if email == "" {
if _, err := mail.ParseAddr(userName); err == nil {
email = userName
}
}
fullName = displayName fullName = displayName
if fullName == "" { if fullName == "" {
fullName = strings.TrimSpace(givenName + " " + familyName) fullName = strings.TrimSpace(givenName + " " + familyName)
@@ -673,10 +698,10 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string,
title = t title = t
} }
return email, fullName, active, title return userName, email, fullName, active, title, externalId
} }
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool, title string) { func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool, title string, userName *string, externalId *string) {
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
var givenName, familyName string var givenName, familyName string
@@ -699,10 +724,18 @@ func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullNam
title = t title = t
} }
return fullName, &activeVal, title if un, ok := attributes["userName"].(string); ok && un != "" {
userName = &un
} }
func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool, title string) { if eid, ok := attributes["externalId"].(string); ok && eid != "" {
externalId = &eid
}
return fullName, &activeVal, title, userName, externalId
}
func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool, title string, userName *string, externalId *string) {
var givenName, familyName string var givenName, familyName string
for _, op := range operations { for _, op := range operations {
@@ -712,8 +745,6 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
path = op.Path.String() path = op.Path.String()
} }
// Handle empty path with value map (Okta style)
// e.g., { "op": "Replace", "value": { "active": false } }
if path == "" { if path == "" {
if valueMap, ok := op.Value.(map[string]any); ok { if valueMap, ok := op.Value.(map[string]any); ok {
if a, ok := valueMap["active"].(bool); ok { if a, ok := valueMap["active"].(bool); ok {
@@ -730,6 +761,12 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
familyName = fn familyName = fn
} }
} }
if un, ok := valueMap["userName"].(string); ok && un != "" {
userName = &un
}
if eid, ok := valueMap["externalId"].(string); ok && eid != "" {
externalId = &eid
}
} }
continue continue
} }
@@ -755,24 +792,36 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
if t, ok := op.Value.(string); ok { if t, ok := op.Value.(string); ok {
title = t title = t
} }
case "username":
if un, ok := op.Value.(string); ok && un != "" {
userName = &un
}
case "externalid":
if eid, ok := op.Value.(string); ok && eid != "" {
externalId = &eid
}
} }
} }
} }
// If no displayName was set but we have name parts, build full name
if fullName == "" && (givenName != "" || familyName != "") { if fullName == "" && (givenName != "" || familyName != "") {
fullName = strings.TrimSpace(givenName + " " + familyName) fullName = strings.TrimSpace(givenName + " " + familyName)
} }
return fullName, active, title return fullName, active, title, userName, externalId
} }
func userToResource(p *coredata.MembershipProfile) scim.Resource { func userToResource(p *coredata.MembershipProfile) scim.Resource {
externalID := optional.NewString(p.ID.String())
if p.ExternalID != nil {
externalID = optional.NewString(*p.ExternalID)
}
return scim.Resource{ return scim.Resource{
ID: p.ID.String(), ID: p.ID.String(),
ExternalID: optional.NewString(p.ID.String()), ExternalID: externalID,
Attributes: scim.ResourceAttributes{ Attributes: scim.ResourceAttributes{
"userName": p.EmailAddress.String(), "userName": *p.UserName,
"displayName": p.FullName, "displayName": p.FullName,
"active": p.State == coredata.ProfileStateActive, "active": p.State == coredata.ProfileStateActive,
"name": map[string]any{ "name": map[string]any{