diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 35ac016f..9a266e17 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v5 diff --git a/.github/workflows/quickstarts.yml b/.github/workflows/quickstarts.yml new file mode 100644 index 00000000..bc46ec64 --- /dev/null +++ b/.github/workflows/quickstarts.yml @@ -0,0 +1,39 @@ +# .github/workflows/quickstarts.yaml +name: quickstarts + +on: release + +permissions: + contents: write + +jobs: + quickstart: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Fetch SEMANTIC_VERSION + run : | + echo "SEMANTIC_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + + - name: Show SemanticVersion + run: | + echo "env.SemanticVersion: ${{ env.SEMANTIC_VERSION }}" + + - name: Show current SemanticVersion + run: grep SemanticVersion quick-start/*.yaml + + - name: Update Templates + run: | + git grep -lr -e 'SemanticVersion' -- quick-start | xargs sed -i -E 's/SemanticVersion: [0-9.]+/SemanticVersion: ${{ env.SEMANTIC_VERSION }}/g' + + - name: Show Templates SemanticVersion + run: grep SemanticVersion quick-start/*.yaml + + - name: Commit updated Quick Start Templates + uses: stefanzweifel/git-auto-commit-action@v7 + with: + branch: ${{ github.event.release.target_commitish }} + commit_message: Update Quick Start templates + file_pattern: quick-start diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcf5b1d0..ecaf3499 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v5 @@ -44,7 +44,7 @@ jobs: needs: [ test ] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Unshallow run: git fetch --prune --unshallow diff --git a/README.md b/README.md index d9dae385..ca294be5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ SSO Sync is a powerful CLI tool and AWS Lambda function that enables automatic p ## ✨ Key Features -- **🔄 Bi-directional Sync**: Supports both `groups` and `users_groups` sync methods +- **🔄 Uni-directional Sync**: Supports both `groups` and `users_groups` sync methods - **🎯 Advanced Filtering**: Flexible user and group filtering with Google API query parameters - **🛡️ Dry-Run Mode**: Test synchronization without making actual changes - **⚡ High Performance**: Built with AWS SDK v2 for improved performance and reliability @@ -25,6 +25,9 @@ SSO Sync is a powerful CLI tool and AWS Lambda function that enables automatic p - **📈 Scalable**: Supports large directories with user caching and pagination ## 🚀 Quick Start +Use one of the [quick start](/quick-start/) templates to simplify the your deployment. Use these templates, with the **Sync from Git** option, to make updates to your deployment far simpler. + +## 🚀 Step-by-Step Guide Want to dive straight in? Try this [hands-on lab](https://catalog.workshops.aws/control-tower/en-US/authentication-authorization/google-workspace) from the AWS Control Tower Workshop. The lab guides you through the complete setup process for both AWS and Google Workspace using the recommended Lambda deployment from the [AWS Serverless Application Repository](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-2:004480582608:applications/SSOSync). @@ -184,13 +187,21 @@ 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 (entries may use '*' as a wildcard) ./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,*@contractors.example.com" \ + --ignore-groups "temp-group@company.com,AWS*" ``` +Ignored users and groups are protected in both directions: they are skipped +when Google is enumerated, and they are also skipped when picking AWS +entries to delete. This means a pattern like `*@contractors.example.com` +acts as a deletion guard for accounts that exist only in AWS. + +Only `*` is special — it matches any (possibly empty) substring. Every +other character, including `?`, `[`, `]`, and `\`, is matched literally. + ### Environment Variables All CLI flags can be set via environment variables with the `SSOSYNC_` prefix: @@ -219,8 +230,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 `*` wildcard) | `[]` | +| `--ignore-groups` | `SSOSYNC_IGNORE_GROUPS` | Comma-separated list of groups to ignore (supports `*` wildcard) | `[]` | | `--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` | @@ -455,4 +466,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). \ No newline at end of file +**Need help?** Check out our [Issues](https://github.com/awslabs/ssosync/issues) page or start a [Discussion](https://github.com/awslabs/ssosync/discussions). diff --git a/cicd/cloudformation/release.yaml b/cicd/cloudformation/release.yaml index 135c9906..fcb06733 100644 --- a/cicd/cloudformation/release.yaml +++ b/cicd/cloudformation/release.yaml @@ -198,6 +198,7 @@ Resources: Type: AWS::CodePipeline::Pipeline Properties: Name: SSOSync-Build + PipelineType: V2 RoleArn: !Sub ${CodePipelineRole.Arn} ArtifactStore: Type: S3 diff --git a/cmd/root.go b/cmd/root.go index 037c2fca..5c39d75c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -174,6 +174,7 @@ func initConfig() { "sync_method", "region", "identity_store_id", + "dry_run", } for _, e := range appEnvVars { @@ -193,11 +194,11 @@ func initConfig() { // config logger logConfig(cfg) - if cfg.SyncSuspended { - cfg.UserFilter = " isArchived=false" - } else { - cfg.UserFilter = " isSuspended=false isArchived=false" - } + if cfg.SyncSuspended { + cfg.UserFilter = " isArchived=false" + } else { + cfg.UserFilter = " isSuspended=false isArchived=false" + } } @@ -208,25 +209,28 @@ func getEnvStr(key string, fallback string) string { log.WithField(key, valueStr).Info("EnvVar") return valueStr } - return fallback + return fallback } -func getEnvStrs (key string, fallback []string) []string { - if valueStr, ok := os.LookupEnv(key); ok { - log.WithField(key, valueStr).Info("EnvVar") - return strings.Split(valueStr, ",") - } - return fallback +func getEnvStrs(key string, fallback []string) []string { + if valueStr, ok := os.LookupEnv(key); ok { + log.WithField(key, valueStr).Info("EnvVar") + if valueStr == "" { + return nil + } + return strings.Split(valueStr, ",") + } + return fallback } -func getEnvBool (key string, fallback bool) bool { - if valueStr, ok := os.LookupEnv(key); ok { +func getEnvBool(key string, fallback bool) bool { + if valueStr, ok := os.LookupEnv(key); ok { log.WithField(key, valueStr).Info("EnvVar") - valueBool := strings.ToLower(valueStr) == "true" + valueBool := strings.ToLower(valueStr) == "true" log.WithField(key, valueBool).Info("config") - return valueBool - } - return fallback + return valueBool + } + return fallback } func configLambda() { @@ -255,17 +259,17 @@ func configLambda() { cfg.Region = getSecretFromCache(getEnvStr("REGION", "")) cfg.GoogleCredentials = getSecretFromCache(getEnvStr("GOOGLE_CREDENTIALS", "")) cfg.SCIMAccessToken = getSecretFromCache(getEnvStr("SCIM_ACCESS_TOKEN", "")) - + // Handle environment variables for other settings cfg.LogLevel = getEnvStr("LOG_LEVEL", config.DefaultLogLevel) cfg.LogFormat = getEnvStr("LOG_FORMAT", config.DefaultLogFormat) - cfg.SyncMethod = getEnvStr("SYNC_METHOD", config.DefaultLogFormat) + cfg.SyncMethod = getEnvStr("SYNC_METHOD", config.DefaultSyncMethod) cfg.UserMatch = getEnvStr("USER_MATCH", "") cfg.GroupMatch = getEnvStr("GROUP_MATCH", "*") - cfg.IgnoreGroups = getEnvStrs("IGNORE_GROUPS", []string{}) - cfg.IgnoreUsers = getEnvStrs("IGNORE_USERS", []string{}) - cfg.IncludeGroups = getEnvStrs("INCLUDE_GROUPS", []string{}) - cfg.PrecacheOrgUnits = getEnvStrs("PRECACHE_ORG_UNITS", strings.Split(config.DefaultPrecacheOrgUnits, ",")) + cfg.IgnoreGroups = getEnvStrs("IGNORE_GROUPS", nil) + cfg.IgnoreUsers = getEnvStrs("IGNORE_USERS", nil) + cfg.IncludeGroups = getEnvStrs("INCLUDE_GROUPS", nil) + cfg.PrecacheOrgUnits = getEnvStrs("PRECACHE_ORG_UNITS", nil) cfg.DryRun = getEnvBool("DRY_RUN", false) cfg.SyncSuspended = getEnvBool("SYNC_SUSPENDED", false) @@ -285,20 +289,20 @@ func addFlags(_ *cobra.Command, cfg *config.Config) { rootCmd.PersistentFlags().StringVarP(&cfg.LogFormat, "log-format", "", config.DefaultLogFormat, "log format") rootCmd.PersistentFlags().StringVarP(&cfg.LogLevel, "log-level", "", config.DefaultLogLevel, "log level") rootCmd.PersistentFlags().BoolVarP(&cfg.DryRun, "dry-run", "n", false, "Do *not* perform any actions, instead list what would happen") - rootCmd.PersistentFlags().BoolVarP(&cfg.SyncSuspended, "suspended", "", false, "included suspended users and their group memberships when syncing") + rootCmd.PersistentFlags().BoolVarP(&cfg.SyncSuspended, "suspended", "", false, "included suspended users and their group memberships when syncing") rootCmd.Flags().StringVarP(&cfg.SCIMAccessToken, "access-token", "t", "", "AWS SSO SCIM API Access Token") rootCmd.Flags().StringVarP(&cfg.SCIMEndpoint, "endpoint", "e", "", "AWS SSO SCIM API Endpoint") rootCmd.Flags().StringVarP(&cfg.GoogleCredentials, "google-credentials", "c", config.DefaultGoogleCredentials, "path to Google Workspace credentials file") rootCmd.Flags().StringVarP(&cfg.GoogleAdmin, "google-admin", "u", "", "Google Workspace admin user email") - 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().StringSliceVar(&cfg.IgnoreUsers, "ignore-users", nil, "ignores these Google Workspace users") + rootCmd.Flags().StringSliceVar(&cfg.IgnoreGroups, "ignore-groups", nil, "ignores these Google Workspace groups") + rootCmd.Flags().StringSliceVar(&cfg.IncludeGroups, "include-groups", nil, "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*' '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=AWS-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") - rootCmd.Flags().StringSliceVar(&cfg.PrecacheOrgUnits, "precache-ous", strings.Split(config.DefaultPrecacheOrgUnits, ","), "A common separated list of Google Workspace OrgUnitPathis e.g.'/', to precache all users within the organization or '/OU_1/OU 2,/OU3'. To disable and use caching on the fly, 'DISABLED'.") + rootCmd.Flags().StringSliceVar(&cfg.PrecacheOrgUnits, "precache-ous", nil, "A common separated list of Google Workspace OrgUnitPathis e.g.'/', to precache all users within the organization or '/OU_1/OU 2,/OU3'. Precaching is disabled by default.") } diff --git a/go.mod b/go.mod index 0dc54754..c9c0081c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/awslabs/ssosync -go 1.24 +go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 @@ -17,15 +17,15 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 - golang.org/x/oauth2 v0.30.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.34.0 google.golang.org/api v0.246.0 ) require ( cloud.google.com/go/auth v0.16.4 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.3 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3 // indirect @@ -37,6 +37,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect github.com/aws/smithy-go v1.22.5 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -58,17 +59,17 @@ require ( github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.7 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cf8bb08b..1e956c1b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYLqIQ= @@ -42,6 +42,8 @@ github.com/aws/aws-secretsmanager-caching-go/v2 v2.1.1 h1:L1s6e1t52MWZ2S+96AF7Si github.com/aws/aws-secretsmanager-caching-go/v2 v2.1.1/go.mod h1:tI92REdzBEWATJHIqIVBk/L/9l6XPGj0Xallezr2fPQ= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -99,8 +101,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= @@ -123,72 +125,74 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.246.0 h1:H0ODDs5PnMZVZAEtdLMn2Ul2eQi7QNjqM2DIFp8TlTM= google.golang.org/api v0.246.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/config/config.go b/internal/config/config.go index 0f3cc01a..8bb0fcf4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,8 +60,6 @@ const ( DefaultGoogleCredentials = "credentials.json" // DefaultSyncMethod is the default sync method to use. DefaultSyncMethod = "groups" - // DefaultPrecacheOrgUnits - DefaultPrecacheOrgUnits = "/" ) // New returns a new Config @@ -101,5 +99,5 @@ func (c *Config) Validate() error { return errors.New("sync method must be either 'groups' or 'users_groups'") } - return nil + return nil } diff --git a/internal/sync.go b/internal/sync.go index c5d0583e..9c178980 100644 --- a/internal/sync.go +++ b/internal/sync.go @@ -52,10 +52,10 @@ type syncGSuite struct { cfg *config.Config identityStore interfaces.IdentityStoreAPI - users map[string]*interfaces.User - ignoreUsersSet map[string]struct{} - ignoreGroupsSet map[string]struct{} - includeGroupsSet map[string]struct{} + users map[string]*interfaces.User + ignoreUserPatterns []string + ignoreGroupPatterns []string + includeGroupsSet map[string]struct{} } // New will create a new SyncGSuite object @@ -90,7 +90,35 @@ func (s *syncGSuite) SyncUsers(query string) error { return err } - for _, u := range deletedUsers { + activeGoogleUsers, err := s.google.GetUsers(query, s.cfg.UserFilter) + if err != nil { + return err + } + + var newDeletedUsers = make([]*admin.User, 0) + for _, deleteUser := range deletedUsers { + isDeletedUserActive := false + log.WithFields(log.Fields{ + "email": deleteUser.PrimaryEmail, + }).Debug("Inspecting deleted user email") + for _, activeGoogleUser := range activeGoogleUsers { + if deleteUser.PrimaryEmail == activeGoogleUser.PrimaryEmail { + isDeletedUserActive = true + log.WithFields(log.Fields{ + "email": deleteUser.PrimaryEmail, + }).Debug("User is active again! Breaking loop...") + break + } + } + if !isDeletedUserActive { + log.WithFields(log.Fields{ + "email": deleteUser.PrimaryEmail, + }).Debug("Inactive user email") + newDeletedUsers = append(newDeletedUsers, deleteUser) + } + } + + for _, u := range newDeletedUsers { log.WithFields(log.Fields{ "email": u.PrimaryEmail, }).Info("deleting google user") @@ -353,8 +381,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") @@ -560,6 +588,7 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri gUserDetailCache := make(map[string]*admin.User) gGroupDetailCache := make(map[string]*admin.Group) gUniqUsers := make(map[string]*admin.User) + gGroups := make([]*admin.Group, 0) log.WithFields(log.Fields{ "func": funcName, @@ -567,84 +596,103 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri "queryUsers": queryUsers, }).Debug("getGoogleGroupsAndUsers()") - // Precaching group data, this will speed up processing of nested groups, etc... - log.WithFields(log.Fields{ - "func": funcName, - }).Info("Precache all Groups from Google") - - googleGroups, err := s.google.GetGroups("*") - if err != nil { + // For larger directories this will reduce execution time and avoid throttling limits + // however if you have directory with 10,000+ users you may want to down scope + // this to a specific OU path or disable by leaving empty. + if s.cfg.PrecacheOrgUnits == nil { log.WithFields(log.Fields{ - "func": funcName, - "error": err, - }).Error("failed precaching groups from Google") - return nil, nil, nil, err - } - for _, g := range googleGroups { - gGroupDetailCache[g.Email] = g + "func": funcName, + }).Info("Precaching DISABLED, caching groups on the fly") + } else { + log.WithFields(log.Fields{ + "func": funcName, + }).Info("Precache all Groups from Google") + + googleGroups, err := s.google.GetGroups("*") + if err != nil { + log.WithFields(log.Fields{ + "func": funcName, + "error": err, + }).Error("failed precaching groups from Google") + return nil, nil, nil, err + } + for _, g := range googleGroups { + gGroupDetailCache[g.Email] = g + } } // Fetch Users - log.WithFields(log.Fields{ - "func": funcName, - "queryUsers": queryUsers, - "queryFilters": s.cfg.UserFilter, - }).Info("fetching userMatch") - - googleUsers, err := s.google.GetUsers(queryUsers, s.cfg.UserFilter) - if err != nil { + if queryUsers == "" { log.WithFields(log.Fields{ - "func": funcName, - "error": err, - }).Error("failed fetching userMatch from Google") - return nil, nil, nil, err - } + "func": funcName, + }).Info("Skipping fetch for userMatch") + } else { + log.WithFields(log.Fields{ + "func": funcName, + "queryUsers": queryUsers, + "queryFilters": s.cfg.UserFilter, + }).Info("fetching userMatch") - log.WithFields(log.Fields{ - "func": funcName, - }).Debug("process users from google, filtering as required") + googleUsers, err := s.google.GetUsers(queryUsers, s.cfg.UserFilter) + if err != nil { + log.WithFields(log.Fields{ + "func": funcName, + "error": err, + }).Error("failed fetching userMatch from Google") + return nil, nil, nil, err + } - for _, u := range googleUsers { log.WithFields(log.Fields{ "func": funcName, - "user": u, - }).Debug("process user") + }).Debug("process users from google, filtering as required") - // Remove any users that should be ignored - if s.ignoreUser(u.PrimaryEmail) { + for _, u := range googleUsers { log.WithFields(log.Fields{ - "func": funcName, - "user.Id": u.Id, - }).Info("ignoring user") - continue - } + "func": funcName, + "user": u, + }).Debug("process user") - if _, found := gUniqUsers[u.PrimaryEmail]; !found { - log.WithFields(log.Fields{ - "func": funcName, - "user.Id": u.Id, - }).Debug("adding user") - gUserDetailCache[u.PrimaryEmail] = u - gUniqUsers[u.PrimaryEmail] = gUserDetailCache[u.PrimaryEmail] - continue - } else { - log.WithFields(log.Fields{ - "func": funcName, - "user.Id": u.Id, - }).Debug("already existing") - continue + // Remove any users that should be ignored + if s.ignoreUser(u.PrimaryEmail) { + log.WithFields(log.Fields{ + "func": funcName, + "user.Id": u.Id, + }).Info("ignoring user") + continue + } + + if _, found := gUniqUsers[u.PrimaryEmail]; !found { + log.WithFields(log.Fields{ + "func": funcName, + "user.Id": u.Id, + }).Debug("adding user") + gUserDetailCache[u.PrimaryEmail] = u + gUniqUsers[u.PrimaryEmail] = gUserDetailCache[u.PrimaryEmail] + continue + } else { + log.WithFields(log.Fields{ + "func": funcName, + "user.Id": u.Id, + }).Debug("already existing") + continue + } } } // For larger directories this will reduce execution time and avoid throttling limits - // however if you have directory with 10s of 1000s of users you may want to down scope + // however if you have directory with 10,000+ users you may want to down scope // this to a specific OU path or disable by leaving empty. - if s.cfg.PrecacheOrgUnits[0] != "DISABLED" { + if s.cfg.PrecacheOrgUnits == nil { + log.WithFields(log.Fields{ + "func": funcName, + "OrgUnitPaths": s.cfg.PrecacheOrgUnits, + }).Info("Precaching DISABLED, caching users on the fly") + } else { precacheQueries := "" log.WithFields(log.Fields{ "func": funcName, "OrgUnitPaths": s.cfg.PrecacheOrgUnits, - }).Info("Prechache users from these paths") + }).Info("Precache users from these paths") for _, orgUnitPath := range s.cfg.PrecacheOrgUnits { log.WithFields(log.Fields{ @@ -668,7 +716,7 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri "queryFilters": s.cfg.UserFilter, }).Info("Precaching users") - googleUsers, err = s.google.GetUsers(precacheQueries, s.cfg.UserFilter) + googleUsers, err := s.google.GetUsers(precacheQueries, s.cfg.UserFilter) if err != nil { log.WithFields(log.Fields{ "func": funcName, @@ -704,126 +752,129 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri } } } - } else { - log.WithFields(log.Fields{ - "func": funcName, - }).Info("Precaching DISABLED, caching on the fly") - } - - log.WithFields(log.Fields{ - "func": funcName, - "queryGroups": queryGroups, - }).Info("fetching groups") - - gGroups, err := s.google.GetGroups(queryGroups) - if err != nil { - log.WithFields(log.Fields{ - "func": funcName, - "error": err, - }).Error("failed fetching groups") - return nil, nil, nil, err } - filteredGoogleGroups := []*admin.Group{} - log.WithFields(log.Fields{ - "func": funcName, - }).Info("filter groups by ignoreList") - - for _, g := range gGroups { + // Fetch Users + if queryGroups == "" { log.WithFields(log.Fields{ - "func": funcName, - "group": g, - }).Debug("processing group") - - if s.ignoreGroup(g.Email) { - log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - }).Info("ignoring group") - continue - } - filteredGoogleGroups = append(filteredGoogleGroups, g) - } - gGroups = filteredGoogleGroups - - log.WithField("func", funcName).Info("fetch group memberships") - for _, g := range gGroups { + "func": funcName, + }).Info("Skipping fetch for groupMatch") + } else { log.WithFields(log.Fields{ - "func": funcName, - "group": g, - }).Debug("processing group") + "func": funcName, + "queryGroups": queryGroups, + }).Info("fetching groups") - if s.ignoreGroup(g.Email) { - log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - }).Info("skipping group") - continue - } - - log.WithField("func", funcName).Debug("fetch membership") - membersUsers, err := s.getGoogleUsersInGroup(g, gUserDetailCache, gGroupDetailCache) + googleGroups, err := s.google.GetGroups(queryGroups) if err != nil { + log.WithFields(log.Fields{ + "func": funcName, + "error": err, + }).Error("failed fetching groups") return nil, nil, nil, err } - // If we've not seen the user email address before add it to the list of unique users - // also, we need to deduplicate the list of members. + filteredGoogleGroups := []*admin.Group{} log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - "membersUsers": membersUsers, - }).Debug("Process group membership") + "func": funcName, + }).Info("filter groups by ignoreList") - gUniqMembers := make(map[string]*admin.User) - for _, m := range membersUsers { + for _, g := range googleGroups { log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - "member": m, - }).Debug("Processing member") + "func": funcName, + "group": g, + }).Debug("processing group") - if m == nil { + if s.ignoreGroup(g.Email) { log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - "member.Id": m.Id, - }).Error("nil user") + "func": funcName, + "group.Id": g.Id, + }).Info("ignoring group") continue } - if _, found := gUniqUsers[m.PrimaryEmail]; !found { + filteredGoogleGroups = append(filteredGoogleGroups, g) + } + gGroups = filteredGoogleGroups + + log.WithField("func", funcName).Info("fetch group memberships") + for _, g := range gGroups { + log.WithFields(log.Fields{ + "func": funcName, + "group": g, + }).Debug("processing group") + + if s.ignoreGroup(g.Email) { log.WithFields(log.Fields{ "func": funcName, "group.Id": g.Id, - "member": m.Id, - }).Debug("adding user to UniqueUsers") - gUniqUsers[m.PrimaryEmail] = gUserDetailCache[m.PrimaryEmail] + }).Info("skipping group") + continue + } + + log.WithField("func", funcName).Debug("fetch membership") + membersUsers, err := s.getGoogleUsersInGroup(g, gUserDetailCache, gGroupDetailCache) + if err != nil { + return nil, nil, nil, err } - if _, found := gUniqMembers[m.PrimaryEmail]; !found { + // If we've not seen the user email address before add it to the list of unique users + // also, we need to deduplicate the list of members. + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + "membersUsers": membersUsers, + }).Debug("Process group membership") + + gUniqMembers := make(map[string]*admin.User) + for _, m := range membersUsers { log.WithFields(log.Fields{ "func": funcName, "group.Id": g.Id, - "member": m.Id, - }).Debug("adding user to group") - gUniqMembers[m.PrimaryEmail] = gUserDetailCache[m.PrimaryEmail] + "member": m, + }).Debug("Processing member") + + if m == nil { + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + "member.Id": m.Id, + }).Error("nil user") + continue + } + if _, found := gUniqUsers[m.PrimaryEmail]; !found { + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + "member": m.Id, + }).Debug("adding user to UniqueUsers") + gUniqUsers[m.PrimaryEmail] = gUserDetailCache[m.PrimaryEmail] + } + + if _, found := gUniqMembers[m.PrimaryEmail]; !found { + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + "member": m.Id, + }).Debug("adding user to group") + gUniqMembers[m.PrimaryEmail] = gUserDetailCache[m.PrimaryEmail] + } } - } - log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - }).Debug("create gMembers") - gMembers := make([]*admin.User, 0) - for _, member := range gUniqMembers { - gMembers = append(gMembers, member) + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + }).Debug("create gMembers") + gMembers := make([]*admin.User, 0) + for _, member := range gUniqMembers { + gMembers = append(gMembers, member) + } + log.WithFields(log.Fields{ + "func": funcName, + "group.Id": g.Id, + "gMembers": gMembers, + }).Debug("Finished group membership") + gGroupsUsers[g.Name] = gMembers } - log.WithFields(log.Fields{ - "func": funcName, - "group.Id": g.Id, - "gMembers": gMembers, - }).Debug("Finished group membership") - gGroupsUsers[g.Name] = gMembers } log.WithField("func", funcName).Debug("create gUsers") @@ -835,8 +886,11 @@ func (s *syncGSuite) getGoogleGroupsAndUsers(queryGroups string, queryUsers stri return gGroups, gUsers, gGroupsUsers, nil } -// 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) { +// getGroupOperations returns the groups of AWS that must be added, deleted and are equals. +// ignoreGroup, if non-nil, is consulted before queueing an AWS-only group for deletion; +// matching groups are skipped so wildcard ignore patterns protect AWS groups that have +// no counterpart in Google. +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) @@ -864,6 +918,10 @@ func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Gro // AWS Groups not found in Google for _, awsGroup := range awsGroups { if _, found := googleMap[awsGroup.DisplayName]; !found { + if ignoreGroup != nil && ignoreGroup(awsGroup.DisplayName) { + log.WithField("group", awsGroup.DisplayName).Info("skip delete: ignore list") + continue + } log.WithField("awsGroup", awsGroup).Debug("delete") delete = append(delete, aws.NewGroup(awsGroup.DisplayName)) } @@ -872,8 +930,11 @@ func getGroupOperations(awsGroups []*interfaces.Group, googleGroups []*admin.Gro return add, delete, equals } -// 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) { +// getUserOperations returns the users of AWS that must be added, deleted, updated and are equals. +// ignoreUser, if non-nil, is consulted before queueing an AWS-only user for deletion; +// matching users are skipped so wildcard ignore patterns protect AWS users that have no +// counterpart in Google. +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) @@ -917,6 +978,10 @@ 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 { + if ignoreUser != nil && ignoreUser(awsUser.Username) { + log.WithField("user", awsUser.Username).Info("skip delete: ignore list") + continue + } log.WithFields(log.Fields{ "awsUser": awsUser, }).Debug("delete") @@ -1045,7 +1110,7 @@ func DoSync(ctx context.Context, cfg *config.Config) error { log.WithField("error", err).Warn("Problem performing test query against Identity Store") return err } - log.WithField("Groups", response).Info("Test call for groups successful") + log.WithField("Groups", response).Info("Test call to Identity Store successful") // Initialize sync client with // 1. SCIM API client @@ -1060,14 +1125,18 @@ func DoSync(ctx context.Context, cfg *config.Config) error { return err } } else { - err = c.SyncUsers(cfg.UserMatch) - if err != nil { - return err + if cfg.UserMatch != "" { + err = c.SyncUsers(cfg.UserMatch) + if err != nil { + return err + } } - err = c.SyncGroups(cfg.GroupMatch) - if err != nil { - return err + if cfg.GroupMatch != "" { + err = c.SyncGroups(cfg.GroupMatch) + if err != nil { + return err + } } } @@ -1075,28 +1144,71 @@ func DoSync(ctx context.Context, cfg *config.Config) error { } func (s *syncGSuite) ignoreUser(name string) bool { - if s.ignoreUsersSet == nil { - s.ignoreUsersSet = make(map[string]struct{}, len(s.cfg.IgnoreUsers)) - for _, u := range s.cfg.IgnoreUsers { - s.ignoreUsersSet[u] = struct{}{} - } + if s.ignoreUserPatterns == nil { + s.ignoreUserPatterns = trimPatterns(s.cfg.IgnoreUsers) } - _, exists := s.ignoreUsersSet[name] - return exists + return matchesAny(s.ignoreUserPatterns, strings.TrimSpace(name)) } func (s *syncGSuite) ignoreGroup(name string) bool { - if s.ignoreGroupsSet == nil { - s.ignoreGroupsSet = make(map[string]struct{}, len(s.cfg.IgnoreGroups)) - for _, g := range s.cfg.IgnoreGroups { - s.ignoreGroupsSet[g] = struct{}{} + if s.ignoreGroupPatterns == nil { + s.ignoreGroupPatterns = trimPatterns(s.cfg.IgnoreGroups) + } + return matchesAny(s.ignoreGroupPatterns, strings.TrimSpace(name)) +} + +// trimPatterns returns a copy of patterns with surrounding whitespace removed +// and empty entries dropped. It returns a non-nil (possibly empty) slice so the +// lazy-init check (`== nil`) doesn't retry on every call when the config has +// no patterns. +func trimPatterns(patterns []string) []string { + out := make([]string, 0, len(patterns)) + for _, p := range patterns { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) } } - _, exists := s.ignoreGroupsSet[name] - return exists + return out +} + +func matchesAny(patterns []string, name string) bool { + for _, p := range patterns { + if matchIgnorePattern(p, name) { + return true + } + } + return false +} + +// matchIgnorePattern reports whether name matches pattern. The only special +// character is '*', which matches any (possibly empty) substring. Every other +// character — including '?', '[', ']', and '\' — is matched literally. The +// matcher is deliberately narrower than path.Match / filepath.Match so that +// patterns cannot accidentally pick up extra metacharacter semantics. +func matchIgnorePattern(pattern, name string) bool { + if !strings.Contains(pattern, "*") { + return pattern == name + } + parts := strings.Split(pattern, "*") + if !strings.HasPrefix(name, parts[0]) { + return false + } + name = name[len(parts[0]):] + for _, p := range parts[1 : len(parts)-1] { + i := strings.Index(name, p) + if i < 0 { + return false + } + name = name[i+len(p):] + } + return strings.HasSuffix(name, parts[len(parts)-1]) } func (s *syncGSuite) includeGroup(name string) bool { + if s.cfg.IncludeGroups == nil { + return false + } + if s.includeGroupsSet == nil { s.includeGroupsSet = make(map[string]struct{}, len(s.cfg.IncludeGroups)) for _, g := range s.cfg.IncludeGroups { diff --git a/internal/sync_test.go b/internal/sync_test.go new file mode 100644 index 00000000..678dc251 --- /dev/null +++ b/internal/sync_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2020, Amazon.com, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +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 TestMatchIgnorePattern(t *testing.T) { + cases := []struct { + pattern string + name string + want bool + }{ + // Exact match (no wildcard). + {"foo@example.com", "foo@example.com", true}, + {"foo@example.com", "bar@example.com", false}, + + // Leading wildcard (the *@domain case this PR is about). + {"*@example.com", "alice@example.com", true}, + {"*@example.com", "bob@example.com", true}, + {"*@example.com", "alice@other.com", false}, + {"*@example.com", "@example.com", true}, // '*' matches empty + {"*@example.com", "example.com", false}, // missing '@' + + // Trailing wildcard. + {"svc-*", "svc-prod", true}, + {"svc-*", "svc-", true}, + {"svc-*", "other", false}, + + // Middle wildcard. + {"a*z", "az", true}, + {"a*z", "abcz", true}, + {"a*z", "abc", false}, + {"a*z", "zab", false}, + + // Multiple wildcards. + {"*-temp-*", "x-temp-y", true}, + {"*-temp-*", "-temp-", true}, + {"*-temp-*", "temp", false}, + + // Edge patterns. + {"*", "anything", true}, + {"*", "", true}, + {"**", "anything", true}, + + // Other characters from path.Match are literal here. + {"foo?bar", "foo?bar", true}, // '?' is literal, not single-char wildcard + {"foo?bar", "fooXbar", false}, + {"[abc]", "[abc]", true}, // brackets are literal + {"[abc]", "a", false}, + {`a\*b`, `a\Xb`, true}, // pattern is literal 'a\' + wildcard + 'b' + {`a\*b`, "aXb", false}, // backslash is required literally + {`a\*b`, `a\b`, true}, // '*' matches the empty string + } + + for _, c := range cases { + got := matchIgnorePattern(c.pattern, c.name) + assert.Equalf(t, c.want, got, "matchIgnorePattern(%q, %q)", c.pattern, c.name) + } +} + +func TestIgnoreUserWildcard(t *testing.T) { + s := &syncGSuite{ + cfg: &config.Config{ + IgnoreUsers: []string{ + " *@internal.example.com ", // whitespace should be trimmed + "exact@example.com", + "", // empty entries should be dropped + }, + }, + } + + cases := []struct { + name string + want bool + }{ + {"alice@internal.example.com", true}, + {"bob@internal.example.com", true}, + {"exact@example.com", true}, + {"alice@external.example.com", false}, + {" alice@internal.example.com ", true}, // input whitespace trimmed + } + for _, c := range cases { + assert.Equalf(t, c.want, s.ignoreUser(c.name), "ignoreUser(%q)", c.name) + } +} + +func TestIgnoreGroupWildcard(t *testing.T) { + s := &syncGSuite{ + cfg: &config.Config{ + IgnoreGroups: []string{"AWS*", "exact-group"}, + }, + } + cases := []struct { + name string + want bool + }{ + {"AWSAccountFactory", true}, + {"AWSServiceRole", true}, + {"exact-group", true}, + {"OtherGroup", false}, + } + for _, c := range cases { + assert.Equalf(t, c.want, s.ignoreGroup(c.name), "ignoreGroup(%q)", c.name) + } +} + +func TestGetGroupOperationsRespectsIgnore(t *testing.T) { + ignore := func(name string) bool { + return name == "AWSReserved" || name == "ManualGroup" + } + + awsGroups := []*interfaces.Group{ + {DisplayName: "GroupInBoth"}, + {DisplayName: "AWSReserved"}, // ignored, AWS-only + {DisplayName: "ManualGroup"}, // ignored, AWS-only + {DisplayName: "DeleteMe"}, // not ignored, AWS-only -> delete + } + googleGroups := []*admin.Group{ + {Name: "GroupInBoth"}, + {Name: "NewGroup"}, + } + + add, del, eq := getGroupOperations(awsGroups, googleGroups, ignore) + + assert.Len(t, add, 1) + assert.Equal(t, "NewGroup", add[0].DisplayName) + assert.Len(t, del, 1) + assert.Equal(t, "DeleteMe", del[0].DisplayName) + assert.Len(t, eq, 1) + assert.Equal(t, "GroupInBoth", eq[0].DisplayName) +} + +func TestGetUserOperationsRespectsIgnore(t *testing.T) { + ignore := func(name string) bool { + return name == "ignored@example.com" + } + + awsUsers := []*interfaces.User{ + {Username: "user@example.com", Active: true}, + {Username: "delete-me@example.com"}, + {Username: "ignored@example.com"}, + } + googleUsers := []*admin.User{ + { + PrimaryEmail: "user@example.com", + Suspended: false, + Name: &admin.UserName{GivenName: "Test", FamilyName: "User"}, + }, + { + PrimaryEmail: "new-user@example.com", + Suspended: false, + Name: &admin.UserName{GivenName: "New", FamilyName: "User"}, + }, + } + + add, del, _, _ := getUserOperations(awsUsers, googleUsers, ignore) + + assert.Len(t, add, 1) + assert.Equal(t, "new-user@example.com", add[0].Username) + assert.Len(t, del, 1) + assert.Equal(t, "delete-me@example.com", del[0].Username) +} + +func TestGetOperationsNilIgnore(t *testing.T) { + // Passing nil should not panic and should not skip any deletes. + awsGroups := []*interfaces.Group{{DisplayName: "OnlyInAWS"}} + _, del, _ := getGroupOperations(awsGroups, nil, nil) + assert.Len(t, del, 1) + + awsUsers := []*interfaces.User{{Username: "only@aws"}} + _, delU, _, _ := getUserOperations(awsUsers, nil, nil) + assert.Len(t, delU, 1) +} diff --git a/quick-start/single-account.yaml b/quick-start/single-account.yaml new file mode 100644 index 00000000..d9440d2b --- /dev/null +++ b/quick-start/single-account.yaml @@ -0,0 +1,247 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: 'AWS::Serverless-2016-10-31' + +Description: + This CloudFormation template will deploy two instances of SSOSync from + the AWS Serverless Application Repository (SAR). One holding the Secrets + and the other the app itself. + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: AWS IAM Identity Center (Successor to AWS Single Sign-On) + Parameters: + - SCIMEndpointUrl + - SCIMEndpointAccessToken + - IdentityStoreID + - Region + - Label: + default: Google Workspace Credentials + Parameters: + - GoogleAdminEmail + - GoogleCredentials + - Label: + default: Sync Configuration + Parameters: + - PrecacheOrgUnits + - GoogleUserMatch + - GoogleGroupMatch + - Label: + default: "Lambda Configuration" + Parameters: + - ScheduleExpression + - SyncSuspended + - DryRun + - MemorySize + - TimeOut + - Label: + default: Log Configuration + Parameters: + - LogLevel + - LogFormat + - LogRetention + + +Parameters: + ScheduleExpression: + Type: String + Description: | + [optional] Schedule for trigger the execution of ssosync (see CloudWatch schedule expressions), leave empty if you want to trigger execution by another method such as AWS CodePipeline. + Default: rate(15 minutes) + AllowedPattern: '(?!.*\s)|rate\((?:(?:(?:\d{2,}+|[2-9]) (?:minutes|hours|days))|(?:1 (?:minute|hour|day)))\)|(cron\((([0-9]|[1-5][0-9]|60)|\d\/([0-9]|[1-5][0-9]|60)|\*) (([0-9]|[1][0-9]|[2][0-3])|(\d\/([0-9]|[1][0-9]|[2][0-3]))|(([0-9]|[1][0-9]|[2][0-3])-([0-9]|[1][0-9]|[2][0-3]))|\*) (([1-9]|[1-2][0-9]|[3][0-1])|\d\/([1-9]|[1-2][0-9]|[3][0-1])|[1-5]W|L|\*|\?) (([1-9]|[1][1-2])|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|((JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)-(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)(,(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)){0,11}|\d\/([0-9]|[1][0-2])|\?|\*) ((MON|TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)-(TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)(,(TUE|WED|THU|FRI|SAT|SUN)){0,6}|[1-7]L|[1-7]#[1-5]|\?|\*) ((19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)-(19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)(,(19[7-9][0-9]|2[0-1]\d\d))*|\*)\))' + + LogRetention: + Type: Number + Description: | + How long to retain log for, if you want to retain indefinitely, select 0 + Default: 365 + AllowedValues: + - 0 + - 1 + - 3 + - 5 + - 7 + - 14 + - 30 + - 60 + - 90 + - 120 + - 150 + - 180 + - 365 + - 400 + - 545 + - 731 + - 1096 + - 1827 + - 2192 + - 2557 + - 2922 + - 3288 + - 3653 + + LogLevel: + Type: String + Description: | + [required] Log level for Lambda function logging + Default: warn + AllowedValues: + - panic + - fatal + - error + - warn + - info + - debug + - trace + + LogFormat: + Type: String + Description: | + [required] Log format for Lambda function logging + Default: json + AllowedValues: + - json + - text + + MemorySize: + Type: Number + Description: | + [required] the amount of RAM allocated to the function, within range 128-10240MB. default is 128MB. + Default: 128 + MinValue: 128 + MaxValue: 10240 + + TimeOut: + Type: Number + Description: | + [required] Timeout for the Lambda function + Default: 300 + MinValue: 1 + MaxValue: 900 + + SyncSuspended: + Type: String + Description: | + If enabled suspended users that match either the GoogleUserMatch or GoogleGroupMatch criteria they will be replicated. + Default: ignore + AllowedValues: + - ignore + - sync + + DryRun: + Type: String + Description: | + Enabled Dry Run, means the lambda will execute highlighting the changes it would make to the Identity Store, will not making any changes. + Default: live + AllowedValues: + - live + - dry-run + + PrecacheOrgUnits: + Type: String + Description: | + To reduce the volume of apis, ssosync precaches the users and groups from the Google directory. If you directory is large >10,000 users, you may wish to limit the organizational units that are precached. The default is the whole directory '/'. The parameter accpets a comma separate list of OrgUnitPaths, /OU1,/OU2/OU3 in this example all users within the tree of /OU1 and /OU2/OU3 would be precached but not from /OU2 itself. Precaching can be disable by leaving the field empty. + Default: "/" + AllowedPattern: '(?!.*\s)|(^\/$)|(\/[a-zA-Z0-9_\/\- ]{0,50})|(\/[a-zA-Z0-9_\/\- ]{0,50})(?:(,\/[a-zA-Z0-9_\/\- ]{1,50}))' + +# Secrets + GoogleCredentials: + Type: String + Description: | + Credentials to log into Google (content of credentials.json) + Default: "" + AllowedPattern: '(?!.*\s)|(\{(\s)*(".*")(\s)*:(\s)*(".*")(\s)*\})' + NoEcho: true + + GoogleAdminEmail: + Type: String + Description: | + Google Admin email + Default: "" + AllowedPattern: '(?!.*\s)|(([a-zA-Z0-9.+=_-]{0,61})@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)' + NoEcho: true + + SCIMEndpointUrl: + Type: String + Description: | + AWS IAM Identity Center - SCIM Endpoint Url + Default: "" + AllowedPattern: '(?!.*\s)|(https://scim.(us(-gov)?|ap|ca|cn|eu|il|me|mx|sa)-(central|(north|south)?(east|west)?)-([0-9]{1}).amazonaws.com/([A-Za-z0-9]{8,11})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{12})/scim/v2/?)' + + SCIMEndpointAccessToken: + Type: String + Description: | + AWS IAM Identity Center - SCIM AccessToken + Default: "" + AllowedPattern: '(?!.*\s)|([0-9a-zA-Z/=+-\\]{500,620})' + NoEcho: true + + Region: + Type: String + Description: | + AWS Region where AWS IAM Identity Center is enabled + Default: "" + AllowedPattern: '(?!.*\s)|(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\d' + + IdentityStoreID: + Type: String + Description: | + Identifier of Identity Store in AWS IAM Identity Center + Default: "" + AllowedPattern: '(?!.*\s)|d-[1-z0-9]{10}' + + GoogleUserMatch: + Type: String + Description: | + [optional] 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, if left empty no users will be selected but if a pattern has been set for GroupMatch users that are members of the groups it matches will still 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}))))))*)' + + GoogleGroupMatch: + Type: String + Description: | + [optional] 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, if left empty no groups 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}))))))*)' + + +Resources: + + Function: + Type: AWS::Serverless::Application + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:us-east-2:004480582608:applications/SSOSync + SemanticVersion: 1.0.0 + Parameters: + DeployPattern: App only + FunctionName: SSOSync + CrossStackConfig: !GetAtt [Secrets, Outputs.AppConfigLocal] + PrecacheOrgUnits: !Ref PrecacheOrgUnits + GoogleUserMatch: !Ref GoogleUserMatch + GoogleGroupMatch: !Ref GoogleGroupMatch + SyncMethod: groups + ScheduleExpression: !Ref ScheduleExpression + SyncSuspended: !Ref SyncSuspended + DryRun: !Ref DryRun + MemorySize: !Ref MemorySize + TimeOut: !Ref TimeOut + LogLevel: !Ref LogLevel + LogFormat: !Ref LogFormat + LogRetention: !Ref LogRetention + + Secrets: + Type: AWS::Serverless::Application + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:us-east-2:004480582608:applications/SSOSync + SemanticVersion: 1.0.0 + Parameters: + DeployPattern: Secrets only + SCIMEndpointUrl: !Ref SCIMEndpointUrl + SCIMEndpointAccessToken: !Ref SCIMEndpointAccessToken + IdentityStoreID: !Ref IdentityStoreID + Region: !Ref Region + GoogleAdminEmail: !Ref GoogleAdminEmail + GoogleCredentials: !Ref GoogleCredentials diff --git a/template.yaml b/template.yaml index b6f007e7..7a566b0b 100644 --- a/template.yaml +++ b/template.yaml @@ -8,13 +8,14 @@ Metadata: default: Which pattern are we deploying? The app with secrets, the app but using existing secrets, or just the secrets. Parameters: - DeployPattern + - CrossStackConfig - Label: default: AWS IAM Identity Center (Successor to AWS Single Sign-On) Parameters: - SCIMEndpointUrl - SCIMEndpointAccessToken - - Region - IdentityStoreID + - Region - Label: default: Google Workspace Credentials Parameters: @@ -24,6 +25,7 @@ Metadata: default: Sync Configuration Parameters: - SyncMethod + - PrecacheOrgUnits - GoogleUserMatch - GoogleGroupMatch - IgnoreUsers @@ -36,13 +38,18 @@ Metadata: default: "Lambda Configuration" Parameters: - FunctionName - - LogLevel - - LogFormat - - TimeOut - ScheduleExpression - SyncSuspended - DryRun - - PrecacheOrgUnits + - MemorySize + - TimeOut + - Label: + default: Log Configuration + Parameters: + - LogLevel + - LogFormat + - LogRetention + AWS::ServerlessRepo::Application: Name: ssosync @@ -55,7 +62,7 @@ Metadata: Labels: [serverless, sso, lambda, scim] HomePageUrl: https://github.com/awslabs/ssosync # Update the semantic version and run sam publish to publish a new version of your app - SemanticVersion: 1.0.0-rc.10 + SemanticVersion: 1.0.0 # best practice is to use git tags for each release and link to the version tag as your source code URL SourceCodeUrl: https://github.com/awslabs/ssosync @@ -96,7 +103,37 @@ Parameters: Description: | [optional] Schedule for trigger the execution of ssosync (see CloudWatch schedule expressions), leave empty if you want to trigger execution by another method such as AWS CodePipeline. Default: rate(15 minutes) - AllowedPattern: '(?!.*\s)|rate\(\d{1,3} (minutes|hours|days)\)|(cron\((([0-9]|[1-5][0-9]|60)|\d\/([0-9]|[1-5][0-9]|60)|\*) (([0-9]|[1][0-9]|[2][0-3])|(\d\/([0-9]|[1][0-9]|[2][0-3]))|(([0-9]|[1][0-9]|[2][0-3])-([0-9]|[1][0-9]|[2][0-3]))|\*) (([1-9]|[1-2][0-9]|[3][0-1])|\d\/([1-9]|[1-2][0-9]|[3][0-1])|[1-5]W|L|\*|\?) (([1-9]|[1][1-2])|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|((JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)-(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)(,(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)){0,11}|\d\/([0-9]|[1][0-2])|\?|\*) ((MON|TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)-(TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)(,(TUE|WED|THU|FRI|SAT|SUN)){0,6}|[1-7]L|[1-7]#[1-5]|\?|\*) ((19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)-(19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)(,(19[7-9][0-9]|2[0-1]\d\d))*|\*)\))' + AllowedPattern: '(?!.*\s)|rate\((?:(?:(?:\d{2,}+|[2-9]) (?:minutes|hours|days))|(?:1 (?:minute|hour|day)))\)|(cron\((([0-9]|[1-5][0-9]|60)|\d\/([0-9]|[1-5][0-9]|60)|\*) (([0-9]|[1][0-9]|[2][0-3])|(\d\/([0-9]|[1][0-9]|[2][0-3]))|(([0-9]|[1][0-9]|[2][0-3])-([0-9]|[1][0-9]|[2][0-3]))|\*) (([1-9]|[1-2][0-9]|[3][0-1])|\d\/([1-9]|[1-2][0-9]|[3][0-1])|[1-5]W|L|\*|\?) (([1-9]|[1][1-2])|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|((JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)-(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV)(,(FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)){0,11}|\d\/([0-9]|[1][0-2])|\?|\*) ((MON|TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)-(TUE|WED|THU|FRI|SAT|SUN)|(MON|TUE|WED|THU|FRI|SAT)(,(TUE|WED|THU|FRI|SAT|SUN)){0,6}|[1-7]L|[1-7]#[1-5]|\?|\*) ((19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)-(19[7-9][0-9]|2[0-1]\d\d)|(19[7-9][0-9]|2[0-1]\d\d)(,(19[7-9][0-9]|2[0-1]\d\d))*|\*)\))' + + LogRetention: + Type: Number + Description: | + How long to retain log for, if you want to retain indefinitely, select 0 + Default: 0 + AllowedValues: + - 0 + - 1 + - 3 + - 5 + - 7 + - 14 + - 30 + - 60 + - 90 + - 120 + - 150 + - 180 + - 365 + - 400 + - 545 + - 731 + - 1096 + - 1827 + - 2192 + - 2557 + - 2922 + - 3288 + - 3653 LogLevel: Type: String @@ -158,9 +195,9 @@ Parameters: PrecacheOrgUnits: Type: String Description: | - To reduce the volume of apis, ssosync precaches the users and groups from the Google directory. If you directory is large >10,000 users, you may wish to limit the organizational units that are precached. The default is the whole directory '/'. The parameter accpets a comma separate list of OrgUnitPaths, /OU1,/OU2/OU 3 in this example all users within the tree of /OU1 and /OU2/OU 3 would be precached but not from /OU2 itself. Precaching can be disable by leaving the field empty. + To reduce the volume of apis, ssosync precaches the users and groups from the Google directory. If you directory is large >10,000 users, you may wish to limit the organizational units that are precached. The default is the whole directory '/'. The parameter accpets a comma separate list of OrgUnitPaths, /OU1,/OU2/OU3 in this example all users within the tree of /OU1 and /OU2/OU3 would be precached but not from /OU2 itself. Precaching can be disable by leaving the field empty. Default: "/" - AllowedPattern: '(?!.*\s)|(^\/$)|(\/[a-zA-Z0-9_\/\- ]{1,50})(?:(,\/[a-zA-Z0-9_\/\- ]{1,50}))*' + AllowedPattern: '(?!.*\s)|(^\/$)|(\/[a-zA-Z0-9_\/\- ]{0,50})|(\/[a-zA-Z0-9_\/\- ]{0,50})(?:(,\/[a-zA-Z0-9_\/\- ]{1,50}))' # Secrets GoogleCredentials: @@ -184,7 +221,7 @@ Parameters: Description: | AWS IAM Identity Center - SCIM Endpoint Url Default: "" - AllowedPattern: '(?!.*\s)|(https://scim.(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-([0-9]{1}).amazonaws.com/([A-Za-z0-9]{11})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{12})/scim/v2/?)' + AllowedPattern: '(?!.*\s)|(https://scim.(us(-gov)?|ap|ca|cn|eu|il|me|mx|sa)-(central|(north|south)?(east|west)?)-([0-9]{1}).amazonaws.com/([A-Za-z0-9]{8,11})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{4})-([A-Za-z0-9]{12})/scim/v2/?)' SCIMEndpointAccessToken: Type: String @@ -288,16 +325,32 @@ Conditions: - !Equals - !Ref SyncMethod - "users_groups" + SetLogRetention: !Not + - !Equals + - !Ref LogRetention + - 0 SetDryRun: !Equals - !Ref DryRun - "dry-run" SetSyncSuspended: !Equals - !Ref SyncSuspended - "sync" - OnSchedule: !Not - - !Equals + OnSchedule: !And + - !Not + - !Equals - !Ref ScheduleExpression - "" + - !Or + - !Equals + - !Ref DeployPattern + - "App for cross-account" + - !Equals + - !Ref DeployPattern + - "App + secrets" + - !Equals + - !Ref DeployPattern + - "App only" + CreateFunction: !Or - !Equals - !Ref DeployPattern @@ -462,7 +515,6 @@ Resources: Type: AWS::IAM::Role Condition: LocalSecrets Properties: - RoleName: SSOSyncAppRole AssumeRolePolicyDocument: Version: 2012-10-17 Statement: @@ -525,7 +577,6 @@ Resources: Type: AWS::IAM::Role Condition: RemoteSecrets Properties: - RoleName: SSOSyncAppRole AssumeRolePolicyDocument: Version: 2012-10-17 Statement: @@ -597,7 +648,7 @@ Resources: FunctionName: !If [SetFunctionName, !Ref FunctionName, !Ref AWS::NoValue] Description: "An instance of ssosync deplyed from the Serverless Application Repository, for details see http://https://github.com/awslabs/ssosync" Role: !If [RemoteSecrets, !GetAtt SSOSyncRoleRemote.Arn, !GetAtt SSOSyncRoleLocal.Arn] - Runtime: provided.al2 + Runtime: provided.al2023 Handler: bootstrap Architectures: - arm64 @@ -620,18 +671,35 @@ Resources: IGNORE_GROUPS: !If [SetIgnoreGroups, !Ref IgnoreGroups, !Ref AWS::NoValue] IGNORE_USERS: !If [SetIgnoreUsers, !Ref IgnoreUsers, !Ref AWS::NoValue] INCLUDE_GROUPS: !If [SetIncludeGroups, !Ref IncludeGroups, !Ref AWS::NoValue] - DRY_RUN: !If [SetDryRun, 'True', 'False'] - SYNC_SUSPENDED: !If [SetSyncSuspended, 'True', 'False'] - PRECACHE_ORG_UNITS: !If [DisablePrecaching, "DISABLED", !Ref PrecacheOrgUnits] - - Events: - SyncScheduledEvent: - Type: Schedule - Name: AWSSyncSchedule - Properties: - Enabled: !If [OnSchedule, true, true] - Schedule: !If [OnSchedule, !Ref ScheduleExpression, AWS::NoValue] + DRY_RUN: !If [SetDryRun, 'True', !Ref AWS::NoValue] + SYNC_SUSPENDED: !If [SetSyncSuspended, 'True', !Ref AWS::NoValue] + PRECACHE_ORG_UNITS: !If [DisablePrecaching, !Ref AWS::NoValue, !Ref PrecacheOrgUnits] + PermissionForEventsToInvokeLambda: + Type: AWS::Lambda::Permission + Condition: OnSchedule + Properties: + FunctionName: !Ref SSOSyncFunction + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt SyncScheduledEvent.Arn + + SyncScheduledEvent: + Type: AWS::Events::Rule + Condition: OnSchedule + Properties: + Name: AWSSyncSchedule + ScheduleExpression: !Ref ScheduleExpression + Targets: + - Id: SSOSync + Arn: !GetAtt SSOSyncFunction.Arn + + LogGroup: + Type: AWS::Logs::LogGroup + Condition: SetLogRetention + Properties: + LogGroupName: !Sub /aws/lambda/${SSOSyncFunction} + RetentionInDays: !Ref LogRetention KeyAlias: Type: AWS::KMS::Alias