From 00e266b6adc35258e2d7faaff32c95d84a8d1480 Mon Sep 17 00:00:00 2001 From: murali-partha <137029787+murali-partha@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:58:31 +0530 Subject: [PATCH] Azure Rotating Secrets Changes (#1143) * adding azure integration for rotating secrets changes * adding test file * adding changelog file * updateing validation * fixing test failures * docs * minor changes * Update internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go Co-authored-by: Max Coulombe <109547106+maxcoulombe@users.noreply.github.com> * addressing review comments * addressing review comments * addressing review comments --------- Co-authored-by: Max Coulombe <109547106+maxcoulombe@users.noreply.github.com> --- .changelog/1143.txt | 3 + .../vault_secrets_integration_azure.md | 75 ++++ .../vault_secrets_rotating_secret.md | 22 ++ .../import.sh | 4 + .../resource.tf | 9 + .../resource.tf | 11 + go.mod | 2 +- go.sum | 4 +- internal/provider/provider.go | 1 + ...esource_vault_secrets_integration_azure.go | 359 ++++++++++++++++++ ...ce_vault_secrets_integration_azure_test.go | 190 +++++++++ .../resource_vault_secrets_rotating_secret.go | 42 +- .../vaultsecrets/rotating_secret_azure.go | 89 +++++ .../vaultsecrets/vault_secrets_utils.go | 1 + 14 files changed, 803 insertions(+), 9 deletions(-) create mode 100644 .changelog/1143.txt create mode 100644 docs/resources/vault_secrets_integration_azure.md create mode 100644 examples/resources/hcp_vault_secrets_integration_azure/import.sh create mode 100644 examples/resources/hcp_vault_secrets_integration_azure/resource.tf create mode 100644 internal/provider/vaultsecrets/resource_vault_secrets_integration_azure.go create mode 100644 internal/provider/vaultsecrets/resource_vault_secrets_integration_azure_test.go create mode 100644 internal/provider/vaultsecrets/rotating_secret_azure.go diff --git a/.changelog/1143.txt b/.changelog/1143.txt new file mode 100644 index 000000000..db72e3c80 --- /dev/null +++ b/.changelog/1143.txt @@ -0,0 +1,3 @@ +```release-note:feature +add vault_secrets_integration_azure resource and add support for azure secrets to vault_secrets_rotating_secret resource +``` \ No newline at end of file diff --git a/docs/resources/vault_secrets_integration_azure.md b/docs/resources/vault_secrets_integration_azure.md new file mode 100644 index 000000000..122de295e --- /dev/null +++ b/docs/resources/vault_secrets_integration_azure.md @@ -0,0 +1,75 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "hcp_vault_secrets_integration_azure Resource - terraform-provider-hcp" +subcategory: "" +description: |- + The Vault Secrets Azure integration resource manages an Azure integration. +--- + +# hcp_vault_secrets_integration_azure (Resource) + +The Vault Secrets Azure integration resource manages an Azure integration. + +## Example Usage + +```terraform +resource "hcp_vault_secrets_integration_azure" "example" { + name = "my-azure-1" + capabilities = ["ROTATION"] + client_secret = { + "tenant_id" = "7eb3...", + "client_id" = "9de0...", + "client_secret" = "WZk8..." + } +} +``` + + +## Schema + +### Required + +- `capabilities` (Set of String) Capabilities enabled for the integration. See the Vault Secrets documentation for the list of supported capabilities per provider. +- `name` (String) The Vault Secrets integration name. + +### Optional + +- `client_secret` (Attributes) Azure client secret used to authenticate against the target Azure application. Cannot be used with `federated_workload_identity`. (see [below for nested schema](#nestedatt--client_secret)) +- `federated_workload_identity` (Attributes) (Recommended) Federated identity configuration to authenticate against the target Azure application. Cannot be used with `client_secret`. (see [below for nested schema](#nestedatt--federated_workload_identity)) +- `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. +- `resource_id` (String) Resource ID used to uniquely identify the integration instance on the HCP platform. +- `resource_name` (String) Resource name used to uniquely identify the integration instance on the HCP platform. + + +### Nested Schema for `client_secret` + +Required: + +- `client_id` (String) Azure client ID corresponding to the Azure application. +- `client_secret` (String) Secret value corresponding to the Azure client secret. +- `tenant_id` (String) Azure tenant ID corresponding to the Azure application. + + + +### Nested Schema for `federated_workload_identity` + +Required: + +- `audience` (String) Audience configured on the Azure federated identity credentials to federate access with HCP. +- `client_id` (String) Azure client ID corresponding to the Azure application. +- `tenant_id` (String) Azure tenant ID corresponding to the Azure application. + +## Import + +Import is supported using the following syntax: + +```shell +# Vault Secrets Azure Integration can be imported by specifying the name of the integration +# Note that since the client secret is never returned on the Vault Secrets API, +# the next plan or apply will show a diff for that field. +terraform import hcp_vault_secrets_integration_azure.example my-azure-1 +``` diff --git a/docs/resources/vault_secrets_rotating_secret.md b/docs/resources/vault_secrets_rotating_secret.md index 388d81f8c..4c24966b2 100644 --- a/docs/resources/vault_secrets_rotating_secret.md +++ b/docs/resources/vault_secrets_rotating_secret.md @@ -66,6 +66,18 @@ resource "hcp_vault_secrets_rotating_secret" "example_confluent" { service_account_id = "" } } + +resource "hcp_vault_secrets_rotating_secret" "example_azure" { + app_name = "my-app-1" + secret_provider = "azure" + name = "my_azure_1_secret" + integration_name = "my-azure-1" + rotation_policy_name = "built-in:60-days-2-active" + azure_application_password = { + app_object_id = "" + app_client_id = "" + } +} ``` @@ -82,6 +94,7 @@ resource "hcp_vault_secrets_rotating_secret" "example_confluent" { ### 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)) +- `azure_application_password` (Attributes) Azure configuration to manage the application password rotation for the given application. Required if `secret_provider` is `Azure`. (see [below for nested schema](#nestedatt--azure_application_password)) - `confluent_service_account` (Attributes) Confluent configuration to manage the cloud api key rotation for the given service account. Required if `secret_provider` is `confluent`. (see [below for nested schema](#nestedatt--confluent_service_account)) - `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)) @@ -100,6 +113,15 @@ Required: - `iam_username` (String) AWS IAM username to rotate the access keys for. + +### Nested Schema for `azure_application_password` + +Required: + +- `app_client_id` (String) Application client ID to rotate the application password for. +- `app_object_id` (String) Application object ID to rotate the application password for. + + ### Nested Schema for `confluent_service_account` diff --git a/examples/resources/hcp_vault_secrets_integration_azure/import.sh b/examples/resources/hcp_vault_secrets_integration_azure/import.sh new file mode 100644 index 000000000..ac22c516c --- /dev/null +++ b/examples/resources/hcp_vault_secrets_integration_azure/import.sh @@ -0,0 +1,4 @@ +# Vault Secrets Azure Integration can be imported by specifying the name of the integration +# Note that since the client secret is never returned on the Vault Secrets API, +# the next plan or apply will show a diff for that field. +terraform import hcp_vault_secrets_integration_azure.example my-azure-1 diff --git a/examples/resources/hcp_vault_secrets_integration_azure/resource.tf b/examples/resources/hcp_vault_secrets_integration_azure/resource.tf new file mode 100644 index 000000000..f33fe393f --- /dev/null +++ b/examples/resources/hcp_vault_secrets_integration_azure/resource.tf @@ -0,0 +1,9 @@ +resource "hcp_vault_secrets_integration_azure" "example" { + name = "my-azure-1" + capabilities = ["ROTATION"] + client_secret = { + "tenant_id" = "7eb3...", + "client_id" = "9de0...", + "client_secret" = "WZk8..." + } +} \ 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 index 3aea335ba..ea115d9c4 100644 --- a/examples/resources/hcp_vault_secrets_rotating_secret/resource.tf +++ b/examples/resources/hcp_vault_secrets_rotating_secret/resource.tf @@ -53,3 +53,14 @@ resource "hcp_vault_secrets_rotating_secret" "example_confluent" { } } +resource "hcp_vault_secrets_rotating_secret" "example_azure" { + app_name = "my-app-1" + secret_provider = "azure" + name = "my_azure_1_secret" + integration_name = "my-azure-1" + rotation_policy_name = "built-in:60-days-2-active" + azure_application_password = { + app_object_id = "" + app_client_id = "" + } +} diff --git a/go.mod b/go.mod index 9db7b44b6..1f287ace3 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,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.123.0 + github.com/hashicorp/hcp-sdk-go v0.124.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 93bbb3b81..3d88edefb 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,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.123.0 h1:kUf/kSCVkQ4XXyny8GUyUWjvIIIanGRRkhRmgj2lC+4= -github.com/hashicorp/hcp-sdk-go v0.123.0/go.mod h1:vQ4fzdL1AmhIAbCw+4zmFe5Hbpajj3NvRWkJoVuxmAk= +github.com/hashicorp/hcp-sdk-go v0.124.0 h1:Th4qCAAqlPrC5s2riHnMTsHFIZ5GsFWzK7l2W7vqsN4= +github.com/hashicorp/hcp-sdk-go v0.124.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 e739f2c82..288536ef7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -159,6 +159,7 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res vaultsecrets.NewVaultSecretsIntegrationMongoDBAtlasResource, vaultsecrets.NewVaultSecretsIntegrationTwilioResource, vaultsecrets.NewVaultSecretsIntegrationsConfluentResource, + vaultsecrets.NewVaultSecretsIntegrationAzureResource, vaultsecrets.NewVaultSecretsDynamicSecretResource, vaultsecrets.NewVaultSecretsRotatingSecretResource, // IAM diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure.go new file mode 100644 index 000000000..41aff0019 --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure.go @@ -0,0 +1,359 @@ +// 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/stable/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/models" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/modifiers" + "golang.org/x/exp/maps" +) + +type IntegrationAzure struct { + // Input fields + ProjectID types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + Capabilities types.Set `tfsdk:"capabilities"` + ClientSecret types.Object `tfsdk:"client_secret"` + FederatedWorkloadIdentity types.Object `tfsdk:"federated_workload_identity"` + + // Computed fields + OrganizationID types.String `tfsdk:"organization_id"` + ResourceID types.String `tfsdk:"resource_id"` + ResourceName types.String `tfsdk:"resource_name"` + + // Inner API-compatible models derived from the Terraform fields + capabilities []*secretmodels.Secrets20231128Capability `tfsdk:"-"` + clientSecret *secretmodels.Secrets20231128AzureClientSecretRequest `tfsdk:"-"` + federatedWorkloadIdentity *secretmodels.Secrets20231128AzureFederatedWorkloadIdentityRequest `tfsdk:"-"` +} + +// Helper structs to help populate concrete targets from types.Object fields +type clientSecret struct { + TenantID types.String `tfsdk:"tenant_id"` + ClientID types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` +} + +type azureFederatedWorkloadIdentity struct { + TenantID types.String `tfsdk:"tenant_id"` + ClientID types.String `tfsdk:"client_id"` + Audience types.String `tfsdk:"audience"` +} + +var _ resource.Resource = &resourceVaultSecretsIntegrationAzure{} +var _ resource.ResourceWithConfigure = &resourceVaultSecretsIntegrationAzure{} +var _ resource.ResourceWithModifyPlan = &resourceVaultSecretsIntegrationAzure{} +var _ resource.ResourceWithImportState = &resourceVaultSecretsIntegrationAzure{} + +func NewVaultSecretsIntegrationAzureResource() resource.Resource { + return &resourceVaultSecretsIntegrationAzure{} +} + +type resourceVaultSecretsIntegrationAzure struct { + client *clients.Client +} + +func (r *resourceVaultSecretsIntegrationAzure) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vault_secrets_integration_azure" +} + +func (r *resourceVaultSecretsIntegrationAzure) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := map[string]schema.Attribute{ + "client_secret": schema.SingleNestedAttribute{ + Description: "Azure client secret used to authenticate against the target Azure application. Cannot be used with `federated_workload_identity`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "tenant_id": schema.StringAttribute{ + Description: "Azure tenant ID corresponding to the Azure application.", + Required: true, + }, + "client_id": schema.StringAttribute{ + Description: "Azure client ID corresponding to the Azure application.", + Required: true, + }, + "client_secret": schema.StringAttribute{ + Description: "Secret value corresponding to the Azure client secret.", + Required: true, + }, + }, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("federated_workload_identity"), + }...), + }, + }, + "federated_workload_identity": schema.SingleNestedAttribute{ + Description: "(Recommended) Federated identity configuration to authenticate against the target Azure application. Cannot be used with `client_secret`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "tenant_id": schema.StringAttribute{ + Description: "Azure tenant ID corresponding to the Azure application.", + Required: true, + }, + "client_id": schema.StringAttribute{ + Description: "Azure client ID corresponding to the Azure application.", + Required: true, + }, + "audience": schema.StringAttribute{ + Description: "Audience configured on the Azure federated identity credentials to federate access with HCP.", + Required: true, + }, + }, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("client_secret"), + }...), + }, + }, + } + + maps.Copy(attributes, locationAttributes) + maps.Copy(attributes, sharedIntegrationAttributes) + + resp.Schema = schema.Schema{ + MarkdownDescription: "The Vault Secrets Azure integration resource manages an Azure integration.", + Attributes: attributes, + } +} + +func (r *resourceVaultSecretsIntegrationAzure) 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 *resourceVaultSecretsIntegrationAzure) 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 *resourceVaultSecretsIntegrationAzure) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAzure](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { + integration, ok := i.(*IntegrationAzure) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *IntegrationAzure, got: %T, this is a bug on the provider", i) + } + + response, err := r.client.VaultSecrets.GetAzureIntegration( + secret_service.NewGetAzureIntegrationParamsWithContext(ctx). + WithOrganizationID(integration.OrganizationID.ValueString()). + WithProjectID(integration.ProjectID.ValueString()). + WithName(integration.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Integration, nil + })...) +} + +func (r *resourceVaultSecretsIntegrationAzure) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAzure](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { + integration, ok := i.(*IntegrationAzure) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *IntegrationAzure, got: %T, this is a bug on the provider", i) + } + + response, err := r.client.VaultSecrets.CreateAzureIntegration(&secret_service.CreateAzureIntegrationParams{ + Body: &secretmodels.SecretServiceCreateAzureIntegrationBody{ + Capabilities: integration.capabilities, + FederatedWorkloadIdentity: integration.federatedWorkloadIdentity, + Name: integration.Name.ValueString(), + ClientSecret: integration.clientSecret, + }, + OrganizationID: integration.OrganizationID.ValueString(), + ProjectID: integration.ProjectID.ValueString(), + }, nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Integration, nil + })...) +} + +func (r *resourceVaultSecretsIntegrationAzure) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAzure](ctx, r.client, &resp.State, req.Plan.Get, "updating", func(i hvsResource) (any, error) { + integration, ok := i.(*IntegrationAzure) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *IntegrationAzure, got: %T, this is a bug on the provider", i) + } + + response, err := r.client.VaultSecrets.UpdateAzureIntegration(&secret_service.UpdateAzureIntegrationParams{ + Body: &secretmodels.SecretServiceUpdateAzureIntegrationBody{ + Capabilities: integration.capabilities, + FederatedWorkloadIdentity: integration.federatedWorkloadIdentity, + ClientSecret: integration.clientSecret, + }, + Name: integration.Name.ValueString(), + OrganizationID: integration.OrganizationID.ValueString(), + ProjectID: integration.ProjectID.ValueString(), + }, nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Integration, nil + })...) +} + +func (r *resourceVaultSecretsIntegrationAzure) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.Append(decorateOperation[*IntegrationAzure](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { + integration, ok := i.(*IntegrationAzure) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *IntegrationAzure, got: %T, this is a bug on the provider", i) + } + + _, err := r.client.VaultSecrets.DeleteAzureIntegration( + secret_service.NewDeleteAzureIntegrationParams(). + WithOrganizationID(integration.OrganizationID.ValueString()). + WithProjectID(integration.ProjectID.ValueString()). + WithName(integration.Name.ValueString()), nil) + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + return nil, nil + })...) +} + +func (r *resourceVaultSecretsIntegrationAzure) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // The Vault Secrets API does not return sensitive values like the client secret, so they will be initialized to an empty value + // It means the first plan/apply after a successful import will always show a diff for the client secret. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), r.client.Config.OrganizationID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), r.client.Config.ProjectID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) +} + +var _ hvsResource = &IntegrationAzure{} + +func (i *IntegrationAzure) projectID() types.String { + return i.ProjectID +} + +func (i *IntegrationAzure) initModel(ctx context.Context, orgID, projID string) diag.Diagnostics { + // Init fields that depend on the Terraform provider configuration + i.OrganizationID = types.StringValue(orgID) + i.ProjectID = types.StringValue(projID) + + // Init the HVS domain models from the Terraform domain models + var capabilities []types.String + diags := i.Capabilities.ElementsAs(ctx, &capabilities, false) + if diags.HasError() { + return diags + } + for _, c := range capabilities { + i.capabilities = append(i.capabilities, secretmodels.Secrets20231128Capability(c.ValueString()).Pointer()) + } + + if !i.ClientSecret.IsNull() { + cs := clientSecret{} + diags = i.ClientSecret.As(ctx, &cs, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return diags + } + + i.clientSecret = &secretmodels.Secrets20231128AzureClientSecretRequest{ + TenantID: cs.TenantID.ValueString(), + ClientID: cs.ClientID.ValueString(), + ClientSecret: cs.ClientSecret.ValueString(), + } + } + + if !i.FederatedWorkloadIdentity.IsNull() { + fwi := azureFederatedWorkloadIdentity{} + diags = i.FederatedWorkloadIdentity.As(ctx, &fwi, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return diags + } + + i.federatedWorkloadIdentity = &secretmodels.Secrets20231128AzureFederatedWorkloadIdentityRequest{ + Audience: fwi.Audience.ValueString(), + TenantID: fwi.TenantID.ValueString(), + ClientID: fwi.ClientID.ValueString(), + } + } + + return diag.Diagnostics{} +} + +func (i *IntegrationAzure) fromModel(ctx context.Context, orgID, projID string, model any) diag.Diagnostics { + diags := diag.Diagnostics{} + + integrationModel, ok := model.(*secretmodels.Secrets20231128AzureIntegration) + if !ok { + diags.AddError("Invalid model type, this is a bug on the provider.", fmt.Sprintf("Expected *secretmodels.Secrets20231128AzureIntegration, got: %T", model)) + return diags + } + + i.OrganizationID = types.StringValue(orgID) + i.ProjectID = types.StringValue(projID) + i.ResourceID = types.StringValue(integrationModel.ResourceID) + i.ResourceName = types.StringValue(integrationModel.ResourceName) + i.Name = types.StringValue(integrationModel.Name) + + var values []attr.Value + for _, c := range integrationModel.Capabilities { + values = append(values, types.StringValue(string(*c))) + } + i.Capabilities, diags = types.SetValue(types.StringType, values) + if diags.HasError() { + return diags + } + + if integrationModel.ClientSecret != nil { + clientSecret := "" + if i.clientSecret != nil { + clientSecret = i.clientSecret.ClientSecret + } + i.ClientSecret, diags = types.ObjectValue(i.ClientSecret.AttributeTypes(ctx), map[string]attr.Value{ + "tenant_id": types.StringValue(integrationModel.ClientSecret.TenantID), + "client_id": types.StringValue(integrationModel.ClientSecret.ClientID), + "client_secret": types.StringValue(clientSecret), + }) + if diags.HasError() { + return diags + } + } + + if integrationModel.FederatedWorkloadIdentity != nil { + i.FederatedWorkloadIdentity, diags = types.ObjectValue(i.FederatedWorkloadIdentity.AttributeTypes(ctx), map[string]attr.Value{ + "tenant_id": types.StringValue(integrationModel.FederatedWorkloadIdentity.TenantID), + "client_id": types.StringValue(integrationModel.FederatedWorkloadIdentity.ClientID), + "audience": types.StringValue(integrationModel.FederatedWorkloadIdentity.Audience), + }) + if diags.HasError() { + return diags + } + } + + return diags +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure_test.go new file mode 100644 index 000000000..875e0342a --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_azure_test.go @@ -0,0 +1,190 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vaultsecrets_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/models" + "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 TestAccVaultSecretsResourceIntegrationAzure(t *testing.T) { + tenantID := checkRequiredEnvVarOrFail(t, "AZURE_TENANT_ID") + clientID := checkRequiredEnvVarOrFail(t, "AZURE_CLIENT_ID") + clientSecret := checkRequiredEnvVarOrFail(t, "AZURE_CLIENT_SECRET") + audience := checkRequiredEnvVarOrFail(t, "AZURE_INTEGRATION_AUDIENCE") + + integrationName1 := generateRandomSlug() + // Set the integration name that is configured in the subject claim while creating a federated credential. + integrationName2 := checkRequiredEnvVarOrFail(t, "AZURE_INTEGRATION_NAME_WIF") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create initial integration with access keys + { + Config: azureClientSecretConfig(integrationName1, clientID, tenantID, clientSecret), + Check: resource.ComposeTestCheckFunc( + azureCheckClientSecretKeyFuncs(integrationName1, clientID, tenantID, clientSecret)..., + ), + }, + // Changing the name forces a recreation + { + Config: azureClientSecretConfig(integrationName2, clientID, tenantID, clientSecret), + Check: resource.ComposeTestCheckFunc( + azureCheckClientSecretKeyFuncs(integrationName2, clientID, tenantID, clientSecret)..., + ), + }, + // Modifying mutable fields causes an update + { + Config: azureFederatedIdentityConfig(integrationName2, clientID, tenantID, audience), + Check: resource.ComposeTestCheckFunc( + azureCheckFederatedIdentityFuncs(integrationName2, clientID, tenantID, audience)..., + ), + }, + // Deleting the integration out of band causes a recreation + { + PreConfig: func() { + t.Helper() + client := acctest.HCPClients(t) + _, err := client.VaultSecrets.DeleteAzureIntegration(&secret_service.DeleteAzureIntegrationParams{ + Name: integrationName2, + OrganizationID: client.Config.OrganizationID, + ProjectID: client.Config.ProjectID, + }, nil) + if err != nil { + t.Fatal(err) + } + }, + Config: azureFederatedIdentityConfig(integrationName2, clientID, tenantID, audience), + Check: resource.ComposeTestCheckFunc( + azureCheckFederatedIdentityFuncs(integrationName2, clientID, tenantID, audience)..., + ), + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + // Pre-existing integration can be imported + { + PreConfig: func() { + t.Helper() + client := acctest.HCPClients(t) + _, err := client.VaultSecrets.CreateAzureIntegration(&secret_service.CreateAzureIntegrationParams{ + Body: &secretmodels.SecretServiceCreateAzureIntegrationBody{ + Capabilities: []*secretmodels.Secrets20231128Capability{secretmodels.Secrets20231128CapabilityROTATION.Pointer()}, + FederatedWorkloadIdentity: &secretmodels.Secrets20231128AzureFederatedWorkloadIdentityRequest{ + Audience: audience, + ClientID: clientID, + TenantID: tenantID, + }, + Name: integrationName2, + }, + OrganizationID: client.Config.OrganizationID, + ProjectID: client.Config.ProjectID, + }, nil) + if err != nil { + t.Fatal(err) + } + }, + Config: azureFederatedIdentityConfig(integrationName2, clientID, tenantID, audience), + Check: resource.ComposeTestCheckFunc( + azureCheckFederatedIdentityFuncs(integrationName2, clientID, tenantID, audience)..., + ), + ResourceName: "hcp_vault_secrets_integration_azure.acc_test", + ImportStateId: integrationName2, + ImportState: true, + PlanOnly: true, + }, + }, + CheckDestroy: func(_ *terraform.State) error { + if azureIntegrationExists(t, integrationName1) { + return fmt.Errorf("test azure integration %s was not destroyed", integrationName1) + } + if azureIntegrationExists(t, integrationName2) { + return fmt.Errorf("test azure integration %s was not destroyed", integrationName2) + } + return nil + }, + }) +} + +func azureClientSecretConfig(integrationName, clientID, tenantID, clientSecret string) string { + return fmt.Sprintf(` + resource "hcp_vault_secrets_integration_azure" "acc_test" { + name = %q + capabilities = ["ROTATION"] + client_secret = { + tenant_id = %q + client_id = %q + client_secret = %q + } + }`, integrationName, tenantID, clientID, clientSecret) +} + +func azureFederatedIdentityConfig(integrationName, clientID, tenantID, audience string) string { + return fmt.Sprintf(` + resource "hcp_vault_secrets_integration_azure" "acc_test" { + name = %q + capabilities = ["ROTATION"] + federated_workload_identity = { + tenant_id = %q + client_id = %q + audience = %q + } + }`, integrationName, tenantID, clientID, audience) +} + +func azureCheckClientSecretKeyFuncs(integrationName, clientID, tenantID, clientSecret string) []resource.TestCheckFunc { + return []resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "resource_id"), + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "resource_name"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "name", integrationName), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "capabilities.#", "1"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "capabilities.0", "ROTATION"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "client_secret.client_id", clientID), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "client_secret.tenant_id", tenantID), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "client_secret.client_secret", clientSecret), + } +} + +func azureCheckFederatedIdentityFuncs(integrationName, clientID, tenantID, audience string) []resource.TestCheckFunc { + return []resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "resource_id"), + resource.TestCheckResourceAttrSet("hcp_vault_secrets_integration_azure.acc_test", "resource_name"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "name", integrationName), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "capabilities.#", "1"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "capabilities.0", "ROTATION"), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "federated_workload_identity.audience", audience), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "federated_workload_identity.tenant_id", tenantID), + resource.TestCheckResourceAttr("hcp_vault_secrets_integration_azure.acc_test", "federated_workload_identity.client_id", clientID), + } +} + +func azureIntegrationExists(t *testing.T, name string) bool { + t.Helper() + + client := acctest.HCPClients(t) + + response, err := client.VaultSecrets.GetAzureIntegration( + secret_service.NewGetAzureIntegrationParamsWithContext(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.Integration != nil +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go index a45777b73..05e9fee98 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_rotating_secret.go @@ -31,6 +31,7 @@ var exactlyOneRotatingSecretTypeFieldsValidator = objectvalidator.ExactlyOneOf( path.MatchRoot("mongodb_atlas_user"), path.MatchRoot("twilio_api_key"), path.MatchRoot("confluent_service_account"), + path.MatchRoot("azure_application_password"), }..., ) @@ -50,6 +51,7 @@ var rotatingSecretsImpl = map[Provider]rotatingSecret{ ProviderMongoDBAtlas: &mongoDBAtlasRotatingSecret{}, ProviderTwilio: &twilioRotatingSecret{}, ProviderConfluent: &confluentRotatingSecret{}, + ProviderAzure: &azureRotatingSecret{}, } type RotatingSecret struct { @@ -62,12 +64,12 @@ type RotatingSecret struct { 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"` - ConfluentServiceAccount *confluentServiceAccount `tfsdk:"confluent_service_account"` - + 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"` + ConfluentServiceAccount *confluentServiceAccount `tfsdk:"confluent_service_account"` + AzureApplicationPassword *AzureApplicationPassword `tfsdk:"azure_application_password"` // Computed fields OrganizationID types.String `tfsdk:"organization_id"` @@ -93,6 +95,11 @@ type confluentServiceAccount struct { ServiceAccountID types.String `tfsdk:"service_account_id"` } +type AzureApplicationPassword struct { + AppClientID types.String `tfsdk:"app_client_id"` + AppObjectID types.String `tfsdk:"app_object_id"` +} + type twilioAPIKey struct{} var _ resource.Resource = &resourceVaultSecretsRotatingSecret{} @@ -206,6 +213,29 @@ func (r *resourceVaultSecretsRotatingSecret) Schema(_ context.Context, _ resourc exactlyOneRotatingSecretTypeFieldsValidator, }, }, + "azure_application_password": schema.SingleNestedAttribute{ + Description: "Azure configuration to manage the application password rotation for the given application. Required if `secret_provider` is `Azure`.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "app_client_id": schema.StringAttribute{ + Description: "Application client ID to rotate the application password for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "app_object_id": schema.StringAttribute{ + Description: "Application object ID to rotate the application password for.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + Validators: []validator.Object{ + exactlyOneRotatingSecretTypeFieldsValidator, + }, + }, } maps.Copy(attributes, locationAttributes) diff --git a/internal/provider/vaultsecrets/rotating_secret_azure.go b/internal/provider/vaultsecrets/rotating_secret_azure.go new file mode 100644 index 000000000..cde2cd88e --- /dev/null +++ b/internal/provider/vaultsecrets/rotating_secret_azure.go @@ -0,0 +1,89 @@ +// 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/stable/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/models" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ rotatingSecret = &azureRotatingSecret{} + +type azureRotatingSecret struct{} + +func (s *azureRotatingSecret) read(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + response, err := client.GetAzureApplicationPasswordRotatingSecretConfig( + secret_service.NewGetAzureApplicationPasswordRotatingSecretConfigParamsWithContext(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 *azureRotatingSecret) create(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.AzureApplicationPassword == nil { + return nil, fmt.Errorf("missing required field 'azure_application_password'") + } + + response, err := client.CreateAzureApplicationPasswordRotatingSecret( + secret_service.NewCreateAzureApplicationPasswordRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithBody(&secretmodels.SecretServiceCreateAzureApplicationPasswordRotatingSecretBody{ + IntegrationName: secret.IntegrationName.ValueString(), + RotationPolicyName: secret.RotationPolicyName.ValueString(), + AzureApplicationPasswordParams: &secretmodels.Secrets20231128AzureApplicationPasswordParams{ + AppClientID: secret.AzureApplicationPassword.AppClientID.ValueString(), + AppObjectID: secret.AzureApplicationPassword.AppObjectID.ValueString(), + }, + Name: 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 *azureRotatingSecret) update(ctx context.Context, client secret_service.ClientService, secret *RotatingSecret) (any, error) { + if secret.AzureApplicationPassword == nil { + return nil, fmt.Errorf("missing required field 'azure_application_password'") + } + response, err := client.UpdateAzureApplicationPasswordRotatingSecret( + secret_service.NewUpdateAzureApplicationPasswordRotatingSecretParamsWithContext(ctx). + WithOrganizationID(secret.OrganizationID.ValueString()). + WithProjectID(secret.ProjectID.ValueString()). + WithAppName(secret.AppName.ValueString()). + WithName(secret.Name.ValueString()). + WithBody(&secretmodels.SecretServiceUpdateAzureApplicationPasswordRotatingSecretBody{ + RotationPolicyName: secret.RotationPolicyName.ValueString(), + AzureApplicationPasswordParams: &secretmodels.Secrets20231128AzureApplicationPasswordParams{ + AppClientID: secret.AzureApplicationPassword.AppClientID.ValueString(), + AppObjectID: secret.AzureApplicationPassword.AppObjectID.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 16a25d46d..83707569d 100644 --- a/internal/provider/vaultsecrets/vault_secrets_utils.go +++ b/internal/provider/vaultsecrets/vault_secrets_utils.go @@ -27,6 +27,7 @@ const ( ProviderMongoDBAtlas Provider = "mongodb_atlas" ProviderTwilio Provider = "twilio" ProviderConfluent Provider = "confluent" + ProviderAzure Provider = "azure" ) func (p Provider) String() string {