-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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 API helper for renewing a secret #2886
Conversation
2feb942
to
60c1c65
Compare
api/api_test.go
Outdated
|
||
// restVaultServer runs an instance of the Vault server in development mode. | ||
// This requires that the vault binary is installed and in the $PATH. | ||
func testVaultServer(t *testing.T) (*Client, func()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to invoke the binary rather than simply launching three test cores with listeners using TestCluster? There are a number of benefits -- it includes HA, fully-set-up clients using TLS, and more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One of the issues with doing it this way for example is having to coordinate with API vs. changes in other packages, so if this test is run without first compiling and placing a new version of vault in your path then you'll have problems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We followed a similar pattern in Consul - the main reason to use this style was to avoid a cyclic dependency. If TestCluster doesn't create one, then great! Maybe we can use that instead.
For actually running these tests, what we did with Consul was to just compile the "dev" target prior to running the "test" target in the Makefile. That way we could easily launch the binary from $PWD/bin/vault
using the current HEAD.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To avoid a cyclic dependency in the tests you could make the test file's package name package api_test
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jefferai go has an exception for _test
packages. you can have package api
and package api_test
in the same directory. we do it in the database plugin tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, neat! I didn't know that. That is very very useful.
api/api_test.go
Outdated
return nil, nil | ||
} | ||
|
||
func testPostgresDatabase(t *testing.T) (string, func()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have another place where we invoke a postgres database using dockertest. Rather than put this in the API package it probably would be good to pick one of the two implementations and put it in a helper package that can be called by both.
Using a helpers/testing
package to hold both testing common code and as a launchpoint for tests that can't easily be run in-package (e.g. ones using vault.TestCluster
) might be the right approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ran into issues trying to import helpers/testing because of a circular import cycle 🐸. Lots of things depend on the api package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What issues? There should be absolutely no issues importing both api and vault from a separate package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From a separate package no, but I didn't know about being able to create a "test" package separately. I'm going to try that now. I would like to not have to spin up a separate cluster and just use the shared helpers 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What problem did you have when creating a separate package?
api/auth_token.go
Outdated
@@ -135,6 +135,26 @@ func (c *TokenAuth) RenewSelf(increment int) (*Secret, error) { | |||
return ParseSecret(resp.Body) | |||
} | |||
|
|||
// RenewSelfAsToken behaves like renew-self, but authenticates using a provided | |||
// token instead of the token attached to the client. | |||
func (c *TokenAuth) RenewSelfAsToken(token string, increment int) (*Secret, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think RenewTokenAsSelf
may be less confusing
api/renewer.go
Outdated
// Grace is a minimum renewal (in seconds) before returring so the upstream | ||
// client can do a re-read. This can be used to prevent clients from waiting | ||
// too long to read a new credential and incur downtime. | ||
Grace int |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably better to make it a time.Duration
since this is a Go API.
api/renewer.go
Outdated
client: c, | ||
secret: secret, | ||
grace: grace, | ||
doneCh: make(chan error, 1), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This (and stopCh
) should be unbuffered. They should be closed when done/stopped, since a closed channel always returns immediately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I see what you're doing with doneCh
and it probably shouldn't be closed when done but probably also doesn't need to be buffered since you'll only read from it once.
api/renewer.go
Outdated
|
||
// Push a message that a renewal took place. | ||
select { | ||
case r.tickCh <- struct{}{}: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than an empty struct, I think it would be useful to instead just push down the Auth info, or some way for the user to be able to introspect the new information without then having to perform a lookup on the lease or token. I haven't thought deeply about what information should be sent, but at a minimum maybe a struct with the new TTL?
api/renewer.go
Outdated
select { | ||
case <-r.stopCh: | ||
return nil | ||
case <-time.After(time.Duration(leaseDuration/2.0) * time.Second): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to divide by 3 instead? I have done this before, thinking that if there is some issue during token renewal (Vault outage, network problems, etc.), we will get another chance at renewing prior to the hard expiration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is some more detailed logic in VSI that Seth said he was planning to integrate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jefferai what is VSI? I am also not sure what you mean - is the simple division on the leaseDuration a hold-over while we add something better? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ryanuber An internal something, ping me separately if you want info.
What I mean is, we have code that has less naive logic. Seth said he was going to look at integrating it, but wanted an initial run through of the proposed API/behavior first.
27e1953
to
d136bc3
Compare
"pki": pki.Factory, | ||
} | ||
|
||
func testVaultServer(t testing.TB) (*api.Client, func()) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to discuss pulling the functions in this file into a testing package which other Go tools that leverage Vault can use to stand up a Vault cluster and client in tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm +1 for this, I would definitely use it. Consul has a similar thing in its testutil
package. I'm not married to the testutil
name but a dedicated package with all of the test helpers exported (public) would be huge. For reference, the consul test server stuff is here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would also vote for tackling this in a separate PR though to minimize scope.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Vault already has a bunch of these. I would suggest not adding such a PR right now as you're likely to duplicate work.
api/renewer.go
Outdated
|
||
func init() { | ||
// Seed the random generator | ||
rand.Seed(time.Now().Unix()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is intended to be imported by other projects, should we do this? It's a bit odd because the importer may have already seeded from another source. I wonder if there is any real downside to using the predictable math/rand for this anyways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought about this, and I really don't have a good answer. If consumers of the client library don't randomize, we completely lose the point of randomization, since they'll randomize on the same randomness. I'm not opposed to removing or keeping it. If there's a pre-defined pattern, I'd love to learn more about it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the high security context of Vault I'd be interested in hearing from @jefferai and team on this one, since it will affect all importers and might cause unintended side-effects. For most practical cases though I agree it's probably fine to just re-seed like you are doing already. We should probably switch it to a UnixNano()
to slim the chances of a common seed down.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could store a Rand object in the Renewer
struct so that you can independently seed each Renewer
and avoid the side effect on import. https://golang.org/pkg/math/rand/#Rand
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just pushed that up. I like that API because we can use a different random per renewer, but people who really care can pass in their own randomizer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! I like this. 👍
// token instead of the token attached to the client. | ||
func (c *TokenAuth) RenewTokenAsSelf(token string, increment int) (*Secret, error) { | ||
r := c.c.NewRequest("PUT", "/v1/auth/token/renew-self") | ||
r.ClientToken = token |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will make the client not thread safe, since any call to RenewTokenAsSelf
will change the token that is being used in another thread. Might be worth copying the underlying client to avoid the global modification.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copying the client is really challenging due to the way we setup the underlying TLS and connection stuff 😦
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also note this is setting the token on the request, not the client (it's r.ClientToken
, not c.Token
). Since each call to NewRequest makes a new struct, I don't see this actually mutating state. Am I misinterpreting the way NewRequest works? It does not seem to modify the actual client.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sethvargo Yes, I believe that should be fine. FYI there is a tls.Config.Clone() method (https://golang.org/pkg/crypto/tls/#Config.Clone) but it's only semi-safe in that underlying pointers are copied as-is so you can't use that and then say modify the CA pools without affecting other clients.
api/renewer.go
Outdated
secret: secret, | ||
grace: grace, | ||
random: random, | ||
doneCh: make(chan error), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably want doneCh
at least size 1 to not block
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a comment in the Renew()
function that states the function will block if nothing is reading from DoneCh
. I think that's the correct behavior, since it seems silly to fire off the function and then not wait for a result. What do you think?
I updated the code to not block if we are closed.
api/renewer.go
Outdated
grace: grace, | ||
random: random, | ||
doneCh: make(chan error), | ||
renewCh: make(chan *RenewOutput, 5), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renewCh
of size 5 seems sort of arbitrary, what happens when its full? Maybe the renew functions should guard on that and avoid blocking.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's totally arbitrary, but seemed like a sane default. I suppose it could be a configurable. All the writes to the renewCh are non-blocking.
api/renewer.go
Outdated
// both cases, the caller should attempt a re-read of the secret. Clients should | ||
// check the return value of the channel to see if renewal was successful. | ||
type Renewer struct { | ||
sync.Mutex |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would make the lock private, unless the caller is ever going to Lock
this explicitly
@sethvargo I think I missed the part about the request! I would consider just buffering |
11a7eb9
to
44501db
Compare
api/api_integration_test.go
Outdated
config.Address = address | ||
config.HttpClient = cleanhttp.DefaultClient() | ||
config.HttpClient.Transport.(*http.Transport).TLSClientConfig = cores[0].TLSConfig | ||
client, err := api.NewClient(config) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason not to just use the client from cores[0].Client rather than all of this boilerplate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't aware that existed. I was copying code from elsewhere in Vault that built their own client.
default: | ||
} | ||
|
||
// Somehow, sometimes, this happens. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be nice to know when this happens, would using a non-nil logger help figure it out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't reproduce it with Vault on master, but it occurred back in 0.5 occasionally. There are Consul Template issues about it. I put it in there because we can't guarantee someone using the client library is on the latest version of Vault.
cea1be8
to
45d0048
Compare
api/renewer.go
Outdated
// } | ||
// | ||
// // Renewal is now over | ||
// case renewal := <-RenewCh(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this meant to be renewer.RenewCh()
? Same for DoneCh above.
} | ||
|
||
// renewLease is a helper for renewing a lease. | ||
func (r *Renewer) renewLease() error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels like the previous function and this function could be pretty trivially combined with a couple of switch
statements, instead of having mostly duplicate code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I felt that way too, but I cannot think of a better way to write it.
This works, but it's tricky. There's a lot of boolean logic that's well tested, but challenging to follow.
// renewIt is a helper for renewing authentication.
func (r *Renewer) renewIt(renewable, auth bool, thing string) error {
if !renewable || thing == "" {
return ErrRenewerNotRenewable
}
client := r.client
for {
// Check if we are stopped.
select {
case <-r.stopCh:
return nil
default:
}
// Renew the auth.
var renewal *api.Secret
var err error
if auth {
renewal, err = client.Auth().Token().RenewTokenAsSelf(thing, 0)
if err != nil {
return err
}
} else {
renewal, err = client.Sys().Renew(thing, 0)
if err != nil {
return err
}
}
// Push a message that a renewal took place.
select {
case r.renewCh <- &RenewOutput{time.Now().UTC(), renewal}:
default:
}
// Somehow, sometimes, this happens.
if renewal == nil || (auth && renewal.Auth == nil) {
return ErrRenewerNoSecretData
}
// Do nothing if we are not renewable
if (auth && !renewal.Auth.Renewable) || !renewal.Renewable {
return ErrRenewerNotRenewable
}
// Grab the lease duration and sleep duration - note that we grab the auth
// lease duration, not the secret lease duration.
var leaseDuration time.Duration
if auth {
leaseDuration = time.Duration(renewal.Auth.LeaseDuration) * time.Second
} else {
leaseDuration = time.Duration(renewal.LeaseDuration) * time.Second
}
sleepDuration := r.sleepDuration(leaseDuration)
// If we are within grace, return now.
if leaseDuration <= r.grace || sleepDuration <= r.grace {
return nil
}
select {
case <-r.stopCh:
return nil
case <-time.After(sleepDuration):
continue
}
}
}
I tried writing something that accepted functions, but the fields you have to check for a successful auth are different from a successful renewal (which is one of the main reasons why I wrote this renewer).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made a couple of comments, looks good otherwise!
This removes the cyclic dependency
45d0048
to
c29e851
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. Just one small documentation issue.
api/renewer.go
Outdated
// Secret is the secret to renew | ||
Secret *Secret | ||
|
||
// Grace is a minimum renewal (in seconds) before returring so the upstream |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should remove the "(in seconds)" since it can take any time.Duration. Also s/returring/returning/.
This adds a client API helper for renewing a secret with a configurable grace period. I really got sick of writing the same code over and over again, and figured the API client could benefit here. This isn't an end-all-be-all renewal method; clients which want more control should implement this logic themselves. However, for the normal basic cases, it suffices.
This is really hard to test. There's no way to spin up a Vault server in the api package because the vault core depends on the API package, creating a circular dependency. I didn't want to do a huge refactor and shuffle things around for this tiny PR.Thanks to @ryanuber, I have a relatively good way to test this!