Repository: juanfont/headscale

Issue:
[Bug] SSH autogroup:nonroot does not permit non-root usernames
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

With the following block:

```yaml
"ssh": [
    {
      "action": "accept",
      "src": ["tag:admin", "group:admin"],
      "dst": ["*"],
      "users": ["autogroup:nonroot", "root"]
    }
  ]
```

Despite `--ssh` being set and the user existing on the target system, admins are unable to SSH with the username `test` to the destination.

The error returned is: 

```
mitch@Mitchs-MacBook ~ % ssh test@100.123.123.123
tailscale: failed to evaluate SSH policyConnection closed by 100.123.123.123 port 22
```

When I explicitly add `"test"` to `users:`, it works as expected. 

```yaml
"ssh": [
    {
      "action": "accept",
      "src": ["tag:admin", "group:admin"],
      "dst": ["*"],
      "users": ["autogroup:nonroot", "root", "test"]
    }
  ]
```

### Expected Behavior

As per [Tailscale docs](https://tailscale.com/kb/1337/policy-syntax#users), the `autogroup:nonroot` group in an SSH ACL policy, should permit any username that is not `root` to login to the machine. This does not currently appear to be the case with Headscale `0.26.1`.

### Steps To Reproduce

As per above, create an SSH block permitting `autogroup:nonroot` and users will not be able to login.

### Environment

```markdown
- OS: Debian 12 via Docker
- Headscale version: 0.26.1
- Tailscale version: 1.81.1
```

### Runtime environment

- [x] Headscale is behind a (reverse) proxy
- [x] Headscale runs in a container

### Debug information

N/A

Hints from issue comments:
@turnah: I don't think this is just affecting nonroot group, but same when explicitly defining usernames, ie "michael@". I also can't get my ssh group to connect after updating. Same message and my config hasn't changed (apart from @). Thought I was going mad or to do with changing my usernames so they contain an @@mitchplze: There’s another bug If you didn’t know as well. Try restarting Headscale whenever you change ACLs or tags, or they won’t refresh. @nblock: > I don't think this is just affecting nonroot group, but same when explicitly defining usernames, ie "michael@".

The `@` in the username is only required for Headscale users. The `users` list in the `ssh` section is just a list of unix usernames on the system you want to connect to with SSH.@turnah: > > I don't think this is just affecting nonroot group, but same when explicitly defining usernames, ie "michael@".
> 
> The `@` in the username is only required for Headscale users. The `users` list in the `ssh` section is just a list of unix usernames on the system you want to connect to with SSH.

Well this solves my problem! Thank you, sorry for cross posting on this issue@nblock: I can confirm this on 0.26.1 and 0.25.1. It likely exists in many older versions as well.

The reason seems to be a difference in the netmap's `sshUsers` section found within `SSHPolicy.rules[*].sshUsers`. This
can be observed by configuring a similar policy in Tailscale SaaS and Headscale and inspecting the netmap via e.g.
`tailscale debug netmap | jq .SSHPolicy.rules[0]`.

A few SSH policy examples with different allowed users follow. Note the differences in the `sshUsers` section. 

### Allow tailscale ssh as any local user except `root`

```json5
"ssh": [
  {
    ...
    "users": ["autogroup:nonroot"]
  }
]
```

<details>
<summary>SSHPolicy from Tailscale SaaS</summary>

```json5
{
  "ruleExpires": "2025-07-20T06:05:17Z",
  "principals": [{...}],
  "sshUsers": {
    "*": "=",
    "root": ""
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true,
    "allowRemotePortForwarding": true
  }
}
```

</details>


<details>
<summary>SSHPolicy Headscale 0.26.1 (policy v2)</summary>

```json5
{
  "principals": [{...}],
  "sshUsers": {
    "autogroup:nonroot": "="
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true
  }
}
```

</details>


### Allow tailscale ssh as any user

```json5
"ssh": [
  {
    ...
    "users": ["autogroup:nonroot", "root"]
  }
]
```

<details>
<summary>SSHPolicy from Tailscale SaaS</summary>

```json5
{
  "ruleExpires": "2025-07-20T06:05:17Z",
  "principals": [{...}],
  "sshUsers": {
    "*": "=",
    "root": "root"
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true,
    "allowRemotePortForwarding": true
  }
}
```

</details>


<details>
<summary>SSHPolicy Headscale 0.26.1 (policy v2)</summary>

```json5
{
  "principals": [{...}],
  "sshUsers": {
    "autogroup:nonroot": "=",
    "root": "="
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true
  }
}
```

</details>


### Allow tailscale ssh as `ubuntu` or `root`

```json5
"ssh": [
  {
    ...
    "users":  ["ubuntu", "root"],
  }
]
```

<details>
<summary>SSHPolicy from Tailscale SaaS</summary>

```json5
{
  "ruleExpires": "2025-07-20T06:05:17Z",
  "principals": [{...}],
  "sshUsers": {
    "root": "root",
    "ubuntu": "ubuntu"
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true,
    "allowRemotePortForwarding": true
  }
}
```

</details>


<details>
<summary>SSHPolicy Headscale 0.26.1 (policy v2)</summary>

```json5
{
  "principals": [{...}],
  "sshUsers": {
    "root": "=",
    "ubuntu": "="
  },
  "action": {
    "accept": true,
    "allowAgentForwarding": true,
    "allowLocalPortForwarding": true
  }
}
```

</details>

### Summary

* Headscale configures the username to be `autogroup:nonroot` while Tailscale SaaS uses a wildcard to match any username (except `root`)
* The values also differ: `=` vs the literal username
* Also note that Tailscale SaaS additionally configures `allowRemotePortForwarding`.

Solution patch:
diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go
index 338e513b..5793d96c 100644
--- a/hscontrol/policy/v2/filter.go
+++ b/hscontrol/policy/v2/filter.go
@@ -89,11 +89,12 @@ func (pol *Policy) compileFilterRules(
 
 func sshAction(accept bool, duration time.Duration) tailcfg.SSHAction {
 	return tailcfg.SSHAction{
-		Reject:                   !accept,
-		Accept:                   accept,
-		SessionDuration:          duration,
-		AllowAgentForwarding:     true,
-		AllowLocalPortForwarding: true,
+		Reject:                    !accept,
+		Accept:                    accept,
+		SessionDuration:           duration,
+		AllowAgentForwarding:      true,
+		AllowLocalPortForwarding:  true,
+		AllowRemotePortForwarding: true,
 	}
 }
 
@@ -153,8 +154,17 @@ func (pol *Policy) compileSSHPolicy(
 		}
 
 		userMap := make(map[string]string, len(rule.Users))
-		for _, user := range rule.Users {
-			userMap[user.String()] = "="
+		if rule.Users.ContainsNonRoot() {
+			userMap["*"] = "="
+
+			// by default, we do not allow root unless explicitly stated
+			userMap["root"] = ""
+		}
+		if rule.Users.ContainsRoot() {
+			userMap["root"] = "root"
+		}
+		for _, u := range rule.Users.NormalUsers() {
+			userMap[u.String()] = u.String()
 		}
 		rules = append(rules, &tailcfg.SSHRule{
 			Principals: principals,
diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go
index a2541da6..80797e17 100644
--- a/hscontrol/policy/v2/types.go
+++ b/hscontrol/policy/v2/types.go
@@ -20,6 +20,7 @@ import (
 	"tailscale.com/types/ptr"
 	"tailscale.com/types/views"
 	"tailscale.com/util/multierr"
+	"tailscale.com/util/slicesx"
 )
 
 const Wildcard = Asterix(0)
@@ -506,6 +507,10 @@ func (ag *AutoGroup) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+func (ag AutoGroup) String() string {
+	return string(ag)
+}
+
 // MarshalJSON marshals the AutoGroup to JSON.
 func (ag AutoGroup) MarshalJSON() ([]byte, error) {
 	return json.Marshal(string(ag))
@@ -1562,7 +1567,7 @@ type SSH struct {
 	Action       string         `json:"action"`
 	Sources      SSHSrcAliases  `json:"src"`
 	Destinations SSHDstAliases  `json:"dst"`
-	Users        []SSHUser      `json:"users"`
+	Users        SSHUsers       `json:"users"`
 	CheckPeriod  model.Duration `json:"checkPeriod,omitempty"`
 }
 
@@ -1715,6 +1720,22 @@ func (a SSHSrcAliases) Resolve(p *Policy, users types.Users, nodes views.Slice[t
 // It can be a list of usernames, tags or autogroups.
 type SSHDstAliases []Alias
 
+type SSHUsers []SSHUser
+
+func (u SSHUsers) ContainsRoot() bool {
+	return slices.Contains(u, "root")
+}
+
+func (u SSHUsers) ContainsNonRoot() bool {
+	return slices.Contains(u, SSHUser(AutoGroupNonRoot))
+}
+
+func (u SSHUsers) NormalUsers() []SSHUser {
+	return slicesx.Filter(nil, u, func(user SSHUser) bool {
+		return user != "root" && user != SSHUser(AutoGroupNonRoot)
+	})
+}
+
 type SSHUser string
 
 func (u SSHUser) String() string {



Test patch:
diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go
index f19ac3d3..5d8f791d 100644
--- a/hscontrol/policy/policy_test.go
+++ b/hscontrol/policy/policy_test.go
@@ -1612,13 +1612,7 @@ func TestSSHPolicyRules(t *testing.T) {
 		UserID:   2,
 		User:     users[1],
 	}
-	taggedServer := types.Node{
-		Hostname:   "tagged-server",
-		IPv4:       ap("100.64.0.3"),
-		UserID:     3,
-		User:       users[2],
-		ForcedTags: []string{"tag:server"},
-	}
+
 	taggedClient := types.Node{
 		Hostname:   "tagged-client",
 		IPv4:       ap("100.64.0.4"),
@@ -1659,32 +1653,32 @@ func TestSSHPolicyRules(t *testing.T) {
 						{NodeIP: "100.64.0.2"},
 					},
 					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
+						"*":    "=",
+						"root": "",
 					},
 					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
 					},
 				},
 			}},
 		},
 		{
-			name:       "group-to-tag",
-			targetNode: taggedServer,
-			peers:      types.Nodes{&nodeUser1, &nodeUser2},
+			name:       "check-period-specified",
+			targetNode: nodeUser1,
+			peers:      types.Nodes{&taggedClient},
 			policy: `{
 				"tagOwners": {
-				  "tag:server": ["user3@"],
-				},
-				"groups": {
-					"group:users": ["user1@", "user2@"]
+					"tag:client": ["user1@"],
 				},
 				"ssh": [
 					{
-						"action": "accept",
-						"src": ["group:users"],
-						"dst": ["tag:server"],
+						"action": "check",
+						"checkPeriod": "24h",
+						"src": ["tag:client"],
+						"dst": ["user1@"],
 						"users": ["autogroup:nonroot"]
 					}
 				]
@@ -1692,28 +1686,30 @@ func TestSSHPolicyRules(t *testing.T) {
 			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
 				{
 					Principals: []*tailcfg.SSHPrincipal{
-						{NodeIP: "100.64.0.1"},
-						{NodeIP: "100.64.0.2"},
+						{NodeIP: "100.64.0.4"},
 					},
 					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
+						"*":    "=",
+						"root": "",
 					},
 					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
+						Accept:                    true,
+						SessionDuration:           24 * time.Hour,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
 					},
 				},
 			}},
 		},
 		{
-			name:       "tag-to-user",
-			targetNode: nodeUser1,
-			peers:      types.Nodes{&taggedClient},
+			name:       "no-matching-rules",
+			targetNode: nodeUser2,
+			peers:      types.Nodes{&nodeUser1},
 			policy: `{
 			    "tagOwners": {
-					"tag:client": ["user1@"],
-				},
+			    	"tag:client": ["user1@"],
+			    },
 				"ssh": [
 					{
 						"action": "accept",
@@ -1723,60 +1719,64 @@ func TestSSHPolicyRules(t *testing.T) {
 					}
 				]
 			}`,
-			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
-				{
-					Principals: []*tailcfg.SSHPrincipal{
-						{NodeIP: "100.64.0.4"},
-					},
-					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
-					},
-					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
-					},
-				},
-			}},
+			wantSSH: &tailcfg.SSHPolicy{Rules: nil},
 		},
 		{
-			name:       "tag-to-tag",
-			targetNode: taggedServer,
-			peers:      types.Nodes{&taggedClient},
+			name:       "invalid-action",
+			targetNode: nodeUser1,
+			peers:      types.Nodes{&nodeUser2},
 			policy: `{
-				"tagOwners": {
-					"tag:client": ["user2@"],
-					"tag:server": ["user3@"],
-				},
 				"ssh": [
 					{
-						"action": "accept",
-						"src": ["tag:client"],
-						"dst": ["tag:server"],
+						"action": "invalid",
+						"src": ["group:admins"],
+						"dst": ["user1@"],
 						"users": ["autogroup:nonroot"]
 					}
 				]
 			}`,
-			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
-				{
-					Principals: []*tailcfg.SSHPrincipal{
-						{NodeIP: "100.64.0.4"},
-					},
-					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
-					},
-					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
-					},
-				},
-			}},
+			expectErr:    true,
+			errorMessage: `SSH action "invalid" is not valid, must be accept or check`,
+		},
+		{
+			name:       "invalid-check-period",
+			targetNode: nodeUser1,
+			peers:      types.Nodes{&nodeUser2},
+			policy: `{
+				"ssh": [
+					{
+						"action": "check",
+						"checkPeriod": "invalid",
+						"src": ["group:admins"],
+						"dst": ["user1@"],
+						"users": ["autogroup:nonroot"]
+					}
+				]
+			}`,
+			expectErr:    true,
+			errorMessage: "not a valid duration string",
 		},
 		{
-			name:       "group-to-wildcard",
+			name:       "unsupported-autogroup",
 			targetNode: nodeUser1,
-			peers:      types.Nodes{&nodeUser2, &taggedClient},
+			peers:      types.Nodes{&taggedClient},
+			policy: `{
+        "ssh": [
+            {
+                "action": "accept",
+                "src": ["tag:client"],
+                "dst": ["user1@"],
+                "users": ["autogroup:invalid"]
+            }
+        ]
+    }`,
+			expectErr:    true,
+			errorMessage: "autogroup \"autogroup:invalid\" is not supported",
+		},
+		{
+			name:       "autogroup-nonroot-should-use-wildcard-with-root-excluded",
+			targetNode: nodeUser1,
+			peers:      types.Nodes{&nodeUser2},
 			policy: `{
 				"groups": {
 					"group:admins": ["user2@"]
@@ -1785,167 +1785,102 @@ func TestSSHPolicyRules(t *testing.T) {
 					{
 						"action": "accept",
 						"src": ["group:admins"],
-						"dst": ["*"],
+						"dst": ["user1@"],
 						"users": ["autogroup:nonroot"]
 					}
 				]
 			}`,
+			// autogroup:nonroot should map to wildcard "*" with root excluded
 			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
 				{
 					Principals: []*tailcfg.SSHPrincipal{
 						{NodeIP: "100.64.0.2"},
 					},
 					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
+						"*":    "=",
+						"root": "",
 					},
 					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
 					},
 				},
 			}},
 		},
 		{
-			name:       "check-period-specified",
+			name:       "autogroup-nonroot-plus-root-should-use-wildcard-with-root-mapped",
 			targetNode: nodeUser1,
-			peers:      types.Nodes{&taggedClient},
+			peers:      types.Nodes{&nodeUser2},
 			policy: `{
-				"tagOwners": {
-					"tag:client": ["user1@"],
+				"groups": {
+					"group:admins": ["user2@"]
 				},
 				"ssh": [
 					{
-						"action": "check",
-						"checkPeriod": "24h",
-						"src": ["tag:client"],
+						"action": "accept",
+						"src": ["group:admins"],
 						"dst": ["user1@"],
-						"users": ["autogroup:nonroot"]
+						"users": ["autogroup:nonroot", "root"]
 					}
 				]
 			}`,
+			// autogroup:nonroot + root should map to wildcard "*" with root mapped to itself
 			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
 				{
 					Principals: []*tailcfg.SSHPrincipal{
-						{NodeIP: "100.64.0.4"},
+						{NodeIP: "100.64.0.2"},
 					},
 					SSHUsers: map[string]string{
-						"autogroup:nonroot": "=",
+						"*":    "=",
+						"root": "root",
 					},
 					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						SessionDuration:          24 * time.Hour,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
 					},
 				},
 			}},
 		},
 		{
-			name:       "no-matching-rules",
-			targetNode: nodeUser2,
-			peers:      types.Nodes{&nodeUser1},
-			policy: `{
-			    "tagOwners": {
-			    	"tag:client": ["user1@"],
-			    },
-				"ssh": [
-					{
-						"action": "accept",
-						"src": ["tag:client"],
-						"dst": ["user1@"],
-						"users": ["autogroup:nonroot"]
-					}
-				]
-			}`,
-			wantSSH: &tailcfg.SSHPolicy{Rules: nil},
-		},
-		{
-			name:       "invalid-action",
-			targetNode: nodeUser1,
-			peers:      types.Nodes{&nodeUser2},
-			policy: `{
-				"ssh": [
-					{
-						"action": "invalid",
-						"src": ["group:admins"],
-						"dst": ["user1@"],
-						"users": ["autogroup:nonroot"]
-					}
-				]
-			}`,
-			expectErr:    true,
-			errorMessage: `SSH action "invalid" is not valid, must be accept or check`,
-		},
-		{
-			name:       "invalid-check-period",
+			name:       "specific-users-should-map-to-themselves-not-equals",
 			targetNode: nodeUser1,
 			peers:      types.Nodes{&nodeUser2},
 			policy: `{
+				"groups": {
+					"group:admins": ["user2@"]
+				},
 				"ssh": [
 					{
-						"action": "check",
-						"checkPeriod": "invalid",
+						"action": "accept",
 						"src": ["group:admins"],
 						"dst": ["user1@"],
-						"users": ["autogroup:nonroot"]
+						"users": ["ubuntu", "root"]
 					}
 				]
 			}`,
-			expectErr:    true,
-			errorMessage: "not a valid duration string",
-		},
-		{
-			name:       "multiple-ssh-users-with-autogroup",
-			targetNode: nodeUser1,
-			peers:      types.Nodes{&taggedClient},
-			policy: `{
-			"tagOwners": {
-				"tag:client": ["user1@"],
-			},
-        	"ssh": [
-        	    {
-        	        "action": "accept",
-        	        "src": ["tag:client"],
-        	        "dst": ["user1@"],
-        	        "users": ["alice", "bob"]
-        	    }
-        	]
-    }`,
+			// specific usernames should map to themselves, not "="
 			wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
 				{
 					Principals: []*tailcfg.SSHPrincipal{
-						{NodeIP: "100.64.0.4"},
+						{NodeIP: "100.64.0.2"},
 					},
 					SSHUsers: map[string]string{
-						"alice": "=",
-						"bob":   "=",
+						"root":   "root",
+						"ubuntu": "ubuntu",
 					},
 					Action: &tailcfg.SSHAction{
-						Accept:                   true,
-						AllowAgentForwarding:     true,
-						AllowLocalPortForwarding: true,
+						Accept:                    true,
+						AllowAgentForwarding:      true,
+						AllowLocalPortForwarding:  true,
+						AllowRemotePortForwarding: true,
 					},
 				},
 			}},
 		},
-		{
-			name:       "unsupported-autogroup",
-			targetNode: nodeUser1,
-			peers:      types.Nodes{&taggedClient},
-			policy: `{
-        "ssh": [
-            {
-                "action": "accept",
-                "src": ["tag:client"],
-                "dst": ["user1@"],
-                "users": ["autogroup:invalid"]
-            }
-        ]
-    }`,
-			expectErr:    true,
-			errorMessage: "autogroup \"autogroup:invalid\" is not supported",
-		},
 	}
 
 	for _, tt := range tests {
diff --git a/hscontrol/policy/v2/filter_test.go b/hscontrol/policy/v2/filter_test.go
index 12c60fbb..8b73a6f5 100644
--- a/hscontrol/policy/v2/filter_test.go
+++ b/hscontrol/policy/v2/filter_test.go
@@ -1,10 +1,16 @@
 package v2
 
 import (
+	"encoding/json"
+	"net/netip"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/prometheus/common/model"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"gorm.io/gorm"
 	"tailscale.com/tailcfg"
 )
@@ -376,3 +382,406 @@ func TestParsing(t *testing.T) {
 		})
 	}
 }
+
+func TestCompileSSHPolicy_UserMapping(t *testing.T) {
+	users := types.Users{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+		{Name: "user2", Model: gorm.Model{ID: 2}},
+	}
+
+	// Create test nodes
+	nodeUser1 := types.Node{
+		Hostname: "user1-device",
+		IPv4:     createAddr("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+	nodeUser2 := types.Node{
+		Hostname: "user2-device",
+		IPv4:     createAddr("100.64.0.2"),
+		UserID:   2,
+		User:     users[1],
+	}
+
+	nodes := types.Nodes{&nodeUser1, &nodeUser2}
+
+	tests := []struct {
+		name         string
+		targetNode   types.Node
+		policy       *Policy
+		wantSSHUsers map[string]string
+		wantEmpty    bool
+	}{
+		{
+			name:       "specific user mapping",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{"ssh-it-user"},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"ssh-it-user": "ssh-it-user",
+			},
+		},
+		{
+			name:       "multiple specific users",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{"ubuntu", "admin", "deploy"},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"ubuntu": "ubuntu",
+				"admin":  "admin",
+				"deploy": "deploy",
+			},
+		},
+		{
+			name:       "autogroup:nonroot only",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{SSHUser(AutoGroupNonRoot)},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"*":    "=",
+				"root": "",
+			},
+		},
+		{
+			name:       "root only",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{"root"},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"root": "root",
+			},
+		},
+		{
+			name:       "autogroup:nonroot plus root",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{SSHUser(AutoGroupNonRoot), "root"},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"*":    "=",
+				"root": "root",
+			},
+		},
+		{
+			name:       "mixed specific users and autogroups",
+			targetNode: nodeUser1,
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")},
+						Users:        []SSHUser{SSHUser(AutoGroupNonRoot), "root", "ubuntu", "admin"},
+					},
+				},
+			},
+			wantSSHUsers: map[string]string{
+				"*":      "=",
+				"root":   "root",
+				"ubuntu": "ubuntu",
+				"admin":  "admin",
+			},
+		},
+		{
+			name:       "no matching destination",
+			targetNode: nodeUser2, // Target node2, but policy only allows user1
+			policy: &Policy{
+				Groups: Groups{
+					Group("group:admins"): []Username{Username("user2@")},
+				},
+				SSHs: []SSH{
+					{
+						Action:       "accept",
+						Sources:      SSHSrcAliases{gp("group:admins")},
+						Destinations: SSHDstAliases{up("user1@")}, // Only user1, not user2
+						Users:        []SSHUser{"ssh-it-user"},
+					},
+				},
+			},
+			wantEmpty: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Validate the policy
+			err := tt.policy.validate()
+			require.NoError(t, err)
+
+			// Compile SSH policy
+			sshPolicy, err := tt.policy.compileSSHPolicy(users, tt.targetNode.View(), nodes.ViewSlice())
+			require.NoError(t, err)
+
+			if tt.wantEmpty {
+				if sshPolicy == nil {
+					return // Expected empty result
+				}
+				assert.Empty(t, sshPolicy.Rules, "SSH policy should be empty when no rules match")
+				return
+			}
+
+			require.NotNil(t, sshPolicy)
+			require.Len(t, sshPolicy.Rules, 1, "Should have exactly one SSH rule")
+
+			rule := sshPolicy.Rules[0]
+			assert.Equal(t, tt.wantSSHUsers, rule.SSHUsers, "SSH users mapping should match expected")
+
+			// Verify principals are set correctly (should contain user2's IP since that's the source)
+			require.Len(t, rule.Principals, 1)
+			assert.Equal(t, "100.64.0.2", rule.Principals[0].NodeIP)
+
+			// Verify action is set correctly
+			assert.True(t, rule.Action.Accept)
+			assert.True(t, rule.Action.AllowAgentForwarding)
+			assert.True(t, rule.Action.AllowLocalPortForwarding)
+			assert.True(t, rule.Action.AllowRemotePortForwarding)
+		})
+	}
+}
+
+func TestCompileSSHPolicy_CheckAction(t *testing.T) {
+	users := types.Users{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+		{Name: "user2", Model: gorm.Model{ID: 2}},
+	}
+
+	nodeUser1 := types.Node{
+		Hostname: "user1-device",
+		IPv4:     createAddr("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+	nodeUser2 := types.Node{
+		Hostname: "user2-device",
+		IPv4:     createAddr("100.64.0.2"),
+		UserID:   2,
+		User:     users[1],
+	}
+
+	nodes := types.Nodes{&nodeUser1, &nodeUser2}
+
+	policy := &Policy{
+		Groups: Groups{
+			Group("group:admins"): []Username{Username("user2@")},
+		},
+		SSHs: []SSH{
+			{
+				Action:       "check",
+				CheckPeriod:  model.Duration(24 * time.Hour),
+				Sources:      SSHSrcAliases{gp("group:admins")},
+				Destinations: SSHDstAliases{up("user1@")},
+				Users:        []SSHUser{"ssh-it-user"},
+			},
+		},
+	}
+
+	err := policy.validate()
+	require.NoError(t, err)
+
+	sshPolicy, err := policy.compileSSHPolicy(users, nodeUser1.View(), nodes.ViewSlice())
+	require.NoError(t, err)
+	require.NotNil(t, sshPolicy)
+	require.Len(t, sshPolicy.Rules, 1)
+
+	rule := sshPolicy.Rules[0]
+
+	// Verify SSH users are correctly mapped
+	expectedUsers := map[string]string{
+		"ssh-it-user": "ssh-it-user",
+	}
+	assert.Equal(t, expectedUsers, rule.SSHUsers)
+
+	// Verify check action with session duration
+	assert.True(t, rule.Action.Accept)
+	assert.Equal(t, 24*time.Hour, rule.Action.SessionDuration)
+}
+
+// TestSSHIntegrationReproduction reproduces the exact scenario from the integration test
+// TestSSHOneUserToAll that was failing with empty sshUsers
+func TestSSHIntegrationReproduction(t *testing.T) {
+	// Create users matching the integration test
+	users := types.Users{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+		{Name: "user2", Model: gorm.Model{ID: 2}},
+	}
+
+	// Create simple nodes for testing
+	node1 := &types.Node{
+		Hostname: "user1-node",
+		IPv4:     createAddr("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+
+	node2 := &types.Node{
+		Hostname: "user2-node",
+		IPv4:     createAddr("100.64.0.2"),
+		UserID:   2,
+		User:     users[1],
+	}
+
+	nodes := types.Nodes{node1, node2}
+
+	// Create a simple policy that reproduces the issue
+	policy := &Policy{
+		Groups: Groups{
+			Group("group:integration-test"): []Username{Username("user1@")},
+		},
+		SSHs: []SSH{
+			{
+				Action:       "accept",
+				Sources:      SSHSrcAliases{gp("group:integration-test")},
+				Destinations: SSHDstAliases{up("user2@")},       // Target user2
+				Users:        []SSHUser{SSHUser("ssh-it-user")}, // This is the key - specific user
+			},
+		},
+	}
+
+	// Validate policy
+	err := policy.validate()
+	require.NoError(t, err)
+
+	// Test SSH policy compilation for node2 (target)
+	sshPolicy, err := policy.compileSSHPolicy(users, node2.View(), nodes.ViewSlice())
+	require.NoError(t, err)
+	require.NotNil(t, sshPolicy)
+	require.Len(t, sshPolicy.Rules, 1)
+
+	rule := sshPolicy.Rules[0]
+
+	// This was the failing assertion in integration test - sshUsers was empty
+	assert.NotEmpty(t, rule.SSHUsers, "SSH users should not be empty")
+	assert.Contains(t, rule.SSHUsers, "ssh-it-user", "ssh-it-user should be present in SSH users")
+	assert.Equal(t, "ssh-it-user", rule.SSHUsers["ssh-it-user"], "ssh-it-user should map to itself")
+
+	// Verify that ssh-it-user is correctly mapped
+	expectedUsers := map[string]string{
+		"ssh-it-user": "ssh-it-user",
+	}
+	assert.Equal(t, expectedUsers, rule.SSHUsers, "ssh-it-user should be mapped to itself")
+}
+
+// TestSSHJSONSerialization verifies that the SSH policy can be properly serialized
+// to JSON and that the sshUsers field is not empty
+func TestSSHJSONSerialization(t *testing.T) {
+	users := types.Users{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+	}
+
+	node := &types.Node{
+		Hostname: "test-node",
+		IPv4:     createAddr("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+
+	nodes := types.Nodes{node}
+
+	policy := &Policy{
+		SSHs: []SSH{
+			{
+				Action:       "accept",
+				Sources:      SSHSrcAliases{up("user1@")},
+				Destinations: SSHDstAliases{up("user1@")},
+				Users:        []SSHUser{"ssh-it-user", "ubuntu", "admin"},
+			},
+		},
+	}
+
+	err := policy.validate()
+	require.NoError(t, err)
+
+	sshPolicy, err := policy.compileSSHPolicy(users, node.View(), nodes.ViewSlice())
+	require.NoError(t, err)
+	require.NotNil(t, sshPolicy)
+
+	// Serialize to JSON to verify structure
+	jsonData, err := json.MarshalIndent(sshPolicy, "", "  ")
+	require.NoError(t, err)
+
+	// Parse back to verify structure
+	var parsed tailcfg.SSHPolicy
+	err = json.Unmarshal(jsonData, &parsed)
+	require.NoError(t, err)
+
+	// Verify the parsed structure has the expected SSH users
+	require.Len(t, parsed.Rules, 1)
+	rule := parsed.Rules[0]
+
+	expectedUsers := map[string]string{
+		"ssh-it-user": "ssh-it-user",
+		"ubuntu":      "ubuntu",
+		"admin":       "admin",
+	}
+	assert.Equal(t, expectedUsers, rule.SSHUsers, "SSH users should survive JSON round-trip")
+
+	// Verify JSON contains the SSH users (not empty)
+	assert.Contains(t, string(jsonData), `"ssh-it-user"`)
+	assert.Contains(t, string(jsonData), `"ubuntu"`)
+	assert.Contains(t, string(jsonData), `"admin"`)
+	assert.NotContains(t, string(jsonData), `"sshUsers": {}`, "SSH users should not be empty")
+	assert.NotContains(t, string(jsonData), `"sshUsers": null`, "SSH users should not be null")
+}
+
+// Helper function to create IP addresses for testing
+func createAddr(ip string) *netip.Addr {
+	addr, _ := netip.ParseAddr(ip)
+	return &addr
+}
diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go
index 5bdb7885..2a3ab578 100644
--- a/hscontrol/policy/v2/types_test.go
+++ b/hscontrol/policy/v2/types_test.go
@@ -1615,6 +1615,134 @@ func TestResolveAutoApprovers(t *testing.T) {
 	}
 }
 
+func TestSSHUsers_NormalUsers(t *testing.T) {
+	tests := []struct {
+		name     string
+		users    SSHUsers
+		expected []SSHUser
+	}{
+		{
+			name:     "empty users",
+			users:    SSHUsers{},
+			expected: []SSHUser{},
+		},
+		{
+			name:     "only root",
+			users:    SSHUsers{"root"},
+			expected: []SSHUser{},
+		},
+		{
+			name:     "only autogroup:nonroot",
+			users:    SSHUsers{SSHUser(AutoGroupNonRoot)},
+			expected: []SSHUser{},
+		},
+		{
+			name:     "only normal user",
+			users:    SSHUsers{"ssh-it-user"},
+			expected: []SSHUser{"ssh-it-user"},
+		},
+		{
+			name:     "multiple normal users",
+			users:    SSHUsers{"ubuntu", "admin", "user1"},
+			expected: []SSHUser{"ubuntu", "admin", "user1"},
+		},
+		{
+			name:     "mixed users with root",
+			users:    SSHUsers{"ubuntu", "root", "admin"},
+			expected: []SSHUser{"ubuntu", "admin"},
+		},
+		{
+			name:     "mixed users with autogroup:nonroot",
+			users:    SSHUsers{"ubuntu", SSHUser(AutoGroupNonRoot), "admin"},
+			expected: []SSHUser{"ubuntu", "admin"},
+		},
+		{
+			name:     "mixed users with both root and autogroup:nonroot",
+			users:    SSHUsers{"ubuntu", "root", SSHUser(AutoGroupNonRoot), "admin"},
+			expected: []SSHUser{"ubuntu", "admin"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.users.NormalUsers()
+			assert.ElementsMatch(t, tt.expected, result, "NormalUsers() should return expected normal users")
+		})
+	}
+}
+
+func TestSSHUsers_ContainsRoot(t *testing.T) {
+	tests := []struct {
+		name     string
+		users    SSHUsers
+		expected bool
+	}{
+		{
+			name:     "empty users",
+			users:    SSHUsers{},
+			expected: false,
+		},
+		{
+			name:     "contains root",
+			users:    SSHUsers{"root"},
+			expected: true,
+		},
+		{
+			name:     "does not contain root",
+			users:    SSHUsers{"ubuntu", "admin"},
+			expected: false,
+		},
+		{
+			name:     "contains root among others",
+			users:    SSHUsers{"ubuntu", "root", "admin"},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.users.ContainsRoot()
+			assert.Equal(t, tt.expected, result, "ContainsRoot() should return expected result")
+		})
+	}
+}
+
+func TestSSHUsers_ContainsNonRoot(t *testing.T) {
+	tests := []struct {
+		name     string
+		users    SSHUsers
+		expected bool
+	}{
+		{
+			name:     "empty users",
+			users:    SSHUsers{},
+			expected: false,
+		},
+		{
+			name:     "contains autogroup:nonroot",
+			users:    SSHUsers{SSHUser(AutoGroupNonRoot)},
+			expected: true,
+		},
+		{
+			name:     "does not contain autogroup:nonroot",
+			users:    SSHUsers{"ubuntu", "admin", "root"},
+			expected: false,
+		},
+		{
+			name:     "contains autogroup:nonroot among others",
+			users:    SSHUsers{"ubuntu", SSHUser(AutoGroupNonRoot), "admin"},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.users.ContainsNonRoot()
+			assert.Equal(t, tt.expected, result, "ContainsNonRoot() should return expected result")
+		})
+	}
+}
+
 func mustIPSet(prefixes ...string) *netipx.IPSet {
 	var builder netipx.IPSetBuilder
 	for _, p := range prefixes {