From 859b60cafccb6e6c7dcc0f645894a6ec604fa72a Mon Sep 17 00:00:00 2001 From: vinay-gopalan <86625824+vinay-gopalan@users.noreply.github.com> Date: Tue, 20 Jul 2021 09:48:29 -0700 Subject: [PATCH] [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 --- builtin/logical/aws/path_config_root.go | 47 ++++-- builtin/logical/aws/path_config_root_test.go | 13 +- builtin/logical/aws/secret_access_keys.go | 113 +++++++++++--- .../logical/aws/secret_access_keys_test.go | 145 ++++++++++++++++++ changelog/12066.txt | 3 + website/content/api-docs/secret/aws.mdx | 3 + 6 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 changelog/12066.txt diff --git a/builtin/logical/aws/path_config_root.go b/builtin/logical/aws/path_config_root.go index fa2f59f64..77c760e02 100644 --- a/builtin/logical/aws/path_config_root.go +++ b/builtin/logical/aws/path_config_root.go @@ -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 = ` diff --git a/builtin/logical/aws/path_config_root_test.go b/builtin/logical/aws/path_config_root_test.go index 89a9b04d8..d90ee6cac 100644 --- a/builtin/logical/aws/path_config_root_test.go +++ b/builtin/logical/aws/path_config_root_test.go @@ -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{ diff --git a/builtin/logical/aws/secret_access_keys.go b/builtin/logical/aws/secret_access_keys.go index 327eafd9b..56b2a46ca 100644 --- a/builtin/logical/aws/secret_access_keys.go +++ b/builtin/logical/aws/secret_access_keys.go @@ -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 +} diff --git a/builtin/logical/aws/secret_access_keys_test.go b/builtin/logical/aws/secret_access_keys_test.go index 22f780a88..b9dce2514 100644 --- a/builtin/logical/aws/secret_access_keys_test.go +++ b/builtin/logical/aws/secret_access_keys_test.go @@ -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, + ) + } +} diff --git a/changelog/12066.txt b/changelog/12066.txt new file mode 100644 index 000000000..6a672ff76 --- /dev/null +++ b/changelog/12066.txt @@ -0,0 +1,3 @@ +```release-note:feature +secrets/aws: add support for custom IAM usernames +``` diff --git a/website/content/api-docs/secret/aws.mdx b/website/content/api-docs/secret/aws.mdx index 48254cc21..6ed9d3141 100644 --- a/website/content/api-docs/secret/aws.mdx +++ b/website/content/api-docs/secret/aws.mdx @@ -58,6 +58,9 @@ valid AWS credentials with proper permissions. - `sts_endpoint` `(string: )` – Specifies a custom HTTP STS endpoint to use. +- `username_template` `(string: )` - [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