Skip to content

Commit 6d639a8

Browse files
authored
Merge pull request #498 from slashdevops/feat/issue#483
feat: add configurable user field sync for SCIM attributes
2 parents f92856e + 114d60c commit 6d639a8

30 files changed

Lines changed: 3061 additions & 90 deletions

README.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ Keep your [AWS IAM Identity Center](https://aws.amazon.com/iam/identity-center/)
1616
## ✨ Features
1717

1818
***Extended Attribute Support**: Syncs extended AWS SSO SCIM API fields as described in the [official documentation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/limitations.html).
19+
***Configurable User Fields**: Choose which optional user attributes (phone numbers, addresses, enterprise data, etc.) to sync. See [Configurable User Fields](#configurable-user-fields) for details.
1920
***Efficient Data Retrieval**: Uses [partial responses](https://cloud.google.com/storage/docs/json_api#partial-response) from the Google Workspace API to fetch only the data you need.
2021
***Nested Groups Support**: Supports nested groups in Google Workspace thanks to the `includeDerivedMembership` API query parameter.
2122
***Multiple Deployment Options**: Can be deployed via the `AWS Serverless Application Repository`, as a `Container Image`, or as a `CLI`.
2223
***Incremental Sync**: Drastically reduces the number of requests to the AWS SSO SCIM API by using a [state file](docs/State-File-example.md) to track changes.
2324

25+
## 🆕 What's New
26+
27+
For a detailed list of new features, improvements, and bug fixes in each release, see the [What's New](docs/Whats-New.md) page.
28+
2429
## Compatibility
2530

2631
This project is compatible with the latest AWS Lambda runtimes. Since version `v0.0.19`, it uses the `provided.al2` runtime and `arm64` architecture.
@@ -116,6 +121,66 @@ make build-dist
116121
* **Docker Image**
117122
* Pull the image from one of the public repositories.
118123

124+
## Configurable User Fields
125+
126+
By default, all optional user attributes are synced from Google Workspace to AWS SSO SCIM. You can control which optional fields are included using the `sync_user_fields` configuration option.
127+
128+
**Available fields:**
129+
130+
| Field | Description |
131+
| ------------------- | --------------------------------------------------------------------------- |
132+
| `phoneNumbers` | User's phone numbers |
133+
| `addresses` | User's addresses (street, city, region, postal code, country) |
134+
| `title` | User's job title |
135+
| `preferredLanguage` | User's preferred language |
136+
| `locale` | User's locale |
137+
| `timezone` | User's timezone |
138+
| `nickName` | User's nickname |
139+
| `profileURL` | User's profile URL |
140+
| `userType` | User type attribute |
141+
| `enterpriseData` | Enterprise extension (employeeNumber, costCenter, organization, department, division, manager) |
142+
143+
**Required fields** (always synced, not configurable): `name`, `userName`, `displayName`, `emails`, `active`.
144+
145+
### Configuration Examples
146+
147+
**Config file (.idpscim.yaml):**
148+
149+
```yaml
150+
# Sync only phone numbers, addresses, and enterprise data
151+
sync_user_fields:
152+
- phoneNumbers
153+
- addresses
154+
- enterpriseData
155+
```
156+
157+
**Environment variable (Lambda / SAM):**
158+
159+
```bash
160+
IDPSCIM_SYNC_USER_FIELDS=phoneNumbers,addresses,enterpriseData
161+
```
162+
163+
**CLI flag:**
164+
165+
```bash
166+
idpscim --sync-user-fields phoneNumbers,addresses,enterpriseData
167+
```
168+
169+
**SAM template parameter:**
170+
171+
Set the `SyncUserFields` parameter when deploying:
172+
173+
```bash
174+
sam deploy --parameter-overrides SyncUserFields=phoneNumbers,addresses,enterpriseData
175+
```
176+
177+
### Behavior Notes
178+
179+
* **Default (empty or not set):** When `sync_user_fields` is empty or not configured, all optional fields are synced. This preserves backward compatibility with existing deployments.
180+
* **Specifying fields:** Only the listed fields will be synced. For example, setting `sync_user_fields: [phoneNumbers]` will sync only phone numbers; addresses, enterprise data, and other optional attributes will not be sent to AWS SSO SCIM.
181+
* **Invalid field names:** If an invalid field name is provided, the application will fail at startup with a clear error message listing the unrecognized field.
182+
* **Changing on an existing deployment:** The first sync after modifying this configuration will detect all users as "changed" (due to hash differences) and update them in AWS SSO. This is expected behavior — it will clear the excluded fields from SCIM.
183+
119184
## 📦 Repositories
120185

121186
* 📦 [AWS Serverless Application Repository](https://serverlessrepo.aws.amazon.com/applications/us-east-1/889836709304/idp-scim-sync)
@@ -126,7 +191,7 @@ make build-dist
126191
## ⚠️ Limitations
127192

128193
* **Group Limit**: The AWS SSO SCIM API has a limit of 50 groups per request. Please support the feature request on the [AWS Support site](https://repost.aws/questions/QUqqnVkIo_SYyF_SlX5LcUjg/aws-sso-scim-api-pagination-for-methods) to help get this limit increased.
129-
* **Throttling**: With a large number of users and groups, you may encounter a `ThrottlingException` from the AWS SSO SCIM API. This project uses a [retryable HTTP client](https://github.com/hashicorp/go-retryablehttp) to mitigate this, but it's still a possibility.
194+
* **Throttling**: With a large number of users and groups, you may encounter a `ThrottlingException` from the AWS SSO SCIM API. This project uses a [retryable HTTP client](https://github.com/p2p-b2b/httpretrier) to mitigate this, but it's still a possibility.
130195
* **User Status**: The Google Workspace API doesn't differentiate between normal and guest users except for their status. This project only syncs `ACTIVE` users.
131196

132197
## For `ssosync` Users
@@ -135,6 +200,7 @@ If you are coming from the [awslabs/ssosync](https://github.com/awslabs/ssosync)
135200

136201
* This project only implements the `--sync-method groups`.
137202
* This project only implements filtering for Google Workspace Groups, not Users.
203+
* This project supports selecting which optional user attributes to sync via `--sync-user-fields` (e.g., phone numbers, addresses, enterprise data).
138204
* The flag names are different.
139205
* Not all features of `ssosync` are implemented here, and they may not be in the future.
140206

cmd/idpscim/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func init() {
7777
rootCmd.Flags().StringSliceVarP(&cfg.GWSGroupsFilter, "gws-groups-filter", "q", []string{""}, "GWS Groups query parameter, example: --gws-groups-filter 'name:Admin* email:admin*' --gws-groups-filter 'name:Power* email:power*'")
7878
rootCmd.PersistentFlags().StringVarP(&cfg.SyncMethod, "sync-method", "m", config.DefaultSyncMethod, "Sync method to use [groups]")
7979
rootCmd.PersistentFlags().BoolVarP(&cfg.UseSecretsManager, "use-secrets-manager", "g", config.DefaultUseSecretsManager, "use AWS Secrets Manager content or not (default false)")
80+
rootCmd.Flags().StringSliceVar(&cfg.SyncUserFields, "sync-user-fields", nil, "optional user fields to sync (e.g., phoneNumbers,addresses,enterpriseData); default: all fields")
8081
}
8182

8283
func run(ctx context.Context) error {

docs/Whats-New.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# What's New
2+
3+
This document tracks notable changes, new features, and bug fixes across releases.
4+
5+
## v0.44.0
6+
7+
### Configurable User Fields
8+
9+
You can now choose which optional user attributes are synced from Google Workspace to AWS SSO SCIM using the new `sync_user_fields` configuration option.
10+
11+
For example, sync only phone numbers and enterprise data while excluding addresses, locale, or timezone. When not configured, all fields are synced as before (fully backward compatible).
12+
13+
**Available fields:** `phoneNumbers`, `addresses`, `title`, `preferredLanguage`, `locale`, `timezone`, `nickName`, `profileURL`, `userType`, `enterpriseData`.
14+
15+
See [Configurable User Fields](../README.md#configurable-user-fields) for configuration examples and behavior notes.
16+
17+
### Bug Fix: Unnecessary member re-syncs
18+
19+
Fixed a bug where group members were re-synced on every Lambda execution even when nothing changed in Google Workspace.
20+
21+
**Root cause:** `MergeGroupsMembersResult` was not consolidating entries for the same group when merging "created" and "equal" member sets. This produced duplicate group entries in the state file, causing the groups-members hash to never match the IDP data on subsequent syncs.
22+
23+
**Impact:** After upgrading, the first sync will reconcile the state file automatically. Subsequent syncs will correctly skip member reconciliation when no changes are detected.
24+
25+
### Performance Improvements
26+
27+
* **Concurrent user fetching:** `GetUsersByGroupsMembers` now fetches user details from the Google Workspace API concurrently (up to 10 parallel requests) instead of sequentially. For deployments with 100+ users, this reduces the user retrieval phase from minutes to seconds.
28+
29+
* **Optimized member comparison:** Removed a redundant O(m) inner loop in `membersDataSets` that iterated over the entire SCIM member set to find an email already confirmed by a direct map lookup. Benchmarks show ~16-19% improvement for large groups.
30+
31+
* **Goroutine leak safety:** Concurrent operations are verified with `synctest.Test` (Go 1.26) to ensure no goroutine leaks in both success and error paths.

docs/idpscim.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Flags:
3939
-f, --log-format string set the log format (default "text")
4040
-l, --log-level string set the log level [panic|fatal|error|warn|info|debug|trace] (default "info")
4141
-m, --sync-method string Sync method to use [groups] (default "groups")
42+
--sync-user-fields strings optional user fields to sync (e.g., phoneNumbers,addresses,enterpriseData); default: all fields
4243
-g, --use-secrets-manager use AWS Secrets Manager content or not
4344
-v, --version version for idpscim
4445
```

internal/config/config.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Package config provides the configuration for the application.
22
package config
33

4-
import "fmt"
4+
import (
5+
"fmt"
6+
7+
"github.com/slashdevops/idp-scim-sync/internal/model"
8+
)
59

610
const (
711
// DefaultIsLambda is the program execute as a lambda function?
@@ -88,6 +92,12 @@ type Config struct {
8892
GWSUsersFilter []string `mapstructure:"gws_users_filter" json:"gws_users_filter" yaml:"gws_users_filter"`
8993
GWSServiceAccountScopes []string `mapstructure:"gws_service_account_scopes" json:"gws_service_account_scopes" yaml:"gws_service_account_scopes"`
9094

95+
// SyncUserFields controls which optional user attributes are synced from the identity provider.
96+
// When empty (default), all fields are synced. When specified, only listed fields are included.
97+
// Valid values: phoneNumbers, addresses, title, preferredLanguage, locale, timezone,
98+
// nickName, profileURL, userType, enterpriseData
99+
SyncUserFields []string `mapstructure:"sync_user_fields" json:"sync_user_fields" yaml:"sync_user_fields"`
100+
91101
IsLambda bool
92102
Debug bool
93103

@@ -150,5 +160,14 @@ func (c *Config) Validate() error {
150160
}
151161
}
152162

163+
for _, field := range c.SyncUserFields {
164+
if field == "" {
165+
continue
166+
}
167+
if err := model.ValidateSyncUserField(field); err != nil {
168+
return fmt.Errorf("invalid sync_user_fields configuration: %w", err)
169+
}
170+
}
171+
153172
return nil
154173
}

internal/config/config_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,115 @@ func TestNew(t *testing.T) {
2525
assert.Equal(cfg.AWSSCIMAccessTokenSecretName, DefaultAWSSCIMAccessTokenSecretName)
2626
assert.Equal(cfg.UseSecretsManager, DefaultUseSecretsManager)
2727
}
28+
29+
func validConfig() Config {
30+
return Config{
31+
LogLevel: "info",
32+
LogFormat: "json",
33+
AWSSCIMEndpoint: "https://scim.example.com",
34+
AWSSCIMAccessToken: "token",
35+
GWSServiceAccountFile: "credentials.json",
36+
GWSUserEmail: "admin@example.com",
37+
}
38+
}
39+
40+
func TestValidate(t *testing.T) {
41+
t.Run("valid config passes", func(t *testing.T) {
42+
cfg := validConfig()
43+
assert.NoError(t, cfg.Validate())
44+
})
45+
46+
t.Run("invalid log level", func(t *testing.T) {
47+
cfg := validConfig()
48+
cfg.LogLevel = "invalid"
49+
err := cfg.Validate()
50+
assert.ErrorIs(t, err, ErrInvalidLogLevel)
51+
})
52+
53+
t.Run("all valid log levels", func(t *testing.T) {
54+
for _, level := range []string{"debug", "info", "warn", "error", "fatal", "panic"} {
55+
cfg := validConfig()
56+
cfg.LogLevel = level
57+
assert.NoError(t, cfg.Validate(), "expected %q to be valid", level)
58+
}
59+
})
60+
61+
t.Run("invalid log format", func(t *testing.T) {
62+
cfg := validConfig()
63+
cfg.LogFormat = "yaml"
64+
err := cfg.Validate()
65+
assert.ErrorIs(t, err, ErrInvalidLogFormat)
66+
})
67+
68+
t.Run("valid log formats", func(t *testing.T) {
69+
for _, format := range []string{"text", "json"} {
70+
cfg := validConfig()
71+
cfg.LogFormat = format
72+
assert.NoError(t, cfg.Validate(), "expected %q to be valid", format)
73+
}
74+
})
75+
76+
t.Run("missing AWS SCIM endpoint", func(t *testing.T) {
77+
cfg := validConfig()
78+
cfg.AWSSCIMEndpoint = ""
79+
err := cfg.Validate()
80+
assert.ErrorIs(t, err, ErrMissingAWSSCIMEndpoint)
81+
})
82+
83+
t.Run("missing AWS SCIM access token", func(t *testing.T) {
84+
cfg := validConfig()
85+
cfg.AWSSCIMAccessToken = ""
86+
err := cfg.Validate()
87+
assert.ErrorIs(t, err, ErrMissingAWSSCIMAccessToken)
88+
})
89+
90+
t.Run("missing GWS service account file", func(t *testing.T) {
91+
cfg := validConfig()
92+
cfg.GWSServiceAccountFile = ""
93+
err := cfg.Validate()
94+
assert.ErrorIs(t, err, ErrMissingGWSServiceAccountFile)
95+
})
96+
97+
t.Run("missing GWS user email", func(t *testing.T) {
98+
cfg := validConfig()
99+
cfg.GWSUserEmail = ""
100+
err := cfg.Validate()
101+
assert.ErrorIs(t, err, ErrMissingGWSUserEmail)
102+
})
103+
104+
t.Run("secrets manager skips credential validation", func(t *testing.T) {
105+
cfg := validConfig()
106+
cfg.UseSecretsManager = true
107+
cfg.AWSSCIMEndpoint = ""
108+
cfg.AWSSCIMAccessToken = ""
109+
cfg.GWSServiceAccountFile = ""
110+
cfg.GWSUserEmail = ""
111+
assert.NoError(t, cfg.Validate())
112+
})
113+
114+
t.Run("valid sync_user_fields", func(t *testing.T) {
115+
cfg := validConfig()
116+
cfg.SyncUserFields = []string{"phoneNumbers", "addresses", "enterpriseData"}
117+
assert.NoError(t, cfg.Validate())
118+
})
119+
120+
t.Run("invalid sync_user_fields", func(t *testing.T) {
121+
cfg := validConfig()
122+
cfg.SyncUserFields = []string{"phoneNumbers", "invalidField"}
123+
err := cfg.Validate()
124+
assert.Error(t, err)
125+
assert.Contains(t, err.Error(), "invalidField")
126+
})
127+
128+
t.Run("empty string in sync_user_fields is ignored", func(t *testing.T) {
129+
cfg := validConfig()
130+
cfg.SyncUserFields = []string{"", "phoneNumbers", ""}
131+
assert.NoError(t, cfg.Validate())
132+
})
133+
134+
t.Run("only empty strings in sync_user_fields passes", func(t *testing.T) {
135+
cfg := validConfig()
136+
cfg.SyncUserFields = []string{""}
137+
assert.NoError(t, cfg.Validate())
138+
})
139+
}

0 commit comments

Comments
 (0)