diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index f4cef93c54..181b7604c2 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -31,6 +31,7 @@ jobs: serverless: ${{ steps.filter.outputs.serverless }} network: ${{ steps.filter.outputs.network }} config: ${{ steps.filter.outputs.config }} + assume_role: ${{ steps.filter.outputs.assume_role }} steps: - uses: actions/checkout@v4 if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || inputs.parent-event-name == 'release' }} @@ -38,6 +39,8 @@ jobs: id: filter with: filters: | + assume_role: + - 'mongodbatlas/**provider**.go' cluster_outage_simulation: - 'mongodbatlas/**cluster_outage_simulation**.go' advanced_cluster: @@ -103,6 +106,28 @@ jobs: - 'mongodbatlas/resource_mongodbatlas_team*.go' - 'mongodbatlas/resource_mongodbatlas_third_party_integration*.go' + fetch-sts-assume-role-creds: + runs-on: ubuntu-latest + outputs: + aws_access_key_id: ${{ steps.sts-assume-role-step.outputs.aws_access_key_id }} + aws_secret_access_key: ${{ steps.sts-assume-role-step.outputs.aws_secret_access_key }} + aws_session_token: ${{ steps.sts-assume-role-step.outputs.aws_session_token }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + scripts + - id: sts-assume-role-step + name: Generate STS Temporary credential for acceptance testing + shell: bash + env: + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + ASSUME_ROLE_ARN: ${{ vars.ASSUME_ROLE_ARN }} + run: bash ./scripts/generate-credentials-with-sts-assume-role.sh + cluster_outage_simulation: needs: [ change-detection ] if: ${{ needs.change-detection.outputs.cluster_outage_simulation == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.label.name == 'run-testacc' || github.event.label.name == 'run-testacc-cluster-outage-simulation' || inputs.parent-event-name == 'release' }} @@ -424,3 +449,33 @@ jobs: CI: true TEST_REGEX: "^TestAccConfig" run: make testacc + + assume_role: + needs: [ change-detection, fetch-sts-assume-role-creds] + if: ${{ needs.change-detection.outputs.config == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event.label.name == 'run-testacc' || github.event.label.name == 'run-testacc-config' || inputs.parent-event-name == 'release' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: 'go.mod' + - name: Acceptance Tests + env: + ASSUME_ROLE_ARN: ${{ vars.ASSUME_ROLE_ARN }} + AWS_REGION: ${{ vars.AWS_REGION }} + STS_ENDPOINT: ${{ vars.STS_ENDPOINT }} + SECRET_NAME: ${{ vars.AWS_SECRET_NAME }} + AWS_ACCESS_KEY_ID: ${{ needs.fetch-sts-assume-role-creds.outputs.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ needs.fetch-sts-assume-role-creds.outputs.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ needs.fetch-sts-assume-role-creds.outputs.AWS_SESSION_TOKEN }} + MONGODB_ATLAS_ORG_ID: ${{ vars.MONGODB_ATLAS_ORG_ID_CLOUD_DEV }} + MONGODB_ATLAS_BASE_URL: ${{ vars.MONGODB_ATLAS_BASE_URL }} + ACCTEST_TIMEOUT: ${{ vars.ACCTEST_TIMEOUT }} + TF_LOG: ${{ vars.LOG_LEVEL }} + TF_ACC: 1 + PARALLEL_GO_TEST: 20 + CI: true + TEST_REGEX: "^TestAccSTSAssumeRole" + run: make testacc \ No newline at end of file diff --git a/mongodbatlas/fw_provider.go b/mongodbatlas/fw_provider.go index 18f86d7f1c..cb0f981b25 100644 --- a/mongodbatlas/fw_provider.go +++ b/mongodbatlas/fw_provider.go @@ -8,7 +8,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -21,6 +23,7 @@ import ( sdkv2schema "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" cstmvalidator "github.com/mongodb/terraform-provider-mongodbatlas/mongodbatlas/framework/validator" + "github.com/mongodb/terraform-provider-mongodbatlas/mongodbatlas/util" "github.com/mongodb/terraform-provider-mongodbatlas/version" ) @@ -69,6 +72,18 @@ type tfAssumeRoleModel struct { SourceIdentity types.String `tfsdk:"source_identity"` } +var AssumeRoleType = types.ObjectType{AttrTypes: map[string]attr.Type{ + "policy_arns": types.SetType{ElemType: types.StringType}, + "transitive_tag_keys": types.SetType{ElemType: types.StringType}, + "tags": types.MapType{ElemType: types.StringType}, + "duration": types.StringType, + "external_id": types.StringType, + "policy": types.StringType, + "role_arn": types.StringType, + "session_name": types.StringType, + "source_identity": types.StringType, +}} + func (p *MongodbtlasProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "mongodbatlas" resp.Version = version.ProviderVersion @@ -202,11 +217,7 @@ func (p *MongodbtlasProvider) Configure(ctx context.Context, req provider.Config return } - var assumeRoles []tfAssumeRoleModel - data.AssumeRole.ElementsAs(ctx, &assumeRoles, true) - awsRoleDefined := len(assumeRoles) > 0 - - data = setDefaultValuesWithValidations(&data, awsRoleDefined, resp) + data = setDefaultValuesWithValidations(ctx, &data, resp) if resp.Diagnostics.HasError() { return } @@ -218,10 +229,13 @@ func (p *MongodbtlasProvider) Configure(ctx context.Context, req provider.Config RealmBaseURL: data.RealmBaseURL.ValueString(), } + var assumeRoles []tfAssumeRoleModel + data.AssumeRole.ElementsAs(ctx, &assumeRoles, true) + awsRoleDefined := len(assumeRoles) > 0 if awsRoleDefined { config.AssumeRole = parseTfModel(ctx, &assumeRoles[0]) secret := data.SecretName.ValueString() - region := data.Region.ValueString() + region := util.MongoDBRegionToAWSRegion(data.Region.ValueString()) awsAccessKeyID := data.AwsAccessKeyID.ValueString() awsSecretAccessKey := data.AwsSecretAccessKeyID.ValueString() awsSessionToken := data.AwsSessionToken.ValueString() @@ -281,7 +295,7 @@ func parseTfModel(ctx context.Context, tfAssumeRoleModel *tfAssumeRoleModel) *As const MongodbGovCloudURL = "https://cloud.mongodbgov.com" -func setDefaultValuesWithValidations(data *tfMongodbAtlasProviderModel, awsRoleDefined bool, resp *provider.ConfigureResponse) tfMongodbAtlasProviderModel { +func setDefaultValuesWithValidations(ctx context.Context, data *tfMongodbAtlasProviderModel, resp *provider.ConfigureResponse) tfMongodbAtlasProviderModel { if mongodbgovCloud := data.IsMongodbGovCloud.ValueBool(); mongodbgovCloud { data.BaseURL = types.StringValue(MongodbGovCloudURL) } @@ -292,6 +306,31 @@ func setDefaultValuesWithValidations(data *tfMongodbAtlasProviderModel, awsRoleD }, "").(string)) } + awsRoleDefined := false + if len(data.AssumeRole.Elements()) == 0 { + assumeRoleArn := MultiEnvDefaultFunc([]string{ + "ASSUME_ROLE_ARN", + "TF_VAR_ASSUME_ROLE_ARN", + }, "").(string) + if assumeRoleArn != "" { + awsRoleDefined = true + var diags diag.Diagnostics + data.AssumeRole, diags = types.ListValueFrom(ctx, AssumeRoleType, []tfAssumeRoleModel{ + { + Tags: types.MapNull(types.StringType), + PolicyARNs: types.SetNull(types.StringType), + TransitiveTagKeys: types.SetNull(types.StringType), + RoleARN: types.StringValue(assumeRoleArn), + }, + }) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + } + } + } else { + awsRoleDefined = true + } + if data.PublicKey.ValueString() == "" { data.PublicKey = types.StringValue(MultiEnvDefaultFunc([]string{ "MONGODB_ATLAS_PUBLIC_KEY", @@ -353,6 +392,13 @@ func setDefaultValuesWithValidations(data *tfMongodbAtlasProviderModel, awsRoleD }, "").(string)) } + if data.SecretName.ValueString() == "" { + data.SecretName = types.StringValue(MultiEnvDefaultFunc([]string{ + "SECRET_NAME", + "TF_VAR_SECRET_NAME", + }, "").(string)) + } + return *data } diff --git a/mongodbatlas/fw_provider_authentication_test.go b/mongodbatlas/fw_provider_authentication_test.go new file mode 100644 index 0000000000..1ef4a19526 --- /dev/null +++ b/mongodbatlas/fw_provider_authentication_test.go @@ -0,0 +1,44 @@ +package mongodbatlas + +import ( + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + matlas "go.mongodb.org/atlas/mongodbatlas" +) + +func TestAccSTSAssumeRole_basic(t *testing.T) { + var ( + resourceName = "mongodbatlas_project.test" + projectName = acctest.RandomWithPrefix("test-acc") + orgID = os.Getenv("MONGODB_ATLAS_ORG_ID") + clusterCount = "0" + ) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testCheckSTSAssumeRole(t); testCheckRegularCredsAreEmpty(t) }, + ProtoV6ProviderFactories: testAccProviderV6Factories, + CheckDestroy: testAccCheckMongoDBAtlasProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccMongoDBAtlasProjectConfig(projectName, orgID, + []*matlas.ProjectTeam{}, + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name", projectName), + resource.TestCheckResourceAttr(resourceName, "org_id", orgID), + resource.TestCheckResourceAttr(resourceName, "cluster_count", clusterCount), + resource.TestCheckResourceAttr(resourceName, "teams.#", "0"), + ), + }, + { + ResourceName: resourceName, + ImportStateIdFunc: testAccCheckMongoDBAtlasProjectImportStateIDFunc(resourceName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"with_default_alerts_settings"}, + }, + }, + }) +} diff --git a/mongodbatlas/provider.go b/mongodbatlas/provider.go index 4883589f36..994d8365a6 100644 --- a/mongodbatlas/provider.go +++ b/mongodbatlas/provider.go @@ -25,6 +25,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/mongodb/terraform-provider-mongodbatlas/mongodbatlas/util" "github.com/mwielbut/pointy" "github.com/spf13/cast" "github.com/zclconf/go-cty/cty" @@ -254,9 +255,7 @@ func addBetaFeatures(provider *schema.Provider) { } func providerConfigure(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) { - assumeRoleValue, ok := d.GetOk("assume_role") - awsRoleDefined := ok && len(assumeRoleValue.([]any)) > 0 && assumeRoleValue.([]any)[0] != nil - diagnostics := setDefaultsAndValidations(d, awsRoleDefined) + diagnostics := setDefaultsAndValidations(d) if diagnostics.HasError() { return nil, diagnostics } @@ -268,10 +267,12 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (any, diag.D RealmBaseURL: d.Get("realm_base_url").(string), } + assumeRoleValue, ok := d.GetOk("assume_role") + awsRoleDefined := ok && len(assumeRoleValue.([]any)) > 0 && assumeRoleValue.([]any)[0] != nil if awsRoleDefined { config.AssumeRole = expandAssumeRole(assumeRoleValue.([]any)[0].(map[string]any)) secret := d.Get("secret_name").(string) - region := d.Get("region").(string) + region := util.MongoDBRegionToAWSRegion(d.Get("region").(string)) awsAccessKeyID := d.Get("aws_access_key_id").(string) awsSecretAccessKey := d.Get("aws_secret_access_key").(string) awsSessionToken := d.Get("aws_session_token").(string) @@ -290,7 +291,7 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (any, diag.D return client, diagnostics } -func setDefaultsAndValidations(d *schema.ResourceData, awsRoleDefined bool) diag.Diagnostics { +func setDefaultsAndValidations(d *schema.ResourceData) diag.Diagnostics { diagnostics := []diag.Diagnostic{} mongodbgovCloud := pointy.Bool(d.Get("is_mongodbgov_cloud").(bool)) @@ -307,6 +308,23 @@ func setDefaultsAndValidations(d *schema.ResourceData, awsRoleDefined bool) diag return append(diagnostics, diag.FromErr(err)...) } + awsRoleDefined := false + assumeRoles := d.Get("assume_role").([]any) + if len(assumeRoles) == 0 { + roleArn := MultiEnvDefaultFunc([]string{ + "ASSUME_ROLE_ARN", + "TF_VAR_ASSUME_ROLE_ARN", + }, "").(string) + if roleArn != "" { + awsRoleDefined = true + if err := d.Set("assume_role", []map[string]any{{"role_arn": roleArn}}); err != nil { + return append(diagnostics, diag.FromErr(err)...) + } + } + } else { + awsRoleDefined = true + } + if err := setValueFromConfigOrEnv(d, "public_key", []string{ "MONGODB_ATLAS_PUBLIC_KEY", "MCLI_PUBLIC_API_KEY", @@ -362,6 +380,13 @@ func setDefaultsAndValidations(d *schema.ResourceData, awsRoleDefined bool) diag return append(diagnostics, diag.FromErr(err)...) } + if err := setValueFromConfigOrEnv(d, "secret_name", []string{ + "SECRET_NAME", + "TF_VAR_SECRET_NAME", + }); err != nil { + return append(diagnostics, diag.FromErr(err)...) + } + if err := setValueFromConfigOrEnv(d, "aws_session_token", []string{ "AWS_SESSION_TOKEN", "TF_VAR_AWS_SESSION_TOKEN", diff --git a/mongodbatlas/provider_test.go b/mongodbatlas/provider_test.go index db3348f980..c395074c7f 100644 --- a/mongodbatlas/provider_test.go +++ b/mongodbatlas/provider_test.go @@ -159,6 +159,36 @@ func testCheckAwsEnv(tb testing.TB) { } } +func testCheckRegularCredsAreEmpty(tb testing.TB) { + if os.Getenv("MONGODB_ATLAS_PUBLIC_KEY") != "" || os.Getenv("MONGODB_ATLAS_PRIVATE_KEY") != "" { + tb.Fatal(`"MONGODB_ATLAS_PUBLIC_KEY" and "MONGODB_ATLAS_PRIVATE_KEY" are defined in this test and they should not.`) + } +} + +func testCheckSTSAssumeRole(tb testing.TB) { + if os.Getenv("AWS_REGION") == "" { + tb.Fatal(`'AWS_REGION' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("STS_ENDPOINT") == "" { + tb.Fatal(`'STS_ENDPOINT' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("ASSUME_ROLE_ARN") == "" { + tb.Fatal(`'ASSUME_ROLE_ARN' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("AWS_ACCESS_KEY_ID") == "" { + tb.Fatal(`'AWS_ACCESS_KEY_ID' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" { + tb.Fatal(`'AWS_SECRET_ACCESS_KEY' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("AWS_SESSION_TOKEN") == "" { + tb.Fatal(`'AWS_SESSION_TOKEN' must be set for acceptance testing with STS Assume Role.`) + } + if os.Getenv("SECRET_NAME") == "" { + tb.Fatal(`'SECRET_NAME' must be set for acceptance testing with STS Assume Role.`) + } +} + func TestEncodeDecodeID(t *testing.T) { expected := map[string]string{ "project_id": "5cf5a45a9ccf6400e60981b6", diff --git a/mongodbatlas/util/type_conversion.go b/mongodbatlas/util/type_conversion.go index b426235dfa..f060041464 100644 --- a/mongodbatlas/util/type_conversion.go +++ b/mongodbatlas/util/type_conversion.go @@ -1,6 +1,9 @@ package util -import "time" +import ( + "strings" + "time" +) func SafeString(s *string) string { if s != nil { @@ -48,3 +51,8 @@ func IntPtrToInt64Ptr(i *int) *int64 { func IsStringPresent(strPtr *string) bool { return strPtr != nil && len(*strPtr) > 0 } + +// MongoDBRegionToAWSRegion converts region in US_EAST_1-like format to us-east-1-like +func MongoDBRegionToAWSRegion(region string) string { + return strings.ReplaceAll(strings.ToLower(region), "_", "-") +} diff --git a/mongodbatlas/util/type_conversion_test.go b/mongodbatlas/util/type_conversion_test.go index efc4b05d65..dff52cecf4 100644 --- a/mongodbatlas/util/type_conversion_test.go +++ b/mongodbatlas/util/type_conversion_test.go @@ -50,3 +50,20 @@ func TestIsStringPresent(t *testing.T) { } } } + +func TestMongoDBRegionToAWSRegion(t *testing.T) { + tests := []struct { + region string + expected string + }{ + {"US_EAST_1", "us-east-1"}, + {"us-east-1", "us-east-1"}, + {"nothing", "nothing"}, + } + + for _, test := range tests { + if resp := util.MongoDBRegionToAWSRegion(test.region); resp != test.expected { + t.Errorf("MongoDBRegionToAWSRegion(%v) = %v; want %v", test.region, resp, test.expected) + } + } +} diff --git a/scripts/generate-credentials-with-sts-assume-role.sh b/scripts/generate-credentials-with-sts-assume-role.sh new file mode 100755 index 0000000000..63860b31ee --- /dev/null +++ b/scripts/generate-credentials-with-sts-assume-role.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -Eeou pipefail + +# This script uses aws sts assume-role to generate temporary credentials +# and outputs them in $GITHUB_OUTPUT so those can be used in other workflow jobs. +# role-arn = arn:aws:iam::358363220050:role/terraform-provider-mongodbatlas-acceptancetests + +# Define a function to convert a string to lowercase +function to_lowercase() { + echo "$1" | tr '[:upper:]' '[:lower:]' +} +# Convert the input string to lowercase +aws_region=$(to_lowercase "$AWS_REGION") +# Replace all underscores with hyphens +aws_region=${aws_region//_/-} +# e.g. from US_EAST_1 to us-east-1 + +# Get the STS credentials +export AWS_REGION="$aws_region" +CREDENTIALS=$(aws sts assume-role --role-arn "$ASSUME_ROLE_ARN" --role-session-name newSession --output text --query 'Credentials.[AccessKeyId, SecretAccessKey, SessionToken]') + +# Extract the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN from the STS credentials +AWS_ACCESS_KEY_ID=$(echo "$CREDENTIALS" | awk '{print $1}') +AWS_SECRET_ACCESS_KEY=$(echo "$CREDENTIALS" | awk '{print $2}') +AWS_SESSION_TOKEN=$(echo "$CREDENTIALS" | awk '{print $3}') + +{ + echo "aws_access_key_id=${AWS_ACCESS_KEY_ID}" + echo "aws_secret_access_key=$AWS_SECRET_ACCESS_KEY" + echo "aws_session_token=$AWS_SESSION_TOKEN" +} >> "$GITHUB_OUTPUT"