From 16d52c2e1223f8022dffb3141170b9aeebdd2c63 Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Tue, 28 Nov 2023 08:04:22 -0600 Subject: [PATCH] Feat: add new OIDC credentials creation and assigment (AWS Resource) (#755) * Feat: add new OIDC credentials creation and assigment (AWS Resource) * minor fixes --- client/cloud_credentials.go | 9 +- env0/credentials.go | 2 + env0/provider.go | 1 + env0/resource_aws_oidc_credentials.go | 98 +++++++ env0/resource_aws_oidc_credentials_test.go | 239 ++++++++++++++++++ .../env0_aws_oidc_credentials/import.sh | 2 + .../env0_aws_oidc_credentials/resource.tf | 5 + .../resource.tf | 10 + tests/integration/006_aws_credentials/main.tf | 5 + 9 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 env0/resource_aws_oidc_credentials.go create mode 100644 env0/resource_aws_oidc_credentials_test.go create mode 100644 examples/resources/env0_aws_oidc_credentials/import.sh create mode 100644 examples/resources/env0_aws_oidc_credentials/resource.tf diff --git a/client/cloud_credentials.go b/client/cloud_credentials.go index f5d6ab21..51c43ce5 100644 --- a/client/cloud_credentials.go +++ b/client/cloud_credentials.go @@ -42,8 +42,8 @@ type AzureCredentialsValuePayload struct { } type AwsCredentialsCreatePayload struct { - Name string `json:"name"` - OrganizationId string `json:"organizationId"` + Name string `json:"name,omitempty"` + OrganizationId string `json:"organizationId,omitempty"` Type AwsCredentialsType `json:"type"` Value AwsCredentialsValuePayload `json:"value"` } @@ -51,8 +51,8 @@ type AwsCredentialsCreatePayload struct { type AwsCredentialsValuePayload struct { RoleArn string `json:"roleArn" tfschema:"arn"` Duration int `json:"duration,omitempty"` - AccessKeyId string `json:"accessKeyId"` - SecretAccessKey string `json:"secretAccessKey"` + AccessKeyId string `json:"accessKeyId,omitempty"` + SecretAccessKey string `json:"secretAccessKey,omitempty"` } type GoogleCostCredentialsCreatePayload struct { @@ -101,6 +101,7 @@ const ( AwsCostCredentialsType AwsCredentialsType = "AWS_ASSUMED_ROLE" AwsAssumedRoleCredentialsType AwsCredentialsType = "AWS_ASSUMED_ROLE_FOR_DEPLOYMENT" AwsAccessKeysCredentialsType AwsCredentialsType = "AWS_ACCESS_KEYS_FOR_DEPLOYMENT" + AwsOidcCredentialsType AwsCredentialsType = "AWS_OIDC" GcpServiceAccountCredentialsType GcpCredentialsType = "GCP_SERVICE_ACCOUNT_FOR_DEPLOYMENT" AzureServicePrincipalCredentialsType AzureCredentialsType = "AZURE_SERVICE_PRINCIPAL_FOR_DEPLOYMENT" ) diff --git a/env0/credentials.go b/env0/credentials.go index 539f34c7..cb892b9d 100644 --- a/env0/credentials.go +++ b/env0/credentials.go @@ -21,6 +21,7 @@ const ( GCP_COST_TYPE CloudType = "google_cost" AZURE_COST_TYPE CloudType = "azure_cost" AWS_COST_TYPE CloudType = "aws_cost" + AWS_OIDC_TYPE CloudType = "aws_oidc" ) var credentialsTypeToPrefixList map[CloudType][]string = map[CloudType][]string{ @@ -30,6 +31,7 @@ var credentialsTypeToPrefixList map[CloudType][]string = map[CloudType][]string{ GCP_COST_TYPE: {string(client.GoogleCostCredentialsType)}, AZURE_COST_TYPE: {string(client.AzureCostCredentialsType)}, AWS_COST_TYPE: {string(client.AwsCostCredentialsType)}, + AWS_OIDC_TYPE: {string(client.AwsOidcCredentialsType)}, } func getCredentialsByName(name string, prefixList []string, meta interface{}) (client.Credentials, error) { diff --git a/env0/provider.go b/env0/provider.go index 265e9465..1076a4dd 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -100,6 +100,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_template": resourceTemplate(), "env0_ssh_key": resourceSshKey(), "env0_aws_credentials": resourceAwsCredentials(), + "env0_aws_oidc_credentials": resourceAwsOidcCredentials(), "env0_aws_cost_credentials": resourceCostCredentials("aws"), "env0_azure_cost_credentials": resourceCostCredentials("azure"), "env0_gcp_cost_credentials": resourceCostCredentials("google"), diff --git a/env0/resource_aws_oidc_credentials.go b/env0/resource_aws_oidc_credentials.go new file mode 100644 index 00000000..ce45f27a --- /dev/null +++ b/env0/resource_aws_oidc_credentials.go @@ -0,0 +1,98 @@ +package env0 + +import ( + "context" + "fmt" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceAwsOidcCredentials() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAwsOidcCredentialsCreate, + UpdateContext: resourceAwsOidcCredentialsUpdate, + ReadContext: resourceCredentialsRead(AWS_OIDC_TYPE), + DeleteContext: resourceCredentialsDelete, + + Importer: &schema.ResourceImporter{StateContext: resourceCredentialsImport(AWS_OIDC_TYPE)}, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "name for the oidc credentials", + Required: true, + ForceNew: true, + }, + "role_arn": { + Type: schema.TypeString, + Description: "the aws role arn", + Required: true, + }, + "duration": { + Type: schema.TypeInt, + Description: "the session duration in seconds. If set must be one of the following: 3600 (1h), 7200 (2h), 14400 (4h), 18000 (5h default), 28800 (8h), 43200 (12h)", + Optional: true, + ValidateDiagFunc: NewIntInValidator([]int{3600, 7200, 14400, 18000, 28800, 43200}), + Default: 18000, + }, + }, + } +} + +func awsOidcCredentialsGetValue(d *schema.ResourceData) (client.AwsCredentialsValuePayload, error) { + value := client.AwsCredentialsValuePayload{} + + if err := readResourceData(&value, d); err != nil { + return value, fmt.Errorf("schema resource data deserialization failed: %w", err) + } + + value.RoleArn = d.Get("role_arn").(string) // tfschema is set (for older resources) need to manually set the role arn. + + return value, nil +} + +func resourceAwsOidcCredentialsCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + value, err := awsOidcCredentialsGetValue(d) + if err != nil { + return diag.FromErr(err) + } + + request := client.AwsCredentialsCreatePayload{ + Name: d.Get("name").(string), + Value: value, + Type: client.AwsOidcCredentialsType, + } + + credentials, err := apiClient.CredentialsCreate(&request) + if err != nil { + return diag.Errorf("could not create aws oidc credentials: %v", err) + } + + d.SetId(credentials.Id) + + return nil +} + +func resourceAwsOidcCredentialsUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + value, err := awsOidcCredentialsGetValue(d) + if err != nil { + return diag.FromErr(err) + } + + request := client.AwsCredentialsCreatePayload{ + Value: value, + Type: client.AwsOidcCredentialsType, + } + + if _, err := apiClient.CredentialsUpdate(d.Id(), &request); err != nil { + return diag.Errorf("could not update aws oidc credentials: %s %v", d.Id(), err) + } + + return nil +} diff --git a/env0/resource_aws_oidc_credentials_test.go b/env0/resource_aws_oidc_credentials_test.go new file mode 100644 index 00000000..94e7dcc1 --- /dev/null +++ b/env0/resource_aws_oidc_credentials_test.go @@ -0,0 +1,239 @@ +package env0 + +import ( + "fmt" + "regexp" + "strconv" + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/env0/terraform-provider-env0/client/http" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "go.uber.org/mock/gomock" +) + +func TestUnitAwsOidcCredentialsResource(t *testing.T) { + resourceType := "env0_aws_oidc_credentials" + resourceName := "test" + resourceNameImport := resourceType + "." + resourceName + accessor := resourceAccessor(resourceType, resourceName) + + duration := 3600 + updatedDuration := 2 * duration + + awsCredentialsResource := map[string]interface{}{ + "name": "test", + "role_arn": "11111", + "duration": strconv.Itoa(duration), + } + + updatedAwsCredentialsResource := map[string]interface{}{ + "name": "test", + "role_arn": "22222", + "duration": strconv.Itoa(updatedDuration), + } + + createPayload := client.AwsCredentialsCreatePayload{ + Name: awsCredentialsResource["name"].(string), + Value: client.AwsCredentialsValuePayload{ + RoleArn: awsCredentialsResource["role_arn"].(string), + Duration: duration, + }, + Type: client.AwsOidcCredentialsType, + } + + updatePayload := client.AwsCredentialsCreatePayload{ + Value: client.AwsCredentialsValuePayload{ + RoleArn: updatedAwsCredentialsResource["role_arn"].(string), + Duration: updatedDuration, + }, + Type: client.AwsOidcCredentialsType, + } + + returnValues := client.Credentials{ + Id: "f595c4b6-0a24-4c22-89f7-7030045de30f", + Name: "test", + OrganizationId: "id", + Type: string(client.AwsOidcCredentialsType), + } + + otherTypeReturnValues := client.Credentials{ + Id: "f595c4b6-0a24-4c22-89f7-7030045de30a", + Name: "test", + OrganizationId: "id", + Type: "AWS_....", + } + + updateReturnValues := client.Credentials{ + Id: returnValues.Id, + Name: returnValues.Name, + OrganizationId: "id", + Type: string(client.AwsOidcCredentialsType), + } + + testCaseForCreateAndUpdate := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, awsCredentialsResource), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "name", awsCredentialsResource["name"].(string)), + resource.TestCheckResourceAttr(accessor, "role_arn", awsCredentialsResource["role_arn"].(string)), + resource.TestCheckResourceAttr(accessor, "id", returnValues.Id), + resource.TestCheckResourceAttr(accessor, "duration", awsCredentialsResource["duration"].(string)), + ), + }, + { + Config: resourceConfigCreate(resourceType, resourceName, updatedAwsCredentialsResource), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "name", updatedAwsCredentialsResource["name"].(string)), + resource.TestCheckResourceAttr(accessor, "role_arn", updatedAwsCredentialsResource["role_arn"].(string)), + resource.TestCheckResourceAttr(accessor, "id", updateReturnValues.Id), + resource.TestCheckResourceAttr(accessor, "duration", updatedAwsCredentialsResource["duration"].(string)), + ), + }, + }, + } + + t.Run("create and update", func(t *testing.T) { + runUnitTest(t, testCaseForCreateAndUpdate, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(2).Return(returnValues, nil), + mock.EXPECT().CredentialsUpdate(returnValues.Id, &updatePayload).Times(1).Return(updateReturnValues, nil), + mock.EXPECT().CloudCredentials(updateReturnValues.Id).Times(1).Return(updateReturnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("drift", func(t *testing.T) { + stepConfig := resourceConfigCreate(resourceType, resourceName, awsCredentialsResource) + + createTestCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: stepConfig, + }, + { + Config: stepConfig, + }, + }, + } + + runUnitTest(t, createTestCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, http.NewMockFailedResponseError(404)), + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by name", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, awsCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: awsCredentialsResource["name"].(string), + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"role_arn", "duration"}, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsList().Times(1).Return([]client.Credentials{otherTypeReturnValues, returnValues}, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by id", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, awsCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: returnValues.Id, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"role_arn", "duration"}, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(3).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by id not found", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, awsCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: otherTypeReturnValues.Id, + ImportStateVerify: true, + ExpectError: regexp.MustCompile("credentials not found"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(otherTypeReturnValues.Id).Times(1).Return(client.Credentials{}, &client.NotFoundError{}), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) + + t.Run("import by name not found", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, awsCredentialsResource), + }, + { + ResourceName: resourceNameImport, + ImportState: true, + ImportStateId: awsCredentialsResource["name"].(string), + ImportStateVerify: true, + ExpectError: regexp.MustCompile(fmt.Sprintf("credentials with name %v not found", awsCredentialsResource["name"].(string))), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().CredentialsCreate(&createPayload).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentials(returnValues.Id).Times(1).Return(returnValues, nil), + mock.EXPECT().CloudCredentialsList().Times(1).Return([]client.Credentials{otherTypeReturnValues}, nil), + mock.EXPECT().CloudCredentialsDelete(returnValues.Id).Times(1).Return(nil), + ) + }) + }) +} diff --git a/examples/resources/env0_aws_oidc_credentials/import.sh b/examples/resources/env0_aws_oidc_credentials/import.sh new file mode 100644 index 00000000..9262ff10 --- /dev/null +++ b/examples/resources/env0_aws_oidc_credentials/import.sh @@ -0,0 +1,2 @@ +terraform import env0_aws_oidc_credentials.by_id d31a6b30-5f69-4d24-937c-22322754934e +terraform import env0_aws_oidc_credentials.by_name "credentials name" diff --git a/examples/resources/env0_aws_oidc_credentials/resource.tf b/examples/resources/env0_aws_oidc_credentials/resource.tf new file mode 100644 index 00000000..2f31acbb --- /dev/null +++ b/examples/resources/env0_aws_oidc_credentials/resource.tf @@ -0,0 +1,5 @@ +resource "env0_aws_oidc_credentials" "credentials" { + name = "example" + role_arn = "arn::role::34" + duration = 3600 +} diff --git a/examples/resources/env0_cloud_credentials_project_assignment/resource.tf b/examples/resources/env0_cloud_credentials_project_assignment/resource.tf index 964fb4fd..39c1a9de 100644 --- a/examples/resources/env0_cloud_credentials_project_assignment/resource.tf +++ b/examples/resources/env0_cloud_credentials_project_assignment/resource.tf @@ -3,6 +3,11 @@ resource "env0_aws_credentials" "credentials" { arn = "Example role ARN" } +resource "env0_aws_oidc_credentials" "credentials" { + name = "example" + role_arn = "Example role ARN" +} + data "env0_project" "project" { name = "Default Organization Project" } @@ -11,3 +16,8 @@ resource "env0_cloud_credentials_project_assignment" "example" { credential_id = env0_aws_credentials.credentials.id project_id = data.env0_project.project.id } + +resource "env0_cloud_credentials_project_assignment" "example_oidc" { + credential_id = env0_aws_oidc_credentials.credentials.id + project_id = data.env0_project.project.id +} diff --git a/tests/integration/006_aws_credentials/main.tf b/tests/integration/006_aws_credentials/main.tf index d19af4fa..8731cf69 100644 --- a/tests/integration/006_aws_credentials/main.tf +++ b/tests/integration/006_aws_credentials/main.tf @@ -26,6 +26,11 @@ data "env0_aws_credentials" "my_role_by_access_key" { depends_on = [env0_aws_credentials.my_role_by_access_key] } +resource "env0_aws_oidc_credentials" "oidc_credentials" { + name = "Test Oidc Credentials ${random_string.random.result}" + role_arn = "Role ARN" + duration = 7200 +} output "name_by_arn" { value = replace(data.env0_aws_credentials.my_role_by_arn.name, random_string.random.result, "")