policy/v2: add localpart:*@domain SSH user compilation

Add support for localpart:*@<domain> entries in SSH policy users.
When a user SSHes into a target, their email local-part becomes the
OS username (e.g. alice@example.com → OS user alice).

Type system (types.go):
- SSHUser.IsLocalpart() and ParseLocalpart() for validation
- SSHUsers.LocalpartEntries(), NormalUsers(), ContainsLocalpart()
- Enforces format: localpart:*@<domain> (wildcard-only)
- UserWildcard.Resolve for user:*@domain SSH source aliases
- acceptEnv passthrough for SSH rules

Compilation (filter.go):
- resolveLocalparts: pure function mapping users to local-parts
  by email domain. No node walking, easy to test.
- groupSourcesByUser: single walk producing per-user principals
  with sorted user IDs, and tagged principals separately.
- ipSetToPrincipals: shared helper replacing 6 inline copies.
- selfPrincipalsForNode: self-access using pre-computed byUser.

The approach separates data gathering from rule assembly. Localpart
rules are interleaved per source user to match Tailscale SaaS
first-match-wins ordering.

Updates #3049
This commit is contained in:
Kristoffer Dalby 2026-02-24 19:39:52 +00:00
parent 414d3bbbd8
commit 0acf09bdd2
5 changed files with 1452 additions and 220 deletions

View file

@ -1077,6 +1077,8 @@ func TestSSHPolicyRules(t *testing.T) {
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
{Name: "user3", Model: gorm.Model{ID: 3}},
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 4}},
{Name: "bob", Email: "bob@example.com", Model: gorm.Model{ID: 5}},
}
// Create standard node setups used across tests
@ -1110,6 +1112,20 @@ func TestSSHPolicyRules(t *testing.T) {
Tags: []string{"tag:server"},
}
// Nodes for localpart tests (users with email addresses)
nodeAlice := types.Node{
Hostname: "alice-device",
IPv4: ap("100.64.0.6"),
UserID: new(uint(4)),
User: new(users[3]),
}
nodeBob := types.Node{
Hostname: "bob-device",
IPv4: ap("100.64.0.7"),
UserID: new(uint(5)),
User: new(users[4]),
}
tests := []struct {
name string
targetNode types.Node
@ -1446,6 +1462,7 @@ func TestSSHPolicyRules(t *testing.T) {
},
SSHUsers: map[string]string{
"debian": "debian",
"root": "",
},
Action: &tailcfg.SSHAction{
Accept: true,
@ -1456,6 +1473,108 @@ func TestSSHPolicyRules(t *testing.T) {
},
}},
},
{
name: "localpart-maps-email-to-os-user",
targetNode: nodeTaggedServer,
peers: types.Nodes{&nodeAlice, &nodeBob},
policy: `{
"tagOwners": {
"tag:server": ["alice@example.com"]
},
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:server"],
"users": ["localpart:*@example.com"]
}
]
}`,
// Per-user common+localpart interleaved: each user gets root deny then localpart.
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
SSHUsers: map[string]string{"root": ""},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
SSHUsers: map[string]string{"alice": "alice"},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.7"}},
SSHUsers: map[string]string{"root": ""},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.7"}},
SSHUsers: map[string]string{"bob": "bob"},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
}},
},
{
name: "localpart-combined-with-root",
targetNode: nodeTaggedServer,
peers: types.Nodes{&nodeAlice},
policy: `{
"tagOwners": {
"tag:server": ["alice@example.com"]
},
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:server"],
"users": ["localpart:*@example.com", "root"]
}
]
}`,
// Common root rule followed by alice's per-user localpart rule (interleaved).
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
SSHUsers: map[string]string{"root": "root"},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
{
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
SSHUsers: map[string]string{"alice": "alice"},
Action: &tailcfg.SSHAction{
Accept: true,
AllowAgentForwarding: true,
AllowLocalPortForwarding: true,
AllowRemotePortForwarding: true,
},
},
}},
},
}
for _, tt := range tests {