From e1b3b4ff828494d4ee1520c398b8df98184c36ef Mon Sep 17 00:00:00 2001 From: Tomas <40318863+tomasmik@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:03:54 +0300 Subject: [PATCH] S3 backend add account ID whitelisting arguments (#760) Signed-off-by: tomasmik --- CHANGELOG.md | 1 + internal/backend/remote-state/s3/backend.go | 49 +++++++++++++++++++ .../remote-state/s3/backend_complete_test.go | 36 ++++++++++++++ .../backend/remote-state/s3/backend_test.go | 10 ++++ .../docs/language/settings/backends/s3.mdx | 2 + 5 files changed, 98 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f30899904..e0b99f854c 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 account whitelisting using the `forbidden_account_ids` and `allowed_account_ids` arguments. ([#699](https://github.com/opentofu/opentofu/issues/699)) ## Previous Releases diff --git a/internal/backend/remote-state/s3/backend.go b/internal/backend/remote-state/s3/backend.go index 8521636ab4..e7c4abb8a4 100644 --- a/internal/backend/remote-state/s3/backend.go +++ b/internal/backend/remote-state/s3/backend.go @@ -297,6 +297,16 @@ func (b *Backend) ConfigSchema() *configschema.Block { }, }, }, + "forbidden_account_ids": { + Type: cty.Set(cty.String), + Optional: true, + Description: "List of forbidden AWS account IDs.", + }, + "allowed_account_ids": { + Type: cty.Set(cty.String), + Optional: true, + Description: "List of allowed AWS account IDs.", + }, }, } } @@ -433,6 +443,11 @@ func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) } } + validateAttributesConflict( + cty.GetAttrPath("allowed_account_ids"), + cty.GetAttrPath("forbidden_account_ids"), + )(obj, cty.Path{}, &diags) + return obj, diags } @@ -569,6 +584,14 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { cfg.SharedConfigFiles = val } + if val, ok := stringSliceAttrOk(obj, "allowed_account_ids"); ok { + cfg.AllowedAccountIds = val + } + + if val, ok := stringSliceAttrOk(obj, "forbidden_account_ids"); ok { + cfg.ForbiddenAccountIds = val + } + ctx := context.TODO() _, awsConfig, awsDiags := awsbase.GetAwsConfig(ctx, cfg) @@ -580,6 +603,10 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { )) } + if d := verifyAllowedAccountID(ctx, awsConfig, cfg); len(d) != 0 { + diags = diags.Append(d) + } + if diags.HasErrors() { return diags } @@ -593,6 +620,28 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { return diags } +func verifyAllowedAccountID(ctx context.Context, awsConfig aws.Config, cfg *awsbase.Config) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + accountID, _, awsDiags := awsbase.GetAwsAccountIDAndPartition(ctx, awsConfig, cfg) + for _, d := range awsDiags { + diags = diags.Append(tfdiags.Sourceless( + baseSeverityToTofuSeverity(d.Severity()), + fmt.Sprintf("Retrieving AWS account details: %s", d.Summary()), + d.Detail(), + )) + } + + err := cfg.VerifyAccountIDAllowed(accountID) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid account ID", + err.Error(), + )) + } + return diags +} + func getDynamoDBConfig(obj cty.Value) func(options *dynamodb.Options) { return func(options *dynamodb.Options) { if v, ok := stringAttrDefaultEnvVarOk(obj, "dynamodb_endpoint", "AWS_DYNAMODB_ENDPOINT", "AWS_ENDPOINT_URL_DYNAMODB"); ok { diff --git a/internal/backend/remote-state/s3/backend_complete_test.go b/internal/backend/remote-state/s3/backend_complete_test.go index beef2598da..148eb76293 100644 --- a/internal/backend/remote-state/s3/backend_complete_test.go +++ b/internal/backend/remote-state/s3/backend_complete_test.go @@ -156,6 +156,9 @@ func TestBackendConfig_Authentication(t *testing.T) { }{ "empty config": { config: map[string]any{}, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, ValidateDiags: ExpectDiagsMatching( tfdiags.Error, equalsMatcher("No valid credential sources found"), @@ -175,6 +178,23 @@ func TestBackendConfig_Authentication(t *testing.T) { ValidateDiags: ExpectNoDiags, }, + "config AccessKey forbidden account": { + config: map[string]any{ + "access_key": servicemocks.MockStaticAccessKey, + "secret_key": servicemocks.MockStaticSecretKey, + "forbidden_account_ids": []any{"222222222222"}, + }, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, + ExpectedCredentialsValue: mockdata.MockStaticCredentials, + ValidateDiags: ExpectDiagsMatching( + tfdiags.Error, + equalsMatcher("Invalid account ID"), + equalsMatcher("AWS account ID not allowed: 222222222222"), + ), + }, + "config Profile shared credentials profile aws_access_key_id": { config: map[string]any{ "profile": "SharedCredentialsProfile", @@ -545,6 +565,9 @@ region = us-east-1 EnvironmentVariables: map[string]string{ "AWS_PROFILE": "no-such-profile", }, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, SharedCredentialsFile: ` [some-profile] aws_access_key_id = DefaultSharedCredentialsAccessKey @@ -566,6 +589,9 @@ aws_secret_access_key = DefaultSharedCredentialsSecretKey aws_access_key_id = DefaultSharedCredentialsAccessKey aws_secret_access_key = DefaultSharedCredentialsSecretKey `, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, ValidateDiags: ExpectDiagsMatching( tfdiags.Error, equalsMatcher("failed to get shared config profile, no-such-profile"), @@ -603,6 +629,9 @@ aws_secret_access_key = ProfileSharedCredentialsSecretKey "AWS_SECRET_ACCESS_KEY": servicemocks.MockEnvSecretKey, "AWS_PROFILE": "no-such-profile", }, + MockStsEndpoints: []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }, SharedCredentialsFile: ` [some-profile] aws_access_key_id = DefaultSharedCredentialsAccessKey @@ -1766,6 +1795,13 @@ region = us-west-2 defer closeEc2Metadata() } + sts := servicemocks.MockAwsApiServer("STS", []*servicemocks.MockEndpoint{ + servicemocks.MockStsGetCallerIdentityValidEndpoint, + }) + defer sts.Close() + + tc.config["sts_endpoint"] = sts.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 c892f6ee04..8a8c180dae 100644 --- a/internal/backend/remote-state/s3/backend_test.go +++ b/internal/backend/remote-state/s3/backend_test.go @@ -713,6 +713,16 @@ func TestBackendConfig_PrepareConfigValidation(t *testing.T) { }), expectedErr: `Only one of "kms_key_id" and "sse_customer_key" can be set`, }, + "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("111111111111")}), + "forbidden_account_ids": cty.SetVal([]cty.Value{cty.StringVal("111111111111")}), + }), + expectedErr: "Invalid Attribute Combination: Only one of allowed_account_ids, forbidden_account_ids can be set.", + }, } for name, tc := range cases { diff --git a/website/docs/language/settings/backends/s3.mdx b/website/docs/language/settings/backends/s3.mdx index 8c38e67119..ad12f0497a 100644 --- a/website/docs/language/settings/backends/s3.mdx +++ b/website/docs/language/settings/backends/s3.mdx @@ -170,6 +170,8 @@ The following configuration is optional: * `skip_metadata_api_check` - (Optional) Skip usage of EC2 Metadata API. * `sts_endpoint` - (Optional) Custom endpoint for the AWS Security Token Service (STS) API. This can also be sourced from the `AWS_STS_ENDPOINT` environment variable. * `token` - (Optional) Multi-Factor Authentication (MFA) token. This can also be sourced from the `AWS_SESSION_TOKEN` environment variable. +* `allowed_account_ids` (Optional): A list of permitted AWS account IDs to safeguard against accidental disruption of a live environment. This option conflicts with `forbidden_account_ids`. +* `forbidden_account_ids` (Optional): A list of prohibited AWS account IDs to prevent unintentional disruption of a live environment. This option conflicts with `allowed_account_ids`. * `use_legacy_workflow` - (Optional) Prefer environment variables for legacy authentication; default is 'true.' This method doesn't match AWS CLI or SDK authentication and will be removed in the future. #### Assume Role Configuration