Repository: hashicorp/vault (Go)

Issue:
Vault SSH backend: Cannot have '@' symbol in path
**Is your feature request related to a problem? Please describe.**
I'm trying to deploy a ssh role per identity. It turns out my identifier is an email.
When creating a role with the '@' symbol in it, Vault complains about the path.

```
Error: error writing role "user@domain.com" for backend "ssh-client-signer": Error making API request.

URL: PUT https://vault.internal.domain.com/v1/ssh-client-signer/roles/user@domain.com
Code: 404. Errors:

* 1 error occurred:
	* unsupported path
```

**Describe the solution you'd like**
I'd like Vault to accept the @ character

**Describe alternatives you've considered**
I could try to replace the character and use a different id across my Vault config, but it's an additional step to take into account.

**Explain any additional use-cases**
If there are any use-cases that would help us understand the use/need/value please share them as they can help us decide on acceptance and prioritization.

**Additional context**
I'm logging in through oidc with GSuite SSO and the only ids in the response are a user id (bunch of numbers) or the email. User ids, while immutable, are one more step to correlate to trace back to a user.
This has already been done in the following issue:
https://github.com/hashicorp/vault/issues/5645


Discussion:
Coming back to this, I have an extra use-case:

Consider a templated policy:

```
path "ssh-client-signer/roles/{{identity.entity.metadata.email}}-*" {
  capabilities = ["create", "update"]
}
```

This will not work, since `@` is not accepted. Furthermore, since templating does not support calling functions (such as replace), I cannot find an easy way to allow a specific user to a list of roles using templates.



Patch:
diff --git a/builtin/logical/ssh/path_creds_create.go b/builtin/logical/ssh/path_creds_create.go
index b9b9a641040..f6fa7e76604 100644
--- a/builtin/logical/ssh/path_creds_create.go
+++ b/builtin/logical/ssh/path_creds_create.go
@@ -20,7 +20,7 @@ type sshOTP struct {
 
 func pathCredsCreate(b *backend) *framework.Path {
 	return &framework.Path{
-		Pattern: "creds/" + framework.GenericNameRegex("role"),
+		Pattern: "creds/" + framework.GenericNameWithAtRegex("role"),
 		Fields: map[string]*framework.FieldSchema{
 			"role": &framework.FieldSchema{
 				Type:        framework.TypeString,
diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go
index 450d7684477..6cd20cba6aa 100644
--- a/builtin/logical/ssh/path_roles.go
+++ b/builtin/logical/ssh/path_roles.go
@@ -69,7 +69,7 @@ func pathListRoles(b *backend) *framework.Path {
 
 func pathRoles(b *backend) *framework.Path {
 	return &framework.Path{
-		Pattern: "roles/" + framework.GenericNameRegex("role"),
+		Pattern: "roles/" + framework.GenericNameWithAtRegex("role"),
 		Fields: map[string]*framework.FieldSchema{
 			"role": &framework.FieldSchema{
 				Type: framework.TypeString,
diff --git a/builtin/logical/ssh/path_sign.go b/builtin/logical/ssh/path_sign.go
index a64edfa2d9a..95e12af1a42 100644
--- a/builtin/logical/ssh/path_sign.go
+++ b/builtin/logical/ssh/path_sign.go
@@ -37,7 +37,7 @@ type creationBundle struct {
 
 func pathSign(b *backend) *framework.Path {
 	return &framework.Path{
-		Pattern: "sign/" + framework.GenericNameRegex("role"),
+		Pattern: "sign/" + framework.GenericNameWithAtRegex("role"),
 
 		Callbacks: map[logical.Operation]framework.OperationFunc{
 			logical.UpdateOperation: b.pathSign,


Test patch:
diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go
index 62460e1180e..065fdf30ba3 100644
--- a/builtin/logical/ssh/backend_test.go
+++ b/builtin/logical/ssh/backend_test.go
@@ -35,6 +35,7 @@ const (
 	testOTPKeyType       = "otp"
 	testDynamicKeyType   = "dynamic"
 	testCIDRList         = "127.0.0.1/32"
+	testAtRoleName       = "test@RoleName"
 	testDynamicRoleName  = "testDynamicRoleName"
 	testOTPRoleName      = "testOTPRoleName"
 	testKeyName          = "testKeyName"
@@ -256,6 +257,7 @@ func TestSSHBackend_Lookup(t *testing.T) {
 	resp2 := []string{testOTPRoleName}
 	resp3 := []string{testDynamicRoleName, testOTPRoleName}
 	resp4 := []string{testDynamicRoleName}
+	resp5 := []string{testAtRoleName}
 	logicaltest.Test(t, logicaltest.TestCase{
 		AcceptanceTest: true,
 		LogicalFactory: testingFactory,
@@ -270,6 +272,10 @@ func TestSSHBackend_Lookup(t *testing.T) {
 			testLookupRead(t, data, resp4),
 			testRoleDelete(t, testDynamicRoleName),
 			testLookupRead(t, data, resp1),
+			testRoleWrite(t, testAtRoleName, testDynamicRoleData),
+			testLookupRead(t, data, resp5),
+			testRoleDelete(t, testAtRoleName),
+			testLookupRead(t, data, resp1),
 		},
 	})
 }
@@ -289,12 +295,29 @@ func TestSSHBackend_RoleList(t *testing.T) {
 			},
 		},
 	}
+	resp3 := map[string]interface{}{
+		"keys": []string{testAtRoleName, testOTPRoleName},
+		"key_info": map[string]interface{}{
+			testOTPRoleName: map[string]interface{}{
+				"key_type": testOTPKeyType,
+			},
+			testAtRoleName: map[string]interface{}{
+				"key_type": testOTPKeyType,
+			},
+		},
+	}
 	logicaltest.Test(t, logicaltest.TestCase{
 		LogicalFactory: testingFactory,
 		Steps: []logicaltest.TestStep{
 			testRoleList(t, resp1),
 			testRoleWrite(t, testOTPRoleName, testOTPRoleData),
 			testRoleList(t, resp2),
+			testRoleWrite(t, testAtRoleName, testOTPRoleData),
+			testRoleList(t, resp3),
+			testRoleDelete(t, testAtRoleName),
+			testRoleList(t, resp2),
+			testRoleDelete(t, testOTPRoleName),
+			testRoleList(t, resp1),
 		},
 	})
 }
@@ -319,6 +342,8 @@ func TestSSHBackend_DynamicKeyCreate(t *testing.T) {
 			testNamedKeysWrite(t, testKeyName, testSharedPrivateKey),
 			testRoleWrite(t, testDynamicRoleName, testDynamicRoleData),
 			testCredsWrite(t, testDynamicRoleName, data, false),
+			testRoleWrite(t, testAtRoleName, testDynamicRoleData),
+			testCredsWrite(t, testAtRoleName, data, false),
 		},
 	})
 }
@@ -343,6 +368,10 @@ func TestSSHBackend_OTPRoleCrud(t *testing.T) {
 			testRoleRead(t, testOTPRoleName, respOTPRoleData),
 			testRoleDelete(t, testOTPRoleName),
 			testRoleRead(t, testOTPRoleName, nil),
+			testRoleWrite(t, testAtRoleName, testOTPRoleData),
+			testRoleRead(t, testAtRoleName, respOTPRoleData),
+			testRoleDelete(t, testAtRoleName),
+			testRoleRead(t, testAtRoleName, nil),
 		},
 	})
 }
@@ -374,6 +403,10 @@ func TestSSHBackend_DynamicRoleCrud(t *testing.T) {
 			testRoleRead(t, testDynamicRoleName, respDynamicRoleData),
 			testRoleDelete(t, testDynamicRoleName),
 			testRoleRead(t, testDynamicRoleName, nil),
+			testRoleWrite(t, testAtRoleName, testDynamicRoleData),
+			testRoleRead(t, testAtRoleName, respDynamicRoleData),
+			testRoleDelete(t, testAtRoleName),
+			testRoleRead(t, testAtRoleName, nil),
 		},
 	})
 }
@@ -405,6 +438,8 @@ func TestSSHBackend_OTPCreate(t *testing.T) {
 		Steps: []logicaltest.TestStep{
 			testRoleWrite(t, testOTPRoleName, testOTPRoleData),
 			testCredsWrite(t, testOTPRoleName, data, false),
+			testRoleWrite(t, testAtRoleName, testOTPRoleData),
+			testCredsWrite(t, testAtRoleName, data, false),
 		},
 	})
 }
@@ -1108,14 +1143,17 @@ func testRoleRead(t *testing.T, roleName string, expected map[string]interface{}
 			if err := mapstructure.Decode(resp.Data, &d); err != nil {
 				return fmt.Errorf("error decoding response:%s", err)
 			}
-			if roleName == testOTPRoleName {
+			switch d.KeyType {
+			case "otp":
 				if d.KeyType != expected["key_type"] || d.DefaultUser != expected["default_user"] || d.CIDRList != expected["cidr_list"] {
 					return fmt.Errorf("data mismatch. bad: %#v", resp)
 				}
-			} else {
+			case "dynamic":
 				if d.AdminUser != expected["admin_user"] || d.CIDRList != expected["cidr_list"] || d.KeyName != expected["key"] || d.KeyType != expected["key_type"] {
 					return fmt.Errorf("data mismatch. bad: %#v", resp)
 				}
+			default:
+				return fmt.Errorf("unknown key type. bad: %#v", resp)
 			}
 			return nil
 		},