hscontrol/policy/v2: add Grant policy format support

Add support for the Grant policy format as an alternative to ACL format,
following Tailscale's policy v2 specification. Grants provide a more
structured way to define network access rules with explicit separation
of IP-based and capability-based permissions.

Key changes:

- Add Grant struct with Sources, Destinations, InternetProtocols (ip),
  and App (capabilities) fields
- Add ProtocolPort type for unmarshaling protocol:port strings
- Add Grant validation in Policy.validate() to enforce:
  - Mutual exclusivity of ip and app fields
  - Required ip or app field presence
  - Non-empty sources and destinations
- Refactor compileFilterRules to support both ACLs and Grants
- Convert ACLs to Grants internally via aclToGrants() for unified
  processing
- Extract destinationsToNetPortRange() helper for cleaner code
- Rename parseProtocol() to toIANAProtocolNumbers() for clarity
- Add ProtocolNumberToName mapping for reverse lookups

The Grant format allows policies to be written using either the legacy
ACL format or the new Grant format. ACLs are converted to Grants
internally, ensuring backward compatibility while enabling the new
format's benefits.

Updates #2180
This commit is contained in:
Kristoffer Dalby
2026-02-23 04:18:31 +01:00
committed by Kristoffer Dalby
parent 53b8a81d48
commit f74ea5b8ed
4 changed files with 1313 additions and 179 deletions

View File

@@ -34,12 +34,13 @@ func (pol *Policy) compileFilterRules(
var rules []tailcfg.FilterRule var rules []tailcfg.FilterRule
grants := pol.Grants
for _, acl := range pol.ACLs { for _, acl := range pol.ACLs {
if acl.Action != ActionAccept { grants = append(grants, aclToGrants(acl)...)
return nil, ErrInvalidAction }
}
srcIPs, err := acl.Sources.Resolve(pol, users, nodes) for _, grant := range grants {
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
if err != nil { if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving source ips") log.Trace().Caller().Err(err).Msgf("resolving source ips")
} }
@@ -48,66 +49,96 @@ func (pol *Policy) compileFilterRules(
continue continue
} }
protocols := acl.Protocol.parseProtocol() for _, ipp := range grant.InternetProtocols {
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
var destPorts []tailcfg.NetPortRange if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
for _, dest := range acl.Destinations { SrcIPs: ipSetToPrefixStringList(srcIPs),
// Check if destination is a wildcard - use "*" directly instead of expanding DstPorts: destPorts,
if _, isWildcard := dest.Alias.(Asterix); isWildcard { IPProto: ipp.Protocol.toIANAProtocolNumbers(),
for _, port := range dest.Ports { })
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: "*",
Ports: port,
})
}
continue
}
// autogroup:internet does not generate packet filters - it's handled
// by exit node routing via AllowedIPs, not by packet filtering.
if ag, isAutoGroup := dest.Alias.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
continue
}
ips, err := dest.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
}
if ips == nil {
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
continue
}
prefixes := ips.Prefixes()
for _, pref := range prefixes {
for _, port := range dest.Ports {
pr := tailcfg.NetPortRange{
IP: pref.String(),
Ports: port,
}
destPorts = append(destPorts, pr)
}
} }
} }
if len(destPorts) == 0 { if grant.App != nil {
continue var capGrants []tailcfg.CapGrant
}
rules = append(rules, tailcfg.FilterRule{ for _, dst := range grant.Destinations {
SrcIPs: ipSetToPrefixStringList(srcIPs), ips, err := dst.Resolve(pol, users, nodes)
DstPorts: destPorts, if err != nil {
IPProto: protocols, continue
}) }
capGrants = append(capGrants, tailcfg.CapGrant{
Dsts: ips.Prefixes(),
CapMap: grant.App,
})
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcIPs),
CapGrant: capGrants,
})
}
} }
return mergeFilterRules(rules), nil return mergeFilterRules(rules), nil
} }
func (pol *Policy) destinationsToNetPortRange(
users types.Users,
nodes views.Slice[types.NodeView],
dests Aliases,
ports []tailcfg.PortRange,
) []tailcfg.NetPortRange {
var ret []tailcfg.NetPortRange
for _, dest := range dests {
// Check if destination is a wildcard - use "*" directly instead of expanding
if _, isWildcard := dest.(Asterix); isWildcard {
for _, port := range ports {
ret = append(ret, tailcfg.NetPortRange{
IP: "*",
Ports: port,
})
}
continue
}
// autogroup:internet does not generate packet filters - it's handled
// by exit node routing via AllowedIPs, not by packet filtering.
if ag, isAutoGroup := dest.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
continue
}
ips, err := dest.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
}
if ips == nil {
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
continue
}
prefixes := ips.Prefixes()
for _, pref := range prefixes {
for _, port := range ports {
pr := tailcfg.NetPortRange{
IP: pref.String(),
Ports: port,
}
ret = append(ret, pr)
}
}
}
return ret
}
// compileFilterRulesForNode compiles filter rules for a specific node. // compileFilterRulesForNode compiles filter rules for a specific node.
func (pol *Policy) compileFilterRulesForNode( func (pol *Policy) compileFilterRulesForNode(
users types.Users, users types.Users,
@@ -120,60 +151,55 @@ func (pol *Policy) compileFilterRulesForNode(
var rules []tailcfg.FilterRule var rules []tailcfg.FilterRule
grants := pol.Grants
for _, acl := range pol.ACLs { for _, acl := range pol.ACLs {
if acl.Action != ActionAccept { grants = append(grants, aclToGrants(acl)...)
return nil, ErrInvalidAction }
}
aclRules, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes) for _, grant := range grants {
res, err := pol.compileGrantWithAutogroupSelf(grant, users, node, nodes)
if err != nil { if err != nil {
log.Trace().Err(err).Msgf("compiling ACL") log.Trace().Err(err).Msgf("compiling ACL")
continue continue
} }
for _, rule := range aclRules { rules = append(rules, res...)
if rule != nil {
rules = append(rules, *rule)
}
}
} }
return mergeFilterRules(rules), nil return mergeFilterRules(rules), nil
} }
// compileACLWithAutogroupSelf compiles a single ACL rule, handling // compileGrantWithAutogroupSelf compiles a single Grant rule, handling
// autogroup:self per-node while supporting all other alias types normally. // autogroup:self per-node while supporting all other alias types normally.
// It returns a slice of filter rules because when an ACL has both autogroup:self // It returns a slice of filter rules because when an Grant has both autogroup:self
// and other destinations, they need to be split into separate rules with different // and other destinations, they need to be split into separate rules with different
// source filtering logic. // source filtering logic.
// //
//nolint:gocyclo // complex ACL compilation logic //nolint:gocyclo // complex ACL compilation logic
func (pol *Policy) compileACLWithAutogroupSelf( func (pol *Policy) compileGrantWithAutogroupSelf(
acl ACL, grant Grant,
users types.Users, users types.Users,
node types.NodeView, node types.NodeView,
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
) ([]*tailcfg.FilterRule, error) { ) ([]tailcfg.FilterRule, error) {
var ( var (
autogroupSelfDests []AliasWithPorts autogroupSelfDests []Alias
otherDests []AliasWithPorts otherDests []Alias
) )
for _, dest := range acl.Destinations { for _, dest := range grant.Destinations {
if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
autogroupSelfDests = append(autogroupSelfDests, dest) autogroupSelfDests = append(autogroupSelfDests, dest)
} else { } else {
otherDests = append(otherDests, dest) otherDests = append(otherDests, dest)
} }
} }
protocols := acl.Protocol.parseProtocol() var rules []tailcfg.FilterRule
var rules []*tailcfg.FilterRule
var resolvedSrcIPs []*netipx.IPSet var resolvedSrcIPs []*netipx.IPSet
for _, src := range acl.Sources { for _, src := range grant.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) { if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, errSelfInSources return nil, errSelfInSources
} }
@@ -192,42 +218,42 @@ func (pol *Policy) compileACLWithAutogroupSelf(
return rules, nil return rules, nil
} }
// Handle autogroup:self destinations (if any) for _, ipp := range grant.InternetProtocols {
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based) // Handle autogroup:self destinations (if any)
if len(autogroupSelfDests) > 0 && !node.IsTagged() { // Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations if len(autogroupSelfDests) > 0 && !node.IsTagged() {
sameUserNodes := make([]types.NodeView, 0) // Pre-filter to same-user untagged devices once - reuse for both sources and destinations
sameUserNodes := make([]types.NodeView, 0)
for _, n := range nodes.All() { for _, n := range nodes.All() {
if !n.IsTagged() && n.User().ID() == node.User().ID() { if !n.IsTagged() && n.User().ID() == node.User().ID() {
sameUserNodes = append(sameUserNodes, n) sameUserNodes = append(sameUserNodes, n)
}
}
if len(sameUserNodes) > 0 {
// Filter sources to only same-user untagged devices
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcIPs {
for _, n := range sameUserNodes {
// Check if any of this node's IPs are in the source set
if slices.ContainsFunc(n.IPs(), ips.Contains) {
n.AppendToIPSet(&srcIPs)
}
} }
} }
srcSet, err := srcIPs.IPSet() if len(sameUserNodes) > 0 {
if err != nil { // Filter sources to only same-user untagged devices
return nil, err var srcIPs netipx.IPSetBuilder
}
if srcSet != nil && len(srcSet.Prefixes()) > 0 { for _, ips := range resolvedSrcIPs {
var destPorts []tailcfg.NetPortRange
for _, dest := range autogroupSelfDests {
for _, n := range sameUserNodes { for _, n := range sameUserNodes {
for _, port := range dest.Ports { // Check if any of this node's IPs are in the source set
if slices.ContainsFunc(n.IPs(), ips.Contains) {
n.AppendToIPSet(&srcIPs)
}
}
}
srcSet, err := srcIPs.IPSet()
if err != nil {
return nil, err
}
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
var destPorts []tailcfg.NetPortRange
for _, n := range sameUserNodes {
for _, port := range ipp.Ports {
for _, ip := range n.IPs() { for _, ip := range n.IPs() {
destPorts = append(destPorts, tailcfg.NetPortRange{ destPorts = append(destPorts, tailcfg.NetPortRange{
IP: netip.PrefixFrom(ip, ip.BitLen()).String(), IP: netip.PrefixFrom(ip, ip.BitLen()).String(),
@@ -236,82 +262,40 @@ func (pol *Policy) compileACLWithAutogroupSelf(
} }
} }
} }
}
if len(destPorts) > 0 { if len(destPorts) > 0 {
rules = append(rules, &tailcfg.FilterRule{ rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcSet), SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts, DstPorts: destPorts,
IPProto: protocols, IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
}
}
if len(otherDests) > 0 {
var srcIPs netipx.IPSetBuilder
for _, ips := range resolvedSrcIPs {
srcIPs.AddSet(ips)
}
srcSet, err := srcIPs.IPSet()
if err != nil {
return nil, err
}
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
var destPorts []tailcfg.NetPortRange
for _, dest := range otherDests {
// Check if destination is a wildcard - use "*" directly instead of expanding
if _, isWildcard := dest.Alias.(Asterix); isWildcard {
for _, port := range dest.Ports {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: "*",
Ports: port,
}) })
} }
continue
}
// autogroup:internet does not generate packet filters - it's handled
// by exit node routing via AllowedIPs, not by packet filtering.
if ag, isAutoGroup := dest.Alias.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
continue
}
ips, err := dest.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
}
if ips == nil {
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
continue
}
prefixes := ips.Prefixes()
for _, pref := range prefixes {
for _, port := range dest.Ports {
pr := tailcfg.NetPortRange{
IP: pref.String(),
Ports: port,
}
destPorts = append(destPorts, pr)
}
} }
} }
}
if len(destPorts) > 0 { if len(otherDests) > 0 {
rules = append(rules, &tailcfg.FilterRule{ var srcIPs netipx.IPSetBuilder
SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts, for _, ips := range resolvedSrcIPs {
IPProto: protocols, srcIPs.AddSet(ips)
}) }
srcSet, err := srcIPs.IPSet()
if err != nil {
return nil, err
}
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
} }
} }
} }

View File

@@ -63,6 +63,18 @@ var (
ErrACLAutogroupSelfInvalidSource = errors.New("autogroup:self destination requires sources to be users, groups, or autogroup:member only") ErrACLAutogroupSelfInvalidSource = errors.New("autogroup:self destination requires sources to be users, groups, or autogroup:member only")
) )
// Grant validation errors.
var (
ErrGrantIPAndAppMutuallyExclusive = errors.New("grants cannot specify both 'ip' and 'app' fields")
ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field")
ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy")
ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale")
ErrGrantAppProtocolConflict = errors.New("grants with 'app' cannot specify 'ip' protocols")
ErrGrantEmptySources = errors.New("grant sources cannot be empty")
ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty")
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
)
// Policy validation errors. // Policy validation errors.
var ( var (
ErrUnknownAliasType = errors.New("unknown alias type") ErrUnknownAliasType = errors.New("unknown alias type")
@@ -738,6 +750,98 @@ func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
return nil return nil
} }
// ProtocolPort is a representation of the "network layer capabilities"
// of a Grant.
type ProtocolPort struct {
Ports []tailcfg.PortRange
Protocol Protocol
}
func (ve *ProtocolPort) UnmarshalJSON(b []byte) error {
var v any
err := json.Unmarshal(b, &v)
if err != nil {
return err
}
switch vs := v.(type) {
case string:
if vs == "*" {
ve.Protocol = ProtocolNameWildcard
ve.Ports = []tailcfg.PortRange{tailcfg.PortRangeAny}
return nil
}
// Only contains a port, no protocol
if !strings.Contains(vs, ":") {
ports, err := parsePortRange(vs)
if err != nil {
return err
}
ve.Protocol = ProtocolNameWildcard
ve.Ports = ports
return nil
}
parts := strings.Split(vs, ":")
if len(parts) != 2 {
return fmt.Errorf("%w, got: %v(%d)", ErrProtocolPortInvalidFormat, parts, len(parts))
}
protocol := Protocol(parts[0])
err := protocol.validate()
if err != nil {
return err
}
portsPart := parts[1]
ports, err := parsePortRange(portsPart)
if err != nil {
return err
}
ve.Protocol = protocol
ve.Ports = ports
default:
return fmt.Errorf("%w: %T", ErrTypeNotSupported, vs)
}
return nil
}
func (ve ProtocolPort) MarshalJSON() ([]byte, error) {
// Handle wildcard protocol with all ports
if ve.Protocol == ProtocolNameWildcard && len(ve.Ports) == 1 &&
ve.Ports[0].First == 0 && ve.Ports[0].Last == 65535 {
return json.Marshal("*")
}
// Build port string
var portParts []string
for _, portRange := range ve.Ports {
if portRange.First == portRange.Last {
portParts = append(portParts, strconv.FormatUint(uint64(portRange.First), 10))
} else {
portParts = append(portParts, fmt.Sprintf("%d-%d", portRange.First, portRange.Last))
}
}
portStr := strings.Join(portParts, ",")
// Combine protocol and ports
result := fmt.Sprintf("%s:%s", ve.Protocol, portStr)
return json.Marshal(result)
}
func isWildcard(str string) bool { func isWildcard(str string) bool {
return str == "*" return str == "*"
} }
@@ -1467,9 +1571,9 @@ func (p *Protocol) Description() string {
} }
} }
// parseProtocol converts a Protocol to its IANA protocol numbers. // toIANAProtocolNumbers converts a Protocol to its IANA protocol numbers.
// Since validation happens during UnmarshalJSON, this method should not fail for valid Protocol values. // Since validation happens during UnmarshalJSON, this method should not fail for valid Protocol values.
func (p *Protocol) parseProtocol() []int { func (p *Protocol) toIANAProtocolNumbers() []int {
switch *p { switch *p {
case "": case "":
// Empty protocol applies to TCP, UDP, ICMP, and ICMPv6 traffic // Empty protocol applies to TCP, UDP, ICMP, and ICMPv6 traffic
@@ -1583,6 +1687,23 @@ const (
ProtocolFC = 133 // Fibre Channel ProtocolFC = 133 // Fibre Channel
) )
// ProtocolNumberToName maps IANA protocol numbers to their protocol name strings.
var ProtocolNumberToName = map[int]Protocol{
ProtocolICMP: ProtocolNameICMP,
ProtocolIGMP: ProtocolNameIGMP,
ProtocolIPv4: ProtocolNameIPv4,
ProtocolTCP: ProtocolNameTCP,
ProtocolEGP: ProtocolNameEGP,
ProtocolIGP: ProtocolNameIGP,
ProtocolUDP: ProtocolNameUDP,
ProtocolGRE: ProtocolNameGRE,
ProtocolESP: ProtocolNameESP,
ProtocolAH: ProtocolNameAH,
ProtocolIPv6ICMP: ProtocolNameIPv6ICMP,
ProtocolSCTP: ProtocolNameSCTP,
ProtocolFC: ProtocolNameFC,
}
type ACL struct { type ACL struct {
Action Action `json:"action"` Action Action `json:"action"`
Protocol Protocol `json:"proto"` Protocol Protocol `json:"proto"`
@@ -1632,6 +1753,39 @@ func (a *ACL) UnmarshalJSON(b []byte) error {
return nil return nil
} }
type Grant struct {
// TODO(kradalby): Validate grant src/dst according to ts docs
Sources Aliases `json:"src"`
Destinations Aliases `json:"dst"`
// TODO(kradalby): validate that either of these fields are included
InternetProtocols []ProtocolPort `json:"ip,omitempty"`
App tailcfg.PeerCapMap `json:"app,omitzero"`
// TODO(kradalby): implement via
Via []Tag `json:"via,omitzero"`
}
// aclToGrants converts an ACL rule to one or more equivalent Grant rules.
func aclToGrants(acl ACL) []Grant {
ret := make([]Grant, 0, len(acl.Destinations))
for _, dst := range acl.Destinations {
g := Grant{
Sources: acl.Sources,
Destinations: Aliases{dst.Alias},
InternetProtocols: []ProtocolPort{{
Protocol: acl.Protocol,
Ports: dst.Ports,
}},
}
ret = append(ret, g)
}
return ret
}
// Policy represents a Tailscale Network Policy. // Policy represents a Tailscale Network Policy.
// TODO(kradalby): // TODO(kradalby):
// Add validation method checking: // Add validation method checking:
@@ -1649,6 +1803,7 @@ type Policy struct {
Hosts Hosts `json:"hosts,omitempty"` Hosts Hosts `json:"hosts,omitempty"`
TagOwners TagOwners `json:"tagOwners,omitempty"` TagOwners TagOwners `json:"tagOwners,omitempty"`
ACLs []ACL `json:"acls,omitempty"` ACLs []ACL `json:"acls,omitempty"`
Grants []Grant `json:"grants,omitempty"`
AutoApprovers AutoApproverPolicy `json:"autoApprovers"` AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
SSHs []SSH `json:"ssh,omitempty"` SSHs []SSH `json:"ssh,omitempty"`
} }
@@ -2055,6 +2210,124 @@ func (p *Policy) validate() error {
} }
} }
for _, grant := range p.Grants {
// Validate ip/app mutual exclusivity
hasIP := len(grant.InternetProtocols) > 0
hasApp := len(grant.App) > 0
if hasIP && hasApp {
errs = append(errs, ErrGrantIPAndAppMutuallyExclusive)
}
if !hasIP && !hasApp {
errs = append(errs, ErrGrantMissingIPOrApp)
}
// Validate sources
if len(grant.Sources) == 0 {
errs = append(errs, ErrGrantEmptySources)
}
for _, src := range grant.Sources {
switch src := src.(type) {
case *Host:
h := src
if !p.Hosts.exist(*h) {
errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *h))
}
case *AutoGroup:
ag := src
err := validateAutogroupSupported(ag)
if err != nil {
errs = append(errs, err)
continue
}
err = validateAutogroupForSrc(ag)
if err != nil {
errs = append(errs, err)
continue
}
case *Group:
g := src
err := p.Groups.Contains(g)
if err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := src
err := p.TagOwners.Contains(tagOwner)
if err != nil {
errs = append(errs, err)
}
}
}
// Validate destinations
if len(grant.Destinations) == 0 {
errs = append(errs, ErrGrantEmptyDestinations)
}
for _, dst := range grant.Destinations {
switch h := dst.(type) {
case *Host:
if !p.Hosts.exist(*h) {
errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *h))
}
case *AutoGroup:
err := validateAutogroupSupported(h)
if err != nil {
errs = append(errs, err)
continue
}
err = validateAutogroupForDst(h)
if err != nil {
errs = append(errs, err)
continue
}
case *Group:
err := p.Groups.Contains(h)
if err != nil {
errs = append(errs, err)
}
case *Tag:
err := p.TagOwners.Contains(h)
if err != nil {
errs = append(errs, err)
}
}
}
// Validate via tags
for _, viaTag := range grant.Via {
err := p.TagOwners.Contains(&viaTag)
if err != nil {
errs = append(errs, fmt.Errorf("%w in grant via: %q", ErrGrantInvalidViaTag, viaTag))
}
}
// Validate ACL source/destination combinations follow Tailscale's security model
// (Grants use same rules as ACLs for autogroup:self and other constraints)
// Convert grant destinations to AliasWithPorts format for validation
var dstWithPorts []AliasWithPorts
for _, dst := range grant.Destinations {
// For grants, we don't have per-destination ports, so use wildcard
dstWithPorts = append(dstWithPorts, AliasWithPorts{
Alias: dst,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
})
}
err := validateACLSrcDstCombination(grant.Sources, dstWithPorts)
if err != nil {
errs = append(errs, err)
}
}
for _, tagOwners := range p.TagOwners { for _, tagOwners := range p.TagOwners {
for _, tagOwner := range tagOwners { for _, tagOwner := range tagOwners {
switch tagOwner := tagOwner.(type) { switch tagOwner := tagOwner.(type) {

View File

@@ -1,6 +1,7 @@
package v2 package v2
import ( import (
"bytes"
"encoding/json" "encoding/json"
"net/netip" "net/netip"
"strings" "strings"
@@ -4283,3 +4284,879 @@ func TestSSHCheckPeriodPolicyValidation(t *testing.T) {
}) })
} }
} }
func TestUnmarshalGrants(t *testing.T) {
tests := []struct {
name string
input string
want *Policy
wantErr string
}{
{
name: "valid-grant-with-ip-field",
input: `
{
"groups": {
"group:eng": ["alice@example.com"]
},
"tagOwners": {
"tag:server": ["group:eng"]
},
"grants": [
{
"src": ["group:eng"],
"dst": ["tag:server"],
"ip": ["tcp:443", "tcp:80"]
}
]
}
`,
want: &Policy{
Groups: Groups{
Group("group:eng"): []Username{Username("alice@example.com")},
},
TagOwners: TagOwners{
Tag("tag:server"): Owners{gp("group:eng")},
},
Grants: []Grant{
{
Sources: Aliases{
gp("group:eng"),
},
Destinations: Aliases{
tp("tag:server"),
},
InternetProtocols: []ProtocolPort{
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 80, Last: 80}}},
},
},
},
},
},
{
name: "valid-grant-with-app-field",
input: `
{
"groups": {
"group:eng": ["alice@example.com"]
},
"tagOwners": {
"tag:relay": ["group:eng"]
},
"grants": [
{
"src": ["group:eng"],
"dst": ["tag:relay"],
"app": {
"tailscale.com/cap/relay": []
}
}
]
}
`,
want: &Policy{
Groups: Groups{
Group("group:eng"): []Username{Username("alice@example.com")},
},
TagOwners: TagOwners{
Tag("tag:relay"): Owners{gp("group:eng")},
},
Grants: []Grant{
{
Sources: Aliases{
gp("group:eng"),
},
Destinations: Aliases{
tp("tag:relay"),
},
App: tailcfg.PeerCapMap{
"tailscale.com/cap/relay": []tailcfg.RawMessage{},
},
},
},
},
},
{
name: "valid-grant-with-via-tags",
input: `
{
"groups": {
"group:eng": ["alice@example.com"]
},
"tagOwners": {
"tag:server": ["group:eng"],
"tag:router": ["group:eng"]
},
"grants": [
{
"src": ["group:eng"],
"dst": ["autogroup:internet"],
"ip": ["*"],
"via": ["tag:router"]
}
]
}
`,
want: &Policy{
Groups: Groups{
Group("group:eng"): []Username{Username("alice@example.com")},
},
TagOwners: TagOwners{
Tag("tag:server"): Owners{gp("group:eng")},
Tag("tag:router"): Owners{gp("group:eng")},
},
Grants: []Grant{
{
Sources: Aliases{
gp("group:eng"),
},
Destinations: Aliases{
agp("autogroup:internet"),
},
InternetProtocols: []ProtocolPort{
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
},
Via: []Tag{Tag("tag:router")},
},
},
},
},
{
name: "valid-grant-with-wildcard",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["*"]
}
]
}
`,
want: &Policy{
Grants: []Grant{
{
Sources: Aliases{
Wildcard,
},
Destinations: Aliases{
Wildcard,
},
InternetProtocols: []ProtocolPort{
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
},
},
},
},
},
{
name: "valid-grant-with-multiple-sources-destinations",
input: `
{
"groups": {
"group:eng": ["alice@example.com"],
"group:ops": ["bob@example.com"]
},
"tagOwners": {
"tag:web": ["group:eng"],
"tag:db": ["group:ops"]
},
"hosts": {
"server1": "100.64.0.1"
},
"grants": [
{
"src": ["group:eng", "alice@example.com", "100.64.0.10"],
"dst": ["tag:web", "tag:db", "server1"],
"ip": ["tcp:443", "udp:53"]
}
]
}
`,
want: &Policy{
Groups: Groups{
Group("group:eng"): []Username{Username("alice@example.com")},
Group("group:ops"): []Username{Username("bob@example.com")},
},
TagOwners: TagOwners{
Tag("tag:web"): Owners{gp("group:eng")},
Tag("tag:db"): Owners{gp("group:ops")},
},
Hosts: Hosts{
"server1": Prefix(mp("100.64.0.1/32")),
},
Grants: []Grant{
{
Sources: Aliases{
gp("group:eng"),
up("alice@example.com"),
func() *Prefix { p := Prefix(mp("100.64.0.10/32")); return &p }(),
},
Destinations: Aliases{
tp("tag:web"),
tp("tag:db"),
hp("server1"),
},
InternetProtocols: []ProtocolPort{
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
{Protocol: "udp", Ports: []tailcfg.PortRange{{First: 53, Last: 53}}},
},
},
},
},
},
{
name: "valid-grant-with-port-ranges",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["tcp:8000-9000", "80", "443"]
}
]
}
`,
want: &Policy{
Grants: []Grant{
{
Sources: Aliases{
Wildcard,
},
Destinations: Aliases{
Wildcard,
},
InternetProtocols: []ProtocolPort{
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 8000, Last: 9000}}},
{Protocol: "*", Ports: []tailcfg.PortRange{{First: 80, Last: 80}}},
{Protocol: "*", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
},
},
},
},
},
{
name: "valid-grant-with-autogroups",
input: `
{
"grants": [
{
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"ip": ["*"]
}
]
}
`,
want: &Policy{
Grants: []Grant{
{
Sources: Aliases{
agp("autogroup:member"),
},
Destinations: Aliases{
agp("autogroup:self"),
},
InternetProtocols: []ProtocolPort{
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
},
},
},
},
},
{
name: "invalid-grant-both-ip-and-app",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"],
"ip": ["tcp:443"],
"app": {
"tailscale.com/cap/relay": []
}
}
]
}
`,
wantErr: "grants cannot specify both 'ip' and 'app' fields",
},
{
name: "invalid-grant-missing-ip-and-app",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["*"]
}
]
}
`,
wantErr: "grants must specify either 'ip' or 'app' field",
},
{
name: "invalid-grant-empty-sources",
input: `
{
"grants": [
{
"src": [],
"dst": ["*"],
"ip": ["*"]
}
]
}
`,
wantErr: "grant sources cannot be empty",
},
{
name: "invalid-grant-empty-destinations",
input: `
{
"grants": [
{
"src": ["*"],
"dst": [],
"ip": ["*"]
}
]
}
`,
wantErr: "grant destinations cannot be empty",
},
{
name: "invalid-grant-undefined-via-tag",
input: `
{
"tagOwners": {
"tag:server": ["alice@example.com"]
},
"grants": [
{
"src": ["*"],
"dst": ["autogroup:internet"],
"ip": ["*"],
"via": ["tag:undefined-router"]
}
]
}
`,
wantErr: "grant 'via' tag is not defined in policy",
},
{
name: "invalid-grant-undefined-source-group",
input: `
{
"grants": [
{
"src": ["group:undefined"],
"dst": ["*"],
"ip": ["*"]
}
]
}
`,
wantErr: "group not defined in policy",
},
{
name: "invalid-grant-undefined-source-tag",
input: `
{
"grants": [
{
"src": ["tag:undefined"],
"dst": ["*"],
"ip": ["*"]
}
]
}
`,
wantErr: "tag not defined in policy",
},
{
name: "invalid-grant-undefined-destination-host",
input: `
{
"grants": [
{
"src": ["*"],
"dst": ["host-undefined"],
"ip": ["*"]
}
]
}
`,
wantErr: "host not defined",
},
{
name: "invalid-grant-autogroup-self-with-tag-source",
input: `
{
"tagOwners": {
"tag:server": ["alice@example.com"]
},
"grants": [
{
"src": ["tag:server"],
"dst": ["autogroup:self"],
"ip": ["*"]
}
]
}
`,
wantErr: "autogroup:self destination requires sources to be users, groups, or autogroup:member only",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy, err := unmarshalPolicy([]byte(tt.input))
if tt.wantErr != "" {
// Unmarshal succeeded, try validate
if err == nil {
err = policy.validate()
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Validate the policy
err = policy.validate()
if err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
if diff := cmp.Diff(tt.want, policy, cmpopts.IgnoreUnexported(Policy{}, Prefix{})); diff != "" {
t.Errorf("Policy unmarshal mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestACLToGrants(t *testing.T) {
tests := []struct {
name string
acl ACL
want []Grant
}{
{
name: "single-destination-tcp",
acl: ACL{
Action: ActionAccept,
Protocol: ProtocolNameTCP,
Sources: Aliases{gp("group:eng")},
Destinations: []AliasWithPorts{
{
Alias: tp("tag:server"),
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
},
},
},
want: []Grant{
{
Sources: Aliases{gp("group:eng")},
Destinations: Aliases{tp("tag:server")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
},
},
},
},
},
{
name: "multiple-destinations-creates-multiple-grants",
acl: ACL{
Action: ActionAccept,
Protocol: ProtocolNameTCP,
Sources: Aliases{gp("group:eng")},
Destinations: []AliasWithPorts{
{
Alias: tp("tag:web"),
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
},
{
Alias: tp("tag:db"),
Ports: []tailcfg.PortRange{{First: 5432, Last: 5432}},
},
},
},
want: []Grant{
{
Sources: Aliases{gp("group:eng")},
Destinations: Aliases{tp("tag:web")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
},
},
},
{
Sources: Aliases{gp("group:eng")},
Destinations: Aliases{tp("tag:db")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 5432, Last: 5432}},
},
},
},
},
},
{
name: "wildcard-protocol",
acl: ACL{
Action: ActionAccept,
Protocol: ProtocolNameWildcard,
Sources: Aliases{gp("group:admin")},
Destinations: []AliasWithPorts{
{
Alias: up("alice@example.com"),
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
want: []Grant{
{
Sources: Aliases{gp("group:admin")},
Destinations: Aliases{up("alice@example.com")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameWildcard,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
},
},
{
name: "udp-with-port-range",
acl: ACL{
Action: ActionAccept,
Protocol: ProtocolNameUDP,
Sources: Aliases{up("bob@example.com")},
Destinations: []AliasWithPorts{
{
Alias: tp("tag:voip"),
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
},
},
},
want: []Grant{
{
Sources: Aliases{up("bob@example.com")},
Destinations: Aliases{tp("tag:voip")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameUDP,
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
},
},
},
},
},
{
name: "icmp-protocol",
acl: ACL{
Action: ActionAccept,
Protocol: ProtocolNameICMP,
Sources: Aliases{gp("group:monitoring")},
Destinations: []AliasWithPorts{
{
Alias: new(Asterix),
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
want: []Grant{
{
Sources: Aliases{gp("group:monitoring")},
Destinations: Aliases{new(Asterix)},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameICMP,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := aclToGrants(tt.acl)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("aclToGrants() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestGrantMarshalJSON(t *testing.T) {
tests := []struct {
name string
grant Grant
wantJSON string
}{
{
name: "ip-based-grant-tcp-single-port",
grant: Grant{
Sources: Aliases{gp("group:eng")},
Destinations: Aliases{tp("tag:server")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
},
},
},
wantJSON: `{
"src": ["group:eng"],
"dst": ["tag:server"],
"ip": ["tcp:443"]
}`,
},
{
name: "ip-based-grant-udp-port-range",
grant: Grant{
Sources: Aliases{up("alice@example.com")},
Destinations: Aliases{tp("tag:voip")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameUDP,
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
},
},
},
wantJSON: `{
"src": ["alice@example.com"],
"dst": ["tag:voip"],
"ip": ["udp:10000-20000"]
}`,
},
{
name: "ip-based-grant-wildcard-protocol",
grant: Grant{
Sources: Aliases{gp("group:admin")},
Destinations: Aliases{Asterix(0)},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameWildcard,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
wantJSON: `{
"src": ["group:admin"],
"dst": ["*"],
"ip": ["*"]
}`,
},
{
name: "ip-based-grant-icmp",
grant: Grant{
Sources: Aliases{gp("group:monitoring")},
Destinations: Aliases{tp("tag:servers")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameICMP,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
},
wantJSON: `{
"src": ["group:monitoring"],
"dst": ["tag:servers"],
"ip": ["icmp:0-65535"]
}`,
},
{
name: "ip-based-grant-multiple-protocols",
grant: Grant{
Sources: Aliases{gp("group:web")},
Destinations: Aliases{tp("tag:lb")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
},
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
},
},
},
wantJSON: `{
"src": ["group:web"],
"dst": ["tag:lb"],
"ip": ["tcp:80", "tcp:443"]
}`,
},
{
name: "capability-based-grant",
grant: Grant{
Sources: Aliases{gp("group:admins")},
Destinations: Aliases{tp("tag:database")},
App: tailcfg.PeerCapMap{
"backup": []tailcfg.RawMessage{
tailcfg.RawMessage(`{"action":"read"}`),
tailcfg.RawMessage(`{"action":"write"}`),
},
},
},
wantJSON: `{
"src": ["group:admins"],
"dst": ["tag:database"],
"app": {
"backup": [
{"action":"read"},
{"action":"write"}
]
}
}`,
},
{
name: "grant-with-both-ip-and-app",
grant: Grant{
Sources: Aliases{up("bob@example.com")},
Destinations: Aliases{tp("tag:app-server")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 8080, Last: 8080}},
},
},
App: tailcfg.PeerCapMap{
"admin": []tailcfg.RawMessage{
tailcfg.RawMessage(`{"level":"superuser"}`),
},
},
},
wantJSON: `{
"src": ["bob@example.com"],
"dst": ["tag:app-server"],
"ip": ["tcp:8080"],
"app": {
"admin": [{"level":"superuser"}]
}
}`,
},
{
name: "grant-with-via",
grant: Grant{
Sources: Aliases{gp("group:remote-workers")},
Destinations: Aliases{tp("tag:internal")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
},
},
Via: []Tag{
*tp("tag:gateway1"),
*tp("tag:gateway2"),
},
},
wantJSON: `{
"src": ["group:remote-workers"],
"dst": ["tag:internal"],
"ip": ["tcp:0-65535"],
"via": ["tag:gateway1", "tag:gateway2"]
}`,
},
{
name: "grant-omitzero-app-field",
grant: Grant{
Sources: Aliases{gp("group:users")},
Destinations: Aliases{tp("tag:web")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
},
},
App: nil,
},
wantJSON: `{
"src": ["group:users"],
"dst": ["tag:web"],
"ip": ["tcp:80"]
}`,
},
{
name: "grant-omitzero-via-field",
grant: Grant{
Sources: Aliases{gp("group:users")},
Destinations: Aliases{tp("tag:api")},
InternetProtocols: []ProtocolPort{
{
Protocol: ProtocolNameTCP,
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
},
},
Via: nil,
},
wantJSON: `{
"src": ["group:users"],
"dst": ["tag:api"],
"ip": ["tcp:443"]
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Marshal the Grant to JSON
gotJSON, err := json.Marshal(tt.grant)
if err != nil {
t.Fatalf("failed to marshal Grant: %v", err)
}
// Compact the expected JSON to remove whitespace for comparison
var wantCompact bytes.Buffer
err = json.Compact(&wantCompact, []byte(tt.wantJSON))
if err != nil {
t.Fatalf("failed to compact expected JSON: %v", err)
}
// Compare JSON strings
if string(gotJSON) != wantCompact.String() {
t.Errorf("Grant.MarshalJSON() mismatch:\ngot: %s\nwant: %s", string(gotJSON), wantCompact.String())
}
// Test round-trip: unmarshal and compare with original
var unmarshaled Grant
err = json.Unmarshal(gotJSON, &unmarshaled)
if err != nil {
t.Fatalf("failed to unmarshal JSON: %v", err)
}
if diff := cmp.Diff(tt.grant, unmarshaled); diff != "" {
t.Errorf("Grant round-trip mismatch (-original +unmarshaled):\n%s", diff)
}
})
}
}

View File

@@ -9,7 +9,7 @@ import (
) )
// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests. // TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests.
func TestParseDestinationAndPort(t *testing.T) { func TestSplitDestinationAndPort(t *testing.T) {
testCases := []struct { testCases := []struct {
input string input string
wantDst string wantDst string