Add HTTP PATCH support to KV (#12687)

* handle HTTP PATCH requests as logical.PatchOperation

* update go.mod, go.sum

* a nil response for logical.PatchOperation should result in 404

* respond with 415 for incorrect MIME type in PATCH Content-Type header

* add abstraction to handle PatchOperation requests

* add ACLs for patch

* Adding JSON Merge support to the API client

* add HTTP PATCH tests to check high level response logic

* add permission-based 'kv patch' tests in prep to add HTTP PATCH

* adding more 'kv patch' CLI command tests

* fix TestHandler_Patch_NotFound

* Fix TestKvPatchCommand_StdinValue

* add audit log test for HTTP PATCH

* patch CLI changes

* add patch CLI tests

* change JSONMergePatch func to accept a ctx

* fix TestKVPatchCommand_RWMethodNotExists and TestKVPatchCommand_RWMethodSucceeds to specify -method flag

* go fmt

* add a test to verify patching works by default with the root token

* add changelog entry

* get vault-plugin-secrets-kv@add-patch-support

* PR feedback

* reorder some imports; go fmt

* add doc comment for HandlePatchOperation

* add json-patch@v5.5.0 to go.mod

* remove unnecessary cancelFunc for WriteBytes

* remove default for -method

* use stable version of json-patch; go mod tidy

* more PR feedback

* temp go get vault-plugin-secrets-kv@master until official release

Co-authored-by: Josh Black <raskchanky@users.noreply.github.com>
This commit is contained in:
Chris Capurso 2021-10-13 15:24:31 -04:00 committed by GitHub
parent 0bb3e9c07f
commit bbb4ab4a41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1151 additions and 29 deletions

View File

@ -9,8 +9,7 @@ import (
// testHTTPServer creates a test HTTP server that handles requests until
// the listener returned is closed.
func testHTTPServer(
t *testing.T, handler http.Handler) (*Config, net.Listener) {
func testHTTPServer(t *testing.T, handler http.Handler) (*Config, net.Listener) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("err: %s", err)

View File

@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
@ -130,24 +131,37 @@ func (c *Logical) List(path string) (*Secret, error) {
}
func (c *Logical) Write(path string, data map[string]interface{}) (*Secret, error) {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
r := c.c.NewRequest("PUT", "/v1/"+path)
if err := r.SetJSONBody(data); err != nil {
return nil, err
}
return c.write(path, r)
return c.write(ctx, path, r)
}
func (c *Logical) JSONMergePatch(ctx context.Context, path string, data map[string]interface{}) (*Secret, error) {
r := c.c.NewRequest("PATCH", "/v1/"+path)
r.Headers = http.Header{
"Content-Type": []string{"application/merge-patch+json"},
}
if err := r.SetJSONBody(data); err != nil {
return nil, err
}
return c.write(ctx, path, r)
}
func (c *Logical) WriteBytes(path string, data []byte) (*Secret, error) {
r := c.c.NewRequest("PUT", "/v1/"+path)
r.BodyBytes = data
return c.write(path, r)
return c.write(context.Background(), path, r)
}
func (c *Logical) write(path string, request *Request) (*Secret, error) {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
func (c *Logical) write(ctx context.Context, path string, request *Request) (*Secret, error) {
resp, err := c.c.RawRequestWithContext(ctx, request)
if resp != nil {
defer resp.Body.Close()

5
changelog/12687.txt Normal file
View File

@ -0,0 +1,5 @@
```release-note:feature
**KV patch**: Add partial update support the for the `/<mount>/data/:path` kv-v2
endpoint through HTTP `PATCH`. A new `patch` ACL capability has been added and
is required to make such requests.
```

View File

@ -1,11 +1,13 @@
package command
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
@ -18,7 +20,9 @@ var (
type KVPatchCommand struct {
*BaseCommand
testStdin io.Reader // for tests
flagCAS int
flagMethod string
testStdin io.Reader // for tests
}
func (c *KVPatchCommand) Synopsis() string {
@ -45,6 +49,25 @@ Usage: vault kv patch [options] KEY [DATA]
$ echo "abcd1234" | vault kv patch secret/foo bar=-
To perform a Check-And-Set operation, specify the -cas flag with the
appropriate version number corresponding to the key you want to perform
the CAS operation on:
$ vault kv patch -cas=1 secret/foo bar=baz
By default, this operation will attempt an HTTP PATCH operation. If your
policy does not allow that, it will fall back to a read/local update/write approach.
If you wish to specify which method this command should use, you may do so
with the -method flag. When -method=patch is specified, only an HTTP PATCH
operation will be tried. If it fails, the entire command will fail.
$ vault kv patch -method=patch secret/foo bar=baz
When -method=rw is specified, only a read/local update/write approach will be tried.
This was the default behavior previous to Vault 1.9.
$ vault kv patch -method=rw secret/foo bar=baz
Additional flags and more advanced use cases are detailed below.
` + c.Flags().Help()
@ -54,6 +77,27 @@ Usage: vault kv patch [options] KEY [DATA]
func (c *KVPatchCommand) Flags() *FlagSets {
set := c.flagSet(FlagSetHTTP | FlagSetOutputField | FlagSetOutputFormat)
// Patch specific options
f := set.NewFlagSet("Common Options")
f.IntVar(&IntVar{
Name: "cas",
Target: &c.flagCAS,
Default: 0,
Usage: `Specifies to use a Check-And-Set operation. If set to 0 or not
set, the patch will be allowed. If the index is non-zero the patch will
only be allowed if the keys current version matches the version
specified in the cas parameter.`,
})
f.StringVar(&StringVar{
Name: "method",
Target: &c.flagMethod,
Usage: `Specifies which method of patching to use. 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.`,
})
return set
}
@ -121,6 +165,30 @@ func (c *KVPatchCommand) Run(args []string) int {
return 2
}
// Check the method and behave accordingly
var secret *api.Secret
var code int
switch c.flagMethod {
case "rw":
secret, code = c.readThenWrite(client, path, newData)
case "patch":
secret, code = c.mergePatch(client, path, newData, false)
case "":
secret, code = c.mergePatch(client, path, newData, true)
default:
c.UI.Error(fmt.Sprintf("Unsupported method provided to -method flag: %s", c.flagMethod))
return 2
}
if code != 0 {
return code
}
return OutputSecret(c.UI, secret)
}
func (c *KVPatchCommand) readThenWrite(client *api.Client, path string, newData map[string]interface{}) (*api.Secret, int) {
// First, do a read.
// Note that we don't want to see curl output for the read request.
curOutputCurl := client.OutputCurlString()
@ -129,45 +197,45 @@ func (c *KVPatchCommand) Run(args []string) int {
client.SetOutputCurlString(curOutputCurl)
if err != nil {
c.UI.Error(fmt.Sprintf("Error doing pre-read at %s: %s", path, err))
return 2
return nil, 2
}
// Make sure a value already exists
if secret == nil || secret.Data == nil {
c.UI.Error(fmt.Sprintf("No value found at %s", path))
return 2
return nil, 2
}
// Verify metadata found
rawMeta, ok := secret.Data["metadata"]
if !ok || rawMeta == nil {
c.UI.Error(fmt.Sprintf("No metadata found at %s; patch only works on existing data", path))
return 2
return nil, 2
}
meta, ok := rawMeta.(map[string]interface{})
if !ok {
c.UI.Error(fmt.Sprintf("Metadata found at %s is not the expected type (JSON object)", path))
return 2
return nil, 2
}
if meta == nil {
c.UI.Error(fmt.Sprintf("No metadata found at %s; patch only works on existing data", path))
return 2
return nil, 2
}
// Verify old data found
rawData, ok := secret.Data["data"]
if !ok || rawData == nil {
c.UI.Error(fmt.Sprintf("No data found at %s; patch only works on existing data", path))
return 2
return nil, 2
}
data, ok := rawData.(map[string]interface{})
if !ok {
c.UI.Error(fmt.Sprintf("Data found at %s is not the expected type (JSON object)", path))
return 2
return nil, 2
}
if data == nil {
c.UI.Error(fmt.Sprintf("No data found at %s; patch only works on existing data", path))
return 2
return nil, 2
}
// Copy new data over
@ -183,19 +251,58 @@ func (c *KVPatchCommand) Run(args []string) int {
})
if err != nil {
c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err))
return 2
return nil, 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 nil, 0
}
if c.flagField != "" {
return PrintRawField(c.UI, secret, c.flagField)
return nil, PrintRawField(c.UI, secret, c.flagField)
}
return OutputSecret(c.UI, secret)
return secret, 0
}
func (c *KVPatchCommand) mergePatch(client *api.Client, path string, newData map[string]interface{}, rwFallback bool) (*api.Secret, int) {
data := map[string]interface{}{
"data": newData,
"options": map[string]interface{}{},
}
if c.flagCAS > 0 {
data["options"].(map[string]interface{})["cas"] = c.flagCAS
}
secret, err := client.Logical().JSONMergePatch(context.Background(), path, data)
if err != nil {
// If it's a 403, that probably means they don't have the patch capability in their policy. Fall back to
// the old way of doing it if the user didn't specify a -method. If they did, and it was "patch", then just error.
if re, ok := err.(*api.ResponseError); ok && re.StatusCode == 403 && rwFallback {
c.UI.Warn(fmt.Sprintf("Data was written to %s but we recommend that you add the \"patch\" capability to your ACL policy in order to use HTTP PATCH in the future.", path))
return c.readThenWrite(client, path, newData)
}
c.UI.Error(fmt.Sprintf("Error writing data to %s: %s", path, err))
return nil, 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 nil, 0
}
if c.flagField != "" {
return nil, PrintRawField(c.UI, secret, c.flagField)
}
return secret, 0
}

View File

@ -1,6 +1,7 @@
package command
import (
"fmt"
"io"
"strings"
"testing"
@ -552,3 +553,606 @@ func TestKVMetadataGetCommand(t *testing.T) {
assertNoTabs(t, cmd)
})
}
func testKVPatchCommand(tb testing.TB) (*cli.MockUi, *KVPatchCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &KVPatchCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestKVPatchCommand_ArgValidation(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{"kv/patch/foo"},
"Must supply data",
1,
},
{
"kvs_no_value",
[]string{"kv/patch/foo", "foo"},
"Failed to parse K=V data",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
if err := client.Sys().Mount("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Fatalf("expected code to be %d but was %d for cmd %#v with args %#v\n", tc.code, code, cmd, tc.args)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Fatalf("expected output to be %q but was %q for cmd %#v with args %#v\n", tc.out, combined, cmd, tc.args)
}
})
}
}
func TestKvPatchCommand_StdinFull(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 attempt failed - err: %#v\n", err)
}
if _, err := client.Logical().Write("kv/data/patch/foo", map[string]interface{}{
"data": map[string]interface{}{
"foo": "a",
},
}); err != nil {
t.Fatalf("write failed, err: %#v\n", err)
}
stdinR, stdinW := io.Pipe()
go func() {
stdinW.Write([]byte(`{"foo":"bar"}`))
stdinW.Close()
}()
_, cmd := testKVPatchCommand(t)
cmd.client = client
cmd.testStdin = stdinR
args := []string{"kv/patch/foo", "-"}
code := cmd.Run(args)
if code != 0 {
t.Fatalf("expected code to be 0 but was %d for cmd %#v with args %#v\n", code, cmd, args)
}
secret, err := client.Logical().Read("kv/data/patch/foo")
if err != nil {
t.Fatalf("read failed, err: %#v\n", err)
}
if secret == nil || secret.Data == nil {
t.Fatal("expected secret to have data")
}
secretDataRaw, ok := secret.Data["data"]
if !ok {
t.Fatalf("expected secret to have nested data key, data: %#v", secret.Data)
}
secretData := secretDataRaw.(map[string]interface{})
foo, ok := secretData["foo"].(string)
if !ok {
t.Fatal("expected foo to be a string but it wasn't")
}
if exp, act := "bar", foo; exp != act {
t.Fatalf("expected %q to be %q, data: %#v\n", act, exp, secret.Data)
}
}
func TestKvPatchCommand_StdinValue(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 attempt failed - err: %#v\n", err)
}
if _, err := client.Logical().Write("kv/data/patch/foo", map[string]interface{}{
"data": map[string]interface{}{
"foo": "a",
},
}); err != nil {
t.Fatalf("write failed, err: %#v\n", err)
}
stdinR, stdinW := io.Pipe()
go func() {
stdinW.Write([]byte("bar"))
stdinW.Close()
}()
_, cmd := testKVPatchCommand(t)
cmd.client = client
cmd.testStdin = stdinR
args := []string{"kv/patch/foo", "foo=-"}
code := cmd.Run(args)
if code != 0 {
t.Fatalf("expected code to be 0 but was %d for cmd %#v with args %#v\n", code, cmd, args)
}
secret, err := client.Logical().Read("kv/data/patch/foo")
if err != nil {
t.Fatalf("read failed, err: %#v\n", err)
}
if secret == nil || secret.Data == nil {
t.Fatal("expected secret to have data")
}
secretDataRaw, ok := secret.Data["data"]
if !ok {
t.Fatalf("expected secret to have nested data key, data: %#v\n", secret.Data)
}
secretData := secretDataRaw.(map[string]interface{})
if exp, act := "bar", secretData["foo"].(string); exp != act {
t.Fatalf("expected %q to be %q, data: %#v\n", act, exp, secret.Data)
}
}
func TestKVPatchCommand_RWMethodNotExists(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 attempt failed - err: %#v\n", err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = client
args := []string{"-method", "rw", "kv/patch/foo", "foo=a"}
code := cmd.Run(args)
if code != 2 {
t.Fatalf("expected code to be 2 but was %d for cmd %#v with args %#v\n", code, cmd, args)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
expectedOutputSubstr := "No value found"
if !strings.Contains(combined, expectedOutputSubstr) {
t.Fatalf("expected output %q to contain %q for cmd %#v with args %#v\n", combined, expectedOutputSubstr, cmd, args)
}
}
func TestKVPatchCommand_RWMethodSucceeds(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 attempt failed - err: %#v\n", err)
}
if _, err := client.Logical().Write("kv/data/patch/foo", map[string]interface{}{
"data": map[string]interface{}{
"foo": "a",
"bar": "b",
},
}); err != nil {
t.Fatalf("write failed, err: %#v\n", err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = client
// Test single value
args := []string{"-method", "rw", "kv/patch/foo", "foo=aa"}
code := cmd.Run(args)
if code != 0 {
t.Fatalf("expected code to be 0 but was %d for cmd %#v with args %#v\n", code, cmd, args)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
expectedOutputSubstr := "created_time"
if !strings.Contains(combined, expectedOutputSubstr) {
t.Fatalf("expected output %q to contain %q for cmd %#v with args %#v\n", combined, expectedOutputSubstr, cmd, args)
}
// Test multi value
ui, cmd = testKVPatchCommand(t)
cmd.client = client
args = []string{"-method", "rw", "kv/patch/foo", "foo=aaa", "bar=bbb"}
code = cmd.Run(args)
if code != 0 {
t.Fatalf("expected code to be 0 but was %d for cmd %#v with args %#v\n", code, cmd, args)
}
combined = ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, expectedOutputSubstr) {
t.Fatalf("expected output %q to contain %q for cmd %#v with args %#v\n", combined, expectedOutputSubstr, cmd, args)
}
}
func TestKVPatchCommand_CAS(t *testing.T) {
cases := []struct {
name string
args []string
expected string
out string
code int
}{
{
"right version",
[]string{"-cas", "1", "kv/foo", "bar=quux"},
"quux",
"",
0,
},
{
"wrong version",
[]string{"-cas", "2", "kv/foo", "bar=wibble"},
"baz",
"check-and-set parameter did not match the current version",
2,
},
}
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("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
// create a policy with patch capability
policy := `path "kv/*" { capabilities = ["create", "update", "read", "patch"] }`
secretAuth, err := createTokenForPolicy(t, client, policy)
if err != nil {
t.Fatalf("policy/token creation failed for policy %s, err: %#v\n", policy, err)
}
kvClient, err := client.Clone()
if err != nil {
t.Fatal(err)
}
kvClient.SetToken(secretAuth.ClientToken)
_, err = kvClient.Logical().Write("kv/data/foo", map[string]interface{}{"data": map[string]interface{}{"bar": "baz"}})
if err != nil {
t.Fatal(err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = kvClient
code := cmd.Run(tc.args)
if code != tc.code {
t.Fatalf("expected code to be %d but was %d", tc.code, code)
}
if tc.out != "" {
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
}
secret, err := kvClient.Logical().Read("kv/data/foo")
bar := secret.Data["data"].(map[string]interface{})["bar"]
if bar != tc.expected {
t.Fatalf("expected bar to be %q but it was %q", tc.expected, bar)
}
})
}
}
func TestKVPatchCommand_Methods(t *testing.T) {
cases := []struct {
name string
args []string
expected string
code int
}{
{
"rw",
[]string{"-method", "rw", "kv/foo", "bar=quux"},
"quux",
0,
},
{
"patch",
[]string{"-method", "patch", "kv/foo", "bar=wibble"},
"wibble",
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("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
// create a policy with patch capability
policy := `path "kv/*" { capabilities = ["create", "update", "read", "patch"] }`
secretAuth, err := createTokenForPolicy(t, client, policy)
if err != nil {
t.Fatalf("policy/token creation failed for policy %s, err: %#v\n", policy, err)
}
kvClient, err := client.Clone()
if err != nil {
t.Fatal(err)
}
kvClient.SetToken(secretAuth.ClientToken)
_, err = kvClient.Logical().Write("kv/data/foo", map[string]interface{}{"data": map[string]interface{}{"bar": "baz"}})
if err != nil {
t.Fatal(err)
}
_, cmd := testKVPatchCommand(t)
cmd.client = kvClient
code := cmd.Run(tc.args)
if code != tc.code {
t.Fatalf("expected code to be %d but was %d", tc.code, code)
}
secret, err := kvClient.Logical().Read("kv/data/foo")
bar := secret.Data["data"].(map[string]interface{})["bar"]
if bar != tc.expected {
t.Fatalf("expected bar to be %q but it was %q", tc.expected, bar)
}
})
}
}
func TestKVPatchCommand_403Fallback(t *testing.T) {
cases := []struct {
name string
args []string
expected string
code int
}{
// if no -method is specified, and patch fails, it should fall back to rw and succeed
{
"unspecified",
[]string{"kv/foo", "bar=quux"},
`add the "patch" capability to your ACL policy`,
0,
},
// if -method=patch is specified, and patch fails, it should not fall back, and just error
{
"specifying patch",
[]string{"-method", "patch", "kv/foo", "bar=quux"},
"permission denied",
2,
},
}
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("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
// create a policy without patch capability
policy := `path "kv/*" { capabilities = ["create", "update", "read"] }`
secretAuth, err := createTokenForPolicy(t, client, policy)
if err != nil {
t.Fatalf("policy/token creation failed for policy %s, err: %#v\n", policy, err)
}
kvClient, err := client.Clone()
if err != nil {
t.Fatal(err)
}
kvClient.SetToken(secretAuth.ClientToken)
// Write a value then attempt to patch it
_, err = kvClient.Logical().Write("kv/data/foo", map[string]interface{}{"data": map[string]interface{}{"bar": "baz"}})
if err != nil {
t.Fatal(err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = kvClient
code := cmd.Run(tc.args)
if code != tc.code {
t.Fatalf("expected code to be %d but was %d", tc.code, code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.expected) {
t.Errorf("expected %q to contain %q", combined, tc.expected)
}
})
}
}
func createTokenForPolicy(t *testing.T, client *api.Client, policy string) (*api.SecretAuth, error) {
t.Helper()
if err := client.Sys().PutPolicy("policy", policy); err != nil {
return nil, err
}
secret, err := client.Auth().Token().Create(&api.TokenCreateRequest{
Policies: []string{"policy"},
TTL: "30m",
})
if err != nil {
return nil, err
}
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" {
return nil, fmt.Errorf("missing auth data: %#v", secret)
}
return secret.Auth, err
}
func TestKVPatchCommand_RWMethodPolicyVariations(t *testing.T) {
cases := []struct {
name string
args []string
policy string
expected string
code int
}{
// if the policy doesn't have read capability and -method=rw is specified, it fails
{
"no read",
[]string{"-method", "rw", "kv/foo", "bar=quux"},
`path "kv/*" { capabilities = ["create", "update"] }`,
"permission denied",
2,
},
// if the policy doesn't have update capability and -method=rw is specified, it fails
{
"no update",
[]string{"-method", "rw", "kv/foo", "bar=quux"},
`path "kv/*" { capabilities = ["create", "read"] }`,
"permission denied",
2,
},
// if the policy has both read and update and -method=rw is specified, it succeeds
{
"read and update",
[]string{"-method", "rw", "kv/foo", "bar=quux"},
`path "kv/*" { capabilities = ["create", "read", "update"] }`,
"",
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("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
secretAuth, err := createTokenForPolicy(t, client, tc.policy)
if err != nil {
t.Fatalf("policy/token creation failed for policy %s, err: %#v\n", tc.policy, err)
}
client.SetToken(secretAuth.ClientToken)
if _, err := client.Logical().Write("kv/data/foo", map[string]interface{}{
"data": map[string]interface{}{
"foo": "bar",
"bar": "baz",
},
}); err != nil {
t.Fatalf("write failed, err: %#v\n", err)
}
ui, cmd := testKVPatchCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Fatalf("expected code to be %d but was %d for cmd %#v with args %#v\n", tc.code, code, cmd, tc.args)
}
if code != 0 {
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.expected) {
t.Fatalf("expected output %q to contain %q for cmd %#v with args %#v\n", combined, tc.expected, cmd, tc.args)
}
}
})
}
}

5
go.mod
View File

@ -113,13 +113,13 @@ require (
github.com/hashicorp/vault-plugin-secrets-azure v0.6.3-0.20210924190759-58a034528e35
github.com/hashicorp/vault-plugin-secrets-gcp v0.10.2
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.9.0
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20210811133805-e060c2307b24
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211013154503-eec8a1c892fb
github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.4.0
github.com/hashicorp/vault-plugin-secrets-openldap v0.4.1-0.20210921171411-e86105e4986d
github.com/hashicorp/vault-plugin-secrets-terraform v0.1.1-0.20210715043003-e02ca8f6408e
github.com/hashicorp/vault-testing-stepwise v0.1.1
github.com/hashicorp/vault/api v1.1.1
github.com/hashicorp/vault/sdk v0.2.1
github.com/hashicorp/vault/sdk v0.2.2-0.20211004171540-a8c7e135dd6a
github.com/influxdata/influxdb v0.0.0-20190411212539-d24b7ba8c4c4
github.com/jcmturner/gokrb5/v8 v8.0.0
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f
@ -199,6 +199,7 @@ require (
google.golang.org/grpc v1.41.0
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
google.golang.org/protobuf v1.27.1
gopkg.in/evanphx/json-patch.v4 v4.11.0 // indirect
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
gopkg.in/ory-am/dockertest.v3 v3.3.4
gopkg.in/square/go-jose.v2 v2.5.1

11
go.sum
View File

@ -350,6 +350,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v0.0.0-20190203023257-5858425f7550/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I=
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
@ -748,8 +750,10 @@ github.com/hashicorp/vault-plugin-secrets-gcp v0.10.2 h1:+DtlYJTsrFRInQpAo09KkYN
github.com/hashicorp/vault-plugin-secrets-gcp v0.10.2/go.mod h1:psRQ/dm5XatoUKLDUeWrpP9icMJNtu/jmscUr37YGK4=
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.9.0 h1:7a0iWuFA/YNinQ1xXogyZHStolxMVtLV+sy1LpEHaZs=
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.9.0/go.mod h1:hhwps56f2ATeC4Smgghrc5JH9dXR31b4ehSf1HblP5Q=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20210811133805-e060c2307b24 h1:uqPKQzkmO5vybOqk2aOdviXXi5088bcl2MrE0D1MhjM=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20210811133805-e060c2307b24/go.mod h1:4j2pZrSynPuUAAYrZQVgSSHD0A9xj7GK9Ji1sWtnO4s=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211007143158-2d15a6fec12b h1:1GJj7AjgI0Td95haW8EK5on3Usuox78wmzLj+J9vcm4=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211007143158-2d15a6fec12b/go.mod h1:iEKCVaKBQzzYxzb778O6VGLdd+8gA40ZI14bo+8tQjs=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211013154503-eec8a1c892fb h1:nZ2a4a1G0ALLAzKOWQbLzD5oljKo+pjMarbq3BwU0pM=
github.com/hashicorp/vault-plugin-secrets-kv v0.5.7-0.20211013154503-eec8a1c892fb/go.mod h1:D/FQJ7zU5pD6FNJVUwaVtxr75ZsxIIqaG/Nh6RHt/xo=
github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.4.0 h1:6ve+7hZmGn7OpML81iZUxYj2AaJptwys323S5XsvVas=
github.com/hashicorp/vault-plugin-secrets-mongodbatlas v0.4.0/go.mod h1:4mdgPqlkO+vfFX1cFAWcxkeqz6JAtZgKxL/67q/58Oo=
github.com/hashicorp/vault-plugin-secrets-openldap v0.4.1-0.20210921171411-e86105e4986d h1:o5Z9B1FztTYSnTQNzFr+iZJHPM8ZD23uV5A8gMxm2g0=
@ -803,6 +807,7 @@ github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00=
github.com/jefferai/jsonx v1.0.0 h1:Xoz0ZbmkpBvED5W9W1B5B/zc3Oiq7oXqiW7iRV3B6EI=
github.com/jefferai/jsonx v1.0.0/go.mod h1:OGmqmi2tTeI/PS+qQfBDToLHHJIy/RMp24fPo8vFvoQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@ -1672,6 +1677,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v4 v4.11.0 h1:+kbwxm5IBGIiNYVhss+hM3Nv4ck+HnPSNscCNbD1cT0=
gopkg.in/evanphx/json-patch.v4 v4.11.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=

View File

@ -16,6 +16,10 @@ import (
"github.com/go-test/deep"
"github.com/hashicorp/go-cleanhttp"
kv "github.com/hashicorp/vault-plugin-secrets-kv"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/audit"
auditFile "github.com/hashicorp/vault/builtin/audit/file"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/logical"
@ -818,3 +822,198 @@ func TestHandler_Parse_Form(t *testing.T) {
t.Fatal(diff)
}
}
func TestHandler_Patch_BadContentTypeHeader(t *testing.T) {
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
"kv": kv.VersionedKVFactory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: Handler,
})
cluster.Start()
defer cluster.Cleanup()
cores := cluster.Cores
core := cores[0].Core
c := cluster.Cores[0].Client
vault.TestWaitActive(t, core)
// Mount a KVv2 backend
err := c.Sys().Mount("kv", &api.MountInput{
Type: "kv-v2",
})
if err != nil {
t.Fatal(err)
}
kvData := map[string]interface{}{
"data": map[string]interface{}{
"bar": "a",
},
}
resp, err := c.Logical().Write("kv/data/foo", kvData)
if err != nil {
t.Fatalf("write failed - err :%#v, resp: %#v\n", err, resp)
}
resp, err = c.Logical().Read("kv/data/foo")
if err != nil {
t.Fatalf("read failed - err :%#v, resp: %#v\n", err, resp)
}
req := c.NewRequest("PATCH", "/v1/kv/data/foo")
req.Headers = http.Header{
"Content-Type": []string{"application/json"},
}
if err := req.SetJSONBody(kvData); err != nil {
t.Fatal(err)
}
apiResp, err := c.RawRequestWithContext(context.Background(), req)
if err == nil || apiResp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("expected PATCH request to fail with %d status code - err :%#v, resp: %#v\n", http.StatusUnsupportedMediaType, err, apiResp)
}
}
func TestHandler_Patch_NotFound(t *testing.T) {
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
"kv": kv.VersionedKVFactory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: Handler,
})
cluster.Start()
defer cluster.Cleanup()
cores := cluster.Cores
core := cores[0].Core
c := cluster.Cores[0].Client
vault.TestWaitActive(t, core)
// Mount a KVv2 backend
err := c.Sys().Mount("kv", &api.MountInput{
Type: "kv-v2",
})
if err != nil {
t.Fatal(err)
}
kvData := map[string]interface{}{
"data": map[string]interface{}{
"bar": "a",
},
}
resp, err := c.Logical().JSONMergePatch(context.Background(), "kv/data/foo", kvData)
if err == nil {
t.Fatalf("expected PATCH request to fail, resp: %#v\n", resp)
}
responseError := err.(*api.ResponseError)
if responseError.StatusCode != http.StatusNotFound {
t.Fatalf("expected PATCH request to fail with %d status code - err: %#v, resp: %#v\n", http.StatusNotFound, responseError, resp)
}
}
func TestHandler_Patch_Audit(t *testing.T) {
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
"kv": kv.VersionedKVFactory,
},
AuditBackends: map[string]audit.Factory{
"file": auditFile.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: Handler,
})
cluster.Start()
defer cluster.Cleanup()
cores := cluster.Cores
core := cores[0].Core
c := cluster.Cores[0].Client
vault.TestWaitActive(t, core)
if err := c.Sys().Mount("kv/", &api.MountInput{
Type: "kv-v2",
}); err != nil {
t.Fatalf("kv-v2 mount attempt failed - err: %#v\n", err)
}
auditLogFile, err := ioutil.TempFile("", "httppatch")
if err != nil {
t.Fatal(err)
}
err = c.Sys().EnableAuditWithOptions("file", &api.EnableAuditOptions{
Type: "file",
Options: map[string]string{
"file_path": auditLogFile.Name(),
},
})
writeData := map[string]interface{}{
"data": map[string]interface{}{
"bar": "a",
},
}
resp, err := c.Logical().Write("kv/data/foo", writeData)
if err != nil {
t.Fatalf("write request failed, err: %#v, resp: %#v\n", err, resp)
}
patchData := map[string]interface{}{
"data": map[string]interface{}{
"baz": "b",
},
}
resp, err = c.Logical().JSONMergePatch(context.Background(), "kv/data/foo", patchData)
if err != nil {
t.Fatalf("patch request failed, err: %#v, resp: %#v\n", err, resp)
}
patchRequestLogCount := 0
patchResponseLogCount := 0
decoder := json.NewDecoder(auditLogFile)
var auditRecord map[string]interface{}
for decoder.Decode(&auditRecord) == nil {
auditRequest := map[string]interface{}{}
if req, ok := auditRecord["request"]; ok {
auditRequest = req.(map[string]interface{})
}
if auditRequest["operation"] == "patch" && auditRecord["type"] == "request" {
patchRequestLogCount += 1
} else if auditRequest["operation"] == "patch" && auditRecord["type"] == "response" {
patchResponseLogCount += 1
}
}
if patchRequestLogCount != 1 {
t.Fatalf("expected 1 patch request audit log record, saw %d\n", patchRequestLogCount)
}
if patchResponseLogCount != 1 {
t.Fatalf("expected 1 patch response audit log record, saw %d\n", patchResponseLogCount)
}
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"mime"
"net"
"net/http"
"strconv"
@ -38,6 +39,8 @@ func (b *bufferedReader) Close() error {
return b.rOrig.Close()
}
const MergePatchContentTypeHeader = "application/merge-patch+json"
func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.Request) (*logical.Request, io.ReadCloser, int, error) {
ns, err := namespace.FromContext(r.Context())
if err != nil {
@ -139,6 +142,34 @@ func buildLogicalRequestNoAuth(perfStandby bool, w http.ResponseWriter, r *http.
}
}
case "PATCH":
op = logical.PatchOperation
contentTypeHeader := r.Header.Get("Content-Type")
contentType, _, err := mime.ParseMediaType(contentTypeHeader)
if err != nil {
status := http.StatusBadRequest
logical.AdjustErrorStatusCode(&status, err)
return nil, nil, status, err
}
if contentType != MergePatchContentTypeHeader {
return nil, nil, http.StatusUnsupportedMediaType, fmt.Errorf("PATCH requires Content-Type of %s, provided %s", MergePatchContentTypeHeader, contentType)
}
origBody, err = parseJSONRequest(perfStandby, r, w, &data)
if err == io.EOF {
data = nil
err = nil
}
if err != nil {
status := http.StatusBadRequest
logical.AdjustErrorStatusCode(&status, err)
return nil, nil, status, fmt.Errorf("error parsing JSON")
}
case "LIST":
op = logical.ListOperation
if !strings.HasSuffix(path, "/") {

View File

@ -3,6 +3,7 @@ package framework
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"io/ioutil"
@ -13,6 +14,7 @@ import (
"sync"
"time"
jsonpatch "github.com/evanphx/json-patch"
"github.com/hashicorp/errwrap"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-kms-wrapping/entropy"
@ -118,6 +120,10 @@ type InvalidateFunc func(context.Context, string)
// Initialize() just after a plugin has been mounted.
type InitializeFunc func(context.Context, *logical.InitializationRequest) error
// PatchPreprocessorFunc is used by HandlePatchOperation in order to shape
// the input as defined by request handler prior to JSON marshaling
type PatchPreprocessorFunc func(map[string]interface{}) (map[string]interface{}, error)
// Initialize is the logical.Backend implementation.
func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationRequest) error {
if b.InitializeFunc != nil {
@ -272,6 +278,57 @@ func (b *Backend) HandleRequest(ctx context.Context, req *logical.Request) (*log
return callback(ctx, req, &fd)
}
// HandlePatchOperation acts as an abstraction for performing JSON merge patch
// operations (see https://datatracker.ietf.org/doc/html/rfc7396) for HTTP
// PATCH requests. It is responsible for properly processing and marshalling
// the input and existing resource prior to performing the JSON merge operation
// using the MergePatch function from the json-patch library. The preprocessor
// is an arbitrary func that can be provided to further process the input. The
// MergePatch function accepts and returns byte arrays.
func HandlePatchOperation(input *FieldData, resource map[string]interface{}, preprocessor PatchPreprocessorFunc) ([]byte, error) {
var err error
if resource == nil {
return nil, fmt.Errorf("resource does not exist")
}
inputMap := map[string]interface{}{}
// Parse all fields to ensure data types are handled properly according to the FieldSchema
for key := range input.Raw {
val, ok := input.GetOk(key)
// Only accept fields in the schema
if ok {
inputMap[key] = val
}
}
if preprocessor != nil {
inputMap, err = preprocessor(inputMap)
if err != nil {
return nil, err
}
}
marshaledResource, err := json.Marshal(resource)
if err != nil {
return nil, err
}
marshaledInput, err := json.Marshal(inputMap)
if err != nil {
return nil, err
}
modified, err := jsonpatch.MergePatch(marshaledResource, marshaledInput)
if err != nil {
return nil, err
}
return modified, nil
}
// SpecialPaths is the logical.Backend implementation.
func (b *Backend) SpecialPaths() *logical.Paths {
return b.PathsSpecial

View File

@ -350,6 +350,7 @@ const (
CreateOperation Operation = "create"
ReadOperation = "read"
UpdateOperation = "update"
PatchOperation = "patch"
DeleteOperation = "delete"
ListOperation = "list"
HelpOperation = "help"

View File

@ -17,7 +17,7 @@ import (
func RespondErrorCommon(req *Request, resp *Response, err error) (int, error) {
if err == nil && (resp == nil || !resp.IsError()) {
switch {
case req.Operation == ReadOperation:
case req.Operation == ReadOperation, req.Operation == PatchOperation:
if resp == nil {
return http.StatusNotFound, nil
}

View File

@ -305,6 +305,9 @@ func (a *ACL) Capabilities(ctx context.Context, path string) (pathCapabilities [
if capabilities&CreateCapabilityInt > 0 {
pathCapabilities = append(pathCapabilities, CreateCapability)
}
if capabilities&PatchCapabilityInt > 0 {
pathCapabilities = append(pathCapabilities, PatchCapability)
}
// If "deny" is explicitly set or if the path has no capabilities at all,
// set the path capabilities to "deny"
@ -406,6 +409,8 @@ CHECK:
operationAllowed = capabilities&DeleteCapabilityInt > 0
case logical.CreateOperation:
operationAllowed = capabilities&CreateCapabilityInt > 0
case logical.PatchOperation:
operationAllowed = capabilities&PatchCapabilityInt > 0
// These three re-use UpdateCapabilityInt since that's the most appropriate
// capability/operation mapping
@ -440,7 +445,7 @@ CHECK:
// Only check parameter permissions for operations that can modify
// parameters.
if op == logical.ReadOperation || op == logical.UpdateOperation || op == logical.CreateOperation {
if op == logical.ReadOperation || op == logical.UpdateOperation || op == logical.CreateOperation || op == logical.PatchOperation {
for _, parameter := range permissions.RequiredParameters {
if _, ok := req.Data[strings.ToLower(parameter)]; !ok {
return

View File

@ -238,6 +238,12 @@ func testACLSingle(t *testing.T, ns *namespace.Namespace) {
{logical.UpdateOperation, "foo/bar", false, true},
{logical.CreateOperation, "foo/bar", true, true},
{logical.ReadOperation, "baz/quux", true, false},
{logical.CreateOperation, "baz/quux", true, false},
{logical.PatchOperation, "baz/quux", true, false},
{logical.ListOperation, "baz/quux", false, false},
{logical.UpdateOperation, "baz/quux", false, false},
// Path segment wildcards
{logical.ReadOperation, "test/foo/bar/segment", false, false},
{logical.ReadOperation, "test/foo/segment", true, false},
@ -341,6 +347,12 @@ func testLayeredACL(t *testing.T, acl *ACL, ns *namespace.Namespace) {
{logical.ListOperation, "foo/bar", false, false},
{logical.UpdateOperation, "foo/bar", false, false},
{logical.CreateOperation, "foo/bar", false, false},
{logical.ReadOperation, "baz/quux", false, false},
{logical.ListOperation, "baz/quux", false, false},
{logical.UpdateOperation, "baz/quux", false, false},
{logical.CreateOperation, "baz/quux", false, false},
{logical.PatchOperation, "baz/quux", false, false},
}
for _, tc := range tcases {
@ -864,6 +876,9 @@ path "sys/*" {
path "foo/bar" {
capabilities = ["read", "create", "sudo"]
}
path "baz/quux" {
capabilities = ["read", "create", "patch"]
}
path "test/+/segment" {
capabilities = ["read"]
}
@ -912,6 +927,9 @@ path "sys/seal" {
path "foo/bar" {
capabilities = ["deny"]
}
path "baz/quux" {
capabilities = ["deny"]
}
`
// test merging

View File

@ -0,0 +1,57 @@
package kv
import (
"context"
"testing"
logicalKv "github.com/hashicorp/vault-plugin-secrets-kv"
"github.com/hashicorp/vault/api"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
)
// Verifies that patching works by default with the root token
func TestKV_Patch_RootToken(t *testing.T) {
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
"kv": logicalKv.Factory,
},
}
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
})
cluster.Start()
defer cluster.Cleanup()
core := cluster.Cores[0]
client := core.Client
// make sure this client is using the root token
client.SetToken(cluster.RootToken)
// Enable KVv2
err := client.Sys().Mount("kv", &api.MountInput{
Type: "kv-v2",
})
if err != nil {
t.Fatal(err)
}
// Write a kv value and patch it
_, err = client.Logical().Write("kv/data/foo", map[string]interface{}{"data": map[string]interface{}{"bar": "baz"}})
if err != nil {
t.Fatal(err)
}
_, err = client.Logical().JSONMergePatch(context.Background(), "kv/data/foo", map[string]interface{}{"data": map[string]interface{}{"bar": "quux"}})
if err != nil {
t.Fatal(err)
}
secret, err := client.Logical().Read("kv/data/foo")
bar := secret.Data["data"].(map[string]interface{})["bar"]
if bar != "quux" {
t.Fatalf("expected bar to be quux but it was %q", bar)
}
}

View File

@ -1,4 +1,4 @@
package misc
package kv
import (
"bytes"

View File

@ -3398,7 +3398,8 @@ func hasMountAccess(ctx context.Context, acl *ACL, path string) bool {
perms.CapabilitiesBitmap&ListCapabilityInt > 0,
perms.CapabilitiesBitmap&ReadCapabilityInt > 0,
perms.CapabilitiesBitmap&SudoCapabilityInt > 0,
perms.CapabilitiesBitmap&UpdateCapabilityInt > 0:
perms.CapabilitiesBitmap&UpdateCapabilityInt > 0,
perms.CapabilitiesBitmap&PatchCapabilityInt > 0:
aclCapabilitiesGiven = true
@ -3684,6 +3685,9 @@ func (b *SystemBackend) pathInternalUIResultantACL(ctx context.Context, req *log
if perms.CapabilitiesBitmap&UpdateCapabilityInt > 0 {
capabilities = append(capabilities, UpdateCapability)
}
if perms.CapabilitiesBitmap&PatchCapabilityInt > 0 {
capabilities = append(capabilities, PatchCapability)
}
// If "deny" is explicitly set or if the path has no capabilities at all,
// set the path capabilities to "deny"

View File

@ -26,6 +26,7 @@ const (
ListCapability = "list"
SudoCapability = "sudo"
RootCapability = "root"
PatchCapability = "patch"
// Backwards compatibility
OldDenyPathPolicy = "deny"
@ -42,6 +43,7 @@ const (
DeleteCapabilityInt
ListCapabilityInt
SudoCapabilityInt
PatchCapabilityInt
)
// Error constants for testing
@ -83,6 +85,7 @@ var cap2Int = map[string]uint32{
DeleteCapability: DeleteCapabilityInt,
ListCapability: ListCapabilityInt,
SudoCapability: SudoCapabilityInt,
PatchCapability: PatchCapabilityInt,
}
type egpPath struct {
@ -390,7 +393,7 @@ func parsePaths(result *Policy, list *ast.ObjectList, performTemplating bool, en
pc.Capabilities = []string{DenyCapability}
pc.Permissions.CapabilitiesBitmap = DenyCapabilityInt
goto PathFinished
case CreateCapability, ReadCapability, UpdateCapability, DeleteCapability, ListCapability, SudoCapability:
case CreateCapability, ReadCapability, UpdateCapability, DeleteCapability, ListCapability, SudoCapability, PatchCapability:
pc.Permissions.CapabilitiesBitmap |= cap2Int[cap]
default:
return fmt.Errorf("path %q: invalid capability %q", key, cap)

View File

@ -83,6 +83,9 @@ path "test/req" {
capabilities = ["create", "sudo"]
required_parameters = ["foo"]
}
path "test/patch" {
capabilities = ["patch"]
}
path "test/mfa" {
capabilities = ["create", "sudo"]
mfa_methods = ["my_totp", "my_totp2"]
@ -244,6 +247,13 @@ func TestPolicy_Parse(t *testing.T) {
RequiredParameters: []string{"foo"},
},
},
{
Path: "test/patch",
Capabilities: []string{"patch"},
Permissions: &ACLPermissions{
CapabilitiesBitmap: (PatchCapabilityInt),
},
},
{
Path: "test/mfa",
Capabilities: []string{