diff --git a/.changelog/1097.txt b/.changelog/1097.txt new file mode 100644 index 000000000..239228a6e --- /dev/null +++ b/.changelog/1097.txt @@ -0,0 +1,3 @@ +```release-note:feature +add vault_secrets_dynamic_secret resource +``` \ No newline at end of file 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_dynamic_secret.md b/docs/resources/vault_secrets_dynamic_secret.md new file mode 100644 index 000000000..394ea16e8 --- /dev/null +++ b/docs/resources/vault_secrets_dynamic_secret.md @@ -0,0 +1,73 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "hcp_vault_secrets_dynamic_secret Resource - terraform-provider-hcp" +subcategory: "" +description: |- + The Vault Secrets dynamic secret resource manages a dynamic secret configuration. +--- + +# hcp_vault_secrets_dynamic_secret (Resource) + +The Vault Secrets dynamic secret resource manages a dynamic secret configuration. + +## Example Usage + +```terraform +resource "hcp_vault_secrets_dynamic_secret" "example_aws" { + app_name = "my-app-1" + secret_provider = "aws" + name = "my_aws_1" + integration_name = "my-integration-1" + default_ttl = "900s" + aws_assume_role = { + iam_role_arn = "arn:aws:iam:::role/" + } +} + +resource "hcp_vault_secrets_dynamic_secret" "example_gcp" { + app_name = "my-app-1" + secret_provider = "gcp" + name = "my_gcp_1" + integration_name = "my-integration-1" + default_ttl = "900s" + gcp_impersonate_service_account = { + service_account_email = "@.iam.gserviceaccount.com" + } +} +``` + + +## 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. +- `secret_provider` (String) The third party platform the dynamic credentials give access to. One of `aws` or `gcp`. + +### Optional + +- `aws_assume_role` (Attributes) AWS configuration to generate dynamic credentials by assuming an IAM role. Required if `secret_provider` is `aws`. (see [below for nested schema](#nestedatt--aws_assume_role)) +- `default_ttl` (String) TTL the generated credentials will be valid for. +- `gcp_impersonate_service_account` (Attributes) GCP configuration to generate dynamic credentials by impersonating a service account. Required if `secret_provider` is `gcp`. (see [below for nested schema](#nestedatt--gcp_impersonate_service_account)) +- `project_id` (String) HCP project ID that owns the HCP Vault Secrets integration. Inferred from the provider configuration if omitted. + +### Read-Only + +- `organization_id` (String) HCP organization ID that owns the HCP Vault Secrets integration. + + +### Nested Schema for `aws_assume_role` + +Required: + +- `iam_role_arn` (String) AWS IAM role ARN to assume when generating credentials. + + + +### Nested Schema for `gcp_impersonate_service_account` + +Required: + +- `service_account_email` (String) GCP service account email to impersonate. 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_dynamic_secret/resource.tf b/examples/resources/hcp_vault_secrets_dynamic_secret/resource.tf new file mode 100644 index 000000000..5e96d0e5b --- /dev/null +++ b/examples/resources/hcp_vault_secrets_dynamic_secret/resource.tf @@ -0,0 +1,21 @@ +resource "hcp_vault_secrets_dynamic_secret" "example_aws" { + app_name = "my-app-1" + secret_provider = "aws" + name = "my_aws_1" + integration_name = "my-integration-1" + default_ttl = "900s" + aws_assume_role = { + iam_role_arn = "arn:aws:iam:::role/" + } +} + +resource "hcp_vault_secrets_dynamic_secret" "example_gcp" { + app_name = "my-app-1" + secret_provider = "gcp" + name = "my_gcp_1" + integration_name = "my-integration-1" + default_ttl = "900s" + gcp_impersonate_service_account = { + service_account_email = "@.iam.gserviceaccount.com" + } +} \ No newline at end of file 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/go.mod b/go.mod index e638c286d..ade70bd77 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/hcp-sdk-go v0.110.0 + github.com/hashicorp/hcp-sdk-go v0.113.0 github.com/hashicorp/terraform-plugin-docs v0.19.4 github.com/hashicorp/terraform-plugin-framework v1.5.0 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 diff --git a/go.sum b/go.sum index fdea8e90f..522c4345b 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ github.com/hashicorp/hc-install v0.7.0 h1:Uu9edVqjKQxxuD28mR5TikkKDd/p55S8vzPC16 github.com/hashicorp/hc-install v0.7.0/go.mod h1:ELmmzZlGnEcqoUMKUuykHaPCIR1sYLYX+KSggWSKZuA= github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= -github.com/hashicorp/hcp-sdk-go v0.110.0 h1:eaDO6XoEb0H+g00Ka3C+ZbRibhwWyA2YNmv48xFCL2w= -github.com/hashicorp/hcp-sdk-go v0.110.0/go.mod h1:vQ4fzdL1AmhIAbCw+4zmFe5Hbpajj3NvRWkJoVuxmAk= +github.com/hashicorp/hcp-sdk-go v0.113.0 h1:JuA6mZosEezE550lNMEq43VLUCzVc+/jPmPC1Nd39Gk= +github.com/hashicorp/hcp-sdk-go v0.113.0/go.mod h1:vQ4fzdL1AmhIAbCw+4zmFe5Hbpajj3NvRWkJoVuxmAk= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 776653de4..1e8a2a510 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -157,6 +157,8 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res vaultsecrets.NewVaultSecretsIntegrationGCPResource, vaultsecrets.NewVaultSecretsIntegrationMongoDBAtlasResource, vaultsecrets.NewVaultSecretsIntegrationTwilioResource, + vaultsecrets.NewVaultSecretsDynamicSecretResource, + vaultsecrets.NewVaultSecretsRotatingSecretResource, // IAM iam.NewServicePrincipalResource, iam.NewServicePrincipalKeyResource, diff --git a/internal/provider/vaultsecrets/data_source_vault_secrets_rotating_secret_test.go b/internal/provider/vaultsecrets/data_source_vault_secrets_rotating_secret_test.go index 27e5d39b1..a2f9c1a5c 100644 --- a/internal/provider/vaultsecrets/data_source_vault_secrets_rotating_secret_test.go +++ b/internal/provider/vaultsecrets/data_source_vault_secrets_rotating_secret_test.go @@ -66,10 +66,10 @@ func TestAcc_dataSourceVaultSecretsRotatingSecret(t *testing.T) { } reqBody := secretmodels.SecretServiceCreateMongoDBAtlasRotatingSecretBody{ - SecretName: testSecretName, - RotationIntegrationName: testIntegrationName, - RotationPolicyName: "built-in:30-days-2-active", - MongodbGroupID: mongodbAtlasGroupID, + SecretName: testSecretName, + IntegrationName: testIntegrationName, + RotationPolicyName: "built-in:30-days-2-active", + MongodbGroupID: mongodbAtlasGroupID, MongodbRoles: []*secretmodels.Secrets20231128MongoDBRole{ { DatabaseName: mongodbAtlasDBName, diff --git a/internal/provider/vaultsecrets/dynamic_secret_aws.go b/internal/provider/vaultsecrets/dynamic_secret_aws.go new file mode 100644 index 000000000..04ea5b44a --- /dev/null +++ b/internal/provider/vaultsecrets/dynamic_secret_aws.go @@ -0,0 +1,88 @@ +// 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 _ dynamicSecret = &awsDynamicSecret{} + +type awsDynamicSecret struct{} + +func (s *awsDynamicSecret) read(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + response, err := client.GetAwsDynamicSecret( + secret_service.NewGetAwsDynamicSecretParamsWithContext(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.Secret, nil +} + +func (s *awsDynamicSecret) create(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + if secret.AWSAssumeRole == nil { + return nil, fmt.Errorf("missing required field 'aws_assume_role'") + } + + response, err := client.CreateAwsDynamicSecret( + secret_service.NewCreateAwsDynamicSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateAwsDynamicSecretBody{ + DefaultTTL: secret.DefaultTTL.ValueString(), + IntegrationName: secret.IntegrationName.ValueString(), + Name: secret.Name.ValueString(), + AssumeRole: &secretmodels.Secrets20231128AssumeRoleRequest{ + RoleArn: secret.AWSAssumeRole.IAMRoleARN.ValueString(), + }, + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Secret, nil +} + +func (s *awsDynamicSecret) update(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + if secret.AWSAssumeRole == nil { + return nil, fmt.Errorf("missing required field 'aws_assume_role'") + } + + response, err := client.UpdateAwsDynamicSecret( + secret_service.NewUpdateAwsDynamicSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithName(secret.Name.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateAwsDynamicSecretBody{ + DefaultTTL: secret.DefaultTTL.ValueString(), + AssumeRole: &secretmodels.Secrets20231128AssumeRoleRequest{ + RoleArn: secret.AWSAssumeRole.IAMRoleARN.ValueString(), + }, + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Secret, nil +} diff --git a/internal/provider/vaultsecrets/dynamic_secret_gcp.go b/internal/provider/vaultsecrets/dynamic_secret_gcp.go new file mode 100644 index 000000000..89e10db23 --- /dev/null +++ b/internal/provider/vaultsecrets/dynamic_secret_gcp.go @@ -0,0 +1,88 @@ +// 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 _ dynamicSecret = &gcpDynamicSecret{} + +type gcpDynamicSecret struct{} + +func (s *gcpDynamicSecret) read(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + response, err := client.GetGcpDynamicSecret( + secret_service.NewGetGcpDynamicSecretParamsWithContext(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.Secret, nil +} + +func (s *gcpDynamicSecret) create(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + if secret.GCPImpersonateServiceAccount == nil { + return nil, fmt.Errorf("missing required field 'gcp_impersonate_service_account'") + } + + response, err := client.CreateGcpDynamicSecret( + secret_service.NewCreateGcpDynamicSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateGcpDynamicSecretBody{ + DefaultTTL: secret.DefaultTTL.ValueString(), + IntegrationName: secret.IntegrationName.ValueString(), + Name: secret.Name.ValueString(), + ServiceAccountImpersonation: &secretmodels.Secrets20231128ServiceAccountImpersonationRequest{ + ServiceAccountEmail: secret.GCPImpersonateServiceAccount.ServiceAccountEmail.ValueString(), + }, + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Secret, nil +} + +func (s *gcpDynamicSecret) update(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) { + if secret.GCPImpersonateServiceAccount == nil { + return nil, fmt.Errorf("missing required field 'gcp_impersonate_service_account'") + } + + response, err := client.UpdateGcpDynamicSecret( + secret_service.NewUpdateGcpDynamicSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithName(secret.Name.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateGcpDynamicSecretBody{ + DefaultTTL: secret.DefaultTTL.ValueString(), + ServiceAccountImpersonation: &secretmodels.Secrets20231128ServiceAccountImpersonationRequest{ + ServiceAccountEmail: secret.GCPImpersonateServiceAccount.ServiceAccountEmail.ValueString(), + }, + }), + nil) + if err != nil { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Secret, nil +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_dynamic_secret.go b/internal/provider/vaultsecrets/resource_vault_secrets_dynamic_secret.go new file mode 100644 index 000000000..2c7f49ee5 --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_dynamic_secret.go @@ -0,0 +1,239 @@ +// 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" + "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/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" +) + +const ( + unsupportedProviderErrorFmt = "unsupported provider, expected one of %s, got '%s'" + invalidSecretTypeErrorFmt = "invalid secret type, expected *DynamicSecret, got: '%T', this is a bug on the provider" +) + +var exactlyOneDynamicSecretTypeFieldsValidator = objectvalidator.ExactlyOneOf( + path.Expressions{ + path.MatchRoot("aws_assume_role"), + path.MatchRoot("gcp_impersonate_service_account"), + }..., +) + +// dynamicSecret encapsulates the HVS provider-specific logic so the Terraform resource can focus on the Terraform logic +type dynamicSecret interface { + read(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) + create(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) + update(ctx context.Context, client secret_service.ClientService, secret *DynamicSecret) (any, error) + // delete not necessary on the interface, all secrets use the same delete request +} + +// dynamicSecretsImpl is a map of all the concrete dynamic secrets implementations by provider +// so the Terraform resource can look up the correct implementation based on the resource secret_provider field +var dynamicSecretsImpl = map[Provider]dynamicSecret{ + ProviderAWS: &awsDynamicSecret{}, + ProviderGCP: &gcpDynamicSecret{}, +} + +type DynamicSecret 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"` + DefaultTTL types.String `tfsdk:"default_ttl"` + + // Provider specific mutually exclusive fields + AWSAssumeRole *awsAssumeRole `tfsdk:"aws_assume_role"` + GCPImpersonateServiceAccount *gcpImpersonateServiceAccount `tfsdk:"gcp_impersonate_service_account"` + + // Computed fields + OrganizationID types.String `tfsdk:"organization_id"` +} + +type awsAssumeRole struct { + IAMRoleARN types.String `tfsdk:"iam_role_arn"` +} + +type gcpImpersonateServiceAccount struct { + ServiceAccountEmail types.String `tfsdk:"service_account_email"` +} + +var _ resource.Resource = &resourceVaultSecretsDynamicSecret{} +var _ resource.ResourceWithConfigure = &resourceVaultSecretsDynamicSecret{} +var _ resource.ResourceWithModifyPlan = &resourceVaultSecretsDynamicSecret{} + +func NewVaultSecretsDynamicSecretResource() resource.Resource { + return &resourceVaultSecretsDynamicSecret{} +} + +type resourceVaultSecretsDynamicSecret struct { + client *clients.Client +} + +func (r *resourceVaultSecretsDynamicSecret) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vault_secrets_dynamic_secret" +} + +func (r *resourceVaultSecretsDynamicSecret) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := map[string]schema.Attribute{ + "default_ttl": schema.StringAttribute{ + Description: "TTL the generated credentials will be valid for.", + Optional: true, + }, + "aws_assume_role": schema.SingleNestedAttribute{ + Description: "AWS configuration to generate dynamic credentials by assuming an IAM role. Required if `secret_provider` is `aws`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "iam_role_arn": schema.StringAttribute{ + Description: "AWS IAM role ARN to assume when generating credentials.", + Required: true, + }, + }, + Validators: []validator.Object{ + exactlyOneDynamicSecretTypeFieldsValidator, + }, + }, + "gcp_impersonate_service_account": schema.SingleNestedAttribute{ + Description: "GCP configuration to generate dynamic credentials by impersonating a 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, + }, + }, + Validators: []validator.Object{ + exactlyOneDynamicSecretTypeFieldsValidator, + }, + }, + } + + maps.Copy(attributes, locationAttributes) + maps.Copy(attributes, managedSecretAttributes) + + resp.Schema = schema.Schema{ + MarkdownDescription: "The Vault Secrets dynamic secret resource manages a dynamic secret configuration.", + Attributes: attributes, + } +} + +func (r *resourceVaultSecretsDynamicSecret) 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 *resourceVaultSecretsDynamicSecret) 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 *resourceVaultSecretsDynamicSecret) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + resp.Diagnostics.Append(decorateOperation[*DynamicSecret](ctx, r.client, &resp.State, req.State.Get, "reading", func(s hvsResource) (any, error) { + secret, ok := s.(*DynamicSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + dynamicSecretImpl, ok := dynamicSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(dynamicSecretsImpl), secret.SecretProvider.ValueString()) + } + return dynamicSecretImpl.read(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsDynamicSecret) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(decorateOperation[*DynamicSecret](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(s hvsResource) (any, error) { + secret, ok := s.(*DynamicSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + dynamicSecretImpl, ok := dynamicSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(dynamicSecretsImpl), secret.SecretProvider.ValueString()) + } + return dynamicSecretImpl.create(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsDynamicSecret) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.Append(decorateOperation[*DynamicSecret](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(s hvsResource) (any, error) { + secret, ok := s.(*DynamicSecret) + if !ok { + return nil, fmt.Errorf(invalidSecretTypeErrorFmt, s) + } + + dynamicSecretImpl, ok := dynamicSecretsImpl[Provider(secret.SecretProvider.ValueString())] + if !ok { + return nil, fmt.Errorf(unsupportedProviderErrorFmt, maps.Keys(dynamicSecretsImpl), secret.SecretProvider.ValueString()) + } + return dynamicSecretImpl.update(ctx, r.client.VaultSecretsPreview, secret) + })...) +} + +func (r *resourceVaultSecretsDynamicSecret) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.Append(decorateOperation[*DynamicSecret](ctx, r.client, &resp.State, req.State.Get, "deleting", func(s hvsResource) (any, error) { + secret, ok := s.(*DynamicSecret) + 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 = &DynamicSecret{} + +func (s *DynamicSecret) projectID() types.String { + return s.ProjectID +} + +func (s *DynamicSecret) initModel(_ context.Context, orgID, projID string) diag.Diagnostics { + s.OrganizationID = types.StringValue(orgID) + s.ProjectID = types.StringValue(projID) + + return diag.Diagnostics{} +} + +func (s *DynamicSecret) 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_dynamic_secret_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_dynamic_secret_test.go new file mode 100644 index 000000000..8f5cea0ad --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_dynamic_secret_test.go @@ -0,0 +1,249 @@ +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 TestAccVaultSecretsResourceDynamicSecret(t *testing.T) { + if _, exists := os.LookupEnv("AWS_DYNAMIC_SECRET_ACC_ENABLED"); exists { + testAccVaultSecretsResourceDynamicSecretAWS(t) + } + + if _, exists := os.LookupEnv("GCP_DYNAMIC_SECRET_ACC_ENABLED"); exists { + testAccVaultSecretsResourceDynamicSecretGCP(t) + } +} + +func testAccVaultSecretsResourceDynamicSecretAWS(t *testing.T) { + roleARN := checkRequiredEnvVarOrFail(t, "HVS_DYNAMIC_SECRET_ROLE_ARN") + integrationName := checkRequiredEnvVarOrFail(t, "HVS_DYNAMIC_SECRET_INTEGRATION_NAME") + appName := checkRequiredEnvVarOrFail(t, "HVS_APP_NAME") + ttl1 := "901s" + ttl2 := "902s" + 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 dynamic secret + { + Config: awsDynamicSecretConfig(appName, secretName1, integrationName, ttl1, roleARN), + Check: resource.ComposeTestCheckFunc( + awsCheckFunc(appName, secretName1, integrationName, ttl1, roleARN)..., + ), + }, + // Changing an immutable field causes a recreation + { + Config: awsDynamicSecretConfig(appName, secretName2, integrationName, ttl1, roleARN), + Check: resource.ComposeTestCheckFunc( + awsCheckFunc(appName, secretName2, integrationName, ttl1, roleARN)..., + ), + }, + // Changing mutable fields causes an update + { + Config: awsDynamicSecretConfig(appName, secretName2, integrationName, ttl2, roleARN), + Check: resource.ComposeTestCheckFunc( + awsCheckFunc(appName, secretName2, integrationName, ttl2, roleARN)..., + ), + }, + // 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: awsDynamicSecretConfig(appName, secretName2, integrationName, ttl2, roleARN), + Check: resource.ComposeTestCheckFunc( + awsCheckFunc(appName, secretName2, integrationName, ttl2, roleARN)..., + ), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + CheckDestroy: func(_ *terraform.State) error { + if awsDynamicSecretExists(t, secretName1) { + return fmt.Errorf("test dynamic secret %s was not destroyed", secretName1) + } + if awsDynamicSecretExists(t, secretName2) { + return fmt.Errorf("test dynamic secret %s was not destroyed", secretName2) + } + return nil + }, + }) +} + +func awsDynamicSecretConfig(appName, name, integrationName, ttl, iamRoleARN string) string { + return fmt.Sprintf(` + resource "hcp_vault_secrets_dynamic_secret" "acc_test_aws" { + app_name = %q + secret_provider = "aws" + name = %q + integration_name = %q + default_ttl = %q + aws_assume_role = { + iam_role_arn = %q + } + }`, appName, name, integrationName, ttl, iamRoleARN) +} + +func awsCheckFunc(appName, name, integrationName, ttl, iamRoleARN string) []resource.TestCheckFunc { + return []resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("hcp_vault_secrets_dynamic_secret.acc_test_aws", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "app_name", appName), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "secret_provider", "aws"), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "name", name), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "integration_name", integrationName), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "default_ttl", ttl), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_aws", "aws_assume_role.iam_role_arn", iamRoleARN), + } +} + +func awsDynamicSecretExists(t *testing.T, name string) bool { + t.Helper() + + client := acctest.HCPClients(t) + + response, err := client.VaultSecretsPreview.GetAwsDynamicSecret( + secret_service.NewGetAwsDynamicSecretParamsWithContext(ctx). + WithOrganizationID(client.Config.OrganizationID). + WithProjectID(client.Config.ProjectID). + WithName(name), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + t.Fatal(err) + } + + return !clients.IsResponseCodeNotFound(err) && response != nil && response.Payload != nil && response.Payload.Secret != nil +} + +func testAccVaultSecretsResourceDynamicSecretGCP(t *testing.T) { + serviceAccountEmail := checkRequiredEnvVarOrFail(t, "HVS_DYNAMIC_SECRET_SERVICE_ACCOUNT_EMAIL") + integrationName := checkRequiredEnvVarOrFail(t, "HVS_DYNAMIC_SECRET_INTEGRATION_NAME") + appName := checkRequiredEnvVarOrFail(t, "HVS_APP_NAME") + ttl1 := "901s" + ttl2 := "902s" + secretName1 := "acc_tests_gcp_1" + secretName2 := "acc_tests_gcp_2" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create initial dynamic secret + { + Config: gcpDynamicSecretConfig(appName, secretName1, integrationName, ttl1, serviceAccountEmail), + Check: resource.ComposeTestCheckFunc( + gcpCheckFunc(appName, secretName1, integrationName, ttl1, serviceAccountEmail)..., + ), + }, + // Changing an immutable field causes a recreation + { + Config: gcpDynamicSecretConfig(appName, secretName2, integrationName, ttl1, serviceAccountEmail), + Check: resource.ComposeTestCheckFunc( + gcpCheckFunc(appName, secretName2, integrationName, ttl1, serviceAccountEmail)..., + ), + }, + // Changing mutable fields causes an update + { + Config: gcpDynamicSecretConfig(appName, secretName2, integrationName, ttl2, serviceAccountEmail), + Check: resource.ComposeTestCheckFunc( + gcpCheckFunc(appName, secretName2, integrationName, ttl2, serviceAccountEmail)..., + ), + }, + // 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: gcpDynamicSecretConfig(appName, secretName2, integrationName, ttl2, serviceAccountEmail), + Check: resource.ComposeTestCheckFunc( + gcpCheckFunc(appName, secretName2, integrationName, ttl2, serviceAccountEmail)..., + ), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + CheckDestroy: func(_ *terraform.State) error { + if gcpDynamicSecretExists(t, secretName1) { + return fmt.Errorf("test dynamic secret %s was not destroyed", secretName1) + } + if gcpDynamicSecretExists(t, secretName2) { + return fmt.Errorf("test dynamic secret %s was not destroyed", secretName2) + } + return nil + }, + }) +} + +func gcpDynamicSecretConfig(appName, name, integrationName, ttl, serviceAccountEmail string) string { + return fmt.Sprintf(` + resource "hcp_vault_secrets_dynamic_secret" "acc_test_gcp" { + app_name = %q + secret_provider = "gcp" + name = %q + integration_name = %q + default_ttl = %q + gcp_impersonate_service_account = { + service_account_email = %q + } + }`, appName, name, integrationName, ttl, serviceAccountEmail) +} + +func gcpCheckFunc(appName, name, integrationName, ttl, serviceAccountEmail string) []resource.TestCheckFunc { + return []resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "app_name", appName), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "secret_provider", "gcp"), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "name", name), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "integration_name", integrationName), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "default_ttl", ttl), + resource.TestCheckResourceAttr("hcp_vault_secrets_dynamic_secret.acc_test_gcp", "gcp_impersonate_service_account.service_account_email", serviceAccountEmail), + } +} + +func gcpDynamicSecretExists(t *testing.T, name string) bool { + t.Helper() + + client := acctest.HCPClients(t) + + response, err := client.VaultSecretsPreview.GetGcpDynamicSecret( + secret_service.NewGetGcpDynamicSecretParamsWithContext(ctx). + WithOrganizationID(client.Config.OrganizationID). + WithProjectID(client.Config.ProjectID). + WithName(name), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + t.Fatal(err) + } + + return !clients.IsResponseCodeNotFound(err) && response != nil && response.Payload != nil && response.Payload.Secret != nil +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_aws.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_aws.go index 8011e46ef..f4dda92be 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration_aws.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_aws.go @@ -113,6 +113,7 @@ func (r *resourceVaultSecretsIntegrationAWS) Schema(_ context.Context, _ resourc }, } + maps.Copy(attributes, locationAttributes) maps.Copy(attributes, sharedIntegrationAttributes) resp.Schema = schema.Schema{ @@ -141,7 +142,7 @@ func (r *resourceVaultSecretsIntegrationAWS) ModifyPlan(ctx context.Context, req } func (r *resourceVaultSecretsIntegrationAWS) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.State.Get, "reading", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationAWS) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationAWS, got: %T, this is a bug on the provider", i) @@ -163,7 +164,7 @@ func (r *resourceVaultSecretsIntegrationAWS) Read(ctx context.Context, req resou } func (r *resourceVaultSecretsIntegrationAWS) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationAWS) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationAWS, got: %T, this is a bug on the provider", i) @@ -190,7 +191,7 @@ func (r *resourceVaultSecretsIntegrationAWS) Create(ctx context.Context, req res } func (r *resourceVaultSecretsIntegrationAWS) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationAWS) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationAWS, got: %T, this is a bug on the provider", i) @@ -217,7 +218,7 @@ func (r *resourceVaultSecretsIntegrationAWS) Update(ctx context.Context, req res } func (r *resourceVaultSecretsIntegrationAWS) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAWS](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationAWS) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationAWS, got: %T, this is a bug on the provider", i) @@ -243,7 +244,7 @@ func (r *resourceVaultSecretsIntegrationAWS) ImportState(ctx context.Context, re resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) } -var _ integration = &IntegrationAWS{} +var _ hvsResource = &IntegrationAWS{} func (i *IntegrationAWS) projectID() types.String { return i.ProjectID diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_gcp.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_gcp.go index 95c864407..07526c749 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration_gcp.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_gcp.go @@ -117,6 +117,7 @@ func (r *resourceVaultSecretsIntegrationGCP) Schema(_ context.Context, _ resourc }, } + maps.Copy(attributes, locationAttributes) maps.Copy(attributes, sharedIntegrationAttributes) resp.Schema = schema.Schema{ @@ -145,7 +146,7 @@ func (r *resourceVaultSecretsIntegrationGCP) ModifyPlan(ctx context.Context, req } func (r *resourceVaultSecretsIntegrationGCP) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.State.Get, "reading", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationGCP) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationGCP, got: %T, this is a bug on the provider", i) @@ -167,7 +168,7 @@ func (r *resourceVaultSecretsIntegrationGCP) Read(ctx context.Context, req resou } func (r *resourceVaultSecretsIntegrationGCP) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationGCP) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationGCP, got: %T, this is a bug on the provider", i) @@ -194,7 +195,7 @@ func (r *resourceVaultSecretsIntegrationGCP) Create(ctx context.Context, req res } func (r *resourceVaultSecretsIntegrationGCP) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationGCP) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationGCP, got: %T, this is a bug on the provider", i) @@ -221,7 +222,7 @@ func (r *resourceVaultSecretsIntegrationGCP) Update(ctx context.Context, req res } func (r *resourceVaultSecretsIntegrationGCP) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationGCP](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationGCP) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationGCP, got: %T, this is a bug on the provider", i) @@ -247,7 +248,7 @@ func (r *resourceVaultSecretsIntegrationGCP) ImportState(ctx context.Context, re resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) } -var _ integration = &IntegrationGCP{} +var _ hvsResource = &IntegrationGCP{} func (i *IntegrationGCP) projectID() types.String { return i.ProjectID diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_mongodbatlas.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_mongodbatlas.go index 8e2fcf1ec..5549be618 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration_mongodbatlas.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_mongodbatlas.go @@ -80,6 +80,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Schema(_ context.Context, }, } + maps.Copy(attributes, locationAttributes) maps.Copy(attributes, sharedIntegrationAttributes) resp.Schema = schema.Schema{ @@ -108,7 +109,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) ModifyPlan(ctx context.Con } func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.State.Get, "reading", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationMongoDBAtlas) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationMongoDBAtlas, got: %T, this is a bug on the provider", i) @@ -130,7 +131,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Read(ctx context.Context, } func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationMongoDBAtlas) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationMongoDBAtlas, got: %T, this is a bug on the provider", i) @@ -156,7 +157,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Create(ctx context.Context } func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationMongoDBAtlas) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationMongoDBAtlas, got: %T, this is a bug on the provider", i) @@ -182,7 +183,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Update(ctx context.Context } func (r *resourceVaultSecretsIntegrationMongoDBAtlas) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationMongoDBAtlas](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationMongoDBAtlas) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationMongoDBAtlas, got: %T, this is a bug on the provider", i) @@ -208,7 +209,7 @@ func (r *resourceVaultSecretsIntegrationMongoDBAtlas) ImportState(ctx context.Co resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) } -var _ integration = &IntegrationMongoDBAtlas{} +var _ hvsResource = &IntegrationMongoDBAtlas{} func (i *IntegrationMongoDBAtlas) projectID() types.String { return i.ProjectID diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_twilio.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_twilio.go index 10ee59e9a..26b728479 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration_twilio.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_twilio.go @@ -85,6 +85,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) Schema(_ context.Context, _ reso }, } + maps.Copy(attributes, locationAttributes) maps.Copy(attributes, sharedIntegrationAttributes) resp.Schema = schema.Schema{ @@ -113,7 +114,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) ModifyPlan(ctx context.Context, } func (r *resourceVaultSecretsIntegrationTwilio) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.State.Get, "reading", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationTwilio) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationTwilio, got: %T, this is a bug on the provider", i) @@ -135,7 +136,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) Read(ctx context.Context, req re } func (r *resourceVaultSecretsIntegrationTwilio) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationTwilio) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationTwilio, got: %T, this is a bug on the provider", i) @@ -161,7 +162,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) Create(ctx context.Context, req } func (r *resourceVaultSecretsIntegrationTwilio) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationTwilio) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationTwilio, got: %T, this is a bug on the provider", i) @@ -187,7 +188,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) Update(ctx context.Context, req } func (r *resourceVaultSecretsIntegrationTwilio) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i integration) (any, error) { + resp.Diagnostics.Append(decorateOperation[*IntegrationTwilio](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { integration, ok := i.(*IntegrationTwilio) if !ok { return nil, fmt.Errorf("invalid integration type, expected *IntegrationTwilio, got: %T, this is a bug on the provider", i) @@ -213,7 +214,7 @@ func (r *resourceVaultSecretsIntegrationTwilio) ImportState(ctx context.Context, resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) } -var _ integration = &IntegrationTwilio{} +var _ hvsResource = &IntegrationTwilio{} func (i *IntegrationTwilio) projectID() types.String { return i.ProjectID 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/integration_utils.go b/internal/provider/vaultsecrets/vault_secrets_utils.go similarity index 61% rename from internal/provider/vaultsecrets/integration_utils.go rename to internal/provider/vaultsecrets/vault_secrets_utils.go index 2431378c0..443e87a3d 100644 --- a/internal/provider/vaultsecrets/integration_utils.go +++ b/internal/provider/vaultsecrets/vault_secrets_utils.go @@ -16,8 +16,30 @@ import ( "github.com/hashicorp/terraform-provider-hcp/internal/clients" ) -// sharedIntegrationAttributes are the attributes shared between all the Vault Secrets integrations -var sharedIntegrationAttributes = map[string]schema.Attribute{ +type Provider string + +const ( + ProviderAWS Provider = "aws" + ProviderGCP Provider = "gcp" + ProviderMongoDBAtlas Provider = "mongodb_atlas" + ProviderTwilio Provider = "twilio" +) + +func (p Provider) String() string { + return string(p) +} + +var ( + secretNameValidator = stringvalidator.RegexMatches(regexp.MustCompile(`^[_\da-zA-Z]{3,36}$`), + "must contain only letters, numbers or underscores", + ) + slugValidator = stringvalidator.RegexMatches(regexp.MustCompile(`^[-\da-zA-Z]{3,36}$`), + "must contain only letters, numbers or hyphens", + ) +) + +// locationAttributes are attribute shared between all Vault Secrets resources +var locationAttributes = map[string]schema.Attribute{ "organization_id": schema.StringAttribute{ Description: "HCP organization ID that owns the HCP Vault Secrets integration.", Computed: true, @@ -31,6 +53,10 @@ var sharedIntegrationAttributes = map[string]schema.Attribute{ stringplanmodifier.UseStateForUnknown(), }, }, +} + +// sharedIntegrationAttributes are the attributes shared between all the Vault Secrets integrations +var sharedIntegrationAttributes = map[string]schema.Attribute{ "resource_id": schema.StringAttribute{ Description: "Resource ID used to uniquely identify the integration instance on the HCP platform.", Computed: true, @@ -46,9 +72,7 @@ var sharedIntegrationAttributes = map[string]schema.Attribute{ stringplanmodifier.RequiresReplace(), }, Validators: []validator.String{ - stringvalidator.RegexMatches(regexp.MustCompile(`^[-\da-zA-Z]{3,36}$`), - "must contain only letters, numbers or hyphens", - ), + slugValidator, }, }, "capabilities": schema.SetAttribute{ @@ -58,14 +82,55 @@ var sharedIntegrationAttributes = map[string]schema.Attribute{ }, } +// managedSecretAttributes are attribute shared between secrets with a managed lifecycle, such as Dynamic or Rotating +var managedSecretAttributes = map[string]schema.Attribute{ + "app_name": schema.StringAttribute{ + Description: "Vault Secrets application name that owns the secret.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + slugValidator, + }, + }, + "secret_provider": schema.StringAttribute{ + Description: "The third party platform the dynamic credentials give access to. One of `aws` or `gcp`.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "The Vault Secrets secret name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + secretNameValidator, + }, + }, + "integration_name": schema.StringAttribute{ + Description: "The Vault Secrets integration name with the capability to manage the secret's lifecycle.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + slugValidator, + }, + }, +} + // resourceFunc is used to get the appropriate Terraform Vault Secrets integration representation either from the plan (create, update) or the state (read, delete) type resourceFunc func(ctx context.Context, target interface{}) diag.Diagnostics // operationFunc performs the desired operation (read, create, update, delete) on the Vault Secrets backend -type operationFunc func(i integration) (any, error) +type operationFunc func(r hvsResource) (any, error) -// integration abstracts the conversion between Terraform and HVS domains -type integration interface { +// hvsResource abstracts the conversion between Terraform and HVS domains +type hvsResource interface { projectID() types.String initModel(ctx context.Context, orgID, projID string) diag.Diagnostics fromModel(ctx context.Context, orgID, projID string, model any) diag.Diagnostics @@ -73,7 +138,7 @@ type integration interface { // decorateOperation abstracts all the conversion between the Terraform and HVS domain, // as well as all the statefile management when performing operations (read, create, update, delete) -func decorateOperation[T integration](ctx context.Context, c *clients.Client, state *tfsdk.State, resourceFunc resourceFunc, operation string, operationFunc operationFunc) diag.Diagnostics { +func decorateOperation[T hvsResource](ctx context.Context, c *clients.Client, state *tfsdk.State, resourceFunc resourceFunc, operation string, operationFunc operationFunc) diag.Diagnostics { diags := diag.Diagnostics{} var concreteIntegration T @@ -90,7 +155,7 @@ func decorateOperation[T integration](ctx context.Context, c *clients.Client, st model, err := operationFunc(concreteIntegration) if err != nil { - diags.AddError(fmt.Sprintf("Error %s Vault Secrets integration", operation), err.Error()) + diags.AddError(fmt.Sprintf("Error %s Vault Secrets resource", operation), err.Error()) return diags } if model == nil {