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 Patch KV helper #15587

Merged
merged 26 commits into from
Jun 1, 2022
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 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
c9f7284
Add Patch KV helper
digivava May 24, 2022
05ef7e9
Merge and fix conflicts
digivava May 25, 2022
cc4c9df
Add godoc comment and use WithOption ourselves in other KVOption func…
digivava May 25, 2022
08cf80c
Clean up options-handling and resp parsing logic; add more tests
digivava May 26, 2022
53d23fa
Add constants and more patch tests
digivava May 26, 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
171 changes: 163 additions & 8 deletions api/kv_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,39 @@ type KVVersionMetadata struct {
Destroyed bool `mapstructure:"destroyed"`
}

// Currently supported options: WithCheckAndSet
// Currently supported options: WithOption, WithCheckAndSet, WithMethod
type KVOption func() (key string, value interface{})

// WithCheckAndSet can optionally be passed to perform a check-and-set
// operation. If not set, the write will be allowed. If cas is set to 0, a
// write will only be allowed if the key doesn't exist. If set to non-zero,
// the write will only be allowed if the key’s current version matches the
// version specified in the cas parameter.
func WithCheckAndSet(cas int) KVOption {
const (
KVOptionCheckAndSet = "cas"
KVOptionMethod = "method"
)

// WithOption can optionally be passed to provide generic options for a
// KV request. Valid keys and values depend on the type of request.
func WithOption(key string, value interface{}) KVOption {
return func() (string, interface{}) {
return "cas", cas
return key, value
}
}

// WithCheckAndSet can optionally be passed to perform a check-and-set
// operation on a KV request. If not set, the write will be allowed.
// If cas is set to 0, a write will only be allowed if the key doesn't exist.
// If set to non-zero, the write will only be allowed if the key’s current
// version matches the version specified in the cas parameter.
func WithCheckAndSet(cas int) KVOption {
return WithOption(KVOptionCheckAndSet, cas)
}

// WithMethod can optionally be passed to dictate which type of
// patch to perform in a Patch request. If set to "patch", then an HTTP PATCH
// request will be issued. If set to "rw", then a read will be performed,
// then a local update, followed by a remote update. Defaults to "patch".
func WithMethod(method string) KVOption {
return WithOption(KVOptionMethod, method)
}

// Get returns the latest version of a secret from the KV v2 secrets engine.
//
// If the latest version has been deleted, an error will not be thrown, but
Expand Down Expand Up @@ -217,6 +236,53 @@ func (kv *kvv2) Put(ctx context.Context, secretPath string, data map[string]inte
return kvSecret, nil
}

// Patch additively updates the most recent version of a key-value secret,
// differentiating it from Put which will fully overwrite the previous data.
// Only the key-value pairs that are new or changing need to be provided.
//
// The WithMethod KVOption function can optionally be passed to dictate which
// kind of patch to perform, as older Vault server versions (pre-1.9.0) may
// only be able to use the old "rw" (read-then-write) style of partial update,
// whereas newer Vault servers can use the default value of "patch" if the
// client token's policy has the "patch" capability.
func (kv *kvv2) Patch(ctx context.Context, secretPath string, newData map[string]interface{}, opts ...KVOption) (*KVSecret, error) {
// determine patch method
var patchMethod string
var ok bool
for _, opt := range opts {
k, v := opt()
if k == "method" {
patchMethod, ok = v.(string)
if !ok {
return nil, fmt.Errorf("unsupported type provided for option value; value for patch method should be string \"rw\" or \"patch\"")
}
}
}

// Determine which kind of patch to use,
// the newer HTTP Patch style or the older read-then-write style
var kvs *KVSecret
var perr error
switch patchMethod {
case "rw":
kvs, perr = readThenWrite(ctx, kv.c, kv.mountPath, secretPath, newData)
case "patch":
kvs, perr = mergePatch(ctx, kv.c, kv.mountPath, secretPath, newData, opts...)
case "":
kvs, perr = mergePatch(ctx, kv.c, kv.mountPath, secretPath, newData, opts...)
default:
return nil, fmt.Errorf("unsupported patch method provided; value for patch method should be string \"rw\" or \"patch\"")
}
if perr != nil {
return nil, fmt.Errorf("unable to perform patch: %w", perr)
}
if kvs == nil {
return nil, fmt.Errorf("no secret was written to %s", secretPath)
}

return kvs, nil
}

// Delete deletes the most recent version of a secret from the KV v2
// secrets engine. To delete an older version, use DeleteVersions.
func (kv *kvv2) Delete(ctx context.Context, secretPath string) error {
Expand Down Expand Up @@ -399,3 +465,92 @@ func extractFullMetadata(secret *Secret) (*KVMetadata, error) {

return metadata, nil
}

func mergePatch(ctx context.Context, client *Client, mountPath string, secretPath string, newData map[string]interface{}, opts ...KVOption) (*KVSecret, error) {
pathToMergePatch := fmt.Sprintf("%s/data/%s", mountPath, secretPath)

// take any other additional options provided
// and pass them along to the patch request
wrappedData := map[string]interface{}{
"data": newData,
}
options := make(map[string]interface{})
for _, opt := range opts {
k, v := opt()
options[k] = v
}
if len(opts) > 0 {
wrappedData["options"] = options
}

secret, err := client.Logical().JSONMergePatch(ctx, pathToMergePatch, wrappedData)
if err != nil {
// If it's a 405, that probably means the server is running a pre-1.9
// Vault version that doesn't support the HTTP PATCH method.
// Fall back to the old way of doing it.
if re, ok := err.(*ResponseError); ok && re.StatusCode == 405 {
return readThenWrite(ctx, client, mountPath, secretPath, newData)
}

if re, ok := err.(*ResponseError); ok && re.StatusCode == 403 {
return nil, fmt.Errorf("received 403 from Vault server; please ensure that token's policy has \"patch\" capability: %w", err)
}

return nil, fmt.Errorf("error performing merge patch to %s: %s", pathToMergePatch, err)
}

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

kvSecret := &KVSecret{
Data: nil, // secret.Data in this case is the metadata
VersionMetadata: metadata,
Raw: secret,
}

cm, err := extractCustomMetadata(secret)
if err != nil {
return nil, fmt.Errorf("error reading custom metadata for secret %s: %w", secretPath, err)
}
kvSecret.CustomMetadata = cm

return kvSecret, nil
}

func readThenWrite(ctx context.Context, client *Client, mountPath string, secretPath string, newData map[string]interface{}) (*KVSecret, error) {
// First, read the secret.
existingVersion, err := client.KVv2(mountPath).Get(ctx, secretPath)
if err != nil {
return nil, fmt.Errorf("error reading secret as part of read-then-write patch operation: %w", err)
}

// Make sure the secret already exists
if existingVersion == nil || existingVersion.Data == nil {
return nil, fmt.Errorf("no existing secret was found at %s when doing read-then-write patch operation: %w", secretPath, err)
}

// Verify existing secret has metadata
if existingVersion.VersionMetadata == nil {
return nil, fmt.Errorf("no metadata found at %s; patch can only be used on existing data", secretPath)
}

// Verify existing secret has data
if existingVersion.Data == nil {
return nil, fmt.Errorf("no data found at %s; patch can only be used on existing data", secretPath)
}

// Copy new data over with existing data
combinedData := existingVersion.Data
for k, v := range newData {
combinedData[k] = v
}

updatedSecret, err := client.KVv2(mountPath).Put(ctx, secretPath, combinedData, WithCheckAndSet(existingVersion.VersionMetadata.Version))
if err != nil {
return nil, fmt.Errorf("error writing secret to %s: %w", secretPath, err)
}

return updatedSecret, nil
}
109 changes: 91 additions & 18 deletions vault/external_tests/api/kv_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ func TestKVHelpers(t *testing.T) {

//// v1 ////
t.Run("kv v1 helpers", func(t *testing.T) {
if err := client.KVv1("secret").Put(context.Background(), "my-secret", secretData); err != nil {
mountPath := "secret"
secretPath := "my-secret"
if err := client.KVv1(mountPath).Put(context.Background(), secretPath, secretData); err != nil {
t.Fatal(err)
}

secret, err := client.KVv1("secret").Get(context.Background(), "my-secret")
secret, err := client.KVv1(mountPath).Get(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}
Expand All @@ -63,23 +65,25 @@ func TestKVHelpers(t *testing.T) {
t.Fatalf("kv v1 secret did not contain expected value")
}

if err := client.KVv1("secret").Delete(context.Background(), "my-secret"); err != nil {
if err := client.KVv1(mountPath).Delete(context.Background(), secretPath); err != nil {
t.Fatal(err)
}
})

//// v2 ////
t.Run("kv v2 helpers", func(t *testing.T) {
mountPath := "secret-v2"
secretPath := "my-secret"
// create a secret
writtenSecret, err := client.KVv2("secret-v2").Put(context.Background(), "my-secret", secretData)
writtenSecret, err := client.KVv2(mountPath).Put(context.Background(), secretPath, secretData)
if err != nil {
t.Fatal(err)
}
if writtenSecret == nil || writtenSecret.VersionMetadata == nil {
t.Fatal("kv v2 secret did not have expected contents")
}

secret, err := client.KVv2("secret-v2").Get(context.Background(), "my-secret")
secret, err := client.KVv2(mountPath).Get(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}
Expand All @@ -91,7 +95,7 @@ func TestKVHelpers(t *testing.T) {
}

// get its full metadata
fullMetadata, err := client.KVv2("secret-v2").GetMetadata(context.Background(), "my-secret")
fullMetadata, err := client.KVv2(mountPath).GetMetadata(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}
Expand All @@ -100,14 +104,14 @@ func TestKVHelpers(t *testing.T) {
}

// create a second version
_, err = client.KVv2("secret-v2").Put(context.Background(), "my-secret", map[string]interface{}{
_, err = client.KVv2(mountPath).Put(context.Background(), secretPath, map[string]interface{}{
"foo": "baz",
})
if err != nil {
t.Fatal(err)
}

s2, err := client.KVv2("secret-v2").Get(context.Background(), "my-secret")
s2, err := client.KVv2(mountPath).Get(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}
Expand All @@ -119,7 +123,7 @@ func TestKVHelpers(t *testing.T) {
}

// get a specific past version
s1, err := client.KVv2("secret-v2").GetVersion(context.Background(), "my-secret", 1)
s1, err := client.KVv2(mountPath).GetVersion(context.Background(), secretPath, 1)
if err != nil {
t.Fatal(err)
}
Expand All @@ -128,11 +132,11 @@ func TestKVHelpers(t *testing.T) {
}

// delete that version
if err = client.KVv2("secret-v2").DeleteVersions(context.Background(), "my-secret", []int{1}); err != nil {
if err = client.KVv2(mountPath).DeleteVersions(context.Background(), secretPath, []int{1}); err != nil {
t.Fatal(err)
}

s1AfterDelete, err := client.KVv2("secret-v2").GetVersion(context.Background(), "my-secret", 1)
s1AfterDelete, err := client.KVv2(mountPath).GetVersion(context.Background(), secretPath, 1)
if err != nil {
t.Fatal(err)
}
Expand All @@ -146,24 +150,93 @@ func TestKVHelpers(t *testing.T) {
}

// check that KVOption works
_, err = client.KVv2("secret-v2").Put(context.Background(), "my-secret", map[string]interface{}{
////
// WithCheckAndSet
_, err = client.KVv2(mountPath).Put(context.Background(), secretPath, map[string]interface{}{
"meow": "woof",
}, api.WithCheckAndSet(99))
// should fail
if err == nil {
t.Fatalf("expected error from trying to update different version from check-and-set value")
t.Fatalf("expected error from trying to update different version from check-and-set value using WithCheckAndSet")
}

versions, err := client.KVv2("secret-v2").GetVersionsAsList(context.Background(), "my-secret")
// WithOption (generic)
_, err = client.KVv2(mountPath).Put(context.Background(), secretPath, map[string]interface{}{
"bow": "wow",
}, api.WithOption("cas", 99))
// should fail
if err == nil {
t.Fatalf("expected error from trying to update different version from check-and-set value using generic WithOption")
}

// WithMethod Patch (implicit)
patch, err := client.KVv2(mountPath).Patch(context.Background(), secretPath, map[string]interface{}{
"dog": "cat",
})
if err != nil {
t.Fatal(err)
}
if patch.VersionMetadata.Version != 3 {
t.Fatalf("incorrect version %d, expected 3", patch.VersionMetadata.Version)
}

// WithMethod Patch (explicit)
patchExp, err := client.KVv2(mountPath).Patch(context.Background(), secretPath, map[string]interface{}{
"rat": "mouse",
}, api.WithMethod("patch"))
if err != nil {
t.Fatal(err)
}
if patchExp.VersionMetadata.Version != 4 {
t.Fatalf("incorrect version %d, expected 4", patchExp.VersionMetadata.Version)
}

// WithMethod RW
patchRW, err := client.KVv2(mountPath).Patch(context.Background(), secretPath, map[string]interface{}{
"bird": "tweet",
}, api.WithMethod("rw"))
if err != nil {
t.Fatal(err)
}
if patchRW.VersionMetadata.Version != 5 {
t.Fatalf("incorrect version %d, expected 5", patchRW.VersionMetadata.Version)
}

secretAfterPatches, err := client.KVv2(mountPath).Get(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}
_, ok := secretAfterPatches.Data["dog"]
if !ok {
t.Fatalf("secret did not contain data patched with implicit Patch method")
}
_, ok = secretAfterPatches.Data["rat"]
if !ok {
t.Fatalf("secret did not contain data patched with explicit Patch method")
}
_, ok = secretAfterPatches.Data["bird"]
if !ok {
t.Fatalf("secret did not contain data patched with RW method")
}
value, ok := secretAfterPatches.Data["foo"]
if !ok || value != "baz" {
t.Fatalf("secret did not keep original data after patch")
}
////

// get versions as list
versions, err := client.KVv2(mountPath).GetVersionsAsList(context.Background(), secretPath)
if err != nil {
t.Fatal(err)
}

if len(versions) != 2 {
t.Fatalf("expected there to be 2 versions of the secret but got %d", len(versions))
expectedLength := 5
if len(versions) != expectedLength {
t.Fatalf("expected there to be %d versions of the secret but got %d", expectedLength, len(versions))
}

if versions[0].Version != 1 {
t.Fatalf("incorrect value for version; expected 1 but got %d", versions[0].Version)
if versions[0].Version != 1 || versions[len(versions)-1].Version != expectedLength {
t.Fatalf("versions list is not ordered as expected")
}
})
}