diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index 2749ace10..6f2741185 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -340,6 +340,147 @@ func TestBackend_AllowedUsersTemplate_WithStaticPrefix(t *testing.T) { ) } +func TestBackend_DefaultUserTemplate(t *testing.T) { + testDefaultUserTemplate(t, + "{{ identity.entity.metadata.ssh_username }}", + testUserName, + map[string]string{ + "ssh_username": testUserName, + }, + ) +} + +func TestBackend_DefaultUserTemplate_WithStaticPrefix(t *testing.T) { + testDefaultUserTemplate(t, + "user-{{ identity.entity.metadata.ssh_username }}", + "user-"+testUserName, + map[string]string{ + "ssh_username": testUserName, + }, + ) +} + +func TestBackend_DefaultUserTemplateFalse_AllowedUsersTemplateTrue(t *testing.T) { + cluster, userpassToken := getSshCaTestCluster(t, testUserName) + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + // set metadata "ssh_username" to userpass username + tokenLookupResponse, err := client.Logical().Write("/auth/token/lookup", map[string]interface{}{ + "token": userpassToken, + }) + if err != nil { + t.Fatal(err) + } + entityID := tokenLookupResponse.Data["entity_id"].(string) + _, err = client.Logical().Write("/identity/entity/id/"+entityID, map[string]interface{}{ + "metadata": map[string]string{ + "ssh_username": testUserName, + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("ssh/roles/my-role", map[string]interface{}{ + "key_type": testCaKeyType, + "allow_user_certificates": true, + "default_user": "{{identity.entity.metadata.ssh_username}}", + // disable user templating but not allowed_user_template and the request should fail + "default_user_template": false, + "allowed_users": "{{identity.entity.metadata.ssh_username}}", + "allowed_users_template": true, + }) + if err != nil { + t.Fatal(err) + } + + // sign SSH key as userpass user + client.SetToken(userpassToken) + _, err = client.Logical().Write("ssh/sign/my-role", map[string]interface{}{ + "public_key": testCAPublicKey, + }) + if err == nil { + t.Errorf("signing request should fail when default_user is not in the allowed_users list, because allowed_users_template is true and default_user_template is not") + } + + expectedErrStr := "{{identity.entity.metadata.ssh_username}} is not a valid value for valid_principals" + if !strings.Contains(err.Error(), expectedErrStr) { + t.Errorf("expected error to include %q but it was: %q", expectedErrStr, err.Error()) + } +} + +func TestBackend_DefaultUserTemplateFalse_AllowedUsersTemplateFalse(t *testing.T) { + cluster, userpassToken := getSshCaTestCluster(t, testUserName) + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + // set metadata "ssh_username" to userpass username + tokenLookupResponse, err := client.Logical().Write("/auth/token/lookup", map[string]interface{}{ + "token": userpassToken, + }) + if err != nil { + t.Fatal(err) + } + entityID := tokenLookupResponse.Data["entity_id"].(string) + _, err = client.Logical().Write("/identity/entity/id/"+entityID, map[string]interface{}{ + "metadata": map[string]string{ + "ssh_username": testUserName, + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("ssh/roles/my-role", map[string]interface{}{ + "key_type": testCaKeyType, + "allow_user_certificates": true, + "default_user": "{{identity.entity.metadata.ssh_username}}", + "default_user_template": false, + "allowed_users": "{{identity.entity.metadata.ssh_username}}", + "allowed_users_template": false, + }) + if err != nil { + t.Fatal(err) + } + + // sign SSH key as userpass user + client.SetToken(userpassToken) + signResponse, err := client.Logical().Write("ssh/sign/my-role", map[string]interface{}{ + "public_key": testCAPublicKey, + }) + if err != nil { + t.Fatal(err) + } + + // check for the expected valid principals of certificate + signedKey := signResponse.Data["signed_key"].(string) + key, _ := base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1]) + parsedKey, err := ssh.ParsePublicKey(key) + if err != nil { + t.Fatal(err) + } + actualPrincipals := parsedKey.(*ssh.Certificate).ValidPrincipals + if len(actualPrincipals) < 1 { + t.Fatal( + fmt.Sprintf("No ValidPrincipals returned: should have been %v", + []string{"{{identity.entity.metadata.ssh_username}}"}), + ) + } + if len(actualPrincipals) > 1 { + t.Error( + fmt.Sprintf("incorrect number ValidPrincipals, expected only 1: %v should be %v", + actualPrincipals, []string{"{{identity.entity.metadata.ssh_username}}"}), + ) + } + if actualPrincipals[0] != "{{identity.entity.metadata.ssh_username}}" { + t.Fatal( + fmt.Sprintf("incorrect ValidPrincipals: %v should be %v", + actualPrincipals, []string{"{{identity.entity.metadata.ssh_username}}"}), + ) + } +} + func newTestingFactory(t *testing.T) func(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { return func(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { defaultLeaseTTLVal := 2 * time.Minute @@ -1893,6 +2034,65 @@ func getSshCaTestCluster(t *testing.T, userIdentity string) (*vault.TestCluster, return cluster, userpassToken } +func testDefaultUserTemplate(t *testing.T, testDefaultUserTemplate string, + expectedValidPrincipal string, testEntityMetadata map[string]string, +) { + cluster, userpassToken := getSshCaTestCluster(t, testUserName) + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + // set metadata "ssh_username" to userpass username + tokenLookupResponse, err := client.Logical().Write("/auth/token/lookup", map[string]interface{}{ + "token": userpassToken, + }) + if err != nil { + t.Fatal(err) + } + entityID := tokenLookupResponse.Data["entity_id"].(string) + _, err = client.Logical().Write("/identity/entity/id/"+entityID, map[string]interface{}{ + "metadata": testEntityMetadata, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("ssh/roles/my-role", map[string]interface{}{ + "key_type": testCaKeyType, + "allow_user_certificates": true, + "default_user": testDefaultUserTemplate, + "default_user_template": true, + "allowed_users": testDefaultUserTemplate, + "allowed_users_template": true, + }) + if err != nil { + t.Fatal(err) + } + + // sign SSH key as userpass user + client.SetToken(userpassToken) + signResponse, err := client.Logical().Write("ssh/sign/my-role", map[string]interface{}{ + "public_key": testCAPublicKey, + }) + if err != nil { + t.Fatal(err) + } + + // check for the expected valid principals of certificate + signedKey := signResponse.Data["signed_key"].(string) + key, _ := base64.StdEncoding.DecodeString(strings.Split(signedKey, " ")[1]) + parsedKey, err := ssh.ParsePublicKey(key) + if err != nil { + t.Fatal(err) + } + actualPrincipals := parsedKey.(*ssh.Certificate).ValidPrincipals + if actualPrincipals[0] != expectedValidPrincipal { + t.Fatal( + fmt.Sprintf("incorrect ValidPrincipals: %v should be %v", + actualPrincipals, []string{expectedValidPrincipal}), + ) + } +} + func testAllowedUsersTemplate(t *testing.T, testAllowedUsersTemplate string, expectedValidPrincipal string, testEntityMetadata map[string]string, ) { diff --git a/builtin/logical/ssh/path_issue_sign.go b/builtin/logical/ssh/path_issue_sign.go index fb43a3583..64559dd95 100644 --- a/builtin/logical/ssh/path_issue_sign.go +++ b/builtin/logical/ssh/path_issue_sign.go @@ -70,7 +70,14 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic return logical.ErrorResponse(err.Error()), nil } } else { - parsedPrincipals, err = b.calculateValidPrincipals(data, req, role, role.DefaultUser, role.AllowedUsers, strutil.StrListContains) + defaultPrincipal := role.DefaultUser + if role.DefaultUserTemplate { + defaultPrincipal, err = b.renderPrincipal(role.DefaultUser, req) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + } + parsedPrincipals, err = b.calculateValidPrincipals(data, req, role, defaultPrincipal, role.AllowedUsers, strutil.StrListContains) if err != nil { return logical.ErrorResponse(err.Error()), nil } @@ -136,6 +143,23 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic return response, nil } +func (b *backend) renderPrincipal(principal string, req *logical.Request) (string, error) { + // Look for templating markers {{ .* }} + matched := containsTemplateRegex.MatchString(principal) + if matched { + if req.EntityID != "" { + // Retrieve principal based on template + entityID from request. + renderedPrincipal, err := framework.PopulateIdentityTemplate(principal, req.EntityID, b.System()) + if err != nil { + return "", fmt.Errorf("template '%s' could not be rendered -> %s", principal, err) + } + return renderedPrincipal, nil + } + } + // Static principal + return principal, nil +} + func (b *backend) calculateValidPrincipals(data *framework.FieldData, req *logical.Request, role *sshRole, defaultPrincipal, principalsAllowedByRole string, validatePrincipal func([]string, string) bool) ([]string, error) { validPrincipals := "" validPrincipalsRaw, ok := data.GetOk("valid_principals") @@ -150,23 +174,12 @@ func (b *backend) calculateValidPrincipals(data *framework.FieldData, req *logic var allowedPrincipals []string for _, principal := range strutil.RemoveDuplicates(strutil.ParseStringSlice(principalsAllowedByRole, ","), false) { if role.AllowedUsersTemplate { - // Look for templating markers {{ .* }} - matched := containsTemplateRegex.MatchString(principal) - if matched { - if req.EntityID != "" { - // Retrieve principal based on template + entityID from request. - templatePrincipal, err := framework.PopulateIdentityTemplate(principal, req.EntityID, b.System()) - if err == nil { - // Template returned a principal - allowedPrincipals = append(allowedPrincipals, templatePrincipal) - } else { - return nil, fmt.Errorf("template '%s' could not be rendered -> %s", principal, err) - } - } - } else { - // Static principal or err template - allowedPrincipals = append(allowedPrincipals, principal) + rendered, err := b.renderPrincipal(principal, req) + if err != nil { + return nil, fmt.Errorf("template '%s' could not be rendered -> %s", principal, err) } + // Template returned a principal + allowedPrincipals = append(allowedPrincipals, rendered) } else { // Static principal allowedPrincipals = append(allowedPrincipals, principal) diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go index 3264b000c..123696b7a 100644 --- a/builtin/logical/ssh/path_roles.go +++ b/builtin/logical/ssh/path_roles.go @@ -40,6 +40,7 @@ type sshRole struct { KeyBits int `mapstructure:"key_bits" json:"key_bits"` AdminUser string `mapstructure:"admin_user" json:"admin_user"` DefaultUser string `mapstructure:"default_user" json:"default_user"` + DefaultUserTemplate bool `mapstructure:"default_user_template" json:"default_user_template"` CIDRList string `mapstructure:"cidr_list" json:"cidr_list"` ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"` Port int `mapstructure:"port" json:"port"` @@ -122,6 +123,15 @@ func pathRoles(b *backend) *framework.Path { Name: "Default Username", }, }, + "default_user_template": { + Type: framework.TypeBool, + Description: ` + [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + If set, Default user can be specified using identity template policies. + Non-templated users are also permitted. + `, + Default: false, + }, "cidr_list": { Type: framework.TypeString, Description: ` @@ -558,6 +568,7 @@ func (b *backend) createCARole(allowedUsers, defaultUser, signer string, data *f AllowedUsersTemplate: data.Get("allowed_users_template").(bool), AllowedDomains: data.Get("allowed_domains").(string), DefaultUser: defaultUser, + DefaultUserTemplate: data.Get("default_user_template").(bool), AllowBareDomains: data.Get("allow_bare_domains").(bool), AllowSubdomains: data.Get("allow_subdomains").(bool), AllowUserKeyIDs: data.Get("allow_user_key_ids").(bool), @@ -740,6 +751,7 @@ func (b *backend) parseRole(role *sshRole) (map[string]interface{}, error) { "allowed_users_template": role.AllowedUsersTemplate, "allowed_domains": role.AllowedDomains, "default_user": role.DefaultUser, + "default_user_template": role.DefaultUserTemplate, "ttl": int64(ttl.Seconds()), "max_ttl": int64(maxTTL.Seconds()), "allowed_critical_options": role.AllowedCriticalOptions, diff --git a/changelog/16351.txt b/changelog/16351.txt new file mode 100644 index 000000000..879c7f65b --- /dev/null +++ b/changelog/16351.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/ssh: Allow the use of Identity templates in the `default_user` field +``` diff --git a/website/content/api-docs/secret/ssh.mdx b/website/content/api-docs/secret/ssh.mdx index 8abb19951..60258b44c 100644 --- a/website/content/api-docs/secret/ssh.mdx +++ b/website/content/api-docs/secret/ssh.mdx @@ -99,9 +99,15 @@ This endpoint creates or updates a named role. create individual roles for each username to ensure absolute isolation between usernames. This is required for Dynamic Key type and OTP type. + When `default_user_template` is set to `true`, this field can contain an identity + template with any prefix or suffix, like `ssh-{{identity.entity.id}}-user`. + For the CA type, if you wish this to be a valid principal, it must also be in `allowed_users`. +- `default_user_template` `(bool: false)` - If set, `default_users` can be specified + using identity template values. A non-templated user is also permitted. + - `cidr_list` `(string: "")` – Specifies a comma separated list of CIDR blocks for which the role is applicable for. It is possible that a same set of CIDR blocks are part of multiple roles. This is a required parameter, unless the