Fix SCIM bridge PUT loop and pagination

Two bugs caused SCIM sync failures:

1. buildUserPayload conditionally omitted empty fields. When a field was
   cleared in the identity provider, the PUT payload didn't include it,
   so the SCIM handler never cleared the stored value. The bridge kept
   detecting a mismatch every sync cycle, causing a perpetual PUT loop.
   Fix: always include all fields unconditionally.

2. ListUsers ignored the startIndex parameter — the cursor always started
   from nil, so every page returned the same first N users. Organizations
   with more than 100 SCIM-managed users never got a full listing; users
   beyond the first page appeared missing, causing CreateUser calls that
   failed with 409 (uniqueness conflict) and eventually disabled the
   bridge. Fix: replace cursor-based pagination with OFFSET/LIMIT to
   honor SCIM's 1-based startIndex.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-21 15:46:09 +02:00
parent 82748f870f
commit a622c610d7
3 changed files with 132 additions and 55 deletions

View File

@@ -185,26 +185,18 @@ func (c *Client) UpdateUser(ctx context.Context, userID string, user *User) erro
}
func buildUserPayload(user *User) map[string]any {
schemas := []string{"urn:ietf:params:scim:schemas:core:2.0:User"}
schemas := []string{
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
}
enterprise := map[string]any{}
if user.EmployeeNumber != "" {
enterprise["employeeNumber"] = user.EmployeeNumber
}
if user.Department != "" {
enterprise["department"] = user.Department
}
if user.CostCenter != "" {
enterprise["costCenter"] = user.CostCenter
}
if user.EnterpriseOrganization != "" {
enterprise["organization"] = user.EnterpriseOrganization
}
if user.Division != "" {
enterprise["division"] = user.Division
}
if user.ManagerValue != "" {
enterprise["manager"] = map[string]string{"value": user.ManagerValue}
enterprise := map[string]any{
"employeeNumber": user.EmployeeNumber,
"department": user.Department,
"costCenter": user.CostCenter,
"organization": user.EnterpriseOrganization,
"division": user.Division,
"manager": map[string]string{"value": user.ManagerValue},
}
payload := map[string]any{
@@ -215,8 +207,12 @@ func buildUserPayload(user *User) map[string]any {
"familyName": user.FamilyName,
"formatted": user.DisplayName,
},
"displayName": user.DisplayName,
"active": user.Active,
"displayName": user.DisplayName,
"active": user.Active,
"title": user.Title,
"externalId": user.ExternalID,
"userType": user.UserType,
"preferredLanguage": user.PreferredLanguage,
"emails": []map[string]any{
{
"value": user.UserName,
@@ -224,23 +220,7 @@ func buildUserPayload(user *User) map[string]any {
"primary": true,
},
},
"title": user.Title,
}
if user.ExternalID != "" {
payload["externalId"] = user.ExternalID
}
if user.UserType != "" {
payload["userType"] = user.UserType
}
if user.PreferredLanguage != "" {
payload["preferredLanguage"] = user.PreferredLanguage
}
if len(enterprise) > 0 {
schemas = append(schemas, "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")
payload["schemas"] = schemas
payload["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] = enterprise
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": enterprise,
}
return payload

View File

@@ -38,7 +38,6 @@ import (
"go.probo.inc/probo/pkg/crypto/rand"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/webhook"
webhooktypes "go.probo.inc/probo/pkg/webhook/types"
)
@@ -395,25 +394,11 @@ func (s *Service) ListUsers(
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var profiles coredata.MembershipProfiles
var totalCount int
err = s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
totalCount, err = profiles.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter)
if err != nil {
return fmt.Errorf("cannot count profiles: %w", err)
}
orderBy := page.OrderBy[coredata.MembershipProfileOrderField]{
Field: coredata.MembershipProfileOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(count, nil, page.Head, orderBy)
err = profiles.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter)
if err != nil {
if err := profiles.LoadAllByOrganizationID(ctx, conn, scope, config.OrganizationID, filter); err != nil {
return fmt.Errorf("cannot load profiles: %w", err)
}
@@ -430,7 +415,7 @@ func (s *Service) ListUsers(
resources = append(resources, userToResource(p))
}
return resources, totalCount, nil
return resources, len(resources), nil
}
func (s *Service) ReplaceUser(