From 7e995b1ff92daa7c760660f5199d09605afb91e2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 Oct 2026 23:02:34 +0000 Subject: [PATCH] state: auto-approve routes after an admin tag change The new tags may approve announced routes. Approval runs once the policy manager knows the new owner, as after a registration. --- hscontrol/servertest/reauth_compat_test.go | 8 -------- hscontrol/state/autoapprove_test.go | 24 ++++++++++++++++++++++ hscontrol/state/state.go | 14 +++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/hscontrol/servertest/reauth_compat_test.go b/hscontrol/servertest/reauth_compat_test.go index 6a5313fff..8be1c0b22 100644 --- a/hscontrol/servertest/reauth_compat_test.go +++ b/hscontrol/servertest/reauth_compat_test.go @@ -39,10 +39,6 @@ import ( var reauthSkipReasons = map[string]string{ "owner-routes/r-expired-retag/expire/online": expiredPeerOffline, - "owner-routes/r-admin/owner/enabled": adminRetagNoApproval, - "owner-routes/r-admin/owner/served": adminRetagNoApproval, - "owner-routes/r-admin/owner/self-routes": adminRetagNoApproval, - "identity/w-tagreq-relogin-tags/w-tagreq/owner": keyLoginTagsRefused, "identity/w-tagreq-relogin-tags/w-tagreq/outcome": keyLoginTagsRefused, "identity/w-tagreq-relogin-tags/w-tagreq/node-key": keyLoginTagsRefused, @@ -81,10 +77,6 @@ var reauthSkipReasons = map[string]string{ "ephemeral-lifecycle/late-expired/late-exp/legacy-text": refusalText, } -const ( - adminRetagNoApproval = "ADMIN_RETAG_NO_APPROVAL: an admin tag change does not auto-approve the routes the new tags may approve; SaaS approves them at once" -) - const ( keyLoginTagsRefused = "KEY_LOGIN_TAGS_REFUSED: headscale refuses a user's key login that requests tags the user owns; SaaS tags the device, converting it in place or creating it tagged" taggedKeyOwnedTagRefused = "TAGGED_KEY_OWNED_TAG_REFUSED: headscale refuses a tagged key's login that requests a tag the key's tags own; SaaS gives the device the requested tags" diff --git a/hscontrol/state/autoapprove_test.go b/hscontrol/state/autoapprove_test.go index 734004c0f..2e87682c9 100644 --- a/hscontrol/state/autoapprove_test.go +++ b/hscontrol/state/autoapprove_test.go @@ -349,3 +349,27 @@ func TestRegistrationDropsWithdrawnAutoApprovals(t *testing.T) { require.NoError(t, err) assert.Equal(t, []netip.Prefix{kept}, approvedRoutes(t, s, node.ID())) } + +// TestAdminTagChangeApprovesRoutes: an admin tag change runs route +// auto-approval for the routes the new tags may approve. +func TestAdminTagChangeApprovesRoutes(t *testing.T) { + _, s, id := persistTestSetup(t) + t.Cleanup(func() { _ = s.Close() }) + + _, err := s.SetPolicy([]byte(`{ + "tagOwners": {"tag:router": ["persist-user@"]}, + "autoApprovers": {"routes": {"10.0.0.0/16": ["tag:router"]}}, + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] + }`)) + require.NoError(t, err) + + route := netip.MustParsePrefix("10.0.1.0/24") + + announce(t, s, id, route) + require.Empty(t, approvedRoutes(t, s, id), "the user may not approve it") + + node, _, err := s.SetNodeTags(id, []string{"tag:router"}) + require.NoError(t, err) + assert.Equal(t, []netip.Prefix{route}, node.ApprovedRoutes().AsSlice()) + assert.Equal(t, []netip.Prefix{route}, approvedRoutes(t, s, id)) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 1a40f3885..853c3a61f 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1150,6 +1150,20 @@ func (s *State) SetNodeTags(nodeID types.NodeID, tags []string) (types.NodeView, // Setting OriginNode ensures the node gets a self-update with the new tags. c.OriginNode = nodeID + // The new tags may approve announced routes, as on Tailscale SaaS. This + // runs after the policy refresh above, which teaches the policy manager + // the node's new owner. + routesChange, err := s.AutoApproveRoutes(nodeView) + c = c.Merge(routesChange) + + if fresh, ok := s.nodeStore.GetNode(nodeID); ok { + nodeView = fresh + } + + if err != nil { + return nodeView, c, fmt.Errorf("auto approving routes: %w", err) + } + return nodeView, c, nil }