@@ -47,6 +47,14 @@ type PolicyManager struct {
4747 usesAutogroupSelf bool
4848}
4949
50+ // filterAndPolicy combines the compiled filter rules with policy content for hashing.
51+ // This ensures filterHash changes when policy changes, even for autogroup:self where
52+ // the compiled filter is always empty.
53+ type filterAndPolicy struct {
54+ Filter []tailcfg.FilterRule
55+ Policy * Policy
56+ }
57+
5058// NewPolicyManager creates a new PolicyManager from a policy file and a list of users and nodes.
5159// It returns an error if the policy file is invalid.
5260// The policy manager will update the filter rules based on the users and nodes.
@@ -77,14 +85,6 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node
7785// updateLocked updates the filter rules based on the current policy and nodes.
7886// It must be called with the lock held.
7987func (pm * PolicyManager ) updateLocked () (bool , error ) {
80- // Clear the SSH policy map to ensure it's recalculated with the new policy.
81- // TODO(kradalby): This could potentially be optimized by only clearing the
82- // policies for nodes that have changed. Particularly if the only difference is
83- // that nodes has been added or removed.
84- clear (pm .sshPolicyMap )
85- clear (pm .compiledFilterRulesMap )
86- clear (pm .filterRulesMap )
87-
8888 // Check if policy uses autogroup:self
8989 pm .usesAutogroupSelf = pm .pol .usesAutogroupSelf ()
9090
@@ -98,7 +98,14 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
9898 return false , fmt .Errorf ("compiling filter rules: %w" , err )
9999 }
100100
101- filterHash := deephash .Hash (& filter )
101+ // Hash both the compiled filter AND the policy content together.
102+ // This ensures filterHash changes when policy changes, even for autogroup:self
103+ // where the compiled filter is always empty. This eliminates the need for
104+ // a separate policyHash field.
105+ filterHash := deephash .Hash (& filterAndPolicy {
106+ Filter : filter ,
107+ Policy : pm .pol ,
108+ })
102109 filterChanged := filterHash != pm .filterHash
103110 if filterChanged {
104111 log .Debug ().
@@ -164,8 +171,27 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
164171 pm .exitSet = exitSet
165172 pm .exitSetHash = exitSetHash
166173
167- // If neither of the calculated values changed, no need to update nodes
168- if ! filterChanged && ! tagOwnerChanged && ! autoApproveChanged && ! exitSetChanged {
174+ // Determine if we need to send updates to nodes
175+ // filterChanged now includes policy content changes (via combined hash),
176+ // so it will detect changes even for autogroup:self where compiled filter is empty
177+ needsUpdate := filterChanged || tagOwnerChanged || autoApproveChanged || exitSetChanged
178+
179+ // Only clear caches if we're actually going to send updates
180+ // This prevents clearing caches when nothing changed, which would leave nodes
181+ // with stale filters until they reconnect. This is critical for autogroup:self
182+ // where even reloading the same policy would clear caches but not send updates.
183+ if needsUpdate {
184+ // Clear the SSH policy map to ensure it's recalculated with the new policy.
185+ // TODO(kradalby): This could potentially be optimized by only clearing the
186+ // policies for nodes that have changed. Particularly if the only difference is
187+ // that nodes has been added or removed.
188+ clear (pm .sshPolicyMap )
189+ clear (pm .compiledFilterRulesMap )
190+ clear (pm .filterRulesMap )
191+ }
192+
193+ // If nothing changed, no need to update nodes
194+ if ! needsUpdate {
169195 log .Trace ().
170196 Msg ("Policy evaluation detected no changes - all hashes match" )
171197 return false , nil
@@ -491,10 +517,16 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro
491517 // For global policies: the filter must be recompiled to include the new nodes.
492518 if nodesChanged {
493519 // Recompile filter with the new node list
494- _ , err := pm .updateLocked ()
520+ needsUpdate , err := pm .updateLocked ()
495521 if err != nil {
496522 return false , err
497523 }
524+ if ! needsUpdate {
525+ // This ensures fresh filter rules are generated for all nodes
526+ clear (pm .sshPolicyMap )
527+ clear (pm .compiledFilterRulesMap )
528+ clear (pm .filterRulesMap )
529+ }
498530 // Always return true when nodes changed, even if filter hash didn't change
499531 // (can happen with autogroup:self or when nodes are added but don't affect rules)
500532 return true , nil
0 commit comments