diff --git a/go.mod b/go.mod index 756786aa70..589be30ba5 100644 --- a/go.mod +++ b/go.mod @@ -275,3 +275,5 @@ require ( ) go 1.21.3 + +replace github.com/hashicorp/aws-sdk-go-base/v2 => github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.38.0.20231103062846-b877e512ee57 diff --git a/go.sum b/go.sum index 697190aa43..03c0592d78 100644 --- a/go.sum +++ b/go.sum @@ -645,8 +645,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rH github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.38 h1:C5DvIFGNn7Lhu8SV6PAUY5WNq3aPYYqdnC4PT1tJc1o= -github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.38/go.mod h1:zjTe61MBV+nvdnW4MDP1NBFEC6qyCbYEd9tI0x8FY5s= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.38.0.20231103062846-b877e512ee57 h1:JUQYJk6eGLmsdh1/wk3xfqKH6ABd9CrQ87LsjmjkVqE= +github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.38.0.20231103062846-b877e512ee57/go.mod h1:2QDVxfGLmjkLE/eha2EyrkuaW6GcZSukY+ifkg5zclY= github.com/hashicorp/consul/api v1.13.0 h1:2hnLQ0GjQvw7f3O61jMO8gbasZviZTrt9R8WzgiirHc= github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= github.com/hashicorp/consul/sdk v0.8.0 h1:OJtKBtEjboEZvG6AOUdh4Z1Zbyu0WcxQ0qatRrZHTVU= diff --git a/internal/backend/remote-state/s3/backend.go b/internal/backend/remote-state/s3/backend.go index 7bacebcbb2..49b359ddbd 100644 --- a/internal/backend/remote-state/s3/backend.go +++ b/internal/backend/remote-state/s3/backend.go @@ -605,6 +605,19 @@ var endpointsSchema = singleNestedAttribute{ }, }, + "sso": stringAttribute{ + configschema.Attribute{ + Type: cty.String, + Optional: true, + Description: "A custom endpoint for the IAM Identity Center (formerly known as SSO) API", + }, + validateString{ + Validators: []stringValidator{ + validateStringValidURL, + }, + }, + }, + "sts": stringAttribute{ configschema.Attribute{ Type: cty.String, @@ -1052,6 +1065,13 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { cfg.IamEndpoint = v } + if v, ok := retrieveArgument(&diags, + newAttributeRetriever(obj, cty.GetAttrPath("endpoints").GetAttr("sso")), + newEnvvarRetriever("AWS_ENDPOINT_URL_SSO"), + ); ok { + cfg.SsoEndpoint = v + } + if v, ok := retrieveArgument(&diags, newAttributeRetriever(obj, cty.GetAttrPath("endpoints").GetAttr("sts")), newAttributeRetriever(obj, cty.GetAttrPath("sts_endpoint")), diff --git a/internal/backend/remote-state/s3/backend_complete_test.go b/internal/backend/remote-state/s3/backend_complete_test.go index ce513a1b87..433d577b39 100644 --- a/internal/backend/remote-state/s3/backend_complete_test.go +++ b/internal/backend/remote-state/s3/backend_complete_test.go @@ -626,8 +626,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey tc := tc t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.InitSessionTestEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.InitSessionTestEnv(t) ctx := context.TODO() @@ -669,9 +668,9 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey } if tc.EnableWebIdentityEnvVars { - os.Setenv("AWS_ROLE_ARN", servicemocks.MockStsAssumeRoleWithWebIdentityArn) - os.Setenv("AWS_ROLE_SESSION_NAME", servicemocks.MockStsAssumeRoleWithWebIdentitySessionName) - os.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", file.Name()) + t.Setenv("AWS_ROLE_ARN", servicemocks.MockStsAssumeRoleWithWebIdentityArn) + t.Setenv("AWS_ROLE_SESSION_NAME", servicemocks.MockStsAssumeRoleWithWebIdentitySessionName) + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", file.Name()) } /*else if tc.EnableWebIdentityConfig { tc.Config.AssumeRoleWithWebIdentity = &AssumeRoleWithWebIdentity{ RoleARN: servicemocks.MockStsAssumeRoleWithWebIdentityArn, @@ -703,7 +702,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey t.Fatalf("unexpected error writing shared configuration file: %s", err) } - setSharedConfigFile(file.Name()) + setSharedConfigFile(t, file.Name()) } if tc.SharedCredentialsFile != "" { @@ -728,7 +727,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey } for k, v := range tc.EnvironmentVariables { - os.Setenv(k, v) + t.Setenv(k, v) } b, diags := configureBackend(t, tc.config) @@ -1112,8 +1111,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey tc := tc t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.InitSessionTestEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.InitSessionTestEnv(t) ctx := context.TODO() @@ -1162,7 +1160,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey t.Fatalf("unexpected error writing shared configuration file: %s", err) } - setSharedConfigFile(file.Name()) + setSharedConfigFile(t, file.Name()) } if tc.SharedCredentialsFile != "" { @@ -1187,7 +1185,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey } for k, v := range tc.EnvironmentVariables { - os.Setenv(k, v) + t.Setenv(k, v) } b, diags := configureBackend(t, tc.config) @@ -1553,8 +1551,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey tc := tc t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.InitSessionTestEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.InitSessionTestEnv(t) ctx := context.TODO() @@ -1603,7 +1600,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey t.Fatalf("unexpected error writing shared configuration file: %s", err) } - setSharedConfigFile(file.Name()) + setSharedConfigFile(t, file.Name()) } if tc.SharedCredentialsFile != "" { @@ -1628,7 +1625,7 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey } for k, v := range tc.EnvironmentVariables { - os.Setenv(k, v) + t.Setenv(k, v) } b, diags := configureBackend(t, tc.config) @@ -1904,8 +1901,7 @@ web_identity_token_file = no-such-file tc := tc t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.InitSessionTestEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.InitSessionTestEnv(t) ctx := context.TODO() @@ -1919,7 +1915,7 @@ web_identity_token_file = no-such-file } for k, v := range tc.EnvironmentVariables { - os.Setenv(k, v) + t.Setenv(k, v) } ts := servicemocks.MockAwsApiServer("STS", tc.MockStsEndpoints) @@ -1934,7 +1930,7 @@ web_identity_token_file = no-such-file t.Fatalf("error creating temp dir: %s", err) } defer os.Remove(tempdir) - os.Setenv("TMPDIR", tempdir) + t.Setenv("TMPDIR", tempdir) tokenFile, err := os.CreateTemp("", "aws-sdk-go-base-web-identity-token-file") if err != nil { @@ -1967,7 +1963,7 @@ web_identity_token_file = no-such-file } if tc.SetTokenFileEnvironmentVariable { - os.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", tokenFileName) + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", tokenFileName) } if tc.SharedConfigurationFile != "" { @@ -2014,6 +2010,228 @@ web_identity_token_file = no-such-file } } +func TestBackendConfig_Authentication_SSO(t *testing.T) { + const ssoSessionName = "test-sso-session" + + testCases := map[string]struct { + config map[string]any + SharedConfigurationFile string + SetSharedConfigurationFile bool + ExpectedCredentialsValue aws.Credentials + ValidateDiags DiagsValidator + MockStsEndpoints []*servicemocks.MockEndpoint + }{ + "shared configuration file": { + config: map[string]any{}, + SharedConfigurationFile: fmt.Sprintf(` +[default] +sso_session = %s +sso_account_id = 123456789012 +sso_role_name = testRole +region = us-east-1 + +[sso-session test-sso-session] +sso_region = us-east-1 +sso_start_url = https://d-123456789a.awsapps.com/start +sso_registration_scopes = sso:account:access +`, ssoSessionName), + SetSharedConfigurationFile: true, + ExpectedCredentialsValue: mockdata.MockSsoCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, + }, + } + + for name, tc := range testCases { + tc := tc + + t.Run(name, func(t *testing.T) { + servicemocks.InitSessionTestEnv(t) + + ctx := context.TODO() + + // Populate required fields + tc.config["region"] = "us-east-1" + tc.config["bucket"] = "bucket" + tc.config["key"] = "key" + + if tc.ValidateDiags == nil { + tc.ValidateDiags = ExpectNoDiags + } + + err := servicemocks.SsoTestSetup(t, ssoSessionName) + if err != nil { + t.Fatalf("setup: %s", err) + } + + endpoints := map[string]any{} + + closeSso, ssoEndpoint := servicemocks.SsoCredentialsApiMock() + defer closeSso() + endpoints["sso"] = ssoEndpoint + + ts := servicemocks.MockAwsApiServer("STS", tc.MockStsEndpoints) + defer ts.Close() + endpoints["sts"] = ts.URL + + tempdir, err := os.MkdirTemp("", "temp") + if err != nil { + t.Fatalf("error creating temp dir: %s", err) + } + defer os.Remove(tempdir) + t.Setenv("TMPDIR", tempdir) + + if tc.SharedConfigurationFile != "" { + file, err := os.CreateTemp("", "aws-sdk-go-base-shared-configuration-file") + + if err != nil { + t.Fatalf("unexpected error creating temporary shared configuration file: %s", err) + } + + defer os.Remove(file.Name()) + + err = os.WriteFile(file.Name(), []byte(tc.SharedConfigurationFile), 0600) + + if err != nil { + t.Fatalf("unexpected error writing shared configuration file: %s", err) + } + + tc.config["shared_config_files"] = []any{file.Name()} + } + + tc.config["skip_credentials_validation"] = true + + tc.config["endpoints"] = endpoints + + b, diags := configureBackend(t, tc.config) + + tc.ValidateDiags(t, diags) + if diags.HasErrors() { + return + } + + credentials, err := b.awsConfig.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Error when requesting credentials: %s", err) + } + + if diff := cmp.Diff(credentials, tc.ExpectedCredentialsValue, cmpopts.IgnoreFields(aws.Credentials{}, "Expires")); diff != "" { + t.Fatalf("unexpected credentials: (- got, + expected)\n%s", diff) + } + }) + } +} + +func TestBackendConfig_Authentication_LegacySSO(t *testing.T) { + const ssoStartUrl = "https://d-123456789a.awsapps.com/start" + + testCases := map[string]struct { + config map[string]any + SharedConfigurationFile string + SetSharedConfigurationFile bool + ExpectedCredentialsValue aws.Credentials + ValidateDiags DiagsValidator + MockStsEndpoints []*servicemocks.MockEndpoint + }{ + "shared configuration file": { + config: map[string]any{}, + SharedConfigurationFile: fmt.Sprintf(` +[default] +sso_start_url = %s +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = testRole +region = us-east-1 +`, ssoStartUrl), + SetSharedConfigurationFile: true, + ExpectedCredentialsValue: mockdata.MockSsoCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, + }, + } + + for name, tc := range testCases { + tc := tc + + t.Run(name, func(t *testing.T) { + servicemocks.InitSessionTestEnv(t) + + ctx := context.TODO() + + // Populate required fields + tc.config["region"] = "us-east-1" + tc.config["bucket"] = "bucket" + tc.config["key"] = "key" + + if tc.ValidateDiags == nil { + tc.ValidateDiags = ExpectNoDiags + } + + err := servicemocks.SsoTestSetup(t, ssoStartUrl) + if err != nil { + t.Fatalf("setup: %s", err) + } + + endpoints := map[string]any{} + + closeSso, ssoEndpoint := servicemocks.SsoCredentialsApiMock() + defer closeSso() + endpoints["sso"] = ssoEndpoint + + ts := servicemocks.MockAwsApiServer("STS", tc.MockStsEndpoints) + defer ts.Close() + endpoints["sts"] = ts.URL + + tempdir, err := os.MkdirTemp("", "temp") + if err != nil { + t.Fatalf("error creating temp dir: %s", err) + } + defer os.Remove(tempdir) + t.Setenv("TMPDIR", tempdir) + + if tc.SharedConfigurationFile != "" { + file, err := os.CreateTemp("", "aws-sdk-go-base-shared-configuration-file") + + if err != nil { + t.Fatalf("unexpected error creating temporary shared configuration file: %s", err) + } + + defer os.Remove(file.Name()) + + err = os.WriteFile(file.Name(), []byte(tc.SharedConfigurationFile), 0600) + + if err != nil { + t.Fatalf("unexpected error writing shared configuration file: %s", err) + } + + tc.config["shared_config_files"] = []any{file.Name()} + } + + tc.config["skip_credentials_validation"] = true + + tc.config["endpoints"] = endpoints + + b, diags := configureBackend(t, tc.config) + + tc.ValidateDiags(t, diags) + if diags.HasErrors() { + return + } + + credentials, err := b.awsConfig.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Error when requesting credentials: %s", err) + } + + if diff := cmp.Diff(credentials, tc.ExpectedCredentialsValue, cmpopts.IgnoreFields(aws.Credentials{}, "Expires")); diff != "" { + t.Fatalf("unexpected credentials: (- got, + expected)\n%s", diff) + } + }) + } +} + func TestBackendConfig_Region(t *testing.T) { testCases := map[string]struct { config map[string]any @@ -2171,15 +2389,14 @@ region = us-west-2 tc := tc t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.InitSessionTestEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.InitSessionTestEnv(t) // Populate required fields tc.config["bucket"] = "bucket" tc.config["key"] = "key" for k, v := range tc.EnvironmentVariables { - os.Setenv(k, v) + t.Setenv(k, v) } if tc.IMDSRegion != "" { @@ -2216,7 +2433,7 @@ region = us-west-2 t.Fatalf("unexpected error writing shared configuration file: %s", err) } - setSharedConfigFile(file.Name()) + setSharedConfigFile(t, file.Name()) } tc.config["skip_credentials_validation"] = true @@ -2233,9 +2450,9 @@ region = us-west-2 } } -func setSharedConfigFile(filename string) { - os.Setenv("AWS_SDK_LOAD_CONFIG", "1") - os.Setenv("AWS_CONFIG_FILE", filename) +func setSharedConfigFile(t *testing.T, filename string) { + t.Setenv("AWS_SDK_LOAD_CONFIG", "1") + t.Setenv("AWS_CONFIG_FILE", filename) } func configureBackend(t *testing.T, config map[string]any) (*Backend, tfdiags.Diagnostics) { diff --git a/internal/backend/remote-state/s3/backend_test.go b/internal/backend/remote-state/s3/backend_test.go index f9f28f2fbe..f4131c562d 100644 --- a/internal/backend/remote-state/s3/backend_test.go +++ b/internal/backend/remote-state/s3/backend_test.go @@ -1261,8 +1261,7 @@ func TestBackendConfig_PrepareConfigValidation(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.StashEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.StashEnv(t) b := New() @@ -1305,8 +1304,7 @@ func TestBackendConfig_PrepareConfigWithEnvVars(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - oldEnv := servicemocks.StashEnv() - defer servicemocks.PopEnv(oldEnv) + servicemocks.StashEnv(t) b := New() diff --git a/website/docs/language/settings/backends/s3.mdx b/website/docs/language/settings/backends/s3.mdx index b46939bb47..f8aedf8191 100644 --- a/website/docs/language/settings/backends/s3.mdx +++ b/website/docs/language/settings/backends/s3.mdx @@ -198,6 +198,8 @@ The optional argument `endpoints` contains the following arguments: This can also be sourced from the environment variable `AWS_ENDPOINT_URL_IAM` or the deprecated environment variable `AWS_IAM_ENDPOINT`. * `s3` - (Optional) Custom endpoint URL for the AWS S3 API. This can also be sourced from the environment variable `AWS_ENDPOINT_URL_S3` or the deprecated environment variable `AWS_S3_ENDPOINT`. +* `sso` - (Optional) Custom endpoint URL for the AWS IAM Identity Center (formerly known as AWS SSO) API. + This can also be sourced from the environment variable `AWS_ENDPOINT_URL_SSO`. * `sts` - (Optional) Custom endpoint URL for the AWS STS API. This can also be sourced from the environment variable `AWS_ENDPOINT_URL_STS` or the deprecated environment variable `AWS_STS_ENDPOINT`.