Backend/S3: Extract assume_role as a separate block (#754)

Signed-off-by: tomasmik <tomasmik@protonmail.com>
This commit is contained in:
Tomas 2023-10-20 10:11:18 +03:00 committed by GitHub
parent c0b1e801f2
commit 080d89c9b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 1383 additions and 338 deletions

View File

@ -51,7 +51,8 @@ S3 BACKEND:
* The S3 backend was upgraded to use the V2 of the AWS SDK for Go ([#691](https://github.com/opentofu/opentofu/issues/691))
* 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))
## Previous Releases

View File

@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"os"
"sort"
"strings"
"time"
@ -165,46 +166,54 @@ func (b *Backend) ConfigSchema() *configschema.Block {
Type: cty.String,
Optional: true,
Description: "The role to be assumed",
Deprecated: true,
},
"session_name": {
Type: cty.String,
Optional: true,
Description: "The session name to use when assuming the role.",
Deprecated: true,
},
"external_id": {
Type: cty.String,
Optional: true,
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": {
@ -229,6 +238,65 @@ func (b *Backend) ConfigSchema() *configschema.Block {
Optional: true,
Description: "Use the legacy authentication workflow, preferring environment variables over backend configuration.",
},
"assume_role": {
NestedType: &configschema.Object{
Nesting: configschema.NestingSingle,
Attributes: map[string]*configschema.Attribute{
"role_arn": {
Type: cty.String,
Required: true,
Description: "The role to be assumed.",
},
"duration": {
Type: cty.String,
Optional: true,
Description: "Seconds to restrict the assume role session duration.",
},
"external_id": {
Type: cty.String,
Optional: true,
Description: "The external ID to use when assuming the role",
},
"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.",
},
"session_name": {
Type: cty.String,
Optional: true,
Description: "The session name to use when assuming the role.",
},
"tags": {
Type: cty.Map(cty.String),
Optional: true,
Description: "Assume role session tags.",
},
"transitive_tag_keys": {
Type: cty.Set(cty.String),
Optional: true,
Description: "Assume role session tag keys to pass to any subsequent sessions.",
},
//
// NOT SUPPORTED by `aws-sdk-go-base/v1`
// Cannot be added yet.
//
// "source_identity": stringAttribute{
// configschema.Attribute{
// Type: cty.String,
// Optional: true,
// Description: "Source identity specified by the principal assuming the role.",
// ValidateFunc: validAssumeRoleSourceIdentity,
// },
// },
},
},
},
},
}
}
@ -332,6 +400,39 @@ func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics)
attrPath))
}
var assumeRoleDeprecatedFields = map[string]string{
"role_arn": "assume_role.role_arn",
"session_name": "assume_role.session_name",
"external_id": "assume_role.external_id",
"assume_role_duration_seconds": "assume_role.duration",
"assume_role_policy": "assume_role.policy",
"assume_role_policy_arns": "assume_role.policy_arns",
"assume_role_tags": "assume_role.tags",
"assume_role_transitive_tag_keys": "assume_role.transitive_tag_keys",
}
if val := obj.GetAttr("assume_role"); !val.IsNull() {
diags = diags.Append(validateNestedAssumeRole(val, cty.Path{cty.GetAttrStep{Name: "assume_role"}}))
if defined := findDeprecatedFields(obj, assumeRoleDeprecatedFields); len(defined) != 0 {
diags = diags.Append(tfdiags.WholeContainingBody(
tfdiags.Error,
"Conflicting Parameters",
`The following deprecated parameters conflict with the parameter "assume_role". Replace them as follows:`+"\n"+
formatDeprecated(defined),
))
}
} else {
if defined := findDeprecatedFields(obj, assumeRoleDeprecatedFields); len(defined) != 0 {
diags = diags.Append(tfdiags.WholeContainingBody(
tfdiags.Warning,
"Deprecated Parameters",
`The following parameters have been deprecated. Replace them as follows:`+"\n"+
formatDeprecated(defined),
))
}
}
return obj, diags
}
@ -455,7 +556,9 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics {
}
}
if value := obj.GetAttr("role_arn"); !value.IsNull() {
if value := obj.GetAttr("assume_role"); !value.IsNull() {
cfg.AssumeRole = configureNestedAssumeRole(obj)
} else if value := obj.GetAttr("role_arn"); !value.IsNull() {
cfg.AssumeRole = configureAssumeRole(obj)
}
@ -509,6 +612,46 @@ func getS3Config(obj cty.Value) func(options *s3.Options) {
}
}
func configureNestedAssumeRole(obj cty.Value) *awsbase.AssumeRole {
assumeRole := awsbase.AssumeRole{}
obj = obj.GetAttr("assume_role")
if val, ok := stringAttrOk(obj, "role_arn"); ok {
assumeRole.RoleARN = val
}
if val, ok := stringAttrOk(obj, "duration"); ok {
dur, 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))
}
assumeRole.Duration = dur
}
if val, ok := stringAttrOk(obj, "external_id"); ok {
assumeRole.ExternalID = val
}
if val, ok := stringAttrOk(obj, "policy"); ok {
assumeRole.Policy = strings.TrimSpace(val)
}
if val, ok := stringSliceAttrOk(obj, "policy_arns"); ok {
assumeRole.PolicyARNs = val
}
if val, ok := stringAttrOk(obj, "session_name"); ok {
assumeRole.SessionName = val
}
if val, ok := stringMapAttrOk(obj, "tags"); ok {
assumeRole.Tags = val
}
if val, ok := stringSliceAttrOk(obj, "transitive_tag_keys"); ok {
assumeRole.TransitiveTagKeys = val
}
return &assumeRole
}
func configureAssumeRole(obj cty.Value) *awsbase.AssumeRole {
assumeRole := awsbase.AssumeRole{}
@ -518,36 +661,14 @@ func configureAssumeRole(obj cty.Value) *awsbase.AssumeRole {
assumeRole.Policy = stringAttr(obj, "assume_role_policy")
assumeRole.SessionName = stringAttr(obj, "session_name")
if value := obj.GetAttr("assume_role_policy_arns"); !value.IsNull() {
value.ForEachElement(func(key, val cty.Value) (stop bool) {
v, ok := stringValueOk(val)
if ok {
assumeRole.PolicyARNs = append(assumeRole.PolicyARNs, v)
}
return
})
if val, ok := stringSliceAttrOk(obj, "assume_role_policy_arns"); ok {
assumeRole.PolicyARNs = val
}
if tagMap := obj.GetAttr("assume_role_tags"); !tagMap.IsNull() {
assumeRole.Tags = make(map[string]string, tagMap.LengthInt())
tagMap.ForEachElement(func(key, val cty.Value) (stop bool) {
k := stringValue(key)
v, ok := stringValueOk(val)
if ok {
assumeRole.Tags[k] = v
}
return
})
if val, ok := stringMapAttrOk(obj, "assume_role_tags"); ok {
assumeRole.Tags = val
}
if transitiveTagKeySet := obj.GetAttr("assume_role_transitive_tag_keys"); !transitiveTagKeySet.IsNull() {
transitiveTagKeySet.ForEachElement(func(key, val cty.Value) (stop bool) {
v, ok := stringValueOk(val)
if ok {
assumeRole.TransitiveTagKeys = append(assumeRole.TransitiveTagKeys, v)
}
return
})
if val, ok := stringSliceAttrOk(obj, "assume_role_transitive_tag_keys"); ok {
assumeRole.TransitiveTagKeys = val
}
return &assumeRole
@ -670,6 +791,19 @@ func intAttrDefault(obj cty.Value, name string, def int) int {
}
}
func stringMapValueOk(val cty.Value) (map[string]string, bool) {
var m map[string]string
err := gocty.FromCtyValue(val, &m)
if err != nil {
return nil, false
}
return m, true
}
func stringMapAttrOk(obj cty.Value, name string) (map[string]string, bool) {
return stringMapValueOk(obj.GetAttr(name))
}
func pathString(path cty.Path) string {
var buf strings.Builder
for i, step := range path {
@ -707,6 +841,35 @@ func pathString(path cty.Path) string {
return buf.String()
}
func findDeprecatedFields(obj cty.Value, attrs map[string]string) map[string]string {
defined := make(map[string]string)
for attr, v := range attrs {
if val := obj.GetAttr(attr); !val.IsNull() {
defined[attr] = v
}
}
return defined
}
func formatDeprecated(attrs map[string]string) string {
var maxLen int
var buf strings.Builder
names := make([]string, 0, len(attrs))
for deprecated, replacement := range attrs {
names = append(names, deprecated)
if l := len(deprecated); l > maxLen {
maxLen = l
}
fmt.Fprintf(&buf, " * %-[1]*[2]s -> %s\n", maxLen, deprecated, replacement)
}
sort.Strings(names)
return buf.String()
}
const encryptionKeyConflictError = `Only one of "kms_key_id" and "sse_customer_key" can be set.
The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS)

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import (
"fmt"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws/arn"
"github.com/opentofu/opentofu/internal/tfdiags"
@ -63,6 +64,96 @@ func validateKMSKeyARN(path cty.Path, s string) (diags tfdiags.Diagnostics) {
return diags
}
func validateNestedAssumeRole(obj cty.Value, objPath cty.Path) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if val, ok := stringAttrOk(obj, "role_arn"); !ok || val == "" {
path := objPath.GetAttr("role_arn")
diags = diags.Append(attributeErrDiag(
"Missing Required Value",
fmt.Sprintf("The attribute %q is required by the backend.\n\n", pathString(path))+
"Refer to the backend documentation for additional information which attributes are required.",
path,
))
}
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,
))
}
}
}
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"),
))
}
}
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"),
))
}
}
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"),
))
}
}
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"),
))
}
}
}
}
return diags
}
func isKeyARN(arn arn.ARN) bool {
return keyIdFromARNResource(arn.Resource) != "" || aliasIdFromARNResource(arn.Resource) != ""
}

View File

@ -188,3 +188,150 @@ func Test_validateAttributesConflict(t *testing.T) {
})
}
}
func Test_validateNestedAssumeRole(t *testing.T) {
tests := []struct {
description string
input cty.Value
expectedDiags []string
}{
{
description: "Valid Input",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: nil,
},
{
description: "Missing Role ARN",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal(""),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"The attribute \"assume_role.role_arn\" is required by the backend.\n\nRefer to the backend documentation for additional information which attributes are required.",
},
},
{
description: "Invalid Duration",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("invalid-duration"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"The value \"invalid-duration\" cannot be parsed as a duration: time: invalid duration \"invalid-duration\"",
},
},
{
description: "Invalid Duration Length",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("44h"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"Duration must be between 15m0s and 12h0m0s, had 44h",
},
},
{
description: "Invalid External ID (Empty)",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal(""),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"The value cannot be empty or all whitespace",
},
},
{
description: "Invalid Policy (Empty)",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal(""),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"The value cannot be empty or all whitespace",
},
},
{
description: "Invalid Session Name (Empty)",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal(""),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:policy/valid-policy-arn")}),
}),
expectedDiags: []string{
"The value cannot be empty or all whitespace",
},
},
{
description: "Invalid Policy ARN (Invalid ARN Format)",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("invalid-arn-format")}),
}),
expectedDiags: []string{
"The value [\"invalid-arn-format\"] cannot be parsed as an ARN: arn: invalid prefix",
},
},
{
description: "Invalid Policy ARN (Not Starting with 'policy/')",
input: cty.ObjectVal(map[string]cty.Value{
"role_arn": cty.StringVal("valid-role-arn"),
"duration": cty.StringVal("30m"),
"external_id": cty.StringVal("valid-external-id"),
"policy": cty.StringVal("valid-policy"),
"session_name": cty.StringVal("valid-session-name"),
"policy_arns": cty.ListVal([]cty.Value{cty.StringVal("arn:aws:iam::123456789012:role/invalid-policy-arn")}),
}),
expectedDiags: []string{
"Value must be a valid IAM Policy ARN, got [\"arn:aws:iam::123456789012:role/invalid-policy-arn\"]",
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
diagnostics := validateNestedAssumeRole(test.input, cty.Path{cty.GetAttrStep{Name: "assume_role"}})
if len(diagnostics) != len(test.expectedDiags) {
t.Errorf("Expected %d diagnostics, but got %d", len(test.expectedDiags), len(diagnostics))
}
for i, diag := range diagnostics {
if diag.Description().Detail != test.expectedDiags[i] {
t.Errorf("Mismatch in diagnostic %d. Expected: %q, Got: %q", i, test.expectedDiags[i], diag.Description().Detail)
}
}
})
}
}

View File

@ -174,16 +174,55 @@ The following configuration is optional:
#### Assume Role Configuration
The following configuration is optional:
Assuming an IAM Role is optional and can be configured in two ways.
The preferred way is to use the argument `assume_role`, as the other, the other method is deprecated.
The argument `assume_role` contains the following arguments:
* `role_arn` - (Required) The Amazon Resource Name (ARN) of the IAM Role to be assumed.
* `duration` - (Optional) Specifies the validity period for individual credentials.
These credentials are automatically renewed, with the maximum renewal defined by the AWS account.
The duration should be specified in the format `<hours>h<minutes>m<seconds>s`, with each unit being optional.
For example, an hour and a half can be represented as `1h30m` or simply `90m`.
The duration must be within the range of 15 minutes (15m) to 12 hours (12h).
* `external_id` - (Optional) An external identifier to use when assuming the role.
* `policy` - (Optional) JSON representation of an IAM Policy that further restricts permissions for the IAM Role being assumed.
* `policy_arns` - (Optional) A set of Amazon Resource Names (ARNs) for IAM Policies that further limit permissions for the assumed IAM Role.
* `session_name` - (Optional) The session name to be used when assuming the role.
* `tags` - (Optional) A map of tags to be associated with the assumed role session.
* `transitive_tag_keys` - (Optional) A set of tag keys from the assumed role session to be passed to any subsequent sessions.
The following arguments on the top level are deprecated:
* `assume_role_duration_seconds` - (Optional) Number of seconds to restrict the assume role session duration.
Use `assume_role.duration` instead.
* `assume_role_policy` - (Optional) IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.
Use `assume_role.policy` instead.
* `assume_role_policy_arns` - (Optional) Set of Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.
Use `assume_role.policy_arns` instead.
* `assume_role_tags` - (Optional) Map of assume role session tags.
Use `assume_role.tags` instead.
* `assume_role_transitive_tag_keys` - (Optional) Set of assume role session tag keys to pass to any subsequent sessions.
Use `assume_role.transitive_tag_keys` instead.
* `external_id` - (Optional) External identifier to use when assuming the role.
Use `assume_role.external_id` instead.
* `role_arn` - (Optional) Amazon Resource Name (ARN) of the IAM Role to assume.
Use `assume_role.role_arn` instead.
* `session_name` - (Optional) Session name to use when assuming the role.
Use `assume_role.session_name` instead.
```hcl
terraform {
backend "s3" {
bucket = "mybucket"
key = "my/key.tfstate"
region = "us-east-1"
assume_role = {
role_arn = "arn:aws:iam::ACCOUNT-ID:role/Opentofu"
}
}
}
```
### S3 State Storage