diff --git a/plugin/plugin.go b/plugin/plugin.go index de4fc42..d9e602c 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -6,10 +6,17 @@ package plugin import ( "context" + "encoding/json" "errors" + "fmt" + "io" + "path" + "strconv" + "strings" "github.com/drone/drone-go/drone" "github.com/drone/drone-go/plugin/secret" + "github.com/sirupsen/logrus" "github.com/hashicorp/vault/api" ) @@ -78,6 +85,8 @@ func (p *plugin) Find(ctx context.Context, req *secret.Request) (*drone.Secret, // helper function returns the secret from vault. func (p *plugin) find(path string) (map[string]string, error) { + isV2, path := p.rewritePath(path) + secret, err := p.client.Logical().Read(path) if err != nil { return nil, err @@ -86,12 +95,18 @@ func (p *plugin) find(path string) (map[string]string, error) { return nil, errors.New("secret not found") } - // HACK: the vault v2 key value store is confusing - // and I could not quite figure out how to work with - // the api. This is the workaround I came up with. - v := secret.Data["data"] - if data, ok := v.(map[string]interface{}); ok { - secret.Data = data + // the V2 api includes both "data" and "metadata" fields within the top level "data" -- we only care about data. + // https://www.vaultproject.io/api-docs/secret/kv/kv-v1#sample-response + // v1 data schema: + // { properties: { data: { type: object, description: "the actual data" }}} + // https://www.vaultproject.io/api/secret/kv/kv-v2#sample-response-1 + // v2 data schema: + // { properties: { data: { properties: { data: { type: object, description: "the actual data" }}}}} + if isV2 { + v := secret.Data["data"] + if data, ok := v.(map[string]interface{}); ok { + secret.Data = data + } } params := map[string]string{} @@ -104,3 +119,93 @@ func (p *plugin) find(path string) (map[string]string, error) { } return params, err } + +// rewritePath rewrites a secret path if need be according to storage engine constraints; if it fails, it returns the +// original path. +// +// TL;DR: vault requires rewriting secret paths for the V2 engine mount points. This is most visible when you use the +// CLI to output curl strings: +// $ vault kv get \ +// -output-curl-string \ +// foo/versioned/bar +// curl -H "X-Vault-Request: true" \ +// -H "X-Vault-Token: $(vault print token)" \ +// https://vault.example.com/v1/foo/versioned/data/bar +// +// Note the addition of "data" in the output curl string. This only occurs for the v2 engine. This function +// reproduces the logic from the CLI: +// https://github.com/hashicorp/vault/blob/7aa1ffa92ee61b977efad1488b8f309b1e2136df/command/kv_get.go#L94-L110 +func (p *plugin) rewritePath(path string) (bool, string) { + r := p.client.NewRequest("GET", "/v1/sys/internal/ui/mounts/"+path) + resp, err := p.client.RawRequest(r) + if err != nil { + logrus.Debugf("failed querying mount point; defaulting to original: %v", err) + return false, path + } + defer resp.Body.Close() + isV2, rewritten, err := rewritePath(resp.Body, path) + if err != nil { + logrus.Debugf("failed rewriting; defaulting to original: %v", err) + return false, path + } + logrus.Debugf("rewrote %q to %q", path, rewritten) + return isV2, rewritten +} + +func rewritePath(r io.Reader, original string) (isV2 bool, rewritten string, _ error) { + defer func() { + // never permit a trailing slash, no matter what user puts in + rewritten = strings.TrimSuffix(rewritten, "/") + }() + /* + Example v2 response: + { + "request_id": "4a3a3ef6-d0a8-9a9b-d7eb-c320ef170b55", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "accessor": "kv_f055aa7b", + "config": { + "default_lease_ttl": 0, + "force_no_cache": false, + "max_lease_ttl": 0 + }, + "description": "versioned encrypted key/value storage", + "local": false, + "options": { + "version": "2" + }, + "path": "foo/versioned/", + "seal_wrap": false, + "type": "kv", + "uuid": "eb3b578c-a0bf-2a91-19dc-4155e8ae0116" + }, + "wrap_info": null, + "warnings": null, + "auth": null + } + */ + var response struct { + Data struct { + Options struct { + Version string `json:"version"` + } `json:"options"` + Path string `json:"path"` + } `json:"data"` + } + if err := json.NewDecoder(r).Decode(&response); err != nil { + return false, original, fmt.Errorf("failed parsing response: %v", err) + } + v, err := strconv.Atoi(response.Data.Options.Version) + if err != nil || v != 2 { + return false, original, nil // we only rewrite v2 + } + + mountPath := response.Data.Path + if original == mountPath || original == strings.TrimSuffix(mountPath, "/") { + return true, path.Join(mountPath, "data"), nil + } + + return true, path.Join(mountPath, "data", strings.TrimPrefix(original, mountPath)), nil +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index d53a3db..08cd30f 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -6,9 +6,12 @@ package plugin import ( "context" + "encoding/json" "io/ioutil" "net/http" "net/http/httptest" + "os" + "strings" "testing" "github.com/drone/drone-go/drone" @@ -242,3 +245,66 @@ func TestPlugin_KeyNotFound(t *testing.T) { return } } + +/* Test_rewritePath establishes that the behavior of our path rewrite logic exactly parallels that of the Vault CLI; + generated with logic like (requires authentication to a vault namespace with both v1 and v2 engines mounted): + +main() { + for path in mount{/v2{,/data},/v1}{/bar,}{,/}; do + jq --null-input \ + --arg mount_data "$(get_mount "${path}")" \ + --arg rewritten "$(get_rewritten_path "${path}")" \ + --arg path "${path}" \ + '{$mount_data, $rewritten, $path}' + done | + jq -s '[.[] | (.is_v2 = (.mount_data | fromjson).data.options.version == "2")]' +} + +get_mount() { + local path="${1}" + curl \ + --silent \ + -H "X-Vault-Request: true" \ + -H "X-Vault-Token: $(vault print token)" \ + "https://vault.example.com/v1/sys/internal/ui/mounts/${path}" +} + +get_rewritten_path() { + local path="${1}" + vault kv get -output-curl-string "${path}" | cut -d/ -f5- +} + +main +*/ +func Test_rewritePath(t *testing.T) { + var testCases []struct { + Path string `json:"path"` + MountData string `json:"mount_data"` + Rewritten string `json:"rewritten"` + IsV2 bool `json:"is_v2"` + } + func() { + f, err := os.Open("testdata/v2.json") + if err != nil { + t.Skipf("expected test data present at testdata/v2.json: %v", err) + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&testCases); err != nil { + t.Fatalf("malformed test data: %v", err) + } + }() + for _, tc := range testCases { + t.Run(strings.ReplaceAll(tc.Path, "/", "_"), func(t *testing.T) { + isV2, rewrite, err := rewritePath(strings.NewReader(tc.MountData), tc.Path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rewrite != tc.Rewritten { + t.Errorf("expected %q but got %q", tc.Rewritten, rewrite) + } + if isV2 != tc.IsV2 { + t.Errorf("expected %v but got %v", tc.IsV2, isV2) + } + }) + } +} diff --git a/plugin/testdata/generate_v2_json.sh b/plugin/testdata/generate_v2_json.sh new file mode 100644 index 0000000..76c7983 --- /dev/null +++ b/plugin/testdata/generate_v2_json.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +# assumes the user has access to s a PREFIX with a V1 mount and a V2 mount + +readonly _PREFIX="${PREFIX:-mount}" +readonly _V2="${V2_MOUNT:-/v2}" +readonly _V1="${V1_MOUNT:-/v1}" + +main() { + for path in $_PREFIX{$_V2{,/data},$_V1}{/bar,}{,/}; do + jq --null-input \ + --arg mount_data "$(get_mount "${path}")" \ + --arg rewritten "$(get_rewritten_path "${path}")" \ + --arg path "${path}" \ + '{$mount_data, $rewritten, $path}' + done | + jq -s '[.[] | (.is_v2 = (.mount_data | fromjson).data.options.version == "2")]' +} + +get_mount() { + local path="${1}" + curl \ + --silent \ + -H "X-Vault-Request: true" \ + -H "X-Vault-Token: $(vault print token)" \ + "https://vault.example.com/v1/sys/internal/ui/mounts/${path}" +} + +get_rewritten_path() { + local path="${1}" + vault kv get -output-curl-string "${path}" | cut -d/ -f5- +} + +main \ No newline at end of file diff --git a/plugin/testdata/v2.json b/plugin/testdata/v2.json new file mode 100644 index 0000000..6992866 --- /dev/null +++ b/plugin/testdata/v2.json @@ -0,0 +1,74 @@ +[ + { + "mount_data": "{\"request_id\":\"b2f6799d-7a1d-8bdc-ac5f-0c1238c17969\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/bar", + "path": "mount/v2/bar", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"85d5a8e6-348c-a9e5-7cf8-b0fce5d3c3af\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/bar", + "path": "mount/v2/bar/", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"db9ca87a-f7a1-70f0-89aa-8ca985483793\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data", + "path": "mount/v2", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"2c1e9144-af1b-41a4-1c3e-980e13f0e937\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data", + "path": "mount/v2/", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"ccb9fbd3-ed2c-eb98-f8e6-0f5fcb570f57\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/data/bar", + "path": "mount/v2/data/bar", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"a6d671b1-9029-95ac-5560-846345443f81\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/data/bar", + "path": "mount/v2/data/bar/", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"23fba99b-f552-6ec5-a0d8-f6ef41d4e4c0\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/data", + "path": "mount/v2/data", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"b2d1501e-f748-4c71-0258-27b36aa95f2b\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"kv_f055aa7b\",\"config\":{\"default_lease_ttl\":0,\"force_no_cache\":false,\"max_lease_ttl\":0},\"description\":\"versioned encrypted key/value storage\",\"local\":false,\"options\":{\"version\":\"2\"},\"path\":\"mount/v2/\",\"seal_wrap\":false,\"type\":\"kv\",\"uuid\":\"eb3b578c-a0bf-2a91-19dc-4155e8ae0116\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v2/data/data", + "path": "mount/v2/data/", + "is_v2": true + }, + { + "mount_data": "{\"request_id\":\"253551b3-a01e-1126-0dee-f5fd3470b4a4\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"generic_c45ac6cc\",\"config\":{\"default_lease_ttl\":86400,\"force_no_cache\":false,\"max_lease_ttl\":604800},\"description\":\"encrypted key/value storage\",\"local\":false,\"options\":{},\"path\":\"mount/v1/\",\"seal_wrap\":false,\"type\":\"generic\",\"uuid\":\"b0c47310-507d-e5d6-f186-03d3aa71cb58\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v1/bar", + "path": "mount/v1/bar", + "is_v2": false + }, + { + "mount_data": "{\"request_id\":\"1f5f8620-cdb3-0aa1-81f3-4308e3e5fd6a\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"generic_c45ac6cc\",\"config\":{\"default_lease_ttl\":86400,\"force_no_cache\":false,\"max_lease_ttl\":604800},\"description\":\"encrypted key/value storage\",\"local\":false,\"options\":{},\"path\":\"mount/v1/\",\"seal_wrap\":false,\"type\":\"generic\",\"uuid\":\"b0c47310-507d-e5d6-f186-03d3aa71cb58\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v1/bar", + "path": "mount/v1/bar/", + "is_v2": false + }, + { + "mount_data": "{\"request_id\":\"a9ed61f0-fc96-3f5e-188a-c52afcb8341d\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"generic_c45ac6cc\",\"config\":{\"default_lease_ttl\":86400,\"force_no_cache\":false,\"max_lease_ttl\":604800},\"description\":\"encrypted key/value storage\",\"local\":false,\"options\":{},\"path\":\"mount/v1/\",\"seal_wrap\":false,\"type\":\"generic\",\"uuid\":\"b0c47310-507d-e5d6-f186-03d3aa71cb58\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v1", + "path": "mount/v1", + "is_v2": false + }, + { + "mount_data": "{\"request_id\":\"fe545ea3-06a5-51d3-0236-4982e896cf8f\",\"lease_id\":\"\",\"renewable\":false,\"lease_duration\":0,\"data\":{\"accessor\":\"generic_c45ac6cc\",\"config\":{\"default_lease_ttl\":86400,\"force_no_cache\":false,\"max_lease_ttl\":604800},\"description\":\"encrypted key/value storage\",\"local\":false,\"options\":{},\"path\":\"mount/v1/\",\"seal_wrap\":false,\"type\":\"generic\",\"uuid\":\"b0c47310-507d-e5d6-f186-03d3aa71cb58\"},\"wrap_info\":null,\"warnings\":null,\"auth\":null}", + "rewritten": "mount/v1", + "path": "mount/v1/", + "is_v2": false + } +]