Skip to content

Commit

Permalink
KV helper methods for api package (#15305)
Browse files Browse the repository at this point in the history
* Add Read methods for KVClient

* KV write helper

* Add changelog

* Add Delete method

* Use extractVersionMetadata inside extractDataAndVersionMetadata

* Return nil, nil for v1 writes

* Add test for extracting version metadata

* Split kv client into v1 and v2-specific clients

* Add ability to set options on Put

* Add test for KV helpers

* Add custom metadata to top level and allow for getting versions as sorted slice

* Update tests

* Separate KV v1 and v2 into different files

* Add test for GetVersionsAsList, rename Metadata key to VersionMetadata for clarity

* Move structs and godoc comments to more appropriate files

* Add more tests for extract methods

* Rework custom metadata helper to be more consistent with other helpers

* Remove KVSecret from custom metadata test now that we don't append to it as part of helper method

* Return early for readability and make test value name less confusing
  • Loading branch information
digivava committed May 25, 2022
1 parent 11e01b7 commit 242a6f9
Show file tree
Hide file tree
Showing 6 changed files with 1,014 additions and 0 deletions.
50 changes: 50 additions & 0 deletions api/kv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package api

// A KVSecret is a key-value secret returned by Vault's KV secrets engine,
// and is the most basic type of secret stored in Vault.
//
// Data contains the key-value pairs of the secret itself,
// while Metadata contains a subset of metadata describing
// this particular version of the secret.
// The Metadata field for a KV v1 secret will always be nil, as
// metadata is only supported starting in KV v2.
//
// The Raw field can be inspected for information about the lease,
// and passed to a LifetimeWatcher object for periodic renewal.
type KVSecret struct {
Data map[string]interface{}
VersionMetadata *KVVersionMetadata
CustomMetadata map[string]interface{}
Raw *Secret
}

// KVv1 is used to return a client for reads and writes against
// a KV v1 secrets engine in Vault.
//
// The mount path is the location where the target KV secrets engine resides
// in Vault.
//
// While v1 is not necessarily deprecated, Vault development servers tend to
// use v2 as the version of the KV secrets engine, as this is what's mounted
// by default when a server is started in -dev mode. See the kvv2 struct.
//
// Learn more about the KV secrets engine here:
// https://www.vaultproject.io/docs/secrets/kv
func (c *Client) KVv1(mountPath string) *kvv1 {
return &kvv1{c: c, mountPath: mountPath}
}

// KVv2 is used to return a client for reads and writes against
// a KV v2 secrets engine in Vault.
//
// The mount path is the location where the target KV secrets engine resides
// in Vault.
//
// Vault development servers tend to have "secret" as the mount path,
// as these are the default settings when a server is started in -dev mode.
//
// Learn more about the KV secrets engine here:
// https://www.vaultproject.io/docs/secrets/kv
func (c *Client) KVv2(mountPath string) *kvv2 {
return &kvv2{c: c, mountPath: mountPath}
}
334 changes: 334 additions & 0 deletions api/kv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
package api

import (
"reflect"
"testing"
"time"
)

func TestExtractVersionMetadata(t *testing.T) {
t.Parallel()

inputCreatedTimeStr := "2022-05-06T23:02:04.865025Z"
inputDeletionTimeStr := "2022-06-17T01:15:03.279013Z"
expectedCreatedTimeParsed, err := time.Parse(time.RFC3339, inputCreatedTimeStr)
if err != nil {
t.Fatalf("unable to parse expected created time: %v", err)
}
expectedDeletionTimeParsed, err := time.Parse(time.RFC3339, inputDeletionTimeStr)
if err != nil {
t.Fatalf("unable to parse expected created time: %v", err)
}

testCases := []struct {
name string
input *Secret
expected *KVVersionMetadata
}{
{
name: "a secret",
input: &Secret{
Data: map[string]interface{}{
"data": map[string]interface{}{
"password": "Hashi123",
},
"metadata": map[string]interface{}{
"version": 10,
"created_time": inputCreatedTimeStr,
"deletion_time": "",
"destroyed": false,
"custom_metadata": nil,
},
},
},
expected: &KVVersionMetadata{
Version: 10,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: time.Time{},
Destroyed: false,
},
},
{
name: "a secret that has been deleted",
input: &Secret{
Data: map[string]interface{}{
"data": map[string]interface{}{
"password": "Hashi123",
},
"metadata": map[string]interface{}{
"version": 10,
"created_time": inputCreatedTimeStr,
"deletion_time": inputDeletionTimeStr,
"destroyed": false,
"custom_metadata": nil,
},
},
},
expected: &KVVersionMetadata{
Version: 10,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: expectedDeletionTimeParsed,
Destroyed: false,
},
},
{
name: "a response from a Write operation",
input: &Secret{
Data: map[string]interface{}{
"version": 10,
"created_time": inputCreatedTimeStr,
"deletion_time": "",
"destroyed": false,
"custom_metadata": nil,
},
},
expected: &KVVersionMetadata{
Version: 10,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: time.Time{},
Destroyed: false,
},
},
}

for _, tc := range testCases {
versionMetadata, err := extractVersionMetadata(tc.input)
if err != nil {
t.Fatalf("err: %s", err)
}

if !reflect.DeepEqual(versionMetadata, tc.expected) {
t.Fatalf("%s: got\n%#v\nexpected\n%#v\n", tc.name, versionMetadata, tc.expected)
}
}
}

func TestExtractDataAndVersionMetadata(t *testing.T) {
t.Parallel()

inputCreatedTimeStr := "2022-05-06T23:02:04.865025Z"
inputDeletionTimeStr := "2022-06-17T01:15:03.279013Z"
expectedCreatedTimeParsed, err := time.Parse(time.RFC3339, inputCreatedTimeStr)
if err != nil {
t.Fatalf("unable to parse expected created time: %v", err)
}
expectedDeletionTimeParsed, err := time.Parse(time.RFC3339, inputDeletionTimeStr)
if err != nil {
t.Fatalf("unable to parse expected created time: %v", err)
}

readResp := &Secret{
Data: map[string]interface{}{
"data": map[string]interface{}{
"password": "Hashi123",
},
"metadata": map[string]interface{}{
"version": 10,
"created_time": inputCreatedTimeStr,
"deletion_time": "",
"destroyed": false,
"custom_metadata": nil,
},
},
}

readRespDeleted := &Secret{
Data: map[string]interface{}{
"data": nil,
"metadata": map[string]interface{}{
"version": 10,
"created_time": inputCreatedTimeStr,
"deletion_time": inputDeletionTimeStr,
"destroyed": false,
"custom_metadata": nil,
},
},
}

testCases := []struct {
name string
input *Secret
expected *KVSecret
}{
{
name: "a response from a Read operation",
input: readResp,
expected: &KVSecret{
Data: map[string]interface{}{
"password": "Hashi123",
},
VersionMetadata: &KVVersionMetadata{
Version: 10,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: time.Time{},
Destroyed: false,
},
// it's tempting to test some Secrets with custom_metadata but
// we can't in this test because it isn't until we call the
// extractCustomMetadata function that the custom metadata
// gets added onto the struct. See TestExtractCustomMetadata.
CustomMetadata: nil,
Raw: readResp,
},
},
{
name: "a secret that has been deleted and thus has nil data",
input: readRespDeleted,
expected: &KVSecret{
Data: nil,
VersionMetadata: &KVVersionMetadata{
Version: 10,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: expectedDeletionTimeParsed,
Destroyed: false,
},
CustomMetadata: nil,
Raw: readRespDeleted,
},
},
}

for _, tc := range testCases {
dvm, err := extractDataAndVersionMetadata(tc.input)
if err != nil {
t.Fatalf("err: %s", err)
}

if !reflect.DeepEqual(dvm, tc.expected) {
t.Fatalf("%s: got\n%#v\nexpected\n%#v\n", tc.name, dvm, tc.expected)
}
}
}

func TestExtractFullMetadata(t *testing.T) {
inputCreatedTimeStr := "2022-05-20T00:51:49.419794Z"
expectedCreatedTimeParsed, err := time.Parse(time.RFC3339, inputCreatedTimeStr)
if err != nil {
t.Fatalf("unable to parse expected created time: %v", err)
}

inputUpdatedTimeStr := "2022-05-20T20:23:43.284488Z"
expectedUpdatedTimeParsed, err := time.Parse(time.RFC3339, inputUpdatedTimeStr)
if err != nil {
t.Fatalf("unable to parse expected updated time: %v", err)
}

inputDeletedTimeStr := "2022-05-21T00:05:49.521697Z"
expectedDeletedTimeParsed, err := time.Parse(time.RFC3339, inputDeletedTimeStr)
if err != nil {
t.Fatalf("unable to parse expected deletion time: %v", err)
}

metadataResp := &Secret{
Data: map[string]interface{}{
"cas_required": true,
"created_time": inputCreatedTimeStr,
"current_version": 2,
"custom_metadata": map[string]interface{}{
"org": "eng",
},
"delete_version_after": "200s",
"max_versions": 3,
"oldest_version": 1,
"updated_time": inputUpdatedTimeStr,
"versions": map[string]interface{}{
"2": map[string]interface{}{
"created_time": inputUpdatedTimeStr,
"deletion_time": "",
"destroyed": false,
},
"1": map[string]interface{}{
"created_time": inputCreatedTimeStr,
"deletion_time": inputDeletedTimeStr,
"destroyed": false,
},
},
},
}

testCases := []struct {
name string
input *Secret
expected *KVMetadata
}{
{
name: "a metadata response",
input: metadataResp,
expected: &KVMetadata{
CASRequired: true,
CreatedTime: expectedCreatedTimeParsed,
CurrentVersion: 2,
CustomMetadata: map[string]interface{}{
"org": "eng",
},
DeleteVersionAfter: time.Duration(200 * time.Second),
MaxVersions: 3,
OldestVersion: 1,
UpdatedTime: expectedUpdatedTimeParsed,
Versions: map[string]KVVersionMetadata{
"2": {
Version: 2,
CreatedTime: expectedUpdatedTimeParsed,
DeletionTime: time.Time{},
},
"1": {
Version: 1,
CreatedTime: expectedCreatedTimeParsed,
DeletionTime: expectedDeletedTimeParsed,
},
},
},
},
}

for _, tc := range testCases {
md, err := extractFullMetadata(tc.input)
if err != nil {
t.Fatalf("err: %s", err)
}

if !reflect.DeepEqual(md, tc.expected) {
t.Fatalf("%s: got\n%#v\nexpected\n%#v\n", tc.name, md, tc.expected)
}
}
}

func TestExtractCustomMetadata(t *testing.T) {
testCases := []struct {
name string
inputAPIResp *Secret
expected map[string]interface{}
}{
{
name: "a read response with some custom metadata",
inputAPIResp: &Secret{
Data: map[string]interface{}{
"metadata": map[string]interface{}{
"custom_metadata": map[string]interface{}{"org": "eng"},
},
},
},
expected: map[string]interface{}{"org": "eng"},
},
{
name: "a write response with some (pre-existing) custom metadata",
inputAPIResp: &Secret{
Data: map[string]interface{}{
"custom_metadata": map[string]interface{}{"org": "eng"},
},
},
expected: map[string]interface{}{"org": "eng"},
},
}

for _, tc := range testCases {
cm, err := extractCustomMetadata(tc.inputAPIResp)
if err != nil {
t.Fatalf("err: %s", err)
}

if !reflect.DeepEqual(cm, tc.expected) {
t.Fatalf("%s: got\n%#v\nexpected\n%#v\n", tc.name, cm, tc.expected)
}
}
}
Loading

0 comments on commit 242a6f9

Please sign in to comment.