Fix SCIM bridge updating all users on every sync
The SCIM client User struct had json:"-" tags on most fields (GivenName, FamilyName, ExternalID, Department, etc.), so ListUsers never populated them from the JSON response. The bridge comparison always saw empty strings on the SCIM side vs actual values from the provider, making needsUpdate true for every user on every sync cycle. Add custom UnmarshalJSON on User to properly parse nested SCIM JSON (name object, enterprise extension) into the flat struct, so the existing diff logic correctly skips unchanged users. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -309,6 +309,56 @@ func (c *Client) DeleteUser(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Active bool `json:"active"`
|
||||
Title string `json:"title"`
|
||||
ExternalID string `json:"externalId"`
|
||||
UserType string `json:"userType"`
|
||||
PreferredLanguage string `json:"preferredLanguage"`
|
||||
Name struct {
|
||||
GivenName string `json:"givenName"`
|
||||
FamilyName string `json:"familyName"`
|
||||
} `json:"name"`
|
||||
Enterprise struct {
|
||||
EmployeeNumber string `json:"employeeNumber"`
|
||||
Department string `json:"department"`
|
||||
CostCenter string `json:"costCenter"`
|
||||
Organization string `json:"organization"`
|
||||
Division string `json:"division"`
|
||||
Manager struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"manager"`
|
||||
} `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.ID = raw.ID
|
||||
u.UserName = raw.UserName
|
||||
u.DisplayName = raw.DisplayName
|
||||
u.Active = raw.Active
|
||||
u.Title = raw.Title
|
||||
u.ExternalID = raw.ExternalID
|
||||
u.UserType = raw.UserType
|
||||
u.PreferredLanguage = raw.PreferredLanguage
|
||||
u.GivenName = raw.Name.GivenName
|
||||
u.FamilyName = raw.Name.FamilyName
|
||||
u.EmployeeNumber = raw.Enterprise.EmployeeNumber
|
||||
u.Department = raw.Enterprise.Department
|
||||
u.CostCenter = raw.Enterprise.CostCenter
|
||||
u.EnterpriseOrganization = raw.Enterprise.Organization
|
||||
u.Division = raw.Enterprise.Division
|
||||
u.ManagerValue = raw.Enterprise.Manager.Value
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/scim+json")
|
||||
|
||||
138
pkg/iam/scim/bridge/client/client_test.go
Normal file
138
pkg/iam/scim/bridge/client/client_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package scimclient_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
scimclient "go.probo.inc/probo/pkg/iam/scim/bridge/client"
|
||||
)
|
||||
|
||||
func TestUser_UnmarshalJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run(
|
||||
"all fields populated",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte(`{
|
||||
"id": "user-123",
|
||||
"userName": "john@example.com",
|
||||
"displayName": "John Doe",
|
||||
"active": true,
|
||||
"title": "Engineer",
|
||||
"externalId": "ext-456",
|
||||
"userType": "Employee",
|
||||
"preferredLanguage": "en",
|
||||
"name": {
|
||||
"givenName": "John",
|
||||
"familyName": "Doe"
|
||||
},
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
|
||||
"employeeNumber": "EMP001",
|
||||
"department": "Engineering",
|
||||
"costCenter": "CC100",
|
||||
"organization": "Acme Corp",
|
||||
"division": "Platform",
|
||||
"manager": {
|
||||
"value": "jane@example.com"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
var user scimclient.User
|
||||
err := json.Unmarshal(data, &user)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-123", user.ID)
|
||||
assert.Equal(t, "john@example.com", user.UserName)
|
||||
assert.Equal(t, "John Doe", user.DisplayName)
|
||||
assert.Equal(t, true, user.Active)
|
||||
assert.Equal(t, "Engineer", user.Title)
|
||||
assert.Equal(t, "ext-456", user.ExternalID)
|
||||
assert.Equal(t, "Employee", user.UserType)
|
||||
assert.Equal(t, "en", user.PreferredLanguage)
|
||||
assert.Equal(t, "John", user.GivenName)
|
||||
assert.Equal(t, "Doe", user.FamilyName)
|
||||
assert.Equal(t, "EMP001", user.EmployeeNumber)
|
||||
assert.Equal(t, "Engineering", user.Department)
|
||||
assert.Equal(t, "CC100", user.CostCenter)
|
||||
assert.Equal(t, "Acme Corp", user.EnterpriseOrganization)
|
||||
assert.Equal(t, "Platform", user.Division)
|
||||
assert.Equal(t, "jane@example.com", user.ManagerValue)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"no enterprise extension",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte(`{
|
||||
"id": "user-789",
|
||||
"userName": "alice@example.com",
|
||||
"displayName": "Alice Smith",
|
||||
"active": false,
|
||||
"name": {
|
||||
"givenName": "Alice",
|
||||
"familyName": "Smith"
|
||||
}
|
||||
}`)
|
||||
|
||||
var user scimclient.User
|
||||
err := json.Unmarshal(data, &user)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-789", user.ID)
|
||||
assert.Equal(t, "alice@example.com", user.UserName)
|
||||
assert.Equal(t, "Alice Smith", user.DisplayName)
|
||||
assert.Equal(t, false, user.Active)
|
||||
assert.Equal(t, "Alice", user.GivenName)
|
||||
assert.Equal(t, "Smith", user.FamilyName)
|
||||
assert.Equal(t, "", user.EmployeeNumber)
|
||||
assert.Equal(t, "", user.Department)
|
||||
assert.Equal(t, "", user.ManagerValue)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"minimal fields",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte(`{
|
||||
"id": "user-min",
|
||||
"userName": "bob@example.com",
|
||||
"active": true
|
||||
}`)
|
||||
|
||||
var user scimclient.User
|
||||
err := json.Unmarshal(data, &user)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-min", user.ID)
|
||||
assert.Equal(t, "bob@example.com", user.UserName)
|
||||
assert.Equal(t, true, user.Active)
|
||||
assert.Equal(t, "", user.DisplayName)
|
||||
assert.Equal(t, "", user.GivenName)
|
||||
assert.Equal(t, "", user.FamilyName)
|
||||
assert.Equal(t, "", user.Title)
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user