deb.open-vault/builtin/logical/aws/path_static_roles.go

332 lines
10 KiB
Go
Raw Permalink Normal View History

2024-04-20 12:23:50 +00:00
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.`
)