policy/v2: accept empty grant sources and destinations

Tailscale SaaS accepts grants with empty src=[] or dst=[] arrays,
producing no filter rules for any node. Headscale previously rejected
these with validation errors.

Remove the empty source/destination validation checks and add an
early return in compileGrantWithAutogroupSelf when the grant has
literally empty Sources or Destinations arrays. This is distinct
from sources that resolve to empty (e.g., group:empty) where
Tailscale still produces CapGrant rules with empty SrcIPs.

Updates #2180
This commit is contained in:
Kristoffer Dalby
2026-03-18 21:15:58 +00:00
parent a3c262206c
commit 84210c03bb
4 changed files with 37 additions and 24 deletions

View File

@@ -478,6 +478,14 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
}
}
// When the grant has literally empty src=[] or dst=[], produce no rules
// at all — Tailscale returns null for these. This is distinct from sources
// that resolve to empty (e.g., group:empty) where Tailscale still produces
// CapGrant rules with empty SrcIPs.
if len(grant.Sources) == 0 || len(grant.Destinations) == 0 {
return rules, nil
}
if len(resolvedSrcs) == 0 && grant.App == nil {
return rules, nil
}

View File

@@ -217,9 +217,8 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile {
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
//
// Total: 30 tests skipped, ~207 tests expected to pass.
// Total: 28 tests skipped, ~209 tests expected to pass.
var grantSkipReasons = map[string]string{
// ========================================================================
// USER_PASSKEY_WILDCARD (2 tests)
@@ -313,12 +312,7 @@ var grantSkipReasons = map[string]string{
"GRANT-V16": "ERROR_VALIDATION_GAP: dst [0.0.0.0/0, ::/0] with via — both rejected",
"GRANT-V18": "ERROR_VALIDATION_GAP: dst 0.0.0.0/0 with via + app — rejected regardless of via or app",
// Empty src/dst validation difference:
// Tailscale ACCEPTS empty src/dst arrays (producing no filter rules),
// but headscale rejects them with "grant sources/destinations cannot be empty".
// headscale is stricter here — should match Tailscale and accept empty arrays.
"GRANT-H4": "VALIDATION_STRICTNESS: headscale rejects empty src=[] but Tailscale accepts it (producing no rules)",
"GRANT-H5": "VALIDATION_STRICTNESS: headscale rejects empty dst=[] but Tailscale accepts it (producing no rules)",
// (VALIDATION_STRICTNESS tests H4/H5 removed — empty src/dst now accepted)
// ========================================================================
// NIL_VS_EMPTY_RULES (varies)
@@ -352,9 +346,8 @@ var grantSkipReasons = map[string]string{
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
//
// Total: 30 tests skipped, ~207 tests expected to pass.
// Total: 28 tests skipped, ~209 tests expected to pass.
func TestGrantsCompat(t *testing.T) {
t.Parallel()

View File

@@ -2386,11 +2386,7 @@ func (p *Policy) validate() error {
errs = append(errs, ErrGrantMissingIPOrApp)
}
// Validate sources
if len(grant.Sources) == 0 {
errs = append(errs, ErrGrantEmptySources)
}
// Validate sources (empty arrays are allowed — they produce no rules)
for _, src := range grant.Sources {
switch src := src.(type) {
case *Host:
@@ -2429,11 +2425,7 @@ func (p *Policy) validate() error {
}
}
// Validate destinations
if len(grant.Destinations) == 0 {
errs = append(errs, ErrGrantEmptyDestinations)
}
// Validate destinations (empty arrays are allowed — they produce no rules)
for _, dst := range grant.Destinations {
switch h := dst.(type) {
case *Host:

View File

@@ -4632,7 +4632,7 @@ func TestUnmarshalGrants(t *testing.T) {
wantErr: "grants must specify either 'ip' or 'app' field",
},
{
name: "invalid-grant-empty-sources",
name: "valid-grant-empty-sources",
input: `
{
"grants": [
@@ -4644,10 +4644,20 @@ func TestUnmarshalGrants(t *testing.T) {
]
}
`,
wantErr: "grant sources cannot be empty",
want: &Policy{
Grants: []Grant{
{
Sources: Aliases{},
Destinations: Aliases{Wildcard},
InternetProtocols: []ProtocolPort{
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
},
},
},
},
},
{
name: "invalid-grant-empty-destinations",
name: "valid-grant-empty-destinations",
input: `
{
"grants": [
@@ -4659,7 +4669,17 @@ func TestUnmarshalGrants(t *testing.T) {
]
}
`,
wantErr: "grant destinations cannot be empty",
want: &Policy{
Grants: []Grant{
{
Sources: Aliases{Wildcard},
Destinations: Aliases{},
InternetProtocols: []ProtocolPort{
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
},
},
},
},
},
{
name: "invalid-grant-undefined-via-tag",