Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add subkeys endpoint #59

Merged
merged 18 commits into from
Feb 14, 2022
Merged

Add subkeys endpoint #59

merged 18 commits into from
Feb 14, 2022

Conversation

ccapurso
Copy link
Contributor

@ccapurso ccapurso commented Jan 28, 2022

Overview

KVv2 added patch support in #49 which allows users to update a subset of a secret entries subkeys without the need to first read the read the data. The knowledge of a secret entry's structure (i.e. its subkeys) will allow users to more confidently perform patch operations while still not having to retrieve the underlying secret data. This PR adds a new subkeys/:path endpoint to achieve this. Its response structure resembles that of the data/:path endpoint except the underlying values of each leaf subkey is replaced with nil.

The subkeys endpoint remains consistent with the data endpoint in multiple ways. Like the data/:path endpoint, the current version of the secret entry stored at :path will be read. A specific version can be read by using the version parameter. If the requested version has been deleted or destroyed, a nil Logical.Response is returned which ultimately leads to a status of 404 Not Found. Both provide the same metadata structure in the response.

The depth parameter can be used to limit the depth of the structure. If the depth is not provided or is equal to 0, the full structure is returned. A depth of 1, for example, will provide only the top-level subkeys.

Example secret entry:

{
  "foo": "abc",
  "bar": {
    "baz": "def"
  }
}

Example subkeys response:

{
  "subkeys": {
    "foo": null,
    "bar": {
      "baz": null
      },
  },
  "metadata": {
    "created_time": "2021-12-14T20:28:00.773477Z",
    "custom_metadata": null,
    "deletion_time": "",
    "destroyed": false,
    "version": 1
  }
}

Design of Change

The pathSubkeysRead method has been added to handle ReadOperation requests for the subkeys/:path endpoint. The underlying logic closely resembles that of the pathDataRead method which handles ReadOperation requests for the data/:path endpoint. The secret data fetched from storage is run through removeValues which walks the map obtained from unmarshaling the JSON entry and replaces any values for leaf keys with nil. The map is modified in place.

Leaf keys are defined as those whose type is not a map[string]interface{} or is a map[string]interface{} but has no underlying keys. The depth parameter will also artificially treat keys as leaves if they reside at the associated level within the map.

Related Issues/Pull Requests

vault API docs PR #13893

@ccapurso ccapurso changed the title Vault 4302 subkeys endpoint Add subkeys endpoint Feb 3, 2022
@ccapurso ccapurso marked this pull request as ready for review February 4, 2022 17:54
@ccapurso ccapurso requested a review from a team February 4, 2022 17:54
Copy link

@HridoyRoy HridoyRoy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking really good, just a few questions/comments!

helpers_test.go Outdated
@@ -0,0 +1,53 @@
package kv

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these helpers should be moved to a file that doesn't end with test.go? As far as I remember, only files containing tests are named *_test.go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, whoops. You're right, thanks! I can move these to a non _test.go file in a separate test helpers package to make it obvious and import it directly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, after a bit of research this might not be true. Forgive me, since I'm new to Go. It seems that a file ending in _test.go indicates that the file should be packaged up when running go test. I don't think it necessarily dictates that the file must contain tests. Creating a file that doesn't end in _test.go would mean that it would be packaged into the binary which I don't think we want. Thoughts?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All that you're saying is true. Nonetheless, we typically put helpers in non-_test.go files, because otherwise they can't be imported by other packages.

The Go linker is pretty smart, I don't think it'll include the helper code in the prod binary unless prod code invokes it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course if the helpers aren't public anyway, it's up to you.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I would like to follow our general practices so I will move things. Thanks for the feedback everyone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So now looking at this more, having a separate package will result in cyclic imports as the getBackend function requires the VersionedKVFactory. I didn't like that the getBackend function was defined in a path_data_test.go and instead wanted it in a separate file. Though it's all the same test package so maybe I'm just complicating things and might just revert all that and go back to way things were prior to this PR.

helpers_test.go Outdated
}

// Wait for the upgrade to finish
time.Sleep(time.Second)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check to make sure the upgrade finishes instead of using the sleep statement here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getBackend method existed before this PR. It is used by the unit tests to instantiate a KV backend. I moved it from one file to another. That said, I agree that it would be good to ensure that the upgrade finishes rather than a single hopeful sleep.

The backend itself has a flag that states that the upgrade is pending. I think it would be cleaner to add polling with a timeout as you said. That's what we do for the kv CLI tests in the vault repo. I can try to get something like that in place here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a ticker/timeout which ticks per second. Each tick, a read request will be submitted to the config endpoint to ensure that it doesn't result in an error.

helpers_test.go Outdated
}

// getKeySet will produce a set of the keys that exist in m
func getKeySet(m map[string]interface{}) map[string]struct{} {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we changing the tests to check the keySet now? Shouldn't we check the nested structure as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a pre-existing function that I added in the last release cycle to verify the structure of response bodies. I had it defined in a specific test file and moved it to a more central location. It is used to check the top-level keys of the subkeys response (subkeys and metadata) and then the keys underneath metadata See:

if diff := deep.Equal(getKeySet(resp.Data), expectedRespKeys); len(diff) > 0 {
t.Fatalf("metadata map keys mismatch, diff: %#v", diff)
}

metadata, ok := resp.Data["metadata"].(map[string]interface{})
if !ok {
t.Fatalf("expected metadata to be map, actual: %#v", metadata)
}

I just noticed, however, that the error message for the top-level keys check is incorrect and I'll have to change that.

An example of a check for the full nested structure can be found:

expectedSubkeys := map[string]interface{}{
"foo": nil,
"bar": map[string]interface{}{
"a": map[string]interface{}{
"c": map[string]interface{}{
"d": nil,
},
},
"b": nil,
},
"baz": map[string]interface{}{
"e": nil,
},
"quux": nil,
"quuz": nil,
}
if diff := deep.Equal(resp.Data["subkeys"], expectedSubkeys); len(diff) > 0 {
t.Fatalf("resp and expected data mismatch, diff: %#v", diff)
}

},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.upgradeCheck(b.pathSubkeysRead()),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the significance of b.upgradeCheck here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every endpoint is wrapped with b.upgradeCheck. This is to ensure an error is returned if the backend is being upgraded from v1 (non-versioned entries) to v2 (versioned entries). In other words, it ensures the backend is inaccessible while the backend's storage is updated to fit the new structure of v2. This is outlined in the Vault docs here. Does that answer your question?

var walk func(interface{}, int)

walk = func(in interface{}, depth int) {
val := reflect.ValueOf(in)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check IsValid on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes! Good catch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added IsValid checks prior to any method calls on any reflect.Value vars. How does that look?

}

versionNum := meta.CurrentVersion
versionParam := data.Get("version").(int)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be wrong on this, but if memory serves we need to do GetOk for parameters that are not required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that GetOk will actually result in a false value for the ok return val if the field is not provided. Additional logic would then be required to default it appropriately. The Get method will call GetOk under the hood ultimately defaulting to the default or zero value for the field. The version field does not have a default defined thus the zero value (i.e. 0) will be used. A 0 for this field denotes "use the current version." Note that on line 105 versionNum is initialized as meta.CurrentVersion. It is only overridden on line 109 if versionParam > 0.

}

if deletionTime.Before(time.Now()) {
return logical.RespondWithStatusCode(resp, req, http.StatusNotFound)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to return the response here? What about returning logical.ErrorResponse instead?

Copy link
Contributor Author

@ccapurso ccapurso Feb 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that returning a logical.ErrorResponse would result in a 400 status code, correct? The desire is to return a 404. I believe the helper function logical.RespondWithStatusCode is required to achieve that. It takes a logical.Response, the incoming logical.Request and an HTTP status code. It will add HTTPStatusCode to the Data field in the logical.Response. Vault's response handling logic will pull this out and use it as the response status. This is a means to ensure that a 404 response is provided if the secret entry has been deleted (we also check if it is destroyed). This check is consistent with some of the other KV endpoints. See the read for data/:path:

// If the version has been deleted return metadata with a 404
if vm.DeletionTime != nil {
deletionTime, err := ptypes.Timestamp(vm.DeletionTime)
if err != nil {
return nil, err
}
if deletionTime.Before(time.Now()) {
return logical.RespondWithStatusCode(resp, req, http.StatusNotFound)
}
}
// If the version has been destroyed return metadata with a 404
if vm.Destroyed {
return logical.RespondWithStatusCode(resp, req, http.StatusNotFound)
}

Copy link

@ncabatoff ncabatoff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

// provide all subkeys with nesting fully intact. The modifications are made
// to the input in-place. maxDepth will denote how deep to traverse. A maxDepth
// of 0 is the equivalent of no limit.
func removeValues(input map[string]interface{}, maxDepth int) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might like https://github.com/mitchellh/reflectwalk. (I'm not asking you to use it, just pointing out it exists. For your use case it might be simpler to do as you're doing.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did look at that a bit, actually! It's a very intriguing library. I was hesitant to use it though. My thought was that the added boilerplate might outweigh the overall benefit based on the fairly narrow problem scope.

path_subkeys_test.go Outdated Show resolved Hide resolved
Copy link

@swayne275 swayne275 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good!

"metadata": {},
}

if diff := deep.Equal(getKeySet(resp.Data), expectedRespKeys); len(diff) > 0 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this not check the values in the map? like is it only checking that the keys are present and the same?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, this is just checking top-level keys to verify that the response structure matches our expectations. The metadata keys (but not the associated values) are then checked below on line 91. The actual structure of the subkeys map is then checked on line 112. All these checks are done separately to verify the overall structure of the response body.

@ccapurso ccapurso merged commit a1df167 into master Feb 14, 2022
@ccapurso ccapurso deleted the vault-4302-subkeys-endpoint branch February 14, 2022 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants