From df427fcc0be313f23601c571ba3327e5103eca38 Mon Sep 17 00:00:00 2001 From: Mark van Holsteijn Date: Sat, 16 Aug 2025 14:44:54 +0200 Subject: [PATCH] feat: support the use of ephemeral private keys through private_key_pem write only attributes supports #645 Write-only attribute support for TLS managed resources --- .gitignore | 1 + docs/resources/self_signed_cert.md | 6 +- internal/provider/models.go | 36 ++++++------ .../provider/resource_self_signed_cert.go | 55 ++++++++++++++++++- .../resource_self_signed_cert_test.go | 33 +++++++++++ 5 files changed, 110 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 881530ea..a1f8af1f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ website/vendor # Test exclusions !command/test-fixtures/**/*.tfstate !command/test-fixtures/**/.terraform/ +/terraform-provider-tls diff --git a/docs/resources/self_signed_cert.md b/docs/resources/self_signed_cert.md index 8cb948ca..cdd442a8 100644 --- a/docs/resources/self_signed_cert.md +++ b/docs/resources/self_signed_cert.md @@ -42,15 +42,19 @@ resource "tls_self_signed_cert" "example" { ### Required - `allowed_uses` (List of String) List of key usages allowed for the issued certificate. Values are defined in [RFC 5280](https://datatracker.ietf.org/doc/html/rfc5280) and combine flags defined by both [Key Usages](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3) and [Extended Key Usages](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.12). Accepted values: `any_extended`, `cert_signing`, `client_auth`, `code_signing`, `content_commitment`, `crl_signing`, `data_encipherment`, `decipher_only`, `digital_signature`, `email_protection`, `encipher_only`, `ipsec_end_system`, `ipsec_tunnel`, `ipsec_user`, `key_agreement`, `key_encipherment`, `microsoft_commercial_code_signing`, `microsoft_kernel_code_signing`, `microsoft_server_gated_crypto`, `netscape_server_gated_crypto`, `ocsp_signing`, `server_auth`, `timestamping`. -- `private_key_pem` (String, Sensitive) Private key in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format, that the certificate will belong to. This can be read from a separate file using the [`file`](https://www.terraform.io/language/functions/file) interpolation function. - `validity_period_hours` (Number) Number of hours, after initial issuing, that the certificate will remain valid for. ### Optional +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + - `dns_names` (List of String) List of DNS names for which a certificate is being requested (i.e. certificate subjects). - `early_renewal_hours` (Number) The resource will consider the certificate to have expired the given number of hours before its actual expiry time. This can be useful to deploy an updated certificate in advance of the expiration of the current certificate. However, the old certificate remains valid until its true expiration time, since this resource does not (and cannot) support certificate revocation. Also, this advance update can only be performed should the Terraform configuration be applied during the early renewal period. (default: `0`) - `ip_addresses` (List of String) List of IP addresses for which a certificate is being requested (i.e. certificate subjects). - `is_ca_certificate` (Boolean) Is the generated certificate representing a Certificate Authority (CA) (default: `false`). +- `private_key_pem` (String, Sensitive) Private key in [PEM (RFC 1421)](https://datatracker.ietf.org/doc/html/rfc1421) format, that the certificate will belong to. This can be read from a separate file using the [`file`](https://www.terraform.io/language/functions/file) interpolation function. +- `private_key_pem_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Write only variant of private_key_pem. +- `private_key_pem_wo_version` (Number) Triggers update of private_key_pem write-only. - `set_authority_key_id` (Boolean) Should the generated certificate include an [authority key identifier](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.1): for self-signed certificates this is the same value as the [subject key identifier](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2) (default: `false`). - `set_subject_key_id` (Boolean) Should the generated certificate include a [subject key identifier](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.2) (default: `false`). - `subject` (Block List) The subject for which a certificate is being requested. The acceptable arguments are all optional and their naming is based upon [Issuer Distinguished Names (RFC5280)](https://tools.ietf.org/html/rfc5280#section-4.1.2.4) section. (see [below for nested schema](#nestedblock--subject)) diff --git a/internal/provider/models.go b/internal/provider/models.go index 7ea5ef4a..c3961892 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -184,23 +184,25 @@ func (data *privateKeyEphemeralModel) toResourceModel() privateKeyResourceModel } type selfSignedCertResourceModel struct { - PrivateKeyPEM types.String `tfsdk:"private_key_pem"` - DNSNames types.List `tfsdk:"dns_names"` - IPAddresses types.List `tfsdk:"ip_addresses"` - URIs types.List `tfsdk:"uris"` - Subject types.List `tfsdk:"subject"` //< certificateSubjectModel - ValidityPeriodHours types.Int64 `tfsdk:"validity_period_hours"` - AllowedUses types.List `tfsdk:"allowed_uses"` - EarlyRenewalHours types.Int64 `tfsdk:"early_renewal_hours"` - IsCACertificate types.Bool `tfsdk:"is_ca_certificate"` - SetSubjectKeyID types.Bool `tfsdk:"set_subject_key_id"` - SetAuthorityKeyID types.Bool `tfsdk:"set_authority_key_id"` - CertPEM types.String `tfsdk:"cert_pem"` - ReadyForRenewal types.Bool `tfsdk:"ready_for_renewal"` - ValidityStartTime types.String `tfsdk:"validity_start_time"` - ValidityEndTime types.String `tfsdk:"validity_end_time"` - KeyAlgorithm types.String `tfsdk:"key_algorithm"` - ID types.String `tfsdk:"id"` + PrivateKeyPEM types.String `tfsdk:"private_key_pem"` + PrivateKeyPEMWO types.String `tfsdk:"private_key_pem_wo"` + PrivateKeyPEMWOVersion types.Int64 `tfsdk:"private_key_pem_wo_version"` + DNSNames types.List `tfsdk:"dns_names"` + IPAddresses types.List `tfsdk:"ip_addresses"` + URIs types.List `tfsdk:"uris"` + Subject types.List `tfsdk:"subject"` //< certificateSubjectModel + ValidityPeriodHours types.Int64 `tfsdk:"validity_period_hours"` + AllowedUses types.List `tfsdk:"allowed_uses"` + EarlyRenewalHours types.Int64 `tfsdk:"early_renewal_hours"` + IsCACertificate types.Bool `tfsdk:"is_ca_certificate"` + SetSubjectKeyID types.Bool `tfsdk:"set_subject_key_id"` + SetAuthorityKeyID types.Bool `tfsdk:"set_authority_key_id"` + CertPEM types.String `tfsdk:"cert_pem"` + ReadyForRenewal types.Bool `tfsdk:"ready_for_renewal"` + ValidityStartTime types.String `tfsdk:"validity_start_time"` + ValidityEndTime types.String `tfsdk:"validity_end_time"` + KeyAlgorithm types.String `tfsdk:"key_algorithm"` + ID types.String `tfsdk:"id"` } type locallySignedCertResourceModel struct { diff --git a/internal/provider/resource_self_signed_cert.go b/internal/provider/resource_self_signed_cert.go index f8d33ee6..73077e89 100644 --- a/internal/provider/resource_self_signed_cert.go +++ b/internal/provider/resource_self_signed_cert.go @@ -9,11 +9,13 @@ import ( "fmt" "net" "net/url" + "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/booldefault" @@ -46,11 +48,11 @@ func (r *selfSignedCertResource) Metadata(_ context.Context, req resource.Metada } func (r *selfSignedCertResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ - // Required attributes "private_key_pem": schema.StringAttribute{ - Required: true, + Optional: true, PlanModifiers: []planmodifier.String{ requireReplaceIfStateContainsPEMString(), }, @@ -59,7 +61,47 @@ func (r *selfSignedCertResource) Schema(_ context.Context, req resource.SchemaRe "that the certificate will belong to. " + "This can be read from a separate file using the [`file`](https://www.terraform.io/language/functions/file) " + "interpolation function. ", + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("private_key_pem"), + path.MatchRoot("private_key_pem_wo"), + ), + }, + }, + "private_key_pem_wo": schema.StringAttribute{ + Optional: true, + WriteOnly: true, + Sensitive: true, + Description: "Write only variant of private_key_pem.", + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("private_key_pem"), + path.MatchRoot("private_key_pem_wo"), + ), + stringvalidator.AlsoRequires( + path.MatchRoot("private_key_pem_wo_version"), + ), + stringvalidator.RegexMatches( + regexp.MustCompile(`^-----BEGIN [[:alpha:] ]+-----\n(.|\s)+\n-----END [[:alpha:] ]+-----\n?$`), + "must be a valid PEM string", + ), + }, }, + "private_key_pem_wo_version": schema.Int64Attribute{ + Required: false, + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.AlsoRequires( + path.MatchRoot("private_key_pem_wo"), + ), + }, + Sensitive: false, + Description: "Triggers update of the certificate using the private_key_pem_wo write-only value.", + }, + "validity_period_hours": schema.Int64Attribute{ Required: true, PlanModifiers: []planmodifier.Int64{ @@ -331,9 +373,16 @@ func (r *selfSignedCertResource) Create(ctx context.Context, req resource.Create "selfSignedCertConfig": fmt.Sprintf("%+v", newState), }) + privateKeyPEM := "" + diagnostics := req.Config.GetAttribute(ctx, path.Root("private_key_pem_wo"), &privateKeyPEM) + if privateKeyPEM == "" || diagnostics.HasError() { + tflog.Debug(ctx, "private_key_pem_wo not set, using private_key_pem instead") + privateKeyPEM = newState.PrivateKeyPEM.ValueString() + } + // Parse the Private Key PEM tflog.Debug(ctx, "Parsing private key PEM") - prvKey, algorithm, err := parsePrivateKeyPEM([]byte(newState.PrivateKeyPEM.ValueString())) + prvKey, algorithm, err := parsePrivateKeyPEM([]byte(privateKeyPEM)) if err != nil { res.Diagnostics.AddError("Failed to parse private key PEM", err.Error()) return diff --git a/internal/provider/resource_self_signed_cert_test.go b/internal/provider/resource_self_signed_cert_test.go index 082526f5..03f2772a 100644 --- a/internal/provider/resource_self_signed_cert_test.go +++ b/internal/provider/resource_self_signed_cert_test.go @@ -914,3 +914,36 @@ func TestResourceSelfSignedCert_NoSubject(t *testing.T) { }, }) } + +func TestResourceSelfSignedCert_UsingWriteOnlyEphemeralPrivateKey(t *testing.T) { + r.UnitTest(t, r.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []r.TestStep{ + { + Config: ` + ephemeral "tls_private_key" "test" { + algorithm = "RSA" + rsa_bits = 4096 + } + resource "tls_self_signed_cert" "test" { + private_key_pem_wo = ephemeral.tls_private_key.test.private_key_pem + private_key_pem_wo_version = 1 + subject { + organization = "test-organization" + } + is_ca_certificate = true + set_subject_key_id = true + validity_period_hours = 8760 + allowed_uses = [ + "cert_signing", + ] + } + `, + Check: r.ComposeAggregateTestCheckFunc( + r.TestCheckResourceAttr("tls_self_signed_cert.test", "key_algorithm", "RSA"), + tu.TestCheckPEMFormat("tls_self_signed_cert.test", "cert_pem", PreambleCertificate.String()), + ), + }, + }, + }) +}