Add AWS Secret Engine Root Credential Rotation (#5140)

* Add AWS Secret Engine Root Credential Rotation

This allows the AWS Secret Engine to rotate its credentials used to
access AWS. This will only work when the AWS Secret Engine has been
provided explicit IAM credentials via the config/root endpoint, and
further, when the IAM credentials provided are the only access key on
the IAM user associated wtih the access key (because AWS allows a
maximum of 2 access keys per user).

Fixes #4385

* Add test for AWS root credential rotation

Also fix a typo in the root credential rotation code

* Add docs for AWS root rotation

* Add locks around reading and writing config/root

And wire the backend up in a bunch of places so the config can get the
lock

* Respond to PR feedback

* Fix casing in error messages

* Fix merge errors

* Fix locking bugs
This commit is contained in:
Joel Thompson 2018-09-26 10:10:00 -04:00 committed by Vishal Nayak
parent e66795a095
commit 5e6f8904d8
9 changed files with 221 additions and 17 deletions

View File

@ -36,6 +36,7 @@ func Backend() *backend {
Paths: []*framework.Path{ Paths: []*framework.Path{
pathConfigRoot(&b), pathConfigRoot(&b),
pathConfigRotateRoot(&b),
pathConfigLease(&b), pathConfigLease(&b),
pathRoles(&b), pathRoles(&b),
pathListRoles(&b), pathListRoles(&b),
@ -60,7 +61,7 @@ type backend struct {
// Mutex to protect access to reading and writing policies // Mutex to protect access to reading and writing policies
roleMutex sync.RWMutex roleMutex sync.RWMutex
// Mutex to protect access to iam/sts clients // Mutex to protect access to iam/sts clients and client configs
clientMutex sync.RWMutex clientMutex sync.RWMutex
// iamClient and stsClient hold configured iam and sts clients for reuse, and // iamClient and stsClient hold configured iam and sts clients for reuse, and
@ -99,7 +100,7 @@ func (b *backend) clientIAM(ctx context.Context, s logical.Storage) (iamiface.IA
return b.iamClient, nil return b.iamClient, nil
} }
iamClient, err := clientIAM(ctx, s) iamClient, err := nonCachedClientIAM(ctx, s)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -126,7 +127,7 @@ func (b *backend) clientSTS(ctx context.Context, s logical.Storage) (stsiface.ST
return b.stsClient, nil return b.stsClient, nil
} }
stsClient, err := clientSTS(ctx, s) stsClient, err := nonCachedClientSTS(ctx, s)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -80,6 +80,7 @@ func TestBackend_basicSTS(t *testing.T) {
Backend: getBackend(t), Backend: getBackend(t),
Steps: []logicaltest.TestStep{ Steps: []logicaltest.TestStep{
testAccStepConfigWithCreds(t, accessKey), testAccStepConfigWithCreds(t, accessKey),
testAccStepRotateRoot(accessKey),
testAccStepWritePolicy(t, "test", testDynamoPolicy), testAccStepWritePolicy(t, "test", testDynamoPolicy),
testAccStepRead(t, "sts", "test", []credentialTestFunc{listDynamoTablesTest}), testAccStepRead(t, "sts", "test", []credentialTestFunc{listDynamoTablesTest}),
testAccStepWriteArnPolicyRef(t, "test", ec2PolicyArn), testAccStepWriteArnPolicyRef(t, "test", ec2PolicyArn),
@ -436,6 +437,44 @@ func testAccStepConfigWithCreds(t *testing.T, accessKey *awsAccessKey) logicalte
} }
} }
func testAccStepRotateRoot(oldAccessKey *awsAccessKey) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Check: func(resp *logical.Response) error {
if resp == nil {
return fmt.Errorf("received nil response from config/rotate-root")
}
newAccessKeyID := resp.Data["access_key"].(string)
if newAccessKeyID == oldAccessKey.AccessKeyID {
return fmt.Errorf("rotate-root didn't rotate access key")
}
awsConfig := &aws.Config{
Region: aws.String("us-east-1"),
HTTPClient: cleanhttp.DefaultClient(),
Credentials: credentials.NewStaticCredentials(oldAccessKey.AccessKeyID, oldAccessKey.SecretAccessKey, ""),
}
// sigh....
oldAccessKey.AccessKeyID = newAccessKeyID
log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...")
time.Sleep(10 * time.Second)
svc := sts.New(session.New(awsConfig))
params := &sts.GetCallerIdentityInput{}
_, err := svc.GetCallerIdentity(params)
if err == nil {
return fmt.Errorf("bad: old credentials succeeded after rotate")
}
if aerr, ok := err.(awserr.Error); ok {
if aerr.Code() != "InvalidClientTokenId" {
return fmt.Errorf("Unknown error returned from AWS: %#v", aerr)
}
return nil
}
return err
},
}
}
func testAccStepRead(t *testing.T, path, name string, credentialTests []credentialTestFunc) logicaltest.TestStep { func testAccStepRead(t *testing.T, path, name string, credentialTests []credentialTestFunc) logicaltest.TestStep {
return logicaltest.TestStep{ return logicaltest.TestStep{
Operation: logical.ReadOperation, Operation: logical.ReadOperation,

View File

@ -15,6 +15,7 @@ import (
"github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical"
) )
// NOTE: The caller is required to ensure that b.clientMutex is at least read locked
func getRootConfig(ctx context.Context, s logical.Storage, clientType string) (*aws.Config, error) { func getRootConfig(ctx context.Context, s logical.Storage, clientType string) (*aws.Config, error) {
credsConfig := &awsutil.CredentialsConfig{} credsConfig := &awsutil.CredentialsConfig{}
var endpoint string var endpoint string
@ -68,7 +69,7 @@ func getRootConfig(ctx context.Context, s logical.Storage, clientType string) (*
}, nil }, nil
} }
func clientIAM(ctx context.Context, s logical.Storage) (*iam.IAM, error) { func nonCachedClientIAM(ctx context.Context, s logical.Storage) (*iam.IAM, error) {
awsConfig, err := getRootConfig(ctx, s, "iam") awsConfig, err := getRootConfig(ctx, s, "iam")
if err != nil { if err != nil {
return nil, err return nil, err
@ -82,7 +83,7 @@ func clientIAM(ctx context.Context, s logical.Storage) (*iam.IAM, error) {
return client, nil return client, nil
} }
func clientSTS(ctx context.Context, s logical.Storage) (*sts.STS, error) { func nonCachedClientSTS(ctx context.Context, s logical.Storage) (*sts.STS, error) {
awsConfig, err := getRootConfig(ctx, s, "sts") awsConfig, err := getRootConfig(ctx, s, "sts")
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -56,6 +56,9 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
stsendpoint := data.Get("sts_endpoint").(string) stsendpoint := data.Get("sts_endpoint").(string)
maxretries := data.Get("max_retries").(int) maxretries := data.Get("max_retries").(int)
b.clientMutex.Lock()
defer b.clientMutex.Unlock()
entry, err := logical.StorageEntryJSON("config/root", rootConfig{ entry, err := logical.StorageEntryJSON("config/root", rootConfig{
AccessKey: data.Get("access_key").(string), AccessKey: data.Get("access_key").(string),
SecretKey: data.Get("secret_key").(string), SecretKey: data.Get("secret_key").(string),
@ -74,8 +77,6 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
// clear possible cached IAM / STS clients after successfully updating // clear possible cached IAM / STS clients after successfully updating
// config/root // config/root
b.clientMutex.Lock()
defer b.clientMutex.Unlock()
b.iamClient = nil b.iamClient = nil
b.stsClient = nil b.stsClient = nil

View File

@ -0,0 +1,124 @@
package aws
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
)
func pathConfigRotateRoot(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config/rotate-root",
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathConfigRotateRootUpdate,
},
HelpSynopsis: pathConfigRotateRootHelpSyn,
HelpDescription: pathConfigRotateRootHelpDesc,
}
}
func (b *backend) pathConfigRotateRootUpdate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
// have to get the client config first because that takes out a read lock
client, err := b.clientIAM(ctx, req.Storage)
if err != nil {
return nil, err
}
if client == nil {
return nil, fmt.Errorf("nil IAM client")
}
b.clientMutex.Lock()
defer b.clientMutex.Unlock()
rawRootConfig, err := req.Storage.Get(ctx, "config/root")
if err != nil {
return nil, err
}
if rawRootConfig == nil {
return nil, fmt.Errorf("no configuration found for config/root")
}
var config rootConfig
if err := rawRootConfig.DecodeJSON(&config); err != nil {
return nil, errwrap.Wrapf("error reading root configuration: {{err}}", err)
}
if config.AccessKey == "" || config.SecretKey == "" {
return logical.ErrorResponse("Cannot call config/rotate-root when either access_key or secret_key is empty"), nil
}
var getUserInput iam.GetUserInput // empty input means get current user
getUserRes, err := client.GetUser(&getUserInput)
if err != nil {
return nil, errwrap.Wrapf("error calling GetUser: {{err}}", err)
}
if getUserRes == nil {
return nil, fmt.Errorf("nil response from GetUser")
}
if getUserRes.User == nil {
return nil, fmt.Errorf("nil user returned from GetUser")
}
if getUserRes.User.UserName == nil {
return nil, fmt.Errorf("nil UserName returned from GetUser")
}
createAccessKeyInput := iam.CreateAccessKeyInput{
UserName: getUserRes.User.UserName,
}
createAccessKeyRes, err := client.CreateAccessKey(&createAccessKeyInput)
if err != nil {
return nil, errwrap.Wrapf("error calling CreateAccessKey: {{err}}", err)
}
if createAccessKeyRes.AccessKey == nil {
return nil, fmt.Errorf("nil response from CreateAccessKey")
}
if createAccessKeyRes.AccessKey.AccessKeyId == nil || createAccessKeyRes.AccessKey.SecretAccessKey == nil {
return nil, fmt.Errorf("nil AccessKeyId or SecretAccessKey returned from CreateAccessKey")
}
oldAccessKey := config.AccessKey
config.AccessKey = *createAccessKeyRes.AccessKey.AccessKeyId
config.SecretKey = *createAccessKeyRes.AccessKey.SecretAccessKey
newEntry, err := logical.StorageEntryJSON("config/root", config)
if err != nil {
return nil, errwrap.Wrapf("error generating new config/root JSON: {{err}}", err)
}
if err := req.Storage.Put(ctx, newEntry); err != nil {
return nil, errwrap.Wrapf("error saving new config/root: {{err}}", err)
}
b.iamClient = nil
b.stsClient = nil
deleteAccessKeyInput := iam.DeleteAccessKeyInput{
AccessKeyId: aws.String(oldAccessKey),
UserName: getUserRes.User.UserName,
}
_, err = client.DeleteAccessKey(&deleteAccessKeyInput)
if err != nil {
return nil, errwrap.Wrapf("error deleting old access key: {{err}}", err)
}
return &logical.Response{
Data: map[string]interface{}{
"access_key": config.AccessKey,
},
}, nil
}
const pathConfigRotateRootHelpSyn = `
Request to rotate the AWS credentials used by Vault
`
const pathConfigRotateRootHelpDesc = `
This path attempts to rotate the AWS credentials used by Vault for this mount.
It is only valid if Vault has been configured to use AWS IAM credentials via the
config/root endpoint.
`

View File

@ -111,7 +111,7 @@ func (b *backend) pathCredsRead(ctx context.Context, req *logical.Request, d *fr
} }
} }
func pathUserRollback(ctx context.Context, req *logical.Request, _kind string, data interface{}) error { func (b *backend) pathUserRollback(ctx context.Context, req *logical.Request, _kind string, data interface{}) error {
var entry walUser var entry walUser
if err := mapstructure.Decode(data, &entry); err != nil { if err := mapstructure.Decode(data, &entry); err != nil {
return err return err
@ -119,7 +119,7 @@ func pathUserRollback(ctx context.Context, req *logical.Request, _kind string, d
username := entry.UserName username := entry.UserName
// Get the client // Get the client
client, err := clientIAM(ctx, req.Storage) client, err := b.clientIAM(ctx, req.Storage)
if err != nil { if err != nil {
return err return err
} }

View File

@ -9,11 +9,11 @@ import (
"github.com/hashicorp/vault/logical/framework" "github.com/hashicorp/vault/logical/framework"
) )
var walRollbackMap = map[string]framework.WALRollbackFunc{
"user": pathUserRollback,
}
func (b *backend) walRollback(ctx context.Context, req *logical.Request, kind string, data interface{}) error { func (b *backend) walRollback(ctx context.Context, req *logical.Request, kind string, data interface{}) error {
walRollbackMap := map[string]framework.WALRollbackFunc{
"user": b.pathUserRollback,
}
if !b.System().LocalMount() && b.System().ReplicationState().HasState(consts.ReplicationPerformancePrimary) { if !b.System().LocalMount() && b.System().ReplicationState().HasState(consts.ReplicationPerformancePrimary) {
return nil return nil
} }

View File

@ -68,7 +68,6 @@ func genUsername(displayName, policyName, userType string) (ret string, warning
func (b *backend) secretTokenCreate(ctx context.Context, s logical.Storage, func (b *backend) secretTokenCreate(ctx context.Context, s logical.Storage,
displayName, policyName, policy string, displayName, policyName, policy string,
lifeTimeInSeconds int64) (*logical.Response, error) { lifeTimeInSeconds int64) (*logical.Response, error) {
stsClient, err := b.clientSTS(ctx, s) stsClient, err := b.clientSTS(ctx, s)
if err != nil { if err != nil {
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
@ -114,7 +113,6 @@ func (b *backend) secretTokenCreate(ctx context.Context, s logical.Storage,
func (b *backend) assumeRole(ctx context.Context, s logical.Storage, func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
displayName, roleName, roleArn, policy string, displayName, roleName, roleArn, policy string,
lifeTimeInSeconds int64) (*logical.Response, error) { lifeTimeInSeconds int64) (*logical.Response, error) {
stsClient, err := b.clientSTS(ctx, s) stsClient, err := b.clientSTS(ctx, s)
if err != nil { if err != nil {
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
@ -164,7 +162,6 @@ func (b *backend) secretAccessKeysCreate(
ctx context.Context, ctx context.Context,
s logical.Storage, s logical.Storage,
displayName, policyName string, role *awsRoleEntry) (*logical.Response, error) { displayName, policyName string, role *awsRoleEntry) (*logical.Response, error) {
iamClient, err := b.clientIAM(ctx, s) iamClient, err := b.clientIAM(ctx, s)
if err != nil { if err != nil {
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
@ -313,7 +310,7 @@ func (b *backend) secretAccessKeysRevoke(ctx context.Context, req *logical.Reque
} }
// Use the user rollback mechanism to delete this user // Use the user rollback mechanism to delete this user
err := pathUserRollback(ctx, req, "user", map[string]interface{}{ err := b.pathUserRollback(ctx, req, "user", map[string]interface{}{
"username": username, "username": username,
}) })
if err != nil { if err != nil {

View File

@ -80,6 +80,47 @@ $ curl \
http://127.0.0.1:8200/v1/aws/config/root http://127.0.0.1:8200/v1/aws/config/root
``` ```
## Rotate Root IAM Credentials
When you have configured Vault with static credentials, you can use this
endpoint to have Vault rotate the access key it used. Note that, due to AWS
eventual consistency, after calling this endpoint, subsequent calls from Vault
to AWS may fail for a few seconds until AWS becomes consistent again.
In order to call this endpoint, Vault's AWS access key MUST be the only access
key on the IAM user; otherwise, generation of a new access key will fail. Once
this method is called, Vault will now be the only entity that knows the AWS
secret key is used to access AWS.
| Method | Path | Produces |
| :------- | :--------------------------- | :--------------------- |
| `POST` | `/aws/config/rotate-root` | `200 application/json` |
### Parameters
There are no parameters to this operation.
### Sample Request
```$ curl \
--header "X-Vault-Token: ..." \
--request POST \
http://127.0.0.1:8211/v1/aws/config/rotate-root
```
### Sample Response
```json
{
"data": {
"access_key": "AKIA..."
}
}
```
The new access key Vault uses is returned by this operation.
## Configure Lease ## Configure Lease
This endpoint configures lease settings for the AWS secrets engine. It is This endpoint configures lease settings for the AWS secrets engine. It is