diff --git a/changelog/19378.txt b/changelog/19378.txt new file mode 100644 index 000000000000..40a1e82fcb64 --- /dev/null +++ b/changelog/19378.txt @@ -0,0 +1,3 @@ +```release-note:bug +cli/kv: add -mount flag to kv list +``` diff --git a/command/kv_list.go b/command/kv_list.go index 3afbcd42adb2..dbed9b0c0ed8 100644 --- a/command/kv_list.go +++ b/command/kv_list.go @@ -2,6 +2,7 @@ package command import ( "fmt" + "path" "strings" "github.com/mitchellh/cli" @@ -15,6 +16,7 @@ var ( type KVListCommand struct { *BaseCommand + flagMount string } func (c *KVListCommand) Synopsis() string { @@ -40,7 +42,23 @@ Usage: vault kv list [options] PATH } func (c *KVListCommand) Flags() *FlagSets { - return c.flagSet(FlagSetHTTP | FlagSetOutputFormat) + set := c.flagSet(FlagSetHTTP | FlagSetOutputFormat) + + // Common Options + f := set.NewFlagSet("Common Options") + + f.StringVar(&StringVar{ + Name: "mount", + Target: &c.flagMount, + Default: "", // no default, because the handling of the next arg is determined by whether this flag has a value + Usage: `Specifies the path where the KV backend is mounted. If specified, + the next argument will be interpreted as the secret path. If this flag is + not specified, the next argument will be interpreted as the combined mount + path and secret path, with /data/ automatically appended between KV + v2 secrets.`, + }) + + return set } func (c *KVListCommand) AutocompleteArgs() complete.Predictor { @@ -62,8 +80,11 @@ func (c *KVListCommand) Run(args []string) int { args = f.Args() switch { case len(args) < 1: - c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) - return 1 + if c.flagMount == "" { + c.UI.Error(fmt.Sprintf("Not enough arguments (expected 1, got %d)", len(args))) + return 1 + } + args = []string{""} case len(args) > 1: c.UI.Error(fmt.Sprintf("Too many arguments (expected 1, got %d)", len(args))) return 1 @@ -75,25 +96,56 @@ func (c *KVListCommand) Run(args []string) int { return 2 } - // Sanitize path - path := sanitizePath(args[0]) - mountPath, v2, err := isKVv2(path, client) - if err != nil { - c.UI.Error(err.Error()) - return 2 - } + // If true, we're working with "-mount=secret foo" syntax. + // If false, we're using "secret/foo" syntax. + mountFlagSyntax := c.flagMount != "" + + var ( + mountPath string + partialPath string + v2 bool + ) + + // Parse the paths and grab the KV version + if mountFlagSyntax { + // In this case, this arg is the secret path (e.g. "foo"). + partialPath = sanitizePath(args[0]) + mountPath, v2, err = isKVv2(sanitizePath(c.flagMount), client) + if err != nil { + c.UI.Error(err.Error()) + return 2 + } - if v2 { - path = addPrefixToKVPath(path, mountPath, "metadata") + if v2 { + partialPath = path.Join(mountPath, partialPath) + } + } else { + // In this case, this arg is a path-like combination of mountPath/secretPath. + // (e.g. "secret/foo") + partialPath = sanitizePath(args[0]) + mountPath, v2, err = isKVv2(partialPath, client) if err != nil { c.UI.Error(err.Error()) return 2 } } - secret, err := client.Logical().List(path) + // Add /metadata to v2 paths only + var fullPath string + if v2 { + fullPath = addPrefixToKVPath(partialPath, mountPath, "metadata") + } else { + // v1 + if mountFlagSyntax { + fullPath = path.Join(mountPath, partialPath) + } else { + fullPath = partialPath + } + } + + secret, err := client.Logical().List(fullPath) if err != nil { - c.UI.Error(fmt.Sprintf("Error listing %s: %s", path, err)) + c.UI.Error(fmt.Sprintf("Error listing %s: %s", fullPath, err)) return 2 } @@ -111,12 +163,12 @@ func (c *KVListCommand) Run(args []string) int { } if secret == nil || secret.Data == nil { - c.UI.Error(fmt.Sprintf("No value found at %s", path)) + c.UI.Error(fmt.Sprintf("No value found at %s", fullPath)) return 2 } if !ok { - c.UI.Error(fmt.Sprintf("No entries found at %s", path)) + c.UI.Error(fmt.Sprintf("No entries found at %s", fullPath)) return 2 } diff --git a/command/kv_test.go b/command/kv_test.go index 830c9cc30770..c213e397f88c 100644 --- a/command/kv_test.go +++ b/command/kv_test.go @@ -590,6 +590,131 @@ func TestKVGetCommand(t *testing.T) { }) } +func testKVListCommand(tb testing.TB) (*cli.MockUi, *KVListCommand) { + tb.Helper() + ui := cli.NewMockUi() + cmd := &KVListCommand{ + BaseCommand: &BaseCommand{ + UI: ui, + }, + } + + return ui, cmd +} + +// TestKVListCommand runs tests for `vault kv list` +func TestKVListCommand(t *testing.T) { + testCases := []struct { + name string + args []string + outStrings []string + code int + }{ + { + name: "default", + args: []string{"kv/my-prefix"}, + outStrings: []string{"secret-0", "secret-1", "secret-2"}, + code: 0, + }, + { + name: "not_enough_args", + args: []string{}, + outStrings: []string{"Not enough arguments"}, + code: 1, + }, + { + name: "v2_default_with_mount", + args: []string{"-mount", "kv", "my-prefix"}, + outStrings: []string{"secret-0", "secret-1", "secret-2"}, + code: 0, + }, + { + name: "v1_default_with_mount", + args: []string{"kv/my-prefix"}, + outStrings: []string{"secret-0", "secret-1", "secret-2"}, + code: 0, + }, + { + name: "v2_not_found", + args: []string{"kv/nope/not/once/never"}, + outStrings: []string{"No value found at kv/metadata/nope/not/once/never"}, + code: 2, + }, + { + name: "v1_mount_only", + args: []string{"kv"}, + outStrings: []string{"my-prefix"}, + code: 0, + }, + { + name: "v2_mount_only", + args: []string{"-mount", "kv"}, + outStrings: []string{"my-prefix"}, + code: 0, + }, + { + // this is behavior that should be tested + // `kv` here is an explicit mount + // `my-prefix` is not + // the current kv code will ignore `my-prefix` + name: "ignore_multi_part_mounts", + args: []string{"-mount", "kv/my-prefix"}, + outStrings: []string{"my-prefix"}, + code: 0, + }, + } + + t.Run("validations", func(t *testing.T) { + t.Parallel() + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + // test setup + client, closer := testVaultServer(t) + defer closer() + + // enable kv-v2 backend + if err := client.Sys().Mount("kv/", &api.MountInput{ + Type: "kv-v2", + }); err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + + ctx := context.Background() + for i := 0; i < 3; i++ { + path := fmt.Sprintf("my-prefix/secret-%d", i) + _, err := client.KVv2("kv/").Put(ctx, path, map[string]interface{}{ + "foo": "bar", + }) + if err != nil { + t.Fatal(err) + } + } + + ui, cmd := testKVListCommand(t) + cmd.client = client + + code := cmd.Run(testCase.args) + if code != testCase.code { + t.Errorf("expected %d to be %d", code, testCase.code) + } + + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + for _, str := range testCase.outStrings { + if !strings.Contains(combined, str) { + t.Errorf("expected %q to contain %q", combined, str) + } + } + }) + } + }) +} + func testKVMetadataGetCommand(tb testing.TB) (*cli.MockUi, *KVMetadataGetCommand) { tb.Helper() diff --git a/website/content/docs/commands/kv/list.mdx b/website/content/docs/commands/kv/list.mdx index 6f565e2ac965..b6ecc83bcb4c 100644 --- a/website/content/docs/commands/kv/list.mdx +++ b/website/content/docs/commands/kv/list.mdx @@ -21,7 +21,7 @@ Use this command to list all existing key names at a specific path. List values under the key "my-app": ```shell-session -$ vault kv list secret/my-app/ +$ vault kv list -mount=secret my-app/ Keys ---- admin_creds @@ -38,6 +38,12 @@ included on all commands. ### Output Options +- `-mount` `(string: "")` - Specifies the path where the KV backend is mounted. + If specified, the next argument will be interpreted as the secret path. If + this flag is not specified, the next argument will be interpreted as the + combined mount path and secret path, with /data/ automatically inserted for + KV v2 secrets. + - `-format` `(string: "table")` - Print the output in the given format. Valid formats are "table", "json", or "yaml". This can also be specified via the `VAULT_FORMAT` environment variable.