From dfd13615cbacc94d5d26ceb8234bb62ae2bbd149 Mon Sep 17 00:00:00 2001 From: Max Coulombe <109547106+maxcoulombe@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:29:14 -0400 Subject: [PATCH] Rotating secret resource implementation (#1101) * * rotating secret resource implementation --- .changelog/1101.txt | 3 + .../vault_secrets_rotating_secret.md | 111 +++++++ .../resource.tf | 43 +++ internal/provider/provider.go | 1 + .../resource_vault_secrets_rotating_secret.go | 312 ++++++++++++++++++ ...urce_vault_secrets_rotating_secret_test.go | 125 +++++++ .../vaultsecrets/rotating_secret_aws.go | 87 +++++ .../vaultsecrets/rotating_secret_gcp.go | 87 +++++ .../rotating_secret_mongodb_atlas.go | 81 +++++ .../vaultsecrets/rotating_secret_twilio.go | 72 ++++ .../vaultsecrets/vault_secrets_utils.go | 6 +- 11 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 .changelog/1101.txt create mode 100644 docs/resources/vault_secrets_rotating_secret.md create mode 100644 examples/resources/hcp_vault_secrets_rotating_secret/resource.tf create mode 100644 internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go create mode 100644 internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret_test.go create mode 100644 internal/provider/vaultsecrets/rotating_secret_aws.go create mode 100644 internal/provider/vaultsecrets/rotating_secret_gcp.go create mode 100644 internal/provider/vaultsecrets/rotating_secret_mongodb_atlas.go create mode 100644 internal/provider/vaultsecrets/rotating_secret_twilio.go diff --git a/.changelog/1101.txt b/.changelog/1101.txt new file mode 100644 index 000000000..efaedc33d --- /dev/null +++ b/.changelog/1101.txt @@ -0,0 +1,3 @@ +```release-note:feature +add vault_secrets_rotating_secret resource +``` \ No newline at end of file diff --git a/docs/resources/vault_secrets_rotating_secret.md b/docs/resources/vault_secrets_rotating_secret.md new file mode 100644 index 000000000..1869aa82b --- /dev/null +++ b/docs/resources/vault_secrets_rotating_secret.md @@ -0,0 +1,111 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "hcp_vault_secrets_rotating_secret Resource - terraform-provider-hcp" +subcategory: "" +description: |- + The Vault Secrets rotating secret resource manages a rotating secret configuration. +--- + +# hcp_vault_secrets_rotating_secret (Resource) + +The Vault Secrets rotating secret resource manages a rotating secret configuration. + +## Example Usage + +```terraform +resource "hcp_vault_secrets_rotating_secret" "example_aws" { + app_name = "my-app-1" + secret_provider = "aws" + name = "my_aws_1" + integration_name = "my-aws-1" + rotation_policy_name = "built-in:60-days-2-active" + aws_access_keys = { + iam_username = "my-iam-username" + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_gcp" { + app_name = "my-app-1" + secret_provider = "gcp" + name = "my_gcp_1" + integration_name = "my-gcp-1" + rotation_policy_name = "built-in:60-days-2-active" + gcp_service_account_key = { + service_account_email = ">@.iam.gserviceaccount.com" + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_mongodb_atlas" { + app_name = "my-app-1" + secret_provider = "mongodb_atlas" + name = "my_mongodb_atlas_1" + integration_name = "my-mongodbatlas-1" + rotation_policy_name = "built-in:60-days-2-active" + mongodb_atlas_user = { + project_id = ">" + database_name = "my-cluster-0" + roles = ["readWrite", "read"] + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_twilio" { + app_name = "my-app-1" + secret_provider = "twilio" + name = "my_twilio_1" + integration_name = "my-twilio-1" + rotation_policy_name = "built-in:60-days-2-active" + twilio_api_key = {} +} +``` + + +## Schema + +### Required + +- `app_name` (String) Vault Secrets application name that owns the secret. +- `integration_name` (String) The Vault Secrets integration name with the capability to manage the secret's lifecycle. +- `name` (String) The Vault Secrets secret name. +- `rotation_policy_name` (String) Name of the rotation policy that governs the rotation of the secret. +- `secret_provider` (String) The third party platform the dynamic credentials give access to. One of `aws` or `gcp`. + +### Optional + +- `aws_access_keys` (Attributes) AWS configuration to manage the access key rotation for the given IAM user. Required if `secret_provider` is `aws`. (see [below for nested schema](#nestedatt--aws_access_keys)) +- `gcp_service_account_key` (Attributes) GCP configuration to manage the service account key rotation for the given service account. Required if `secret_provider` is `gcp`. (see [below for nested schema](#nestedatt--gcp_service_account_key)) +- `mongodb_atlas_user` (Attributes) MongoDB Atlas configuration to manage the user password rotation on the given database. Required if `secret_provider` is `mongodb_atlas`. (see [below for nested schema](#nestedatt--mongodb_atlas_user)) +- `project_id` (String) HCP project ID that owns the HCP Vault Secrets integration. Inferred from the provider configuration if omitted. +- `twilio_api_key` (Attributes) Twilio configuration to manage the api key rotation on the given account. Required if `secret_provider` is `twilio`. (see [below for nested schema](#nestedatt--twilio_api_key)) + +### Read-Only + +- `organization_id` (String) HCP organization ID that owns the HCP Vault Secrets integration. + + +### Nested Schema for `aws_access_keys` + +Required: + +- `iam_username` (String) AWS IAM username to rotate the access keys for. + + + +### Nested Schema for `gcp_service_account_key` + +Required: + +- `service_account_email` (String) GCP service account email to impersonate. + + + +### Nested Schema for `mongodb_atlas_user` + +Required: + +- `database_name` (String) MongoDB Atlas database or cluster name to rotate the username and password for. +- `project_id` (String) MongoDB Atlas project ID to rotate the username and password for. +- `roles` (List of String) MongoDB Atlas roles to assign to the rotating user. + + + +### Nested Schema for `twilio_api_key` diff --git a/examples/resources/hcp_vault_secrets_rotating_secret/resource.tf b/examples/resources/hcp_vault_secrets_rotating_secret/resource.tf new file mode 100644 index 000000000..8341158cc --- /dev/null +++ b/examples/resources/hcp_vault_secrets_rotating_secret/resource.tf @@ -0,0 +1,43 @@ +resource "hcp_vault_secrets_rotating_secret" "example_aws" { + app_name = "my-app-1" + secret_provider = "aws" + name = "my_aws_1" + integration_name = "my-aws-1" + rotation_policy_name = "built-in:60-days-2-active" + aws_access_keys = { + iam_username = "my-iam-username" + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_gcp" { + app_name = "my-app-1" + secret_provider = "gcp" + name = "my_gcp_1" + integration_name = "my-gcp-1" + rotation_policy_name = "built-in:60-days-2-active" + gcp_service_account_key = { + service_account_email = ">@.iam.gserviceaccount.com" + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_mongodb_atlas" { + app_name = "my-app-1" + secret_provider = "mongodb_atlas" + name = "my_mongodb_atlas_1" + integration_name = "my-mongodbatlas-1" + rotation_policy_name = "built-in:60-days-2-active" + mongodb_atlas_user = { + project_id = ">" + database_name = "my-cluster-0" + roles = ["readWrite", "read"] + } +} + +resource "hcp_vault_secrets_rotating_secret" "example_twilio" { + app_name = "my-app-1" + secret_provider = "twilio" + name = "my_twilio_1" + integration_name = "my-twilio-1" + rotation_policy_name = "built-in:60-days-2-active" + twilio_api_key = {} +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5763654be..1e8a2a510 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -158,6 +158,7 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res vaultsecrets.NewVaultSecretsIntegrationMongoDBAtlasResource, vaultsecrets.NewVaultSecretsIntegrationTwilioResource, vaultsecrets.NewVaultSecretsDynamicSecretResource, + vaultsecrets.NewVaultSecretsRotatingSecretResource, // IAM iam.NewServicePrincipalResource, iam.NewServicePrincipalKeyResource, diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go new file mode 100644 index 000000000..a5bb4e136 --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go @@ -0,0 +1,312 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/modifiers" + "golang.org/x/exp/maps" +) + +var exactlyOneRotatingSecretTypeFieldsValidator = objectvalidator.ExactlyOneOf( + path.Expressions{ + path.MatchRoot("aws_access_keys"), + path.MatchRoot("gcp_service_account_key"), + path.MatchRoot("mongodb_atlas_user"), + path.MatchRoot("twilio_api_key"), + }..., +) + +// rotatingSecret encapsulates the HVS provider-specific logic so the Terraform resource can focus on the Terraform logic +type rotatingSecret interface { + read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) + create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) + update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) + // delete not necessary on the interface, all secrets use the same delete request +} + +// rotatingSecretsImpl is a map of all the concrete rotating secrets implementations by provider +// so the Terraform resource can look up the correct implementation based on the resource secret_provider field +var rotatingSecretsImpl = map[Provider]rotatingSecret{ + ProviderAWS: &awsRotatingSecret{}, + ProviderGCP: &gcpRotatingSecret{}, + ProviderMongoDBAtlas: &mongoDBAtlasRotatingSecret{}, + ProviderTwilio: &twilioRotatingSecret{}, +} + +type RotatingSecret struct { + // Shared input fields + ProjectID types.String `tfsdk:"project_id"` + AppName types.String `tfsdk:"app_name"` + SecretProvider types.String `tfsdk:"secret_provider"` + Name types.String `tfsdk:"name"` + IntegrationName types.String `tfsdk:"integration_name"` + RotationPolicyName types.String `tfsdk:"rotation_policy_name"` + + // Provider specific mutually exclusive fields + AWSAccessKeys *awsAccessKeys `tfsdk:"aws_access_keys"` + GCPServiceAccountKey *gcpServiceAccountKey `tfsdk:"gcp_service_account_key"` + MongoDBAtlasUser *mongoDBAtlasUser `tfsdk:"mongodb_atlas_user"` + TwilioAPIKey *twilioAPIKey `tfsdk:"twilio_api_key"` + + // Computed fields + OrganizationID types.String `tfsdk:"organization_id"` + + // Inner API-compatible models derived from the Terraform fields + mongoDBRoles []*secretmodels.Secrets20231128MongoDBRole `tfsdk:"-"` +} + +type awsAccessKeys struct { + IAMUsername types.String `tfsdk:"iam_username"` +} + +type gcpServiceAccountKey struct { + ServiceAccountEmail types.String `tfsdk:"service_account_email"` +} + +type mongoDBAtlasUser struct { + ProjectID types.String `tfsdk:"project_id"` + DatabaseName types.String `tfsdk:"database_name"` + Roles []types.String `tfsdk:"roles"` +} + +type twilioAPIKey struct{} + +var _ resource.Resource = &resourceVaultSecretsRotatingSecret{} +var _ resource.ResourceWithConfigure = &resourceVaultSecretsRotatingSecret{} +var _ resource.ResourceWithModifyPlan = &resourceVaultSecretsRotatingSecret{} + +func NewVaultSecretsRotatingSecretResource() resource.Resource { + return &resourceVaultSecretsRotatingSecret{} +} + +type resourceVaultSecretsRotatingSecret struct { + client *clients.Client +} + +func (r *resourceVaultSecretsRotatingSecret) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vault_secrets_rotating_secret" +} + +func (r *resourceVaultSecretsRotatingSecret) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := map[string]schema.Attribute{ + "rotation_policy_name": schema.StringAttribute{ + Description: "Name of the rotation policy that governs the rotation of the secret.", + Required: true, + }, + "aws_access_keys": schema.SingleNestedAttribute{ + Description: "AWS configuration to manage the access key rotation for the given IAM user. Required if `secret_provider` is `aws`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "iam_username": schema.StringAttribute{ + Description: "AWS IAM username to rotate the access keys for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + Validators: []validator.Object{ + exactlyOneRotatingSecretTypeFieldsValidator, + }, + }, + "gcp_service_account_key": schema.SingleNestedAttribute{ + Description: "GCP configuration to manage the service account key rotation for the given service account. Required if `secret_provider` is `gcp`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "service_account_email": schema.StringAttribute{ + Description: "GCP service account email to impersonate.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + Validators: []validator.Object{ + exactlyOneRotatingSecretTypeFieldsValidator, + }, + }, + "mongodb_atlas_user": schema.SingleNestedAttribute{ + Description: "MongoDB Atlas configuration to manage the user password rotation on the given database. Required if `secret_provider` is `mongodb_atlas`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "MongoDB Atlas project ID to rotate the username and password for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "database_name": schema.StringAttribute{ + Description: "MongoDB Atlas database or cluster name to rotate the username and password for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "roles": schema.ListAttribute{ + Description: "MongoDB Atlas roles to assign to the rotating user.", + Required: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + }, + }, + Validators: []validator.Object{ + exactlyOneRotatingSecretTypeFieldsValidator, + }, + }, + "twilio_api_key": schema.SingleNestedAttribute{ + Description: "Twilio configuration to manage the api key rotation on the given account. Required if `secret_provider` is `twilio`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + // Twilio does not have rotating-secret-specific fields for the moment, this block is to preserve future backwards compatibility + }, + Validators: []validator.Object{ + exactlyOneRotatingSecretTypeFieldsValidator, + }, + }, + } + + maps.Copy(attributes, locationAttributes) + maps.Copy(attributes, managedSecretAttributes) + + resp.Schema = schema.Schema{ + MarkdownDescription: "The Vault Secrets rotating secret resource manages a rotating secret configuration.", + Attributes: attributes, + } +} + +func (r *resourceVaultSecretsRotatingSecret) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + client, ok := req.ProviderData.(*clients.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *clients.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.client = client +} + +func (r *resourceVaultSecretsRotatingSecret) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + modifiers.ModifyPlanForDefaultProjectChange(ctx, r.client.Config.ProjectID, req.State, req.Config, req.Plan, resp) +} + +func (r *resourceVaultSecretsRotatingSecret) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + resp.Diagnostics.Append(decorateOperation[*RotatingSecret](ctx, r.client, &resp.State, req.State.Get, "reading", func(s hvsResource) (any, error) { + secret, ok := s.(*RotatingSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + rotatingSecretImpl, ok := rotatingSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(rotatingSecretsImpl), secret.SecretProvider.ValueString()) + } + return rotatingSecretImpl.read(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsRotatingSecret) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(decorateOperation[*RotatingSecret](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(s hvsResource) (any, error) { + secret, ok := s.(*RotatingSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + rotatingSecretImpl, ok := rotatingSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(rotatingSecretsImpl), secret.SecretProvider.ValueString()) + } + return rotatingSecretImpl.create(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsRotatingSecret) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.Append(decorateOperation[*RotatingSecret](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(s hvsResource) (any, error) { + secret, ok := s.(*RotatingSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + rotatingSecretImpl, ok := rotatingSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(rotatingSecretsImpl), secret.SecretProvider.ValueString()) + } + return rotatingSecretImpl.update(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsRotatingSecret) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.Append(decorateOperation[*RotatingSecret](ctx, r.client, &resp.State, req.State.Get, "deleting", func(s hvsResource) (any, error) { + secret, ok := s.(*RotatingSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + _, err := r.client.VaultSecretsPreview.DeleteAppSecret( + secret_service.NewDeleteAppSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithSecretName(secret.Name.ValueString()), + nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + return nil, nil + })...) +} + +var _ hvsResource = &RotatingSecret{} + +func (s *RotatingSecret) projectID() types.String { + return s.ProjectID +} + +func (s *RotatingSecret) initModel(_ context.Context, orgID, projID string) diag.Diagnostics { + s.OrganizationID = types.StringValue(orgID) + s.ProjectID = types.StringValue(projID) + + if s.MongoDBAtlasUser != nil { + for _, r := range s.MongoDBAtlasUser.Roles { + role := secretmodels.Secrets20231128MongoDBRole{ + DatabaseName: s.MongoDBAtlasUser.DatabaseName.ValueString(), + RoleName: r.ValueString(), + } + s.mongoDBRoles = append(s.mongoDBRoles, &role) + } + } + + return diag.Diagnostics{} +} + +func (s *RotatingSecret) fromModel(_ context.Context, orgID, projID string, _ any) diag.Diagnostics { + diags := diag.Diagnostics{} + + s.OrganizationID = types.StringValue(orgID) + s.ProjectID = types.StringValue(projID) + + return diags +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret_test.go new file mode 100644 index 000000000..b65a0bfbb --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret_test.go @@ -0,0 +1,125 @@ +package vaultsecrets_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" +) + +func TestAccVaultSecretsResourceRotatingSecret(t *testing.T) { + if _, exists := os.LookupEnv("AWS_ROTATING_SECRET_ACC_ENABLED"); exists { + testAccVaultSecretsResourceRotatingSecretAWS(t) + } +} + +func testAccVaultSecretsResourceRotatingSecretAWS(t *testing.T) { + username := checkRequiredEnvVarOrFail(t, "HVS_ROTATING_SECRET_USERNAME") + integrationName := checkRequiredEnvVarOrFail(t, "HVS_ROTATING_SECRET_INTEGRATION_NAME") + appName := checkRequiredEnvVarOrFail(t, "HVS_APP_NAME") + rotationPolicy := "built-in:60-days-2-active" + secretName1 := "acc_tests_aws_1" + secretName2 := "acc_tests_aws_2" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create initial rotating secret + { + Config: awsRotatingSecretConfig(appName, secretName1, integrationName, rotationPolicy, username), + Check: resource.ComposeTestCheckFunc( + awsRotationCheckFunc(appName, secretName1, integrationName, rotationPolicy, username)..., + ), + }, + // Changing an immutable field causes a recreation + { + Config: awsRotatingSecretConfig(appName, secretName2, integrationName, rotationPolicy, username), + Check: resource.ComposeTestCheckFunc( + awsRotationCheckFunc(appName, secretName2, integrationName, rotationPolicy, username)..., + ), + }, + // Deleting the secret out of band causes a recreation + { + PreConfig: func() { + t.Helper() + client := acctest.HCPClients(t) + _, err := client.VaultSecretsPreview.DeleteAppSecret(&secret_service.DeleteAppSecretParams{ + OrganizationID: client.Config.OrganizationID, + ProjectID: client.Config.ProjectID, + AppName: appName, + SecretName: secretName2, + }, nil) + if err != nil { + t.Fatal(err) + } + }, + Config: awsRotatingSecretConfig(appName, secretName2, integrationName, rotationPolicy, username), + Check: resource.ComposeTestCheckFunc( + awsRotationCheckFunc(appName, secretName2, integrationName, rotationPolicy, username)..., + ), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + CheckDestroy: func(_ *terraform.State) error { + if awsRotatingSecretExists(t, appName, secretName1) { + return fmt.Errorf("test rotating secret %s was not destroyed", secretName1) + } + if awsRotatingSecretExists(t, appName, secretName2) { + return fmt.Errorf("test rotating secret %s was not destroyed", secretName2) + } + return nil + }, + }) +} + +func awsRotatingSecretConfig(appName, name, integrationName, policy, iamUsername string) string { + return fmt.Sprintf(` + resource "hcp_vault_secrets_rotating_secret" "acc_test_aws" { + app_name = %q + secret_provider = "aws" + name = %q + integration_name = %q + rotation_policy_name = %q + aws_access_keys = { + iam_username = %q + } + }`, appName, name, integrationName, policy, iamUsername) +} + +func awsRotationCheckFunc(appName, name, integrationName, policy, iamUsername string) []resource.TestCheckFunc { + return []resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("hcp_vault_secrets_rotating_secret.acc_test_aws", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "app_name", appName), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "secret_provider", "aws"), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "name", name), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "integration_name", integrationName), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "rotation_policy_name", policy), + resource.TestCheckResourceAttr("hcp_vault_secrets_rotating_secret.acc_test_aws", "aws_access_keys.iam_username", iamUsername), + } +} + +func awsRotatingSecretExists(t *testing.T, appName, name string) bool { + t.Helper() + + client := acctest.HCPClients(t) + + response, err := client.VaultSecretsPreview.GetAwsIAMUserAccessKeyRotatingSecretConfig( + secret_service.NewGetAwsIAMUserAccessKeyRotatingSecretConfigParamsWithContext(ctx). + WithOrganizationID(client.Config.OrganizationID). + WithProjectID(client.Config.ProjectID). + WithAppName(appName). + WithName(name), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + t.Fatal(err) + } + + return !clients.IsResponseCodeNotFound(err) && response != nil && response.Payload != nil && response.Payload.Config != nil +} diff --git a/internal/provider/vaultsecrets/rotating_secret_aws.go b/internal/provider/vaultsecrets/rotating_secret_aws.go new file mode 100644 index 000000000..f872251f4 --- /dev/null +++ b/internal/provider/vaultsecrets/rotating_secret_aws.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ rotatingSecret = &awsRotatingSecret{} + +type awsRotatingSecret struct{} + +func (s *awsRotatingSecret) read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.GetAwsIAMUserAccessKeyRotatingSecretConfig( + secret_service.NewGetAwsIAMUserAccessKeyRotatingSecretConfigParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithName(secret.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *awsRotatingSecret) create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.AWSAccessKeys == nil { + return nil, fmt.Errorf("missing required field 'aws_access_keys'") + } + + response, err := client.CreateAwsIAMUserAccessKeyRotatingSecret( + secret_service.NewCreateAwsIAMUserAccessKeyRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateAwsIAMUserAccessKeyRotatingSecretBody{ + AwsIamUserAccessKeyParams: &secretmodels.Secrets20231128AwsIAMUserAccessKeyParams{ + Username: secret.AWSAccessKeys.IAMUsername.ValueString(), + }, + IntegrationName: secret.IntegrationName.ValueString(), + Name: secret.Name.ValueString(), + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *awsRotatingSecret) update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.AWSAccessKeys == nil { + return nil, fmt.Errorf("missing required field 'aws_access_keys'") + } + + response, err := client.UpdateAwsIAMUserAccessKeyRotatingSecret( + secret_service.NewUpdateAwsIAMUserAccessKeyRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateAwsIAMUserAccessKeyRotatingSecretBody{ + AwsIamUserAccessKeyParams: &secretmodels.Secrets20231128AwsIAMUserAccessKeyParams{ + Username: secret.AWSAccessKeys.IAMUsername.ValueString(), + }, + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} diff --git a/internal/provider/vaultsecrets/rotating_secret_gcp.go b/internal/provider/vaultsecrets/rotating_secret_gcp.go new file mode 100644 index 000000000..3c7a4cd15 --- /dev/null +++ b/internal/provider/vaultsecrets/rotating_secret_gcp.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ rotatingSecret = &gcpRotatingSecret{} + +type gcpRotatingSecret struct{} + +func (s *gcpRotatingSecret) read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.GetGcpServiceAccountKeyRotatingSecretConfig( + secret_service.NewGetGcpServiceAccountKeyRotatingSecretConfigParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithName(secret.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *gcpRotatingSecret) create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.GCPServiceAccountKey == nil { + return nil, fmt.Errorf("missing required field 'gcp_service_account_key'") + } + + response, err := client.CreateGcpServiceAccountKeyRotatingSecret( + secret_service.NewCreateGcpServiceAccountKeyRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateGcpServiceAccountKeyRotatingSecretBody{ + GcpServiceAccountKeyParams: &secretmodels.Secrets20231128GcpServiceAccountKeyParams{ + ServiceAccountEmail: secret.GCPServiceAccountKey.ServiceAccountEmail.ValueString(), + }, + IntegrationName: secret.IntegrationName.ValueString(), + Name: secret.Name.ValueString(), + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *gcpRotatingSecret) update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.AWSAccessKeys == nil { + return nil, fmt.Errorf("missing required field 'gcp_service_account_key'") + } + + response, err := client.UpdateGcpServiceAccountKeyRotatingSecret( + secret_service.NewUpdateGcpServiceAccountKeyRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateGcpServiceAccountKeyRotatingSecretBody{ + GcpServiceAccountKeyParams: &secretmodels.Secrets20231128GcpServiceAccountKeyParams{ + ServiceAccountEmail: secret.GCPServiceAccountKey.ServiceAccountEmail.ValueString(), + }, + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} diff --git a/internal/provider/vaultsecrets/rotating_secret_mongodb_atlas.go b/internal/provider/vaultsecrets/rotating_secret_mongodb_atlas.go new file mode 100644 index 000000000..fa6f48ccf --- /dev/null +++ b/internal/provider/vaultsecrets/rotating_secret_mongodb_atlas.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ rotatingSecret = &mongoDBAtlasRotatingSecret{} + +type mongoDBAtlasRotatingSecret struct{} + +func (s *mongoDBAtlasRotatingSecret) read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.GetMongoDBAtlasRotatingSecretConfig( + secret_service.NewGetMongoDBAtlasRotatingSecretConfigParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithSecretName(secret.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *mongoDBAtlasRotatingSecret) create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.MongoDBAtlasUser == nil { + return nil, fmt.Errorf("missing required field 'mongodb_atlas_user'") + } + + response, err := client.CreateMongoDBAtlasRotatingSecret( + secret_service.NewCreateMongoDBAtlasRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateMongoDBAtlasRotatingSecretBody{ + IntegrationName: secret.IntegrationName.ValueString(), + MongodbGroupID: secret.MongoDBAtlasUser.ProjectID.ValueString(), // Group ID must be at this level, not in the secret details + RotationPolicyName: secret.RotationPolicyName.ValueString(), + SecretDetails: &secretmodels.Secrets20231128MongoDBAtlasSecretDetails{ + MongodbRoles: secret.mongoDBRoles, + }, + SecretName: secret.Name.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *mongoDBAtlasRotatingSecret) update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.UpdateTwilioRotatingSecret( + secret_service.NewUpdateTwilioRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateTwilioRotatingSecretBody{ + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} diff --git a/internal/provider/vaultsecrets/rotating_secret_twilio.go b/internal/provider/vaultsecrets/rotating_secret_twilio.go new file mode 100644 index 000000000..29eec8900 --- /dev/null +++ b/internal/provider/vaultsecrets/rotating_secret_twilio.go @@ -0,0 +1,72 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets + +import ( + "context" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ rotatingSecret = &twilioRotatingSecret{} + +type twilioRotatingSecret struct{} + +func (s *twilioRotatingSecret) read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.GetTwilioRotatingSecretConfig( + secret_service.NewGetTwilioRotatingSecretConfigParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithSecretName(secret.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *twilioRotatingSecret) create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.CreateTwilioRotatingSecret( + secret_service.NewCreateTwilioRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateTwilioRotatingSecretBody{ + RotationPolicyName: secret.RotationPolicyName.ValueString(), + IntegrationName: secret.IntegrationName.ValueString(), + SecretName: secret.Name.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} + +func (s *twilioRotatingSecret) update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.UpdateTwilioRotatingSecret( + secret_service.NewUpdateTwilioRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateTwilioRotatingSecretBody{ + RotationPolicyName: secret.RotationPolicyName.ValueString(), + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Config, nil +} diff --git a/internal/provider/vaultsecrets/vault_secrets_utils.go b/internal/provider/vaultsecrets/vault_secrets_utils.go index 19f0a1356..443e87a3d 100644 --- a/internal/provider/vaultsecrets/vault_secrets_utils.go +++ b/internal/provider/vaultsecrets/vault_secrets_utils.go @@ -19,8 +19,10 @@ import ( type Provider string const ( - ProviderAWS Provider = "aws" - ProviderGCP Provider = "gcp" + ProviderAWS Provider = "aws" + ProviderGCP Provider = "gcp" + ProviderMongoDBAtlas Provider = "mongodb_atlas" + ProviderTwilio Provider = "twilio" ) func (p Provider) String() string {