Skip to content

Commit c3ce7f1

Browse files
christiangdaclaude
andcommitted
refactor: improve pkg/aws SCIM client with Go 1.26 best practices
- Fix bug: reflect.DeepEqual always returned false in CreateOrGetUser because it compared different types (*CreateUserRequest vs *GetUserResponse), causing unnecessary PUT updates on every conflict. Replace with typed usersEqual function. - Replace pkg/errors with stdlib errors and fmt packages. - Migrate errors.As to errors.AsType[T] (Go 1.26) for compile-time type safety and better performance. - Fix User.String() and Group.String() calling os.Exit(1) on marshal failure; return safe fallback strings instead. - Eliminate double JSON encode/decode roundtrip in GetUserByUserName and GetGroupByDisplayName; use direct type conversions. - Fix CreateGroup/CreateOrGetGroup attempting to read already-consumed response body on decode failure. - Remove redundant req.WithContext(ctx) in do() since requests are already created with http.NewRequestWithContext. - Simplify CreateOrGetUser/CreateOrGetGroup with type conversions instead of manual field-by-field struct copies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent db7b066 commit c3ce7f1

4 files changed

Lines changed: 109 additions & 152 deletions

File tree

docs/Whats-New.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ Replaced the `httpretrier` library with [httpx](https://github.com/slashdevops/h
1717
* Google Workspace API calls use **exponential backoff** for reliable retries.
1818
* The `httpx` library has zero external dependencies and integrates with Go's `slog` logging.
1919

20+
### AWS SCIM Client Improvements (`pkg/aws`)
21+
22+
Several code quality improvements and bug fixes in the AWS SCIM client:
23+
24+
* **Bug fix:** `CreateOrGetUser` used `reflect.DeepEqual` to compare a `*CreateUserRequest` with a `*GetUserResponse` — different types, so the comparison always returned `false`, causing unnecessary PUT updates on every 409 conflict. Replaced with a typed `usersEqual` function that compares only sync-relevant attributes.
25+
* **Removed `pkg/errors` dependency:** Replaced with stdlib `errors` and `fmt` packages. Sentinel errors now use `errors.New` instead of `errors.Errorf`.
26+
* **Go 1.26 `errors.AsType`:** Migrated all `errors.As` calls to the generic `errors.AsType[T]` for compile-time type safety and better performance.
27+
* **Fixed `String()` methods:** `User.String()` and `Group.String()` no longer call `os.Exit(1)` on marshal failure. They return a safe fallback string instead.
28+
* **Eliminated double JSON decode:** `GetUserByUserName` and `GetGroupByDisplayName` no longer marshal a resource to JSON and re-parse it. They use direct type conversion instead.
29+
* **Fixed decode error fallback:** `CreateGroup` and `CreateOrGetGroup` no longer attempt to read an already-consumed response body on decode failure.
30+
* **Removed redundant context set:** `do()` no longer calls `req.WithContext(ctx)` since the request is already created with `http.NewRequestWithContext`.
31+
* **Simplified type conversions:** `CreateOrGetUser` and `CreateOrGetGroup` use type conversions instead of manual field-by-field struct copies.
32+
2033
## v0.44.0
2134

2235
### Configurable User Fields

pkg/aws/scim.go

Lines changed: 73 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,13 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"log/slog"
1011
"net/http"
1112
"net/url"
1213
"path"
13-
"reflect"
14-
"strings"
15-
16-
"github.com/pkg/errors"
1714
)
1815

1916
// AWS SSO SCIM API
@@ -32,40 +29,40 @@ const (
3229

3330
var (
3431
// ErrURLEmpty is returned when the URL is empty.
35-
ErrURLEmpty = errors.Errorf("aws: url may not be empty")
32+
ErrURLEmpty = errors.New("aws: url may not be empty")
3633

3734
// ErrCreateGroupRequestEmpty is returned when the create group request is empty.
38-
ErrCreateGroupRequestEmpty = errors.Errorf("aws: create group request may not be empty")
35+
ErrCreateGroupRequestEmpty = errors.New("aws: create group request may not be empty")
3936

4037
// ErrCreateUserRequestEmpty is returned when the create user request is empty.
41-
ErrCreateUserRequestEmpty = errors.Errorf("aws: create user request may not be empty")
38+
ErrCreateUserRequestEmpty = errors.New("aws: create user request may not be empty")
4239

4340
// ErrPatchGroupRequestEmpty is returned when the patch group request is empty.
44-
ErrPatchGroupRequestEmpty = errors.Errorf("aws: patch group request may not be empty")
41+
ErrPatchGroupRequestEmpty = errors.New("aws: patch group request may not be empty")
4542

4643
// ErrGroupIDEmpty is returned when the group id is empty.
47-
ErrGroupIDEmpty = errors.Errorf("aws: group id may not be empty")
44+
ErrGroupIDEmpty = errors.New("aws: group id may not be empty")
4845

4946
// ErrPatchUserRequestEmpty is returned when the patch user request is empty.
50-
ErrPatchUserRequestEmpty = errors.Errorf("aws: patch user request may not be empty")
47+
ErrPatchUserRequestEmpty = errors.New("aws: patch user request may not be empty")
5148

5249
// ErrPutUserRequestEmpty is returned when the put user request is empty.
53-
ErrPutUserRequestEmpty = errors.Errorf("aws: put user request may not be empty")
50+
ErrPutUserRequestEmpty = errors.New("aws: put user request may not be empty")
5451

5552
// ErrUserExternalIDEmpty is returned when the user externalId is empty.
56-
ErrUserExternalIDEmpty = errors.Errorf("aws: externalId may not be empty")
53+
ErrUserExternalIDEmpty = errors.New("aws: externalId may not be empty")
5754

5855
// ErrGroupDisplayNameEmpty is returned when the userName is empty.
59-
ErrGroupDisplayNameEmpty = errors.Errorf("aws: displayName may not be empty")
56+
ErrGroupDisplayNameEmpty = errors.New("aws: displayName may not be empty")
6057

6158
// ErrGroupExternalIDEmpty is returned when the userName is empty.
62-
ErrGroupExternalIDEmpty = errors.Errorf("aws: externalId may not be empty")
59+
ErrGroupExternalIDEmpty = errors.New("aws: externalId may not be empty")
6360

6461
// ErrBearerTokenEmpty is returned when the bearer token is empty.
65-
ErrBearerTokenEmpty = errors.Errorf("aws: bearer token may not be empty")
62+
ErrBearerTokenEmpty = errors.New("aws: bearer token may not be empty")
6663

6764
// ErrServiceProviderConfigEmpty is returned when the service provider config is empty.
68-
ErrServiceProviderConfigEmpty = errors.Errorf("aws: service provider config may not be empty")
65+
ErrServiceProviderConfigEmpty = errors.New("aws: service provider config may not be empty")
6966
)
7067

7168
//go:generate go tool mockgen -package=mocks -destination=../../mocks/aws/scim_mocks.go -source=scim.go HTTPClient
@@ -144,14 +141,10 @@ func (s *SCIMService) newRequest(ctx context.Context, method string, u *url.URL,
144141
// do sends an HTTP request and returns an HTTP response, following policy (e.g. redirects, cookies, auth) as configured on the client.
145142
func (s *SCIMService) do(ctx context.Context, req *http.Request) (*http.Response, error) {
146143
// Check if context is already cancelled
147-
select {
148-
case <-ctx.Done():
149-
return nil, ctx.Err()
150-
default:
144+
if err := ctx.Err(); err != nil {
145+
return nil, err
151146
}
152147

153-
req = req.WithContext(ctx)
154-
155148
// Set bearer token
156149
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.bearerToken))
157150

@@ -269,10 +262,8 @@ func (s *SCIMService) CreateOrGetUser(ctx context.Context, cur *CreateUserReques
269262
defer resp.Body.Close()
270263

271264
if e := s.checkHTTPResponse(resp); e != nil {
272-
httpErr := new(HTTPResponseError)
273-
274265
// http.StatusConflict is 409
275-
if errors.As(e, &httpErr) && httpErr.StatusCode == http.StatusConflict {
266+
if httpErr, ok := errors.AsType[*HTTPResponseError](e); ok && httpErr.StatusCode == http.StatusConflict {
276267
slog.Warn(
277268
"aws CreateOrGetUser: user already exists with same name or externalId, trying to get the user information",
278269
"user", cur.UserName,
@@ -297,13 +288,13 @@ func (s *SCIMService) CreateOrGetUser(ctx context.Context, cur *CreateUserReques
297288
// when response.ID is empty, the user does not exists, so this is the case when the new user is a existing user
298289
// with a different email same externalId.
299290
if response.ID == "" {
300-
slog.Warn("aws CreateOrGetUser: group already exists, but with a different name, same id",
291+
slog.Warn("aws CreateOrGetUser: user already exists, but with a different name, same id",
301292
"user", cur.UserName,
302293
"name", cur.DisplayName,
303294
)
304295

305296
// remove the ExternalID from the user request, and call itself again to create the new user
306-
slog.Warn("aws CreateOrGetUser: removing ExternalID from the group request, calling itself again to create the new group name",
297+
slog.Warn("aws CreateOrGetUser: removing ExternalID from the user request, calling itself again to create the new user",
307298
"user", cur.UserName,
308299
"name", cur.DisplayName,
309300
)
@@ -312,77 +303,35 @@ func (s *SCIMService) CreateOrGetUser(ctx context.Context, cur *CreateUserReques
312303
return s.CreateOrGetUser(ctx, cur)
313304
}
314305

315-
curesp := &CreateUserResponse{
316-
ID: response.ID,
317-
ExternalID: response.ExternalID,
318-
Meta: response.Meta,
319-
Schemas: response.Schemas,
320-
UserName: response.UserName,
321-
DisplayName: response.DisplayName,
322-
Title: response.Title,
323-
UserType: response.UserType,
324-
PreferredLanguage: response.PreferredLanguage,
325-
Active: response.Active,
326-
Emails: response.Emails,
327-
Addresses: response.Addresses,
328-
PhoneNumbers: response.PhoneNumbers,
329-
Name: response.Name,
330-
SchemaEnterpriseUser: response.SchemaEnterpriseUser,
331-
}
306+
curesp := CreateUserResponse(*(*User)(response))
332307

333308
// check if the user attributes are the same
334309
// maybe the user in the SCIM side was changed, so we need to update the user in the SCIM Side
335310
// according to the create user request
336-
if !reflect.DeepEqual(cur, response) {
311+
existingUser := (*User)(response)
312+
requestedUser := (*User)(cur)
313+
if !usersEqual(requestedUser, existingUser) {
337314
slog.Warn("aws CreateOrGetUser: user already exists, but attributes are different, updating the user",
338315
"user", response.UserName,
339316
"id", response.ID,
340317
"externalId", response.ExternalID,
341318
"active", response.Active,
342319
"displayName", response.DisplayName,
343-
"email", (*User)(response).GetPrimaryEmailAddress(),
320+
"email", existingUser.GetPrimaryEmailAddress(),
344321
)
345322

346-
pur := &PutUserRequest{
347-
ID: response.ID,
348-
ExternalID: cur.ExternalID,
349-
UserName: cur.UserName,
350-
DisplayName: cur.DisplayName,
351-
Title: cur.Title,
352-
UserType: cur.UserType,
353-
PreferredLanguage: cur.PreferredLanguage,
354-
Active: cur.Active,
355-
Emails: cur.Emails,
356-
Addresses: cur.Addresses,
357-
PhoneNumbers: cur.PhoneNumbers,
358-
Name: cur.Name,
359-
SchemaEnterpriseUser: cur.SchemaEnterpriseUser,
360-
}
323+
pur := PutUserRequest(*requestedUser)
324+
pur.ID = response.ID
361325

362-
resp, err := s.PutUser(ctx, pur)
326+
resp, err := s.PutUser(ctx, &pur)
363327
if err != nil {
364328
return nil, fmt.Errorf("aws CreateOrGetUser: error updating user: %w", err)
365329
}
366330

367-
// update the user information
368-
curesp.ID = resp.ID
369-
curesp.ExternalID = resp.ExternalID
370-
curesp.Meta = resp.Meta
371-
curesp.Schemas = resp.Schemas
372-
curesp.UserName = resp.UserName
373-
curesp.DisplayName = resp.DisplayName
374-
curesp.Title = resp.Title
375-
curesp.UserType = resp.UserType
376-
curesp.PreferredLanguage = resp.PreferredLanguage
377-
curesp.Active = resp.Active
378-
curesp.Emails = resp.Emails
379-
curesp.Addresses = resp.Addresses
380-
curesp.PhoneNumbers = resp.PhoneNumbers
381-
curesp.Name = resp.Name
382-
curesp.SchemaEnterpriseUser = resp.SchemaEnterpriseUser
331+
curesp = CreateUserResponse(*(*User)(resp))
383332
}
384333

385-
return curesp, nil
334+
return &curesp, nil
386335
}
387336
return nil, e
388337
}
@@ -395,6 +344,38 @@ func (s *SCIMService) CreateOrGetUser(ctx context.Context, cur *CreateUserReques
395344
return &response, nil
396345
}
397346

347+
// usersEqual compares two users by their sync-relevant attributes,
348+
// ignoring server-assigned fields (ID, Meta, Schemas).
349+
func usersEqual(a, b *User) bool {
350+
if a.UserName != b.UserName || a.DisplayName != b.DisplayName || a.Active != b.Active {
351+
return false
352+
}
353+
if a.ExternalID != b.ExternalID || a.Title != b.Title || a.UserType != b.UserType {
354+
return false
355+
}
356+
if a.PreferredLanguage != b.PreferredLanguage || a.NickName != b.NickName {
357+
return false
358+
}
359+
if a.ProfileURL != b.ProfileURL || a.Locale != b.Locale || a.Timezone != b.Timezone {
360+
return false
361+
}
362+
if (a.Name == nil) != (b.Name == nil) {
363+
return false
364+
}
365+
if a.Name != nil && (a.Name.GivenName != b.Name.GivenName || a.Name.FamilyName != b.Name.FamilyName || a.Name.Formatted != b.Name.Formatted || a.Name.MiddleName != b.Name.MiddleName) {
366+
return false
367+
}
368+
if len(a.Emails) != len(b.Emails) {
369+
return false
370+
}
371+
for i := range a.Emails {
372+
if a.Emails[i] != b.Emails[i] {
373+
return false
374+
}
375+
}
376+
return true
377+
}
378+
398379
// DeleteUser deletes a user in the AWS SSO Using the API.
399380
func (s *SCIMService) DeleteUser(ctx context.Context, id string) error {
400381
if id == "" {
@@ -420,16 +401,12 @@ func (s *SCIMService) DeleteUser(ctx context.Context, id string) error {
420401
defer resp.Body.Close()
421402

422403
if e := s.checkHTTPResponse(resp); e != nil {
423-
httpErr := new(HTTPResponseError)
424-
425404
// http.StatusNotFound is 404
426405
// in this case, the user was already deleted manually, so we can ignore the error
427-
if errors.As(e, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
406+
if httpErr, ok := errors.AsType[*HTTPResponseError](e); ok && httpErr.StatusCode == http.StatusNotFound {
428407
slog.Warn("aws DeleteUser: user id does not exist, maybe it was already deleted because the username changed", "id", id)
429-
430408
return nil
431409
}
432-
// different error not handled yet
433410
return e
434411
}
435412
return nil
@@ -473,17 +450,12 @@ func (s *SCIMService) GetUserByUserName(ctx context.Context, userName string) (*
473450
return nil, fmt.Errorf("aws GetUserByUserName: userName: %s, error decoding response body: %w", userName, err)
474451
}
475452

476-
var response GetUserResponse
477-
478453
if len(lur.Resources) > 0 {
479-
dataJSON := lur.Resources[0].String()
480-
data := strings.NewReader(dataJSON)
481-
if err = json.NewDecoder(data).Decode(&response); err != nil {
482-
return nil, fmt.Errorf("aws GetUserByUserName: userName: %s, error decoding response body: %w", userName, err)
483-
}
454+
response := GetUserResponse(*lur.Resources[0])
455+
return &response, nil
484456
}
485457

486-
return &response, nil
458+
return &GetUserResponse{}, nil
487459
}
488460

489461
// GetUser returns an user from the AWS SSO Using the API
@@ -672,18 +644,12 @@ func (s *SCIMService) GetGroupByDisplayName(ctx context.Context, displayName str
672644
return nil, fmt.Errorf("aws GetGroupByDisplayName: displayName: %s, error decoding response body: %w", displayName, err)
673645
}
674646

675-
var response GetGroupResponse
676-
677647
if len(lgr.Resources) > 0 {
678-
dataJSON := lgr.Resources[0].String()
679-
680-
data := strings.NewReader(dataJSON)
681-
if err = json.NewDecoder(data).Decode(&response); err != nil {
682-
return nil, fmt.Errorf("aws GetGroupByDisplayName: displayName: %s, error decoding response body: %w", displayName, err)
683-
}
648+
response := GetGroupResponse(*lgr.Resources[0])
649+
return &response, nil
684650
}
685651

686-
return &response, nil
652+
return &GetGroupResponse{}, nil
687653
}
688654

689655
// ListGroups returns a list of groups from the AWS SSO Using the API
@@ -759,12 +725,7 @@ func (s *SCIMService) CreateGroup(ctx context.Context, cgr *CreateGroupRequest)
759725

760726
var response CreateGroupResponse
761727
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
762-
b, err := io.ReadAll(resp.Body)
763-
if err != nil {
764-
return nil, fmt.Errorf("aws CreateGroup: error reading response body: %w", err)
765-
}
766-
767-
return nil, fmt.Errorf("aws CreateGroup: error decoding response body: %w, body: %s", err, string(b))
728+
return nil, fmt.Errorf("aws CreateGroup: error decoding response body: %w", err)
768729
}
769730

770731
return &response, nil
@@ -807,10 +768,8 @@ func (s *SCIMService) CreateOrGetGroup(ctx context.Context, cgr *CreateGroupRequ
807768
defer resp.Body.Close()
808769

809770
if e := s.checkHTTPResponse(resp); e != nil {
810-
httpErr := new(HTTPResponseError)
811-
812771
// http.StatusConflict is 409
813-
if errors.As(e, &httpErr) && httpErr.StatusCode == http.StatusConflict {
772+
if httpErr, ok := errors.AsType[*HTTPResponseError](e); ok && httpErr.StatusCode == http.StatusConflict {
814773
slog.Warn("aws CreateOrGetGroup: groups already exists with same name or externalId, trying to get the group information", "name", cgr.DisplayName)
815774

816775
// This is because the group already exists, but exists with the same name
@@ -834,25 +793,16 @@ func (s *SCIMService) CreateOrGetGroup(ctx context.Context, cgr *CreateGroupRequ
834793
return s.CreateOrGetGroup(ctx, cgr)
835794
}
836795

837-
return &CreateGroupResponse{
838-
ID: response.ID,
839-
Meta: response.Meta,
840-
Schemas: response.Schemas,
841-
DisplayName: response.DisplayName,
842-
}, nil
796+
cgresp := CreateGroupResponse(*(*Group)(response))
797+
return &cgresp, nil
843798
}
844799

845800
return nil, e
846801
}
847802

848803
var response CreateGroupResponse
849804
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
850-
b, err := io.ReadAll(resp.Body)
851-
if err != nil {
852-
return nil, fmt.Errorf("aws CreateOrGetGroup: group: %s, error reading response body: %w", cgr.DisplayName, err)
853-
}
854-
855-
return nil, fmt.Errorf("aws CreateOrGetGroup: group: %s, error decoding response body: %w, body: %s", cgr.DisplayName, err, string(b))
805+
return nil, fmt.Errorf("aws CreateOrGetGroup: group: %s, error decoding response body: %w", cgr.DisplayName, err)
856806
}
857807

858808
return &response, nil
@@ -883,10 +833,8 @@ func (s *SCIMService) DeleteGroup(ctx context.Context, id string) error {
883833
defer resp.Body.Close()
884834

885835
if e := s.checkHTTPResponse(resp); e != nil {
886-
httpErr := new(HTTPResponseError)
887-
888836
// http.StatusNotFound is 404
889-
if errors.As(e, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
837+
if httpErr, ok := errors.AsType[*HTTPResponseError](e); ok && httpErr.StatusCode == http.StatusNotFound {
890838
slog.Warn("aws DeleteGroup: group id does not exists, maybe it was already deleted because the name changed", "id", id)
891839
return nil
892840
}

0 commit comments

Comments
 (0)