diff --git a/CHANGELOG.md b/CHANGELOG.md index 655c0cfdb..9cb3c36af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## 3.6.0 (Unreleased) +IMPROVEMENTS: +* `resource/pki_secret_backend_root_cert`: Force new root CA resource creation on out-of-band changes. + ([#1428](https://github.com/hashicorp/terraform-provider-vault/pull/1428)) + BUGS: * `resource/pki_secret_backend_root_sign_intermediate`: Ensure that the `certificate_bundle`, and `ca_chain` do not contain duplicate certificates. diff --git a/util/util.go b/util/util.go index eb85f63e5..1450b767b 100644 --- a/util/util.go +++ b/util/util.go @@ -42,7 +42,16 @@ func ToStringArray(input []interface{}) []string { } func Is404(err error) bool { - return strings.Contains(err.Error(), "Code: 404") + return ErrorContainsHTTPCode(err, http.StatusNotFound) +} + +func ErrorContainsHTTPCode(err error, codes ...int) bool { + for _, code := range codes { + if strings.Contains(err.Error(), fmt.Sprintf("Code: %d", code)) { + return true + } + } + return false } func CalculateConflictsWith(self string, group []string) []string { @@ -296,3 +305,20 @@ func SetResourceData(d *schema.ResourceData, data map[string]interface{}) error return nil } + +// NormalizeMountPath to be in a form valid for accessing values from api.MountOutput +func NormalizeMountPath(path string) string { + return strings.Trim(path, "/") + "/" +} + +// CheckMountEnabled in Vault, path must contain a trailing '/', +func CheckMountEnabled(client *api.Client, path string) (bool, error) { + mounts, err := client.Sys().ListMounts() + if err != nil { + return false, err + } + + _, ok := mounts[NormalizeMountPath(path)] + + return ok, nil +} diff --git a/vault/resource_pki_secret_backend_cert_test.go b/vault/resource_pki_secret_backend_cert_test.go index fd0471745..61b1a9827 100644 --- a/vault/resource_pki_secret_backend_cert_test.go +++ b/vault/resource_pki_secret_backend_cert_test.go @@ -22,6 +22,7 @@ import ( type testPKICertStore struct { cert string + serialNumber string expectRevoked bool } @@ -341,22 +342,34 @@ func testPkiSecretBackendCertWaitUntilRenewal(n string) resource.TestCheckFunc { } } -func testCapturePKICert(resourcePath string, store *testPKICertStore) resource.TestCheckFunc { +func testCapturePKICert(resourceName string, store *testPKICertStore) resource.TestCheckFunc { return func(s *terraform.State) error { - for _, rs := range s.RootModule().Resources { - if rs.Type != "vault_pki_secret_backend_cert" { - continue - } + rs, err := testGetResourceFromRootModule(s, resourceName) + if err != nil { + return err + } + + cert, ok := rs.Primary.Attributes["certificate"] + if !ok { + return fmt.Errorf("certificate not found in state") + } + store.cert = cert - store.cert = rs.Primary.Attributes["certificate"] - v, err := strconv.ParseBool(rs.Primary.Attributes["revoke"]) + sn, ok := rs.Primary.Attributes["serial_number"] + if !ok { + return fmt.Errorf("serial_number not found in state") + } + store.serialNumber = sn + + if val, ok := rs.Primary.Attributes["revoke"]; ok { + v, err := strconv.ParseBool(val) if err != nil { return err } store.expectRevoked = v - return nil } - return fmt.Errorf("certificate not found in state") + + return nil } } @@ -414,3 +427,29 @@ func testPKICertRevocation(path string, store *testPKICertStore) resource.TestCh return nil } } + +func testPKICertReIssued(resourceName string, store *testPKICertStore) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, err := testGetResourceFromRootModule(s, resourceName) + if err != nil { + return err + } + if store.serialNumber == "" { + return fmt.Errorf("serial_number must be set on test store %#v", store) + } + + if store.serialNumber == rs.Primary.Attributes["serial_number"] { + return fmt.Errorf("expected certificate not re-issued, serial_number was not changed") + } + + return nil + } +} + +func testGetResourceFromRootModule(s *terraform.State, resourceName string) (*terraform.ResourceState, error) { + if rs, ok := s.RootModule().Resources[resourceName]; ok { + return rs, nil + } + + return nil, fmt.Errorf("expected resource %q, not found in state", resourceName) +} diff --git a/vault/resource_pki_secret_backend_root_cert.go b/vault/resource_pki_secret_backend_root_cert.go index 49b281147..bdcecdafd 100644 --- a/vault/resource_pki_secret_backend_root_cert.go +++ b/vault/resource_pki_secret_backend_root_cert.go @@ -1,21 +1,68 @@ package vault import ( + "context" + "crypto/x509" + "encoding/pem" "fmt" + "io" "log" + "net/http" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/helper/certutil" + + "github.com/hashicorp/terraform-provider-vault/util" ) func pkiSecretBackendRootCertResource() *schema.Resource { return &schema.Resource{ Create: pkiSecretBackendRootCertCreate, - Read: pkiSecretBackendRootCertRead, - Update: pkiSecretBackendRootCertUpdate, Delete: pkiSecretBackendRootCertDelete, + Update: func(data *schema.ResourceData, i interface{}) error { + return nil + }, + Read: pkiSecretBackendRootCertRead, + StateUpgraders: []schema.StateUpgrader{ + { + Version: 0, + Type: pkiSecretBackendRootCertV0().CoreConfigSchema().ImpliedType(), + Upgrade: pkiSecretBackendRootCertUpgradeV0, + }, + }, + SchemaVersion: 1, + CustomizeDiff: func(_ context.Context, d *schema.ResourceDiff, meta interface{}) error { + key := "serial" + o, _ := d.GetChange(key) + // skip on new resource + if o.(string) == "" { + return nil + } + + client := meta.(*api.Client) + cert, err := getCACertificate(client, d.Get("backend").(string)) + if err != nil { + return err + } + + if cert != nil { + n := certutil.GetHexFormatted(cert.SerialNumber.Bytes(), ":") + if d.Get(key).(string) != n { + if err := d.SetNewComputed(key); err != nil { + return err + } + if err := d.ForceNew(key); err != nil { + return err + } + } + + } + + return nil + }, Schema: map[string]*schema.Schema{ "backend": { @@ -177,7 +224,7 @@ func pkiSecretBackendRootCertResource() *schema.Resource { "certificate": { Type: schema.TypeString, Computed: true, - Description: "The certicate.", + Description: "The certificate.", }, "issuing_ca": { Type: schema.TypeString, @@ -187,8 +234,14 @@ func pkiSecretBackendRootCertResource() *schema.Resource { "serial": { Type: schema.TypeString, Computed: true, + Deprecated: "Use serial_number instead", Description: "The serial number.", }, + "serial_number": { + Type: schema.TypeString, + Computed: true, + Description: "The certificate's serial number, hex formatted.", + }, }, } } @@ -279,17 +332,67 @@ func pkiSecretBackendRootCertCreate(d *schema.ResourceData, meta interface{}) er d.Set("certificate", resp.Data["certificate"]) d.Set("issuing_ca", resp.Data["issuing_ca"]) d.Set("serial", resp.Data["serial_number"]) + d.Set("serial_number", resp.Data["serial_number"]) d.SetId(path) - return pkiSecretBackendRootCertRead(d, meta) + + return nil } func pkiSecretBackendRootCertRead(d *schema.ResourceData, meta interface{}) error { + if d.IsNewResource() { + return nil + } + + client := meta.(*api.Client) + path := d.Get("backend").(string) + enabled, err := util.CheckMountEnabled(client, path) + if err != nil { + log.Printf("[WARN] Failed to check if mount %q exist, preempting the read operation", path) + return nil + } + + // trigger a resource re-creation whenever the engine's mount has disappeared + if !enabled { + log.Printf("[WARN] Mount %q does not exist, setting resource for re-creation", path) + d.SetId("") + } + return nil } -func pkiSecretBackendRootCertUpdate(d *schema.ResourceData, m interface{}) error { - return nil +func getCACertificate(client *api.Client, mount string) (*x509.Certificate, error) { + path := fmt.Sprintf("/v1/%s/ca/pem", mount) + req := client.NewRequest(http.MethodGet, path) + req.ClientToken = "" + resp, err := client.RawRequest(req) + if err != nil { + if util.ErrorContainsHTTPCode(err, http.StatusNotFound, http.StatusForbidden) { + return nil, nil + } + return nil, err + } + + if resp == nil { + return nil, fmt.Errorf("expected a response body, got nil response") + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + b, _ := pem.Decode(data) + if b != nil { + cert, err := x509.ParseCertificate(b.Bytes) + if err != nil { + return nil, err + } + return cert, nil + } + + return nil, nil } func pkiSecretBackendRootCertDelete(d *schema.ResourceData, meta interface{}) error { @@ -314,3 +417,23 @@ func pkiSecretBackendIntermediateSetSignedReadPath(backend string, rootType stri func pkiSecretBackendIntermediateSetSignedDeletePath(backend string) string { return strings.Trim(backend, "/") + "/root" } + +func pkiSecretBackendRootCertV0() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "serial_number": { + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + }, + } +} + +func pkiSecretBackendRootCertUpgradeV0( + _ context.Context, rawState map[string]interface{}, _ interface{}, +) (map[string]interface{}, error) { + rawState["serial_number"] = rawState["serial"] + + return rawState, nil +} diff --git a/vault/resource_pki_secret_backend_root_cert_test.go b/vault/resource_pki_secret_backend_root_cert_test.go index b8810f0dd..cf379a57d 100644 --- a/vault/resource_pki_secret_backend_root_cert_test.go +++ b/vault/resource_pki_secret_backend_root_cert_test.go @@ -2,6 +2,7 @@ package vault import ( "fmt" + "reflect" "strconv" "strings" "testing" @@ -17,6 +18,28 @@ import ( func TestPkiSecretBackendRootCertificate_basic(t *testing.T) { path := "pki-" + strconv.Itoa(acctest.RandInt()) + resourceName := "vault_pki_secret_backend_root_cert.test" + + var store testPKICertStore + + checks := []resource.TestCheckFunc{ + resource.TestCheckResourceAttr(resourceName, "backend", path), + resource.TestCheckResourceAttr(resourceName, "type", "internal"), + resource.TestCheckResourceAttr(resourceName, "common_name", "test Root CA"), + resource.TestCheckResourceAttr(resourceName, "ttl", "86400"), + resource.TestCheckResourceAttr(resourceName, "format", "pem"), + resource.TestCheckResourceAttr(resourceName, "private_key_format", "der"), + resource.TestCheckResourceAttr(resourceName, "key_type", "rsa"), + resource.TestCheckResourceAttr(resourceName, "key_bits", "4096"), + resource.TestCheckResourceAttr(resourceName, "ou", "test"), + resource.TestCheckResourceAttr(resourceName, "organization", "test"), + resource.TestCheckResourceAttr(resourceName, "country", "test"), + resource.TestCheckResourceAttr(resourceName, "locality", "test"), + resource.TestCheckResourceAttr(resourceName, "province", "test"), + resource.TestCheckResourceAttrSet(resourceName, "serial"), + resource.TestCheckResourceAttrSet(resourceName, "serial_number"), + } + resource.Test(t, resource.TestCase{ Providers: testProviders, PreCheck: func() { testutil.TestAccPreCheck(t) }, @@ -25,20 +48,54 @@ func TestPkiSecretBackendRootCertificate_basic(t *testing.T) { { Config: testPkiSecretBackendRootCertificateConfig_basic(path), Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "backend", path), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "type", "internal"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "common_name", "test Root CA"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "ttl", "86400"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "format", "pem"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "private_key_format", "der"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "key_type", "rsa"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "key_bits", "4096"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "ou", "test"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "organization", "test"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "country", "test"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "locality", "test"), - resource.TestCheckResourceAttr("vault_pki_secret_backend_root_cert.test", "province", "test"), - resource.TestCheckResourceAttrSet("vault_pki_secret_backend_root_cert.test", "serial"), + append(checks, + testCapturePKICert(resourceName, &store), + )..., + ), + }, + { + // test unmounted backend + PreConfig: func() { + client := testProvider.Meta().(*api.Client) + if err := client.Sys().Unmount(path); err != nil { + t.Fatal(err) + } + }, + Config: testPkiSecretBackendRootCertificateConfig_basic(path), + Check: resource.ComposeTestCheckFunc( + append(checks, + testPKICertReIssued(resourceName, &store), + testCapturePKICert(resourceName, &store), + )..., + ), + }, + { + // test out of band update to the root CA + PreConfig: func() { + client := testProvider.Meta().(*api.Client) + _, err := client.Logical().Delete(fmt.Sprintf("%s/root", path)) + if err != nil { + t.Fatal(err) + } + genPath := pkiSecretBackendIntermediateSetSignedReadPath(path, "internal") + resp, err := client.Logical().Write(genPath, + map[string]interface{}{ + "common_name": "out-of-band", + }, + ) + if err != nil { + t.Fatal(err) + } + + if resp == nil { + t.Fatalf("empty response for write on path %s", genPath) + } + }, + Config: testPkiSecretBackendRootCertificateConfig_basic(path), + Check: resource.ComposeTestCheckFunc( + append(checks, + testPKICertReIssued(resourceName, &store), + )..., ), }, }, @@ -69,30 +126,67 @@ func testPkiSecretBackendRootCertificateDestroy(s *terraform.State) error { } func testPkiSecretBackendRootCertificateConfig_basic(path string) string { - return fmt.Sprintf(` + config := fmt.Sprintf(` resource "vault_mount" "test" { - path = "%s" - type = "pki" - description = "test" + path = "%s" + type = "pki" + description = "test" default_lease_ttl_seconds = "86400" max_lease_ttl_seconds = "86400" } resource "vault_pki_secret_backend_root_cert" "test" { - depends_on = [ "vault_mount.test" ] - backend = vault_mount.test.path - type = "internal" - common_name = "test Root CA" - ttl = "86400" - format = "pem" - private_key_format = "der" - key_type = "rsa" - key_bits = 4096 + backend = vault_mount.test.path + type = "internal" + common_name = "test Root CA" + ttl = "86400" + format = "pem" + private_key_format = "der" + key_type = "rsa" + key_bits = 4096 exclude_cn_from_sans = true - ou = "test" - organization = "test" - country = "test" - locality = "test" - province = "test" -}`, path) + ou = "test" + organization = "test" + country = "test" + locality = "test" + province = "test" +} +`, path) + + return config +} + +func Test_pkiSecretBackendRootCertUpgradeV0(t *testing.T) { + tests := []struct { + name string + rawState map[string]interface{} + want map[string]interface{} + wantErr bool + }{ + { + name: "basic", + rawState: map[string]interface{}{ + "serial": "aa:bb:cc:dd:ee", + }, + want: map[string]interface{}{ + "serial": "aa:bb:cc:dd:ee", + "serial_number": "aa:bb:cc:dd:ee", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := pkiSecretBackendRootCertUpgradeV0(nil, tt.rawState, nil) + + if tt.wantErr { + if err == nil { + t.Fatalf("pkiSecretBackendRootCertUpgradeV0() error = %#v, wantErr %#v", err, tt.wantErr) + } + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("pkiSecretBackendRootCertUpgradeV0() got = %#v, want %#v", got, tt.want) + } + }) + } } diff --git a/website/docs/r/pki_secret_backend_root_cert.html.md b/website/docs/r/pki_secret_backend_root_cert.html.md index ddde253cd..a56ea0b2c 100644 --- a/website/docs/r/pki_secret_backend_root_cert.html.md +++ b/website/docs/r/pki_secret_backend_root_cert.html.md @@ -88,8 +88,10 @@ The following arguments are supported: In addition to the fields above, the following attributes are exported: -* `certificate` - The certificate +* `certificate` - The certificate. -* `issuing_ca` - The issuing CA +* `issuing_ca` - The issuing CA certificate. -* `serial` - The serial +* `serial` - Deprecated, use `serial_number` instead. + +* `serial_number` - The certificate's serial number, hex formatted. diff --git a/website/docs/r/pki_secret_backend_sign.html.md b/website/docs/r/pki_secret_backend_sign.html.md index 5945a5253..269f6ac96 100644 --- a/website/docs/r/pki_secret_backend_sign.html.md +++ b/website/docs/r/pki_secret_backend_sign.html.md @@ -97,6 +97,10 @@ In addition to the fields above, the following attributes are exported: * `ca_chain` - The CA chain -* `serial` - The serial +* `serial_number` - The certificate's serial number, hex formatted. * `expiration` - The expiration date of the certificate in unix epoch format + +## Deprecations + +* `serial` - Use `serial_number` instead.