policy/v2: apply via only to the ip part of a grant

SaaS treats app as if via were absent: v80 and v84 peer the same, v87 peers a
non-via router, v88 and v91 send the route on the primary. Dropping a via grant
with no ip at compile left grant-k12's nodes with no peers.

Updates #3626
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 15:53:04 +01:00
1 parent aa7b2ab404
commit 9fff03f4b5
8 files changed
+96 -12

No files matched your search

@@ -95,6 +95,10 @@ func TestBuildPeerMapMatchesPrefixReference(t *testing.T) {
{name: "global", policy: benchPolicies[0].policy},
{name: "self", policy: benchPolicies[1].policy},
{name: "via", policy: benchPolicies[2].policy},
{name: "via capability only", policy: `{
"tagOwners": {"tag:router": ["u1@"]},
"grants": [{"src": ["u2@"], "dst": ["10.0.0.0/8"], "via": ["tag:router"],
"app": {"example.com/cap/test": [{}]}}]}`},
{name: "subnet source", policy: `{
"acls": [{"action": "accept", "src": ["10.0.0.0/8", "2001:db8::/32"],
"dst": ["u2@:*"]}]}`},
+11 -7
View File
@@ -265,12 +265,12 @@ func (pol *Policy) compileGrants(
compiled := make([]compiledGrant, 0, len(grants))
srcs := sourceCache{}
for _, grant := range grants {
compile := func(grant Grant) {
cg, err := pol.compileOneGrant(grant, users, nodes, srcs)
if err != nil {
log.Trace().Err(err).Msg("compiling grant")
continue
return
}
if cg != nil {
@@ -278,6 +278,14 @@ func (pol *Policy) compileGrants(
}
}
for _, grant := range grants {
compile(grant)
if app, ok := grant.appPart(); ok {
compile(app)
}
}
return compiled
}
@@ -294,7 +302,7 @@ func (pol *Policy) compileOneGrant(
srcs sourceCache,
) (*compiledGrant, error) {
// Via grants: resolve sources, store deferred data.
if len(grant.Via) > 0 {
if grant.HasVia() {
return pol.compileOneViaGrant(grant, users, nodes)
}
@@ -448,10 +456,6 @@ func (pol *Policy) compileOneViaGrant(
users types.Users,
nodes views.Slice[types.NodeView],
) (*compiledGrant, error) {
if len(grant.InternetProtocols) == 0 {
return nil, nil //nolint:nilnil
}
resolvedSrcs, _, err := resolveSources(
pol, grant.Sources, users, nodes,
)
+37 -3
View File
@@ -3913,7 +3913,7 @@ func TestCompileViaGrant(t *testing.T) {
},
},
{
name: "app-only via grant with no ip field returns nil",
name: "app-only via grant gets no filter rule",
grant: Grant{
Sources: Aliases{up("testuser@")},
Destinations: Aliases{pp("10.0.0.0/24")},
@@ -3990,8 +3990,9 @@ func TestCompileViaGrant(t *testing.T) {
}
}
// TestViaGrantPeers pins who a via grant links, as via-grant-v81 records: with
// ports, sources peer via routers only, also when a router is itself a source.
// TestViaGrantPeers pins who a via grant links, as via-grant-v80 and v81 record:
// with no ports via changes nothing, so sources peer everything serving the
// destination and nobody gets a filter; with ports, sources peer via routers only.
func TestViaGrantPeers(t *testing.T) {
t.Parallel()
@@ -4030,11 +4031,26 @@ func TestViaGrantPeers(t *testing.T) {
grant string
want map[types.NodeID][]types.NodeID
}{
{
name: "capability only",
grant: `"app": {"example.com/cap/test": [{}]}`,
want: map[types.NodeID][]types.NodeID{1: {2, 5}, 2: {1, 5}, 5: {1, 2}},
},
{
name: "ports",
grant: `"ip": ["tcp:80"]`,
want: map[types.NodeID][]types.NodeID{1: {2}, 2: {1}},
},
{
name: "ports and an empty capability map",
grant: `"ip": ["tcp:80"], "app": {}`,
want: map[types.NodeID][]types.NodeID{1: {2}, 2: {1}},
},
{
name: "ports and a capability",
grant: `"ip": ["tcp:80"], "app": {"example.com/cap/test": [{}]}`,
want: map[types.NodeID][]types.NodeID{1: {2, 5}, 2: {1, 5}, 5: {1, 2}},
},
}
for _, tt := range tests {
@@ -4052,6 +4068,24 @@ func TestViaGrantPeers(t *testing.T) {
assert.Equal(t, tt.want, pm.BuildPeerMap(nodes.ViewSlice()))
})
}
t.Run("capability only sends no filter", func(t *testing.T) {
t.Parallel()
pm, err := NewPolicyManager([]byte(`{
"tagOwners": {"tag:client": [], "tag:router": [], "tag:other": []},
"grants": [{"src": ["tag:client"], "dst": ["10.1.0.0/24"], "via": ["tag:router"], "app": {"example.com/cap/test": [{}]}}]
}`), nil, nodes.ViewSlice())
require.NoError(t, err)
for _, nd := range nodes {
filter, err := pm.FilterForNode(nd.View())
require.NoError(t, err)
assert.Emptyf(t, filter, "node %d", nd.ID)
}
assert.Empty(t, pm.ViaRoutesForPeer(nodes[0].View(), nodes[1].View()))
})
}
func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
@@ -37,6 +37,13 @@ func TestNodeNeedsPeerRecompute(t *testing.T) {
]
}`
viaAppPol := `{
"tagOwners": {"tag:via": ["user1@"]},
"grants": [
{"src": ["*"], "dst": ["10.0.0.0/24"], "app": {"example.com/cap/test": [{}]}, "via": ["tag:via"]}
]
}`
taildrivePol := `{
"tagOwners": {"tag:drive": ["user1@"]},
"grants": [
@@ -108,6 +115,13 @@ func TestNodeNeedsPeerRecompute(t *testing.T) {
subject: viaTarget,
want: true,
},
{
name: "capability-only via tag does not need recompute",
pol: viaAppPol,
nodes: types.Nodes{viaTarget, ordinary},
subject: viaTarget,
want: false,
},
{
name: "ordinary node in a via-using tailnet does not need recompute",
pol: viaPol,
+6
View File
@@ -228,6 +228,12 @@ func scanRandomPolicy(r *rand.Rand) []byte {
g := map[string]any{"src": src, "dst": dst, "ip": [][]string{{"*"}, {"tcp:443"}, {"icmp:*"}, {"udp:53", "tcp:22"}}[r.IntN(4)]}
if r.IntN(8) == 0 {
g["via"] = []string{[]string{"tag:router", "tag:exit"}[r.IntN(2)]}
// Capability only: via changes nothing, unlike a via grant with ports.
if r.IntN(2) == 0 {
delete(g, "ip")
g["app"] = map[string]any{"example.com/cap/test": []map[string]any{{}}}
}
}
grants = append(grants, g)
+7
View File
@@ -1589,6 +1589,13 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via
grants = append(grants, Grant{Sources: acl.Sources, Destinations: dsts})
}
// A grant's app part covers its routes as a regular grant does.
for _, grant := range pm.pol.Grants {
if app, ok := grant.appPart(); ok {
grants = append(grants, app)
}
}
// matchViewer resolves a grant lazily and at most once: non-via
// grants are only needed once a via grant has matched, and
// destinations only for grants that match the viewer.
+15 -2
View File
@@ -1918,9 +1918,22 @@ type Grant struct {
Via []Tag `json:"via,omitzero"`
}
// HasVia reports whether the grant routes through via-tagged nodes.
// HasVia reports whether the grant steers traffic through via-tagged nodes.
// Only its ip part does: via-grant-v80, v84 and v88 show that SaaS treats a
// capability-only grant the same with or without via.
func (g Grant) HasVia() bool {
return len(g.Via) > 0
return len(g.Via) > 0 && len(g.InternetProtocols) > 0
}
// appPart returns the capabilities of a grant with via as a grant of their own,
// since via does not apply to them. via-grant-v89 and v91 show SaaS treating one
// grant holding both parts like two. An empty map is no capability (v92).
func (g Grant) appPart() (Grant, bool) {
if !g.HasVia() || len(g.App) == 0 {
return Grant{}, false
}
return Grant{Sources: g.Sources, Destinations: g.Destinations, App: g.App}, true
}
// NodeAttrGrant attaches Tailscale node capabilities (and/or an IP-pool
+2
View File
@@ -42,6 +42,8 @@ var viaCompatTests = []struct {
{"grant-v11", "tag src steers subnet via router"},
{"grant-v12", "member src steers subnet via router"},
{"grant-v13", "group src steers subnet via router to members only"},
{"grant-k12", "capability-only via grant to a subnet"},
{"grant-k13", "the same via grant with a port"},
{"via-grant-v14", "tag src steers the internet via tag:exit"},
{"via-grant-v15", "wildcard src steers the internet via tag:exit"},
{"via-grant-v16", "tag and member srcs steer the internet via tag:exit"},