diff --git a/README.md b/README.md index f9fa6738..00d88f43 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ SSO Sync will run on any platform that Go can build for. It is available in the [AWS Serverless Application Repository](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-2:004480582608:applications/SSOSync) > [!CAUTION] -> When using ssosync with an instance or IAM Identity Center integrated with AWS Control Tower. AWS Control Tower creates a number of groups and users (directly via the Identity Store API), when an external identity provider is configured these users and groups are can not be used to log in. However it is important to remember that because ssosync implemements a uni-directional sync it will make the IAM Identity Store match the subset of your Google Workspaces directory you specify, including removing these groups and users created by AWS Control Tower. There is a PFR [#88 - ssosync deletes Control Tower groups](https://github.com/awslabs/ssosync/issues/88) to implement an option to ignore these users and groups, hopefully this will be implemented in version 3.x. +> When using ssosync with an instance of IAM Identity Center integrated with AWS Control Tower. AWS Control Tower creates a number of groups and users (directly via the Identity Store API), when an external identity provider is configured these users and groups are can not be used to log in. However it is important to remember that because ssosync implemements a uni-directional sync it will make the IAM Identity Store match the subset of your Google Workspaces directory you specify, including removing these groups and users created by AWS Control Tower. There is a PFR [#88 - ssosync deletes Control Tower groups](https://github.com/awslabs/ssosync/issues/88) to implement an option to ignore these users and groups, hopefully this will be implemented in version 3.x. > [!WARNING] > There are breaking changes for versions `>= 0.02` @@ -30,6 +30,13 @@ SSO Sync will run on any platform that Go can build for. It is available in the > [!IMPORTANT] > `>= 2.1.0` switched to using `provided.al2` powered by ARM64 instances. +> [!Info] +> 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. +> User details are now cached to reduce the number of api calls and improve execution times on large directories. + ## Why? As per the [AWS SSO](https://aws.amazon.com/single-sign-on/) Homepage: @@ -146,7 +153,7 @@ Flags: -e, --endpoint string AWS SSO SCIM API Endpoint -u, --google-admin string Google Workspace admin user email -c, --google-credentials string path to Google Workspace credentials file (default "credentials.json") - -g, --group-match string Google Workspace Groups filter query parameter, example: 'name:Admin* email:aws-*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups + -g, --group-match string Google Workspace Groups filter query parameter, a simple '*' denotes sync all groups (and any users that are members of those groups). example: 'name:Admin*,email:aws-*', 'name=Admins' or '*' see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups -h, --help help for ssosync --ignore-groups strings ignores these Google Workspace groups --ignore-users strings ignores these Google Workspace users @@ -154,7 +161,7 @@ Flags: --log-format string log format (default "text") --log-level string log level (default "info") -s, --sync-method string Sync method to use (users_groups|groups) (default "groups") - -m, --user-match string Google Workspace Users filter query parameter, example: 'name:John* email:admin*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users + -m, --user-match string Google Workspace Users filter query parameter, a simple '*' denotes sync all users in the directory. example: 'name:John*,email:admin*', '*' or name=John Doe,email:admin*' see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users -v, --version version for ssosync -r, --region AWS region where identity store exists -i, --identity-store-id AWS Identity Store ID diff --git a/cmd/root.go b/cmd/root.go index b0de3f74..90810558 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -199,78 +199,86 @@ func configLambda() { unwrap, err := secrets.GoogleAdminEmail(os.Getenv("GOOGLE_ADMIN")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: GOOGLE_ADMIN").Error()) } cfg.GoogleAdmin = unwrap unwrap, err = secrets.GoogleCredentials(os.Getenv("GOOGLE_CREDENTIALS")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: GOOGLE_CREDENTIALS").Error()) } cfg.GoogleCredentials = unwrap unwrap, err = secrets.SCIMAccessToken(os.Getenv("SCIM_ACCESS_TOKEN")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: SCIM_ACCESS_TOKEN").Error()) } cfg.SCIMAccessToken = unwrap unwrap, err = secrets.SCIMEndpointUrl(os.Getenv("SCIM_ENDPOINT")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: SCIM_ENDPOINT").Error()) } cfg.SCIMEndpoint = unwrap unwrap, err = secrets.Region(os.Getenv("REGION")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: REGION").Error()) } cfg.Region = unwrap unwrap, err = secrets.IdentityStoreID(os.Getenv("IDENTITY_STORE_ID")) if err != nil { - log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + log.Fatalf(errors.Wrap(err, "cannot read config: IDENTITY_STORE_ID").Error()) } cfg.IdentityStoreID = unwrap unwrap = os.Getenv("LOG_LEVEL") if len([]rune(unwrap)) != 0 { cfg.LogLevel = unwrap + log.WithField("LogLevel", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("LOG_FORMAT") if len([]rune(unwrap)) != 0 { cfg.LogFormat = unwrap + log.WithField("LogFormay", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("SYNC_METHOD") if len([]rune(unwrap)) != 0 { cfg.SyncMethod = unwrap + log.WithField("SyncMethod", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("USER_MATCH") if len([]rune(unwrap)) != 0 { cfg.UserMatch = unwrap + log.WithField("UserMatch", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("GROUP_MATCH") if len([]rune(unwrap)) != 0 { cfg.GroupMatch = unwrap + log.WithField("GroupMatch", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("IGNORE_GROUPS") if len([]rune(unwrap)) != 0 { cfg.IgnoreGroups = strings.Split(unwrap, ",") + log.WithField("IgnoreGroups", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("IGNORE_USERS") if len([]rune(unwrap)) != 0 { cfg.IgnoreUsers = strings.Split(unwrap, ",") + log.WithField("IgnoreUsers", unwrap).Debug("from EnvVar") } unwrap = os.Getenv("INCLUDE_GROUPS") if len([]rune(unwrap)) != 0 { cfg.IncludeGroups = strings.Split(unwrap, ",") + log.WithField("IncludeGroups", unwrap).Debug("from EnvVar") } } @@ -287,8 +295,8 @@ func addFlags(cmd *cobra.Command, cfg *config.Config) { rootCmd.Flags().StringSliceVar(&cfg.IgnoreUsers, "ignore-users", []string{}, "ignores these Google Workspace users") rootCmd.Flags().StringSliceVar(&cfg.IgnoreGroups, "ignore-groups", []string{}, "ignores these Google Workspace groups") rootCmd.Flags().StringSliceVar(&cfg.IncludeGroups, "include-groups", []string{}, "include only these Google Workspace groups, NOTE: only works when --sync-method 'users_groups'") - rootCmd.Flags().StringVarP(&cfg.UserMatch, "user-match", "m", "", "Google Workspace Users filter query parameter, example: 'name:John* email:admin*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users") - rootCmd.Flags().StringVarP(&cfg.GroupMatch, "group-match", "g", "", "Google Workspace Groups filter query parameter, example: 'name:Admin* email:aws-*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups") + rootCmd.Flags().StringVarP(&cfg.UserMatch, "user-match", "m", "", "Google Workspace Users filter query parameter, example: 'name:John*' 'name=John Doe,email:admin*', to sync all users in the directory specify '*'. For query syntax and more examples see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users") + rootCmd.Flags().StringVarP(&cfg.GroupMatch, "group-match", "g", "*", "Google Workspace Groups filter query parameter, example: 'name:Admin*' 'name=Admins,email:aws-*', to sync all groups (and their member users) specify '*'. For query syntax and more examples see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups") rootCmd.Flags().StringVarP(&cfg.SyncMethod, "sync-method", "s", config.DefaultSyncMethod, "Sync method to use (users_groups|groups)") rootCmd.Flags().StringVarP(&cfg.Region, "region", "r", "", "AWS Region where AWS SSO is enabled") rootCmd.Flags().StringVarP(&cfg.IdentityStoreID, "identity-store-id", "i", "", "Identifier of Identity Store in AWS SSO") diff --git a/internal/google/client.go b/internal/google/client.go index 58ab4fef..02c3e0df 100644 --- a/internal/google/client.go +++ b/internal/google/client.go @@ -17,6 +17,7 @@ package google import ( "context" + "strings" "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" @@ -100,20 +101,33 @@ func (c *client) GetUsers(query string) ([]*admin.User, error) { u := make([]*admin.User, 0) var err error - if query != "" { - err = c.service.Users.List().Query(query).Customer("my_customer").Pages(c.ctx, func(users *admin.Users) error { - u = append(u, users.Users...) - return nil - }) + // If we have an empty query, return nothing. + if query == "" { + return u, err + } - } else { - err = c.service.Users.List().Customer("my_customer").Pages(c.ctx, func(users *admin.Users) error { + // If we have wildcard then fetch all users + if query == "*" { + err = c.service.Users.List().Customer("my_customer").Pages(c.ctx, func(users *admin.Users) error { + u = append(u, users.Users...) + return nil + }) + return u, err + } + + // The Google api doesn't support multi-part queries, but we do so we need to split into an array of query strings + queries := strings.Split(query, ",") + + // Then call the api one query at a time, appending to our list + for _, subQuery := range queries { + err = c.service.Users.List().Query(subQuery).Customer("my_customer").Pages(c.ctx, func(users *admin.Users) error { u = append(u, users.Users...) return nil }) } - return u, err + + } // GetGroups will get the groups from Google's Admin API @@ -133,17 +147,29 @@ func (c *client) GetGroups(query string) ([]*admin.Group, error) { g := make([]*admin.Group, 0) var err error - if query != "" { - err = c.service.Groups.List().Customer("my_customer").Query(query).Pages(context.TODO(), func(groups *admin.Groups) error { - g = append(g, groups.Groups...) - return nil - }) - } else { + // If we have an empty query, then we are not looking for groups + if query == "" { + return g, err + } + + // If we have wildcard then fetch all groups + if query == "*" { err = c.service.Groups.List().Customer("my_customer").Pages(context.TODO(), func(groups *admin.Groups) error { + g = append(g, groups.Groups...) + return nil + }) + return g, err + } + + // The Google api doesn't support multi-part queries, but we do so we need to split into an array of query strings + queries := strings.Split(query, ",") + + // Then call the api one query at a time, appending to our list + for _, subQuery := range queries { + err = c.service.Groups.List().Customer("my_customer").Query(subQuery).Pages(context.TODO(), func(groups *admin.Groups) error { g = append(g, groups.Groups...) return nil }) - } return g, err } diff --git a/internal/sync.go b/internal/sync.go index ce422c21..139f245a 100644 --- a/internal/sync.go +++ b/internal/sync.go @@ -18,7 +18,6 @@ package internal import ( "context" "errors" - "fmt" "io/ioutil" "github.com/awslabs/ssosync/internal/aws" @@ -38,7 +37,7 @@ import ( type SyncGSuite interface { SyncUsers(string) error SyncGroups(string) error - SyncGroupsUsers(string) error + SyncGroupsUsers(string, string) error } // SyncGSuite is an object type that will synchronize real users and groups @@ -285,25 +284,13 @@ func (s *syncGSuite) SyncGroups(query string) error { // 4) add groups in aws and add its members, these were added in google // 5) validate equals aws an google groups members // 6) delete groups in aws, these were deleted in google -func (s *syncGSuite) SyncGroupsUsers(query string) error { +func (s *syncGSuite) SyncGroupsUsers(queryGroups string, queryUsers string) error { - log.WithField("query", query).Info("get google groups") - googleGroups, err := s.google.GetGroups(query) - if err != nil { - return err - } - filteredGoogleGroups := []*admin.Group{} - for _, g := range googleGroups { - if s.ignoreGroup(g.Email) { - log.WithField("group", g.Email).Debug("ignoring group") - continue - } - filteredGoogleGroups = append(filteredGoogleGroups, g) - } - googleGroups = filteredGoogleGroups + log.WithField("queryGroup", queryGroups).Info("get google groups") + log.WithField("queryUsers", queryUsers).Info("get google users") - log.Debug("preparing list of google users and then google groups and their members") - googleUsers, googleGroupsUsers, err := s.getGoogleGroupsAndUsers(googleGroups) + log.Debug("preparing list of google users, groups and their members") + googleGroups, googleUsers, googleGroupsUsers, err := s.getGoogleGroupsAndUsers(queryGroups, queryUsers) if err != nil { return err } @@ -530,13 +517,63 @@ func (s *syncGSuite) SyncGroupsUsers(query string) error { // getGoogleGroupsAndUsers return a list of google users members of googleGroups // and a map of google groups and its users' list -func (s *syncGSuite) getGoogleGroupsAndUsers(googleGroups []*admin.Group) ([]*admin.User, map[string][]*admin.User, error) { +func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers string) ([]*admin.Group, []*admin.User, map[string][]*admin.User, error) { gUsers := make([]*admin.User, 0) gGroupsUsers := make(map[string][]*admin.User) - + gUserDetailCache := make(map[string]*admin.User) gUniqUsers := make(map[string]*admin.User) - for _, g := range googleGroups { + log.Debug("get users from google, based on UserMatch, regardless of group membership") + googleUsers, err := s.google.GetUsers(queryUsers) + if err != nil { + return nil, nil, nil, err + } + + log.Debug("process users from google, filtering as required") + for _, u := range googleUsers { + log.WithField("email", u.PrimaryEmail).Debug("processing member") + + // Remove any users that should be ignored + if s.ignoreUser(u.PrimaryEmail) { + log.WithField("id", u.PrimaryEmail).Debug("ignoring user") + continue + } + _, ok := gUniqUsers[u.PrimaryEmail] + if !ok { + log.WithField("id", u.PrimaryEmail).Debug("adding user") + gUniqUsers[u.PrimaryEmail] = u + } + + } + + log.Debug("get groups from google") + gGroups, err := s.google.GetGroups(queryGroups) + if err != nil { + return nil, nil, nil, err + } + filteredGoogleGroups := []*admin.Group{} + for _, g := range gGroups { + if s.ignoreGroup(g.Email) { + log.WithField("group", g.Email).Debug("ignoring group") + continue + } + filteredGoogleGroups = append(filteredGoogleGroups, g) + } + gGroups = filteredGoogleGroups + + // For large directories this will reduce execution time and avoid throttling limits + log.Debug("Fetching ALL users from google, to use as cache, when processing the group memberships") + googleUsers, err = s.google.GetUsers("*") + if err != nil { + return nil, nil, nil, err + } + + for _, u := range googleUsers { + gUserDetailCache[u.PrimaryEmail] = u + } + + log.Debug("for each group retrieve the group members") + for _, g := range gGroups { log := log.WithFields(log.Fields{"group": g.Name}) @@ -548,36 +585,52 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(googleGroups []*admin.Group) ([]*ad log.Debug("get group members from google") groupMembers, err := s.google.GetGroupMembers(g) if err != nil { - return nil, nil, err + return nil, nil, nil, err } log.Debug("get users") membersUsers := make([]*admin.User, 0) for _, m := range groupMembers { - - if s.ignoreUser(m.Email) { - log.WithField("id", m.Email).Debug("ignoring user") - continue - } - - log.WithField("id", m.Email).Debug("get user") - q := fmt.Sprintf("email:%s", m.Email) - u, err := s.google.GetUsers(q) // TODO: implement GetUser(m.Email) - - if err != nil { - return nil, nil, err - } - if len(u) == 0 { + log.WithField("email", m.Email).Debug("processing member") + // Ignore Owners they aren't relevant in Identity Store + if m.Role == "OWNER" { + log.WithField("id", m.Email).Debug("ignoring owner roles") + continue + } + + // Ignore any external members, since they don't have users + // that can be synced + if m.Type == "USER" && m.Status != "ACTIVE" { + log.WithField("id", m.Email).Warn("ignoring external user") + continue + } + + // handle nested groups, by adding their membership to the end + // of googleMembers + if m.Type == "GROUP" { + groupMembers = append (groupMembers, s.getGoogleSubGroupMembers(m)...) + continue + } + // Remove any users that should be ignored + if s.ignoreUser(m.Email) { + log.WithField("id", m.Email).Debug("ignoring user") + continue + } + + // Find the group member in the cache of UserDetails + _, found := gUserDetailCache[m.Email] + if found { + membersUsers = append(membersUsers, gUserDetailCache[m.Email]) + } else { log.WithField("id", m.Email).Warn("missing user") continue } - membersUsers = append(membersUsers, u[0]) - + // If we've not seen the user email address before add it to the list of unique users _, ok := gUniqUsers[m.Email] if !ok { - gUniqUsers[m.Email] = u[0] + gUniqUsers[m.Email] = gUserDetailCache[m.Email] } } gGroupsUsers[g.Name] = membersUsers @@ -587,7 +640,7 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(googleGroups []*admin.Group) ([]*ad gUsers = append(gUsers, user) } - return gUsers, gGroupsUsers, nil + return gGroups, gUsers, gGroupsUsers, nil } // getGroupOperations returns the groups of AWS that must be added, deleted and are equals @@ -769,7 +822,7 @@ func DoSync(ctx context.Context, cfg *config.Config) error { log.WithField("sync_method", cfg.SyncMethod).Info("syncing") if cfg.SyncMethod == config.DefaultSyncMethod { - err = c.SyncGroupsUsers(cfg.GroupMatch) + err = c.SyncGroupsUsers(cfg.GroupMatch, cfg.UserMatch) if err != nil { return err } @@ -1013,3 +1066,27 @@ func (s *syncGSuite) RemoveUserFromGroup(userId *string, groupId *string) error return nil } + +func (s *syncGSuite) getGoogleSubGroupMembers(m *admin.Member) []*admin.Member { + log.WithField("Email", m.Email).Debug("getGoogleSubGroupMembers()") + // retrieve the members of a group + g, err := s.google.GetGroups("email="+ m.Email) + if err != nil { + log.WithField("error:", err).Error("failed to retrieve group") + return nil + } + + if len(g) == 1 { + log.WithField("Id", g).Debug("fetch members") + + groupMembers, err := s.google.GetGroupMembers(g[0]) + if err != nil { + log.WithField("error:", err).Error("get group Members failed") + return nil + } + return groupMembers + } else { + log.Error("No group found") + } + return nil +} diff --git a/template.yaml b/template.yaml index 3361e30e..fada364c 100644 --- a/template.yaml +++ b/template.yaml @@ -175,16 +175,16 @@ Parameters: GoogleUserMatch: Type: String Description: | - [optional] Google Workspace user filter query parameter, example: 'name:John* email:admin*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users + [optional] Google Workspace user filter query parameter, example: 'name:John* email:admin*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-users, if left empty all users will be selected. Default: "" - AllowedPattern: '(?!.*\s)|(name|Name|NAME)(:([a-zA-Z0-9]{1,64})(\*))|(name|Name|NAME)(=([a-zA-Z0-9 ]{1,64}))|(email|Email|EMAIL)(:([a-zA-Z0-9.\-_]{1,64})(\*))|(email|Email|EMAIL)(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260}))' + AllowedPattern: '(?!.*\s)|(\*)|((((name|Name|NAME)((:[a-zA-Z0-9\- ]{1,64}\*)|(=[a-zA-Z0-9\- ]{1,64})))|((email|Email|EMAIL)((:[a-zA-Z0-9.\-_]{1,64}\*)|(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260})))))(,(((name|Name|NAME)((:[a-zA-Z0-9\- ]{1,64}\*)|(=[a-zA-Z0-9\- ]{1,64})))|((email|Email|EMAIL)((:[a-zA-Z0-9.\-_]{1,64}\*)|(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260}))))))*)' GoogleGroupMatch: Type: String Description: | - [optional] Google Workspace group filter query parameter, example: 'name:Admin* email:aws-*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups - Default: 'name:AWS*' - AllowedPattern: '(?!.*\s)|(name|Name|NAME)(:([a-zA-Z0-9]{1,64})\*)|(name|Name|NAME)(=([a-zA-Z0-9 ]{1,64}))|(email|Email|EMAIL)(:([a-zA-Z0-9.\-_]{1,64})\*)|(email|Email|EMAIL)(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260}))' + [optional] Google Workspace group filter query parameter, example: 'name:Admin* email:aws-*', see: https://developers.google.com/admin-sdk/directory/v1/guides/search-groups, if left empty all groups and their members will be selected. + Default: "*" + AllowedPattern: '(?!.*\s)|(\*)|((((name|Name|NAME)((:[a-zA-Z0-9\- ]{1,64}\*)|(=[a-zA-Z0-9\- ]{1,64})))|((email|Email|EMAIL)((:[a-zA-Z0-9.\-_]{1,64}\*)|(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260})))))(,(((name|Name|NAME)((:[a-zA-Z0-9\- ]{1,64}\*)|(=[a-zA-Z0-9\- ]{1,64})))|((email|Email|EMAIL)((:[a-zA-Z0-9.\-_]{1,64}\*)|(=([a-zA-Z0-9.\-_]{1,64})@([a-zA-Z0-9.\-]{5,260}))))))*)' IgnoreGroups: Type: String @@ -220,22 +220,22 @@ Conditions: - !Equals - !Ref FunctionName - "" - SetGoogleUserMatch: !Or + SetGoogleUserMatch: !And - !Not - !Equals - !Ref GoogleUserMatch - "" - !Equals - !Ref SyncMethod - - "users_groups" - SetGoogleGroupMatch: !Or + - "groups" + SetGoogleGroupMatch: !And - !Not - !Equals - !Ref GoogleGroupMatch - "" - !Equals - !Ref SyncMethod - - "users_groups" + - "groups" SetIgnoreGroups: !Not - !Equals - !Ref IgnoreGroups @@ -244,14 +244,14 @@ Conditions: - !Equals - !Ref IgnoreUsers - "" - SetIncludeGroups: !Or + SetIncludeGroups: !And - !Not - !Equals - !Ref IncludeGroups - "" - !Equals - !Ref SyncMethod - - "groups" + - "users_groups" OnSchedule: !Not - !Equals - !Ref ScheduleExpression