Repository: juanfont/headscale

Issue:
[Bug] load(migrate) policy v2 from file to database
### Is this a support request?

- [ ] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

I configured `policy.mode=database` when I upgraded to version 0.26, but as you know, the service couldn’t start. So I had to switch back to file mode.
Now I have a problem: if I want to use the database mode, I need to manually modify the database to update the policy contents.
Maybe we could have a CLI command to load policy contents from a file into the database?

### Expected Behavior

Maybe we could have a CLI command to load policy contents from a file into the database?

### Steps To Reproduce

upgrade policy v1 to v2 

### Environment

```markdown
- OS: docker
- Headscale version: 0.26 beta.1
- Tailscale version:
```

### Runtime environment

- [x] Headscale is behind a (reverse) proxy
- [x] Headscale runs in a container

### Debug information

None

Hints from issue comments:
@kradalby: > but as you know, the service couldn’t start.

Actually, I didnt know, I would expect it to start with an empty policy, and then you can load your file with `headscale policy set --file`@IamTaoChen: > > but as you know, the service couldn’t start.
> 
> Actually, I didnt know, I would expect it to start with an empty policy, and then you can load your file with `headscale policy set --file`

That means I need to delete the policy from the database first,  am I right?

```bash
headscale  | 2025-05-09T10:03:47-04:00 FTL home/runner/work/headscale/headscale/cmd/headscale/cli/serve.go:24 > Error initializing error="failed to load ACL policy: creating policy manager: parsing policy: parsing policy from bytes: Username has to contain @, got: \"A\""
headscale exited with code 0
headscale  | 2025-05-09T10:03:48-04:00 FTL home/runner/work/headscale/headscale/cmd/headscale/cli/serve.go:24 > Error initializing error="failed to load ACL policy: creating policy manager: parsing policy: parsing policy from bytes: Username has to contain @, got: \"B\""
headscale exited with code 1
headscale  | 2025-05-09T10:03:49-04:00 FTL home/runner/work/headscale/headscale/cmd/headscale/cli/serve.go:24 > Error initializing error="failed to load ACL policy: creating policy manager: parsing policy: parsing policy from bytes: Username has to contain @, got: \"C\""
```

 I'm using docker, so I try to run
```bash
docker compose run --rm headscale policy set --file /etc/headscale/policy.hujson 
[+] Creating 1/1
 ✔ Container headscale-nginx  Running                                                                                                                                                                  0.0s 
2025-05-09T10:12:52-04:00 FTL home/runner/work/headscale/headscale/cmd/headscale/cli/utils.go:124 > Could not connect: context deadline exceeded error="context deadline exceeded"
```

And I try to set policy when the mode is `file`
```bash
➜  docker exec headscale headscale policy set --file /etc/headscale/policy.hujson
Failed to set ACL Policy: rpc error: code = Unknown desc = update is disabled for modes other than 'database'
```


Maybe, `policy set --file` should work in both mode, but in `file` mode it won't write into file and print a warning?@nblock: Headscale won't start with an invalid policy and this also means that the policy can't be updated with the CLI. One may
migrate a policy stored in the database following these steps:

* Dump the policy to a file while still running Headscale 0.25: `headscale policy get > policy.json`
* Create a dummy policy: `echo '{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}' > dummy.json`
* Load the dummy policy into Headscale 0.25: `headscale policy set --file dummy.json`
* Edit `policy.json` and migrate to policy V2
* Update to Headscale 0.26
* Load the modified policy V2: `headscale policy set --file policy.json`@IamTaoChen: Here is the new problem: I couldn't downgrade to 0.25, because of 

```bash
headscale  | 2025-05-09T20:59:54-04:00 FTL home/runner/work/headscale/headscale/cmd/headscale/cli/serve.go:24 > Error initializing error="failed to load ACL policy: loading nodes from database to validate policy: SQL logic error: no such table: routes (1)"
``` 


I'll try to edit the database directly@nblock: > I'll try to edit the database directly

I found an easier way to migrate just using Headscale 0.26:

* Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1` set. You can check that Headscale picked up the environment variable via the INF message: `Using policy manager version: 1`
* Dump the policy to a file: `headscale policy get > policy.json`
* Edit `policy.json` and migrate to policy V2 (check with: `headscale policy check --file policy.json`).
* Load the modified policy: `headscale policy set --file policy.json`
* Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`. You should see a INFO message: `Using policy manager version: 2`.
@agarzon-orangerine: > * HEADSCALE_POLICY_V1=1

Thank you. That worked for me, That allowed me to start the container, adjust the ACL and then run it again as usual.

![Image](https://github.com/user-attachments/assets/04b32d2f-1048-46b6-95ff-c2d32c059857)

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80e08c6e..3d96dc76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,29 @@ new policy code passes all of our tests.
     `@` should be appended at the end. For example, if your user is `john`, it
     must be written as `john@` in the policy.
 
+<details>
+
+<summary>Migration notes when the policy is stored in the database.</summary>
+
+This section **only** applies if the policy is stored in the database.
+
+Headscale won't start with an invalid policy and this also means that the policy
+can't be updated with the CLI. One may migrate a policy stored in the database
+following these steps:
+
+* Dump the policy to a file while still running Headscale 0.25:
+  `headscale policy get > policy.json`
+* Create a dummy policy (here: allow all):
+  `echo '{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}' > dummy.json`
+* Load the dummy policy into Headscale 0.25:
+  `headscale policy set --file dummy.json`
+* Edit `policy.json` and migrate to policy V2
+* Update to Headscale 0.26
+* Load the modified policy V2:
+  `headscale policy set --file policy.json`
+
+</details>
+
 **SSH**
 
 The SSH policy has been reworked to be more consistent with the rest of the
diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go
index 1f08411d..f3a16018 100644
--- a/cmd/headscale/cli/root.go
+++ b/cmd/headscale/cli/root.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"runtime"
+	"slices"
 
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/rs/zerolog"
@@ -25,6 +26,11 @@ func init() {
 		return
 	}
 
+	if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
+		zerolog.SetGlobalLevel(zerolog.Disabled)
+		return
+	}
+
 	cobra.OnInitialize(initConfig)
 	rootCmd.PersistentFlags().
 		StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
@@ -60,7 +66,7 @@ func initConfig() {
 
 	logFormat := viper.GetString("log.format")
 	if logFormat == types.JSONLogFormat {
-	 	log.Logger = log.Output(os.Stdout)
+		log.Logger = log.Output(os.Stdout)
 	}
 
 	disableUpdateCheck := viper.GetBool("disable_check_updates")
diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go
index 4dec2bd4..80235354 100644
--- a/hscontrol/policy/v2/policy.go
+++ b/hscontrol/policy/v2/policy.go
@@ -31,6 +31,8 @@ type PolicyManager struct {
 	tagOwnerMapHash deephash.Sum
 	tagOwnerMap     map[Tag]*netipx.IPSet
 
+	exitSetHash        deephash.Sum
+	exitSet            *netipx.IPSet
 	autoApproveMapHash deephash.Sum
 	autoApproveMap     map[netip.Prefix]*netipx.IPSet
 
@@ -97,7 +99,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 	pm.tagOwnerMap = tagMap
 	pm.tagOwnerMapHash = tagOwnerMapHash
 
-	autoMap, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
+	autoMap, exitSet, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
 	if err != nil {
 		return false, fmt.Errorf("resolving auto approvers map: %w", err)
 	}
@@ -107,8 +109,13 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 	pm.autoApproveMap = autoMap
 	pm.autoApproveMapHash = autoApproveMapHash
 
+	exitSetHash := deephash.Hash(&autoMap)
+	exitSetChanged := exitSetHash != pm.exitSetHash
+	pm.exitSet = exitSet
+	pm.exitSetHash = exitSetHash
+
 	// If neither of the calculated values changed, no need to update nodes
-	if !filterChanged && !tagOwnerChanged && !autoApproveChanged {
+	if !filterChanged && !tagOwnerChanged && !autoApproveChanged && !exitSetChanged {
 		return false, nil
 	}
 
@@ -207,6 +214,23 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
 		return false
 	}
 
+	// If the route to-be-approved is an exit route, then we need to check
+	// if the node is in allowed to approve it. This is treated differently
+	// than the auto-approvers, as the auto-approvers are not allowed to
+	// approve the whole /0 range.
+	// However, an auto approver might be /0, meaning that they can approve
+	// all routes available, just not exit nodes.
+	if tsaddr.IsExitRoute(route) {
+		if pm.exitSet == nil {
+			return false
+		}
+		if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
+			return true
+		}
+
+		return false
+	}
+
 	pm.mu.Lock()
 	defer pm.mu.Unlock()
 
@@ -224,14 +248,6 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
 	// cannot just lookup in the prefix map and have to check
 	// if there is a "parent" prefix available.
 	for prefix, approveAddrs := range pm.autoApproveMap {
-		// We do not want the exit node entry to approve all
-		// sorts of routes. The logic here is that it would be
-		// unexpected behaviour to have specific routes approved
-		// just because the node is allowed to designate itself as
-		// an exit.
-		if tsaddr.IsExitRoute(prefix) {
-			continue
-		}
 
 		// Check if prefix is larger (so containing) and then overlaps
 		// the route to see if the node can approve a subset of an autoapprover
diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go
index 78b1fdbe..a49f55de 100644
--- a/hscontrol/policy/v2/types.go
+++ b/hscontrol/policy/v2/types.go
@@ -862,10 +862,11 @@ type AutoApproverPolicy struct {
 // resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
 // The resulting map can be used to quickly look up if a node can self-approve a route.
 // It is intended for internal use in a PolicyManager.
-func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, error) {
+func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, *netipx.IPSet, error) {
 	if p == nil {
-		return nil, nil
+		return nil, nil, nil
 	}
+	var err error
 
 	routes := make(map[netip.Prefix]*netipx.IPSetBuilder)
 
@@ -877,7 +878,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 			aa, ok := autoApprover.(Alias)
 			if !ok {
 				// Should never happen
-				return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
+				return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
 			}
 			// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
 			ips, _ := aa.Resolve(p, users, nodes)
@@ -891,7 +892,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 			aa, ok := autoApprover.(Alias)
 			if !ok {
 				// Should never happen
-				return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
+				return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
 			}
 			// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
 			ips, _ := aa.Resolve(p, users, nodes)
@@ -903,22 +904,20 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 	for prefix, builder := range routes {
 		ipSet, err := builder.IPSet()
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		ret[prefix] = ipSet
 	}
 
+	var exitNodeSet *netipx.IPSet
 	if len(p.AutoApprovers.ExitNode) > 0 {
-		exitNodeSet, err := exitNodeSetBuilder.IPSet()
+		exitNodeSet, err = exitNodeSetBuilder.IPSet()
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
-
-		ret[tsaddr.AllIPv4()] = exitNodeSet
-		ret[tsaddr.AllIPv6()] = exitNodeSet
 	}
 
-	return ret, nil
+	return ret, exitNodeSet, nil
 }
 
 type ACL struct {



Test patch:
diff --git a/hscontrol/policy/route_approval_test.go b/hscontrol/policy/route_approval_test.go
new file mode 100644
index 00000000..90d5f98e
--- /dev/null
+++ b/hscontrol/policy/route_approval_test.go
@@ -0,0 +1,809 @@
+package policy
+
+import (
+	"fmt"
+	"net/netip"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+)
+
+func TestNodeCanApproveRoute(t *testing.T) {
+	users := []types.User{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+		{Name: "user2", Model: gorm.Model{ID: 2}},
+		{Name: "user3", Model: gorm.Model{ID: 3}},
+	}
+
+	// Create standard node setups used across tests
+	normalNode := types.Node{
+		ID:       1,
+		Hostname: "user1-device",
+		IPv4:     ap("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+
+	exitNode := types.Node{
+		ID:       2,
+		Hostname: "user2-device",
+		IPv4:     ap("100.64.0.2"),
+		UserID:   2,
+		User:     users[1],
+	}
+
+	taggedNode := types.Node{
+		ID:         3,
+		Hostname:   "tagged-server",
+		IPv4:       ap("100.64.0.3"),
+		UserID:     3,
+		User:       users[2],
+		ForcedTags: []string{"tag:router"},
+	}
+
+	multiTagNode := types.Node{
+		ID:         4,
+		Hostname:   "multi-tag-node",
+		IPv4:       ap("100.64.0.4"),
+		UserID:     2,
+		User:       users[1],
+		ForcedTags: []string{"tag:router", "tag:server"},
+	}
+
+	tests := []struct {
+		name       string
+		node       types.Node
+		route      netip.Prefix
+		policy     string
+		canApprove bool
+		skipV1     bool
+	}{
+		{
+			name:  "allow-all-routes-for-admin-user",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "deny-route-that-doesnt-match-autoApprovers",
+			node:  normalNode,
+			route: p("10.0.0.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "user-not-in-group",
+			node:  exitNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "tagged-node-can-approve",
+			node:  taggedNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.0.0.0/8": ["tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "multiple-routes-in-policy",
+			node:  normalNode,
+			route: p("172.16.10.0/24"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"],
+						"172.16.0.0/12": ["group:admin"],
+						"10.0.0.0/8": ["tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "match-specific-route-within-range",
+			node:  normalNode,
+			route: p("192.168.5.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "ip-address-within-range",
+			node:  normalNode,
+			route: p("192.168.1.5/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.1.0/24": ["group:admin"],
+						"192.168.1.128/25": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv4-routes-(0.0.0.0/0)-approval",
+			node:  normalNode,
+			route: p("0.0.0.0/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "all-IPv4-routes-exitnode-approval",
+			node:  normalNode,
+			route: p("0.0.0.0/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv6-routes-exitnode-approval",
+			node:  normalNode,
+			route: p("::/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "specific-IPv4-route-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv6-route-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv4-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv6-routes-(::0/0)-approval",
+			node:  normalNode,
+			route: p("::/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv6-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "IPv6-route-with-IPv4-all-routes-policy",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "IPv4-route-with-IPv6-all-routes-policy",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "both-IPv4-and-IPv6-all-routes-policy",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"],
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "ip-address-with-all-routes-policy",
+			node:  normalNode,
+			route: p("192.168.101.5/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "specific-IPv6-host-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("2001:db8::1/128"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "multiple-groups-allowed-to-approve-same-route",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"],
+					"group:netadmin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.1.0/24": ["group:admin", "group:netadmin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "overlapping-routes-with-different-groups",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"],
+					"group:restricted": ["user2@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:restricted"],
+						"192.168.1.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "unique-local-IPv6-address-with-all-routes-policy",
+			node:  normalNode,
+			route: p("fc00::/7"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "exact-prefix-match-in-policy",
+			node:  normalNode,
+			route: p("203.0.113.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "narrower-range-than-policy",
+			node:  normalNode,
+			route: p("203.0.113.0/26"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "wider-range-than-policy-should-fail",
+			node:  normalNode,
+			route: p("203.0.113.0/23"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "adjacent-route-to-policy-route-should-fail",
+			node:  normalNode,
+			route: p("203.0.114.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "combined-routes-and-exitnode-approvers-specific-route",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"],
+					"routes": {
+						"192.168.1.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "partly-overlapping-route-with-policy-should-fail",
+			node:  normalNode,
+			route: p("203.0.113.128/23"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "multiple-routes-with-aggregatable-ranges",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.0.0.0/9": ["group:admin"],
+						"10.128.0.0/9": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "non-standard-IPv6-notation",
+			node:  normalNode,
+			route: p("2001:db8::1/128"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"2001:db8::/32": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-all-required",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user2@"],
+					"tag:server": ["user2@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:router", "tag:server"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-one-matching-is-sufficient",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user2@"],
+					"tag:server": ["user2@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:router", "group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-missing-required-tag",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+				    "tag:othertag": ["user1@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:othertag"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "node-with-tag-and-group-membership",
+			node:  normalNode,
+			route: p("10.20.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.20.0.0/16": ["group:admin", "tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "small-subnet-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("192.168.1.1/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "empty-policy",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
+			canApprove: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Initialize all policy manager implementations
+			policyManagers, err := PolicyManagersForTest([]byte(tt.policy), users, types.Nodes{&tt.node})
+			if tt.name == "empty policy" {
+				// We expect this one to have a valid but empty policy
+				require.NoError(t, err)
+				if err != nil {
+					return
+				}
+			} else {
+				require.NoError(t, err)
+			}
+
+			for i, pm := range policyManagers {
+				versionNum := i + 1
+				if versionNum == 1 && tt.skipV1 {
+					// Skip V1 policy manager for specific tests
+					continue
+				}
+
+				t.Run(fmt.Sprintf("PolicyV%d", versionNum), func(t *testing.T) {
+					result := pm.NodeCanApproveRoute(&tt.node, tt.route)
+
+					if diff := cmp.Diff(tt.canApprove, result); diff != "" {
+						t.Errorf("NodeCanApproveRoute() mismatch (-want +got):\n%s", diff)
+					}
+					assert.Equal(t, tt.canApprove, result, "Unexpected route approval result")
+				})
+			}
+		})
+	}
+}
diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go
index c25c14a9..3808b547 100644
--- a/hscontrol/policy/v2/types_test.go
+++ b/hscontrol/policy/v2/types_test.go
@@ -1024,10 +1024,11 @@ func TestResolveAutoApprovers(t *testing.T) {
 	}
 
 	tests := []struct {
-		name    string
-		policy  *Policy
-		want    map[netip.Prefix]*netipx.IPSet
-		wantErr bool
+		name            string
+		policy          *Policy
+		want            map[netip.Prefix]*netipx.IPSet
+		wantAllIPRoutes *netipx.IPSet
+		wantErr         bool
 	}{
 		{
 			name: "single-route",
@@ -1041,7 +1042,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "multiple-routes",
@@ -1057,7 +1059,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.2/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "exit-node",
@@ -1066,11 +1069,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 					ExitNode: AutoApprovers{ptr.To(Username("user1@"))},
 				},
 			},
-			want: map[netip.Prefix]*netipx.IPSet{
-				tsaddr.AllIPv4(): mustIPSet("100.64.0.1/32"),
-				tsaddr.AllIPv6(): mustIPSet("100.64.0.1/32"),
-			},
-			wantErr: false,
+			want:            map[netip.Prefix]*netipx.IPSet{},
+			wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
+			wantErr:         false,
 		},
 		{
 			name: "group-route",
@@ -1087,7 +1088,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "tag-route-and-exit",
@@ -1113,10 +1115,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 			},
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.4/32"),
-				tsaddr.AllIPv4():  mustIPSet("100.64.0.5/32"),
-				tsaddr.AllIPv6():  mustIPSet("100.64.0.5/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: mustIPSet("100.64.0.5/32"),
+			wantErr:         false,
 		},
 		{
 			name: "mixed-routes-and-exit-nodes",
@@ -1135,10 +1136,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.3/32"),
-				tsaddr.AllIPv4():  mustIPSet("100.64.0.1/32"),
-				tsaddr.AllIPv6():  mustIPSet("100.64.0.1/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
+			wantErr:         false,
 		},
 	}
 
@@ -1146,7 +1146,7 @@ func TestResolveAutoApprovers(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := resolveAutoApprovers(tt.policy, users, nodes)
+			got, gotAllIPRoutes, err := resolveAutoApprovers(tt.policy, users, nodes)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("resolveAutoApprovers() error = %v, wantErr %v", err, tt.wantErr)
 				return
@@ -1154,6 +1154,15 @@ func TestResolveAutoApprovers(t *testing.T) {
 			if diff := cmp.Diff(tt.want, got, cmps...); diff != "" {
 				t.Errorf("resolveAutoApprovers() mismatch (-want +got):\n%s", diff)
 			}
+			if tt.wantAllIPRoutes != nil {
+				if gotAllIPRoutes == nil {
+					t.Error("resolveAutoApprovers() expected non-nil allIPRoutes, got nil")
+				} else if diff := cmp.Diff(tt.wantAllIPRoutes, gotAllIPRoutes, cmps...); diff != "" {
+					t.Errorf("resolveAutoApprovers() allIPRoutes mismatch (-want +got):\n%s", diff)
+				}
+			} else if gotAllIPRoutes != nil {
+				t.Error("resolveAutoApprovers() expected nil allIPRoutes, got non-nil")
+			}
 		})
 	}
 }