diff --git a/CHANGELOG.md b/CHANGELOG.md index f758c0f52..04937f440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ FEATURES: * Update `vault_database_secret_backend_connection`to support `password_authentication` for PostgreSQL, allowing to encrypt password before being passed to PostgreSQL ([#2371](https://github.com/hashicorp/terraform-provider-vault/pull/2371)) * Add support for `external_id` field for the `vault_aws_auth_backend_sts_role` resource ([#2370](https://github.com/hashicorp/terraform-provider-vault/pull/2370)) +* Add support for ACME configuration with the `vault_pki_secret_backend_config_acme` resource. Requires Vault 1.14+ ([#2157](https://github.com/hashicorp/terraform-provider-vault/pull/2157)). ## 4.5.0 (Nov 19, 2024) diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 1fab9e141..86a56b734 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -438,6 +438,12 @@ const ( FieldMaxRetries = "max_retries" FieldSessionTags = "session_tags" FieldSelfManagedPassword = "self_managed_password" + FieldAllowedIssuers = "allowed_issuers" + FieldAllowedRoles = "allowed_roles" + FieldAllowRoleExtKeyUsage = "allow_role_ext_key_usage" + FieldDefaultDirectoryPolicy = "default_directory_policy" + FieldDnsResolver = "dns_resolver" + FieldEabPolicy = "eab_policy" /* common environment variables diff --git a/vault/provider.go b/vault/provider.go index f64faf555..9c3658da1 100644 --- a/vault/provider.go +++ b/vault/provider.go @@ -579,6 +579,10 @@ var ( Resource: UpdateSchemaResource(pkiSecretBackendCrlConfigResource()), PathInventory: []string{"/pki/config/crl"}, }, + "vault_pki_secret_backend_config_acme": { + Resource: UpdateSchemaResource(pkiSecretBackendConfigACMEResource()), + PathInventory: []string{"/pki/config/acme"}, + }, "vault_pki_secret_backend_config_ca": { Resource: UpdateSchemaResource(pkiSecretBackendConfigCAResource()), PathInventory: []string{"/pki/config/ca"}, diff --git a/vault/resource_pki_secret_backend_config_acme.go b/vault/resource_pki_secret_backend_config_acme.go new file mode 100644 index 000000000..a72e11467 --- /dev/null +++ b/vault/resource_pki_secret_backend_config_acme.go @@ -0,0 +1,209 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vault + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/provider" +) + +var ( + pkiSecretBackendFromConfigACMERegex = regexp.MustCompile("^(.+)/config/acme$") + pkiAcmeFields = []string{ + consts.FieldEnabled, + consts.FieldDefaultDirectoryPolicy, + consts.FieldAllowedRoles, + consts.FieldAllowRoleExtKeyUsage, + consts.FieldAllowedIssuers, + consts.FieldEabPolicy, + consts.FieldDnsResolver, + } +) + +func pkiSecretBackendConfigACMEResource() *schema.Resource { + return &schema.Resource{ + CreateContext: provider.MountCreateContextWrapper(pkiSecretBackendConfigACMECreate, provider.VaultVersion113), + ReadContext: provider.ReadContextWrapper(pkiSecretBackendConfigACMERead), + UpdateContext: pkiSecretBackendConfigACMEUpdate, + DeleteContext: pkiSecretBackendConfigACMEDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + consts.FieldBackend: { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Full path where PKI backend is mounted.", + // standardise on no beginning or trailing slashes + StateFunc: func(v interface{}) string { + return strings.Trim(v.(string), "/") + }, + }, + consts.FieldEnabled: { + Type: schema.TypeBool, + Required: true, + Description: "Specifies whether ACME is enabled.", + }, + consts.FieldDefaultDirectoryPolicy: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Specifies the policy to be used for non-role-qualified ACME requests.", + }, + consts.FieldAllowedRoles: { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "Specifies which roles are allowed for use with ACME.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + consts.FieldAllowRoleExtKeyUsage: { + Type: schema.TypeBool, + Optional: true, + Description: "Specifies whether the ExtKeyUsage field from a role is used.", + }, + consts.FieldAllowedIssuers: { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "Specifies which issuers are allowed for use with ACME.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + consts.FieldEabPolicy: { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Specifies the policy to use for external account binding behaviour.", + }, + consts.FieldDnsResolver: { + Type: schema.TypeString, + Optional: true, + Description: "DNS resolver to use for domain resolution on this mount. " + + "Must be in the format :, with both parts mandatory.", + }, + }, + } +} + +func pkiSecretBackendConfigACMECreate(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 := fmt.Sprintf("%s/config/acme", backend) + + resp, err := client.Logical().ReadWithContext(ctx, path) + if err != nil { + return diag.Errorf("error reading acme config at %s, err=%s", path, err) + } + + if resp == nil { + return diag.Errorf("no acme config found at path %s", path) + } + + d.SetId(path) + + return pkiSecretBackendConfigACMEUpdate(ctx, d, meta) +} + +func pkiSecretBackendConfigACMEUpdate(ctx 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() + + var patchRequired bool + data := map[string]interface{}{} + for _, k := range pkiAcmeFields { + if d.HasChange(k) { + data[k] = d.Get(k) + patchRequired = true + } + } + + if patchRequired { + _, err := client.Logical().WriteWithContext(ctx, path, data) + if err != nil { + return diag.Errorf("error writing data to %q, err=%s", path, err) + } + } + + return pkiSecretBackendConfigACMERead(ctx, d, meta) +} + +func pkiSecretBackendConfigACMERead(ctx 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()) + } + + // get backend from full path + backend, err := pkiSecretBackendFromConfigACME(path) + if err != nil { + return diag.FromErr(err) + } + + log.Printf("[DEBUG] Reading %s from Vault", path) + resp, err := client.Logical().ReadWithContext(ctx, path) + if err != nil { + return diag.Errorf("error reading from Vault: %s", err) + } + + if resp == nil { + return diag.Errorf("got nil response from Vault from path: %q", path) + } + + // set backend and issuerRef + if err := d.Set(consts.FieldBackend, backend); err != nil { + return diag.FromErr(err) + } + + for _, k := range pkiAcmeFields { + if err := d.Set(k, resp.Data[k]); err != nil { + return diag.Errorf("error setting state key %q for acme config, err=%s", + k, err) + } + } + + return nil +} + +func pkiSecretBackendConfigACMEDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + return nil +} + +func pkiSecretBackendFromConfigACME(path string) (string, error) { + if !pkiSecretBackendFromConfigACMERegex.MatchString(path) { + return "", fmt.Errorf("no backend found") + } + res := pkiSecretBackendFromConfigACMERegex.FindStringSubmatch(path) + if len(res) != 2 { + return "", fmt.Errorf("unexpected number of matches (%d) for backend", len(res)) + } + return res[1], nil +} diff --git a/vault/resource_pki_secret_backend_config_acme_test.go b/vault/resource_pki_secret_backend_config_acme_test.go new file mode 100644 index 000000000..b646f4ed9 --- /dev/null +++ b/vault/resource_pki_secret_backend_config_acme_test.go @@ -0,0 +1,113 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package vault + +import ( + "fmt" + "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/internal/provider" + "github.com/hashicorp/terraform-provider-vault/testutil" +) + +func TestPkiSecretBackendConfigACME_basic(t *testing.T) { + backend := acctest.RandomWithPrefix("pki-root") + resourceType := "vault_pki_secret_backend_config_acme" + resourceName := resourceType + ".test" + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + PreCheck: func() { + testutil.TestAccPreCheck(t) + SkipIfAPIVersionLT(t, testProvider.Meta(), provider.VaultVersion114) + }, + CheckDestroy: testCheckMountDestroyed(resourceType, consts.MountTypePKI, consts.FieldBackend), + Steps: []resource.TestStep{ + { + Config: testPkiSecretBackendConfigACME(backend, "sign-verbatim", "*", "*", "not-required", "", + false, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend), + resource.TestCheckResourceAttr(resourceName, consts.FieldEnabled, "false"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDefaultDirectoryPolicy, "sign-verbatim"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedRoles+".0", "*"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedIssuers+".0", "*"), + resource.TestCheckResourceAttr(resourceName, consts.FieldEabPolicy, "not-required"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDnsResolver, ""), + ), + }, + { + Config: testPkiSecretBackendConfigACME(backend, "forbid", "test", "*", "new-account-required", + "1.1.1.1:8443", true, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend), + resource.TestCheckResourceAttr(resourceName, consts.FieldEnabled, "true"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDefaultDirectoryPolicy, "forbid"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedRoles+".0", "test"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedIssuers+".0", "*"), + resource.TestCheckResourceAttr(resourceName, consts.FieldEabPolicy, "new-account-required"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDnsResolver, "1.1.1.1:8443"), + ), + }, + { + Config: testPkiSecretBackendConfigACME(backend, "role:test", "*", "*", "always-required", "", + true, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, consts.FieldBackend, backend), + resource.TestCheckResourceAttr(resourceName, consts.FieldEnabled, "true"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDefaultDirectoryPolicy, "role:test"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedRoles+".0", "*"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowRoleExtKeyUsage, "true"), + resource.TestCheckResourceAttr(resourceName, consts.FieldAllowedIssuers+".0", "*"), + resource.TestCheckResourceAttr(resourceName, consts.FieldEabPolicy, "always-required"), + resource.TestCheckResourceAttr(resourceName, consts.FieldDnsResolver, ""), + ), + }, + testutil.GetImportTestStep(resourceName, false, nil), + }, + }) +} + +func testPkiSecretBackendConfigACME(path, default_directory_policy, allowed_roles, allowed_issuers, + eab_policy, dns_resolver string, enabled, allow_role_ext_key_usage bool) string { + return fmt.Sprintf(` +resource "vault_mount" "test" { + path = "%s" + type = "pki" +} + +resource "vault_pki_secret_backend_root_cert" "test" { + backend = vault_mount.test.path + type = "internal" + common_name = "test" + ttl = "86400" +} + +resource "vault_pki_secret_backend_config_cluster" "test" { + backend = vault_mount.test.path + path = "http://127.0.0.1:8200/v1/${vault_mount.test.path}" + aia_path = "http://127.0.0.1:8200/v1/${vault_mount.test.path}" +} + +resource "vault_pki_secret_backend_role" "test" { + backend = vault_pki_secret_backend_root_cert.test.backend + name = "test" +} + +resource "vault_pki_secret_backend_config_acme" "test" { + backend = vault_mount.test.path + enabled = "%t" + allowed_issuers = ["%s"] + allowed_roles = ["%s"] + allow_role_ext_key_usage = "%t" + default_directory_policy = "%s" + dns_resolver = "%s" + eab_policy = "%s" +}`, path, enabled, allowed_issuers, allowed_roles, allow_role_ext_key_usage, + default_directory_policy, dns_resolver, eab_policy) +} diff --git a/website/docs/r/pki_secret_backend_config_acme.html.md b/website/docs/r/pki_secret_backend_config_acme.html.md new file mode 100644 index 000000000..b52a9f386 --- /dev/null +++ b/website/docs/r/pki_secret_backend_config_acme.html.md @@ -0,0 +1,81 @@ +--- +layout: "vault" +page_title: "Vault: vault_pki_secret_backend_config_acme resource" +sidebar_current: "docs-vault-resource-pki-secret-backend-config-acme" +description: |- + Sets the ACME configuration on a PKI Secret Backend for Vault. +--- + +# vault\_pki\_secret\_backend\_config\_acme + +Allows setting the ACME server configuration used by specified mount. + +## 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_cluster" "pki_config_cluster" { + backend = vault_mount.pki.path + path = "http://127.0.0.1:8200/v1/pki" + aia_path = "http://127.0.0.1:8200/v1/pki" +} + +resource "vault_pki_secret_backend_config_acme" "example" { + backend = vault_mount.pki.path + enabled = true + allowed_issuers = ["*"] + allowed_roles = ["*"] + allow_role_ext_key_usage = false + default_directory_policy = "sign-verbatim" + dns_resolver = "" + eab_policy = "not-required" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `namespace` - (Optional) The namespace to provision the resource in. + The value should not contain leading or trailing forward slashes. + The `namespace` is always relative to the provider's configured [namespace](/docs/providers/vault#namespace). + *Available only for Vault Enterprise*. + +* `backend` - (Required) The path the PKI secret backend is mounted at, with no leading or trailing `/`s. + +* `enabled` - (Required) Specifies whether ACME is enabled. + +* `allowed_issuers` - (Optional) Specifies which issuers are allowed for use with ACME. + +* `allowed_roles` - (Optional) Specifies which roles are allowed for use with ACME. + +* `allow_role_ext_key_usage` - (Optional) Specifies whether the ExtKeyUsage field from a role is used. **Vault 1.14.1+** + +* `default_directory_policy` - (Optional) Specifies the policy to be used for non-role-qualified ACME requests. + Allowed values are `forbid`, `sign-verbatim`, `role:`, `external-policy` or `external-policy:`. + +* `dns_resolver` - (Optional) DNS resolver to use for domain resolution on this mount. + Must be in the format `:`, with both parts mandatory. + +* `eab_policy` - (Optional) Specifies the policy to use for external account binding behaviour. + Allowed values are `not-required`, `new-account-required` or `always-required`. + +## Attributes Reference + +No additional attributes are exported by this resource. + +## Import + +The ACME configuration can be imported using the resource's `id`. +In the case of the example above the `id` would be `pki/config/acme`, +where the `pki` component is the resource's `backend`, e.g. + +``` +$ terraform import vault_pki_secret_backend_config_acme.example pki/config/acme +``` diff --git a/website/vault.erb b/website/vault.erb index e7d1fdfce..0e900c0ab 100644 --- a/website/vault.erb +++ b/website/vault.erb @@ -549,6 +549,10 @@ vault_pki_secret_backend_cert + > + vault_pki_secret_backend_config_acme + + > vault_pki_secret_backend_config_ca