diff --git a/.goreleaser.yml b/.goreleaser.yml index 8779680c..fb6890fb 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -44,4 +44,3 @@ archives: format_overrides: - goos: windows format: zip - diff --git a/README.md b/README.md index 618116e1..56e69610 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,32 @@ as locally running the ssosync tool. ### Google -First, you have to setup your API. In the project you want to use go to the [Console](https://console.developers.google.com/apis) and select *API & Services* > *Enable APIs and Services*. Search for *Admin SDK* and *Enable* the API. +First, you have to setup your API. In the project you want to use go to the [Console](https://console.developers.google.com/apis) and select +*API & Services* > *Enable APIs and Services*. Search for *Admin SDK* and *Enable* the API. -You have to perform this [tutorial](https://developers.google.com/admin-sdk/directory/v1/guides/delegation) to create a service account that you use to sync your users. Save the `JSON file` you create during the process and rename it to `credentials.json`. +You have to perform this [tutorial](https://developers.google.com/admin-sdk/directory/v1/guides/delegation) to create a +service account that you use to sync your users. This is the service account that is used to impersonate a user +(via `--google-admin`). You have two possibilities to use this service account. Create a service account key credential, +as describe in the tutorial above. Or use +[Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation). + +#### Service account key credential + +Save the `JSON file` you create during the process described in the tutorial above and rename it to `credentials.json`. +Please, keep this file safe, or store it in the AWS Secrets Manager. + +#### Workload Identity Federation + +Set up Workload Identity Federation for AWS as described +[here](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds) and save the `JSON file`, and +rename it to `credentials.json`. Provide the email address of service account used to impersonate a user using +`--google-service-account-email`. +Note that the `JSON file` created using this approach **does not** contain any sensitive data. + +> You can also use the `--google-credentials` parameter to explicitly specify the file containing the credentials. +> Setting this parameter to an empty string will use +> [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). -> you can also use the `--google-credentials` parameter to explicitly specify the file with the service credentials. Please, keep this file safe, or store it in the AWS Secrets Manager In the domain-wide delegation for the Admin API, you have to specify the following scopes for the user. @@ -112,6 +133,9 @@ In the Search box type `Admin` and select the `Admin SDK` option. Click the `Ena You will have to specify the email address of an admin via `--google-admin` to assume this users role in the Directory. +> When running this tool as AWS Lambda, the parameter `--google-credentials` is expected to contain the content of the +> `JSON file`. + ### AWS Go to the AWS Single Sign-On console in the region you have set up AWS SSO and select diff --git a/SAR.md b/SAR.md index 5ae21196..c83da6bb 100644 --- a/SAR.md +++ b/SAR.md @@ -23,6 +23,7 @@ There are general configuration parameters to the application stack. * `GoogleCredentials` contains the content of the `credentials.json` file * `GoogleAdminEmail` contains the email address of an admin +* `GoogleSAEmail` contains the email address of a Google service account used to impersonate the admin user The secrets are stored in the [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). diff --git a/cmd/root.go b/cmd/root.go index 4d094d67..36b4ffac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,7 +25,7 @@ import ( "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/codepipeline" + "github.com/aws/aws-sdk-go/service/codepipeline" "github.com/aws/aws-sdk-go/service/secretsmanager" "github.com/awslabs/ssosync/internal" "github.com/awslabs/ssosync/internal/config" @@ -69,7 +69,7 @@ Complete documentation is available at https://github.com/awslabs/ssosync`, func Execute() { if cfg.IsLambda { log.Info("Executing as Lambda") - lambda.Start(Handler) + lambda.Start(Handler) } if err := rootCmd.Execute(); err != nil { @@ -95,7 +95,7 @@ func Handler(ctx context.Context, event events.CodePipelineEvent) (string, error jobID := event.CodePipelineJob.ID if len(jobID) == 0 { panic("CodePipeline Job ID is not set") - } + } // mark the job as Failure. cplFailure := &codepipeline.PutJobFailureResultInput{ JobId: aws.String(jobID), @@ -124,10 +124,10 @@ func Handler(ctx context.Context, event events.CodePipelineEvent) (string, error if cplErr != nil { log.Fatalf(errors.Wrap(err, "Failed to update CodePipeline jobID status").Error()) } - + return "Success", nil } - + if err != nil { log.Fatalf(errors.Wrap(err, "Notifying Lambda and mark this execution as Failure").Error()) return "Failure", err @@ -159,6 +159,7 @@ func initConfig() { appEnvVars := []string{ "google_admin", + "google_sa_email", "google_credentials", "scim_access_token", "scim_endpoint", @@ -194,7 +195,7 @@ func initConfig() { } func configLambda() { - s := session.Must(session.NewSession()) + s := session.Must(session.NewSession()) svc := secretsmanager.New(s) secrets := config.NewSecrets(svc) @@ -204,6 +205,12 @@ func configLambda() { } cfg.GoogleAdmin = unwrap + unwrap, err = secrets.GoogleSAEmail(os.Getenv("GOOGLE_SA_EMAIL")) + if err != nil { + log.Fatalf(errors.Wrap(err, "cannot read config").Error()) + } + cfg.GoogleSAEmail = unwrap + unwrap, err = secrets.GoogleCredentials(os.Getenv("GOOGLE_CREDENTIALS")) if err != nil { log.Fatalf(errors.Wrap(err, "cannot read config: GOOGLE_CREDENTIALS").Error()) @@ -293,6 +300,7 @@ func addFlags(cmd *cobra.Command, cfg *config.Config) { 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().StringVarP(&cfg.GoogleSAEmail, "google-service-account-email", "W", "", "Google Workload Identity Federation SA email. If set, google-credentials must be associated with a Workload Identity Federation json file") 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'") diff --git a/internal/config/config.go b/internal/config/config.go index 820f3a27..aee96127 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,14 +5,21 @@ package config type Config struct { // Verbose toggles the verbosity Debug bool - // LogLevel is the level with with to log for this config + // LogLevel is the level that is used for logging LogLevel string `mapstructure:"log_level"` // LogFormat is the format that is used for logging LogFormat string `mapstructure:"log_format"` // GoogleCredentials ... GoogleCredentials string `mapstructure:"google_credentials"` // GoogleAdmin ... - GoogleAdmin string `mapstructure:"google_admin"` + GoogleAdmin string `mapstructure:"google_admin"` + // GoogleSAEmail is the email of a service account enabled for domain-wide delegation + // If nonempty, it is assumed that Workload Identity Federation is to be used. In that case, the + // specified service account needs to be configured for domain-wide delegation and the service account + // used for Workload Identity Federation must include "Service Account Token Creator" for the specified + // service account. Moreover, GoogleCredentials must be associated with a json file configured for Workload + // Identity Federation. + GoogleSAEmail string `mapstructure:"google_sa_email"` // UserMatch ... UserMatch string `mapstructure:"user_match"` // GroupFilter ... @@ -31,7 +38,7 @@ type Config struct { IgnoreGroups []string `mapstructure:"ignore_groups"` // Include groups ... IncludeGroups []string `mapstructure:"include_groups"` - // SyncMethod allow to defined the sync method used to get the user and groups from Google Workspace + // SyncMethod allow to define the sync method used to get the user and groups from Google Workspace SyncMethod string `mapstructure:"sync_method"` // Region is the region that the identity store exists on Region string `mapstructure:"region"` diff --git a/internal/config/secrets.go b/internal/config/secrets.go index e8ce2137..99171439 100644 --- a/internal/config/secrets.go +++ b/internal/config/secrets.go @@ -26,6 +26,14 @@ func (s *Secrets) GoogleAdminEmail(secretArn string) (string, error) { return s.getSecret(secretArn) } +// GoogleSAEmail ... +func (s *Secrets) GoogleSAEmail(secretArn string) (string, error) { + if len([]rune(secretArn)) == 0 { + return s.getSecret("SSOSyncGoogleSAEmail") + } + return s.getSecret(secretArn) +} + // SCIMAccessToken ... func (s *Secrets) SCIMAccessToken(secretArn string) (string, error) { if len([]rune(secretArn)) == 0 { diff --git a/internal/google/client.go b/internal/google/client.go index 74069918..23179a8f 100644 --- a/internal/google/client.go +++ b/internal/google/client.go @@ -20,8 +20,10 @@ import ( "errors" "strings" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/impersonate" "google.golang.org/api/option" ) @@ -39,7 +41,28 @@ type client struct { } // NewClient creates a new client for Google's Admin API -func NewClient(ctx context.Context, adminEmail string, serviceAccountKey []byte) (Client, error) { +func NewClient(ctx context.Context, adminEmail, saEmail string, serviceAccountKey []byte) (Client, error) { + if saEmail == "" { + return newClientForServiceAccountKey(ctx, adminEmail, serviceAccountKey) + } + + return newClientForWorkloadIdentityFederation(ctx, adminEmail, saEmail, serviceAccountKey) +} + +func newClientForServiceAccountKey(ctx context.Context, adminEmail string, serviceAccountKey []byte) (Client, error) { + if serviceAccountKey == nil { + cred, err := google.FindDefaultCredentials(ctx, admin.AdminDirectoryGroupReadonlyScope, + admin.AdminDirectoryGroupMemberReadonlyScope, + admin.AdminDirectoryUserReadonlyScope) + if err != nil { + return nil, err + } + serviceAccountKey = cred.JSON + if serviceAccountKey == nil { + return nil, errors.New("default credentials not appropriate for JSON configuration") + } + } + config, err := google.JWTConfigFromJSON(serviceAccountKey, admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryGroupMemberReadonlyScope, admin.AdminDirectoryUserReadonlyScope) @@ -63,6 +86,36 @@ func NewClient(ctx context.Context, adminEmail string, serviceAccountKey []byte) }, nil } +func newClientForWorkloadIdentityFederation(ctx context.Context, adminEmail, saEmail string, serviceAccountKey []byte) (Client, error) { + var err error + var ts oauth2.TokenSource + var config = impersonate.CredentialsConfig{ + Subject: adminEmail, + Scopes: []string{admin.AdminDirectoryGroupReadonlyScope, admin.AdminDirectoryGroupMemberReadonlyScope, admin.AdminDirectoryUserReadonlyScope}, + TargetPrincipal: saEmail, + } + + if serviceAccountKey == nil { + ts, err = impersonate.CredentialsTokenSource(ctx, config) + } else { + ts, err = impersonate.CredentialsTokenSource(ctx, config, option.WithCredentialsJSON(serviceAccountKey)) + } + + if err != nil { + return nil, err + } + srv, err := admin.NewService(ctx, option.WithTokenSource(ts)) + + if err != nil { + return nil, err + } + + return &client{ + ctx: ctx, + service: srv, + }, nil +} + // GetDeletedUsers will get the deleted users from the Google's Admin API. func (c *client) GetDeletedUsers() ([]*admin.User, error) { u := make([]*admin.User, 0) diff --git a/internal/sync.go b/internal/sync.go index 9d3a81d3..7ff72785 100644 --- a/internal/sync.go +++ b/internal/sync.go @@ -18,7 +18,7 @@ package internal import ( "context" "errors" - "io/ioutil" + "os" "github.com/awslabs/ssosync/internal/aws" "github.com/awslabs/ssosync/internal/config" @@ -644,7 +644,7 @@ func getGroupOperations(awsGroups []*aws.Group, googleGroups []*admin.Group) (ad // AWS Groups found and not found in google for _, gGroup := range googleGroups { - if _, found := awsMap[gGroup.Name]; found { + if _, found := awsMap[gGroup.Name]; found { log.WithField("gGroup", gGroup).Debug("equals") equals = append(equals, awsMap[gGroup.Name]) } else { @@ -746,16 +746,6 @@ func getGroupUsersOperations(gGroupsUsers map[string][]*admin.User, awsGroupsUse func DoSync(ctx context.Context, cfg *config.Config) error { log.Info("Syncing AWS users and groups from Google Workspace SAML Application") - creds := []byte(cfg.GoogleCredentials) - - if !cfg.IsLambda { - b, err := ioutil.ReadFile(cfg.GoogleCredentials) - if err != nil { - return err - } - creds = b - } - // create a http client with retry and backoff capabilities retryClient := retryablehttp.NewClient() @@ -768,9 +758,21 @@ func DoSync(ctx context.Context, cfg *config.Config) error { httpClient := retryClient.StandardClient() - googleClient, err := google.NewClient(ctx, cfg.GoogleAdmin, creds) + var creds []byte = nil + if cfg.GoogleCredentials != "" { + if cfg.IsLambda { + creds = []byte(cfg.GoogleCredentials) + } else { + b, err := os.ReadFile(cfg.GoogleCredentials) + if err != nil { + return err + } + creds = b + } + } + googleClient, err := google.NewClient(ctx, cfg.GoogleAdmin, cfg.GoogleSAEmail, creds) if err != nil { - log.WithField("error", err).Warn("Problem establising a connection to Google directory") + log.WithField("error", err).Warn("Problem establising a connection to Google directory") return err } @@ -781,7 +783,7 @@ func DoSync(ctx context.Context, cfg *config.Config) error { Token: cfg.SCIMAccessToken, }) if err != nil { - log.WithField("error", err).Warn("Problem establising a SCIM connection to AWS IAM Identity Center") + log.WithField("error", err).Warn("Problem establising a SCIM connection to AWS IAM Identity Center") return err } @@ -792,7 +794,7 @@ func DoSync(ctx context.Context, cfg *config.Config) error { }) if err != nil { - log.WithField("error", err).Warn("Problem establising a session for Identity Store") + log.WithField("error", err).Warn("Problem establising a session for Identity Store") return err } diff --git a/template.yaml b/template.yaml index 1b566aa7..d88ea4a1 100644 --- a/template.yaml +++ b/template.yaml @@ -16,10 +16,11 @@ Metadata: - Region - IdentityStoreID - Label: - default: Google Workspace Credentials + default: Google Workspace Credentials Parameters: - GoogleAdminEmail - GoogleCredentials + - GoogleSAEmail - Label: default: Sync Configuration Parameters: @@ -117,7 +118,7 @@ Parameters: AllowedValues: - json - text - + TimeOut: Type: Number Description: | @@ -143,6 +144,12 @@ Parameters: 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 + GoogleSAEmail: + Type: String + Description: | + Google service account email used to impersonate Google Admin. Providing a value enables Workload Identity Federation: https://cloud.google.com/iam/docs/workload-identity-federation + Default: "" + SCIMEndpointUrl: Type: String Description: | @@ -209,14 +216,14 @@ Parameters: SyncMethod: Type: String - Description: Sync method to use + Description: Sync method to use Default: groups AllowedValues: - groups - users_groups Conditions: - SetFunctionName: !Not + SetFunctionName: !Not - !Equals - !Ref FunctionName - "" @@ -228,7 +235,7 @@ Conditions: - !Equals - !Ref SyncMethod - "groups" - SetGoogleGroupMatch: !And + SetGoogleGroupMatch: !And - !Not - !Equals - !Ref GoogleGroupMatch @@ -236,7 +243,7 @@ Conditions: - !Equals - !Ref SyncMethod - "groups" - SetIgnoreGroups: !Not + SetIgnoreGroups: !Not - !Equals - !Ref IgnoreGroups - "" @@ -442,10 +449,11 @@ Resources: Resource: - !If [CreateSecrets, !Ref SecretGoogleCredentials, !Select [0, !Split [',', !Ref CrossStackConfig]]] - !If [CreateSecrets, !Ref SecretGoogleAdminEmail, !Select [1, !Split [',', !Ref CrossStackConfig]]] - - !If [CreateSecrets, !Ref SecretSCIMEndpoint, !Select [2, !Split [',', !Ref CrossStackConfig]]] - - !If [CreateSecrets, !Ref SecretSCIMAccessToken, !Select [3, !Split [',', !Ref CrossStackConfig]]] - - !If [CreateSecrets, !Ref SecretRegion, !Select [4, !Split [',', !Ref CrossStackConfig]]] - - !If [CreateSecrets, !Ref SecretIdentityStoreID, !Select [5, !Split [',', !Ref CrossStackConfig]]] + - !If [CreateSecrets, !Ref SecretGoogleSAEmail, !Select [2, !Split [',', !Ref CrossStackConfig]]] + - !If [CreateSecrets, !Ref SecretSCIMEndpoint, !Select [3, !Split [',', !Ref CrossStackConfig]]] + - !If [CreateSecrets, !Ref SecretSCIMAccessToken, !Select [4, !Split [',', !Ref CrossStackConfig]]] + - !If [CreateSecrets, !Ref SecretRegion, !Select [5, !Split [',', !Ref CrossStackConfig]]] + - !If [CreateSecrets, !Ref SecretIdentityStoreID, !Select [6, !Split [',', !Ref CrossStackConfig]]] - Sid: IdentityStoreAccesPolicy Effect: Allow Action: @@ -497,16 +505,17 @@ Resources: Resource: - !Select [0, !Split [',', !Ref CrossStackConfig]] # GoogleCredentials - !Select [1, !Split [',', !Ref CrossStackConfig]] # GoogleAdminEmail - - !Select [2, !Split [',', !Ref CrossStackConfig]] # SCIMEndpointUrl - - !Select [3, !Split [',', !Ref CrossStackConfig]] # SCIMEndpointAccessToken - - !Select [4, !Split [',', !Ref CrossStackConfig]] # Region - - !Select [5, !Split [',', !Ref CrossStackConfig]] # IdentityStoreID + - !Select [2, !Split [',', !Ref CrossStackConfig]] # GoogleSAEmail + - !Select [3, !Split [',', !Ref CrossStackConfig]] # SCIMEndpointUrl + - !Select [4, !Split [',', !Ref CrossStackConfig]] # SCIMEndpointAccessToken + - !Select [5, !Split [',', !Ref CrossStackConfig]] # Region + - !Select [6, !Split [',', !Ref CrossStackConfig]] # IdentityStoreID - Sid: KMSDecryptPolicy Effect: Allow Action: - kms:Decrypt Resource: - - !Select [6, !Split [',', !Ref CrossStackConfig]] # KMSSecret + - !Select [7, !Split [',', !Ref CrossStackConfig]] # KMSSecret - Sid: IdentityStoreAccesPolicy Effect: Allow Action: @@ -548,6 +557,7 @@ Resources: LOG_FORMAT: !Ref LogFormat GOOGLE_CREDENTIALS: !If [CreateSecrets, !Ref SecretGoogleCredentials, !Select [0, !Split [',', !Ref CrossStackConfig]]] GOOGLE_ADMIN: !If [CreateSecrets, !Ref SecretGoogleAdminEmail, !Select [1, !Split [',', !Ref CrossStackConfig]]] + GOOGLE_SA_EMAIL: !If [CreateSecrets, !Ref SecretGoogleSAEmail, !Select [1, !Split [',', !Ref CrossStackConfig]]] SCIM_ENDPOINT: !If [CreateSecrets, !Ref SecretSCIMEndpoint, !Select [2, !Split [',', !Ref CrossStackConfig]]] SCIM_ACCESS_TOKEN: !If [CreateSecrets, !Ref SecretSCIMAccessToken, !Select [3, !Split [',', !Ref CrossStackConfig]]] REGION: !If [CreateSecrets, !Ref SecretRegion, !Select [4, !Split [',', !Ref CrossStackConfig]]] @@ -649,6 +659,28 @@ Resources: - secretsmanager:GetSecretValue Resource: '*' + SecretGoogleSAEmail: + Type: "AWS::SecretsManager::Secret" + Properties: + Name: SSOSyncGoogleSAEmail + SecretString: !Ref GoogleSAEmail + KmsKeyId: !If [CreateKey, !Ref KeyAlias, alias/aws/secretsmanager] + + SecretGoogleSAEmailPolicy: + Type: AWS::SecretsManager::ResourcePolicy + Condition: CreateKey + Properties: + SecretId: !Ref GoogleSAEmail + ResourcePolicy: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + AWS: !Sub arn:aws:iam::${CrossStackConfig}:root + Action: + - secretsmanager:GetSecretValue + Resource: '*' + SecretSCIMEndpoint: # This can be moved to custom provider Type: "AWS::SecretsManager::Secret" Condition: CreateSecrets @@ -671,7 +703,7 @@ Resources: Action: - secretsmanager:GetSecretValue Resource: '*' - + SecretSCIMAccessToken: # This can be moved to custom provider Type: "AWS::SecretsManager::Secret" Condition: CreateSecrets @@ -694,7 +726,7 @@ Resources: Action: - secretsmanager:GetSecretValue Resource: '*' - + SecretRegion: Type: "AWS::SecretsManager::Secret" Condition: CreateSecrets @@ -717,7 +749,7 @@ Resources: Action: - secretsmanager:GetSecretValue Resource: '*' - + SecretIdentityStoreID: Type: "AWS::SecretsManager::Secret" Condition: CreateSecrets