27bb03bbc0
* adding copyright header * fix fmt and a test
501 lines
14 KiB
Go
501 lines
14 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package aws
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/helper/template"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/sts"
|
|
"github.com/hashicorp/errwrap"
|
|
)
|
|
|
|
const (
|
|
secretAccessKeyType = "access_keys"
|
|
storageKey = "config/root"
|
|
)
|
|
|
|
func secretAccessKeys(b *backend) *framework.Secret {
|
|
return &framework.Secret{
|
|
Type: secretAccessKeyType,
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"access_key": {
|
|
Type: framework.TypeString,
|
|
Description: "Access Key",
|
|
},
|
|
|
|
"secret_key": {
|
|
Type: framework.TypeString,
|
|
Description: "Secret Key",
|
|
},
|
|
"security_token": {
|
|
Type: framework.TypeString,
|
|
Description: "Security Token",
|
|
},
|
|
},
|
|
|
|
Renew: b.secretAccessKeysRenew,
|
|
Revoke: b.secretAccessKeysRevoke,
|
|
}
|
|
}
|
|
|
|
func genUsername(displayName, policyName, userType, usernameTemplate string) (ret string, err error) {
|
|
switch userType {
|
|
case "iam_user", "assume_role":
|
|
// IAM users are capped at 64 chars
|
|
up, err := template.NewTemplate(template.Template(usernameTemplate))
|
|
if err != nil {
|
|
return "", fmt.Errorf("unable to initialize username template: %w", err)
|
|
}
|
|
|
|
um := UsernameMetadata{
|
|
Type: "IAM",
|
|
DisplayName: normalizeDisplayName(displayName),
|
|
PolicyName: normalizeDisplayName(policyName),
|
|
}
|
|
|
|
ret, err = up.Generate(um)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate username: %w", err)
|
|
}
|
|
// To prevent a custom template from exceeding IAM length limits
|
|
if len(ret) > 64 {
|
|
return "", fmt.Errorf("the username generated by the template exceeds the IAM username length limits of 64 chars")
|
|
}
|
|
case "sts":
|
|
up, err := template.NewTemplate(template.Template(usernameTemplate))
|
|
if err != nil {
|
|
return "", fmt.Errorf("unable to initialize username template: %w", err)
|
|
}
|
|
|
|
um := UsernameMetadata{
|
|
Type: "STS",
|
|
}
|
|
ret, err = up.Generate(um)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate username: %w", err)
|
|
}
|
|
// To prevent a custom template from exceeding STS length limits
|
|
if len(ret) > 32 {
|
|
return "", fmt.Errorf("the username generated by the template exceeds the STS username length limits of 32 chars")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (b *backend) getFederationToken(ctx context.Context, s logical.Storage,
|
|
displayName, policyName, policy string, policyARNs []string,
|
|
iamGroups []string, lifeTimeInSeconds int64) (*logical.Response, error,
|
|
) {
|
|
groupPolicies, groupPolicyARNs, err := b.getGroupPolicies(ctx, s, iamGroups)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
if groupPolicies != nil {
|
|
groupPolicies = append(groupPolicies, policy)
|
|
policy, err = combinePolicyDocuments(groupPolicies...)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
}
|
|
if len(groupPolicyARNs) > 0 {
|
|
policyARNs = append(policyARNs, groupPolicyARNs...)
|
|
}
|
|
|
|
stsClient, err := b.clientSTS(ctx, s)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
config, err := readConfig(ctx, s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read configuration: %w", err)
|
|
}
|
|
|
|
// Set as defaultUsernameTemplate if not provided
|
|
usernameTemplate := config.UsernameTemplate
|
|
if usernameTemplate == "" {
|
|
usernameTemplate = defaultUserNameTemplate
|
|
}
|
|
|
|
username, usernameError := genUsername(displayName, policyName, "sts", usernameTemplate)
|
|
// Send a 400 to Framework.OperationFunc Handler
|
|
if usernameError != nil {
|
|
return nil, usernameError
|
|
}
|
|
|
|
getTokenInput := &sts.GetFederationTokenInput{
|
|
Name: aws.String(username),
|
|
DurationSeconds: &lifeTimeInSeconds,
|
|
}
|
|
if len(policy) > 0 {
|
|
getTokenInput.Policy = aws.String(policy)
|
|
}
|
|
if len(policyARNs) > 0 {
|
|
getTokenInput.PolicyArns = convertPolicyARNs(policyARNs)
|
|
}
|
|
|
|
// If neither a policy document nor policy ARNs are specified, then GetFederationToken will
|
|
// return credentials equivalent to that of the Vault server itself. We probably don't want
|
|
// that by default; the behavior can be explicitly opted in to by associating the Vault role
|
|
// with a policy ARN or document that allows the appropriate permissions.
|
|
if policy == "" && len(policyARNs) == 0 {
|
|
return logical.ErrorResponse("must specify at least one of policy_arns or policy_document with %s credential_type", federationTokenCred), nil
|
|
}
|
|
|
|
tokenResp, err := stsClient.GetFederationToken(getTokenInput)
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error generating STS keys: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
|
|
// STS credentials cannot be revoked so do not create a lease
|
|
return &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"access_key": *tokenResp.Credentials.AccessKeyId,
|
|
"secret_key": *tokenResp.Credentials.SecretAccessKey,
|
|
"security_token": *tokenResp.Credentials.SessionToken,
|
|
"ttl": uint64(tokenResp.Credentials.Expiration.Sub(time.Now()).Seconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
|
|
displayName, roleName, roleArn, policy string, policyARNs []string,
|
|
iamGroups []string, lifeTimeInSeconds int64, roleSessionName string) (*logical.Response, error,
|
|
) {
|
|
// grab any IAM group policies associated with the vault role, both inline
|
|
// and managed
|
|
groupPolicies, groupPolicyARNs, err := b.getGroupPolicies(ctx, s, iamGroups)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
if len(groupPolicies) > 0 {
|
|
groupPolicies = append(groupPolicies, policy)
|
|
policy, err = combinePolicyDocuments(groupPolicies...)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
}
|
|
if len(groupPolicyARNs) > 0 {
|
|
policyARNs = append(policyARNs, groupPolicyARNs...)
|
|
}
|
|
|
|
stsClient, err := b.clientSTS(ctx, s)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
config, err := readConfig(ctx, s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read configuration: %w", err)
|
|
}
|
|
|
|
// Set as defaultUsernameTemplate if not provided
|
|
usernameTemplate := config.UsernameTemplate
|
|
if usernameTemplate == "" {
|
|
usernameTemplate = defaultUserNameTemplate
|
|
}
|
|
|
|
var roleSessionNameError error
|
|
if roleSessionName == "" {
|
|
roleSessionName, roleSessionNameError = genUsername(displayName, roleName, "assume_role", usernameTemplate)
|
|
// Send a 400 to Framework.OperationFunc Handler
|
|
if roleSessionNameError != nil {
|
|
return nil, roleSessionNameError
|
|
}
|
|
} else {
|
|
roleSessionName = normalizeDisplayName(roleSessionName)
|
|
}
|
|
|
|
assumeRoleInput := &sts.AssumeRoleInput{
|
|
RoleSessionName: aws.String(roleSessionName),
|
|
RoleArn: aws.String(roleArn),
|
|
DurationSeconds: &lifeTimeInSeconds,
|
|
}
|
|
if policy != "" {
|
|
assumeRoleInput.SetPolicy(policy)
|
|
}
|
|
if len(policyARNs) > 0 {
|
|
assumeRoleInput.SetPolicyArns(convertPolicyARNs(policyARNs))
|
|
}
|
|
tokenResp, err := stsClient.AssumeRole(assumeRoleInput)
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error assuming role: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
|
|
// STS credentials cannot be revoked so do not create a lease
|
|
return &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"access_key": *tokenResp.Credentials.AccessKeyId,
|
|
"secret_key": *tokenResp.Credentials.SecretAccessKey,
|
|
"security_token": *tokenResp.Credentials.SessionToken,
|
|
"arn": *tokenResp.AssumedRoleUser.Arn,
|
|
"ttl": uint64(tokenResp.Credentials.Expiration.Sub(time.Now()).Seconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func readConfig(ctx context.Context, storage logical.Storage) (rootConfig, error) {
|
|
entry, err := storage.Get(ctx, storageKey)
|
|
if err != nil {
|
|
return rootConfig{}, err
|
|
}
|
|
if entry == nil {
|
|
return rootConfig{}, nil
|
|
}
|
|
|
|
var connConfig rootConfig
|
|
if err := entry.DecodeJSON(&connConfig); err != nil {
|
|
return rootConfig{}, err
|
|
}
|
|
return connConfig, nil
|
|
}
|
|
|
|
func (b *backend) secretAccessKeysCreate(
|
|
ctx context.Context,
|
|
s logical.Storage,
|
|
displayName, policyName string,
|
|
role *awsRoleEntry,
|
|
) (*logical.Response, error) {
|
|
iamClient, err := b.clientIAM(ctx, s)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), nil
|
|
}
|
|
|
|
config, err := readConfig(ctx, s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read configuration: %w", err)
|
|
}
|
|
|
|
// Set as defaultUsernameTemplate if not provided
|
|
usernameTemplate := config.UsernameTemplate
|
|
if usernameTemplate == "" {
|
|
usernameTemplate = defaultUserNameTemplate
|
|
}
|
|
|
|
username, usernameError := genUsername(displayName, policyName, "iam_user", usernameTemplate)
|
|
// Send a 400 to Framework.OperationFunc Handler
|
|
if usernameError != nil {
|
|
return nil, usernameError
|
|
}
|
|
|
|
// Write to the WAL that this user will be created. We do this before
|
|
// the user is created because if switch the order then the WAL put
|
|
// can fail, which would put us in an awkward position: we have a user
|
|
// we need to rollback but can't put the WAL entry to do the rollback.
|
|
walID, err := framework.PutWAL(ctx, s, "user", &walUser{
|
|
UserName: username,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error writing WAL entry: %w", err)
|
|
}
|
|
|
|
userPath := role.UserPath
|
|
if userPath == "" {
|
|
userPath = "/"
|
|
}
|
|
|
|
createUserRequest := &iam.CreateUserInput{
|
|
UserName: aws.String(username),
|
|
Path: aws.String(userPath),
|
|
}
|
|
if role.PermissionsBoundaryARN != "" {
|
|
createUserRequest.PermissionsBoundary = aws.String(role.PermissionsBoundaryARN)
|
|
}
|
|
|
|
// Create the user
|
|
_, err = iamClient.CreateUser(createUserRequest)
|
|
if err != nil {
|
|
if walErr := framework.DeleteWAL(ctx, s, walID); walErr != nil {
|
|
iamErr := fmt.Errorf("error creating IAM user: %w", err)
|
|
return nil, errwrap.Wrap(fmt.Errorf("failed to delete WAL entry: %w", walErr), iamErr)
|
|
}
|
|
return logical.ErrorResponse("Error creating IAM user: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
|
|
for _, arn := range role.PolicyArns {
|
|
// Attach existing policy against user
|
|
_, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
|
UserName: aws.String(username),
|
|
PolicyArn: aws.String(arn),
|
|
})
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error attaching user policy: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
|
|
}
|
|
if role.PolicyDocument != "" {
|
|
// Add new inline user policy against user
|
|
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
|
UserName: aws.String(username),
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(role.PolicyDocument),
|
|
})
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error putting user policy: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
}
|
|
|
|
for _, group := range role.IAMGroups {
|
|
// Add user to IAM groups
|
|
_, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
|
|
UserName: aws.String(username),
|
|
GroupName: aws.String(group),
|
|
})
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error adding user to group: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
}
|
|
|
|
var tags []*iam.Tag
|
|
for key, value := range role.IAMTags {
|
|
// This assignment needs to be done in order to create unique addresses for
|
|
// these variables. Without doing so, all the tags will be copies of the last
|
|
// tag listed in the role.
|
|
k, v := key, value
|
|
tags = append(tags, &iam.Tag{Key: &k, Value: &v})
|
|
}
|
|
|
|
if len(tags) > 0 {
|
|
_, err = iamClient.TagUser(&iam.TagUserInput{
|
|
Tags: tags,
|
|
UserName: &username,
|
|
})
|
|
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error adding tags to user: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
}
|
|
|
|
// Create the keys
|
|
keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
|
|
UserName: aws.String(username),
|
|
})
|
|
if err != nil {
|
|
return logical.ErrorResponse("Error creating access keys: %s", err), awsutil.CheckAWSError(err)
|
|
}
|
|
|
|
// Remove the WAL entry, we succeeded! If we fail, we don't return
|
|
// the secret because it'll get rolled back anyways, so we have to return
|
|
// an error here.
|
|
if err := framework.DeleteWAL(ctx, s, walID); err != nil {
|
|
return nil, fmt.Errorf("failed to commit WAL entry: %w", err)
|
|
}
|
|
|
|
// Return the info!
|
|
resp := b.Secret(secretAccessKeyType).Response(map[string]interface{}{
|
|
"access_key": *keyResp.AccessKey.AccessKeyId,
|
|
"secret_key": *keyResp.AccessKey.SecretAccessKey,
|
|
"security_token": nil,
|
|
}, map[string]interface{}{
|
|
"username": username,
|
|
"policy": role,
|
|
"is_sts": false,
|
|
})
|
|
|
|
lease, err := b.Lease(ctx, s)
|
|
if err != nil || lease == nil {
|
|
lease = &configLease{}
|
|
}
|
|
|
|
resp.Secret.TTL = lease.Lease
|
|
resp.Secret.MaxTTL = lease.LeaseMax
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) secretAccessKeysRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
|
// STS already has a lifetime, and we don't support renewing it
|
|
isSTSRaw, ok := req.Secret.InternalData["is_sts"]
|
|
if ok {
|
|
isSTS, ok := isSTSRaw.(bool)
|
|
if ok {
|
|
if isSTS {
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
lease, err := b.Lease(ctx, req.Storage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lease == nil {
|
|
lease = &configLease{}
|
|
}
|
|
|
|
resp := &logical.Response{Secret: req.Secret}
|
|
resp.Secret.TTL = lease.Lease
|
|
resp.Secret.MaxTTL = lease.LeaseMax
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) secretAccessKeysRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
|
// STS cleans up after itself so we can skip this if is_sts internal data
|
|
// element set to true. If is_sts is not set, assumes old version
|
|
// and defaults to the IAM approach.
|
|
isSTSRaw, ok := req.Secret.InternalData["is_sts"]
|
|
if ok {
|
|
isSTS, ok := isSTSRaw.(bool)
|
|
if ok {
|
|
if isSTS {
|
|
return nil, nil
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("secret has is_sts but value could not be understood")
|
|
}
|
|
}
|
|
|
|
// Get the username from the internal data
|
|
usernameRaw, ok := req.Secret.InternalData["username"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("secret is missing username internal data")
|
|
}
|
|
username, ok := usernameRaw.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("secret is missing username internal data")
|
|
}
|
|
|
|
// Use the user rollback mechanism to delete this user
|
|
err := b.pathUserRollback(ctx, req, "user", map[string]interface{}{
|
|
"username": username,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func normalizeDisplayName(displayName string) string {
|
|
re := regexp.MustCompile("[^a-zA-Z0-9+=,.@_-]")
|
|
return re.ReplaceAllString(displayName, "_")
|
|
}
|
|
|
|
func convertPolicyARNs(policyARNs []string) []*sts.PolicyDescriptorType {
|
|
size := len(policyARNs)
|
|
retval := make([]*sts.PolicyDescriptorType, size, size)
|
|
for i, arn := range policyARNs {
|
|
retval[i] = &sts.PolicyDescriptorType{
|
|
Arn: aws.String(arn),
|
|
}
|
|
}
|
|
return retval
|
|
}
|
|
|
|
type UsernameMetadata struct {
|
|
Type string
|
|
DisplayName string
|
|
PolicyName string
|
|
}
|