Add PATCH support to Vault CLI (#17650)

* Add patch support to CLI

This is based off the existing write command, using the
JSONMergePatch(...) API client method rather than Write(...), allowing
us to update specific fields.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add documentation on PATCH support

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add changelog

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Alexander Scheel 2022-10-26 14:30:40 -04:00 committed by GitHub
parent 1dd9e1cb53
commit 1721cc9f75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 437 additions and 0 deletions

3
changelog/17650.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
cli: Add support for creating requests to existing non-KVv2 PATCH-capable endpoints.
```

View File

@ -488,6 +488,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) {
BaseCommand: getBaseCommand(),
}, nil
},
"patch": func() (cli.Command, error) {
return &PatchCommand{
BaseCommand: getBaseCommand(),
}, nil
},
"path-help": func() (cli.Command, error) {
return &PathHelpCommand{
BaseCommand: getBaseCommand(),

135
command/patch.go Normal file
View File

@ -0,0 +1,135 @@
package command
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
var (
_ cli.Command = (*PatchCommand)(nil)
_ cli.CommandAutocomplete = (*PatchCommand)(nil)
)
// PatchCommand is a Command that puts data into the Vault.
type PatchCommand struct {
*BaseCommand
flagForce bool
testStdin io.Reader // for tests
}
func (c *PatchCommand) Synopsis() string {
return "Patch data, configuration, and secrets"
}
func (c *PatchCommand) Help() string {
helpText := `
Usage: vault patch [options] PATH [DATA K=V...]
Patches data in Vault at the given path. The data can be credentials, secrets,
configuration, or arbitrary data. The specific behavior of this command is
determined at the thing mounted at the path.
Data is specified as "key=value" pairs. If the value begins with an "@", then
it is loaded from a file. If the value is "-", Vault will read the value from
stdin.
Unlike write, patch will only modify specified fields.
Persist data in the generic secrets engine without modifying any other fields:
$ vault patch pki/roles/example allow_localhost=false
The data can also be consumed from a file on disk by prefixing with the "@"
symbol. For example:
$ vault patch pki/roles/example @role.json
Or it can be read from stdin using the "-" symbol:
$ echo "example.com" | vault patch pki/roles/example allowed_domains=-
For a full list of examples and paths, please see the documentation that
corresponds to the secret engines in use.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *PatchCommand) Flags() *FlagSets {
set := c.flagSet(FlagSetHTTP | FlagSetOutputField | FlagSetOutputFormat)
f := set.NewFlagSet("Command Options")
f.BoolVar(&BoolVar{
Name: "force",
Aliases: []string{"f"},
Target: &c.flagForce,
Default: false,
EnvVar: "",
Completion: complete.PredictNothing,
Usage: "Allow the operation to continue with no key=value pairs. This " +
"allows writing to keys that do not need or expect data.",
})
return set
}
func (c *PatchCommand) AutocompleteArgs() complete.Predictor {
// Return an anything predictor here. Without a way to access help
// information, we don't know what paths we could patch.
return complete.PredictAnything
}
func (c *PatchCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *PatchCommand) 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.flagForce:
c.UI.Error("Must supply data or use -force")
return 1
}
// Pull our fake stdin if needed
stdin := (io.Reader)(os.Stdin)
if c.testStdin != nil {
stdin = c.testStdin
}
path := sanitizePath(args[0])
data, err := parseArgsData(stdin, args[1:])
if err != nil {
c.UI.Error(fmt.Sprintf("Failed to parse K=V data: %s", err))
return 1
}
client, err := c.Client()
if err != nil {
c.UI.Error(err.Error())
return 2
}
secret, err := client.Logical().JSONMergePatch(context.Background(), path, data)
return handleWriteSecretOutput(c.BaseCommand, path, secret, err)
}

202
command/patch_test.go Normal file
View File

@ -0,0 +1,202 @@
package command
import (
"io"
"strings"
"testing"
"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
)
func testPatchCommand(tb testing.TB) (*cli.MockUi, *PatchCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &PatchCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestPatchCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"empty_kvs",
[]string{"secret/write/foo"},
"Must supply data or use -force",
1,
},
{
"force_kvs",
[]string{"-force", "pki/roles/example"},
"Success!",
0,
},
{
"force_f_kvs",
[]string{"-f", "pki/roles/example"},
"Success!",
0,
},
{
"kvs_no_value",
[]string{"pki/roles/example", "foo"},
"Failed to parse K=V data",
1,
},
{
"single_value",
[]string{"pki/roles/example", "allow_localhost=true"},
"Success!",
0,
},
{
"multi_value",
[]string{"pki/roles/example", "allow_localhost=true", "allowed_domains=true"},
"Success!",
0,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
if err := client.Sys().Mount("pki", &api.MountInput{
Type: "pki",
}); err != nil {
t.Fatalf("pki mount error: %#v", err)
}
if _, err := client.Logical().Write("pki/roles/example", nil); err != nil {
t.Fatalf("failed to prime role: %v", err)
}
if _, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{
"key_type": "ec",
"common_name": "Root X1",
}); err != nil {
t.Fatalf("failed to prime CA: %v", err)
}
ui, cmd := testPatchCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("stdin_full", func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
if err := client.Sys().Mount("pki", &api.MountInput{
Type: "pki",
}); err != nil {
t.Fatalf("pki mount error: %#v", err)
}
if _, err := client.Logical().Write("pki/roles/example", nil); err != nil {
t.Fatalf("failed to prime role: %v", err)
}
if _, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{
"key_type": "ec",
"common_name": "Root X1",
}); err != nil {
t.Fatalf("failed to prime CA: %v", err)
}
stdinR, stdinW := io.Pipe()
go func() {
stdinW.Write([]byte(`{"allow_localhost":"false","allow_wildcard_certificates":"false"}`))
stdinW.Close()
}()
ui, cmd := testPatchCommand(t)
cmd.client = client
cmd.testStdin = stdinR
code := cmd.Run([]string{
"pki/roles/example", "-",
})
if code != 0 {
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
t.Fatalf("expected retcode=%d to be 0\nOutput:\n%v", code, combined)
}
secret, err := client.Logical().Read("pki/roles/example")
if err != nil {
t.Fatal(err)
}
if secret == nil || secret.Data == nil {
t.Fatal("expected secret to have data")
}
if exp, act := false, secret.Data["allow_localhost"].(bool); exp != act {
t.Errorf("expected allowed_localhost=%v to be %v", act, exp)
}
if exp, act := false, secret.Data["allow_wildcard_certificates"].(bool); exp != act {
t.Errorf("expected allow_wildcard_certificates=%v to be %v", act, exp)
}
})
t.Run("communication_failure", func(t *testing.T) {
t.Parallel()
client, closer := testVaultServerBad(t)
defer closer()
ui, cmd := testPatchCommand(t)
cmd.client = client
code := cmd.Run([]string{
"foo/bar", "a=b",
})
if exp := 2; code != exp {
t.Errorf("expected %d to be %d", code, exp)
}
expected := "Error writing data to foo/bar: "
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, expected) {
t.Errorf("expected %q to contain %q", combined, expected)
}
})
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testPatchCommand(t)
assertNoTabs(t, cmd)
})
}

View File

@ -6,6 +6,7 @@ import (
"os"
"strings"
"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
@ -138,6 +139,10 @@ func (c *WriteCommand) Run(args []string) int {
}
secret, err := client.Logical().Write(path, data)
return handleWriteSecretOutput(c.BaseCommand, path, secret, err)
}
func handleWriteSecretOutput(c *BaseCommand, path string, secret *api.Secret, err error) int {
if err != nil {
c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err))
if secret != nil {

View File

@ -0,0 +1,87 @@
---
layout: docs
page_title: patch - Command
description: |-
The "patch" command updates data in Vault at the given path. The data can be
credentials, secrets, configuration, or arbitrary data. The specific behavior
of this command is determined at the thing mounted at the path.
---
# patch
The `patch` command updates data in Vault at the given path (wrapper command for
HTTP PATCH using the [JSON Patch format](https://datatracker.ietf.org/doc/html/rfc6902)).
The data can be credentials, secrets, configuration, or arbitrary data. The specific
behavior of the `patch` command is determined at the thing mounted at the path.
Data is specified as "**key=value**" pairs on the command line. If the value begins
with an "**@**", then it is loaded from a file. If the value for a key is "**-**", Vault
will read the value from stdin rather than the command line.
Some API fields require more advanced structures such as maps. These cannot
directly be represented on the command line. However, direct control of the
request parameters can be achieved by using `-` as the only data argument.
This causes `vault patch` to read a JSON blob containing all request parameters
from stdin. This argument will be ignored if used in conjunction with any
"key=value" pairs.
For a full list of examples and paths, please see the documentation that
corresponds to the secrets engines in use.
Unlike [the `write` command](/docs/commands/write), the `patch` command only
modifies data specified on the command line.
## Examples
Updates a PKI role to modify a single parameter:
```shell-session
$ vault patch pki/roles/example allow_localhost=false
```
### API versus CLI
Updates a PKI role to modify the `allow_localhost` parameter:
```shell-session
$ vault patch pki/roles/example allow_localhost=false
```
Equivalent cURL command for this operation:
```shell-session
$ tee request_payload.json -<<EOF
{
"organization": "hashicorp"
}
EOF
$ curl --header "X-Vault-Token: $VAULT_TOKEN" \
--request PATCH \
--header 'Content-Type: application/merge-patch+json'
--data @request_payload.json \
$VAULT_ADDR/v1/pki/roles/example
```
The `vault patch` command simplifies the API call.
## Usage
The following flags are available in addition to the [standard set of
flags](/docs/commands) included on all commands.
### Output Options
- `-field` `(string: "")` - Print only the field with the given name. Specifying
this option will take precedence over other formatting directives. The result
will not have a trailing newline making it ideal for piping to other processes.
- `-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.
### Command Options
- `-force` `(bool: false)` - Allow the operation to continue with no key=value
pairs. This allows writing to keys that do not need or expect data. This is
aliased as `-f`.