From 8b665ee63513327d3173ed369a910932f5d22623 Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Fri, 1 Sep 2023 16:44:32 -0400 Subject: [PATCH] backend/s3: add [allowed|forbidden]_account_ids arguments --- internal/backend/remote-state/s3/backend.go | 56 +++++++++++++++---- .../remote-state/s3/backend_complete_test.go | 20 +++++++ .../backend/remote-state/s3/backend_test.go | 17 ++++++ internal/backend/remote-state/s3/diags.go | 13 +++++ .../docs/language/settings/backends/s3.mdx | 2 + 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/internal/backend/remote-state/s3/backend.go b/internal/backend/remote-state/s3/backend.go index 66711e8481..542166d01f 100644 --- a/internal/backend/remote-state/s3/backend.go +++ b/internal/backend/remote-state/s3/backend.go @@ -18,7 +18,6 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/s3" awsbase "github.com/hashicorp/aws-sdk-go-base/v2" - basediag "github.com/hashicorp/aws-sdk-go-base/v2/diag" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/tfdiags" @@ -66,6 +65,11 @@ func (b *Backend) ConfigSchema() *configschema.Block { Optional: true, Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).", }, + "allowed_account_ids": { + Type: cty.Set(cty.String), + Optional: true, + Description: "List of allowed AWS account IDs.", + }, "dynamodb_endpoint": { Type: cty.String, Optional: true, @@ -115,6 +119,11 @@ func (b *Backend) ConfigSchema() *configschema.Block { }, }, }, + "forbidden_account_ids": { + Type: cty.Set(cty.String), + Optional: true, + Description: "List of forbidden AWS account IDs.", + }, "iam_endpoint": { Type: cty.String, Optional: true, @@ -522,6 +531,11 @@ func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) endpointModeValidators.ValidateAttr(val, attrPath, &diags) } + validateAttributesConflict( + cty.GetAttrPath("allowed_account_ids"), + cty.GetAttrPath("forbidden_account_ids"), + )(obj, cty.Path{}, &diags) + return obj, diags } @@ -844,19 +858,19 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { cfg.RetryMode = aws.RetryMode(v) } + if val, ok := stringSetAttrOk(obj, "allowed_account_ids"); ok { + cfg.AllowedAccountIds = val + } + if val, ok := stringSetAttrOk(obj, "forbidden_account_ids"); ok { + cfg.ForbiddenAccountIds = val + } + _ /* ctx */, awsConfig, cfgDiags := awsbase.GetAwsConfig(ctx, cfg) - for _, diag := range cfgDiags { - var severity tfdiags.Severity - switch diag.Severity() { - case basediag.SeverityError: - severity = tfdiags.Error - case basediag.SeverityWarning: - severity = tfdiags.Warning - } + for _, d := range cfgDiags { diags = diags.Append(tfdiags.Sourceless( - severity, - diag.Summary(), - diag.Detail(), + baseSeverityToTerraformSeverity(d.Severity()), + d.Summary(), + d.Detail(), )) } if diags.HasErrors() { @@ -864,6 +878,24 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { } b.awsConfig = awsConfig + accountID, _, awsDiags := awsbase.GetAwsAccountIDAndPartition(ctx, awsConfig, cfg) + for _, d := range awsDiags { + diags = append(diags, tfdiags.Sourceless( + baseSeverityToTerraformSeverity(d.Severity()), + fmt.Sprintf("Retrieving AWS account details: %s", d.Summary()), + d.Detail(), + )) + } + + err := cfg.VerifyAccountIDAllowed(accountID) + if err != nil { + diags = append(diags, tfdiags.Sourceless( + tfdiags.Error, + "Invalid account ID", + err.Error(), + )) + } + b.dynClient = dynamodb.NewFromConfig(awsConfig, func(opts *dynamodb.Options) { if v, ok := retrieveArgument(&diags, newAttributeRetriever(obj, cty.GetAttrPath("endpoints").GetAttr("dynamodb")), diff --git a/internal/backend/remote-state/s3/backend_complete_test.go b/internal/backend/remote-state/s3/backend_complete_test.go index 12946240ae..85fa6e0e7c 100644 --- a/internal/backend/remote-state/s3/backend_complete_test.go +++ b/internal/backend/remote-state/s3/backend_complete_test.go @@ -1693,6 +1693,7 @@ func TestBackendConfig_Authentication_AssumeRoleWithWebIdentity(t *testing.T) { ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1707,6 +1708,7 @@ func TestBackendConfig_Authentication_AssumeRoleWithWebIdentity(t *testing.T) { ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1722,6 +1724,7 @@ func TestBackendConfig_Authentication_AssumeRoleWithWebIdentity(t *testing.T) { ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1735,6 +1738,7 @@ func TestBackendConfig_Authentication_AssumeRoleWithWebIdentity(t *testing.T) { ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1749,6 +1753,7 @@ role_session_name = %[2]s ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1768,6 +1773,7 @@ role_session_name = %[2]s ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1782,6 +1788,7 @@ role_session_name = %[2]s // ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, // MockStsEndpoints: []*servicemocks.MockEndpoint{ // servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + // servicemocks.MockStsGetCallerIdentityValidEndpoint, // }, // }, @@ -1801,6 +1808,7 @@ web_identity_token_file = no-such-file ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1821,6 +1829,7 @@ web_identity_token_file = no-such-file ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidEndpoint, + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1836,6 +1845,7 @@ web_identity_token_file = no-such-file ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidWithOptions(map[string]string{"DurationSeconds": "3600"}), + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -1851,6 +1861,7 @@ web_identity_token_file = no-such-file ExpectedCredentialsValue: mockdata.MockStsAssumeRoleWithWebIdentityCredentials, MockStsEndpoints: []*servicemocks.MockEndpoint{ servicemocks.MockStsAssumeRoleWithWebIdentityValidWithOptions(map[string]string{"Policy": "{}"}), + servicemocks.MockStsGetCallerIdentityValidEndpoint, }, }, @@ -2183,6 +2194,15 @@ region = us-west-2 defer closeEc2Metadata() } + ts := servicemocks.MockAwsApiServer("STS", []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }) + defer ts.Close() + + tc.config["endpoints"] = map[string]any{ + "sts": ts.URL, + } + if tc.SharedConfigurationFile != "" { file, err := os.CreateTemp("", "aws-sdk-go-base-shared-configuration-file") diff --git a/internal/backend/remote-state/s3/backend_test.go b/internal/backend/remote-state/s3/backend_test.go index 2fed638ca5..4a3f524772 100644 --- a/internal/backend/remote-state/s3/backend_test.go +++ b/internal/backend/remote-state/s3/backend_test.go @@ -1062,6 +1062,23 @@ func TestBackendConfig_PrepareConfigValidation(t *testing.T) { ), }, }, + + "allowed forbidden account ids conflict": { + config: cty.ObjectVal(map[string]cty.Value{ + "bucket": cty.StringVal("test"), + "key": cty.StringVal("test"), + "region": cty.StringVal("us-west-2"), + "allowed_account_ids": cty.SetVal([]cty.Value{cty.StringVal("012345678901")}), + "forbidden_account_ids": cty.SetVal([]cty.Value{cty.StringVal("012345678901")}), + }), + expectedDiags: tfdiags.Diagnostics{ + attributeErrDiag( + "Invalid Attribute Combination", + `Only one of allowed_account_ids, forbidden_account_ids can be set.`, + cty.Path{}, + ), + }, + }, } for name, tc := range cases { diff --git a/internal/backend/remote-state/s3/diags.go b/internal/backend/remote-state/s3/diags.go index be8ee70411..0b0d9939de 100644 --- a/internal/backend/remote-state/s3/diags.go +++ b/internal/backend/remote-state/s3/diags.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + basediag "github.com/hashicorp/aws-sdk-go-base/v2/diag" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -39,3 +40,15 @@ func diagnosticsString(diags tfdiags.Diagnostics) string { } return buffer.String() } + +func baseSeverityToTerraformSeverity(s basediag.Severity) tfdiags.Severity { + switch s { + case basediag.SeverityWarning: + return tfdiags.Warning + case basediag.SeverityError: + return tfdiags.Error + default: + var zero tfdiags.Severity + return zero + } +} diff --git a/website/docs/language/settings/backends/s3.mdx b/website/docs/language/settings/backends/s3.mdx index 4e32af3f94..7d6d1d0b19 100644 --- a/website/docs/language/settings/backends/s3.mdx +++ b/website/docs/language/settings/backends/s3.mdx @@ -153,9 +153,11 @@ The following configuration is required: The following configuration is optional: * `access_key` - (Optional) AWS access key. If configured, must also configure `secret_key`. This can also be sourced from the `AWS_ACCESS_KEY_ID` environment variable, AWS shared credentials file (e.g. `~/.aws/credentials`), or AWS shared configuration file (e.g. `~/.aws/config`). +* `allowed_account_ids` - (Optional) List of allowed AWS account IDs to prevent potential destruction of a live environment. Conflicts with `forbidden_account_ids`. * `custom_ca_bundle` - (Optional) File containing custom root and intermediate certificates. Can also be set using the `AWS_CA_BUNDLE` environment variable. Setting ca_bundle in the shared config file is not supported. * `ec2_metadata_service_endpoint` - (Optional) Address of the EC2 metadata service (IMDS) endpoint to use. Can also be set with the `AWS_EC2_METADATA_SERVICE_ENDPOINT` environment variable. * `ec2_metadata_service_endpoint_mode` - (Optional) Mode to use in communicating with the metadata service. Valid values are `IPv4` and `IPv6`. Can also be set with the `AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE` environment variable. +* `forbidden_account_ids` - (Optional) List of forbidden AWS account IDs to prevent potential destruction of a live environment. Conflicts with `allowed_account_ids`. * `http_proxy` - (Optional) Address of an HTTP proxy to use when accessing the AWS API. Can also be set using the `HTTP_PROXY` or `HTTPS_PROXY` environment variables. * `iam_endpoint` - (Optional, **Deprecated**) Custom endpoint for the AWS Identity and Access Management (IAM) API. Use `endpoints.iam` instead.