[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:
parent
4a9669a1bc
commit
859b60cafc
|
@ -8,6 +8,8 @@ import (
|
|||
"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 {
|
||||
return &framework.Path{
|
||||
Pattern: "config/root",
|
||||
|
@ -39,6 +41,10 @@ func pathConfigRoot(b *backend) *framework.Path {
|
|||
Default: aws.UseServiceDefaultRetries,
|
||||
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{
|
||||
|
@ -70,11 +76,12 @@ func (b *backend) pathConfigRootRead(ctx context.Context, req *logical.Request,
|
|||
}
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"access_key": config.AccessKey,
|
||||
"region": config.Region,
|
||||
"iam_endpoint": config.IAMEndpoint,
|
||||
"sts_endpoint": config.STSEndpoint,
|
||||
"max_retries": config.MaxRetries,
|
||||
"access_key": config.AccessKey,
|
||||
"region": config.Region,
|
||||
"iam_endpoint": config.IAMEndpoint,
|
||||
"sts_endpoint": config.STSEndpoint,
|
||||
"max_retries": config.MaxRetries,
|
||||
"username_template": config.UsernameTemplate,
|
||||
}
|
||||
return &logical.Response{
|
||||
Data: configData,
|
||||
|
@ -86,17 +93,22 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
|
|||
iamendpoint := data.Get("iam_endpoint").(string)
|
||||
stsendpoint := data.Get("sts_endpoint").(string)
|
||||
maxretries := data.Get("max_retries").(int)
|
||||
usernameTemplate := data.Get("username_template").(string)
|
||||
if usernameTemplate == "" {
|
||||
usernameTemplate = defaultUserNameTemplate
|
||||
}
|
||||
|
||||
b.clientMutex.Lock()
|
||||
defer b.clientMutex.Unlock()
|
||||
|
||||
entry, err := logical.StorageEntryJSON("config/root", rootConfig{
|
||||
AccessKey: data.Get("access_key").(string),
|
||||
SecretKey: data.Get("secret_key").(string),
|
||||
IAMEndpoint: iamendpoint,
|
||||
STSEndpoint: stsendpoint,
|
||||
Region: region,
|
||||
MaxRetries: maxretries,
|
||||
AccessKey: data.Get("access_key").(string),
|
||||
SecretKey: data.Get("secret_key").(string),
|
||||
IAMEndpoint: iamendpoint,
|
||||
STSEndpoint: stsendpoint,
|
||||
Region: region,
|
||||
MaxRetries: maxretries,
|
||||
UsernameTemplate: usernameTemplate,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -115,12 +127,13 @@ func (b *backend) pathConfigRootWrite(ctx context.Context, req *logical.Request,
|
|||
}
|
||||
|
||||
type rootConfig struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
IAMEndpoint string `json:"iam_endpoint"`
|
||||
STSEndpoint string `json:"sts_endpoint"`
|
||||
Region string `json:"region"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
IAMEndpoint string `json:"iam_endpoint"`
|
||||
STSEndpoint string `json:"sts_endpoint"`
|
||||
Region string `json:"region"`
|
||||
MaxRetries int `json:"max_retries"`
|
||||
UsernameTemplate string `json:"username_template"`
|
||||
}
|
||||
|
||||
const pathConfigRootHelpSyn = `
|
||||
|
|
|
@ -18,12 +18,13 @@ func TestBackend_PathConfigRoot(t *testing.T) {
|
|||
}
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"access_key": "AKIAEXAMPLE",
|
||||
"secret_key": "RandomData",
|
||||
"region": "us-west-2",
|
||||
"iam_endpoint": "https://iam.amazonaws.com",
|
||||
"sts_endpoint": "https://sts.us-west-2.amazonaws.com",
|
||||
"max_retries": 10,
|
||||
"access_key": "AKIAEXAMPLE",
|
||||
"secret_key": "RandomData",
|
||||
"region": "us-west-2",
|
||||
"iam_endpoint": "https://iam.amazonaws.com",
|
||||
"sts_endpoint": "https://sts.us-west-2.amazonaws.com",
|
||||
"max_retries": 10,
|
||||
"username_template": defaultUserNameTemplate,
|
||||
}
|
||||
|
||||
configReq := &logical.Request{
|
||||
|
|
|
@ -3,12 +3,12 @@ package aws
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/helper/awsutil"
|
||||
"github.com/hashicorp/vault/sdk/helper/template"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
|
@ -17,7 +17,11 @@ import (
|
|||
"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 {
|
||||
return &framework.Secret{
|
||||
|
@ -43,26 +47,45 @@ func secretAccessKeys(b *backend) *framework.Secret {
|
|||
}
|
||||
}
|
||||
|
||||
func genUsername(displayName, policyName, userType string) (ret string, warning string) {
|
||||
var midString string
|
||||
func genUsername(displayName, policyName, userType, usernameTemplate string) (ret string, warning string, err error) {
|
||||
|
||||
switch userType {
|
||||
case "iam_user":
|
||||
// IAM users are capped at 64 chars; this leaves, after the beginning and
|
||||
// end added below, 42 chars to play with.
|
||||
midString = fmt.Sprintf("%s-%s-",
|
||||
normalizeDisplayName(displayName),
|
||||
normalizeDisplayName(policyName))
|
||||
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"
|
||||
up, err := template.NewTemplate(template.Template(usernameTemplate))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to initialize username template: %w", err)
|
||||
}
|
||||
|
||||
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":
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -90,7 +113,11 @@ func (b *backend) getFederationToken(ctx context.Context, s logical.Storage,
|
|||
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{
|
||||
Name: aws.String(username),
|
||||
|
@ -165,15 +192,27 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
|
|||
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 := ""
|
||||
var roleSessionNameError error
|
||||
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 {
|
||||
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{
|
||||
|
@ -216,6 +255,22 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
|
|||
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(
|
||||
ctx context.Context,
|
||||
s logical.Storage,
|
||||
|
@ -226,7 +281,22 @@ func (b *backend) secretAccessKeysCreate(
|
|||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
type UsernameMetadata struct {
|
||||
DisplayName string
|
||||
PolicyName string
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
package aws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
secrets/aws: add support for custom IAM usernames
|
||||
```
|
|
@ -58,6 +58,9 @@ valid AWS credentials with proper permissions.
|
|||
|
||||
- `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
|
||||
|
||||
```json
|
||||
|
|
Loading…
Reference in New Issue