diff --git a/hscontrol/policy/v2/canaccess_equiv_test.go b/hscontrol/policy/v2/canaccess_equiv_test.go index 0bd2eab22..20fff0785 100644 --- a/hscontrol/policy/v2/canaccess_equiv_test.go +++ b/hscontrol/policy/v2/canaccess_equiv_test.go @@ -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@:*"]}]}`}, diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index d7ad7472d..348dea29b 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -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, ) diff --git a/hscontrol/policy/v2/filter_test.go b/hscontrol/policy/v2/filter_test.go index eace8ff57..fbde1ff2b 100644 --- a/hscontrol/policy/v2/filter_test.go +++ b/hscontrol/policy/v2/filter_test.go @@ -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) { diff --git a/hscontrol/policy/v2/node_recompute_test.go b/hscontrol/policy/v2/node_recompute_test.go index 9688b3556..3c09b2180 100644 --- a/hscontrol/policy/v2/node_recompute_test.go +++ b/hscontrol/policy/v2/node_recompute_test.go @@ -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, diff --git a/hscontrol/policy/v2/peermap_scan_test.go b/hscontrol/policy/v2/peermap_scan_test.go index f78fefdb3..3ae704d5a 100644 --- a/hscontrol/policy/v2/peermap_scan_test.go +++ b/hscontrol/policy/v2/peermap_scan_test.go @@ -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) diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 613e8dfaa..f7da39895 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -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. diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 977520d1d..aa342287e 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -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 diff --git a/hscontrol/servertest/via_compat_test.go b/hscontrol/servertest/via_compat_test.go index b6c29b9fa..99d1273f2 100644 --- a/hscontrol/servertest/via_compat_test.go +++ b/hscontrol/servertest/via_compat_test.go @@ -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"},