mirror of
https://github.com/juanfont/headscale.git
synced 2026-02-07 20:04:00 +01:00
policy/v2: add localpart:*@domain SSH user type
Add support for the localpart:*@<domain> SSH user specifier, which maps the local-part of a user's email address to an OS username for SSH access. For example, user alice@example.com would SSH as local user "alice". - Add SSHUserLocalpartPrefix constant and SSHUser methods: IsLocalpart, ParseLocalpart - Add SSHUsers methods: ContainsLocalpart, LocalpartEntries - Update NormalUsers to exclude localpart entries - Add validation in Policy.validate for localpart format - Add comprehensive unit tests Updates #3049
This commit is contained in:
parent
d15ec28799
commit
537f3b1fa1
@ -43,6 +43,7 @@ var (
|
||||
ErrSSHAutogroupSelfRequiresUserSource = errors.New("autogroup:self destination requires source to contain only users or groups, not tags or autogroup:tagged")
|
||||
ErrSSHTagSourceToAutogroupMember = errors.New("tags in SSH source cannot access autogroup:member (user-owned devices)")
|
||||
ErrSSHWildcardDestination = errors.New("wildcard (*) is not supported as SSH destination")
|
||||
ErrInvalidLocalpart = errors.New("invalid localpart format, must be localpart:*@<domain>")
|
||||
)
|
||||
|
||||
// ACL validation errors.
|
||||
@ -1953,6 +1954,14 @@ func (p *Policy) validate() error {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if user.IsLocalpart() {
|
||||
_, err := user.ParseLocalpart()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, src := range ssh.Sources {
|
||||
@ -2255,6 +2264,11 @@ type SSHDstAliases []Alias
|
||||
|
||||
type SSHUsers []SSHUser
|
||||
|
||||
// SSHUserLocalpartPrefix is the prefix for localpart SSH user entries.
|
||||
// Format: localpart:*@<domain>
|
||||
// See: https://tailscale.com/docs/features/tailscale-ssh#users
|
||||
const SSHUserLocalpartPrefix = "localpart:"
|
||||
|
||||
func (u SSHUsers) ContainsRoot() bool {
|
||||
return slices.Contains(u, "root")
|
||||
}
|
||||
@ -2263,9 +2277,25 @@ func (u SSHUsers) ContainsNonRoot() bool {
|
||||
return slices.Contains(u, SSHUser(AutoGroupNonRoot))
|
||||
}
|
||||
|
||||
// ContainsLocalpart returns true if any entry has the localpart: prefix.
|
||||
func (u SSHUsers) ContainsLocalpart() bool {
|
||||
return slices.ContainsFunc(u, func(user SSHUser) bool {
|
||||
return user.IsLocalpart()
|
||||
})
|
||||
}
|
||||
|
||||
// NormalUsers returns all SSH users that are not root, autogroup:nonroot,
|
||||
// or localpart: entries.
|
||||
func (u SSHUsers) NormalUsers() []SSHUser {
|
||||
return slicesx.Filter(nil, u, func(user SSHUser) bool {
|
||||
return user != "root" && user != SSHUser(AutoGroupNonRoot)
|
||||
return user != "root" && user != SSHUser(AutoGroupNonRoot) && !user.IsLocalpart()
|
||||
})
|
||||
}
|
||||
|
||||
// LocalpartEntries returns only the localpart: prefixed entries.
|
||||
func (u SSHUsers) LocalpartEntries() []SSHUser {
|
||||
return slicesx.Filter(nil, u, func(user SSHUser) bool {
|
||||
return user.IsLocalpart()
|
||||
})
|
||||
}
|
||||
|
||||
@ -2275,6 +2305,41 @@ func (u SSHUser) String() string {
|
||||
return string(u)
|
||||
}
|
||||
|
||||
// IsLocalpart returns true if the SSHUser has the localpart: prefix.
|
||||
func (u SSHUser) IsLocalpart() bool {
|
||||
return strings.HasPrefix(string(u), SSHUserLocalpartPrefix)
|
||||
}
|
||||
|
||||
// ParseLocalpart validates and extracts the domain from a localpart: entry.
|
||||
// The expected format is localpart:*@<domain>.
|
||||
// Returns the domain part or an error if the format is invalid.
|
||||
func (u SSHUser) ParseLocalpart() (string, error) {
|
||||
if !u.IsLocalpart() {
|
||||
return "", fmt.Errorf("%w: missing prefix %q in %q", ErrInvalidLocalpart, SSHUserLocalpartPrefix, u)
|
||||
}
|
||||
|
||||
pattern := strings.TrimPrefix(string(u), SSHUserLocalpartPrefix)
|
||||
|
||||
// Must be *@<domain>
|
||||
atIdx := strings.LastIndex(pattern, "@")
|
||||
if atIdx < 0 {
|
||||
return "", fmt.Errorf("%w: missing @ in %q", ErrInvalidLocalpart, u)
|
||||
}
|
||||
|
||||
localPart := pattern[:atIdx]
|
||||
domain := pattern[atIdx+1:]
|
||||
|
||||
if localPart != "*" {
|
||||
return "", fmt.Errorf("%w: local part must be *, got %q in %q", ErrInvalidLocalpart, localPart, u)
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("%w: empty domain in %q", ErrInvalidLocalpart, u)
|
||||
}
|
||||
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// MarshalJSON marshals the SSHUser to JSON.
|
||||
func (u SSHUser) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(u))
|
||||
|
||||
@ -1766,6 +1766,105 @@ func TestUnmarshalPolicy(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh-localpart-valid",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {"tag:prod": ["admin@"]},
|
||||
"ssh": [{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:prod"],
|
||||
"users": ["localpart:*@example.com"]
|
||||
}]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:prod"): Owners{up("admin@")},
|
||||
},
|
||||
SSHs: []SSH{
|
||||
{
|
||||
Action: "accept",
|
||||
Sources: SSHSrcAliases{agp("autogroup:member")},
|
||||
Destinations: SSHDstAliases{tp("tag:prod")},
|
||||
Users: []SSHUser{SSHUser("localpart:*@example.com")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh-localpart-with-other-users",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {"tag:prod": ["admin@"]},
|
||||
"ssh": [{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:prod"],
|
||||
"users": ["localpart:*@example.com", "root", "autogroup:nonroot"]
|
||||
}]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:prod"): Owners{up("admin@")},
|
||||
},
|
||||
SSHs: []SSH{
|
||||
{
|
||||
Action: "accept",
|
||||
Sources: SSHSrcAliases{agp("autogroup:member")},
|
||||
Destinations: SSHDstAliases{tp("tag:prod")},
|
||||
Users: []SSHUser{SSHUser("localpart:*@example.com"), "root", SSHUser(AutoGroupNonRoot)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh-localpart-invalid-no-at-sign",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {"tag:prod": ["admin@"]},
|
||||
"ssh": [{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:prod"],
|
||||
"users": ["localpart:foo"]
|
||||
}]
|
||||
}
|
||||
`,
|
||||
wantErr: "invalid localpart format",
|
||||
},
|
||||
{
|
||||
name: "ssh-localpart-invalid-non-wildcard",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {"tag:prod": ["admin@"]},
|
||||
"ssh": [{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:prod"],
|
||||
"users": ["localpart:alice@example.com"]
|
||||
}]
|
||||
}
|
||||
`,
|
||||
wantErr: "invalid localpart format",
|
||||
},
|
||||
{
|
||||
name: "ssh-localpart-invalid-empty-domain",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {"tag:prod": ["admin@"]},
|
||||
"ssh": [{
|
||||
"action": "accept",
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["tag:prod"],
|
||||
"users": ["localpart:*@"]
|
||||
}]
|
||||
}
|
||||
`,
|
||||
wantErr: "invalid localpart format",
|
||||
},
|
||||
}
|
||||
|
||||
cmps := append(util.Comparers,
|
||||
@ -2576,6 +2675,154 @@ func TestSSHUsers_ContainsNonRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUsers_ContainsLocalpart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
users SSHUsers
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty users",
|
||||
users: SSHUsers{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "contains localpart",
|
||||
users: SSHUsers{SSHUser("localpart:*@example.com")},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "does not contain localpart",
|
||||
users: SSHUsers{"ubuntu", "admin", "root"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "contains localpart among others",
|
||||
users: SSHUsers{"ubuntu", SSHUser("localpart:*@example.com"), "admin"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple localpart entries",
|
||||
users: SSHUsers{SSHUser("localpart:*@a.com"), SSHUser("localpart:*@b.com")},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.users.ContainsLocalpart()
|
||||
assert.Equal(t, tt.expected, result, "ContainsLocalpart() should return expected result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUsers_LocalpartEntries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
users SSHUsers
|
||||
expected []SSHUser
|
||||
}{
|
||||
{
|
||||
name: "empty users",
|
||||
users: SSHUsers{},
|
||||
expected: []SSHUser{},
|
||||
},
|
||||
{
|
||||
name: "no localpart entries",
|
||||
users: SSHUsers{"root", "ubuntu", SSHUser(AutoGroupNonRoot)},
|
||||
expected: []SSHUser{},
|
||||
},
|
||||
{
|
||||
name: "single localpart entry",
|
||||
users: SSHUsers{"root", SSHUser("localpart:*@example.com"), "ubuntu"},
|
||||
expected: []SSHUser{SSHUser("localpart:*@example.com")},
|
||||
},
|
||||
{
|
||||
name: "multiple localpart entries",
|
||||
users: SSHUsers{SSHUser("localpart:*@a.com"), "root", SSHUser("localpart:*@b.com")},
|
||||
expected: []SSHUser{SSHUser("localpart:*@a.com"), SSHUser("localpart:*@b.com")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.users.LocalpartEntries()
|
||||
assert.ElementsMatch(t, tt.expected, result, "LocalpartEntries() should return expected entries")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUsers_NormalUsers_ExcludesLocalpart(t *testing.T) {
|
||||
users := SSHUsers{
|
||||
"ubuntu",
|
||||
"root",
|
||||
SSHUser(AutoGroupNonRoot),
|
||||
SSHUser("localpart:*@example.com"),
|
||||
"admin",
|
||||
}
|
||||
|
||||
result := users.NormalUsers()
|
||||
assert.ElementsMatch(t, []SSHUser{"ubuntu", "admin"}, result,
|
||||
"NormalUsers() should exclude root, autogroup:nonroot, and localpart entries")
|
||||
}
|
||||
|
||||
func TestSSHUser_ParseLocalpart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user SSHUser
|
||||
expectedDomain string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid localpart",
|
||||
user: SSHUser("localpart:*@example.com"),
|
||||
expectedDomain: "example.com",
|
||||
},
|
||||
{
|
||||
name: "valid localpart with subdomain",
|
||||
user: SSHUser("localpart:*@corp.example.com"),
|
||||
expectedDomain: "corp.example.com",
|
||||
},
|
||||
{
|
||||
name: "missing prefix",
|
||||
user: SSHUser("ubuntu"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing @ sign",
|
||||
user: SSHUser("localpart:foo"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-wildcard local part",
|
||||
user: SSHUser("localpart:alice@example.com"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty domain",
|
||||
user: SSHUser("localpart:*@"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "just prefix",
|
||||
user: SSHUser("localpart:"),
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
domain, err := tt.user.ParseLocalpart()
|
||||
if tt.expectErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedDomain, domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustIPSet(prefixes ...string) *netipx.IPSet {
|
||||
var builder netipx.IPSetBuilder
|
||||
for _, p := range prefixes {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user