Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,16 @@ what it is going to do.
> `>= 2.1.0` switched to using `provided.al2` powered by ARM64 instances.

> [!IMPORTANT]
> As of `v2.2.0` multiple query patterns are supported for both Group and User matching, simply separate each query with a `,`. For full sync of groups and/or users specify '*' in the relevant match field.
> As of `v2.2.0` multiple query patterns are supported for both Group and User matching, simply separate each query with a `,`. For full sync of groups and/or users specify '*' in the relevant match field.
> User match and group match can now be used in combination with the sync method of groups.
> Nested groups will now be flattened into the top level groups.
> External users are ignored.
> Group owners are treated as regular group members.
> User details are now cached to reduce the number of api calls and improve execution times on large directories.

|> [!IMPORTANT]
|> **Wildcard support in ignore lists**: You can now use `*` wildcards in `--ignore-users` and `--ignore-groups`. For example, `--ignore-users "*@example.com"` acts as a **Global Safety Net**, preventing the deletion of any user matching that pattern even if they are not in the current sync list.

### References

* [SCIM Protocol RFC](https://tools.ietf.org/html/rfc7644)
Expand Down Expand Up @@ -184,11 +187,11 @@ SSO Sync requires configuration from both Google Workspace and AWS sides.
--group-match "*" \
--sync-method users_groups

# Ignore specific users/groups
# Ignore specific users/groups (supports wildcards)
./ssosync \
--group-match "*" \
--ignore-users "service@company.com,bot@company.com" \
--ignore-groups "temp-group@company.com"
--ignore-users "service@company.com,bot@company.com,*@company-2.com" \
--ignore-groups "temp-group@company.com,temp-*,internal-*"
```

### Environment Variables
Expand Down Expand Up @@ -219,8 +222,8 @@ export SSOSYNC_DRY_RUN="true"
| `--sync-method` | `SSOSYNC_SYNC_METHOD` | Sync method (`groups` or `users_groups`) | `groups` |
| `--group-match` | `SSOSYNC_GROUP_MATCH` | Google Groups filter query | `*` |
| `--user-match` | `SSOSYNC_USER_MATCH` | Google Users filter query | `""` |
| `--ignore-users` | `SSOSYNC_IGNORE_USERS` | Comma-separated list of users to ignore | `[]` |
| `--ignore-groups` | `SSOSYNC_IGNORE_GROUPS` | Comma-separated list of groups to ignore | `[]` |
| `--ignore-users` | `SSOSYNC_IGNORE_USERS` | Comma-separated list of users to ignore (supports `*` wildcards) | `[]` |
| `--ignore-groups` | `SSOSYNC_IGNORE_GROUPS` | Comma-separated list of groups to ignore (supports `*` wildcards) | `[]` |
| `--include-groups` | `SSOSYNC_INCLUDE_GROUPS` | Include only these groups (users_groups method only) | `[]` |
| `--dry-run` | `SSOSYNC_DRY_RUN` | Enable dry-run mode | `false` |
| `--log-level` | `SSOSYNC_LOG_LEVEL` | Log level (debug, info, warn, error) | `info` |
Expand Down Expand Up @@ -455,4 +458,4 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS

---

**Need help?** Check out our [Issues](https://github.com/awslabs/ssosync/issues) page or start a [Discussion](https://github.com/awslabs/ssosync/discussions).
**Need help?** Check out our [Issues](https://github.com/awslabs/ssosync/issues) page or start a [Discussion](https://github.com/awslabs/ssosync/discussions).
79 changes: 63 additions & 16 deletions internal/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"context"
"errors"
"os"
"path"
"strings"
"time"

Expand Down Expand Up @@ -381,8 +382,8 @@ func (s *syncGSuite) SyncGroupsUsers(queryGroups string, queryUsers string) erro
}

// create list of changes by operations
addAWSUsers, delAWSUsers, updateAWSUsers, _ := getUserOperations(awsUsers, googleUsers)
addAWSGroups, delAWSGroups, equalAWSGroups := getGroupOperations(awsGroups, googleGroups)
addAWSUsers, delAWSUsers, updateAWSUsers, _ := getUserOperations(awsUsers, googleUsers, s.ignoreUser)
addAWSGroups, delAWSGroups, equalAWSGroups := getGroupOperations(awsGroups, googleGroups, s.ignoreGroup)

log.Info("syncing changes")

Expand Down Expand Up @@ -525,8 +526,13 @@ func (s *syncGSuite) SyncGroupsUsers(queryGroups string, queryUsers string) erro
}

// delete aws groups (deleted in google)
log.Debug("delete aws groups deleted in google")
log.WithField("count", len(delAWSGroups)).Debug("Starting deletion loop for aws groups deleted in google")
for _, awsGroup := range delAWSGroups {
// Double check ignore list here as a safety measure
if s.ignoreGroup(awsGroup.DisplayName) {
log.WithField("group", awsGroup.DisplayName).Debug("Skipping group deletion (on ignore list)")
continue
}

log := log.WithFields(log.Fields{"group": awsGroup.DisplayName})

Expand All @@ -550,8 +556,13 @@ func (s *syncGSuite) SyncGroupsUsers(queryGroups string, queryUsers string) erro
}

// delete aws users (deleted in google)
log.Debug("deleting aws users deleted in google")
log.WithField("count", len(delAWSUsers)).Debug("Starting deletion loop for aws users deleted in google")
for _, awsUser := range delAWSUsers {
// Double check ignore list here as a safety measure
if s.ignoreUser(awsUser.Username) {
log.WithField("user", awsUser.Username).Debug("Skipping user deletion (on ignore list)")
continue
}

log := log.WithFields(log.Fields{"user": awsUser.Username})

Expand Down Expand Up @@ -864,7 +875,7 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri
}

// getGroupOperations returns the groups of AWS that must be added, deleted and are equals
func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Group) (add []*interfaces.Group, delete []*interfaces.Group, equals []*interfaces.Group) {
func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Group, ignoreGroup func(string) bool) (add []*interfaces.Group, delete []*interfaces.Group, equals []*interfaces.Group) {

log.Debug("getGroupOperations()")
awsMap := make(map[string]*interfaces.Group)
Expand Down Expand Up @@ -892,7 +903,12 @@ func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Gro
// AWS Groups not found in Google
for _, awsGroup := range awsGroups {
if _, found := googleMap[awsGroup.DisplayName]; !found {
log.WithField("awsGroup", awsGroup).Debug("delete")
log.WithField("group", awsGroup.DisplayName).Debug("Group found in AWS but NOT in Google")
if ignoreGroup(awsGroup.DisplayName) {
log.WithField("group", awsGroup.DisplayName).Debug("Skipping group deletion (on ignore list)")
continue
}
log.WithField("awsGroup", awsGroup.DisplayName).Debug("Adding group to delete list")
delete = append(delete, aws.NewGroup(awsGroup.DisplayName))
}
}
Expand All @@ -901,7 +917,7 @@ func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Gro
}

// getUserOperations returns the users of AWS that must be added, deleted, updated and are equals
func getUserOperations(awsUsers []*interfaces.User, googleUsers []*admin.User) (add []*interfaces.User, delete []*interfaces.User, update []*interfaces.User, equals []*interfaces.User) {
func getUserOperations(awsUsers []*interfaces.User, googleUsers []*admin.User, ignoreUser func(string) bool) (add []*interfaces.User, delete []*interfaces.User, update []*interfaces.User, equals []*interfaces.User) {

log.Debug("getUserOperations()")
awsMap := make(map[string]*interfaces.User)
Expand Down Expand Up @@ -945,9 +961,14 @@ func getUserOperations(awsUsers []*interfaces.User, googleUsers []*admin.User) (
// Google Users founds and not in aws
for _, awsUser := range awsUsers {
if _, found := googleMap[awsUser.Username]; !found {
log.WithField("user", awsUser.Username).Debug("User found in AWS but NOT in Google")
if ignoreUser(awsUser.Username) {
log.WithField("user", awsUser.Username).Info("Skipping user deletion (on ignore list)")
continue
}
log.WithFields(log.Fields{
"awsUser": awsUser,
}).Debug("delete")
"awsUser": awsUser.Username,
}).Debug("Adding user to delete list")
delete = append(delete, aws.NewUser(awsUser.Name.GivenName, awsUser.Name.FamilyName, awsUser.Username, awsUser.Active))
}
}
Expand Down Expand Up @@ -1103,25 +1124,51 @@ func DoSync(ctx context.Context, cfg *config.Config) error {
}

func (s *syncGSuite) ignoreUser(name string) bool {
name = strings.TrimSpace(name)
if s.ignoreUsersSet == nil {
s.ignoreUsersSet = make(map[string]struct{}, len(s.cfg.IgnoreUsers))
for _, u := range s.cfg.IgnoreUsers {
s.ignoreUsersSet[u] = struct{}{}
s.ignoreUsersSet[strings.TrimSpace(u)] = struct{}{}
}
}
_, exists := s.ignoreUsersSet[name]
return exists
if _, exists := s.ignoreUsersSet[name]; exists {
log.WithField("user", name).Debug("User ignored (exact match)")
return true
}
for _, pattern := range s.cfg.IgnoreUsers {
p := strings.TrimSpace(pattern)
log.WithFields(log.Fields{"user": name, "pattern": p}).Debug("Checking wildcard pattern")
if matched, _ := path.Match(p, name); matched {
log.WithFields(log.Fields{"user": name, "pattern": p}).Debug("User ignored (wildcard match)")
return true
}
}
log.WithField("user", name).Debug("User NOT ignored")
return false
}

func (s *syncGSuite) ignoreGroup(name string) bool {
name = strings.TrimSpace(name)
log.WithField("group", name).Debug("Checking if group should be ignored")
if s.ignoreGroupsSet == nil {
s.ignoreGroupsSet = make(map[string]struct{}, len(s.cfg.IgnoreGroups))
for _, g := range s.cfg.IgnoreGroups {
s.ignoreGroupsSet[g] = struct{}{}
s.ignoreGroupsSet[strings.TrimSpace(g)] = struct{}{}
}
}
_, exists := s.ignoreGroupsSet[name]
return exists
if _, exists := s.ignoreGroupsSet[name]; exists {
return true
}
for _, pattern := range s.cfg.IgnoreGroups {
p := strings.TrimSpace(pattern)
log.WithFields(log.Fields{"group": name, "pattern": p}).Debug("Checking wildcard pattern")
if matched, _ := path.Match(p, name); matched {
log.WithFields(log.Fields{"group": name, "pattern": p}).Debug("Group ignored (wildcard match)")
return true
}
}
log.WithField("group", name).Debug("Group NOT ignored")
return false
}

func (s *syncGSuite) includeGroup(name string) bool {
Expand All @@ -1145,7 +1192,7 @@ func ConvertIdentityStoreGroupToAWSGroup(group identitystore_types.Group) *inter
log.WithField("group", group).Warn("ConvertIdentityStoreGroupToAWSGroup() Group has no DisplayName")
return nil
}
log.WithField("groupId", group.GroupId).WithField("displayName", group.DisplayName).Debug("ConvertIdentityStoreGroupToAWSGroup() Group converted")
log.WithField("groupId", *group.GroupId).WithField("displayName", *group.DisplayName).Debug("ConvertIdentityStoreGroupToAWSGroup() Group converted")
return &interfaces.Group{
ID: *group.GroupId,
Schemas: []string{constants.SCIMSchemaGroup},
Expand Down
116 changes: 116 additions & 0 deletions internal/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package internal

import (
"testing"

"github.com/awslabs/ssosync/internal/config"
"github.com/awslabs/ssosync/internal/interfaces"
"github.com/stretchr/testify/assert"
admin "google.golang.org/api/admin/directory/v1"
)

func TestIgnoreUserWildcard(t *testing.T) {
s := &syncGSuite{
cfg: &config.Config{
IgnoreUsers: []string{"admin@*", "*@doit.com", "exact-match@example.com"},
},
}

tests := []struct {
email string
expected bool
}{
{"admin@example.com", true},
{"user@doit.com", true},
{"exact-match@example.com", true},
{"user@example.com", false},
}

for _, tt := range tests {
assert.Equal(t, tt.expected, s.ignoreUser(tt.email), tt.email)
}
}

func TestIgnoreGroupWildcard(t *testing.T) {
s := &syncGSuite{
cfg: &config.Config{
IgnoreGroups: []string{"AWS*", "exact-group"},
},
}

tests := []struct {
name string
expected bool
}{
{"AWSAccountFactory", true},
{"AWSServiceRole", true},
{"exact-group", true},
{"OtherGroup", false},
}

for _, tt := range tests {
assert.Equal(t, tt.expected, s.ignoreGroup(tt.name), tt.name)
}
}

func TestGetGroupOperationsWithIgnore(t *testing.T) {
ignoreFn := func(name string) bool {
return name == "AWSReserved" || name == "ManualGroup"
}

awsGroups := []*interfaces.Group{
{DisplayName: "GroupInBoth"},
{DisplayName: "AWSReserved"},
{DisplayName: "ManualGroup"},
{DisplayName: "DeleteMe"},
}

googleGroups := []*admin.Group{
{Name: "GroupInBoth"},
{Name: "NewGroup"},
}

add, delete, equals := getGroupOperations(awsGroups, googleGroups, ignoreFn)

assert.Len(t, add, 1)
assert.Equal(t, "NewGroup", add[0].DisplayName)

assert.Len(t, delete, 1)
assert.Equal(t, "DeleteMe", delete[0].DisplayName)

assert.Len(t, equals, 1)
assert.Equal(t, "GroupInBoth", equals[0].DisplayName)
}

func TestGetUserOperationsWithIgnore(t *testing.T) {
ignoreFn := func(name string) bool {
return name == "ignored@example.com"
}

awsUsers := []*interfaces.User{
{Username: "user@example.com", Active: true, Name: struct {
FamilyName string `json:"familyName"`
GivenName string `json:"givenName"`
}{FamilyName: "User", GivenName: "Test"}},
{Username: "delete-me@example.com"},
{Username: "ignored@example.com"},
}

googleUsers := []*admin.User{
{PrimaryEmail: "user@example.com", Suspended: false, Name: &admin.UserName{FamilyName: "User", GivenName: "Test"}},
{PrimaryEmail: "new-user@example.com", Suspended: false, Name: &admin.UserName{FamilyName: "User", GivenName: "New"}},
}

add, delete, update, equals := getUserOperations(awsUsers, googleUsers, ignoreFn)

assert.Len(t, add, 1)
assert.Equal(t, "new-user@example.com", add[0].Username)

assert.Len(t, delete, 1)
assert.Equal(t, "delete-me@example.com", delete[0].Username)

assert.Len(t, equals, 1)
assert.Equal(t, "user@example.com", equals[0].Username)

assert.Len(t, update, 0)
}