Repository: hashicorp/terraform-provider-vault

Issue:
Feature request: Support pki/config/auto-tidy
Related to #1437 (which got closed because it's a 1-time job), it would be nice if the module had support to configure auto-tidy via pki/config/auto-tidy endpoint (api docs: https://developer.hashicorp.com/vault/api-docs/secret/pki#configure-automatic-tidy)

Discussion:
@lucymhdavies: As a workaround, you could use the `vault_generic_endpoint` resource:
https://registry.terraform.io/providers/hashicorp/vault/latest/docs/resources/generic_endpoint

It's pretty useful for configuring arbitrary Vault endpoints for which there isn't an official resource yet.

Disclaimer: While I am a HashiCorp employee, I'm not on the Vault team, but I have used both Vault and this TF provider for many many years in production. @mdgreenfield: We use `vault_generic_endpoint` to work around this feature. Unfortunately, the `data_json` parameter is marked as `sensitive` and therefore results in a perpetual diff on every Terraform plan/apply. https://github.com/hashicorp/terraform-provider-vault/issues/1663 has some details on improvements I think which would help make `vault_generic_endpoint` better to avoid this.@lucymhdavies: Ah, good point. I should have probably included a code example here as, like you say, this perpetual diff problem is something easy to get into with this resource.

(And like you say, because of https://github.com/hashicorp/terraform-provider-vault/issues/1663 it's difficult to debug when this does happen)

I don't think the problem here is actually that the parameter is marked as `sensitive`, I think it could be because when you're reading back from Vault it doesn't have the same fields as the ones you've written, or in some other way does not exactly match.
But I'd need to see your particular TF config to know for sure.
(And that's maybe a conversation for the other Issue)


Here's my example config from my own Vault:

```
resource "vault_generic_endpoint" "pki_inter-auto-tidy" {
  path = "${vault_mount.pki_inter.path}/config/auto-tidy"

  # We're not configuring all available options here, just some of them
  # so ignore things which are returned from Vault which we didn't explicitly set
  ignore_absent_fields = true

  data_json = <<EOT
{
  "enabled": true,
  "tidy_cert_store": true,
  "safety_buffer": 86400
}
EOT
}
```

Using:

```
$ terraform version
Terraform v1.4.4
on darwin_amd64
..
+ provider registry.terraform.io/hashicorp/vault v3.12.0
```


First plan:

```
Terraform will perform the following actions:

  # vault_generic_endpoint.pki_inter-auto-tidy will be created
  + resource "vault_generic_endpoint" "pki_inter-auto-tidy" {
      + data_json            = (sensitive value)
      + disable_delete       = false
      + disable_read         = false
      + id                   = (known after apply)
      + ignore_absent_fields = true
      + path                 = "pki/inter/config/auto-tidy"
      + write_data           = (known after apply)
      + write_data_json      = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
```

Second plan:

```
No changes. Your infrastructure matches the configuration.
```



In my case, I was originally using the following `data_json`:

```
{
  "enabled": true,
  "tidy_cert_store": true,
  "safety_buffer": "24h"
}
```

which DID result in that perpetual diff issue you were seeing. But that's because the API was returning `"safety_buffer": 86400`@igor-nikiforov: I have exactly same configuration as @lucymhdavies but Terraform always detect drift in configuration after each apply even if `ignore_absent_fields = true`:

```hcl
resource "vault_generic_endpoint" "intermediate_ca_auto_tidy" {
  path = "${vault_mount.intermediate_ca.path}/config/auto-tidy"

  ignore_absent_fields = true
  data_json = <<EOT
{
  "enabled": true,
  "tidy_cert_store": true,
  "tidy_revoked_certs": true,
  "safety_buffer" : "1s",
  "interval_duration" : "3m"
}
EOT
}
```

Plan:

```hcl
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # vault_generic_endpoint.intermediate_ca_auto_tidy will be updated in-place
  ~ resource "vault_generic_endpoint" "intermediate_ca_auto_tidy" {
      ~ data_json            = (sensitive value)
        id                   = "intermediate-ca/config/auto-tidy"
        # (6 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.
```
@lucymhdavies: Check the API response from `pki/config/auto-tidy`

In my case, I found the duration fields return their value in seconds rather than duration strings.

for example, I when I had `"safety_buffer": "24h"`, the API responded with `"safety_buffer": 86400` (which TF identifies as a difference)

In your case, you have duration strings for `safety_buffer` and `interval_duration`, so if you switch those to integer number of seconds, then it may work.@igor-nikiforov: @lucymhdavies thanks for pointing this out! That's was exactly my issue. I think this should be highlighted somehow in docs for this resource to avoid any confusion.@Fuochi: There should be #1934 to address this, let's hope it'll be covered soon

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94e94d22..b8052f43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ FEATURES:
 * Add new data source `vault_pki_secret_backend_cert_metadata` and support for `cert_metadata` in `vault_pki_secret_backend_cert` and `vault_pki_secret_backend_sign` [#2422](https://github.com/hashicorp/terraform-provider-vault/pull/2422)
 * Add support for `max_crl_entries` in `vault_pki_secret_backend_crl_config` [#2423](https://github.com/hashicorp/terraform-provider-vault/pull/2423)
 * Add support for new Automated Root Rotation parameters to AWS Auth/Secrets and DB Secrets resources. Requires Vault Enterprise 1.19.0+ ([#2414](https://github.com/hashicorp/terraform-provider-vault/pull/2414)).
+* Add new resource `vault_pki_secret_backend_config_auto_tidy` to set PKI automatic tidy configuration [#1934](https://github.com/hashicorp/terraform-provider-vault/pull/1934)
 
 BUGS:
 
diff --git a/internal/consts/consts.go b/internal/consts/consts.go
index 48481941..b57a5066 100644
--- a/internal/consts/consts.go
+++ b/internal/consts/consts.go
@@ -500,6 +500,27 @@ const (
 	FieldSignatureContext         = "signature_context"
 	FieldDisableAutomatedRotation = "disable_automated_rotation"
 
+	FieldIntervalDuration                     = "interval_duration"
+	FieldMaintainStoredCertificateCounts      = "maintain_stored_certificate_counts"
+	FieldMinStartupBackoffDuration            = "min_startup_backoff_duration"
+	FieldMaxStartupBackoffDuration            = "max_startup_backoff_duration"
+	FieldPublishStoredCertificateCountMetrics = "publish_stored_certificate_count_metrics"
+	FieldTidyCertStore                        = "tidy_cert_store"
+	FieldTidyRevokedCerts                     = "tidy_revoked_certs"
+	FieldTidyRevokedCertIssuerAssociations    = "tidy_revoked_cert_issuer_associations"
+	FieldTidyExpiredIssuers                   = "tidy_expired_issuers"
+	FieldTidyMoveLegacyCaBundle               = "tidy_move_legacy_ca_bundle"
+	FieldTidyCrossClusterRevokedCerts         = "tidy_cross_cluster_revoked_certs"
+	FieldTidyCertMetadata                     = "tidy_cert_metadata"
+	FieldTidyCmpv2NonceStore                  = "tidy_cmpv2_nonce_store"
+	FieldTidyAcme                             = "tidy_acme"
+	FieldTidyRevocationQueue                  = "tidy_revocation_queue"
+	FieldSafetyBuffer                         = "safety_buffer"
+	FieldIssuerSafetyBuffer                   = "issuer_safety_buffer"
+	FieldAcmeAccountSafetyBuffer              = "acme_account_safety_buffer"
+	FieldPauseDuration                        = "pause_duration"
+	FieldRevocationQueueSafetyBuffer          = "revocation_queue_safety_buffer"
+
 	/*
 		common environment variables
 	*/
diff --git a/vault/provider.go b/vault/provider.go
index b560e973..1ce69620 100644
--- a/vault/provider.go
+++ b/vault/provider.go
@@ -623,6 +623,10 @@ var (
 			Resource:      UpdateSchemaResource(pkiSecretBackendConfigUrlsResource()),
 			PathInventory: []string{"/pki/config/urls"},
 		},
+		"vault_pki_secret_backend_config_auto_tidy": {
+			Resource:      UpdateSchemaResource(pkiSecretBackendConfigAutoTidyResource()),
+			PathInventory: []string{"/pki/config/auto-tidy"},
+		},
 		"vault_pki_secret_backend_intermediate_cert_request": {
 			Resource:      UpdateSchemaResource(pkiSecretBackendIntermediateCertRequestResource()),
 			PathInventory: []string{"/pki/intermediate/generate/{exported}"},
diff --git a/vault/resource_pki_secret_backend_config_auto_tidy.go b/vault/resource_pki_secret_backend_config_auto_tidy.go
new file mode 100644
index 00000000..5c0cbe1e
--- /dev/null
+++ b/vault/resource_pki_secret_backend_config_auto_tidy.go
@@ -0,0 +1,231 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package vault
+
+import (
+	"context"
+	"fmt"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/hashicorp/terraform-provider-vault/internal/consts"
+	"github.com/hashicorp/terraform-provider-vault/internal/provider"
+)
+
+func pkiSecretBackendConfigAutoTidyResource() *schema.Resource {
+	return &schema.Resource{
+		CreateContext: provider.MountCreateContextWrapper(pkiSecretBackendConfigAutoTidyCreateUpdate, provider.VaultVersion112),
+		ReadContext:   provider.ReadContextWrapper(pkiSecretBackendConfigAutoTidyRead),
+		UpdateContext: pkiSecretBackendConfigAutoTidyCreateUpdate,
+		DeleteContext: pkiSecretBackendConfigAutoTidyDelete,
+		Importer: &schema.ResourceImporter{
+			StateContext: schema.ImportStatePassthroughContext,
+		},
+		Schema: pkiSecretBackendConfigAutoTidySchema(),
+	}
+}
+
+var pkiSecretBackendConfigAutoTidyDurationFields = map[string]string{
+	consts.FieldMinStartupBackoffDuration:   "The minimum amount of time auto-tidy will be delayed after startup.",
+	consts.FieldMaxStartupBackoffDuration:   "The maximum amount of time auto-tidy will be delayed after startup.",
+	consts.FieldIntervalDuration:            "Interval at which to run an auto-tidy operation. This is the time between tidy invocations (after one finishes to the start of the next).",
+	consts.FieldSafetyBuffer:                "The amount of extra time that must have passed beyond certificate expiration before it is removed from the backend storage and/or revocation list.",
+	consts.FieldIssuerSafetyBuffer:          "The amount of extra time that must have passed beyond issuer's expiration before it is removed from the backend storage.",
+	consts.FieldAcmeAccountSafetyBuffer:     "The amount of time that must pass after creation that an account with no orders is marked revoked, and the amount of time after being marked revoked or deactivated.",
+	consts.FieldPauseDuration:               "The amount of time to wait between processing certificates.",
+	consts.FieldRevocationQueueSafetyBuffer: "The amount of time that must pass from the cross-cluster revocation request being initiated to when it will be slated for removal.",
+}
+
+var pkiSecretBackendConfigAutoTidyBoolFields = map[string]string{
+	consts.FieldEnabled:                              "Specifies whether automatic tidy is enabled or not.",
+	consts.FieldMaintainStoredCertificateCounts:      "This configures whether stored certificate are counted upon initialization of the backend, and whether during normal operation, a running count of certificates stored is maintained.",
+	consts.FieldPublishStoredCertificateCountMetrics: "This configures whether the stored certificate count is published to the metrics consumer.",
+	consts.FieldTidyCertStore:                        "Set to true to enable tidying up the certificate store",
+	consts.FieldTidyRevokedCerts:                     "Set to true to remove all invalid and expired certificates from storage. A revoked storage entry is considered invalid if the entry is empty, or the value within the entry is empty. If a certificate is removed due to expiry, the entry will also be removed from the CRL, and the CRL will be rotated.",
+	consts.FieldTidyRevokedCertIssuerAssociations:    "Set to true to validate issuer associations on revocation entries. This helps increase the performance of CRL building and OCSP responses.",
+	consts.FieldTidyExpiredIssuers:                   "Set to true to automatically remove expired issuers past the issuer_safety_buffer. No keys will be removed as part of this operation.",
+	consts.FieldTidyMoveLegacyCaBundle:               "Set to true to move the legacy ca_bundle from /config/ca_bundle to /config/ca_bundle.bak.",
+	consts.FieldTidyAcme:                             "Set to true to enable tidying ACME accounts, orders and authorizations.",
+	consts.FieldTidyRevocationQueue:                  "Set to true to remove stale revocation queue entries that haven't been confirmed by any active cluster.",
+	consts.FieldTidyCrossClusterRevokedCerts:         "Set to true to enable tidying up the cross-cluster revoked certificate store.",
+	consts.FieldTidyCertMetadata:                     "Set to true to enable tidying up certificate metadata.",
+	consts.FieldTidyCmpv2NonceStore:                  "Set to true to enable tidying up the CMPv2 nonce store.",
+}
+
+func pkiSecretBackendConfigAutoTidySchema() map[string]*schema.Schema {
+	var atLeastOneTidyOperation []string
+	var isOneOfField func(string) bool
+	{
+		atLeastOneOfSet := make(map[string]any)
+		for field := range pkiSecretBackendConfigAutoTidyBoolFields {
+			if strings.HasPrefix(field, "tidy_") {
+				atLeastOneTidyOperation = append(atLeastOneTidyOperation, field)
+				atLeastOneOfSet[field] = nil
+			}
+		}
+		isOneOfField = func(field string) bool {
+			_, ok := atLeastOneOfSet[field]
+			return ok
+		}
+	}
+
+	ret := map[string]*schema.Schema{
+		consts.FieldBackend: {
+			Type:        schema.TypeString,
+			Required:    true,
+			Description: "The path of the PKI secret backend the resource belongs to.",
+		},
+	}
+
+	// Add the boolean fields
+	for field, desc := range pkiSecretBackendConfigAutoTidyBoolFields {
+		ret[field] = &schema.Schema{
+			Type:        schema.TypeBool,
+			Optional:    true,
+			Description: desc,
+		}
+		if isOneOfField(field) {
+			ret[field].AtLeastOneOf = atLeastOneTidyOperation
+		}
+	}
+	ret[consts.FieldEnabled].Optional = false
+	ret[consts.FieldEnabled].Required = true
+
+	// Add the duration fields
+	for field, desc := range pkiSecretBackendConfigAutoTidyDurationFields {
+		ret[field] = &schema.Schema{
+			Type:                  schema.TypeString,
+			Optional:              true,
+			Computed:              true,
+			Description:           desc,
+			ValidateFunc:          provider.ValidateDuration,
+			DiffSuppressFunc:      pkiSecretBackendConfigAutoTidySuppressDurationDiff,
+			DiffSuppressOnRefresh: true,
+		}
+	}
+	return ret
+}
+
+// pkiSecretBackendConfigAutoTidySuppressDurationDiff takes care of adjusting for auto-tidy's bad
+// behaviour regarding duration fields. Although the fields can be specified with duration
+// strings (e.g. "1h2m3s"), the value is always returned as the number of seconds. There is
+// one exception: pause_duration.
+func pkiSecretBackendConfigAutoTidySuppressDurationDiff(key, oldValue, newValue string, _ *schema.ResourceData) bool {
+	if _, isDuration := pkiSecretBackendConfigAutoTidyDurationFields[key]; !isDuration {
+		return false
+	}
+	// The old value is what is returned by auto-tidy config. It will be either the empty
+	// string or the number of seconds. In the case of pause_duration which does return
+	// a duration string, Atoi() will fail making this function return false, which is
+	// the correct thing to do since we don't want to suppress the diff in that case.
+	seconds, err := strconv.Atoi(oldValue)
+	if err != nil {
+		return false
+	}
+	// The new value is what we have in the state, which will be a duration string.
+	duration, err := time.ParseDuration(newValue)
+	if err != nil {
+		return false
+	}
+	return seconds == int(duration.Seconds())
+}
+
+func pkiSecretBackendConfigAutoTidyCreateUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client, e := provider.GetClient(d, meta)
+	if e != nil {
+		return diag.FromErr(e)
+	}
+
+	backend := d.Get(consts.FieldBackend).(string)
+
+	path := pkiSecretBackendConfigAutoTidyPath(backend)
+
+	action := "Create"
+	if !d.IsNewResource() {
+		action = "Update"
+	}
+
+	data := map[string]interface{}{}
+
+	for k, s := range pkiSecretBackendConfigAutoTidySchema() {
+		if k == consts.FieldBackend {
+			continue
+		}
+		if s.Type == schema.TypeBool {
+			data[k] = d.Get(k)
+		} else { // all other fields are duration strings
+			if v, ok := d.GetOk(k); ok {
+				data[k] = v
+			}
+		}
+	}
+
+	log.Printf("[DEBUG] %s auto tidy config on PKI secret backend %q", action, backend)
+	_, err := client.Logical().Write(path, data)
+	if err != nil {
+		return diag.Errorf("error writing PKI auto tidy config to %q: %s", backend, err)
+	}
+	log.Printf("[DEBUG] %sd auto tidy config on PKI secret backend %q", action, backend)
+
+	if d.IsNewResource() {
+		d.SetId(fmt.Sprintf("%s/config/auto-tidy", backend))
+	}
+
+	return pkiSecretBackendConfigAutoTidyRead(ctx, d, meta)
+}
+
+func pkiSecretBackendConfigAutoTidyRead(_ context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client, e := provider.GetClient(d, meta)
+	if e != nil {
+		return diag.FromErr(e)
+	}
+
+	path := d.Id()
+
+	if path == "" {
+		return diag.Errorf("no path set, id=%q", d.Id())
+	}
+
+	backend, found := strings.CutSuffix(path, "/config/auto-tidy")
+	if !found {
+		return diag.Errorf("error parsing backend from ID %s", path)
+	}
+	if err := d.Set(consts.FieldBackend, backend); err != nil {
+		return diag.FromErr(err)
+	}
+
+	log.Printf("[DEBUG] Reading auto tidy config from PKI secret path %q", path)
+	resp, err := client.Logical().Read(path)
+	if err != nil {
+		return diag.Errorf("error reading auto tidy config on PKI secret backend %q: %s", path, err)
+	}
+	if resp == nil {
+		return diag.Errorf("got nil response from Vault from path: %q", path)
+	}
+
+	for k := range pkiSecretBackendConfigAutoTidySchema() {
+		if k == consts.FieldBackend {
+			continue
+		}
+		if v, ok := resp.Data[k]; ok {
+			if err := d.Set(k, v); err != nil {
+				return diag.FromErr(err)
+			}
+		}
+	}
+
+	return nil
+}
+
+func pkiSecretBackendConfigAutoTidyDelete(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics {
+	return nil
+}
+
+func pkiSecretBackendConfigAutoTidyPath(backend string) string {
+	return strings.Trim(backend, "/") + "/config/auto-tidy"
+}
diff --git a/website/docs/r/pki_secret_backend_config_auto_tidy.html.md b/website/docs/r/pki_secret_backend_config_auto_tidy.html.md
new file mode 100644
index 00000000..1688ac0c
--- /dev/null
+++ b/website/docs/r/pki_secret_backend_config_auto_tidy.html.md
@@ -0,0 +1,98 @@
+---
+layout: "vault"
+page_title: "Vault: vault_pki_secret_backend_config_auto_tidy resource"
+sidebar_current: "docs-vault-resource-pki-secret-backend-config-auto-tidy"
+description: |-
+  Sets the Auto Tidy configuration on a PKI Secret Backend for Vault.
+---
+
+# vault\_pki\_secret\_backend\_config\_auto\_tidy
+
+Allows setting the Auto Tidy configuration on a PKI Secret Backend
+
+## Example Usage
+
+```hcl
+resource "vault_mount" "pki" {
+  path                      = "pki"
+  type                      = "pki"
+  default_lease_ttl_seconds = 3600
+  max_lease_ttl_seconds     = 86400
+}
+
+resource "vault_pki_secret_backend_config_auto_tidy" "test" {
+  backend = vault_mount.pki.path
+  enabled = true
+  tidy_cert_store = true
+  interval_duration = "1h"
+}
+```
+
+## Argument Reference
+
+The following arguments are supported (at least one tidy operation is required):
+
+* `namespace` - (Optional) The namespace of the target resource.
+  The value should not contain leading or trailing forward slashes.
+  The `namespace` is always relative to the provider's configured [namespace](/docs/providers/vault/index.html#namespace).
+  *Available only for Vault Enterprise*.
+
+* `backend` - (Required) The path to the PKI secret backend to
+  read the configuration from, with no leading or trailing `/`s.
+
+* `enabled` - (Optional) Specifies whether automatic tidy is enabled or not.
+
+* `interval_duration` - (Optional) Interval at which to run an auto-tidy operation. This is the time
+  between tidy invocations (after one finishes to the start of the next).
+
+* `issuer_safety_buffer` - (Optional) The amount of extra time that must have passed beyond issuer's
+  expiration before it is removed from the backend storage.
+
+* `maintain_stored_certificate_counts` - (Optional) This configures whether stored certificate are
+  counted upon initialization of the backend, and whether during normal operation, a running count
+  of certificates stored is maintained.
+
+* `max_startup_backoff_duration` - (Optional) The maximum amount of time auto-tidy will be delayed
+  after startup.
+
+* `min_startup_backoff_duration` - (Optional) The minimum amount of time auto-tidy will be delayed
+  after startup.
+
+* `pause_duration` - (Optional) The amount of time to wait between processing certificates.
+
+* `publish_stored_certificate_count_metrics` - (Optional) This configures whether the stored
+  certificate count is published to the metrics consumer.
+
+* `revocation_queue_safety_buffer` - (Optional) The amount of time that must pass from the
+  cross-cluster revocation request being initiated to when it will be slated for removal.
+
+* `safety_buffer` - (Optional) The amount of extra time that must have passed beyond certificate
+  expiration before it is removed from the backend storage and/or revocation list.
+
+* `tidy_acme` - (Optional) Set to true to enable tidying ACME accounts, orders and authorizations.
+
+* `tidy_cert_metadata` - (Optional) Set to true to enable tidying up certificate metadata.
+
+* `tidy_cert_store` - (Optional) Set to true to enable tidying up the certificate store
+
+* `tidy_cmpv2_nonce_store` - (Optional) Set to true to enable tidying up the CMPv2 nonce store.
+
+* `tidy_cross_cluster_revoked_certs` - (Optional) Set to true to enable tidying up the cross-cluster
+  revoked certificate store.
+
+* `tidy_expired_issuers` - (Optional) Set to true to automatically remove expired issuers past the
+  `issuer_safety_buffer`. No keys will be removed as part of this operation.
+
+* `tidy_move_legacy_ca_bundle` - (Optional) Set to true to move the legacy `ca_bundle` from
+  `/config/ca_bundle` to `/config/ca_bundle.bak`.
+
+* `tidy_revocation_queue` - (Optional) Set to true to remove stale revocation queue entries that
+  haven't been confirmed by any active cluster.
+
+* `tidy_revoked_cert_issuer_associations` - (Optional) Set to true to validate issuer associations
+  on revocation entries. This helps increase the performance of CRL building and OCSP responses.
+
+* `tidy_revoked_certs` - (Optional) Set to true to remove all invalid and expired certificates from
+  storage. A revoked storage entry is considered invalid if the entry is empty, or the value within
+  the entry is empty. If a certificate is removed due to expiry, the entry will also be removed from
+  the CRL, and the CRL will be rotated.
diff --git a/website/vault.erb b/website/vault.erb
index bb237f84..24dbb770 100644
--- a/website/vault.erb
+++ b/website/vault.erb
@@ -561,6 +561,10 @@
                             <a href="/docs/providers/vault/r/pki_secret_backend_config_acme.html">vault_pki_secret_backend_config_acme</a>
                         </li>
 
+                        <li<%= sidebar_current("docs-vault-resource-pki-secret-backend-config-auto-tidy") %>>
+                            <a href="/docs/providers/vault/r/pki_secret_backend_config_auto_tidy.html">vault_pki_secret_backend_config_auto_tidy</a>
+                        </li>
+
                         <li<%= sidebar_current("docs-vault-resource-pki-secret-backend-config-ca") %>>
                             <a href="/docs/providers/vault/r/pki_secret_backend_config_ca.html">vault_pki_secret_backend_config_ca</a>
                         </li>



Test patch:
diff --git a/vault/resource_pki_secret_backend_config_auto_tidy_test.go b/vault/resource_pki_secret_backend_config_auto_tidy_test.go
new file mode 100644
index 00000000..6351dab7
--- /dev/null
+++ b/vault/resource_pki_secret_backend_config_auto_tidy_test.go
@@ -0,0 +1,380 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package vault
+
+import (
+	"fmt"
+	"github.com/hashicorp/go-version"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/hashicorp/terraform-provider-vault/internal/provider"
+	"github.com/stretchr/testify/require"
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+
+	"github.com/hashicorp/terraform-provider-vault/internal/consts"
+	"github.com/hashicorp/terraform-provider-vault/testutil"
+)
+
+func TestPkiSecretBackendConfigAutoTidySuppressDurationDiff(t *testing.T) {
+	type testCase struct {
+		oldValue string
+		newValue string
+		expected bool
+	}
+	same := func(oldValue, newValue string) testCase {
+		return testCase{oldValue, newValue, true}
+	}
+	diff := func(oldValue, newValue string) testCase {
+		return testCase{oldValue, newValue, false}
+	}
+	testCases := []testCase{
+		diff("", "1"),
+		diff("1", ""),
+		same("1", "1s"),
+		same("1", "0h0m1s"),
+		diff("1s", "2s"),
+		same("60", "1m"),
+		same("3600", "1h"),
+		same("61", "1m1s"),
+	}
+	for _, tc := range testCases {
+		require.Equal(t, tc.expected,
+			pkiSecretBackendConfigAutoTidySuppressDurationDiff("interval_duration", tc.oldValue, tc.newValue, nil),
+			"test case %v", tc)
+	}
+}
+
+func TestAccPKISecretBackendConfigAutoTidy_basic(t *testing.T) {
+	backend := acctest.RandomWithPrefix("tf-test-pki")
+	resourceType := "vault_pki_secret_backend_config_auto_tidy"
+	resourceName := resourceType + ".test"
+
+	checkAttributes := func(expected string, fields ...string) resource.TestCheckFunc {
+		return func(s *terraform.State) error {
+			meta := testProvider.Meta().(*provider.ProviderMeta)
+			var checks []resource.TestCheckFunc
+			for _, f := range fields {
+				switch {
+				case f == consts.FieldTidyCertMetadata && !meta.IsAPISupported(provider.VaultVersion117):
+				case f == consts.FieldTidyCmpv2NonceStore && !meta.IsAPISupported(provider.VaultVersion118):
+				case f == consts.FieldMaxStartupBackoffDuration && !meta.IsAPISupported(provider.VaultVersion118):
+				case f == consts.FieldMinStartupBackoffDuration && !meta.IsAPISupported(provider.VaultVersion118):
+				default:
+					checks = append(checks, resource.TestCheckResourceAttr(resourceName, f, expected))
+				}
+			}
+			return resource.ComposeTestCheckFunc(checks...)(s)
+		}
+	}
+
+	getImportTestStep := func(minVer *version.Version, ignoreFields ...string) resource.TestStep {
+		ret := testutil.GetImportTestStep(resourceName, false, nil, ignoreFields...)
+		if minVer != nil {
+			ret.SkipFunc = func() (bool, error) {
+				meta := testProvider.Meta().(*provider.ProviderMeta)
+				return !meta.IsAPISupported(minVer), nil
+			}
+		}
+		return ret
+	}
+
+	allAttributesSetCheck := func(s *terraform.State) error {
+		meta := testProvider.Meta().(*provider.ProviderMeta)
+		for field := range pkiSecretBackendConfigAutoTidySchema() {
+			switch {
+			case field == consts.FieldTidyCertMetadata && !meta.IsAPISupported(provider.VaultVersion117):
+			case field == consts.FieldTidyCmpv2NonceStore && !meta.IsAPISupported(provider.VaultVersion118):
+			case field == consts.FieldMaxStartupBackoffDuration && !meta.IsAPISupported(provider.VaultVersion118):
+			case field == consts.FieldMinStartupBackoffDuration && !meta.IsAPISupported(provider.VaultVersion118):
+			default:
+				err := resource.TestCheckResourceAttrSet(resourceName, field)(s)
+				if err != nil {
+					return err
+				}
+			}
+		}
+		return nil
+	}
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: providerFactories,
+		PreCheck:          func() { testutil.TestAccPreCheck(t) },
+		CheckDestroy:      testCheckMountDestroyed(resourceType, consts.MountTypePKI, consts.FieldBackend),
+		Steps: []resource.TestStep{
+			{
+				Config:      testAccPKISecretBackendConfigAutoTidy_basic(backend, `enabled = true`),
+				ExpectError: regexp.MustCompile("Missing required argument"),
+			},
+			{
+				// Simplest call with enabled = false
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = false
+tidy_cert_store = true
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("false", consts.FieldEnabled),
+					checkAttributes("true", consts.FieldTidyCertStore),
+					allAttributesSetCheck,
+				),
+			},
+			{
+				// Enable a couple of tidy operations
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_cert_store = true
+tidy_acme = true
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore,
+						consts.FieldTidyAcme),
+					checkAttributes("false",
+						consts.FieldTidyRevokedCerts,
+						consts.FieldTidyRevokedCertIssuerAssociations,
+						consts.FieldTidyExpiredIssuers,
+						consts.FieldTidyMoveLegacyCaBundle,
+						consts.FieldTidyRevocationQueue,
+						consts.FieldTidyCrossClusterRevokedCerts,
+						consts.FieldTidyCertMetadata,
+						consts.FieldTidyCmpv2NonceStore,
+					),
+				),
+			},
+			{
+				// Enable all tidy operations (minus the ENT ones: tidy_cert_metadata and tidy_cmpv2_nonce_store)
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_cert_store = true
+tidy_revoked_certs = true
+tidy_revoked_cert_issuer_associations = true
+tidy_expired_issuers = true
+tidy_move_legacy_ca_bundle = true
+tidy_acme = true
+tidy_revocation_queue = true
+tidy_cross_cluster_revoked_certs = true
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore,
+						consts.FieldTidyRevokedCerts,
+						consts.FieldTidyRevokedCertIssuerAssociations,
+						consts.FieldTidyExpiredIssuers,
+						consts.FieldTidyMoveLegacyCaBundle,
+						consts.FieldTidyAcme,
+						consts.FieldTidyRevocationQueue,
+						consts.FieldTidyCrossClusterRevokedCerts,
+					),
+				),
+			},
+			{
+				// Set a duration attribute
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_cert_store = true
+interval_duration = "123s"
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore),
+					checkAttributes("123", consts.FieldIntervalDuration),
+				),
+			},
+			testutil.GetImportTestStep(resourceName, false, nil),
+			{
+				// Change a duration attribute
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_cert_store = true
+interval_duration = "3m"
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore),
+					checkAttributes("180", consts.FieldIntervalDuration),
+				),
+			},
+			{
+				// Set the non-tidy bool fields
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_acme = true
+maintain_stored_certificate_counts = true
+publish_stored_certificate_count_metrics = true
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyAcme,
+						consts.FieldMaintainStoredCertificateCounts,
+						consts.FieldPublishStoredCertificateCountMetrics),
+				),
+			},
+			{
+				// Set all the duration fields, pre 1.18 (no min/max startup backoff duration)
+				SkipFunc: func() (bool, error) {
+					meta := testProvider.Meta().(*provider.ProviderMeta)
+					return meta.GetVaultVersion().LessThan(provider.VaultVersion118), nil
+				},
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_acme = true
+acme_account_safety_buffer = "10000s"
+issuer_safety_buffer = "12000s"
+pause_duration = "4m2s"
+revocation_queue_safety_buffer = "2800s"
+safety_buffer = "59000s"
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyAcme,
+					),
+					checkAttributes("10000", consts.FieldAcmeAccountSafetyBuffer),
+					checkAttributes("12000", consts.FieldIssuerSafetyBuffer),
+					checkAttributes("4m2s", consts.FieldPauseDuration), // Interesting, pause_duration behaves correctly
+					checkAttributes("2800", consts.FieldRevocationQueueSafetyBuffer),
+					checkAttributes("59000", consts.FieldSafetyBuffer),
+				),
+			},
+			{
+				// Set all the duration fields 1.18+
+				SkipFunc: func() (bool, error) {
+					meta := testProvider.Meta().(*provider.ProviderMeta)
+					return meta.GetVaultVersion().GreaterThanOrEqual(provider.VaultVersion118), nil
+				},
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_acme = true
+acme_account_safety_buffer = "10000s"
+issuer_safety_buffer = "12000s"
+max_startup_backoff_duration = "15000s"
+min_startup_backoff_duration = "1000s"
+pause_duration = "4m2s"
+revocation_queue_safety_buffer = "2800s"
+safety_buffer = "59000s"
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyAcme,
+					),
+					checkAttributes("10000", consts.FieldAcmeAccountSafetyBuffer),
+					checkAttributes("12000", consts.FieldIssuerSafetyBuffer),
+					checkAttributes("15000", consts.FieldMaxStartupBackoffDuration),
+					checkAttributes("1000", consts.FieldMinStartupBackoffDuration),
+					checkAttributes("4m2s", consts.FieldPauseDuration), // Interesting, pause_duration behaves correctly
+					checkAttributes("2800", consts.FieldRevocationQueueSafetyBuffer),
+					checkAttributes("59000", consts.FieldSafetyBuffer),
+				),
+			},
+			{
+				// This is the example in the documentation
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+  enabled = true
+  tidy_cert_store = true
+  interval_duration = "1h"
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore,
+					),
+					checkAttributes("3600", consts.FieldIntervalDuration),
+				),
+			},
+			getImportTestStep(provider.VaultVersion118),
+			getImportTestStep(provider.VaultVersion117, consts.FieldTidyCmpv2NonceStore, consts.FieldMinStartupBackoffDuration, consts.FieldMaxStartupBackoffDuration),
+			getImportTestStep(nil, consts.FieldTidyCertMetadata, consts.FieldTidyCmpv2NonceStore, consts.FieldMinStartupBackoffDuration, consts.FieldMaxStartupBackoffDuration),
+		},
+	})
+}
+
+func TestAccPKISecretBackendConfigAutoTidy_ent(t *testing.T) {
+	backend := acctest.RandomWithPrefix("tf-test-pki")
+	resourceType := "vault_pki_secret_backend_config_auto_tidy"
+	resourceName := resourceType + ".test"
+
+	checkAttributes := func(expected string, fields ...string) resource.TestCheckFunc {
+		var checks []resource.TestCheckFunc
+		for _, f := range fields {
+			checks = append(checks, resource.TestCheckResourceAttr(resourceName, f, expected))
+		}
+		return resource.ComposeTestCheckFunc(checks...)
+	}
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: providerFactories,
+		PreCheck: func() {
+			testutil.TestAccPreCheck(t)
+			testutil.TestEntPreCheck(t)
+			SkipIfAPIVersionLT(t, testProvider.Meta(), provider.VaultVersion117)
+		},
+		CheckDestroy: testCheckMountDestroyed(resourceType, consts.MountTypePKI, consts.FieldBackend),
+		Steps: []resource.TestStep{
+			{
+				// Enable all tidy operations
+				Config: testAccPKISecretBackendConfigAutoTidy_basic(backend, `
+enabled = true
+tidy_cert_store = true
+tidy_revoked_certs = true
+tidy_revoked_cert_issuer_associations = true
+tidy_expired_issuers = true
+tidy_move_legacy_ca_bundle = true
+tidy_acme = true
+tidy_revocation_queue = true
+tidy_cross_cluster_revoked_certs = true
+tidy_cert_metadata = true
+# tidy_cmpv2_nonce_store = true # TODO: Enable once VAULT-34539 is fixed 
+`),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend),
+					checkAttributes("true",
+						consts.FieldEnabled,
+						consts.FieldTidyCertStore,
+						consts.FieldTidyRevokedCerts,
+						consts.FieldTidyRevokedCertIssuerAssociations,
+						consts.FieldTidyExpiredIssuers,
+						consts.FieldTidyMoveLegacyCaBundle,
+						consts.FieldTidyAcme,
+						consts.FieldTidyRevocationQueue,
+						consts.FieldTidyCrossClusterRevokedCerts,
+						consts.FieldTidyCertMetadata,
+						// consts.FieldTidyCmpv2NonceStore, // TODO: Enable once VAULT-34539 is fixed (and set min req version)
+					),
+				),
+			},
+			testutil.GetImportTestStep(resourceName, false, nil),
+		},
+	})
+
+}
+
+func testAccPKISecretBackendConfigAutoTidy_basic(path, fields string) string {
+	return fmt.Sprintf(`
+resource "vault_mount" "test" {
+	path        = "%s"
+	type        = "pki"
+    description = "PKI secret engine mount"
+}
+
+resource "vault_pki_secret_backend_config_auto_tidy" "test" {
+  backend = vault_mount.test.path
+  %s
+}`, path, fields)
+}