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
grants := pol.Grants
for _, acl := range pol.ACLs {
if acl.Action != ActionAccept {
return nil, ErrInvalidAction
}
grants = append(grants, aclToGrants(acl)...)
}
srcIPs, err := acl.Sources.Resolve(pol, users, nodes)
for _, grant := range grants {
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
if err != nil {
log.Trace().Caller().Err(err).Msgf("resolving source ips")
}
@@ -48,66 +49,96 @@ func (pol *Policy) compileFilterRules(
continue
}
protocols := acl.Protocol.parseProtocol()
for _, ipp := range grant.InternetProtocols {
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
var destPorts []tailcfg.NetPortRange
for _, dest := range acl.Destinations {
// 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 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcIPs),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
}
if len(destPorts) == 0 {
continue
}
if grant.App != nil {
var capGrants []tailcfg.CapGrant
rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcIPs),
DstPorts: destPorts,
IPProto: protocols,
})
for _, dst := range grant.Destinations {
ips, err := dst.Resolve(pol, users, nodes)
if err != nil {
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
}
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.
func (pol *Policy) compileFilterRulesForNode(
users types.Users,
@@ -120,60 +151,55 @@ func (pol *Policy) compileFilterRulesForNode(
var rules []tailcfg.FilterRule
grants := pol.Grants
for _, acl := range pol.ACLs {
if acl.Action != ActionAccept {
return nil, ErrInvalidAction
}
grants = append(grants, aclToGrants(acl)...)
}
aclRules, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes)
for _, grant := range grants {
res, err := pol.compileGrantWithAutogroupSelf(grant, users, node, nodes)
if err != nil {
log.Trace().Err(err).Msgf("compiling ACL")
continue
}
for _, rule := range aclRules {
if rule != nil {
rules = append(rules, *rule)
}
}
rules = append(rules, res...)
}
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.
// 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
// source filtering logic.
//
//nolint:gocyclo // complex ACL compilation logic
func (pol *Policy) compileACLWithAutogroupSelf(
acl ACL,
func (pol *Policy) compileGrantWithAutogroupSelf(
grant Grant,
users types.Users,
node types.NodeView,
nodes views.Slice[types.NodeView],
) ([]*tailcfg.FilterRule, error) {
) ([]tailcfg.FilterRule, error) {
var (
autogroupSelfDests []AliasWithPorts
otherDests []AliasWithPorts
autogroupSelfDests []Alias
otherDests []Alias
)
for _, dest := range acl.Destinations {
if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
for _, dest := range grant.Destinations {
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
autogroupSelfDests = append(autogroupSelfDests, dest)
} else {
otherDests = append(otherDests, dest)
}
}
protocols := acl.Protocol.parseProtocol()
var rules []*tailcfg.FilterRule
var rules []tailcfg.FilterRule
var resolvedSrcIPs []*netipx.IPSet
for _, src := range acl.Sources {
for _, src := range grant.Sources {
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
return nil, errSelfInSources
}
@@ -192,42 +218,42 @@ func (pol *Policy) compileACLWithAutogroupSelf(
return rules, nil
}
// Handle autogroup:self destinations (if any)
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
sameUserNodes := make([]types.NodeView, 0)
for _, ipp := range grant.InternetProtocols {
// Handle autogroup:self destinations (if any)
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
sameUserNodes := make([]types.NodeView, 0)
for _, n := range nodes.All() {
if !n.IsTagged() && n.User().ID() == node.User().ID() {
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)
}
for _, n := range nodes.All() {
if !n.IsTagged() && n.User().ID() == node.User().ID() {
sameUserNodes = append(sameUserNodes, n)
}
}
srcSet, err := srcIPs.IPSet()
if err != nil {
return nil, err
}
if len(sameUserNodes) > 0 {
// Filter sources to only same-user untagged devices
var srcIPs netipx.IPSetBuilder
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
var destPorts []tailcfg.NetPortRange
for _, dest := range autogroupSelfDests {
for _, ips := range resolvedSrcIPs {
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() {
destPorts = append(destPorts, tailcfg.NetPortRange{
IP: netip.PrefixFrom(ip, ip.BitLen()).String(),
@@ -236,82 +262,40 @@ func (pol *Policy) compileACLWithAutogroupSelf(
}
}
}
}
if len(destPorts) > 0 {
rules = append(rules, &tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts,
IPProto: protocols,
})
}
}
}
}
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,
if len(destPorts) > 0 {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts,
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
})
}
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 {
rules = append(rules, &tailcfg.FilterRule{
SrcIPs: ipSetToPrefixStringList(srcSet),
DstPorts: destPorts,
IPProto: protocols,
})
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 {
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")
)
// 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.
var (
ErrUnknownAliasType = errors.New("unknown alias type")
@@ -738,6 +750,98 @@ func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
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 {
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.
func (p *Protocol) parseProtocol() []int {
func (p *Protocol) toIANAProtocolNumbers() []int {
switch *p {
case "":
// Empty protocol applies to TCP, UDP, ICMP, and ICMPv6 traffic
@@ -1583,6 +1687,23 @@ const (
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 {
Action Action `json:"action"`
Protocol Protocol `json:"proto"`
@@ -1632,6 +1753,39 @@ func (a *ACL) UnmarshalJSON(b []byte) error {
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.
// TODO(kradalby):
// Add validation method checking:
@@ -1649,6 +1803,7 @@ type Policy struct {
Hosts Hosts `json:"hosts,omitempty"`
TagOwners TagOwners `json:"tagOwners,omitempty"`
ACLs []ACL `json:"acls,omitempty"`
Grants []Grant `json:"grants,omitempty"`
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
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 _, tagOwner := range tagOwners {
switch tagOwner := tagOwner.(type) {

View File

@@ -1,6 +1,7 @@
package v2
import (
"bytes"
"encoding/json"
"net/netip"
"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.
func TestParseDestinationAndPort(t *testing.T) {
func TestSplitDestinationAndPort(t *testing.T) {
testCases := []struct {
input string
wantDst string