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) {
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,
) {

View File

@ -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)

View File

@ -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,

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
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