diff --git a/CHANGELOG.md b/CHANGELOG.md index 4788b6b6ce..35d39a728f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ S3 BACKEND: * Adds support for `shared_config_files` and `shared_credentials_files` arguments and deprecates the `shared_credentials_file` argument. ([#690](https://github.com/opentofu/opentofu/issues/690)) * Arguments associated with assuming an IAM role were moved into a nested block - `assume_role`. This deprecates the arguments `role_arn`, `session_name`, `external_id`, `assume_role_duration_seconds`, `assume_role_policy`, `assume_role_policy_arns`, `assume_role_tags`, and `assume_role_transitive_tag_keys`. ([#747](https://github.com/opentofu/opentofu/issues/747)) +* Adds support for the `assume_role_with_web_identity` block. ([#689](https://github.com/opentofu/opentofu/issues/689)) * Adds support for account whitelisting using the `forbidden_account_ids` and `allowed_account_ids` arguments. ([#699](https://github.com/opentofu/opentofu/issues/699)) * Adds the `custom_ca_bundle` argument. ([#689](https://github.com/opentofu/opentofu/issues/689)) diff --git a/internal/backend/remote-state/s3/backend.go b/internal/backend/remote-state/s3/backend.go index 784ebca25f..3867b80baf 100644 --- a/internal/backend/remote-state/s3/backend.go +++ b/internal/backend/remote-state/s3/backend.go @@ -180,54 +180,46 @@ func (b *Backend) ConfigSchema(context.Context) *configschema.Block { Description: "The external ID to use when assuming the role", Deprecated: true, }, - "assume_role_duration_seconds": { Type: cty.Number, Optional: true, Description: "Seconds to restrict the assume role session duration.", Deprecated: true, }, - "assume_role_policy": { Type: cty.String, Optional: true, Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.", Deprecated: true, }, - "assume_role_policy_arns": { Type: cty.Set(cty.String), Optional: true, Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.", Deprecated: true, }, - "assume_role_tags": { Type: cty.Map(cty.String), Optional: true, Description: "Assume role session tags.", Deprecated: true, }, - "assume_role_transitive_tag_keys": { Type: cty.Set(cty.String), Optional: true, Description: "Assume role session tag keys to pass to any subsequent sessions.", Deprecated: true, }, - "workspace_key_prefix": { Type: cty.String, Optional: true, Description: "The prefix applied to the non-default state path inside the bucket.", }, - "force_path_style": { Type: cty.Bool, Optional: true, Description: "Force s3 to use path style api.", }, - "max_retries": { Type: cty.Number, Optional: true, @@ -302,6 +294,49 @@ func (b *Backend) ConfigSchema(context.Context) *configschema.Block { }, }, }, + "assume_role_with_web_identity": { + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "role_arn": { + Type: cty.String, + Optional: true, + Description: "The Amazon Resource Name (ARN) role to assume.", + }, + "web_identity_token": { + Type: cty.String, + Optional: true, + Sensitive: true, + Description: "The OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider.", + }, + "web_identity_token_file": { + Type: cty.String, + Optional: true, + Description: "The path to a file which contains an OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider.", + }, + "session_name": { + Type: cty.String, + Optional: true, + Description: "The name applied to this assume-role session.", + }, + "policy": { + Type: cty.String, + Optional: true, + Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.", + }, + "policy_arns": { + Type: cty.Set(cty.String), + Optional: true, + Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.", + }, + "duration": { + Type: cty.String, + Optional: true, + Description: "The duration, between 15 minutes and 12 hours, of the role session. Valid time units are ns, us (or µs), ms, s, h, or m.", + }, + }, + }, + }, "forbidden_account_ids": { Type: cty.Set(cty.String), Optional: true, @@ -448,6 +483,10 @@ func (b *Backend) PrepareConfig(ctx context.Context, obj cty.Value) (cty.Value, } } + if val := obj.GetAttr("assume_role_with_web_identity"); !val.IsNull() { + diags = diags.Append(validateAssumeRoleWithWebIdentity(val, cty.GetAttrPath("assume_role_with_web_identity"))) + } + validateAttributesConflict( cty.GetAttrPath("allowed_account_ids"), cty.GetAttrPath("forbidden_account_ids"), @@ -583,6 +622,10 @@ func (b *Backend) Configure(ctx context.Context, obj cty.Value) tfdiags.Diagnost cfg.AssumeRole = configureAssumeRole(obj) } + if val := obj.GetAttr("assume_role_with_web_identity"); !val.IsNull() { + cfg.AssumeRoleWithWebIdentity = configureAssumeRoleWithWebIdentity(val) + } + if val, ok := stringSliceAttrDefaultEnvVarOk(obj, "shared_credentials_files", "AWS_SHARED_CREDENTIALS_FILE"); ok { cfg.SharedCredentialsFiles = val } @@ -732,6 +775,27 @@ func configureAssumeRole(obj cty.Value) *awsbase.AssumeRole { return &assumeRole } +func configureAssumeRoleWithWebIdentity(obj cty.Value) *awsbase.AssumeRoleWithWebIdentity { + cfg := &awsbase.AssumeRoleWithWebIdentity{ + RoleARN: stringAttrDefaultEnvVar(obj, "role_arn", "AWS_ROLE_ARN"), + Policy: stringAttr(obj, "policy"), + PolicyARNs: stringSliceAttr(obj, "policy_arns"), + SessionName: stringAttrDefaultEnvVar(obj, "session_name", "AWS_ROLE_SESSION_NAME"), + WebIdentityToken: stringAttrDefaultEnvVar(obj, "web_identity_token", "AWS_WEB_IDENTITY_TOKEN"), + WebIdentityTokenFile: stringAttrDefaultEnvVar(obj, "web_identity_token_file", "AWS_WEB_IDENTITY_TOKEN_FILE"), + } + if val, ok := stringAttrOk(obj, "duration"); ok { + d, err := time.ParseDuration(val) + if err != nil { + // This should never happen because the schema should have + // already validated the duration. + panic(fmt.Sprintf("invalid duration %q: %s", val, err)) + } + cfg.Duration = d + } + return cfg +} + func stringValue(val cty.Value) string { v, _ := stringValueOk(val) return v @@ -761,6 +825,11 @@ func stringAttrDefault(obj cty.Value, name, def string) string { } } +func stringSliceValue(val cty.Value) []string { + v, _ := stringSliceValueOk(val) + return v +} + func stringSliceValueOk(val cty.Value) ([]string, bool) { if val.IsNull() { return nil, false @@ -773,6 +842,10 @@ func stringSliceValueOk(val cty.Value) ([]string, bool) { return v, true } +func stringSliceAttr(obj cty.Value, name string) []string { + return stringSliceValue(obj.GetAttr(name)) +} + func stringSliceAttrOk(obj cty.Value, name string) ([]string, bool) { return stringSliceValueOk(obj.GetAttr(name)) } diff --git a/internal/backend/remote-state/s3/backend_complete_test.go b/internal/backend/remote-state/s3/backend_complete_test.go index 40c7922d25..cb17b1b939 100644 --- a/internal/backend/remote-state/s3/backend_complete_test.go +++ b/internal/backend/remote-state/s3/backend_complete_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "regexp" "testing" @@ -1617,6 +1618,290 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey } } +func TestBackendConfig_Authentication_AssumeRoleWithWebIdentity(t *testing.T) { + testCases := map[string]struct { + config map[string]any + SetConfig bool + ExpandEnvVars bool + EnvironmentVariables map[string]string + SetTokenFileEnvironmentVariable bool + SharedConfigurationFile string + SetSharedConfigurationFile bool + ExpectedCredentialsValue aws.Credentials + ValidateDiags diagsValidator + MockStsEndpoints []*servicemocks.MockEndpoint + }{ + "config with inline token": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + "web_identity_token": servicemocks.MockWebIdentityToken, + }, + }, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "config with token file": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + }, + }, + SetConfig: true, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "config with expanded path": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + }, + }, + SetConfig: true, + ExpandEnvVars: true, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "envvar": { + config: map[string]any{}, + EnvironmentVariables: map[string]string{ + "AWS_ROLE_ARN": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "AWS_ROLE_SESSION_NAME": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + }, + SetTokenFileEnvironmentVariable: true, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "shared configuration file": { + config: map[string]any{}, + SharedConfigurationFile: fmt.Sprintf(` +[default] +role_arn = %[1]s +role_session_name = %[2]s +`, servicemocks.MockStsAssumeRoleWithWebIdentityArn, servicemocks.MockStsAssumeRoleWithWebIdentitySessionName), + SetSharedConfigurationFile: true, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "config overrides envvar": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + "web_identity_token": servicemocks.MockWebIdentityToken, + }, + }, + EnvironmentVariables: map[string]string{ + "AWS_ROLE_ARN": servicemocks.MockStsAssumeRoleWithWebIdentityAlternateArn, + "AWS_ROLE_SESSION_NAME": servicemocks.MockStsAssumeRoleWithWebIdentityAlternateSessionName, + "AWS_WEB_IDENTITY_TOKEN_FILE": "no-such-file", + }, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "envvar overrides shared configuration": { + config: map[string]any{}, + EnvironmentVariables: map[string]string{ + "AWS_ROLE_ARN": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "AWS_ROLE_SESSION_NAME": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + }, + SetTokenFileEnvironmentVariable: true, + SharedConfigurationFile: fmt.Sprintf(` +[default] +role_arn = %[1]s +role_session_name = %[2]s +web_identity_token_file = no-such-file +`, servicemocks.MockStsAssumeRoleWithWebIdentityAlternateArn, servicemocks.MockStsAssumeRoleWithWebIdentityAlternateSessionName), + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "config overrides shared configuration": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + "web_identity_token": servicemocks.MockWebIdentityToken, + }, + }, + SharedConfigurationFile: fmt.Sprintf(` +[default] +role_arn = %[1]s +role_session_name = %[2]s +web_identity_token_file = no-such-file +`, servicemocks.MockStsAssumeRoleWithWebIdentityAlternateArn, servicemocks.MockStsAssumeRoleWithWebIdentityAlternateSessionName), + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + }, + }, + + "with duration": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + "web_identity_token": servicemocks.MockWebIdentityToken, + "duration": "1h", + }, + }, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidWithOptions(map[string]string{"DurationSeconds": "3600"}), + }, + }, + + "with policy": { + config: map[string]any{ + "assume_role_with_web_identity": map[string]any{ + "role_arn": servicemocks.MockStsAssumeRoleWithWebIdentityArn, + "session_name": servicemocks.MockStsAssumeRoleWithWebIdentitySessionName, + "web_identity_token": servicemocks.MockWebIdentityToken, + "policy": "{}", + }, + }, + ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsAssumeRoleWithWebIdentityValidWithOptions(map[string]string{"Policy": "{}"}), + }, + }, + } + + for name, tc := range testCases { + tc := tc + + t.Run(name, func(t *testing.T) { + oldEnv := servicemocks.InitSessionTestEnv() + defer servicemocks.PopEnv(oldEnv) + + 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 + } + + for k, v := range tc.EnvironmentVariables { + os.Setenv(k, v) + } + + ts := servicemocks.MockAwsApiServer("STS", tc.MockStsEndpoints) + defer ts.Close() + + tc.config["sts_endpoint"] = ts.URL + + tempdir, err := os.MkdirTemp("", "temp") + if err != nil { + t.Fatalf("error creating temp dir: %s", err) + } + defer os.Remove(tempdir) + os.Setenv("TMPDIR", tempdir) + + tokenFile, err := os.CreateTemp("", "aws-sdk-go-base-web-identity-token-file") + if err != nil { + t.Fatalf("unexpected error creating temporary web identity token file: %s", err) + } + tokenFileName := tokenFile.Name() + + defer os.Remove(tokenFileName) + + err = os.WriteFile(tokenFileName, []byte(servicemocks.MockWebIdentityToken), 0600) + + if err != nil { + t.Fatalf("unexpected error writing web identity token file: %s", err) + } + + if tc.ExpandEnvVars { + tmpdir := os.Getenv("TMPDIR") + rel, err := filepath.Rel(tmpdir, tokenFileName) + if err != nil { + t.Fatalf("error making path relative: %s", err) + } + t.Logf("relative: %s", rel) + tokenFileName = filepath.Join("$TMPDIR", rel) + t.Logf("env tempfile: %s", tokenFileName) + } + + if tc.SetConfig { + ar := tc.config["assume_role_with_web_identity"].(map[string]any) + ar["web_identity_token_file"] = tokenFileName + } + + if tc.SetTokenFileEnvironmentVariable { + os.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", tokenFileName) + } + + 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()) + + if tc.SetSharedConfigurationFile { + tc.SharedConfigurationFile += fmt.Sprintf("web_identity_token_file = %s\n", tokenFileName) + } + + 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 + + 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 diff --git a/internal/backend/remote-state/s3/validate.go b/internal/backend/remote-state/s3/validate.go index 5e3ee51f80..566505e3eb 100644 --- a/internal/backend/remote-state/s3/validate.go +++ b/internal/backend/remote-state/s3/validate.go @@ -78,77 +78,50 @@ func validateNestedAssumeRole(obj cty.Value, objPath cty.Path) tfdiags.Diagnosti } if val, ok := stringAttrOk(obj, "duration"); ok { - path := objPath.GetAttr("duration") - d, err := time.ParseDuration(val) - if err != nil { - diags = diags.Append(attributeErrDiag( - "Invalid Duration", - fmt.Sprintf("The value %q cannot be parsed as a duration: %s", val, err), - path, - )) - } else { - min := 15 * time.Minute - max := 12 * time.Hour - if d < min || d > max { - diags = diags.Append(attributeErrDiag( - "Invalid Duration", - fmt.Sprintf("Duration must be between %s and %s, had %s", min, max, val), - path, - )) - } - } + validateDuration(val, 15*time.Minute, 12*time.Hour, objPath.GetAttr("duration"), &diags) } if val, ok := stringAttrOk(obj, "external_id"); ok { - if len(strings.TrimSpace(val)) == 0 { - diags = diags.Append(attributeErrDiag( - "Invalid Value", - "The value cannot be empty or all whitespace", - objPath.GetAttr("external_id"), - )) - } + validateNonEmptyString(val, objPath.GetAttr("external_id"), &diags) } if val, ok := stringAttrOk(obj, "policy"); ok { - if len(strings.TrimSpace(val)) == 0 { - diags = diags.Append(attributeErrDiag( - "Invalid Value", - "The value cannot be empty or all whitespace", - objPath.GetAttr("policy"), - )) - } + validateNonEmptyString(val, objPath.GetAttr("policy"), &diags) } if val, ok := stringAttrOk(obj, "session_name"); ok { - if len(strings.TrimSpace(val)) == 0 { - diags = diags.Append(attributeErrDiag( - "Invalid Value", - "The value cannot be empty or all whitespace", - objPath.GetAttr("session_name"), - )) - } + validateNonEmptyString(val, objPath.GetAttr("session_name"), &diags) } if val, ok := stringSliceAttrOk(obj, "policy_arns"); ok { - for _, v := range val { - arn, err := arn.Parse(v) - if err != nil { - diags = diags.Append(attributeErrDiag( - "Invalid ARN", - fmt.Sprintf("The value %q cannot be parsed as an ARN: %s", val, err), - objPath.GetAttr("policy_arns"), - )) - break - } else { - if !strings.HasPrefix(arn.Resource, "policy/") { - diags = diags.Append(attributeErrDiag( - "Invalid IAM Policy ARN", - fmt.Sprintf("Value must be a valid IAM Policy ARN, got %q", val), - objPath.GetAttr("policy_arns"), - )) - } - } - } + validatePolicyARNSlice(val, objPath.GetAttr("policy_arns"), &diags) + } + + return diags +} + +func validateAssumeRoleWithWebIdentity(obj cty.Value, objPath cty.Path) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + validateAttributesConflict( + cty.GetAttrPath("web_identity_token"), + cty.GetAttrPath("web_identity_token_file"), + )(obj, objPath, &diags) + + if val, ok := stringAttrOk(obj, "session_name"); ok { + validateNonEmptyString(val, objPath.GetAttr("session_name"), &diags) + } + + if val, ok := stringAttrOk(obj, "policy"); ok { + validateNonEmptyString(val, objPath.GetAttr("policy"), &diags) + } + + if val, ok := stringSliceAttrOk(obj, "policy_arns"); ok { + validatePolicyARNSlice(val, objPath.GetAttr("policy_arns"), &diags) + } + + if val, ok := stringAttrOk(obj, "duration"); ok { + validateDuration(val, 15*time.Minute, 12*time.Hour, objPath.GetAttr("duration"), &diags) } return diags @@ -222,3 +195,54 @@ func attributeErrDiag(summary, detail string, attrPath cty.Path) tfdiags.Diagnos func attributeWarningDiag(summary, detail string, attrPath cty.Path) tfdiags.Diagnostic { return tfdiags.AttributeValue(tfdiags.Warning, summary, detail, attrPath.Copy()) } + +func validateNonEmptyString(val string, path cty.Path, diags *tfdiags.Diagnostics) { + if len(strings.TrimSpace(val)) == 0 { + *diags = diags.Append(attributeErrDiag( + "Invalid Value", + "The value cannot be empty or all whitespace", + path, + )) + } +} + +func validatePolicyARNSlice(val []string, path cty.Path, diags *tfdiags.Diagnostics) { + for _, v := range val { + arn, err := arn.Parse(v) + if err != nil { + *diags = diags.Append(attributeErrDiag( + "Invalid ARN", + fmt.Sprintf("The value %q cannot be parsed as an ARN: %s", val, err), + path, + )) + break + } else { + if !strings.HasPrefix(arn.Resource, "policy/") { + *diags = diags.Append(attributeErrDiag( + "Invalid IAM Policy ARN", + fmt.Sprintf("Value must be a valid IAM Policy ARN, got %q", val), + path, + )) + } + } + } +} + +func validateDuration(val string, min, max time.Duration, path cty.Path, diags *tfdiags.Diagnostics) { + d, err := time.ParseDuration(val) + if err != nil { + *diags = diags.Append(attributeErrDiag( + "Invalid Duration", + fmt.Sprintf("The value %q cannot be parsed as a duration: %s", val, err), + path, + )) + return + } + if (min > 0 && d < min) || (max > 0 && d > max) { + *diags = diags.Append(attributeErrDiag( + "Invalid Duration", + fmt.Sprintf("Duration must be between %s and %s, had %s", min, max, val), + path, + )) + } +} diff --git a/website/docs/language/settings/backends/s3.mdx b/website/docs/language/settings/backends/s3.mdx index 226c6a4fbb..3f7279b016 100644 --- a/website/docs/language/settings/backends/s3.mdx +++ b/website/docs/language/settings/backends/s3.mdx @@ -227,6 +227,74 @@ terraform { } ``` +#### Assume Role With Web Identity Configuration + +The following `assume_role_with_web_identity` configuration block is optional: + +* `role_arn` - (Required) Amazon Resource Name (ARN) of the IAM Role to assume. +Can also be set with the `AWS_ROLE_ARN` environment variable. +* `duration` - (Optional) The duration individual credentials will be valid. +Credentials are automatically renewed up to the maximum defined by the AWS account. +Specified using the format `hms` with any unit being optional. +For example, an hour and a half can be specified as `1h30m` or `90m`. +Must be between 15 minutes (15m) and 12 hours (12h). +* `policy` - (Optional) IAM Policy JSON describing further restricting permissions for the IAM Role being assumed. +* `policy_arns` - (Optional) Set of Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed. +* `session_name` - (Optional) Session name to use when assuming the role. +Can also be set with the `AWS_ROLE_SESSION_NAME` environment variable. +* `web_identity_token` - (Optional) The value of a web identity token from an OpenID Connect (OIDC) or OAuth provider. +One of `web_identity_token` or `web_identity_token_file` is required. +* `web_identity_token_file` - (Optional) File containing a web identity token from an OpenID Connect (OIDC) or OAuth provider. +One of `web_identity_token_file` or `web_identity_token` is required. +Can also be set with the `AWS_WEB_IDENTITY_TOKEN_FILE` environment variable. + +```hcl +terraform { + backend "s3" { + bucket = "mybucket" + key = "my/key.tfstate" + region = "us-east-1" + + assume_role_with_web_identity = { + role_arn = "arn:aws:iam::ACCOUNT-ID:role/Opentofu" + web_identity_token = "" + } + } +} +``` + +It's possible to constrain the assumed role by providing a policy. + +```hcl +terraform { + backend "s3" { + bucket = "mybucket" + key = "my/key.tfstate" + region = "us-east-1" + + assume_role_with_web_identity = { + role_arn = "arn:aws:iam::ACCOUNT-ID:role/Opentofu" + web_identity_token = "" + policy = <<-JSON + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::mybucket/*", + "arn:aws:s3:::mybucket" + ] + } + ] + } + JSON + } + } +} +``` + ### S3 State Storage The following configuration is required: