policy/v2: use Alias and SSHUser types in SSHPolicyTest

This commit is contained in:
Kristoffer Dalby committed 2026-05-13 10:45:03 +00:00
1 parent 63de6b2d38
commit 9b4f68257d
5 files changed
+127 -55

No files matched your search

+26 -26
View File
@@ -230,8 +230,13 @@ func runSSHPolicyTest(
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
cache map[types.NodeID]*tailcfg.SSHPolicy, cache map[types.NodeID]*tailcfg.SSHPolicy,
) SSHPolicyTestResult { ) SSHPolicyTestResult {
srcLabel := ""
if test.Src != nil {
srcLabel = test.Src.String()
}
res := SSHPolicyTestResult{ res := SSHPolicyTestResult{
Src: test.Src, Src: srcLabel,
Passed: true, Passed: true,
} }
@@ -239,7 +244,7 @@ func runSSHPolicyTest(
if err != nil { if err != nil {
res.Passed = false res.Passed = false
res.Errors = append(res.Errors, res.Errors = append(res.Errors,
fmt.Sprintf("failed to resolve source %q: %v", test.Src, err)) fmt.Sprintf("failed to resolve source %q: %v", srcLabel, err))
return res return res
} }
@@ -247,7 +252,7 @@ func runSSHPolicyTest(
if len(srcAddrs) == 0 { if len(srcAddrs) == 0 {
res.Passed = false res.Passed = false
res.Errors = append(res.Errors, res.Errors = append(res.Errors,
fmt.Sprintf("source %q resolved to no IP addresses", test.Src)) fmt.Sprintf("source %q resolved to no IP addresses", srcLabel))
return res return res
} }
@@ -290,7 +295,7 @@ func runSSHPolicyTest(
for _, user := range test.Accept { for _, user := range test.Accept {
evaluateAssertion( evaluateAssertion(
pol, users, nodes, cache, pol, users, nodes, cache,
srcAddrs, dstNodes, user, srcAddrs, dstNodes, user.String(),
assertAccept, &res, assertAccept, &res,
) )
} }
@@ -298,7 +303,7 @@ func runSSHPolicyTest(
for _, user := range test.Deny { for _, user := range test.Deny {
evaluateAssertion( evaluateAssertion(
pol, users, nodes, cache, pol, users, nodes, cache,
srcAddrs, dstNodes, user, srcAddrs, dstNodes, user.String(),
assertDeny, &res, assertDeny, &res,
) )
} }
@@ -306,7 +311,7 @@ func runSSHPolicyTest(
for _, user := range test.Check { for _, user := range test.Check {
evaluateAssertion( evaluateAssertion(
pol, users, nodes, cache, pol, users, nodes, cache,
srcAddrs, dstNodes, user, srcAddrs, dstNodes, user.String(),
assertCheck, &res, assertCheck, &res,
) )
} }
@@ -438,23 +443,22 @@ func appendUserDst(m map[string][]string, user, dst string) map[string][]string
return m return m
} }
// resolveSSHTestSource resolves the src alias into a list of // resolveSSHTestSource resolves the typed src alias into a list of
// netip.Addr (one per principal address the SSH compiler would emit // netip.Addr (one per principal address the SSH compiler would emit
// for the same source). For user-shaped sources, srcUserID returns the // for the same source). For user-shaped sources, srcUserID returns the
// resolved user's ID so autogroup:self destinations can scope to the // resolved user's ID so autogroup:self destinations can scope to the
// same user. Returns ID 0 when the source is a tag, host, or IP. // same user. Returns ID 0 when the source is a tag, host, or IP.
func resolveSSHTestSource( func resolveSSHTestSource(
src string, src Alias,
pol *Policy, pol *Policy,
users []types.User, users []types.User,
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
) ([]netip.Addr, uint, error) { ) ([]netip.Addr, uint, error) {
alias, err := parseAlias(src) if src == nil {
if err != nil { return nil, 0, nil
return nil, 0, fmt.Errorf("invalid alias: %w", err)
} }
addrs, err := alias.Resolve(pol, users, nodes) addrs, err := src.Resolve(pol, users, nodes)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("resolving: %w", err) return nil, 0, fmt.Errorf("resolving: %w", err)
} }
@@ -470,7 +474,7 @@ func resolveSSHTestSource(
var userID uint var userID uint
u, ok := alias.(*Username) u, ok := src.(*Username)
if ok { if ok {
resolved, rErr := u.resolveUser(users) resolved, rErr := u.resolveUser(users)
if rErr == nil { if rErr == nil {
@@ -490,7 +494,7 @@ func resolveSSHTestSource(
// node's IPs via InIPSet (the same primitive the SSH compiler uses to // node's IPs via InIPSet (the same primitive the SSH compiler uses to
// decide whether a node is a destination of a given rule). // decide whether a node is a destination of a given rule).
func resolveSSHTestDestNodes( func resolveSSHTestDestNodes(
dsts []string, dsts SSHTestDestinations,
pol *Policy, pol *Policy,
users []types.User, users []types.User,
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
@@ -503,12 +507,8 @@ func resolveSSHTestDestNodes(
emptyDsts []string emptyDsts []string
) )
for _, dst := range dsts { for _, alias := range dsts {
alias, err := parseAlias(dst) dstLabel := alias.String()
if err != nil {
return nil, nil, fmt.Errorf("invalid destination %q: %w", dst, err)
}
matched := false matched := false
if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { if ag, ok := alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
@@ -519,7 +519,7 @@ func resolveSSHTestDestNodes(
// (matches SaaS, which treats a no-node dst as a // (matches SaaS, which treats a no-node dst as a
// failing assertion). // failing assertion).
if srcUserID == 0 { if srcUserID == 0 {
emptyDsts = append(emptyDsts, dst) emptyDsts = append(emptyDsts, dstLabel)
continue continue
} }
@@ -548,7 +548,7 @@ func resolveSSHTestDestNodes(
} }
if !matched { if !matched {
emptyDsts = append(emptyDsts, dst) emptyDsts = append(emptyDsts, dstLabel)
} }
continue continue
@@ -556,11 +556,11 @@ func resolveSSHTestDestNodes(
ips, err := alias.Resolve(pol, users, nodes) ips, err := alias.Resolve(pol, users, nodes)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("resolving destination %q: %w", dst, err) return nil, nil, fmt.Errorf("resolving destination %q: %w", dstLabel, err)
} }
if ips == nil || ips.Empty() { if ips == nil || ips.Empty() {
emptyDsts = append(emptyDsts, dst) emptyDsts = append(emptyDsts, dstLabel)
continue continue
} }
@@ -570,7 +570,7 @@ func resolveSSHTestDestNodes(
// the resolved prefixes. // the resolved prefixes.
set, err := prefixesToIPSet(ips.Prefixes()) set, err := prefixesToIPSet(ips.Prefixes())
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("building IPSet for %q: %w", dst, err) return nil, nil, fmt.Errorf("building IPSet for %q: %w", dstLabel, err)
} }
for _, n := range nodes.All() { for _, n := range nodes.All() {
@@ -589,7 +589,7 @@ func resolveSSHTestDestNodes(
} }
if !matched { if !matched {
emptyDsts = append(emptyDsts, dst) emptyDsts = append(emptyDsts, dstLabel)
} }
} }
+71 -5
View File
@@ -7,6 +7,7 @@ import (
"slices" "slices"
"strings" "strings"
"github.com/go-json-experiment/json"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@@ -67,27 +68,92 @@ type PolicyTest struct {
type SSHPolicyTest struct { type SSHPolicyTest struct {
// Src is a single source alias (user, group, tag, host, or IP). Same // Src is a single source alias (user, group, tag, host, or IP). Same
// shape as PolicyTest.Src — Tailscale only supports one src per entry. // shape as PolicyTest.Src — Tailscale only supports one src per entry.
Src string `json:"src"` Src Alias `json:"src"`
// Dst lists destination host aliases the test exercises. Tags, hosts, // Dst lists destination host aliases the test exercises. Tags, hosts,
// and the SSH-compatible autogroups are valid; ports, CIDR ranges, and // and the SSH-compatible autogroups are valid; ports, CIDR ranges, and
// autogroup:internet are rejected at parse time. // autogroup:internet are rejected at parse time.
Dst []string `json:"dst"` Dst SSHTestDestinations `json:"dst"`
// Accept lists SSH login users that must be allowed by an action:accept // Accept lists SSH login users that must be allowed by an action:accept
// or action:check rule when Src connects to each entry in Dst. // or action:check rule when Src connects to each entry in Dst.
Accept []string `json:"accept,omitempty"` Accept []SSHUser `json:"accept,omitempty"`
// Deny lists SSH login users that must NOT be allowed by any rule when // Deny lists SSH login users that must NOT be allowed by any rule when
// Src connects to each entry in Dst. // Src connects to each entry in Dst.
Deny []string `json:"deny,omitempty"` Deny []SSHUser `json:"deny,omitempty"`
// Check lists SSH login users that must reach every dst via an // Check lists SSH login users that must reach every dst via an
// action:check rule specifically (the HoldAndDelegate signal on the // action:check rule specifically (the HoldAndDelegate signal on the
// compiled SSH policy). An action:accept rule alone does not satisfy // compiled SSH policy). An action:accept rule alone does not satisfy
// a check assertion — SaaS keeps the two categories distinct so // a check assertion — SaaS keeps the two categories distinct so
// policy authors can pin sensitive logins to check rules. // policy authors can pin sensitive logins to check rules.
Check []string `json:"check,omitempty"` Check []SSHUser `json:"check,omitempty"`
}
// SSHTestDestinations is the list of destination aliases an sshTests entry
// targets. Unmarshalling reuses the same alias parser the rest of the
// policy engine drives so each element lands as a typed Alias; the parse-
// time shape rules in validateSSHTestDestination continue to enforce the
// SSH-specific restrictions (no :port, no CIDR, no autogroup:internet,
// known tag).
type SSHTestDestinations []Alias
// UnmarshalJSON walks the JSON array, dispatching each element through
// AliasEnc so trimming and prefix detection match the rest of the parser.
func (d *SSHTestDestinations) UnmarshalJSON(b []byte) error {
var aliases []AliasEnc
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
if err != nil {
return err
}
*d = make([]Alias, len(aliases))
for i, a := range aliases {
(*d)[i] = a.Alias
}
return nil
}
// UnmarshalJSON drives the typed shape of SSHPolicyTest. The wire format
// is unchanged: src is a JSON string parsed through parseAlias; dst is an
// array of strings handled by SSHTestDestinations; accept/deny/check are
// arrays of strings handled per element by SSHUser.UnmarshalJSON. An
// empty src string lands as a nil Alias so the empty-src case stays a
// validation-time error with the SaaS-aligned ErrSSHTestEmptySrc body
// rather than a raw parser failure.
func (t *SSHPolicyTest) UnmarshalJSON(b []byte) error {
var raw struct {
Src string `json:"src"`
Dst SSHTestDestinations `json:"dst"`
Accept []SSHUser `json:"accept,omitempty"`
Deny []SSHUser `json:"deny,omitempty"`
Check []SSHUser `json:"check,omitempty"`
}
err := json.Unmarshal(b, &raw, policyJSONOpts...)
if err != nil {
return err
}
trimmedSrc := strings.TrimSpace(raw.Src)
if trimmedSrc != "" {
alias, parseErr := parseAlias(trimmedSrc)
if parseErr != nil {
return parseErr
}
t.Src = alias
}
t.Dst = raw.Dst
t.Accept = raw.Accept
t.Deny = raw.Deny
t.Check = raw.Check
return nil
} }
// PolicyTestResult is the outcome of a single PolicyTest. // PolicyTestResult is the outcome of a single PolicyTest.
+15 -12
View File
@@ -843,6 +843,12 @@ type Alias interface {
Validate() error Validate() error
UnmarshalJSON(b []byte) error UnmarshalJSON(b []byte) error
// String renders the alias back to its policy-file form. Implementations
// are expected to return a value that round-trips through parseAlias for
// any alias the parser accepted, so callers can use it as a stable
// identity in rendered errors and logs.
String() string
// Resolve resolves the Alias to an IPSet. The IPSet will contain all the IP // Resolve resolves the Alias to an IPSet. The IPSet will contain all the IP
// addresses that the Alias represents within Headscale. It is the product // addresses that the Alias represents within Headscale. It is the product
// of the Alias and the Policy, Users and Nodes. // of the Alias and the Policy, Users and Nodes.
@@ -3286,7 +3292,7 @@ func validateSSHTests(pol *Policy, tests []SSHPolicyTest) error {
var errs []error var errs []error
for i, t := range tests { for i, t := range tests {
if t.Src == "" { if t.Src == nil {
errs = append(errs, fmt.Errorf("sshTest %d: %w", i, ErrSSHTestEmptySrc)) errs = append(errs, fmt.Errorf("sshTest %d: %w", i, ErrSSHTestEmptySrc))
} }
@@ -3317,11 +3323,8 @@ func validateSSHTests(pol *Policy, tests []SSHPolicyTest) error {
// (only valid in ACL destinations, not SSH ones). Tag entries must // (only valid in ACL destinations, not SSH ones). Tag entries must
// reference a tag that exists in tagOwners; bare hosts must resolve to a // reference a tag that exists in tagOwners; bare hosts must resolve to a
// single-address prefix. // single-address prefix.
func validateSSHTestDestination(pol *Policy, dst string) error { func validateSSHTestDestination(pol *Policy, alias Alias) error {
alias, err := parseAlias(dst) dst := alias.String()
if err != nil {
return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst)
}
switch a := alias.(type) { switch a := alias.(type) {
case *AutoGroup: case *AutoGroup:
@@ -3333,12 +3336,12 @@ func validateSSHTestDestination(pol *Policy, dst string) error {
} }
case *Prefix: case *Prefix:
// A CIDR literal in dst is rejected. A bare IP parses as a Prefix // IP / CIDR dsts are rejected: SSH dsts name hosts, tags, or
// with no slash in the input string — distinguish on the raw text // SSH-compatible autogroups, not raw addresses. Bare IPs and
// the same way validateTestDestination does. // `/BitLen` literals parse to the same Prefix value so this
if strings.Contains(dst, "/") { // branch covers both with one disallowed-element answer.
return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst) _ = a
} return fmt.Errorf("%w %q", ErrSSHTestDstDisallowedElement, dst)
case *Tag: case *Tag:
// A tag must be declared in tagOwners. The `tag:server:22` shape // A tag must be declared in tagOwners. The `tag:server:22` shape
+9 -6
View File
@@ -6003,9 +6003,12 @@ func TestUnmarshalPolicySSHTests(t *testing.T) {
t.Helper() t.Helper()
require.Len(t, pol.SSHTests, 1) require.Len(t, pol.SSHTests, 1)
got := pol.SSHTests[0] got := pol.SSHTests[0]
require.Equal(t, "thor@example.org", got.Src) require.IsType(t, (*Username)(nil), got.Src)
require.Equal(t, []string{"tag:server"}, got.Dst) require.Equal(t, "thor@example.org", got.Src.String())
require.Equal(t, []string{"root"}, got.Accept) require.Len(t, got.Dst, 1)
require.IsType(t, (*Tag)(nil), got.Dst[0])
require.Equal(t, "tag:server", got.Dst[0].String())
require.Equal(t, []SSHUser{"root"}, got.Accept)
require.Empty(t, got.Deny) require.Empty(t, got.Deny)
require.Empty(t, got.Check) require.Empty(t, got.Check)
}, },
@@ -6030,9 +6033,9 @@ func TestUnmarshalPolicySSHTests(t *testing.T) {
t.Helper() t.Helper()
require.Len(t, pol.SSHTests, 1) require.Len(t, pol.SSHTests, 1)
got := pol.SSHTests[0] got := pol.SSHTests[0]
require.Equal(t, []string{"root"}, got.Accept) require.Equal(t, []SSHUser{"root"}, got.Accept)
require.Equal(t, []string{"nobody"}, got.Deny) require.Equal(t, []SSHUser{"nobody"}, got.Deny)
require.Equal(t, []string{"alice"}, got.Check) require.Equal(t, []SSHUser{"alice"}, got.Check) //nolint:goconst
}, },
}, },
{ {
+6 -6
View File
@@ -200,9 +200,9 @@ func TestSSHTestsRejectFailingPolicy(t *testing.T) {
}, },
SSHTests: []policyv2.SSHPolicyTest{ SSHTests: []policyv2.SSHPolicyTest{
{ {
Src: user1, Src: usernamep(user1),
Dst: []string{"autogroup:member"}, Dst: policyv2.SSHTestDestinations{new(policyv2.AutoGroupMember)},
Accept: []string{"root"}, Accept: []policyv2.SSHUser{policyv2.SSHUser("root")},
}, },
}, },
} }
@@ -215,9 +215,9 @@ func TestSSHTestsRejectFailingPolicy(t *testing.T) {
badPolicy := goodPolicy badPolicy := goodPolicy
badPolicy.SSHTests = []policyv2.SSHPolicyTest{ badPolicy.SSHTests = []policyv2.SSHPolicyTest{
{ {
Src: user2, Src: usernamep(user2),
Dst: []string{"autogroup:member"}, Dst: policyv2.SSHTestDestinations{new(policyv2.AutoGroupMember)},
Accept: []string{"root"}, Accept: []policyv2.SSHUser{policyv2.SSHUser("root")},
}, },
} }