Allow identity templates in ssh backend `default_user` field (#16351)

* Allow identity templates in ssh backend `default_user` field

* use correct test expected value

* include api docs for `default_user_template` field
This commit is contained in:
Ian Ferguson 2022-07-29 09:45:52 -04:00 committed by GitHub
parent 4dc7b71a28
commit dc603b4f7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 251 additions and 17 deletions

View File

@ -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) { 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) { return func(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
defaultLeaseTTLVal := 2 * time.Minute defaultLeaseTTLVal := 2 * time.Minute
@ -1893,6 +2034,65 @@ func getSshCaTestCluster(t *testing.T, userIdentity string) (*vault.TestCluster,
return cluster, userpassToken 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, func testAllowedUsersTemplate(t *testing.T, testAllowedUsersTemplate string,
expectedValidPrincipal string, testEntityMetadata map[string]string, expectedValidPrincipal string, testEntityMetadata map[string]string,
) { ) {

View File

@ -70,7 +70,14 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
} }
} else { } 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 { if err != nil {
return logical.ErrorResponse(err.Error()), nil return logical.ErrorResponse(err.Error()), nil
} }
@ -136,6 +143,23 @@ func (b *backend) pathSignIssueCertificateHelper(ctx context.Context, req *logic
return response, nil 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) { func (b *backend) calculateValidPrincipals(data *framework.FieldData, req *logical.Request, role *sshRole, defaultPrincipal, principalsAllowedByRole string, validatePrincipal func([]string, string) bool) ([]string, error) {
validPrincipals := "" validPrincipals := ""
validPrincipalsRaw, ok := data.GetOk("valid_principals") validPrincipalsRaw, ok := data.GetOk("valid_principals")
@ -150,23 +174,12 @@ func (b *backend) calculateValidPrincipals(data *framework.FieldData, req *logic
var allowedPrincipals []string var allowedPrincipals []string
for _, principal := range strutil.RemoveDuplicates(strutil.ParseStringSlice(principalsAllowedByRole, ","), false) { for _, principal := range strutil.RemoveDuplicates(strutil.ParseStringSlice(principalsAllowedByRole, ","), false) {
if role.AllowedUsersTemplate { if role.AllowedUsersTemplate {
// Look for templating markers {{ .* }} rendered, err := b.renderPrincipal(principal, req)
matched := containsTemplateRegex.MatchString(principal) if err != nil {
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) return nil, fmt.Errorf("template '%s' could not be rendered -> %s", principal, err)
} }
} // Template returned a principal
} else { allowedPrincipals = append(allowedPrincipals, rendered)
// Static principal or err template
allowedPrincipals = append(allowedPrincipals, principal)
}
} else { } else {
// Static principal // Static principal
allowedPrincipals = append(allowedPrincipals, principal) allowedPrincipals = append(allowedPrincipals, principal)

View File

@ -40,6 +40,7 @@ type sshRole struct {
KeyBits int `mapstructure:"key_bits" json:"key_bits"` KeyBits int `mapstructure:"key_bits" json:"key_bits"`
AdminUser string `mapstructure:"admin_user" json:"admin_user"` AdminUser string `mapstructure:"admin_user" json:"admin_user"`
DefaultUser string `mapstructure:"default_user" json:"default_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"` CIDRList string `mapstructure:"cidr_list" json:"cidr_list"`
ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"` ExcludeCIDRList string `mapstructure:"exclude_cidr_list" json:"exclude_cidr_list"`
Port int `mapstructure:"port" json:"port"` Port int `mapstructure:"port" json:"port"`
@ -122,6 +123,15 @@ func pathRoles(b *backend) *framework.Path {
Name: "Default Username", 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": { "cidr_list": {
Type: framework.TypeString, Type: framework.TypeString,
Description: ` Description: `
@ -558,6 +568,7 @@ func (b *backend) createCARole(allowedUsers, defaultUser, signer string, data *f
AllowedUsersTemplate: data.Get("allowed_users_template").(bool), AllowedUsersTemplate: data.Get("allowed_users_template").(bool),
AllowedDomains: data.Get("allowed_domains").(string), AllowedDomains: data.Get("allowed_domains").(string),
DefaultUser: defaultUser, DefaultUser: defaultUser,
DefaultUserTemplate: data.Get("default_user_template").(bool),
AllowBareDomains: data.Get("allow_bare_domains").(bool), AllowBareDomains: data.Get("allow_bare_domains").(bool),
AllowSubdomains: data.Get("allow_subdomains").(bool), AllowSubdomains: data.Get("allow_subdomains").(bool),
AllowUserKeyIDs: data.Get("allow_user_key_ids").(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_users_template": role.AllowedUsersTemplate,
"allowed_domains": role.AllowedDomains, "allowed_domains": role.AllowedDomains,
"default_user": role.DefaultUser, "default_user": role.DefaultUser,
"default_user_template": role.DefaultUserTemplate,
"ttl": int64(ttl.Seconds()), "ttl": int64(ttl.Seconds()),
"max_ttl": int64(maxTTL.Seconds()), "max_ttl": int64(maxTTL.Seconds()),
"allowed_critical_options": role.AllowedCriticalOptions, "allowed_critical_options": role.AllowedCriticalOptions,

3
changelog/16351.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
secrets/ssh: Allow the use of Identity templates in the `default_user` field
```

View File

@ -99,9 +99,15 @@ This endpoint creates or updates a named role.
create individual roles for each username to ensure absolute isolation between create individual roles for each username to ensure absolute isolation between
usernames. This is required for Dynamic Key type and OTP type. 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 For the CA type, if you wish this to be a valid principal, it must also be
in `allowed_users`. 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 - `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 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 blocks are part of multiple roles. This is a required parameter, unless the