diff --git a/.changelog/76.txt b/.changelog/76.txt new file mode 100644 index 0000000..840dc5f --- /dev/null +++ b/.changelog/76.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +resource/mailgun_smtp_credential: Add write-only `password_wo` and `password_wo_version` arguments. The secret is never stored in Terraform state; increment the version to rotate. Requires Terraform CLI >= 1.11. +``` + +```release-note:deprecation +resource/mailgun_smtp_credential: The `password` argument is deprecated in favor of `password_wo`/`password_wo_version` and will be removed in a future major release. +``` diff --git a/docs/resources/smtp_credential.md b/docs/resources/smtp_credential.md index 2b77a20..16b40ac 100644 --- a/docs/resources/smtp_credential.md +++ b/docs/resources/smtp_credential.md @@ -23,8 +23,34 @@ resource "mailgun_smtp_credential" "app" { output "smtp_full_login" { value = mailgun_smtp_credential.app.full_login } + +# Write-only password (recommended). Requires Terraform CLI >= 1.11. +# The secret is never written to Terraform state. Bump password_wo_version to rotate. +# An ephemeral random_password keeps the generated secret out of state entirely. +ephemeral "random_password" "smtp" { + length = 24 + special = false +} + +resource "mailgun_smtp_credential" "app_wo" { + domain = "mail.example.com" + login = "app-mailer-wo" + password_wo = ephemeral.random_password.smtp.result + password_wo_version = 1 +} ``` +## Write-Only Password + +The `password_wo` and `password_wo_version` arguments provide a write-only credential workflow that keeps the secret out of Terraform state entirely: + +- Requires Terraform CLI >= 1.11. +- The value of `password_wo` is never stored in state or shown in plan output. +- Rotate the password by changing `password_wo` and incrementing `password_wo_version`. +- Credentials imported with `terraform import` have no password in state; use `password_wo` + `password_wo_version` to set one and enable future rotation. + +The legacy `password` argument is deprecated and will be removed in a future major release. + ## Schema @@ -35,7 +61,11 @@ output "smtp_full_login" { ### Optional -- `password` (String, Sensitive) The password for SMTP authentication. This is write-only and cannot be read back from the API. Set this when creating a credential or when rotating the password of an imported credential. Leave it unset to keep the existing password of an imported credential. +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + +- `password` (String, Sensitive, Deprecated) The password for SMTP authentication. This is write-only and cannot be read back from the API. Set this when creating credentials or when rotating the password. Leave it unset to maintain the existing password during import. +- `password_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Write-only password for SMTP authentication. The value is never stored in Terraform state. Always set it together with password_wo_version. +- `password_wo_version` (Number) Version counter for password_wo. Increment this value to rotate the write-only password. **Required** when password_wo is set. ### Read-Only diff --git a/examples/resources/mailgun_smtp_credential/resource.tf b/examples/resources/mailgun_smtp_credential/resource.tf index b6fe929..304cfe8 100644 --- a/examples/resources/mailgun_smtp_credential/resource.tf +++ b/examples/resources/mailgun_smtp_credential/resource.tf @@ -9,3 +9,18 @@ resource "mailgun_smtp_credential" "app" { output "smtp_full_login" { value = mailgun_smtp_credential.app.full_login } + +# Write-only password (recommended). Requires Terraform CLI >= 1.11. +# The secret is never written to Terraform state. Bump password_wo_version to rotate. +# An ephemeral random_password keeps the generated secret out of state entirely. +ephemeral "random_password" "smtp" { + length = 24 + special = false +} + +resource "mailgun_smtp_credential" "app_wo" { + domain = "mail.example.com" + login = "app-mailer-wo" + password_wo = ephemeral.random_password.smtp.result + password_wo_version = 1 +} diff --git a/internal/provider/smtp_credentials/helpers_internal_test.go b/internal/provider/smtp_credentials/helpers_internal_test.go new file mode 100644 index 0000000..fc48ed7 --- /dev/null +++ b/internal/provider/smtp_credentials/helpers_internal_test.go @@ -0,0 +1,138 @@ +// Copyright Hack The Box 2025, 2026 +// SPDX-License-Identifier: MPL-2.0 + +package smtp_credentials + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestPasswordForCreate(t *testing.T) { + tests := []struct { + name string + passwordWO types.String + legacy types.String + wantPass string + wantOK bool + }{ + {"write-only preferred", types.StringValue("wo-secret"), types.StringValue("legacy"), "wo-secret", true}, + {"legacy when no wo", types.StringNull(), types.StringValue("legacy"), "legacy", true}, + {"neither set", types.StringNull(), types.StringNull(), "", false}, + {"legacy unknown ignored", types.StringNull(), types.StringUnknown(), "", false}, + {"wo unknown falls back to legacy", types.StringUnknown(), types.StringValue("legacy"), "legacy", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPass, gotOK := passwordForCreate(tt.passwordWO, tt.legacy) + if gotPass != tt.wantPass || gotOK != tt.wantOK { + t.Errorf("passwordForCreate() = (%q, %v), want (%q, %v)", gotPass, gotOK, tt.wantPass, tt.wantOK) + } + }) + } +} + +func TestWriteOnlyRotationRequested(t *testing.T) { + tests := []struct { + name string + plan types.Int64 + state types.Int64 + want bool + }{ + {"version bumped", types.Int64Value(2), types.Int64Value(1), true}, + {"version unchanged", types.Int64Value(1), types.Int64Value(1), false}, + {"first set from null state", types.Int64Value(1), types.Int64Null(), true}, + {"no version in plan", types.Int64Null(), types.Int64Null(), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := writeOnlyRotationRequested(tt.plan, tt.state); got != tt.want { + t.Errorf("writeOnlyRotationRequested() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveUpdatePassword(t *testing.T) { + tests := []struct { + name string + passwordWO types.String + planPW types.String + planVersion types.Int64 + stateVersion types.Int64 + wantPW string + wantRotate bool + wantErr string + }{ + { + name: "write-only with version bump rotates", + passwordWO: types.StringValue("wo-secret"), + planPW: types.StringNull(), + planVersion: types.Int64Value(2), + stateVersion: types.Int64Value(1), + wantPW: "wo-secret", + wantRotate: true, + wantErr: "", + }, + { + name: "write-only without version bump skips rotation", + passwordWO: types.StringValue("wo-secret"), + planPW: types.StringNull(), + planVersion: types.Int64Value(1), + stateVersion: types.Int64Value(1), + wantPW: "", + wantRotate: false, + wantErr: "", + }, + { + name: "legacy null preserves imported state", + passwordWO: types.StringNull(), + planPW: types.StringNull(), + planVersion: types.Int64Null(), + stateVersion: types.Int64Null(), + wantPW: "", + wantRotate: false, + wantErr: "", + }, + { + name: "legacy empty string is an error", + passwordWO: types.StringNull(), + planPW: types.StringValue(""), + planVersion: types.Int64Null(), + stateVersion: types.Int64Null(), + wantPW: "", + wantRotate: false, + wantErr: "Invalid Password", + }, + { + name: "legacy non-empty rotates", + passwordWO: types.StringNull(), + planPW: types.StringValue("newpass"), + planVersion: types.Int64Null(), + stateVersion: types.Int64Null(), + wantPW: "newpass", + wantRotate: true, + wantErr: "", + }, + { + name: "write-only unknown falls through to legacy", + passwordWO: types.StringUnknown(), + planPW: types.StringValue("legacy"), + planVersion: types.Int64Null(), + stateVersion: types.Int64Null(), + wantPW: "legacy", + wantRotate: true, + wantErr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPW, gotRotate, gotErr := resolveUpdatePassword(tt.passwordWO, tt.planPW, tt.planVersion, tt.stateVersion) + if gotPW != tt.wantPW || gotRotate != tt.wantRotate || gotErr != tt.wantErr { + t.Errorf("resolveUpdatePassword() = (%q, %v, %q), want (%q, %v, %q)", + gotPW, gotRotate, gotErr, tt.wantPW, tt.wantRotate, tt.wantErr) + } + }) + } +} diff --git a/internal/provider/smtp_credentials/resource.go b/internal/provider/smtp_credentials/resource.go index b7d155d..df7e01a 100644 --- a/internal/provider/smtp_credentials/resource.go +++ b/internal/provider/smtp_credentials/resource.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/mailgun/mailgun-go/v5" @@ -65,6 +66,12 @@ func (r *SmtpCredentialResource) Create(ctx context.Context, req resource.Create return } + var passwordWO types.String + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("password_wo"), &passwordWO)...) + if resp.Diagnostics.HasError() { + return + } + // Validate that client is configured if r.client == nil { resp.Diagnostics.AddError( @@ -76,7 +83,6 @@ func (r *SmtpCredentialResource) Create(ctx context.Context, req resource.Create domain := plan.Domain.ValueString() login := plan.Login.ValueString() - password := plan.Password.ValueString() // Validate required fields if domain == "" { @@ -87,8 +93,13 @@ func (r *SmtpCredentialResource) Create(ctx context.Context, req resource.Create resp.Diagnostics.AddError("Missing Login", "The login is required to create an SMTP credential.") return } - if plan.Password.IsNull() || plan.Password.IsUnknown() || password == "" { - resp.Diagnostics.AddError("Missing Password", "The password is required to create an SMTP credential.") + + password, ok := passwordForCreate(passwordWO, plan.Password) + if !ok { + resp.Diagnostics.AddError( + "Missing Password", + "A password is required to create an SMTP credential. Set password_wo (with password_wo_version) or the deprecated password argument.", + ) return } @@ -110,6 +121,10 @@ func (r *SmtpCredentialResource) Create(ctx context.Context, req resource.Create plan.Id = types.StringValue(fmt.Sprintf("%s/%s", domain, login)) plan.FullLogin = types.StringValue(fmt.Sprintf("%s@%s", login, domain)) + if !passwordWO.IsNull() && !passwordWO.IsUnknown() { + plan.Password = types.StringNull() + } + // Try to get the created_at from the API by listing credentials credential, err := r.findCredential(ctx, domain, login) if err != nil { @@ -119,7 +134,6 @@ func (r *SmtpCredentialResource) Create(ctx context.Context, req resource.Create plan.CreatedAt = types.StringValue(time.Time(credential.CreatedAt).Format(time.RFC3339)) } - // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -163,7 +177,7 @@ func (r *SmtpCredentialResource) Read(ctx context.Context, req resource.ReadRequ resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } -// Update updates an existing SMTP credential (only password can be changed). +// Update updates an existing SMTP credential (only the password can be changed). func (r *SmtpCredentialResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan SmtpCredentialModel var state SmtpCredentialModel @@ -175,6 +189,12 @@ func (r *SmtpCredentialResource) Update(ctx context.Context, req resource.Update return } + var passwordWO types.String + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("password_wo"), &passwordWO)...) + if resp.Diagnostics.HasError() { + return + } + // Validate that client is configured if r.client == nil { resp.Diagnostics.AddError( @@ -186,49 +206,39 @@ func (r *SmtpCredentialResource) Update(ctx context.Context, req resource.Update domain := plan.Domain.ValueString() login := plan.Login.ValueString() - password := plan.Password.ValueString() - - // Password is write-only and cannot be read back from the API. When it is - // omitted from configuration for an imported resource, preserve the - // existing state and do not attempt a password rotation. - if plan.Password.IsNull() { - plan.Password = state.Password - plan.Id = state.Id - plan.FullLogin = state.FullLogin - plan.CreatedAt = state.CreatedAt - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) - return - } - - if password == "" { - resp.Diagnostics.AddError( - "Invalid Password", + newPassword, rotate, errMsg := resolveUpdatePassword(passwordWO, plan.Password, plan.PasswordWOVersion, state.PasswordWOVersion) + if errMsg != "" { + resp.Diagnostics.AddError(errMsg, "The password cannot be an empty string. Omit the attribute to preserve the existing imported password, or set a non-empty value to rotate it.", ) return } - // Create context with timeout - updateCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() + if rotate { + updateCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() - // Only the password can be updated - err := r.client.ChangeCredentialPassword(updateCtx, domain, login, password) - if err != nil { - resp.Diagnostics.AddError( - "Error Updating SMTP Credential", - fmt.Sprintf("Could not update password for SMTP credential %s@%s: %s", login, domain, err), - ) - return + if err := r.client.ChangeCredentialPassword(updateCtx, domain, login, newPassword); err != nil { + resp.Diagnostics.AddError( + "Error Updating SMTP Credential", + fmt.Sprintf("Could not update password for SMTP credential %s@%s: %s", login, domain, err), + ) + return + } } - // Keep computed values from state, update password from plan plan.Id = state.Id plan.FullLogin = state.FullLogin plan.CreatedAt = state.CreatedAt - // Save updated state + usingWriteOnly := !passwordWO.IsNull() && !passwordWO.IsUnknown() + if usingWriteOnly { + plan.Password = types.StringNull() + } else if plan.Password.IsNull() { + plan.Password = state.Password + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } @@ -305,21 +315,55 @@ func (r *SmtpCredentialResource) ImportState(ctx context.Context, req resource.I return } - // Create state from imported data state := SmtpCredentialModel{ - Id: types.StringValue(fmt.Sprintf("%s/%s", domain, login)), - Domain: types.StringValue(domain), - Login: types.StringValue(login), - FullLogin: types.StringValue(credential.Login), - CreatedAt: types.StringValue(time.Time(credential.CreatedAt).Format(time.RFC3339)), - // Password cannot be imported because the API never returns it. - Password: types.StringNull(), + Id: types.StringValue(fmt.Sprintf("%s/%s", domain, login)), + Domain: types.StringValue(domain), + Login: types.StringValue(login), + FullLogin: types.StringValue(credential.Login), + CreatedAt: types.StringValue(time.Time(credential.CreatedAt).Format(time.RFC3339)), + Password: types.StringNull(), + PasswordWO: types.StringNull(), + PasswordWOVersion: types.Int64Null(), } // Save imported state resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } +// passwordForCreate returns the password to use at create time, preferring the +// write-only value over the deprecated stateful password. ok is false when +// neither source provides a usable (known, non-empty) password. +func passwordForCreate(passwordWO, legacy types.String) (password string, ok bool) { + if !passwordWO.IsNull() && !passwordWO.IsUnknown() { + return passwordWO.ValueString(), true + } + if !legacy.IsNull() && !legacy.IsUnknown() && legacy.ValueString() != "" { + return legacy.ValueString(), true + } + return "", false +} + +func writeOnlyRotationRequested(planVersion, stateVersion types.Int64) bool { + return !planVersion.IsNull() && !planVersion.Equal(stateVersion) +} + +func resolveUpdatePassword(passwordWO, planPW types.String, planVersion, stateVersion types.Int64) (newPassword string, rotate bool, errTitle string) { + usingWriteOnly := !passwordWO.IsNull() && !passwordWO.IsUnknown() + switch { + case usingWriteOnly: + if writeOnlyRotationRequested(planVersion, stateVersion) { + return passwordWO.ValueString(), true, "" + } + return "", false, "" + case planPW.IsNull(): + return "", false, "" + case planPW.ValueString() == "": + return "", false, "Invalid Password" + default: + return planPW.ValueString(), true, "" + } +} + // findCredential searches for a specific credential by domain and login func (r *SmtpCredentialResource) findCredential(ctx context.Context, domain, login string) (*mtypes.Credential, error) { // Create context with timeout diff --git a/internal/provider/smtp_credentials/resource_model.go b/internal/provider/smtp_credentials/resource_model.go index 82d593f..6ada950 100644 --- a/internal/provider/smtp_credentials/resource_model.go +++ b/internal/provider/smtp_credentials/resource_model.go @@ -10,9 +10,12 @@ import ( // SmtpCredentialModel represents the Terraform state for an SMTP credential resource type SmtpCredentialModel struct { // Required inputs - Domain types.String `tfsdk:"domain"` - Login types.String `tfsdk:"login"` - Password types.String `tfsdk:"password"` + Domain types.String `tfsdk:"domain"` + Login types.String `tfsdk:"login"` + + Password types.String `tfsdk:"password"` + PasswordWO types.String `tfsdk:"password_wo"` + PasswordWOVersion types.Int64 `tfsdk:"password_wo_version"` // Computed attributes Id types.String `tfsdk:"id"` diff --git a/internal/provider/smtp_credentials/resource_schema.go b/internal/provider/smtp_credentials/resource_schema.go index 9eebdb7..d2d68ee 100644 --- a/internal/provider/smtp_credentials/resource_schema.go +++ b/internal/provider/smtp_credentials/resource_schema.go @@ -4,7 +4,9 @@ package smtp_credentials import ( + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" @@ -41,18 +43,41 @@ func SmtpCredentialResourceSchema() rschema.Schema { }, "password": rschema.StringAttribute{ Description: "The password for SMTP authentication. This is write-only and cannot be read back from the API. " + - "Set this when creating a credential or when rotating the password of an imported credential. " + - "Leave it unset to keep the existing password of an imported credential.", + "Set this when creating credentials or when rotating the password. " + + "Leave it unset to maintain the existing password during import.", + DeprecationMessage: "Use password_wo and password_wo_version instead. The password argument stores the " + + "secret in Terraform state; it remains supported for backward compatibility but will be removed in a future major release.", Optional: true, Computed: true, Sensitive: true, Validators: []validator.String{ stringvalidator.LengthAtLeast(1), + stringvalidator.ConflictsWith(path.MatchRoot("password_wo")), }, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, + "password_wo": rschema.StringAttribute{ + Description: "Write-only password for SMTP authentication. The value is never stored in Terraform state. " + + "Always set it together with password_wo_version.", + Optional: true, + Sensitive: true, + WriteOnly: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.ConflictsWith(path.MatchRoot("password")), + stringvalidator.AlsoRequires(path.MatchRoot("password_wo_version")), + }, + }, + "password_wo_version": rschema.Int64Attribute{ + Description: "Version counter for password_wo. Increment this value to rotate the write-only password. **Required** when password_wo is set.", + Optional: true, + Validators: []validator.Int64{ + int64validator.AlsoRequires(path.MatchRoot("password_wo")), + int64validator.ConflictsWith(path.MatchRoot("password")), + }, + }, "full_login": rschema.StringAttribute{ Description: "The full SMTP login in format 'login@domain'. Use this value for SMTP authentication.", Computed: true, diff --git a/internal/provider/smtp_credentials/resource_test.go b/internal/provider/smtp_credentials/resource_test.go index 662113c..f6da298 100644 --- a/internal/provider/smtp_credentials/resource_test.go +++ b/internal/provider/smtp_credentials/resource_test.go @@ -89,6 +89,39 @@ func TestSmtpCredentialsListDataSourceSchema_HasRequiredFields(t *testing.T) { } } +func TestSmtpCredentialResourceSchema_WriteOnlyPassword(t *testing.T) { + schema := smtp_credentials.SmtpCredentialResourceSchema() + + legacy, ok := schema.Attributes["password"].(rschema.StringAttribute) + if !ok { + t.Fatal("Schema missing string 'password' attribute") + } + if legacy.DeprecationMessage == "" { + t.Error("password should be deprecated in favor of password_wo") + } + + wo, ok := schema.Attributes["password_wo"].(rschema.StringAttribute) + if !ok { + t.Fatal("Schema missing string 'password_wo' attribute") + } + if !wo.WriteOnly { + t.Error("password_wo must be WriteOnly") + } + if !wo.Optional { + t.Error("password_wo must be Optional") + } + if !wo.Sensitive { + t.Error("password_wo must be Sensitive") + } + if wo.Computed { + t.Error("password_wo must not be Computed (WriteOnly forbids Computed)") + } + + if _, ok := schema.Attributes["password_wo_version"].(rschema.Int64Attribute); !ok { + t.Fatal("Schema missing Int64 'password_wo_version' attribute") + } +} + // Acceptance Tests - These tests require MAILGUN_API_KEY and make real API calls func TestAccSmtpCredentialResource(t *testing.T) { @@ -219,3 +252,57 @@ func testAccCheckSmtpCredentialDestroy(s *terraform.State) error { // This is a placeholder for more complex destroy checks if needed return nil } + +func testAccSmtpCredentialWriteOnlyConfig(domain, login, password string, version int) string { + return fmt.Sprintf(` +provider "mailgun" { + api_key = "%s" +} + +resource "mailgun_smtp_credential" "wo" { + domain = "%s" + login = "%s" + password_wo = "%s" + password_wo_version = %d +} +`, os.Getenv("MAILGUN_API_KEY"), domain, login, password, version) +} + +func TestAccSmtpCredentialResource_WriteOnly(t *testing.T) { + if os.Getenv("MAILGUN_API_KEY") == "" { + t.Skip("MAILGUN_API_KEY environment variable is not set") + } + domainName := os.Getenv("MAILGUN_TEST_DOMAIN") + if domainName == "" { + t.Skip("MAILGUN_TEST_DOMAIN environment variable is not set") + } + + loginName := test_helpers.RandomString(8) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { test_helpers.AccPreCheck(t) }, + ProtoV6ProviderFactories: test_helpers.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckSmtpCredentialDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSmtpCredentialWriteOnlyConfig(domainName, loginName, "wo-initial-123", 1), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("mailgun_smtp_credential.wo", "login", loginName), + resource.TestCheckResourceAttr("mailgun_smtp_credential.wo", "password_wo_version", "1"), + resource.TestCheckNoResourceAttr("mailgun_smtp_credential.wo", "password_wo"), + resource.TestCheckResourceAttrSet("mailgun_smtp_credential.wo", "created_at"), + ), + }, + { + Config: testAccSmtpCredentialWriteOnlyConfig(domainName, loginName, "wo-initial-123", 1), + PlanOnly: true, + }, + { + Config: testAccSmtpCredentialWriteOnlyConfig(domainName, loginName, "wo-rotated-456", 2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("mailgun_smtp_credential.wo", "password_wo_version", "2"), + ), + }, + }, + }) +} diff --git a/templates/resources/smtp_credential.md.tmpl b/templates/resources/smtp_credential.md.tmpl index 20e44f9..3a9bb33 100644 --- a/templates/resources/smtp_credential.md.tmpl +++ b/templates/resources/smtp_credential.md.tmpl @@ -15,6 +15,17 @@ description: |- {{tffile .ExampleFile }} {{- end }} +## Write-Only Password + +The `password_wo` and `password_wo_version` arguments provide a write-only credential workflow that keeps the secret out of Terraform state entirely: + +- Requires Terraform CLI >= 1.11. +- The value of `password_wo` is never stored in state or shown in plan output. +- Rotate the password by changing `password_wo` and incrementing `password_wo_version`. +- Credentials imported with `terraform import` have no password in state; use `password_wo` + `password_wo_version` to set one and enable future rotation. + +The legacy `password` argument is deprecated and will be removed in a future major release. + {{ .SchemaMarkdown | trimspace }} {{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }}