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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ what it is going to do.
> [!WARNING]
> `>= 2.0.0` this makes use of the **Identity Store API** which means:
> * if deploying the lambda 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) then it needs to be deployed into the [IAM Identity Center delegated administration](https://docs.aws.amazon.com/singlesignon/latest/userguide/delegated-admin.html) account. Technically you could deploy in the management account but we would recommend against this.
> * if you are running the project as a cli tool, then the environment will need to be using credentials of a user in the [IAM Identity Center delegated administration](https://docs.aws.amazon.com/singlesignon/latest/userguide/delegated-admin.html) account, with appropriate permissions.
> * if you are running the project as a cli tool, then the environment will need to be using credentials of a user in the [IAM Identity Center delegated administration](https://docs.aws.amazon.com/singlesignon/latest/userguide/delegated-admin.html) account, with appropriate permissions, or credentials that can assume a role in that account via `--assume-role-arn` / `SSOSYNC_ASSUME_ROLE_ARN`.

> [!WARNING]
> `>= 2.1.0` make use of named IAM resources, so if deploying via CICD or IaC template will require **CAPABILITY_NAMED_IAM** to be specified.
Expand Down Expand Up @@ -146,6 +146,7 @@ SSO Sync requires configuration from both Google Workspace and AWS sides.
- AWS credentials file (`~/.aws/credentials`)
- Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`)
- IAM roles (for Lambda deployment)
- If `ssosync` runs outside the delegated admin or management account, provide base credentials that can assume a role in the target account and set `--assume-role-arn` or `SSOSYNC_ASSUME_ROLE_ARN` so Identity Store API calls use that role.

## 🚀 Usage

Expand All @@ -168,6 +169,19 @@ SSO Sync requires configuration from both Google Workspace and AWS sides.
--group-match "name:AWS*"
```

```bash
# Run outside the delegated admin / management account by assuming a target role
./ssosync \
--google-admin admin@company.com \
--google-credentials ./credentials.json \
--scim-endpoint https://scim.us-east-1.amazonaws.com/... \
--scim-access-token AQoDYXdzE... \
--region us-east-1 \
--identity-store-id d-1234567890 \
--assume-role-arn arn:aws:iam::123456789012:role/SSOSyncIdentityCenterAccess \
--group-match "name:AWS*"
```

#### Advanced Examples

```bash
Expand Down Expand Up @@ -202,6 +216,7 @@ export SSOSYNC_SCIM_ENDPOINT="https://scim.us-east-1.amazonaws.com/..."
export SSOSYNC_SCIM_ACCESS_TOKEN="AQoDYXdzE..."
export SSOSYNC_REGION="us-east-1"
export SSOSYNC_IDENTITY_STORE_ID="d-1234567890"
export SSOSYNC_ASSUME_ROLE_ARN="arn:aws:iam::123456789012:role/SSOSyncIdentityCenterAccess"
export SSOSYNC_GROUP_MATCH="name:AWS*"
export SSOSYNC_DRY_RUN="true"
```
Expand All @@ -216,6 +231,7 @@ export SSOSYNC_DRY_RUN="true"
| `--scim-access-token` | `SSOSYNC_SCIM_ACCESS_TOKEN` | AWS SCIM access token | Required |
| `--region` | `SSOSYNC_REGION` | AWS region | Required |
| `--identity-store-id` | `SSOSYNC_IDENTITY_STORE_ID` | AWS Identity Store ID | Required |
| `--assume-role-arn` | `SSOSYNC_ASSUME_ROLE_ARN` | Optional IAM role ARN to assume for Identity Store AWS API calls when running outside the delegated admin / management account | `""` |
| `--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 | `""` |
Expand Down Expand Up @@ -370,6 +386,7 @@ REGION=<secret-arn>
IDENTITY_STORE_ID=<secret-arn>

# Optional environment variables
ASSUME_ROLE_ARN=arn:aws:iam::123456789012:role/SSOSyncIdentityCenterAccess
LOG_LEVEL=info
LOG_FORMAT=json
SYNC_METHOD=groups
Expand All @@ -380,6 +397,8 @@ IGNORE_GROUPS=
DRY_RUN=false
```

`ASSUME_ROLE_ARN` is optional. `SSOSYNC_ASSUME_ROLE_ARN` also works via the normal CLI-style config flow. Set either when the Lambda function runs outside the delegated admin or management account and must assume a target role before making Identity Store API calls.

## 📊 Monitoring & Troubleshooting

### CloudWatch Logs
Expand Down Expand Up @@ -455,4 +474,4 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS

---

**Need help?** Check out our [Issues](https://github.com/awslabs/ssosync/issues) page or start a [Discussion](https://github.com/awslabs/ssosync/discussions).
**Need help?** Check out our [Issues](https://github.com/awslabs/ssosync/issues) page or start a [Discussion](https://github.com/awslabs/ssosync/discussions).
5 changes: 4 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func initConfig() {
"sync_method",
"region",
"identity_store_id",
"assume_role_arn",
}

for _, e := range appEnvVars {
Expand Down Expand Up @@ -255,7 +256,8 @@ func configLambda() {
cfg.Region = getSecretFromCache(getEnvStr("REGION", ""))
cfg.GoogleCredentials = getSecretFromCache(getEnvStr("GOOGLE_CREDENTIALS", ""))
cfg.SCIMAccessToken = getSecretFromCache(getEnvStr("SCIM_ACCESS_TOKEN", ""))

cfg.AssumeRoleArn = getEnvStr("ASSUME_ROLE_ARN", cfg.AssumeRoleArn)

// Handle environment variables for other settings
cfg.LogLevel = getEnvStr("LOG_LEVEL", config.DefaultLogLevel)
cfg.LogFormat = getEnvStr("LOG_FORMAT", config.DefaultLogFormat)
Expand Down Expand Up @@ -298,6 +300,7 @@ func addFlags(_ *cobra.Command, cfg *config.Config) {
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().StringVar(&cfg.AssumeRoleArn, "assume-role-arn", "", "Optional IAM role ARN to assume before calling Identity Center AWS APIs")
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'.")

}
Expand Down
29 changes: 29 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import (
"testing"

"github.com/awslabs/ssosync/internal/config"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAddFlagsIncludesAssumeRoleArn(t *testing.T) {
flag := rootCmd.Flags().Lookup("assume-role-arn")
require.NotNil(t, flag)
assert.Equal(t, "", flag.DefValue)
}

func TestViperParsesAssumeRoleArn(t *testing.T) {
t.Setenv("SSOSYNC_ASSUME_ROLE_ARN", "arn:aws:iam::123456789012:role/identity-center-admin")

v := viper.New()
v.SetEnvPrefix("ssosync")
v.AutomaticEnv()
require.NoError(t, v.BindEnv("assume_role_arn"))

cfg := config.New()
require.NoError(t, v.Unmarshal(cfg))
assert.Equal(t, "arn:aws:iam::123456789012:role/identity-center-admin", cfg.AssumeRoleArn)
}
48 changes: 48 additions & 0 deletions internal/aws/identitystore_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package aws

import (
"context"

aws_sdk "github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
"github.com/aws/aws-sdk-go-v2/service/sts"
)

const assumeRoleSessionName = "ssosync"

type assumeRoleAPI interface {
AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error)
}

var loadDefaultAWSConfig = func(ctx context.Context, optFns ...func(*aws_config.LoadOptions) error) (aws_sdk.Config, error) {
return aws_config.LoadDefaultConfig(ctx, optFns...)
}

var newAssumeRoleClient = func(cfg aws_sdk.Config) assumeRoleAPI {
return sts.NewFromConfig(cfg)
}

// LoadIdentityStoreConfig returns the AWS config used for Identity Store API calls.
// When assumeRoleArn is set, the returned config uses credentials from STS AssumeRole.
func LoadIdentityStoreConfig(ctx context.Context, region string, assumeRoleArn string) (aws_sdk.Config, error) {
cfg, err := loadDefaultAWSConfig(ctx, aws_config.WithRegion(region))
if err != nil {
return aws_sdk.Config{}, err
}

if assumeRoleArn == "" {
return cfg, nil
}

assumedCfg := cfg
assumedCfg.Credentials = aws_sdk.NewCredentialsCache(stscreds.NewAssumeRoleProvider(
newAssumeRoleClient(cfg),
assumeRoleArn,
func(options *stscreds.AssumeRoleOptions) {
options.RoleSessionName = assumeRoleSessionName
},
))

return assumedCfg, nil
}
115 changes: 115 additions & 0 deletions internal/aws/identitystore_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package aws

import (
"context"
"testing"
"time"

aws_sdk "github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sts"
sts_types "github.com/aws/aws-sdk-go-v2/service/sts/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type stubAssumeRoleClient struct {
input *sts.AssumeRoleInput
}

func (c *stubAssumeRoleClient) AssumeRole(_ context.Context, params *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) {
c.input = params

return &sts.AssumeRoleOutput{
Credentials: &sts_types.Credentials{
AccessKeyId: aws_sdk.String("assumed-access-key"),
SecretAccessKey: aws_sdk.String("assumed-secret"),
SessionToken: aws_sdk.String("assumed-token"),
Expiration: aws_sdk.Time(time.Now().Add(time.Hour)),
},
}, nil
}

func TestLoadIdentityStoreConfigWithoutAssumeRole(t *testing.T) {
originalLoader := loadDefaultAWSConfig
originalSTSClient := newAssumeRoleClient
t.Cleanup(func() {
loadDefaultAWSConfig = originalLoader
newAssumeRoleClient = originalSTSClient
})

baseCfg := aws_sdk.Config{
Region: "eu-west-1",
Credentials: credentials.NewStaticCredentialsProvider("base-access-key", "base-secret", ""),
}

loadDefaultAWSConfig = func(_ context.Context, optFns ...func(*aws_config.LoadOptions) error) (aws_sdk.Config, error) {
var options aws_config.LoadOptions
for _, optFn := range optFns {
require.NoError(t, optFn(&options))
}
assert.Equal(t, "eu-west-1", options.Region)

return baseCfg, nil
}

stsClientCalled := false
newAssumeRoleClient = func(cfg aws_sdk.Config) assumeRoleAPI {
stsClientCalled = true
return &stubAssumeRoleClient{}
}

cfg, err := LoadIdentityStoreConfig(context.Background(), "eu-west-1", "")
require.NoError(t, err)

assert.False(t, stsClientCalled)
assert.Equal(t, baseCfg.Region, cfg.Region)

creds, err := cfg.Credentials.Retrieve(context.Background())
require.NoError(t, err)
assert.Equal(t, "base-access-key", creds.AccessKeyID)
}

func TestLoadIdentityStoreConfigWithAssumeRole(t *testing.T) {
originalLoader := loadDefaultAWSConfig
originalSTSClient := newAssumeRoleClient
t.Cleanup(func() {
loadDefaultAWSConfig = originalLoader
newAssumeRoleClient = originalSTSClient
})

stsClient := &stubAssumeRoleClient{}
loadDefaultAWSConfig = func(_ context.Context, optFns ...func(*aws_config.LoadOptions) error) (aws_sdk.Config, error) {
var options aws_config.LoadOptions
for _, optFn := range optFns {
require.NoError(t, optFn(&options))
}
assert.Equal(t, "eu-west-1", options.Region)

return aws_sdk.Config{
Region: options.Region,
Credentials: credentials.NewStaticCredentialsProvider("base-access-key", "base-secret", ""),
}, nil
}

newAssumeRoleClient = func(cfg aws_sdk.Config) assumeRoleAPI {
assert.Equal(t, "eu-west-1", cfg.Region)
return stsClient
}

cfg, err := LoadIdentityStoreConfig(context.Background(), "eu-west-1", "arn:aws:iam::123456789012:role/ssosync-target")
require.NoError(t, err)

assert.Equal(t, "eu-west-1", cfg.Region)

creds, err := cfg.Credentials.Retrieve(context.Background())
require.NoError(t, err)
assert.Equal(t, "assumed-access-key", creds.AccessKeyID)
assert.Equal(t, "assumed-secret", creds.SecretAccessKey)
assert.Equal(t, "assumed-token", creds.SessionToken)

require.NotNil(t, stsClient.input)
assert.Equal(t, "arn:aws:iam::123456789012:role/ssosync-target", aws_sdk.ToString(stsClient.input.RoleArn))
assert.Equal(t, assumeRoleSessionName, aws_sdk.ToString(stsClient.input.RoleSessionName))
}
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type Config struct {
Region string `mapstructure:"region"`
// IdentityStoreID is the ID of the identity store
IdentityStoreID string `mapstructure:"identity_store_id"`
// AssumeRoleArn is the optional IAM role ARN to assume for Identity Center AWS API calls
AssumeRoleArn string `mapstructure:"assume_role_arn"`
// Precaching queries as a comma separated list of query strings
PrecacheOrgUnits []string
// DryRun flag, when set to true, no change will be made in the Identity Store
Expand Down
7 changes: 2 additions & 5 deletions internal/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (
retryablehttp "github.com/hashicorp/go-retryablehttp"

aws_sdk "github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
aws_identitystore "github.com/aws/aws-sdk-go-v2/service/identitystore"
identitystore_types "github.com/aws/aws-sdk-go-v2/service/identitystore/types"
log "github.com/sirupsen/logrus"
Expand Down Expand Up @@ -1040,15 +1039,13 @@ func DoSync(ctx context.Context, cfg *config.Config) error {
return err
}

aws_cfg, err := aws_config.LoadDefaultConfig(context.Background())
identityStoreAWSConfig, err := aws.LoadIdentityStoreConfig(ctx, cfg.Region, cfg.AssumeRoleArn)
if err != nil {
return err
}

// Initialize AWS Identity Store Public API Client with session
identityStoreClient := aws_identitystore.NewFromConfig(aws_cfg, func(o *aws_identitystore.Options) {
o.Region = cfg.Region
})
identityStoreClient := aws_identitystore.NewFromConfig(identityStoreAWSConfig)

// Wrap with dry run client if in dry run mode
var finalIdentityStoreClient interfaces.IdentityStoreAPI = identityStoreClient
Expand Down