Skip to content

Commit 7e8cee6

Browse files
authored
chore: fix filterHash to work with autogroup:self in the acls (#2882)
1 parent 7f1631c commit 7e8cee6

File tree

2 files changed

+130
-12
lines changed

2 files changed

+130
-12
lines changed

hscontrol/policy/v2/policy.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
7987
func (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

hscontrol/policy/v2/policy_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,89 @@ func TestAutogroupSelfWithOtherRules(t *testing.T) {
519519
require.NoError(t, err)
520520
require.NotEmpty(t, rules, "test-1 should have filter rules from both ACL rules")
521521
}
522+
523+
// TestAutogroupSelfPolicyUpdateTriggersMapResponse verifies that when a policy with
524+
// autogroup:self is updated, SetPolicy returns true to trigger MapResponse updates,
525+
// even if the global filter hash didn't change (which is always empty for autogroup:self).
526+
// This fixes the issue where policy updates would clear caches but not trigger updates,
527+
// leaving nodes with stale filter rules until reconnect.
528+
func TestAutogroupSelfPolicyUpdateTriggersMapResponse(t *testing.T) {
529+
users := types.Users{
530+
{Model: gorm.Model{ID: 1}, Name: "test-1", Email: "[email protected]"},
531+
{Model: gorm.Model{ID: 2}, Name: "test-2", Email: "[email protected]"},
532+
}
533+
534+
test1Node := &types.Node{
535+
ID: 1,
536+
Hostname: "test-1-device",
537+
IPv4: ap("100.64.0.1"),
538+
IPv6: ap("fd7a:115c:a1e0::1"),
539+
User: users[0],
540+
UserID: users[0].ID,
541+
Hostinfo: &tailcfg.Hostinfo{},
542+
}
543+
544+
test2Node := &types.Node{
545+
ID: 2,
546+
Hostname: "test-2-device",
547+
IPv4: ap("100.64.0.2"),
548+
IPv6: ap("fd7a:115c:a1e0::2"),
549+
User: users[1],
550+
UserID: users[1].ID,
551+
Hostinfo: &tailcfg.Hostinfo{},
552+
}
553+
554+
nodes := types.Nodes{test1Node, test2Node}
555+
556+
// Initial policy with autogroup:self
557+
initialPolicy := `{
558+
"acls": [
559+
{
560+
"action": "accept",
561+
"src": ["autogroup:member"],
562+
"dst": ["autogroup:self:*"]
563+
}
564+
]
565+
}`
566+
567+
pm, err := NewPolicyManager([]byte(initialPolicy), users, nodes.ViewSlice())
568+
require.NoError(t, err)
569+
require.True(t, pm.usesAutogroupSelf, "policy should use autogroup:self")
570+
571+
// Get initial filter rules for test-1 (should be cached)
572+
rules1, err := pm.FilterForNode(test1Node.View())
573+
require.NoError(t, err)
574+
require.NotEmpty(t, rules1, "test-1 should have filter rules")
575+
576+
// Update policy with a different ACL that still results in empty global filter
577+
// (only autogroup:self rules, which compile to empty global filter)
578+
// We add a comment/description change by adding groups (which don't affect filter compilation)
579+
updatedPolicy := `{
580+
"groups": {
581+
"group:test": ["[email protected]"]
582+
},
583+
"acls": [
584+
{
585+
"action": "accept",
586+
"src": ["autogroup:member"],
587+
"dst": ["autogroup:self:*"]
588+
}
589+
]
590+
}`
591+
592+
// SetPolicy should return true even though global filter hash didn't change
593+
policyChanged, err := pm.SetPolicy([]byte(updatedPolicy))
594+
require.NoError(t, err)
595+
require.True(t, policyChanged, "SetPolicy should return true when policy content changes, even if global filter hash unchanged (autogroup:self)")
596+
597+
// Verify that caches were cleared and new rules are generated
598+
// The cache should be empty, so FilterForNode will recompile
599+
rules2, err := pm.FilterForNode(test1Node.View())
600+
require.NoError(t, err)
601+
require.NotEmpty(t, rules2, "test-1 should have filter rules after policy update")
602+
603+
// Verify that the policy hash tracking works - a second identical update should return false
604+
policyChanged2, err := pm.SetPolicy([]byte(updatedPolicy))
605+
require.NoError(t, err)
606+
require.False(t, policyChanged2, "SetPolicy should return false when policy content hasn't changed")
607+
}

0 commit comments

Comments
 (0)