Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for a KV V2 Secret Metadata resource #1687

Merged
merged 13 commits into from
Jan 5, 2023
5 changes: 5 additions & 0 deletions internal/consts/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ const (
FieldGroupName = "group_name"
FieldExternal = "external"
FieldInternal = "internal"
FieldMaxVersions = "max_versions"
FieldCASRequired = "cas_required"
FieldDeleteVersionAfter = "delete_version_after"
FieldCustomMetadata = "custom_metadata"
FieldCustomMetadataJSON = "custom_metadata_json"

/*
common environment variables
Expand Down
136 changes: 136 additions & 0 deletions vault/resource_kv_secret_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"encoding/json"
"fmt"
"log"
"time"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/vault/api"

"github.com/hashicorp/terraform-provider-vault/internal/consts"
"github.com/hashicorp/terraform-provider-vault/internal/provider"
Expand Down Expand Up @@ -100,6 +102,43 @@ func kvSecretV2Resource(name string) *schema.Resource {
Default: false,
Description: "If set to true, permanently deletes all versions for the specified key.",
},

consts.FieldCustomMetadata: {
Type: schema.TypeList,
Optional: true,
Description: "Custom metadata to be set for the secret",
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
consts.FieldMaxVersions: {
Type: schema.TypeInt,
Optional: true,
Computed: true,
Description: "The number of versions to keep per key.",
},
consts.FieldCASRequired: {
Type: schema.TypeBool,
Optional: true,
Computed: true,
Description: "If true, all keys will require the cas " +
"parameter to be set on all write requests.",
},
consts.FieldDeleteVersionAfter: {
Type: schema.TypeInt,
Optional: true,
Description: "If set, specifies the length of time before " +
"a version is deleted",
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
},
consts.FieldData: {
Type: schema.TypeMap,
Optional: true,
Computed: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this ever be a computed value? Presumably the default is empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to make sure we are able to import the custom metadata and set it to the TF state for the KV secret, specifically in the cases that the secret metadata was created outside of TF and via Vault. That was my thought process around setting this field to also be possibly computed. Is that overkill/unnecessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the Computed from the individual nested fields, but still set Computed: true for the parent custom_metadata field

Description: "A map of arbitrary string to string valued " +
"user-provided metadata meant to describe the secret",
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
},
},
},
MaxItems: 1,
},
},
}
}
Expand All @@ -108,6 +147,33 @@ func getKVV2Path(mount, name, prefix string) string {
return fmt.Sprintf("%s/%s/%s", mount, prefix, name)
}

func getKVV2MetadataPath(mount, name string) string {
return fmt.Sprintf("%s/metadata/%s", mount, name)
}

func getCustomMetadata(d *schema.ResourceData) map[string]interface{} {
data := map[string]interface{}{}

metadataFields := []string{
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
consts.FieldMaxVersions,
consts.FieldCASRequired,
consts.FieldDeleteVersionAfter,
consts.FieldData,
}
fieldPrefix := fmt.Sprintf("%s.0", consts.FieldCustomMetadata)
for _, k := range metadataFields {
fieldKey := fmt.Sprintf("%s.%s", fieldPrefix, k)
vaultStateKey := k
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
if k == consts.FieldData {
vaultStateKey = consts.FieldCustomMetadata
}
if v, ok := d.GetOk(fieldKey); ok {
data[vaultStateKey] = v
}
}
return data
}

func kvSecretV2Write(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
client, e := provider.GetClient(d, meta)
if e != nil {
Expand Down Expand Up @@ -140,6 +206,17 @@ func kvSecretV2Write(ctx context.Context, d *schema.ResourceData, meta interface

d.SetId(path)

// Write custom metadata for secret if provided
if _, ok := d.GetOk(consts.FieldCustomMetadata); ok {
cm := getCustomMetadata(d)

metadataPath := getKVV2MetadataPath(mount, name)
log.Printf("[DEBUG] Writing custom metadata for secret at %s", path)
if _, err := client.Logical().Write(metadataPath, cm); err != nil {
return diag.Errorf("error writing custom metadata to %s, err=%s", metadataPath, err)
}
}

return kvSecretV2Read(ctx, d, meta)
}

Expand Down Expand Up @@ -184,13 +261,72 @@ func kvSecretV2Read(_ context.Context, d *schema.ResourceData, meta interface{})
if err := d.Set(consts.FieldMetadata, serializeDataMapToString(v)); err != nil {
return diag.FromErr(err)
}

// Read & Set custom metadata
if _, ok := v[consts.FieldCustomMetadata]; ok {
cm, err := readKVV2Metadata(d, client)
if err != nil {
return diag.FromErr(err)
}

customMetadata := []interface{}{cm}
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
if err := d.Set(consts.FieldCustomMetadata, customMetadata); err != nil {
return diag.FromErr(err)
}
}
}
}

}

return nil
}

func readKVV2Metadata(d *schema.ResourceData, client *api.Client) (map[string]interface{}, error) {
mount := d.Get(consts.FieldMount).(string)
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
name := d.Get(consts.FieldName).(string)

path := getKVV2MetadataPath(mount, name)

log.Printf("[DEBUG] Reading metadata for KVV2 secret at %s", path)
resp, err := client.Logical().Read(path)
if err != nil {
return nil, err
}

if resp == nil {
log.Printf("[DEBUG] no metadata found for secret")
return nil, nil
}

metadataFields := map[string]string{
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
consts.FieldMaxVersions: consts.FieldMaxVersions,
consts.FieldCASRequired: consts.FieldCASRequired,
consts.FieldDeleteVersionAfter: consts.FieldDeleteVersionAfter,
consts.FieldCustomMetadata: consts.FieldData,
}
data := map[string]interface{}{}

for vaultKey, tfKey := range metadataFields {
if val, ok := resp.Data[vaultKey]; ok {
// the delete_version_after field is written to
// Vault as an integer but is returned as a string
// of the format "3h12m10s"
if vaultKey == consts.FieldDeleteVersionAfter {
t, err := time.ParseDuration(val.(string))
if err != nil {
return nil, fmt.Errorf("error parsing duration, err=%s", err)
}
data[tfKey] = t.Seconds()
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
} else {
data[tfKey] = val
}
}
}

return data, nil
}

func kvSecretV2Delete(_ context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
client, e := provider.GetClient(d, meta)
if e != nil {
Expand Down
53 changes: 32 additions & 21 deletions vault/resource_kv_secret_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,29 @@ func TestAccKVSecretV2(t *testing.T) {
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, consts.FieldMount, mount),
resource.TestCheckResourceAttr(resourceName, consts.FieldName, name),
resource.TestCheckResourceAttr(resourceName, "cas", "1"),
resource.TestCheckResourceAttr(resourceName, consts.FieldPath, fmt.Sprintf("%s/data/%s", mount, name)),
resource.TestCheckResourceAttr(resourceName, "delete_all_versions", "true"),
resource.TestCheckResourceAttr(resourceName, "data.zip", "zap"),
resource.TestCheckResourceAttr(resourceName, "data.foo", "bar"),
resource.TestCheckResourceAttr(resourceName, "data.flag", "false"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.cas_required", "false"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.data.%", "2"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.data.extra", "cheese"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.data.pizza", "please"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.delete_version_after", "0"),
resource.TestCheckResourceAttr(resourceName, "custom_metadata.0.max_versions", "5"),
),
},
{
ResourceName: resourceName,
ImportState: true,
ImportStateVerify: true,
ImportStateVerifyIgnore: []string{
"data_json", "disable_read",
"delete_all_versions", "mount",
"name", "cas",
},
},
//{
// ResourceName: resourceName,
// ImportState: true,
// ImportStateVerify: true,
// ImportStateVerifyIgnore: []string{
// "data_json", "disable_read",
// "delete_all_versions", "mount",
// "name", "cas",
// },
//},
},
})
}
Expand All @@ -55,17 +60,23 @@ func testKVSecretV2Config(mount, name string) string {

ret += fmt.Sprintf(`
resource "vault_kv_secret_v2" "test" {
mount = vault_mount.kvv2.path
name = "%s"
cas = 1
delete_all_versions = true
data_json = jsonencode(
{
zip = "zap",
foo = "bar",
flag = false
}
mount = vault_mount.kvv2.path
name = "%s"
delete_all_versions = true
data_json = jsonencode(
{
zip = "zap",
foo = "bar",
flag = false
}
)
custom_metadata {
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
max_versions = 5
data = {
extra = "cheese",
pizza = "please"
}
}
}`, name)

return ret
Expand Down
3 changes: 2 additions & 1 deletion website/docs/r/kv_secret_backend_v2.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ No additional attributes are exported by this resource.

## Import

The KV-V2 secret backend can be imported using the `path`, e.g.
The KV-V2 secret backend can be imported using its unique ID,
the `${mount}/config`, e.g.

```
$ terraform import vault_kv_secret_backend_v2.config kvv2/config
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
5 changes: 5 additions & 0 deletions website/docs/r/kv_secret_v2.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ The following arguments are supported:
* `data_json` - (Required) JSON-encoded string that will be
written as the secret data at the given path.

* `custom_metadata` - (Optional) A nested block containing configuration options for the
vinay-gopalan marked this conversation as resolved.
Show resolved Hide resolved
secret metadata. Refer to the
[Vault docs](https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#create-update-metadata)
for more info on configuration options.

## Required Vault Capabilities

Use of this resource requires the `create` or `update` capability
Expand Down