VAULT-12226: Add Static Roles to the AWS plugin (#20536)
Add static roles to the aws secrets engine --------- Co-authored-by: maxcoulombe <max.coulombe@hashicorp.com> Co-authored-by: vinay-gopalan <86625824+vinay-gopalan@users.noreply.github.com> Co-authored-by: Yoko Hyakuna <yoko@hashicorp.com>
This commit is contained in:
parent
a151ec76dd
commit
628c51516a
|
@ -12,7 +12,9 @@ import (
|
||||||
"github.com/aws/aws-sdk-go/service/iam/iamiface"
|
"github.com/aws/aws-sdk-go/service/iam/iamiface"
|
||||||
"github.com/aws/aws-sdk-go/service/sts/stsiface"
|
"github.com/aws/aws-sdk-go/service/sts/stsiface"
|
||||||
"github.com/hashicorp/vault/sdk/framework"
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/helper/consts"
|
||||||
"github.com/hashicorp/vault/sdk/logical"
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/hashicorp/vault/sdk/queue"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -23,15 +25,16 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
|
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
|
||||||
b := Backend()
|
b := Backend(conf)
|
||||||
if err := b.Setup(ctx, conf); err != nil {
|
if err := b.Setup(ctx, conf); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Backend() *backend {
|
func Backend(conf *logical.BackendConfig) *backend {
|
||||||
var b backend
|
var b backend
|
||||||
|
b.credRotationQueue = queue.New()
|
||||||
b.Backend = &framework.Backend{
|
b.Backend = &framework.Backend{
|
||||||
Help: strings.TrimSpace(backendHelp),
|
Help: strings.TrimSpace(backendHelp),
|
||||||
|
|
||||||
|
@ -40,7 +43,8 @@ func Backend() *backend {
|
||||||
framework.WALPrefix,
|
framework.WALPrefix,
|
||||||
},
|
},
|
||||||
SealWrapStorage: []string{
|
SealWrapStorage: []string{
|
||||||
"config/root",
|
rootConfigPath,
|
||||||
|
pathStaticCreds + "/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -50,6 +54,8 @@ func Backend() *backend {
|
||||||
pathConfigLease(&b),
|
pathConfigLease(&b),
|
||||||
pathRoles(&b),
|
pathRoles(&b),
|
||||||
pathListRoles(&b),
|
pathListRoles(&b),
|
||||||
|
pathStaticRoles(&b),
|
||||||
|
pathStaticCredentials(&b),
|
||||||
pathUser(&b),
|
pathUser(&b),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -60,7 +66,17 @@ func Backend() *backend {
|
||||||
Invalidate: b.invalidate,
|
Invalidate: b.invalidate,
|
||||||
WALRollback: b.walRollback,
|
WALRollback: b.walRollback,
|
||||||
WALRollbackMinAge: minAwsUserRollbackAge,
|
WALRollbackMinAge: minAwsUserRollbackAge,
|
||||||
BackendType: logical.TypeLogical,
|
PeriodicFunc: func(ctx context.Context, req *logical.Request) error {
|
||||||
|
repState := conf.System.ReplicationState()
|
||||||
|
if (conf.System.LocalMount() ||
|
||||||
|
!repState.HasState(consts.ReplicationPerformanceSecondary)) &&
|
||||||
|
!repState.HasState(consts.ReplicationDRSecondary) &&
|
||||||
|
!repState.HasState(consts.ReplicationPerformanceStandby) {
|
||||||
|
return b.rotateExpiredStaticCreds(ctx, req)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
BackendType: logical.TypeLogical,
|
||||||
}
|
}
|
||||||
|
|
||||||
return &b
|
return &b
|
||||||
|
@ -79,6 +95,10 @@ type backend struct {
|
||||||
// to enable mocking with AWS iface for tests
|
// to enable mocking with AWS iface for tests
|
||||||
iamClient iamiface.IAMAPI
|
iamClient iamiface.IAMAPI
|
||||||
stsClient stsiface.STSAPI
|
stsClient stsiface.STSAPI
|
||||||
|
|
||||||
|
// the age of a static role's credential is tracked by a priority queue and handled
|
||||||
|
// by the PeriodicFunc
|
||||||
|
credRotationQueue *queue.PriorityQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
const backendHelp = `
|
const backendHelp = `
|
||||||
|
|
|
@ -148,7 +148,7 @@ func TestBackend_throttled(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -141,7 +141,7 @@ func Test_getGroupPolicies(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ func TestBackend_PathConfigRoot(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ func TestBackend_PathListRoles(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -224,7 +224,7 @@ func TestRoleCRUDWithPermissionsBoundary(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -268,7 +268,7 @@ func TestRoleWithPermissionsBoundaryValidation(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathStaticCreds = "static-creds"
|
||||||
|
|
||||||
|
paramAccessKeyID = "access_key_id"
|
||||||
|
paramSecretsAccessKey = "secret_access_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
type awsCredentials struct {
|
||||||
|
AccessKeyID string `json:"access_key_id" structs:"access_key_id" mapstructure:"access_key_id"`
|
||||||
|
SecretAccessKey string `json:"secret_access_key" structs:"secret_access_key" mapstructure:"secret_access_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStaticCredentials(b *backend) *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: fmt.Sprintf("%s/%s", pathStaticCreds, framework.GenericNameWithAtRegex(paramRoleName)),
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
paramRoleName: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descRoleName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.ReadOperation: &framework.PathOperation{
|
||||||
|
Callback: b.pathStaticCredsRead,
|
||||||
|
Responses: map[int][]framework.Response{
|
||||||
|
http.StatusOK: {{
|
||||||
|
Description: http.StatusText(http.StatusOK),
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
paramAccessKeyID: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descAccessKeyID,
|
||||||
|
},
|
||||||
|
paramSecretsAccessKey: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descSecretAccessKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
HelpSynopsis: pathStaticCredsHelpSyn,
|
||||||
|
HelpDescription: pathStaticCredsHelpDesc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *backend) pathStaticCredsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
roleName, ok := data.GetOk(paramRoleName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing %q parameter", paramRoleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := req.Storage.Get(ctx, formatCredsStoragePath(roleName.(string)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read credentials for role %q: %w", roleName, err)
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials awsCredentials
|
||||||
|
if err := entry.DecodeJSON(&credentials); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: structs.New(credentials).Map(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCredsStoragePath(roleName string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", pathStaticCreds, roleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathStaticCredsHelpSyn = `Retrieve static credentials from the named role.`
|
||||||
|
|
||||||
|
const pathStaticCredsHelpDesc = `
|
||||||
|
This path reads AWS credentials for a certain static role. The keys are rotated
|
||||||
|
periodically according to their configuration, and will return the same password
|
||||||
|
until they are rotated.`
|
||||||
|
|
||||||
|
const (
|
||||||
|
descAccessKeyID = "The access key of the AWS Credential"
|
||||||
|
descSecretAccessKey = "The secret key of the AWS Credential"
|
||||||
|
)
|
|
@ -0,0 +1,92 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStaticCredsRead verifies that we can correctly read a cred that exists, and correctly _not read_
|
||||||
|
// a cred that does not exist.
|
||||||
|
func TestStaticCredsRead(t *testing.T) {
|
||||||
|
// setup
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
bgCTX := context.Background() // for brevity later
|
||||||
|
|
||||||
|
// insert a cred to get
|
||||||
|
creds := &awsCredentials{
|
||||||
|
AccessKeyID: "foo",
|
||||||
|
SecretAccessKey: "bar",
|
||||||
|
}
|
||||||
|
entry, err := logical.StorageEntryJSON(formatCredsStoragePath("test"), creds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = config.StorageView.Put(bgCTX, entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cases
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
roleName string
|
||||||
|
expectedError error
|
||||||
|
expectedResponse *logical.Response
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "get existing creds",
|
||||||
|
roleName: "test",
|
||||||
|
expectedResponse: &logical.Response{
|
||||||
|
Data: structs.New(creds).Map(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get non-existent creds",
|
||||||
|
roleName: "this-doesnt-exist",
|
||||||
|
// returns nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
b := Backend(config)
|
||||||
|
|
||||||
|
req := &logical.Request{
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"name": c.roleName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := b.pathStaticCredsRead(bgCTX, req, staticCredsFieldData(req.Data))
|
||||||
|
|
||||||
|
if err != c.expectedError {
|
||||||
|
t.Fatalf("got error %q, but expected %q", err, c.expectedError)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(resp, c.expectedResponse) {
|
||||||
|
t.Fatalf("got response %v, but expected %v", resp, c.expectedResponse)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticCredsFieldData(data map[string]interface{}) *framework.FieldData {
|
||||||
|
schema := map[string]*framework.FieldSchema{
|
||||||
|
paramRoleName: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descRoleName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &framework.FieldData{
|
||||||
|
Raw: data,
|
||||||
|
Schema: schema,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,331 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
"github.com/fatih/structs"
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/hashicorp/vault/sdk/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathStaticRole = "static-roles"
|
||||||
|
|
||||||
|
paramRoleName = "name"
|
||||||
|
paramUsername = "username"
|
||||||
|
paramRotationPeriod = "rotation_period"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staticRoleEntry struct {
|
||||||
|
Name string `json:"name" structs:"name" mapstructure:"name"`
|
||||||
|
ID string `json:"id" structs:"id" mapstructure:"id"`
|
||||||
|
Username string `json:"username" structs:"username" mapstructure:"username"`
|
||||||
|
RotationPeriod time.Duration `json:"rotation_period" structs:"rotation_period" mapstructure:"rotation_period"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStaticRoles(b *backend) *framework.Path {
|
||||||
|
roleResponse := map[int][]framework.Response{
|
||||||
|
http.StatusOK: {{
|
||||||
|
Description: http.StatusText(http.StatusOK),
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
paramRoleName: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descRoleName,
|
||||||
|
},
|
||||||
|
paramUsername: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descUsername,
|
||||||
|
},
|
||||||
|
paramRotationPeriod: {
|
||||||
|
Type: framework.TypeDurationSecond,
|
||||||
|
Description: descRotationPeriod,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: fmt.Sprintf("%s/%s", pathStaticRole, framework.GenericNameWithAtRegex(paramRoleName)),
|
||||||
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
paramRoleName: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descRoleName,
|
||||||
|
},
|
||||||
|
paramUsername: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descUsername,
|
||||||
|
},
|
||||||
|
paramRotationPeriod: {
|
||||||
|
Type: framework.TypeDurationSecond,
|
||||||
|
Description: descRotationPeriod,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.ReadOperation: &framework.PathOperation{
|
||||||
|
Callback: b.pathStaticRolesRead,
|
||||||
|
Responses: roleResponse,
|
||||||
|
},
|
||||||
|
logical.UpdateOperation: &framework.PathOperation{
|
||||||
|
Callback: b.pathStaticRolesWrite,
|
||||||
|
ForwardPerformanceSecondary: true,
|
||||||
|
ForwardPerformanceStandby: true,
|
||||||
|
Responses: roleResponse,
|
||||||
|
},
|
||||||
|
logical.DeleteOperation: &framework.PathOperation{
|
||||||
|
Callback: b.pathStaticRolesDelete,
|
||||||
|
ForwardPerformanceSecondary: true,
|
||||||
|
ForwardPerformanceStandby: true,
|
||||||
|
Responses: map[int][]framework.Response{
|
||||||
|
http.StatusNoContent: {{
|
||||||
|
Description: http.StatusText(http.StatusNoContent),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
HelpSynopsis: pathStaticRolesHelpSyn,
|
||||||
|
HelpDescription: pathStaticRolesHelpDesc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *backend) pathStaticRolesRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
roleName, ok := data.GetOk(paramRoleName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing %q parameter", paramRoleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.roleMutex.RLock()
|
||||||
|
defer b.roleMutex.RUnlock()
|
||||||
|
|
||||||
|
entry, err := req.Storage.Get(ctx, formatRoleStoragePath(roleName.(string)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read configuration for static role %q: %w", roleName, err)
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var config staticRoleEntry
|
||||||
|
if err := entry.DecodeJSON(&config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode configuration for static role %q: %w", roleName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: formatResponse(config),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *backend) pathStaticRolesWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
// Create & validate config from request parameters
|
||||||
|
config := staticRoleEntry{}
|
||||||
|
isCreate := req.Operation == logical.CreateOperation
|
||||||
|
|
||||||
|
if rawRoleName, ok := data.GetOk(paramRoleName); ok {
|
||||||
|
config.Name = rawRoleName.(string)
|
||||||
|
|
||||||
|
if err := b.validateRoleName(config.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return logical.ErrorResponse("missing %q parameter", paramRoleName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve old role value
|
||||||
|
entry, err := req.Storage.Get(ctx, formatRoleStoragePath(config.Name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't check storage for pre-existing role: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry != nil {
|
||||||
|
err = entry.DecodeJSON(&config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't convert existing role into config struct: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if we couldn't find an entry, this is a create event
|
||||||
|
isCreate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// other params are optional if we're not Creating
|
||||||
|
|
||||||
|
if rawUsername, ok := data.GetOk(paramUsername); ok {
|
||||||
|
config.Username = rawUsername.(string)
|
||||||
|
|
||||||
|
if err := b.validateIAMUserExists(ctx, req.Storage, &config, isCreate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if isCreate {
|
||||||
|
return logical.ErrorResponse("missing %q parameter", paramUsername), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawRotationPeriod, ok := data.GetOk(paramRotationPeriod); ok {
|
||||||
|
config.RotationPeriod = time.Duration(rawRotationPeriod.(int)) * time.Second
|
||||||
|
|
||||||
|
if err := b.validateRotationPeriod(config.RotationPeriod); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if isCreate {
|
||||||
|
return logical.ErrorResponse("missing %q parameter", paramRotationPeriod), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b.roleMutex.Lock()
|
||||||
|
defer b.roleMutex.Unlock()
|
||||||
|
|
||||||
|
// Upsert role config
|
||||||
|
newRole, err := logical.StorageEntryJSON(formatRoleStoragePath(config.Name), config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal object to JSON: %w", err)
|
||||||
|
}
|
||||||
|
err = req.Storage.Put(ctx, newRole)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save object in storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap initial set of keys if they did not exist before. AWS Secret Access Keys can only be obtained on creation,
|
||||||
|
// so we need to boostrap new roles with a new initial set of keys to be able to serve valid credentials to Vault clients.
|
||||||
|
existingCreds, err := req.Storage.Get(ctx, formatCredsStoragePath(config.Name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to verify if credentials already exist for role %q: %w", config.Name, err)
|
||||||
|
}
|
||||||
|
if existingCreds == nil {
|
||||||
|
err := b.createCredential(ctx, req.Storage, config, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create new credentials for role %q: %w", config.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.credRotationQueue.Push(&queue.Item{
|
||||||
|
Key: config.Name,
|
||||||
|
Value: config,
|
||||||
|
Priority: time.Now().Add(config.RotationPeriod).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add item into the rotation queue for role %q: %w", config.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: formatResponse(config),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *backend) pathStaticRolesDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||||
|
roleName, ok := data.GetOk(paramRoleName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing %q parameter", paramRoleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.roleMutex.Lock()
|
||||||
|
defer b.roleMutex.Unlock()
|
||||||
|
|
||||||
|
entry, err := req.Storage.Get(ctx, formatRoleStoragePath(roleName.(string)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't locate role in storage due to error: %w", err)
|
||||||
|
}
|
||||||
|
// no entry in storage, but no error either, congrats, it's deleted!
|
||||||
|
if entry == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var cfg staticRoleEntry
|
||||||
|
err = entry.DecodeJSON(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't convert storage entry to role config")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.deleteCredential(ctx, req.Storage, cfg, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to clean credentials while deleting role %q: %w", roleName.(string), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete from the queue
|
||||||
|
_, err = b.credRotationQueue.PopByKey(cfg.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't delete key from queue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, req.Storage.Delete(ctx, formatRoleStoragePath(roleName.(string)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *backend) validateRoleName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("empty role name attribute given")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateIAMUser checks the user information we have for the role against the information on AWS. On a create, it uses the username
|
||||||
|
// to retrieve the user information and _sets_ the userID. On update, it validates the userID and username.
|
||||||
|
func (b *backend) validateIAMUserExists(ctx context.Context, storage logical.Storage, entry *staticRoleEntry, isCreate bool) error {
|
||||||
|
c, err := b.clientIAM(ctx, storage)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to validate username %q: %w", entry.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// we don't really care about the content of the result, just that it's not an error
|
||||||
|
out, err := c.GetUser(&iam.GetUserInput{
|
||||||
|
UserName: aws.String(entry.Username),
|
||||||
|
})
|
||||||
|
if err != nil || out.User == nil {
|
||||||
|
return fmt.Errorf("unable to validate username %q: %w", entry.Username, err)
|
||||||
|
}
|
||||||
|
if *out.User.UserName != entry.Username {
|
||||||
|
return fmt.Errorf("AWS GetUser returned a username, but it didn't match: %q was requested, but %q was returned", entry.Username, *out.User.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isCreate && *out.User.UserId != entry.ID {
|
||||||
|
return fmt.Errorf("AWS GetUser returned a user, but the ID did not match: %q was requested, but %q was returned", entry.ID, *out.User.UserId)
|
||||||
|
} else {
|
||||||
|
// if this is an insert, store the userID. This is the immutable part of an IAM user, but it's not exactly user-friendly.
|
||||||
|
// So, we allow users to specify usernames, but on updates we'll use the ID as a verification cross-check.
|
||||||
|
entry.ID = *out.User.UserId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
minAllowableRotationPeriod = 1 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *backend) validateRotationPeriod(period time.Duration) error {
|
||||||
|
if period < minAllowableRotationPeriod {
|
||||||
|
return fmt.Errorf("role rotation period out of range: must be greater than %.2f seconds", minAllowableRotationPeriod.Seconds())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatResponse(cfg staticRoleEntry) map[string]interface{} {
|
||||||
|
response := structs.New(cfg).Map()
|
||||||
|
response[paramRotationPeriod] = int64(cfg.RotationPeriod.Seconds())
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRoleStoragePath(roleName string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", pathStaticRole, roleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathStaticRolesHelpSyn = `
|
||||||
|
Manage static roles for AWS.
|
||||||
|
`
|
||||||
|
|
||||||
|
const pathStaticRolesHelpDesc = `
|
||||||
|
This path lets you manage static roles (users) for the AWS secret backend.
|
||||||
|
A static role is associated with a single IAM user, and manages the access
|
||||||
|
keys based on a rotation period, automatically rotating the credential. If
|
||||||
|
the IAM user has multiple access keys, the oldest key will be rotated.
|
||||||
|
`
|
||||||
|
|
||||||
|
const (
|
||||||
|
descRoleName = "The name of this role."
|
||||||
|
descUsername = "The IAM user to adopt as a static role."
|
||||||
|
descRotationPeriod = `Period by which to rotate the backing credential of the adopted user.
|
||||||
|
This can be a Go duration (e.g, '1m', 24h'), or an integer number of seconds.`
|
||||||
|
)
|
|
@ -0,0 +1,490 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/sdk/queue"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"github.com/hashicorp/vault/sdk/framework"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStaticRolesValidation verifies that valid requests pass validation and that invalid requests fail validation.
|
||||||
|
// This includes the user already existing in IAM roles, and the rotation period being sufficiently long.
|
||||||
|
func TestStaticRolesValidation(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
bgCTX := context.Background() // for brevity
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
opts []awsutil.MockIAMOption
|
||||||
|
requestData map[string]interface{}
|
||||||
|
isError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all good",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{User: &iam.User{UserName: aws.String("jane-doe"), UserId: aws.String("unique-id")}}),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("abcdefghijklmnopqrstuvwxyz"),
|
||||||
|
SecretAccessKey: aws.String("zyxwvutsrqponmlkjihgfedcba"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
requestData: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "jane-doe",
|
||||||
|
"rotation_period": "1d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad user",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserError(errors.New("oh no")),
|
||||||
|
},
|
||||||
|
requestData: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "jane-doe",
|
||||||
|
"rotation_period": "24h",
|
||||||
|
},
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user mismatch",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{User: &iam.User{UserName: aws.String("ms-impostor"), UserId: aws.String("fake-id")}}),
|
||||||
|
},
|
||||||
|
requestData: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "jane-doe",
|
||||||
|
"rotation_period": "1d2h",
|
||||||
|
},
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad rotation period",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{User: &iam.User{UserName: aws.String("jane-doe"), UserId: aws.String("unique-id")}}),
|
||||||
|
},
|
||||||
|
requestData: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "jane-doe",
|
||||||
|
"rotation_period": "45s",
|
||||||
|
},
|
||||||
|
isError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
b := Backend(config)
|
||||||
|
miam, err := awsutil.NewMockIAM(c.opts...)(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.iamClient = miam
|
||||||
|
if err := b.Setup(bgCTX, config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := &logical.Request{
|
||||||
|
Operation: logical.UpdateOperation,
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: c.requestData,
|
||||||
|
Path: "static-roles/test",
|
||||||
|
}
|
||||||
|
_, err = b.pathStaticRolesWrite(bgCTX, req, staticRoleFieldData(req.Data))
|
||||||
|
if c.isError && err == nil {
|
||||||
|
t.Fatal("expected an error but didn't get one")
|
||||||
|
} else if !c.isError && err != nil {
|
||||||
|
t.Fatalf("got an unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticRolesWrite validates that we can write a new entry for a new static role, and that we correctly
|
||||||
|
// do not write if the request is invalid in some way.
|
||||||
|
func TestStaticRolesWrite(t *testing.T) {
|
||||||
|
bgCTX := context.Background()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
opts []awsutil.MockIAMOption
|
||||||
|
data map[string]interface{}
|
||||||
|
expectedError bool
|
||||||
|
findUser bool
|
||||||
|
isUpdate bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "happy path",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{User: &iam.User{UserName: aws.String("jane-doe"), UserId: aws.String("unique-id")}}),
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("abcdefghijklmnopqrstuvwxyz"),
|
||||||
|
SecretAccessKey: aws.String("zyxwvutsrqponmlkjihgfedcba"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
data: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "jane-doe",
|
||||||
|
"rotation_period": "1d",
|
||||||
|
},
|
||||||
|
// writes role, writes cred
|
||||||
|
findUser: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no aws user",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserError(errors.New("no such user, etc etc")),
|
||||||
|
},
|
||||||
|
data: map[string]interface{}{
|
||||||
|
"name": "test",
|
||||||
|
"username": "a-nony-mous",
|
||||||
|
"rotation_period": "15s",
|
||||||
|
},
|
||||||
|
expectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update existing user",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{User: &iam.User{UserName: aws.String("john-doe"), UserId: aws.String("unique-id")}}),
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{},
|
||||||
|
IsTruncated: aws.Bool(false),
|
||||||
|
}),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("abcdefghijklmnopqrstuvwxyz"),
|
||||||
|
SecretAccessKey: aws.String("zyxwvutsrqponmlkjihgfedcba"),
|
||||||
|
UserName: aws.String("john-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
data: map[string]interface{}{
|
||||||
|
"name": "johnny",
|
||||||
|
"rotation_period": "19m",
|
||||||
|
},
|
||||||
|
findUser: true,
|
||||||
|
isUpdate: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// if a user exists (user doesn't exist is tested in validation)
|
||||||
|
// we'll check how many keys the user has - if it's two, we delete one.
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
|
miam, err := awsutil.NewMockIAM(
|
||||||
|
c.opts...,
|
||||||
|
)(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Backend(config)
|
||||||
|
b.iamClient = miam
|
||||||
|
if err := b.Setup(bgCTX, config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// put a role in storage for update tests
|
||||||
|
staticRole := staticRoleEntry{
|
||||||
|
Name: "johnny",
|
||||||
|
Username: "john-doe",
|
||||||
|
ID: "unique-id",
|
||||||
|
RotationPeriod: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
entry, err := logical.StorageEntryJSON(formatRoleStoragePath(staticRole.Name), staticRole)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = config.StorageView.Put(bgCTX, entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &logical.Request{
|
||||||
|
Operation: logical.UpdateOperation,
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: c.data,
|
||||||
|
Path: "static-roles/" + c.data["name"].(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := b.pathStaticRolesWrite(bgCTX, req, staticRoleFieldData(req.Data))
|
||||||
|
if c.expectedError && err == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if c.expectedError {
|
||||||
|
return // save us some if statements
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got an error back unexpectedly: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.findUser && r == nil {
|
||||||
|
t.Fatal("response was nil, but it shouldn't have been")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := config.StorageView.Get(bgCTX, req.Path)
|
||||||
|
if c.findUser && (err != nil || role == nil) {
|
||||||
|
t.Fatalf("couldn't find the role we should have stored: %s", err)
|
||||||
|
}
|
||||||
|
var actualData staticRoleEntry
|
||||||
|
err = role.DecodeJSON(&actualData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't convert storage data to role entry: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// construct expected data
|
||||||
|
var expectedData staticRoleEntry
|
||||||
|
fieldData := staticRoleFieldData(c.data)
|
||||||
|
if c.isUpdate {
|
||||||
|
// data is johnny + c.data
|
||||||
|
expectedData = staticRole
|
||||||
|
}
|
||||||
|
|
||||||
|
if u, ok := fieldData.GetOk("username"); ok {
|
||||||
|
expectedData.Username = u.(string)
|
||||||
|
}
|
||||||
|
if r, ok := fieldData.GetOk("rotation_period"); ok {
|
||||||
|
expectedData.RotationPeriod = time.Duration(r.(int)) * time.Second
|
||||||
|
}
|
||||||
|
if n, ok := fieldData.GetOk("name"); ok {
|
||||||
|
expectedData.Name = n.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate fields
|
||||||
|
if eu, au := expectedData.Username, actualData.Username; eu != au {
|
||||||
|
t.Fatalf("mismatched username, expected %q but got %q", eu, au)
|
||||||
|
}
|
||||||
|
if er, ar := expectedData.RotationPeriod, actualData.RotationPeriod; er != ar {
|
||||||
|
t.Fatalf("mismatched rotation period, expected %q but got %q", er, ar)
|
||||||
|
}
|
||||||
|
if en, an := expectedData.Name, actualData.Name; en != an {
|
||||||
|
t.Fatalf("mismatched role name, expected %q, but got %q", en, an)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticRoleRead validates that we can read a configured role and correctly do not read anything if we
|
||||||
|
// request something that doesn't exist.
|
||||||
|
func TestStaticRoleRead(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
bgCTX := context.Background()
|
||||||
|
|
||||||
|
// test cases are run against an inmem storage holding a role called "test" attached to an IAM user called "jane-doe"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
roleName string
|
||||||
|
found bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "role name exists",
|
||||||
|
roleName: "test",
|
||||||
|
found: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "role name not found",
|
||||||
|
roleName: "toast",
|
||||||
|
found: false, // implied, but set for clarity
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
staticRole := staticRoleEntry{
|
||||||
|
Name: "test",
|
||||||
|
Username: "jane-doe",
|
||||||
|
RotationPeriod: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
entry, err := logical.StorageEntryJSON(formatRoleStoragePath(staticRole.Name), staticRole)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = config.StorageView.Put(bgCTX, entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
req := &logical.Request{
|
||||||
|
Operation: logical.ReadOperation,
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"name": c.roleName,
|
||||||
|
},
|
||||||
|
Path: formatRoleStoragePath(c.roleName),
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Backend(config)
|
||||||
|
|
||||||
|
r, err := b.pathStaticRolesRead(bgCTX, req, staticRoleFieldData(req.Data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.found {
|
||||||
|
if r == nil {
|
||||||
|
t.Fatal("response was nil, but it shouldn't have been")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if r != nil {
|
||||||
|
t.Fatal("response should have been nil on a non-existent role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticRoleDelete validates that we correctly remove a role on a delete request, and that we correctly do not
|
||||||
|
// remove anything if a role does not exist with that name.
|
||||||
|
func TestStaticRoleDelete(t *testing.T) {
|
||||||
|
bgCTX := context.Background()
|
||||||
|
|
||||||
|
// test cases are run against an inmem storage holding a role called "test" attached to an IAM user called "jane-doe"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
role string
|
||||||
|
found bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "role found",
|
||||||
|
role: "test",
|
||||||
|
found: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "role not found",
|
||||||
|
role: "tossed",
|
||||||
|
found: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
|
// fake an IAM
|
||||||
|
var iamfunc awsutil.IAMAPIFunc
|
||||||
|
if !c.found {
|
||||||
|
iamfunc = awsutil.NewMockIAM(awsutil.WithDeleteAccessKeyError(errors.New("shouldn't have called delete")))
|
||||||
|
} else {
|
||||||
|
iamfunc = awsutil.NewMockIAM()
|
||||||
|
}
|
||||||
|
miam, err := iamfunc(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't initialize mockiam: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Backend(config)
|
||||||
|
b.iamClient = miam
|
||||||
|
|
||||||
|
// put in storage
|
||||||
|
staticRole := staticRoleEntry{
|
||||||
|
Name: "test",
|
||||||
|
Username: "jane-doe",
|
||||||
|
RotationPeriod: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
entry, err := logical.StorageEntryJSON(formatRoleStoragePath(staticRole.Name), staticRole)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = config.StorageView.Put(bgCTX, entry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := config.StorageView.List(bgCTX, "")
|
||||||
|
if err != nil || len(l) != 1 {
|
||||||
|
t.Fatalf("couldn't add an entry to storage during test setup: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// put in queue
|
||||||
|
err = b.credRotationQueue.Push(&queue.Item{
|
||||||
|
Key: staticRole.Name,
|
||||||
|
Value: staticRole,
|
||||||
|
Priority: time.Now().Add(90 * time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't add items to pq")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &logical.Request{
|
||||||
|
Operation: logical.ReadOperation,
|
||||||
|
Storage: config.StorageView,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"name": c.role,
|
||||||
|
},
|
||||||
|
Path: formatRoleStoragePath(c.role),
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := b.pathStaticRolesDelete(bgCTX, req, staticRoleFieldData(req.Data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
t.Fatal("response wasn't nil, but it should have been")
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err = config.StorageView.List(bgCTX, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.found && len(l) != 0 {
|
||||||
|
t.Fatal("size of role storage is non zero after delete")
|
||||||
|
} else if !c.found && len(l) != 1 {
|
||||||
|
t.Fatal("size of role storage changed after what should have been no deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.found && b.credRotationQueue.Len() != 0 {
|
||||||
|
t.Fatal("size of queue is non-zero after delete")
|
||||||
|
} else if !c.found && b.credRotationQueue.Len() != 1 {
|
||||||
|
t.Fatal("size of queue changed after what should have been no deletion")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticRoleFieldData(data map[string]interface{}) *framework.FieldData {
|
||||||
|
schema := map[string]*framework.FieldSchema{
|
||||||
|
paramRoleName: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descRoleName,
|
||||||
|
},
|
||||||
|
paramUsername: {
|
||||||
|
Type: framework.TypeString,
|
||||||
|
Description: descUsername,
|
||||||
|
},
|
||||||
|
paramRotationPeriod: {
|
||||||
|
Type: framework.TypeDurationSecond,
|
||||||
|
Description: descRotationPeriod,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &framework.FieldData{
|
||||||
|
Raw: data,
|
||||||
|
Schema: schema,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,188 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/hashicorp/vault/sdk/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rotateExpiredStaticCreds will pop expired credentials (credentials whose priority
|
||||||
|
// represents a time before the present), rotate the associated credential, and push
|
||||||
|
// them back onto the queue with the new priority.
|
||||||
|
func (b *backend) rotateExpiredStaticCreds(ctx context.Context, req *logical.Request) error {
|
||||||
|
var errs *multierror.Error
|
||||||
|
|
||||||
|
for {
|
||||||
|
keepGoing, err := b.rotateCredential(ctx, req.Storage)
|
||||||
|
if err != nil {
|
||||||
|
errs = multierror.Append(errs, err)
|
||||||
|
}
|
||||||
|
if !keepGoing {
|
||||||
|
if errs.ErrorOrNil() != nil {
|
||||||
|
return fmt.Errorf("error(s) occurred while rotating expired static credentials: %w", errs)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotateCredential pops an element from the priority queue, and if it is expired, rotate and re-push.
|
||||||
|
// If a cred was rotated, it returns true, otherwise false.
|
||||||
|
func (b *backend) rotateCredential(ctx context.Context, storage logical.Storage) (rotated bool, err error) {
|
||||||
|
// If queue is empty or first item does not need a rotation (priority is next rotation timestamp) there is nothing to do
|
||||||
|
item, err := b.credRotationQueue.Pop()
|
||||||
|
if err != nil {
|
||||||
|
// the queue is just empty, which is fine.
|
||||||
|
if err == queue.ErrEmpty {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("failed to pop from queue for role %q: %w", item.Key, err)
|
||||||
|
}
|
||||||
|
if item.Priority > time.Now().Unix() {
|
||||||
|
// no rotation required
|
||||||
|
// push the item back into priority queue
|
||||||
|
err = b.credRotationQueue.Push(item)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to add item into the rotation queue for role %q: %w", item.Key, err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := item.Value.(staticRoleEntry)
|
||||||
|
|
||||||
|
err = b.createCredential(ctx, storage, cfg, true)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// set new priority and re-queue
|
||||||
|
item.Priority = time.Now().Add(cfg.RotationPeriod).Unix()
|
||||||
|
err = b.credRotationQueue.Push(item)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to add item into the rotation queue for role %q: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCredential will create a new iam credential, deleting the oldest one if necessary.
|
||||||
|
func (b *backend) createCredential(ctx context.Context, storage logical.Storage, cfg staticRoleEntry, shouldLockStorage bool) error {
|
||||||
|
iamClient, err := b.clientIAM(ctx, storage)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to get the AWS IAM client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IAM users can have a most 2 sets of keys at a time.
|
||||||
|
// (https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html)
|
||||||
|
// Ideally we would get this value through an api check, but I'm not sure one exists.
|
||||||
|
const maxAllowedKeys = 2
|
||||||
|
|
||||||
|
err = b.validateIAMUserExists(ctx, storage, &cfg, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("iam user didn't exist, or username/userid didn't match: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKeys, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{
|
||||||
|
UserName: aws.String(cfg.Username),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to list existing access keys for IAM user %q: %w", cfg.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have the maximum number of keys, we have to delete one to make another (so we can get the credentials).
|
||||||
|
// We'll delete the oldest one.
|
||||||
|
//
|
||||||
|
// Since this check relies on a pre-coded maximum, it's a bit fragile. If the number goes up, we risk deleting
|
||||||
|
// a key when we didn't need to. If this number goes down, we'll start throwing errors because we think we're
|
||||||
|
// allowed to create a key and aren't. In either case, adjusting the constant should be sufficient to fix things.
|
||||||
|
if len(accessKeys.AccessKeyMetadata) >= maxAllowedKeys {
|
||||||
|
oldestKey := accessKeys.AccessKeyMetadata[0]
|
||||||
|
|
||||||
|
for i := 1; i < len(accessKeys.AccessKeyMetadata); i++ {
|
||||||
|
if accessKeys.AccessKeyMetadata[i].CreateDate.Before(*oldestKey.CreateDate) {
|
||||||
|
oldestKey = accessKeys.AccessKeyMetadata[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||||
|
AccessKeyId: oldestKey.AccessKeyId,
|
||||||
|
UserName: oldestKey.UserName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to delete oldest access keys for user %q: %w", cfg.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new set of keys
|
||||||
|
out, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
||||||
|
UserName: aws.String(cfg.Username),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create new access keys for user %q: %w", cfg.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist new keys
|
||||||
|
entry, err := logical.StorageEntryJSON(formatCredsStoragePath(cfg.Name), &awsCredentials{
|
||||||
|
AccessKeyID: *out.AccessKey.AccessKeyId,
|
||||||
|
SecretAccessKey: *out.AccessKey.SecretAccessKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal object to JSON: %w", err)
|
||||||
|
}
|
||||||
|
if shouldLockStorage {
|
||||||
|
b.roleMutex.Lock()
|
||||||
|
defer b.roleMutex.Unlock()
|
||||||
|
}
|
||||||
|
err = storage.Put(ctx, entry)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save object in storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete credential will remove the credential associated with the role from storage.
|
||||||
|
func (b *backend) deleteCredential(ctx context.Context, storage logical.Storage, cfg staticRoleEntry, shouldLockStorage bool) error {
|
||||||
|
// synchronize storage access if we didn't in the caller.
|
||||||
|
if shouldLockStorage {
|
||||||
|
b.roleMutex.Lock()
|
||||||
|
defer b.roleMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := storage.Get(ctx, formatCredsStoragePath(cfg.Name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't find key in storage: %w", err)
|
||||||
|
}
|
||||||
|
// no entry, so i guess we deleted it already
|
||||||
|
if key == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var creds awsCredentials
|
||||||
|
err = key.DecodeJSON(&creds)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't decode storage entry to a valid credential: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = storage.Delete(ctx, formatCredsStoragePath(cfg.Name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't delete from storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// because we have the information, this is the one we created, so it's safe for us to delete.
|
||||||
|
_, err = b.iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
||||||
|
AccessKeyId: aws.String(creds.AccessKeyID),
|
||||||
|
UserName: aws.String(cfg.Username),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't delete from IAM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,348 @@
|
||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam/iamiface"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/hashicorp/vault/sdk/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRotation verifies that the rotation code and priority queue correctly selects and rotates credentials
|
||||||
|
// for static secrets.
|
||||||
|
func TestRotation(t *testing.T) {
|
||||||
|
bgCTX := context.Background()
|
||||||
|
|
||||||
|
type credToInsert struct {
|
||||||
|
config staticRoleEntry // role configuration from a normal createRole request
|
||||||
|
age time.Duration // how old the cred should be - if this is longer than the config.RotationPeriod,
|
||||||
|
// the cred is 'pre-expired'
|
||||||
|
|
||||||
|
changed bool // whether we expect the cred to change - this is technically redundant to a comparison between
|
||||||
|
// rotationPeriod and age.
|
||||||
|
}
|
||||||
|
|
||||||
|
// due to a limitation with the mockIAM implementation, any cred you want to rotate must have
|
||||||
|
// username jane-doe and userid unique-id, since we can only pre-can one exact response to GetUser
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
creds []credToInsert
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "refresh one",
|
||||||
|
creds: []credToInsert{
|
||||||
|
{
|
||||||
|
config: staticRoleEntry{
|
||||||
|
Name: "test",
|
||||||
|
Username: "jane-doe",
|
||||||
|
ID: "unique-id",
|
||||||
|
RotationPeriod: 2 * time.Second,
|
||||||
|
},
|
||||||
|
age: 5 * time.Second,
|
||||||
|
changed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "refresh none",
|
||||||
|
creds: []credToInsert{
|
||||||
|
{
|
||||||
|
config: staticRoleEntry{
|
||||||
|
Name: "test",
|
||||||
|
Username: "jane-doe",
|
||||||
|
ID: "unique-id",
|
||||||
|
RotationPeriod: 1 * time.Minute,
|
||||||
|
},
|
||||||
|
age: 5 * time.Second,
|
||||||
|
changed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "refresh one of two",
|
||||||
|
creds: []credToInsert{
|
||||||
|
{
|
||||||
|
config: staticRoleEntry{
|
||||||
|
Name: "toast",
|
||||||
|
Username: "john-doe",
|
||||||
|
ID: "other-id",
|
||||||
|
RotationPeriod: 1 * time.Minute,
|
||||||
|
},
|
||||||
|
age: 5 * time.Second,
|
||||||
|
changed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: staticRoleEntry{
|
||||||
|
Name: "test",
|
||||||
|
Username: "jane-doe",
|
||||||
|
ID: "unique-id",
|
||||||
|
RotationPeriod: 1 * time.Second,
|
||||||
|
},
|
||||||
|
age: 5 * time.Second,
|
||||||
|
changed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no creds to rotate",
|
||||||
|
creds: []credToInsert{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ak := "long-access-key-id"
|
||||||
|
oldSecret := "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
newSecret := "zyxwvutsrqponmlkjihgfedcba"
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
|
b := Backend(config)
|
||||||
|
|
||||||
|
// insert all our creds
|
||||||
|
for i, cred := range c.creds {
|
||||||
|
|
||||||
|
// all the creds will be the same for every user, but that's okay
|
||||||
|
// since what we care about is whether they changed on a single-user basis.
|
||||||
|
miam, err := awsutil.NewMockIAM(
|
||||||
|
// blank list for existing user
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{
|
||||||
|
{},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// initial key to store
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String(ak),
|
||||||
|
SecretAccessKey: aws.String(oldSecret),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
UserId: aws.String(cred.config.ID),
|
||||||
|
UserName: aws.String(cred.config.Username),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't initialze mock IAM handler: %s", err)
|
||||||
|
}
|
||||||
|
b.iamClient = miam
|
||||||
|
|
||||||
|
err = b.createCredential(bgCTX, config.StorageView, cred.config, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't insert credential %d: %s", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &queue.Item{
|
||||||
|
Key: cred.config.Name,
|
||||||
|
Value: cred.config,
|
||||||
|
Priority: time.Now().Add(-1 * cred.age).Add(cred.config.RotationPeriod).Unix(),
|
||||||
|
}
|
||||||
|
err = b.credRotationQueue.Push(item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't push item onto queue: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update aws responses, same argument for why it's okay every cred will be the same
|
||||||
|
miam, err := awsutil.NewMockIAM(
|
||||||
|
// old key
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{
|
||||||
|
{
|
||||||
|
AccessKeyId: aws.String(ak),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// new key
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String(ak),
|
||||||
|
SecretAccessKey: aws.String(newSecret),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
UserId: aws.String("unique-id"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't initialze mock IAM handler: %s", err)
|
||||||
|
}
|
||||||
|
b.iamClient = miam
|
||||||
|
|
||||||
|
req := &logical.Request{
|
||||||
|
Storage: config.StorageView,
|
||||||
|
}
|
||||||
|
err = b.rotateExpiredStaticCreds(bgCTX, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got an error rotating credentials: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check our credentials
|
||||||
|
for i, cred := range c.creds {
|
||||||
|
entry, err := config.StorageView.Get(bgCTX, formatCredsStoragePath(cred.config.Name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got an error retrieving credentials %d", i)
|
||||||
|
}
|
||||||
|
var out awsCredentials
|
||||||
|
err = entry.DecodeJSON(&out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not unmarshal storage view entry for cred %d to an aws credential: %s", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.changed && out.SecretAccessKey != newSecret {
|
||||||
|
t.Fatalf("expected the key for cred %d to have changed, but it hasn't", i)
|
||||||
|
} else if !cred.changed && out.SecretAccessKey != oldSecret {
|
||||||
|
t.Fatalf("expected the key for cred %d to have stayed the same, but it changed", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeIAM struct {
|
||||||
|
iamiface.IAMAPI
|
||||||
|
delReqs []*iam.DeleteAccessKeyInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIAM) DeleteAccessKey(r *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
|
||||||
|
f.delReqs = append(f.delReqs, r)
|
||||||
|
return f.IAMAPI.DeleteAccessKey(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateCredential verifies that credential creation firstly only deletes credentials if it needs to (i.e., two
|
||||||
|
// or more credentials on IAM), and secondly correctly deletes the oldest one.
|
||||||
|
func TestCreateCredential(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
username string
|
||||||
|
id string
|
||||||
|
deletedKey string
|
||||||
|
opts []awsutil.MockIAMOption
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zero keys",
|
||||||
|
username: "jane-doe",
|
||||||
|
id: "unique-id",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{},
|
||||||
|
}),
|
||||||
|
// delete should _not_ be called
|
||||||
|
awsutil.WithDeleteAccessKeyError(errors.New("should not have been called")),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("key"),
|
||||||
|
SecretAccessKey: aws.String("itsasecret"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
UserId: aws.String("unique-id"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one key",
|
||||||
|
username: "jane-doe",
|
||||||
|
id: "unique-id",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{
|
||||||
|
{AccessKeyId: aws.String("foo"), CreateDate: aws.Time(time.Now())},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// delete should _not_ be called
|
||||||
|
awsutil.WithDeleteAccessKeyError(errors.New("should not have been called")),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("key"),
|
||||||
|
SecretAccessKey: aws.String("itsasecret"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
UserId: aws.String("unique-id"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two keys",
|
||||||
|
username: "jane-doe",
|
||||||
|
id: "unique-id",
|
||||||
|
deletedKey: "foo",
|
||||||
|
opts: []awsutil.MockIAMOption{
|
||||||
|
awsutil.WithListAccessKeysOutput(&iam.ListAccessKeysOutput{
|
||||||
|
AccessKeyMetadata: []*iam.AccessKeyMetadata{
|
||||||
|
{AccessKeyId: aws.String("foo"), CreateDate: aws.Time(time.Time{})},
|
||||||
|
{AccessKeyId: aws.String("bar"), CreateDate: aws.Time(time.Now())},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("key"),
|
||||||
|
SecretAccessKey: aws.String("itsasecret"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
awsutil.WithGetUserOutput(&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
UserId: aws.String("unique-id"),
|
||||||
|
UserName: aws.String("jane-doe"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := logical.TestBackendConfig()
|
||||||
|
config.StorageView = &logical.InmemStorage{}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
miam, err := awsutil.NewMockIAM(
|
||||||
|
c.opts...,
|
||||||
|
)(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fiam := &fakeIAM{
|
||||||
|
IAMAPI: miam,
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Backend(config)
|
||||||
|
b.iamClient = fiam
|
||||||
|
|
||||||
|
err = b.createCredential(context.Background(), config.StorageView, staticRoleEntry{Username: c.username, ID: c.id}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got an error we didn't expect: %q", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.deletedKey != "" {
|
||||||
|
if len(fiam.delReqs) != 1 {
|
||||||
|
t.Fatalf("called the wrong number of deletes (called %d deletes)", len(fiam.delReqs))
|
||||||
|
}
|
||||||
|
actualKey := *fiam.delReqs[0].AccessKeyId
|
||||||
|
if c.deletedKey != actualKey {
|
||||||
|
t.Fatalf("we deleted the wrong key: %q instead of %q", actualKey, c.deletedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -120,7 +120,7 @@ func TestGenUsername(t *testing.T) {
|
||||||
func TestReadConfig_DefaultTemplate(t *testing.T) {
|
func TestReadConfig_DefaultTemplate(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -164,7 +164,7 @@ func TestReadConfig_DefaultTemplate(t *testing.T) {
|
||||||
func TestReadConfig_CustomTemplate(t *testing.T) {
|
func TestReadConfig_CustomTemplate(t *testing.T) {
|
||||||
config := logical.TestBackendConfig()
|
config := logical.TestBackendConfig()
|
||||||
config.StorageView = &logical.InmemStorage{}
|
config.StorageView = &logical.InmemStorage{}
|
||||||
b := Backend()
|
b := Backend(config)
|
||||||
if err := b.Setup(context.Background(), config); err != nil {
|
if err := b.Setup(context.Background(), config); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:feature
|
||||||
|
**AWS Static Roles**: The AWS Secrets Engine can manage static roles configured by users.
|
||||||
|
```
|
2
go.mod
2
go.mod
|
@ -94,7 +94,7 @@ require (
|
||||||
github.com/hashicorp/go-raftchunking v0.6.3-0.20191002164813-7e9e8525653a
|
github.com/hashicorp/go-raftchunking v0.6.3-0.20191002164813-7e9e8525653a
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.2
|
github.com/hashicorp/go-retryablehttp v0.7.2
|
||||||
github.com/hashicorp/go-rootcerts v1.0.2
|
github.com/hashicorp/go-rootcerts v1.0.2
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6
|
github.com/hashicorp/go-secure-stdlib/awsutil v0.2.0
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
|
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
|
||||||
github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1
|
github.com/hashicorp/go-secure-stdlib/gatedwriter v0.1.1
|
||||||
github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2
|
github.com/hashicorp/go-secure-stdlib/kv-builder v0.1.2
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -1715,8 +1715,8 @@ github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5
|
||||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6 h1:W9WN8p6moV1fjKLkeqEgkAMu5rauy9QeYDAmIaPuuiA=
|
github.com/hashicorp/go-secure-stdlib/awsutil v0.2.0 h1:VmeslHTkAkPaolVKr9MCsZrY5i73Y7ITDgTJ4eVv+94=
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6/go.mod h1:MpCPSPGLDILGb4JMm94/mMi3YysIqsXzGCzkEZjcjXg=
|
github.com/hashicorp/go-secure-stdlib/awsutil v0.2.0/go.mod h1:MpCPSPGLDILGb4JMm94/mMi3YysIqsXzGCzkEZjcjXg=
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
|
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
||||||
|
|
|
@ -64,8 +64,8 @@ valid AWS credentials with proper permissions.
|
||||||
|
|
||||||
To ensure generated usernames are within length limits for both STS/IAM, the template must adequately handle
|
To ensure generated usernames are within length limits for both STS/IAM, the template must adequately handle
|
||||||
both conditional cases (see [Conditional Templates](https://pkg.go.dev/text/template)). As an example, if no template
|
both conditional cases (see [Conditional Templates](https://pkg.go.dev/text/template)). As an example, if no template
|
||||||
is provided the field defaults to the template below. It is to be noted that, DisplayName is the name of the vault
|
is provided the field defaults to the template below. It is to be noted that, DisplayName is the name of the vault
|
||||||
authenticated user running the AWS credential generation and PolicyName is the name of the Role for which the
|
authenticated user running the AWS credential generation and PolicyName is the name of the Role for which the
|
||||||
credential is being generated for:
|
credential is being generated for:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
@ -585,3 +585,136 @@ $ curl \
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Create Static Role
|
||||||
|
This endpoint creates or updates static role definitions. A static role is a 1-to-1 mapping
|
||||||
|
with an AWS IAM User, which will be adopted and managed by Vault, including rotating it according
|
||||||
|
to the configured `rotation_period`.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
|
||||||
|
Vault will create a new credential upon configuration, and if the maximum number of access keys already exist, Vault will rotate the oldest one. Vault must do this to know the credential.
|
||||||
|
|
||||||
|
At each rotation, Vault will rotate the oldest existing credential.
|
||||||
|
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| :----- | :------------------------ |
|
||||||
|
| `POST` | `/aws/static-roles/:name` |
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- `name` `(string: <required>)` – Specifies the name of the role to create. This
|
||||||
|
is specified as part of the URL.
|
||||||
|
|
||||||
|
- `username` `(string: <required>)` – Specifies the username of the IAM user.
|
||||||
|
|
||||||
|
- `rotation_period` `(string/int: <required>)` – Specifies the amount of time
|
||||||
|
Vault should wait before rotating the password. The minimum is 1 minute. Can be
|
||||||
|
specified in either `24h` or `86400` format (see [duration format strings](/vault/docs/concepts/duration-format)).
|
||||||
|
|
||||||
|
### Sample Payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "example-user",
|
||||||
|
"rotation_period": "11h30m"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Request
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ curl \
|
||||||
|
--header "X-Vault-Token: ..." \
|
||||||
|
--request POST \
|
||||||
|
--data @payload.json \
|
||||||
|
http://127.0.0.1:8200/v1/aws/static-roles/my-static-role
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Response
|
||||||
|
|
||||||
|
## Read Static Role
|
||||||
|
|
||||||
|
This endpoint queries the static role definition.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| :----- | :------------------------ |
|
||||||
|
| `GET` | `/aws/static-roles/:name` |
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- `name` `(string: <required>)` – Specifies the name of the static role to read.
|
||||||
|
This is specified as part of the URL.
|
||||||
|
|
||||||
|
### Sample Request
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ curl \
|
||||||
|
--header "X-Vault-Token: ..." \
|
||||||
|
--request GET \
|
||||||
|
http://127.0.0.1:8200/v1/aws/static-roles/my-static-role
|
||||||
|
```
|
||||||
|
### Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-static-role",
|
||||||
|
"username": "example-user",
|
||||||
|
"rotation_period": "11h30m"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delete Static Role
|
||||||
|
|
||||||
|
This endpoint deletes the static role definition. The user, having been defined externally,
|
||||||
|
must be cleaned up manually.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| :------- | :------------------------ |
|
||||||
|
| `DELETE` | `/aws/static-roles/:name` |
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- `name` `(string: <required>)` – Specifies the name of the static role to
|
||||||
|
delete. This is specified as part of the URL.
|
||||||
|
|
||||||
|
### Sample Request
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ curl \
|
||||||
|
--header "X-Vault-Token: ..." \
|
||||||
|
--request DELETE \
|
||||||
|
http://127.0.0.1:8200/v1/aws/static-roles/my-static-role
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get Static Credentials
|
||||||
|
|
||||||
|
This endpoint returns the current credentials based on the named static role.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| :----- | :------------------------ |
|
||||||
|
| `GET` | `/aws/static-creds/:name` |
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- `name` `(string: <required>)` – Specifies the name of the static role to get
|
||||||
|
credentials for. This is specified as part of the URL.
|
||||||
|
|
||||||
|
### Sample Request
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ curl \
|
||||||
|
--header "X-Vault-Token: ..." \
|
||||||
|
http://127.0.0.1:8200/v1/aws/static-creds/my-static-role
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_key_id": "AKIA...",
|
||||||
|
"access_secret_key": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
@ -32,6 +32,18 @@ Vault supports three different types of credentials to retrieve from AWS:
|
||||||
passing in the supplied AWS policy document and return the access key, secret
|
passing in the supplied AWS policy document and return the access key, secret
|
||||||
key, and session token to the caller.
|
key, and session token to the caller.
|
||||||
|
|
||||||
|
### Static Roles
|
||||||
|
The AWS secrets engine supports the concept of "static roles", which are
|
||||||
|
a 1-to-1 mapping of Vault Roles to IAM users. The current password
|
||||||
|
for the user is stored and automatically rotated by Vault on a
|
||||||
|
configurable period of time. This is in contrast to dynamic secrets, where a
|
||||||
|
unique username and password pair are generated with each credential request.
|
||||||
|
When credentials are requested for the Role, Vault returns the current
|
||||||
|
Access Key ID and Secret Access Key for the configured user, allowing anyone with the proper
|
||||||
|
Vault policies to have access to the IAM credentials.
|
||||||
|
|
||||||
|
Please see the [API documentation](/vault/api-docs/secret/aws#create-static-role) for details on this feature.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Most secrets engines must be configured in advance before they can perform their
|
Most secrets engines must be configured in advance before they can perform their
|
||||||
|
@ -160,7 +172,7 @@ the proper permission, it can generate credentials.
|
||||||
--- -----
|
--- -----
|
||||||
access_key AKIA3ALIVABCDG5XC8H4
|
access_key AKIA3ALIVABCDG5XC8H4
|
||||||
```
|
```
|
||||||
|
|
||||||
~> **Note:** Due to AWS eventual consistency, after calling the
|
~> **Note:** Due to AWS eventual consistency, after calling the
|
||||||
`aws/config/rotate-root` endpoint, subsequent calls from Vault to
|
`aws/config/rotate-root` endpoint, subsequent calls from Vault to
|
||||||
AWS may fail for a few seconds until AWS becomes consistent again.
|
AWS may fail for a few seconds until AWS becomes consistent again.
|
||||||
|
|
Loading…
Reference in New Issue