mirror of
				https://github.com/optim-enterprises-bv/vault.git
				synced 2025-10-30 18:17:55 +00:00 
			
		
		
		
	Add HTTP PATCH support for KV key metadata (#13215)
* go get vault-plugin-secrets-kv@vault-4290-patch-metadata * add kv metadata patch command * add changelog entry * success tests for kv metadata patch flags * add more kv metadata patch flags tests * add kv metadata patch cas warning test * add kv-v2 key metadata patch API docs * add kv metadata patch to docs * prevent unintentional field overwriting in kv metadata put cmd * like create/update ops, prevent patch to paths ending in / * fix kv metadata patch cmd in docs * fix flag defaults for kv metadata put * go get vault-plugin-secrets-kv@vault-4290-patch-metadata * fix TestKvMetadataPatchCommand_Flags test * doc fixes * go get vault-plugin-secrets-kv@master; go mod tidy
This commit is contained in:
		
							
								
								
									
										3
									
								
								changelog/13215.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changelog/13215.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ```release-note:improvement | ||||
| secrets/kv: add patch support for KVv2 key metadata | ||||
| ``` | ||||
| @@ -718,6 +718,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { | ||||
| 				BaseCommand: getBaseCommand(), | ||||
| 			}, nil | ||||
| 		}, | ||||
| 		"kv metadata patch": func() (cli.Command, error) { | ||||
| 			return &KVMetadataPatchCommand{ | ||||
| 				BaseCommand: getBaseCommand(), | ||||
| 			}, nil | ||||
| 		}, | ||||
| 		"kv metadata get": func() (cli.Command, error) { | ||||
| 			return &KVMetadataGetCommand{ | ||||
| 				BaseCommand: getBaseCommand(), | ||||
|   | ||||
							
								
								
									
										193
									
								
								command/kv_metadata_patch.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								command/kv_metadata_patch.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,193 @@ | ||||
| package command | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/mitchellh/cli" | ||||
| 	"github.com/posener/complete" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	_ cli.Command             = (*KVMetadataPutCommand)(nil) | ||||
| 	_ cli.CommandAutocomplete = (*KVMetadataPutCommand)(nil) | ||||
| ) | ||||
|  | ||||
| type KVMetadataPatchCommand struct { | ||||
| 	*BaseCommand | ||||
|  | ||||
| 	flagMaxVersions        int | ||||
| 	flagCASRequired        BoolPtr | ||||
| 	flagDeleteVersionAfter time.Duration | ||||
| 	flagCustomMetadata     map[string]string | ||||
| 	testStdin              io.Reader // for tests | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) Synopsis() string { | ||||
| 	return "Patches key settings in the KV store" | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) Help() string { | ||||
| 	helpText := ` | ||||
| Usage: vault metadata kv patch [options] KEY | ||||
|  | ||||
|   This command can be used to create a blank key in the key-value store or to | ||||
|   update key configuration for a specified key. | ||||
|  | ||||
|   Create a key in the key-value store with no data: | ||||
|  | ||||
|       $ vault kv metadata patch secret/foo | ||||
|  | ||||
|   Set a max versions setting on the key: | ||||
|  | ||||
|       $ vault kv metadata patch -max-versions=5 secret/foo | ||||
|  | ||||
|   Set delete-version-after on the key: | ||||
|  | ||||
|       $ vault kv metadata patch -delete-version-after=3h25m19s secret/foo | ||||
|  | ||||
|   Require Check-and-Set for this key: | ||||
|  | ||||
|       $ vault kv metadata patch -cas-required secret/foo | ||||
|  | ||||
|   Set custom metadata on the key: | ||||
|  | ||||
|       $ vault kv metadata patch -custom-metadata=foo=abc -custom-metadata=bar=123 secret/foo | ||||
|  | ||||
|   Additional flags and more advanced use cases are detailed below. | ||||
|  | ||||
| ` + c.Flags().Help() | ||||
| 	return strings.TrimSpace(helpText) | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) Flags() *FlagSets { | ||||
| 	set := c.flagSet(FlagSetHTTP | FlagSetOutputFormat) | ||||
|  | ||||
| 	// Common Options | ||||
| 	f := set.NewFlagSet("Common Options") | ||||
|  | ||||
| 	f.IntVar(&IntVar{ | ||||
| 		Name:    "max-versions", | ||||
| 		Target:  &c.flagMaxVersions, | ||||
| 		Default: -1, | ||||
| 		Usage:   `The number of versions to keep. If not set, the backend’s configured max version is used.`, | ||||
| 	}) | ||||
|  | ||||
| 	f.BoolPtrVar(&BoolPtrVar{ | ||||
| 		Name:   "cas-required", | ||||
| 		Target: &c.flagCASRequired, | ||||
| 		Usage:  `If true the key will require the cas parameter to be set on all write requests. If false, the backend’s configuration will be used.`, | ||||
| 	}) | ||||
|  | ||||
| 	f.DurationVar(&DurationVar{ | ||||
| 		Name:       "delete-version-after", | ||||
| 		Target:     &c.flagDeleteVersionAfter, | ||||
| 		Default:    -1, | ||||
| 		EnvVar:     "", | ||||
| 		Completion: complete.PredictAnything, | ||||
| 		Usage: `Specifies the length of time before a version is deleted. | ||||
| 		If not set, the backend's configured delete-version-after is used. Cannot be | ||||
| 		greater than the backend's delete-version-after. The delete-version-after is | ||||
| 		specified as a numeric string with a suffix like "30s" or | ||||
| 		"3h25m19s".`, | ||||
| 	}) | ||||
|  | ||||
| 	f.StringMapVar(&StringMapVar{ | ||||
| 		Name:    "custom-metadata", | ||||
| 		Target:  &c.flagCustomMetadata, | ||||
| 		Default: map[string]string{}, | ||||
| 		Usage: `Specifies arbitrary version-agnostic key=value metadata meant to describe a secret. | ||||
| 		This can be specified multiple times to add multiple pieces of metadata.`, | ||||
| 	}) | ||||
|  | ||||
| 	return set | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) AutocompleteArgs() complete.Predictor { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) AutocompleteFlags() complete.Flags { | ||||
| 	return c.Flags().Completions() | ||||
| } | ||||
|  | ||||
| func (c *KVMetadataPatchCommand) Run(args []string) int { | ||||
| 	f := c.Flags() | ||||
|  | ||||
| 	if err := f.Parse(args); err != nil { | ||||
| 		c.UI.Error(err.Error()) | ||||
| 		return 1 | ||||
| 	} | ||||
|  | ||||
| 	args = f.Args() | ||||
|  | ||||
| 	switch { | ||||
| 	case len(args) < 1: | ||||
| 		c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) | ||||
| 		return 1 | ||||
| 	case len(args) > 1: | ||||
| 		c.UI.Error(fmt.Sprintf("Too many arguments (expected 1, got %d)", len(args))) | ||||
| 		return 1 | ||||
| 	} | ||||
|  | ||||
| 	client, err := c.Client() | ||||
| 	if err != nil { | ||||
| 		c.UI.Error(err.Error()) | ||||
| 		return 2 | ||||
| 	} | ||||
|  | ||||
| 	path := sanitizePath(args[0]) | ||||
|  | ||||
| 	mountPath, v2, err := isKVv2(path, client) | ||||
| 	if err != nil { | ||||
| 		c.UI.Error(err.Error()) | ||||
| 		return 2 | ||||
| 	} | ||||
| 	if !v2 { | ||||
| 		c.UI.Error("Metadata not supported on KV Version 1") | ||||
| 		return 1 | ||||
| 	} | ||||
|  | ||||
| 	path = addPrefixToVKVPath(path, mountPath, "metadata") | ||||
|  | ||||
| 	data := map[string]interface{}{} | ||||
|  | ||||
| 	if c.flagMaxVersions >= 0 { | ||||
| 		data["max_versions"] = c.flagMaxVersions | ||||
| 	} | ||||
|  | ||||
| 	if c.flagCASRequired.IsSet() { | ||||
| 		data["cas_required"] = c.flagCASRequired.Get() | ||||
| 	} | ||||
|  | ||||
| 	if c.flagDeleteVersionAfter >= 0 { | ||||
| 		data["delete_version_after"] = c.flagDeleteVersionAfter.String() | ||||
| 	} | ||||
|  | ||||
| 	if len(c.flagCustomMetadata) > 0 { | ||||
| 		data["custom_metadata"] = c.flagCustomMetadata | ||||
| 	} | ||||
|  | ||||
| 	secret, err := client.Logical().JSONMergePatch(context.Background(), path, data) | ||||
| 	if err != nil { | ||||
| 		c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err)) | ||||
|  | ||||
| 		if secret != nil { | ||||
| 			OutputSecret(c.UI, secret) | ||||
| 		} | ||||
| 		return 2 | ||||
| 	} | ||||
|  | ||||
| 	if secret == nil { | ||||
| 		// Don't output anything unless using the "table" format | ||||
| 		if Format(c.UI) == "table" { | ||||
| 			c.UI.Info(fmt.Sprintf("Success! Data written to: %s", path)) | ||||
| 		} | ||||
| 		return 0 | ||||
| 	} | ||||
|  | ||||
| 	return OutputSecret(c.UI, secret) | ||||
| } | ||||
							
								
								
									
										273
									
								
								command/kv_metadata_patch_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								command/kv_metadata_patch_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,273 @@ | ||||
| package command | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/go-test/deep" | ||||
| 	"github.com/hashicorp/vault/api" | ||||
| 	"github.com/mitchellh/cli" | ||||
| ) | ||||
|  | ||||
| func testKVMetadataPatchCommand(tb testing.TB) (*cli.MockUi, *KVMetadataPatchCommand) { | ||||
| 	tb.Helper() | ||||
|  | ||||
| 	ui := cli.NewMockUi() | ||||
| 	return ui, &KVMetadataPatchCommand{ | ||||
| 		BaseCommand: &BaseCommand{ | ||||
| 			UI: ui, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func kvMetadataPatchWithRetry(t *testing.T, client *api.Client, args []string, stdin *io.PipeReader) (int, string) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	return retryKVCommand(t, func() (int, string) { | ||||
| 		ui, cmd := testKVMetadataPatchCommand(t) | ||||
| 		cmd.client = client | ||||
|  | ||||
| 		if stdin != nil { | ||||
| 			cmd.testStdin = stdin | ||||
| 		} | ||||
|  | ||||
| 		code := cmd.Run(args) | ||||
| 		combined := ui.OutputWriter.String() + ui.ErrorWriter.String() | ||||
|  | ||||
| 		return code, combined | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func kvMetadataPutWithRetry(t *testing.T, client *api.Client, args []string, stdin *io.PipeReader) (int, string) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	return retryKVCommand(t, func() (int, string) { | ||||
| 		ui, cmd := testKVMetadataPutCommand(t) | ||||
| 		cmd.client = client | ||||
|  | ||||
| 		if stdin != nil { | ||||
| 			cmd.testStdin = stdin | ||||
| 		} | ||||
|  | ||||
| 		code := cmd.Run(args) | ||||
| 		combined := ui.OutputWriter.String() + ui.ErrorWriter.String() | ||||
|  | ||||
| 		return code, combined | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPatchCommand_EmptyArgs(t *testing.T) { | ||||
| 	client, closer := testVaultServer(t) | ||||
| 	defer closer() | ||||
|  | ||||
| 	if err := client.Sys().Mount("kv/", &api.MountInput{ | ||||
| 		Type: "kv-v2", | ||||
| 	}); err != nil { | ||||
| 		t.Fatalf("kv-v2 mount error: %#v", err) | ||||
| 	} | ||||
|  | ||||
| 	args := make([]string, 0) | ||||
| 	code, combined := kvMetadataPatchWithRetry(t, client, args, nil) | ||||
|  | ||||
| 	expectedCode := 1 | ||||
| 	expectedOutput := "Not enough arguments" | ||||
|  | ||||
| 	if code != expectedCode { | ||||
| 		t.Fatalf("expected code to be %d but was %d for patch cmd with args %#v", expectedCode, code, args) | ||||
| 	} | ||||
|  | ||||
| 	if !strings.Contains(combined, expectedOutput) { | ||||
| 		t.Fatalf("expected output to be %q but was %q for patch cmd with args %#v", expectedOutput, combined, args) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPatchCommand_Flags(t *testing.T) { | ||||
| 	t.Parallel() | ||||
|  | ||||
| 	cases := []struct { | ||||
| 		name            string | ||||
| 		args            []string | ||||
| 		out             string | ||||
| 		code            int | ||||
| 		expectedUpdates map[string]interface{} | ||||
| 	}{ | ||||
| 		{ | ||||
| 			"cas_required_success", | ||||
| 			[]string{"-cas-required=true"}, | ||||
| 			"Success!", | ||||
| 			0, | ||||
| 			map[string]interface{}{ | ||||
| 				"cas_required": true, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"cas_required_invalid", | ||||
| 			[]string{"-cas-required=12345"}, | ||||
| 			"invalid boolean value", | ||||
| 			1, | ||||
| 			map[string]interface{}{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"custom_metadata_success", | ||||
| 			[]string{"-custom-metadata=baz=ghi"}, | ||||
| 			"Success!", | ||||
| 			0, | ||||
| 			map[string]interface{}{ | ||||
| 				"custom_metadata": map[string]interface{}{ | ||||
| 					"foo": "abc", | ||||
| 					"bar": "def", | ||||
| 					"baz": "ghi", | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"delete_version_after_success", | ||||
| 			[]string{"-delete-version-after=5s"}, | ||||
| 			"Success!", | ||||
| 			0, | ||||
| 			map[string]interface{}{ | ||||
| 				"delete_version_after": "5s", | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"delete_version_after_invalid", | ||||
| 			[]string{"-delete-version-after=false"}, | ||||
| 			"invalid duration", | ||||
| 			1, | ||||
| 			map[string]interface{}{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"max_versions_success", | ||||
| 			[]string{"-max-versions=10"}, | ||||
| 			"Success!", | ||||
| 			0, | ||||
| 			map[string]interface{}{ | ||||
| 				"max_versions": json.Number("10"), | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"max_versions_invalid", | ||||
| 			[]string{"-max-versions=false"}, | ||||
| 			"invalid syntax", | ||||
| 			1, | ||||
| 			map[string]interface{}{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			"multiple_flags_success", | ||||
| 			[]string{"-max-versions=20", "-custom-metadata=baz=123"}, | ||||
| 			"Success!", | ||||
| 			0, | ||||
| 			map[string]interface{}{ | ||||
| 				"max_versions": json.Number("20"), | ||||
| 				"custom_metadata": map[string]interface{}{ | ||||
| 					"foo": "abc", | ||||
| 					"bar": "def", | ||||
| 					"baz": "123", | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range cases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			client, closer := testVaultServer(t) | ||||
| 			defer closer() | ||||
|  | ||||
| 			basePath := t.Name() + "/" | ||||
| 			secretPath := basePath + "my-secret" | ||||
| 			metadataPath := basePath + "metadata/" + "my-secret" | ||||
|  | ||||
| 			if err := client.Sys().Mount(basePath, &api.MountInput{ | ||||
| 				Type: "kv-v2", | ||||
| 			}); err != nil { | ||||
| 				t.Fatalf("kv-v2 mount error: %#v", err) | ||||
| 			} | ||||
|  | ||||
| 			putArgs := []string{"-cas-required=true", "-custom-metadata=foo=abc", "-custom-metadata=bar=def", secretPath} | ||||
| 			code, combined := kvMetadataPutWithRetry(t, client, putArgs, nil) | ||||
|  | ||||
| 			if code != 0 { | ||||
| 				t.Fatalf("initial metadata put failed, code: %d, output: %s", code, combined) | ||||
| 			} | ||||
|  | ||||
| 			initialMetadata, err := client.Logical().Read(metadataPath) | ||||
| 			if err != nil { | ||||
| 				t.Fatalf("metadata read failed, err: %#v", err) | ||||
| 			} | ||||
|  | ||||
| 			patchArgs := append(tc.args, secretPath) | ||||
|  | ||||
| 			code, combined = kvMetadataPatchWithRetry(t, client, patchArgs, nil) | ||||
|  | ||||
| 			if !strings.Contains(combined, tc.out) { | ||||
| 				t.Fatalf("expected output to be %q but was %q for patch cmd with args %#v", tc.out, combined, patchArgs) | ||||
| 			} | ||||
| 			if code != tc.code { | ||||
| 				t.Fatalf("expected code to be %d but was %d for patch cmd with args %#v", tc.code, code, patchArgs) | ||||
| 			} | ||||
|  | ||||
| 			patchedMetadata, err := client.Logical().Read(metadataPath) | ||||
| 			if err != nil { | ||||
| 				t.Fatalf("metadata read failed, err: %#v", err) | ||||
| 			} | ||||
|  | ||||
| 			for k, v := range patchedMetadata.Data { | ||||
| 				var expectedVal interface{} | ||||
|  | ||||
| 				if inputVal, ok := tc.expectedUpdates[k]; ok { | ||||
| 					expectedVal = inputVal | ||||
| 				} else { | ||||
| 					expectedVal = initialMetadata.Data[k] | ||||
| 				} | ||||
|  | ||||
| 				if diff := deep.Equal(expectedVal, v); len(diff) > 0 { | ||||
| 					t.Fatalf("patched %q mismatch, diff: %#v", k, diff) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPatchCommand_CasWarning(t *testing.T) { | ||||
| 	client, closer := testVaultServer(t) | ||||
| 	defer closer() | ||||
|  | ||||
| 	basePath := "kv/" | ||||
| 	if err := client.Sys().Mount(basePath, &api.MountInput{ | ||||
| 		Type: "kv-v2", | ||||
| 	}); err != nil { | ||||
| 		t.Fatalf("kv-v2 mount error: %#v", err) | ||||
| 	} | ||||
|  | ||||
| 	secretPath := basePath + "my-secret" | ||||
|  | ||||
| 	args := []string{"-cas-required=true", secretPath} | ||||
| 	code, combined := kvMetadataPutWithRetry(t, client, args, nil) | ||||
|  | ||||
| 	if code != 0 { | ||||
| 		t.Fatalf("metadata put failed, code: %d, output: %s", code, combined) | ||||
| 	} | ||||
|  | ||||
| 	casConfig := map[string]interface{}{ | ||||
| 		"cas_required": true, | ||||
| 	} | ||||
|  | ||||
| 	_, err := client.Logical().Write(basePath + "config", casConfig) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("config write failed, err: #%v", err) | ||||
| 	} | ||||
|  | ||||
| 	args = []string{"-cas-required=false", secretPath} | ||||
| 	code, combined = kvMetadataPatchWithRetry(t, client, args, nil) | ||||
|  | ||||
| 	if code != 0 { | ||||
| 		t.Fatalf("expected code to be 0 but was %d for patch cmd with args %#v", code, args) | ||||
| 	} | ||||
|  | ||||
| 	expectedOutput := "\"cas_required\" set to false, but is mandated by backend config" | ||||
| 	if !strings.Contains(combined, expectedOutput) { | ||||
| 		t.Fatalf("expected output to be %q but was %q for patch cmd with args %#v", expectedOutput, combined, args) | ||||
| 	} | ||||
| } | ||||
| @@ -19,7 +19,7 @@ type KVMetadataPutCommand struct { | ||||
| 	*BaseCommand | ||||
|  | ||||
| 	flagMaxVersions        int | ||||
| 	flagCASRequired        bool | ||||
| 	flagCASRequired        BoolPtr | ||||
| 	flagDeleteVersionAfter time.Duration | ||||
| 	flagCustomMetadata     map[string]string | ||||
| 	testStdin              io.Reader // for tests | ||||
| @@ -71,14 +71,13 @@ func (c *KVMetadataPutCommand) Flags() *FlagSets { | ||||
| 	f.IntVar(&IntVar{ | ||||
| 		Name:    "max-versions", | ||||
| 		Target:  &c.flagMaxVersions, | ||||
| 		Default: 0, | ||||
| 		Default: -1, | ||||
| 		Usage:   `The number of versions to keep. If not set, the backend’s configured max version is used.`, | ||||
| 	}) | ||||
|  | ||||
| 	f.BoolVar(&BoolVar{ | ||||
| 	f.BoolPtrVar(&BoolPtrVar{ | ||||
| 		Name:    "cas-required", | ||||
| 		Target:  &c.flagCASRequired, | ||||
| 		Default: false, | ||||
| 		Usage:   `If true the key will require the cas parameter to be set on all write requests. If false, the backend’s configuration will be used.`, | ||||
| 	}) | ||||
|  | ||||
| @@ -151,16 +150,24 @@ func (c *KVMetadataPutCommand) Run(args []string) int { | ||||
| 	} | ||||
|  | ||||
| 	path = addPrefixToVKVPath(path, mountPath, "metadata") | ||||
| 	data := map[string]interface{}{ | ||||
| 		"max_versions":  c.flagMaxVersions, | ||||
| 		"cas_required":  c.flagCASRequired, | ||||
| 		"custom_metadata": c.flagCustomMetadata, | ||||
| 	data := map[string]interface{}{} | ||||
|  | ||||
| 	if c.flagMaxVersions >= 0 { | ||||
| 		data["max_versions"] = c.flagMaxVersions | ||||
| 	} | ||||
|  | ||||
| 	if c.flagDeleteVersionAfter >= 0 { | ||||
| 		data["delete_version_after"] = c.flagDeleteVersionAfter.String() | ||||
| 	} | ||||
|  | ||||
| 	if c.flagCASRequired.IsSet() { | ||||
| 		data["cas_required"] = c.flagCASRequired.Get() | ||||
| 	} | ||||
|  | ||||
| 	if len(c.flagCustomMetadata) > 0 { | ||||
| 		data["custom_metadata"] = c.flagCustomMetadata | ||||
| 	} | ||||
|  | ||||
| 	secret, err := client.Logical().Write(path, data) | ||||
| 	if err != nil { | ||||
| 		c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err)) | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| package command | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/go-test/deep" | ||||
| 	"github.com/hashicorp/vault/api" | ||||
| 	"github.com/mitchellh/cli" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func testKVMetadataPutCommand(tb testing.TB) (*cli.MockUi, *KVMetadataPutCommand) { | ||||
| @@ -19,7 +21,7 @@ func testKVMetadataPutCommand(tb testing.TB) (*cli.MockUi, *KVMetadataPutCommand | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPutCommandDeleteVersionAfter(t *testing.T) { | ||||
| func TestKvMetadataPutCommand_DeleteVersionAfter(t *testing.T) { | ||||
| 	client, closer := testVaultServer(t) | ||||
| 	defer closer() | ||||
|  | ||||
| @@ -78,7 +80,7 @@ func TestKvMetadataPutCommandDeleteVersionAfter(t *testing.T) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPutCommandCustomMetadata(t *testing.T) { | ||||
| func TestKvMetadataPutCommand_CustomMetadata(t *testing.T) { | ||||
| 	client, closer := testVaultServer(t) | ||||
| 	defer closer() | ||||
|  | ||||
| @@ -154,3 +156,47 @@ func TestKvMetadataPutCommandCustomMetadata(t *testing.T) { | ||||
| 		t.Fatal(diff) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestKvMetadataPutCommand_UnprovidedFlags(t *testing.T) { | ||||
| 	client, closer := testVaultServer(t) | ||||
| 	defer closer() | ||||
|  | ||||
| 	basePath := t.Name() + "/" | ||||
| 	secretPath := basePath + "my-secret" | ||||
|  | ||||
| 	if err := client.Sys().Mount(basePath, &api.MountInput{ | ||||
| 		Type: "kv-v2", | ||||
| 	}); err != nil { | ||||
| 		t.Fatalf("kv-v2 mount error: %#v", err) | ||||
| 	} | ||||
|  | ||||
| 	_, cmd := testKVMetadataPutCommand(t) | ||||
| 	cmd.client = client | ||||
|  | ||||
| 	args := []string{"-cas-required=true", "-max-versions=10", secretPath} | ||||
| 	code, _ := kvMetadataPutWithRetry(t, client, args, nil) | ||||
|  | ||||
| 	if code != 0 { | ||||
| 		t.Fatalf("expected 0 exit status but received %d", code) | ||||
| 	} | ||||
|  | ||||
| 	args = []string{"-custom-metadata=foo=bar", secretPath} | ||||
| 	code, _ = kvMetadataPutWithRetry(t, client, args, nil) | ||||
|  | ||||
| 	if code != 0 { | ||||
| 		t.Fatalf("expected 0 exit status but received %d", code) | ||||
| 	} | ||||
|  | ||||
| 	secret, err := client.Logical().Read(basePath + "metadata/" + "my-secret") | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	if secret.Data["cas_required"] != true { | ||||
| 		t.Fatalf("expected cas_required to be true but received %#v", secret.Data["cas_required"]) | ||||
| 	} | ||||
|  | ||||
| 	if secret.Data["max_versions"] != json.Number("10") { | ||||
| 		t.Fatalf("expected max_versions to be 10 but received %#v", secret.Data["max_versions"]) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										4
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.mod
									
									
									
									
									
								
							| @@ -108,7 +108,7 @@ require ( | ||||
| 	github.com/hashicorp/vault-plugin-secrets-azure v0.11.2 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-gcp v0.11.1 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-gcpkms v0.10.0 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211123171606-16933c88368a | ||||
| 	github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20220112155832-c2eb38b5f5b6 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.5.1 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-openldap v0.6.0 | ||||
| 	github.com/hashicorp/vault-plugin-secrets-terraform v0.3.0 | ||||
| @@ -116,7 +116,7 @@ require ( | ||||
| 	github.com/hashicorp/vault/api v1.3.1 | ||||
| 	github.com/hashicorp/vault/api/auth/approle v0.1.0 | ||||
| 	github.com/hashicorp/vault/api/auth/userpass v0.1.0 | ||||
| 	github.com/hashicorp/vault/sdk v0.3.0 | ||||
| 	github.com/hashicorp/vault/sdk v0.3.1-0.20220112143259-b48602fdb885 | ||||
| 	github.com/influxdata/influxdb v0.0.0-20190411212539-d24b7ba8c4c4 | ||||
| 	github.com/jcmturner/gokrb5/v8 v8.4.2 | ||||
| 	github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f | ||||
|   | ||||
							
								
								
									
										4
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.sum
									
									
									
									
									
								
							| @@ -965,8 +965,8 @@ github.com/hashicorp/vault-plugin-secrets-gcp v0.11.1 h1:v8XfuZVrgP4pIwaZe/GgrPC | ||||
| github.com/hashicorp/vault-plugin-secrets-gcp v0.11.1/go.mod h1:ndpmRkIPHW5UYqv2nn2AJNVZsucJ8lY2bp5i5Ngvhuc= | ||||
| github.com/hashicorp/vault-plugin-secrets-gcpkms v0.10.0 h1:0Vi5WEIpZctk/ZoRClodV9WCnM/lCzw9XekMhRZdo8k= | ||||
| github.com/hashicorp/vault-plugin-secrets-gcpkms v0.10.0/go.mod h1:6DPwGu8oGR1sZRpjwkcAnrQZWQuAJ/Ph+rQHfUo1Yf4= | ||||
| github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211123171606-16933c88368a h1:GVA3sY+FRhQrMexWGMCsIfVVMgcdru36WMKvDtKed5I= | ||||
| github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211123171606-16933c88368a/go.mod h1:TNPRoB53Twd9tYvlhqqEhMsQPiVN604kZw9jr2zUzDk= | ||||
| github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20220112155832-c2eb38b5f5b6 h1:Z3NnaIBragxW6iTW7OnvklRzZSZdaidxjs/vkCneGAg= | ||||
| github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20220112155832-c2eb38b5f5b6/go.mod h1:9V2Ecim3m/qw+YAQelUeFADqZ1GVo8xwoLqfKsqh9pI= | ||||
| github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.5.1 h1:Maewon4nu0KL1ALBOvL6Rsj+Qyr9hdULWflyMz7+9nk= | ||||
| github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.5.1/go.mod h1:PLx2vxXukfsKsDRo/PlG4fxmJ1d+H2h82wT3vf4buuI= | ||||
| github.com/hashicorp/vault-plugin-secrets-openldap v0.6.0 h1:d6N/aMlklMfEacyiIuu5ZnTlADhGkGZkDrOtQXBRuhI= | ||||
|   | ||||
| @@ -464,7 +464,8 @@ func (c *Core) handleCancelableRequest(ctx context.Context, req *logical.Request | ||||
| 	// backends. Basically, it's all just terrible, so don't allow it. | ||||
| 	if strings.HasSuffix(req.Path, "/") && | ||||
| 		(req.Operation == logical.UpdateOperation || | ||||
| 			req.Operation == logical.CreateOperation) { | ||||
| 			req.Operation == logical.CreateOperation || | ||||
| 			req.Operation == logical.PatchOperation) { | ||||
| 		return logical.ErrorResponse("cannot write to a path ending in '/'"), nil | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -29,7 +29,7 @@ key-value store. | ||||
|  | ||||
| - `max_versions` `(int: 0)` – The number of versions to keep per key. This value | ||||
|   applies to all keys, but a key's metadata setting can overwrite this value. | ||||
|   Once a key has more than the configured allowed versions the oldest version | ||||
|   Once a key has more than the configured allowed versions, the oldest version | ||||
|   will be permanently deleted. When 0 is used or the value is unset, Vault | ||||
|   will keep 10 versions. | ||||
|  | ||||
| @@ -519,10 +519,10 @@ It does not create a new version. | ||||
|  | ||||
| - `max_versions` `(int: 0)` – The number of versions to keep per key. If not | ||||
|   set, the backend’s configured max version is used. Once a key has more than | ||||
|   the configured allowed versions the oldest version will be permanently | ||||
|   the configured allowed versions, the oldest version will be permanently | ||||
|   deleted. | ||||
|  | ||||
| - `cas_required` `(bool: false)` – If true the key will require the cas | ||||
| - `cas_required` `(bool: false)` – If true, the key will require the `cas` | ||||
|   parameter to be set on all write requests. If false, the backend’s | ||||
|   configuration will be used. | ||||
|  | ||||
| @@ -561,6 +561,60 @@ $ curl \ | ||||
|     https://127.0.0.1:8200/v1/secret/metadata/my-secret | ||||
| ``` | ||||
|  | ||||
| ## Patch Metadata | ||||
| This endpoint patches an existing metadata entry of a secret at the specified | ||||
| location. The calling token must have an ACL policy granting the `patch` | ||||
| capability. Currently, only JSON merge patch is supported and must be specified | ||||
| using a `Content-Type` header value of `application/merge-patch+json`. It does | ||||
| not create a new version. | ||||
|  | ||||
| | Method  | Path                     | | ||||
| | :------ | :----------------------- | | ||||
| | `PATCH` | `/secret/metadata/:path` | | ||||
|  | ||||
| ### Parameters | ||||
|  | ||||
| - `max_versions` `(int: 0)` – The number of versions to keep per key. If not | ||||
|   set, the backend’s configured max version is used. Once a key has more than | ||||
|   the configured allowed versions, the oldest version will be permanently | ||||
|   deleted. | ||||
|  | ||||
| - `cas_required` `(bool: false)` – If true, the key will require the `cas` | ||||
|   parameter to be set on all write requests. If false, the backend’s | ||||
|   configuration will be used. | ||||
|  | ||||
| - `delete_version_after` `(string:"0s")` – Set the `delete_version_after` value | ||||
|   to a duration to specify the `deletion_time` for all new versions | ||||
|   written to this key. If not set, the backend's `delete_version_after` will be | ||||
|   used. If the value is greater than the backend's `delete_version_after`, the | ||||
|   backend's `delete_version_after` will be used. Accepts [Go duration | ||||
|   format string][duration-godoc]. | ||||
|  | ||||
| - `custom_metadata` `(map<string|string>: nil)` - A map of arbitrary string to string valued user-provided metadata meant | ||||
|   to describe the secret. | ||||
|  | ||||
| ### Sample Payload | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "max_versions": 5, | ||||
|   "custom_metadata": { | ||||
|     "bar": "123", | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Sample Request | ||||
|  | ||||
| ```shell-session | ||||
| $ curl \ | ||||
|     --header "X-Vault-Token: ..." \ | ||||
|     --header "Content-Type: application/merge-patch+json" | ||||
|     --request PATCH \ | ||||
|     --data @payload.json \ | ||||
|     https://127.0.0.1:8200/v1/secret/metadata/my-secret | ||||
| ``` | ||||
|  | ||||
| ## Delete Metadata and All Versions | ||||
|  | ||||
| This endpoint permanently deletes the key metadata and all version data for the | ||||
|   | ||||
| @@ -169,7 +169,7 @@ allows for writing keys with arbitrary values. | ||||
|  | ||||
| 1. Write arbitrary data: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv put secret/my-secret foo=a bar=b | ||||
|    Key              Value | ||||
|    ---              ----- | ||||
| @@ -182,7 +182,7 @@ allows for writing keys with arbitrary values. | ||||
|  | ||||
| 1. Read arbitrary data: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv get secret/my-secret | ||||
|    ====== Metadata ====== | ||||
|    Key              Value | ||||
| @@ -206,7 +206,7 @@ allows for writing keys with arbitrary values. | ||||
|    allowed if the key’s current version matches the version specified in the | ||||
|    cas parameter. | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv put -cas=1 secret/my-secret foo=aa bar=bb | ||||
|    Key              Value | ||||
|    ---              ----- | ||||
| @@ -219,7 +219,7 @@ allows for writing keys with arbitrary values. | ||||
|  | ||||
| 1. Reading now will return the newest version of the data: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv get secret/my-secret | ||||
|    ====== Metadata ====== | ||||
|    Key              Value | ||||
| @@ -249,7 +249,7 @@ allows for writing keys with arbitrary values. | ||||
|    read-then-write flow will use the `version` value from the secret returned by | ||||
|    the read to perform a check-and-set operation in the subsequent write. | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv patch -cas=2 secret/my-secret bar=bbb | ||||
|    Key              Value | ||||
|    ---              ----- | ||||
| @@ -266,7 +266,7 @@ allows for writing keys with arbitrary values. | ||||
|  | ||||
|    Perform a patch using the `patch` method: | ||||
|  | ||||
|       ```text | ||||
|       ```shell-session | ||||
|       $ vault kv patch -method=patch -cas=2 secret/my-secret bar=bbb | ||||
|       Key              Value | ||||
|       ---              ----- | ||||
| @@ -278,7 +278,7 @@ allows for writing keys with arbitrary values. | ||||
|       ``` | ||||
|  | ||||
|    Perform a patch using the read-then-write method: | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv patch -method=rw secret/my-secret bar=bbb | ||||
|    Key              Value | ||||
|    ---              ----- | ||||
| @@ -292,7 +292,7 @@ allows for writing keys with arbitrary values. | ||||
| 1. Reading after a patch will return the newest version of the data in which | ||||
|    only the specified fields were updated: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv get secret/my-secret | ||||
|    ====== Metadata ====== | ||||
|    Key              Value | ||||
| @@ -312,7 +312,7 @@ allows for writing keys with arbitrary values. | ||||
|  | ||||
| 1. Previous versions can be accessed with the `-version` flag: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv get -version=1 secret/my-secret | ||||
|    ====== Metadata ====== | ||||
|    Key              Value | ||||
| @@ -349,14 +349,14 @@ See the commands below for more information: | ||||
| 1. The latest version of a key can be deleted with the delete command, this also | ||||
|    takes a `-versions` flag to delete prior versions: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv delete secret/my-secret | ||||
|    t | ||||
|    Success! Data deleted (if it existed) at: secret/my-secret | ||||
|    ``` | ||||
|  | ||||
| 1. Versions can be undeleted: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv undelete -versions=2 secret/my-secret | ||||
|    Success! Data written to: secret/undelete/my-secret | ||||
|  | ||||
| @@ -378,7 +378,7 @@ See the commands below for more information: | ||||
|  | ||||
| 1. Destroying a version permanently deletes the underlying data: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv destroy -versions=2 secret/my-secret | ||||
|    Success! Data written to: secret/destroy/my-secret | ||||
|    ``` | ||||
| @@ -393,7 +393,7 @@ See the commands below for more information: | ||||
|  | ||||
| 1. All metadata and versions for a key can be viewed: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv metadata get secret/my-secret | ||||
|    ========== Metadata ========== | ||||
|    Key                     Value | ||||
| @@ -424,7 +424,7 @@ See the commands below for more information: | ||||
|  | ||||
| 1. The metadata settings for a key can be configured: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv metadata put -max-versions 2 -delete-version-after="3h25m19s" secret/my-secret | ||||
|    Success! Data written to: secret/metadata/my-secret | ||||
|    ``` | ||||
| @@ -432,7 +432,7 @@ See the commands below for more information: | ||||
|    Delete-version-after settings will apply only to new versions. Max versions | ||||
|    changes will be applied on next write: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv put secret/my-secret my-value=newer-s3cr3t | ||||
|    Key              Value | ||||
|    ---              ----- | ||||
| @@ -446,7 +446,7 @@ See the commands below for more information: | ||||
|    Once a key has more versions than max versions the oldest versions | ||||
|    are cleaned up: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv metadata get secret/my-secret | ||||
|    ========== Metadata ========== | ||||
|    Key                     Value | ||||
| @@ -476,17 +476,25 @@ See the commands below for more information: | ||||
|    ``` | ||||
|  | ||||
|   A secret's key metadata can contain custom metadata used to describe the secret. The | ||||
|   data will be stored as string-to-string key-value pairs. If the `-custom-metadata` flag | ||||
|   is set, the value of `custom_metadata` will be fully overwritten. The `-custom-metadata` | ||||
|   flag can be repeated to add multiple key-value pairs: | ||||
|   data will be stored as string-to-string key-value pairs. The `-custom-metadata` | ||||
|   flag can be repeated to add multiple key-value pairs. | ||||
|  | ||||
|   ```text | ||||
|   vault kv metadata put -custom-metadata=foo=abc -custom-metadata=bar=123 secret/my-secret | ||||
|   The `vault kv metadata put` command can be used to fully overwrite the value of `custom_metadata`: | ||||
|  | ||||
|   ```shell-session | ||||
|   $ vault kv metadata put -custom-metadata=foo=abc -custom-metadata=bar=123 secret/my-secret | ||||
|   ``` | ||||
|  | ||||
|     The `vault kv metadata patch` command can be used to partially overwrite the value of `custom_metadata`. | ||||
|     The following invocation will update `custom_metadata` sub-field `foo` but leave `bar` untouched: | ||||
|  | ||||
|     ```shell-session | ||||
|     $ vault kv metadata patch -custom-metadata=foo=def secret/my-secret | ||||
|     ``` | ||||
|  | ||||
| 1. Permanently delete all metadata and versions for a key: | ||||
|  | ||||
|    ```text | ||||
|    ```shell-session | ||||
|    $ vault kv metadata delete secret/my-secret | ||||
|    Success! Data deleted (if it existed) at: secret/metadata/my-secret | ||||
|    ``` | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Chris Capurso
					Chris Capurso