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

KV helper methods for api package #15305

Merged
merged 22 commits into from
May 25, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
681faca
Add Read methods for KVClient
digivava May 4, 2022
377a224
Merge branch 'main' into VAULT-5973_api-kv-helpers
digivava May 4, 2022
275f67e
KV write helper
digivava May 5, 2022
8377269
Add changelog
digivava May 5, 2022
ce740af
Add Delete method
digivava May 6, 2022
56aa04b
Use extractVersionMetadata inside extractDataAndVersionMetadata
digivava May 6, 2022
24c6d83
Return nil, nil for v1 writes
digivava May 6, 2022
003a7f3
Add test for extracting version metadata
digivava May 6, 2022
7855382
Merge branch 'digivava/more-api-kv-helpers' into VAULT-5973_api-kv-he…
digivava May 9, 2022
53b2cc1
Merge branch 'main' into VAULT-5973_api-kv-helpers
digivava May 17, 2022
c271d46
Split kv client into v1 and v2-specific clients
digivava May 17, 2022
060a639
Add ability to set options on Put
digivava May 18, 2022
263ae37
Add test for KV helpers
digivava May 18, 2022
b8f93f0
Add custom metadata to top level and allow for getting versions as so…
digivava May 20, 2022
dd5d630
Update tests
digivava May 20, 2022
1df6a35
Separate KV v1 and v2 into different files
digivava May 20, 2022
09ce4df
Add test for GetVersionsAsList, rename Metadata key to VersionMetadat…
digivava May 20, 2022
e682c91
Move structs and godoc comments to more appropriate files
digivava May 20, 2022
4e88f89
Add more tests for extract methods
digivava May 23, 2022
38f94ca
Rework custom metadata helper to be more consistent with other helpers
digivava May 23, 2022
a049bfe
Remove KVSecret from custom metadata test now that we don't append to…
digivava May 23, 2022
ae82f4f
Return early for readability and make test value name less confusing
digivava May 25, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions api/kv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
package api

import (
"context"
"fmt"
"strconv"
"time"

"github.com/mitchellh/mapstructure"
)

// A KVClient is used to perform reads and writes against a KV secrets engine in Vault.
//
// The mount path is the location where the target KV secrets engine resides
// in Vault. The version refers to the version of the target KV secrets engine.
//
// Vault development servers tend to have "secret" as the mount path
// and 2 as the version, as these are the default settings when a server
// is started in -dev mode.
digivava marked this conversation as resolved.
Show resolved Hide resolved
//
// Learn more about the KV secrets engine here:
// https://www.vaultproject.io/docs/secrets/kv
type KVClient struct {
c *Client
mountPath string
version int
digivava marked this conversation as resolved.
Show resolved Hide resolved
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

type KVSecret struct {
Data map[string]interface{}
Metadata *VersionMetadata
Raw *Secret
}

type KVMetadata struct {
CASRequired bool `mapstructure:"cas_required"`
CreatedTime time.Time `mapstructure:"created_time"`
CurrentVersion int `mapstructure:"current_version"`
CustomMetadata map[string]interface{} `mapstructure:"custom_metadata"`
DeleteVersionAfter time.Duration `mapstructure:"delete_version_after"`
MaxVersions int `mapstructure:"max_versions"`
OldestVersion int `mapstructure:"oldest_version"`
UpdatedTime time.Time `mapstructure:"updated_time"`
// Keys are stringified ints, e.g. "3"
Versions map[string]VersionMetadata `mapstructure:"versions"`
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be nicer UX to have this as either map[int]VersionMetadata or []VersionMetadata (sorted by version, since Version is already in the struct)

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 couldn't find a way to make mapstructure happy if I had it as an int (since it comes in as a string), maybe there's some decode hook I could use but couldn't find it... 🤔 I did add a GetVersionsAsList method though so that folks have access to a sorted slice as well.

}

type VersionMetadata struct {
digivava marked this conversation as resolved.
Show resolved Hide resolved
Version int `mapstructure:"version"`
CreatedTime time.Time `mapstructure:"created_time"`
DeletionTime time.Time `mapstructure:"deletion_time"`
Destroyed bool `mapstructure:"destroyed"`
// There is currently no version-specific custom metadata.
// This field is just a copy of what's in the CustomMetadata field
// for the full KVMetadata of the secret.
CustomMetadata map[string]string `mapstructure:"custom_metadata"`
digivava marked this conversation as resolved.
Show resolved Hide resolved
}

digivava marked this conversation as resolved.
Show resolved Hide resolved
func (c *Client) KVv1(mountPath string) *KVClient {
return &KVClient{c: c, mountPath: mountPath, version: 1}
}

func (c *Client) KVv2(mountPath string) *KVClient {
return &KVClient{c: c, mountPath: mountPath, version: 2}
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

func (kv *KVClient) Read(ctx context.Context, secretPath string) (*KVSecret, error) {
digivava marked this conversation as resolved.
Show resolved Hide resolved
pathToRead, err := kv.getFullPath(secretPath)
if err != nil {
return nil, fmt.Errorf("error assembling full path to KV secret: %v", err)
digivava marked this conversation as resolved.
Show resolved Hide resolved
}

secret, err := kv.c.Logical().ReadWithContext(ctx, pathToRead)
if err != nil {
return nil, fmt.Errorf("error encountered while reading secret at %s: %v", pathToRead, err)
}
if secret == nil || secret.Data == nil {
return nil, fmt.Errorf("no secret found at %s", pathToRead)
}

return extractDataAndVersionMetadata(secret, kv.version)
}

// ReadVersion returns the data and metadata for a specific version of the
// given secret. If that version has been deleted, the Data field on the
// returned secret will be nil, and the Metadata field will contain the deletion time.
digivava marked this conversation as resolved.
Show resolved Hide resolved
func (kv *KVClient) ReadVersion(ctx context.Context, secretPath string, version int) (*KVSecret, error) {
pathToRead, err := kv.getFullPath(secretPath)
if err != nil {
return nil, fmt.Errorf("error assembling full path to KV secret: %v", err)
}

queryParams := map[string][]string{"version": {strconv.Itoa(version)}}
secret, err := kv.c.Logical().ReadWithDataWithContext(ctx, pathToRead, queryParams)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
digivava marked this conversation as resolved.
Show resolved Hide resolved
return nil, fmt.Errorf("no secret version found at %s", pathToRead)
}

return extractDataAndVersionMetadata(secret, kv.version)
}

func (kv *KVClient) ReadMetadata(ctx context.Context, secretPath string) (*KVMetadata, error) {
pathToRead, err := kv.getFullMetadataPath(secretPath)
if err != nil {
return nil, fmt.Errorf("error assembling full path to KV secret's metadata: %v", err)
}

secret, err := kv.c.Logical().ReadWithContext(ctx, pathToRead)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
return nil, fmt.Errorf("no secret metadata found at %s", pathToRead)
}

return extractFullMetadata(secret)
}

func (kv *KVClient) Write(ctx context.Context, secretPath string, data map[string]interface{}) (*KVSecret, error) {
digivava marked this conversation as resolved.
Show resolved Hide resolved
pathToWriteTo, err := kv.getFullPath(secretPath)
if err != nil {
return nil, fmt.Errorf("error assembling full path to KV secret: %v", err)
}

secret, err := kv.c.Logical().WriteWithContext(ctx, pathToWriteTo, data)
if err != nil {
return nil, fmt.Errorf("error writing secret to %s: %v", pathToWriteTo, err)
}
if kv.version == 1 {
return nil, nil // v1 Logical Write returns a nil secret
digivava marked this conversation as resolved.
Show resolved Hide resolved
}
if secret == nil || secret.Data == nil {
return nil, fmt.Errorf("no secret was written to %s", pathToWriteTo)
digivava marked this conversation as resolved.
Show resolved Hide resolved
}

metadata, err := extractVersionMetadata(secret)
if err != nil {
return nil, fmt.Errorf("secret was written successfully, but unable to view version metadata from response: %v", err)
}

return &KVSecret{
Data: nil,
digivava marked this conversation as resolved.
Show resolved Hide resolved
Metadata: metadata,
Raw: secret,
}, nil
}

func (kv *KVClient) Delete(ctx context.Context, secretPath string, versions ...int) error {
digivava marked this conversation as resolved.
Show resolved Hide resolved
pathToDelete, err := kv.getFullPath(secretPath)
if err != nil {
return fmt.Errorf("error assembling full path to KV secret: %v", err)
}

var derr error

switch kv.version {
case 1:
if len(versions) > 0 {
return fmt.Errorf("cannot specify versions for KV v1 secrets")
}
_, derr = kv.c.Logical().DeleteWithContext(ctx, pathToDelete)
case 2:
// verb and path are different when trying to delete past versions
if len(versions) > 0 {
var versionsToDelete []string
for _, version := range versions {
versionsToDelete = append(versionsToDelete, strconv.Itoa(version))
}
versionsMap := map[string]interface{}{
"versions": versionsToDelete,
}
pathToDelete = fmt.Sprintf("%s/delete/%s", kv.mountPath, secretPath)
_, derr = kv.c.Logical().Write(pathToDelete, versionsMap)
digivava marked this conversation as resolved.
Show resolved Hide resolved
} else {
_, derr = kv.c.Logical().DeleteWithContext(ctx, pathToDelete)
}
}
if derr != nil {
return fmt.Errorf("error deleting secret at %s: %v", pathToDelete, derr)
}

return nil
}

func (kv *KVClient) getFullPath(secretPath string) (string, error) {
var pathToRead string
switch kv.version {
case 1:
pathToRead = fmt.Sprintf("%s/%s", kv.mountPath, secretPath)
case 2:
pathToRead = fmt.Sprintf("%s/data/%s", kv.mountPath, secretPath)
default:
return "", fmt.Errorf("KV client was initialized with invalid KV secrets engine version")
}

return pathToRead, nil
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

func (kv *KVClient) getFullMetadataPath(secretPath string) (string, error) {
var pathToRead string
switch kv.version {
case 1:
return "", fmt.Errorf("metadata is not supported in v1 of the KV secrets engine")
case 2:
pathToRead = fmt.Sprintf("%s/metadata/%s", kv.mountPath, secretPath)
default:
return "", fmt.Errorf("KV client was initialized with invalid KV secrets engine version")
}

return pathToRead, nil
}

func extractDataAndVersionMetadata(secret *Secret, version int) (*KVSecret, error) {
digivava marked this conversation as resolved.
Show resolved Hide resolved
var data map[string]interface{}
var metadata *VersionMetadata
switch version {
case 1:
data = secret.Data
metadata = nil
case 2:
dataInterface, ok := secret.Data["data"]
if !ok {
return nil, fmt.Errorf("missing expected 'data' element")
}

if dataInterface == nil {
// this can happen when the secret has been deleted, but the metadata is still available
data = nil
} else {
data, ok = dataInterface.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected type for 'data' element: %T (%#v)", data, data)
}
}

var err error
metadata, err = extractVersionMetadata(secret)
if err != nil {
return nil, fmt.Errorf("unable to get version metadata: %v", err)
}
default:
return nil, fmt.Errorf("cannot parse secret without specifying valid KV secrets engine version")
}

return &KVSecret{
Data: data,
Metadata: metadata,
Raw: secret,
}, nil
}

func extractVersionMetadata(secret *Secret) (*VersionMetadata, error) {
// Writes return the metadata directly, Reads return it nested inside the "metadata" key
var metadataMap map[string]interface{}
metadataInterface, ok := secret.Data["metadata"]
if ok {
metadataMap, ok = metadataInterface.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected type for 'metadata' element: %T (%#v)", metadataInterface, metadataInterface)
}
} else {
metadataMap = secret.Data
}

var metadata *VersionMetadata

// deletion_time usually comes in as an empty string which can't be
// processed as time.RFC3339, so we reset it to a convertible value
if metadataMap["deletion_time"] == "" {
metadataMap["deletion_time"] = time.Time{}
}

d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339),
Result: &metadata,
})
if err != nil {
return nil, fmt.Errorf("error setting up decoder for API response: %v", err)
}

err = d.Decode(metadataMap)
if err != nil {
return nil, fmt.Errorf("error decoding metadata from API response into VersionMetadata: %v", err)
}

return metadata, nil
}

func extractFullMetadata(secret *Secret) (*KVMetadata, error) {
var metadata *KVMetadata

// deletion_time usually comes in as an empty string which can't be
// processed as time.RFC3339, so we reset it to a convertible value
Copy link
Contributor Author

@digivava digivava May 5, 2022

Choose a reason for hiding this comment

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

If anyone knows how to get the DecodeHook to shut up and accept the empty string as time.RFC3339, lemme know. I tried the ,omitempty and ,string struct tags and still no good. So I'm just kinda hacking around this manually.

if versions, ok := secret.Data["versions"]; ok {
versionsMap := versions.(map[string]interface{})
if len(versionsMap) > 0 {
for version, metadata := range versionsMap {
metadataMap := metadata.(map[string]interface{})
if metadataMap["deletion_time"] == "" {
metadataMap["deletion_time"] = time.Time{}
}
versionsMap[version] = metadataMap // save the updated copy of the metadata map
}
}
secret.Data["versions"] = versionsMap // save the updated copy of the versions map
}

d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToTimeDurationHookFunc(),
),
Result: &metadata,
})
if err != nil {
return nil, fmt.Errorf("error setting up decoder for API response: %v", err)
}

err = d.Decode(secret.Data)
if err != nil {
return nil, fmt.Errorf("error decoding metadata from API response into KVMetadata: %v", err)
}

return metadata, nil
}
Loading