[VAULT-1969] Add support for custom IAM usernames based on templates (#12066)

* add ability to customize IAM usernames based on templates

* add changelog

* remove unnecessary logs

* patch: add test for readConfig

* patch: add default STS Template

* patch: remove unnecessary if cases

* patch: add regex checks in username test

* patch: update genUsername to return an error instead of warnings

* patch: separate tests for default and custom templates

* patch: return truncate warning from genUsername and trigger a 400 response on errors

* patch: truncate midString to 42 chars in default template

* docs: add new username_template field to aws docs
This commit is contained in:
vinay-gopalan 2021-07-20 09:48:29 -07:00 committed by GitHub
parent 4a9669a1bc
commit 859b60cafc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 282 additions and 42 deletions

View File

@ -8,6 +8,8 @@ import (
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
) )
const defaultUserNameTemplate = `{{ printf "vault-%s-%s-%s" (printf "%s-%s" (.DisplayName) (.PolicyName) | truncate 42) (unix_time) (random 20) | truncate 64 }}`
func pathConfigRoot(b *backend) *framework.Path { func pathConfigRoot(b *backend) *framework.Path {
return &framework.Path{ return &framework.Path{
Pattern: "config/root", Pattern: "config/root",
@ -39,6 +41,10 @@ func pathConfigRoot(b *backend) *framework.Path {
Default: aws.UseServiceDefaultRetries, Default: aws.UseServiceDefaultRetries,
Description: "Maximum number of retries for recoverable exceptions of AWS APIs", Description: "Maximum number of retries for recoverable exceptions of AWS APIs",
}, },
"username_template": {
Type: framework.TypeString,
Description: "Template to generate custom IAM usernames",
},
}, },
Callbacks: map[logical.Operation]framework.OperationFunc{ Callbacks: map[logical.Operation]framework.OperationFunc{
@ -70,11 +76,12 @@ func (b *backend) pathConfigRootRead(ctx context.Context, req *logical.Request,
} }
configData := map[string]interface{}{ configData := map[string]interface{}{
"access_key": config.AccessKey, "access_key": config.AccessKey,
"region": config.Region, "region": config.Region,
"iam_endpoint": config.IAMEndpoint, "iam_endpoint": config.IAMEndpoint,
"sts_endpoint": config.STSEndpoint, "sts_endpoint": config.STSEndpoint,
"max_retries": config.MaxRetries, "max_retries": config.MaxRetries,
"username_template": config.UsernameTemplate,
} }
return &logical.Response{ return &logical.Response{
Data: configData, Data: configData,
@ -86,17 +93,22 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
iamendpoint := data.Get("iam_endpoint").(string) iamendpoint := data.Get("iam_endpoint").(string)
stsendpoint := data.Get("sts_endpoint").(string) stsendpoint := data.Get("sts_endpoint").(string)
maxretries := data.Get("max_retries").(int) maxretries := data.Get("max_retries").(int)
usernameTemplate := data.Get("username_template").(string)
if usernameTemplate == "" {
usernameTemplate = defaultUserNameTemplate
}
b.clientMutex.Lock() b.clientMutex.Lock()
defer b.clientMutex.Unlock() defer b.clientMutex.Unlock()
entry, err := logical.StorageEntryJSON("config/root", rootConfig{ entry, err := logical.StorageEntryJSON("config/root", rootConfig{
AccessKey: data.Get("access_key").(string), AccessKey: data.Get("access_key").(string),
SecretKey: data.Get("secret_key").(string), SecretKey: data.Get("secret_key").(string),
IAMEndpoint: iamendpoint, IAMEndpoint: iamendpoint,
STSEndpoint: stsendpoint, STSEndpoint: stsendpoint,
Region: region, Region: region,
MaxRetries: maxretries, MaxRetries: maxretries,
UsernameTemplate: usernameTemplate,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -115,12 +127,13 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
} }
type rootConfig struct { type rootConfig struct {
AccessKey string `json:"access_key"` AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key"`
IAMEndpoint string `json:"iam_endpoint"` IAMEndpoint string `json:"iam_endpoint"`
STSEndpoint string `json:"sts_endpoint"` STSEndpoint string `json:"sts_endpoint"`
Region string `json:"region"` Region string `json:"region"`
MaxRetries int `json:"max_retries"` MaxRetries int `json:"max_retries"`
UsernameTemplate string `json:"username_template"`
} }
const pathConfigRootHelpSyn = ` const pathConfigRootHelpSyn = `

View File

@ -18,12 +18,13 @@ func TestBackend_PathConfigRoot(t *testing.T) {
} }
configData := map[string]interface{}{ configData := map[string]interface{}{
"access_key": "AKIAEXAMPLE", "access_key": "AKIAEXAMPLE",
"secret_key": "RandomData", "secret_key": "RandomData",
"region": "us-west-2", "region": "us-west-2",
"iam_endpoint": "https://iam.amazonaws.com", "iam_endpoint": "https://iam.amazonaws.com",
"sts_endpoint": "https://sts.us-west-2.amazonaws.com", "sts_endpoint": "https://sts.us-west-2.amazonaws.com",
"max_retries": 10, "max_retries": 10,
"username_template": defaultUserNameTemplate,
} }
configReq := &logical.Request{ configReq := &logical.Request{

View File

@ -3,12 +3,12 @@ package aws
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand"
"regexp" "regexp"
"time" "time"
"github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/awsutil" "github.com/hashicorp/vault/sdk/helper/awsutil"
"github.com/hashicorp/vault/sdk/helper/template"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
@ -17,7 +17,11 @@ import (
"github.com/hashicorp/errwrap" "github.com/hashicorp/errwrap"
) )
const secretAccessKeyType = "access_keys" const (
secretAccessKeyType = "access_keys"
storageKey = "config/root"
defaultSTSTemplate = `{{ printf "vault-%d-%d" (unix_time) (random 20) | truncate 32 }}`
)
func secretAccessKeys(b *backend) *framework.Secret { func secretAccessKeys(b *backend) *framework.Secret {
return &framework.Secret{ return &framework.Secret{
@ -43,26 +47,45 @@ func secretAccessKeys(b *backend) *framework.Secret {
} }
} }
func genUsername(displayName, policyName, userType string) (ret string, warning string) { func genUsername(displayName, policyName, userType, usernameTemplate string) (ret string, warning string, err error) {
var midString string
switch userType { switch userType {
case "iam_user": case "iam_user":
// IAM users are capped at 64 chars; this leaves, after the beginning and // IAM users are capped at 64 chars; this leaves, after the beginning and
// end added below, 42 chars to play with. // end added below, 42 chars to play with.
midString = fmt.Sprintf("%s-%s-", up, err := template.NewTemplate(template.Template(usernameTemplate))
normalizeDisplayName(displayName), if err != nil {
normalizeDisplayName(policyName)) return "", "", fmt.Errorf("unable to initialize username template: %w", err)
if len(midString) > 42 { }
midString = midString[0:42]
warning = "the calling token display name/IAM policy name were truncated to fit into IAM username length limits" um := UsernameMetadata{
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 template from exceeding IAM length limits
if len(ret) > 64 {
ret = ret[0:64]
warning = "the calling token display name/IAM policy name were truncated to 64 characters to fit within IAM username length limits"
} }
case "sts": case "sts":
// Capped at 32 chars, which leaves only a couple of characters to play // Capped at 32 chars, which leaves only a couple of characters to play
// with, so don't insert display name or policy name at all // with, so don't insert display name or policy name at all
} up, err := template.NewTemplate(template.Template(usernameTemplate))
if err != nil {
return "", "", fmt.Errorf("unable to initialize username template: %w", err)
}
ret = fmt.Sprintf("vault-%s%d-%d", midString, time.Now().Unix(), rand.Int31n(10000)) um := UsernameMetadata{}
ret, err = up.Generate(um)
if err != nil {
return "", "", fmt.Errorf("failed to generate username: %w", err)
}
}
return return
} }
@ -90,7 +113,11 @@ func (b *backend) getFederationToken(ctx context.Context, s logical.Storage,
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
} }
username, usernameWarning := genUsername(displayName, policyName, "sts") username, usernameWarning, usernameError := genUsername(displayName, policyName, "sts", defaultSTSTemplate)
// Send a 400 to Framework.OperationFunc Handler
if usernameError != nil {
return nil, usernameError
}
getTokenInput := &sts.GetFederationTokenInput{ getTokenInput := &sts.GetFederationTokenInput{
Name: aws.String(username), Name: aws.String(username),
@ -165,15 +192,27 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
return logical.ErrorResponse(err.Error()), 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
}
roleSessionNameWarning := "" roleSessionNameWarning := ""
var roleSessionNameError error
if roleSessionName == "" { if roleSessionName == "" {
roleSessionName, roleSessionNameWarning = genUsername(displayName, roleName, "iam_user") roleSessionName, roleSessionNameWarning, roleSessionNameError = genUsername(displayName, roleName, "iam_user", usernameTemplate)
// Send a 400 to Framework.OperationFunc Handler
if roleSessionNameError != nil {
return nil, roleSessionNameError
}
} else { } else {
roleSessionName = normalizeDisplayName(roleSessionName) roleSessionName = normalizeDisplayName(roleSessionName)
if len(roleSessionName) > 64 {
roleSessionName = roleSessionName[0:64]
roleSessionNameWarning = "the role session name was truncated to 64 characters to fit within IAM session name length limits"
}
} }
assumeRoleInput := &sts.AssumeRoleInput{ assumeRoleInput := &sts.AssumeRoleInput{
@ -216,6 +255,22 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
return resp, nil return resp, 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( func (b *backend) secretAccessKeysCreate(
ctx context.Context, ctx context.Context,
s logical.Storage, s logical.Storage,
@ -226,7 +281,22 @@ func (b *backend) secretAccessKeysCreate(
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
} }
username, usernameWarning := genUsername(displayName, policyName, "iam_user") 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, usernameWarning, 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 // 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 // the user is created because if switch the order then the WAL put
@ -435,3 +505,8 @@ func convertPolicyARNs(policyARNs []string) []*sts.PolicyDescriptorType {
} }
return retval return retval
} }
type UsernameMetadata struct {
DisplayName string
PolicyName string
}

View File

@ -1,7 +1,12 @@
package aws package aws
import ( import (
"context"
"strings"
"testing" "testing"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/require"
) )
func TestNormalizeDisplayName_NormRequired(t *testing.T) { func TestNormalizeDisplayName_NormRequired(t *testing.T) {
@ -40,3 +45,143 @@ func TestNormalizeDisplayName_NormNotRequired(t *testing.T) {
} }
} }
} }
func TestGenUsername(t *testing.T) {
testUsername, warning, err := genUsername("name1", "policy1", "iam_user", `{{ printf "vault-%s-%s-%s-%s" (.DisplayName) (.PolicyName) (unix_time) (random 20) | truncate 64 }}`)
if err != nil {
t.Fatalf(
"expected no err; got %s",
err,
)
}
expectedUsernameRegex := `^vault-name1-policy1-[0-9]+-[a-zA-Z0-9]+`
require.Regexp(t, expectedUsernameRegex, testUsername)
// IAM usernames are capped at 64 characters
if len(testUsername) > 64 {
t.Fatalf(
"expected IAM username to be of length 64, got %d",
len(testUsername),
)
}
testUsername, warning, err = genUsername(
"this---is---a---very---long---name",
"long------policy------name",
"iam_user",
`{{ printf "%s-%s-%s-%s" (.DisplayName) (.PolicyName) (unix_time) (random 20) }}`,
)
if warning == "" || !strings.Contains(warning, "calling token display name/IAM policy name were truncated to 64 characters") {
t.Fatalf("expected a truncate warning; received empty string")
}
if len(testUsername) != 64 {
t.Fatalf("expected a username cap at 64 chars; got length: %d", len(testUsername))
}
testUsername, warning, err = genUsername("name1", "policy1", "sts", defaultSTSTemplate)
if strings.Contains(testUsername, "name1") || strings.Contains(testUsername, "policy1") {
t.Fatalf(
"expected sts username to not contain display name or policy name; got %s",
testUsername,
)
}
// STS usernames are capped at 64 characters
if len(testUsername) > 32 {
t.Fatalf(
"expected sts username to be under 32 chars; got %s of length %d",
testUsername,
len(testUsername),
)
}
}
func TestReadConfig_DefaultTemplate(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b := Backend()
if err := b.Setup(context.Background(), config); err != nil {
t.Fatal(err)
}
testTemplate := ""
configData := map[string]interface{}{
"connection_uri": "test_uri",
"username": "guest",
"password": "guest",
"username_template": testTemplate,
}
configReq := &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/root",
Storage: config.StorageView,
Data: configData,
}
resp, err := b.HandleRequest(context.Background(), configReq)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad: resp: %#v\nerr:%s", resp, err)
}
if resp != nil {
t.Fatal("expected a nil response")
}
configResult, err := readConfig(context.Background(), config.StorageView)
if err != nil {
t.Fatalf("expected err to be nil; got %s", err)
}
// No template provided, config set to defaultUsernameTemplate
if configResult.UsernameTemplate != defaultUserNameTemplate {
t.Fatalf(
"expected template %s; got %s",
defaultUserNameTemplate,
configResult.UsernameTemplate,
)
}
}
func TestReadConfig_CustomTemplate(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b := Backend()
if err := b.Setup(context.Background(), config); err != nil {
t.Fatal(err)
}
testTemplate := "`foo-{{ .DisplayName }}`"
configData := map[string]interface{}{
"connection_uri": "test_uri",
"username": "guest",
"password": "guest",
"username_template": testTemplate,
}
configReq := &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/root",
Storage: config.StorageView,
Data: configData,
}
resp, err := b.HandleRequest(context.Background(), configReq)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad: resp: %#v\nerr:%s", resp, err)
}
if resp != nil {
t.Fatal("expected a nil response")
}
configResult, err := readConfig(context.Background(), config.StorageView)
if err != nil {
t.Fatalf("expected err to be nil; got %s", err)
}
if configResult.UsernameTemplate != testTemplate {
t.Fatalf(
"expected template %s; got %s",
testTemplate,
configResult.UsernameTemplate,
)
}
}

3
changelog/12066.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
secrets/aws: add support for custom IAM usernames
```

View File

@ -58,6 +58,9 @@ valid AWS credentials with proper permissions.
- `sts_endpoint` `(string: <optional>)`  Specifies a custom HTTP STS endpoint to use. - `sts_endpoint` `(string: <optional>)`  Specifies a custom HTTP STS endpoint to use.
- `username_template` `(string: <optional>)` - [Template](/docs/concepts/username-templating) describing how
dynamic usernames are generated. IAM usernames are capped at 64 characters. Longer usernames will be truncated.
### Sample Payload ### Sample Payload
```json ```json