Skip to content

Commit 45e4ef1

Browse files
committed
fix(cache): optimistic updates on delete + disable
Bug symptom: after clicking Delete or Disable on a user/group/computer, the list+drawer still showed the target entry for several seconds ("took longer than expected"), even though the LDAP op succeeded. Cause is AD replication delay — Refresh() runs against the readonly-bind DC which hasn't yet received the mutation from the writing DC. Solution: bypass the replication round-trip by applying the mutation to the in-memory cache directly, the moment the per-entity LDAP op returns without error. Follows the existing OnAddUserToGroup / OnRemoveUserFromGroup pattern. New Manager hooks (wired into finaliseBulk* in bulk_handlers.go): - OnDeleteUser(dn) — drops entry, scrubs from Groups.Members - OnDeleteGroup(dn) — drops entry, scrubs from User.Groups / Computer.Groups - OnDeleteComputer(dn) — drops entry - OnDisableUser(dn) — flips Enabled=false on the user - OnDisableComputer(dn) — flips Enabled=false on the computer Supporting change: new unexported Cache[T].remove(dn) that deletes a single entry by DN and rebuilds the O(1) indexes. No-op on miss so callers can call it optimistically. The trailing full Refresh() in finaliseBulk* is kept as a reconciliation pass but correctness of the redirected list no longer depends on it winning the race against replication. Coverage: hooks_test.go (8 subtests): Cache.remove happy/miss/empty, OnDeleteUser member-scrub, OnDeleteGroup memberOf-scrub on both users and computers, OnDeleteComputer idempotence, OnDisable{User,Computer} Enabled-flip. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent bf8926f commit 45e4ef1

4 files changed

Lines changed: 373 additions & 11 deletions

File tree

internal/ldap_cache/cache.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ func (c *Cache[T]) update(fn func(*T)) {
129129
c.buildIndexes()
130130
}
131131

132+
// remove drops the item with the given distinguished name from the cache
133+
// and rebuilds indexes. A no-op when no matching entry exists, so callers
134+
// can invoke it optimistically after an LDAP delete without first
135+
// checking presence. Used by the Manager's OnDelete* hooks to keep the
136+
// in-memory cache correct immediately after a successful LDAP mutation,
137+
// without waiting for the next background Refresh (which can be delayed
138+
// by AD replication between the modifying DC and the readonly-bind DC).
139+
func (c *Cache[T]) remove(dn string) {
140+
c.m.Lock()
141+
defer c.m.Unlock()
142+
143+
for idx := range c.items {
144+
if c.items[idx].DN() != dn {
145+
continue
146+
}
147+
148+
c.items = append(c.items[:idx], c.items[idx+1:]...)
149+
c.buildIndexes()
150+
151+
return
152+
}
153+
}
154+
132155
// Get returns a snapshot copy of all cached items.
133156
// The returned slice is safe to iterate without holding any lock.
134157
func (c *Cache[T]) Get() []T {

internal/ldap_cache/hooks_test.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// internal/ldap_cache/hooks_test.go
2+
package ldap_cache
3+
4+
import (
5+
"testing"
6+
7+
ldap "github.com/netresearch/simple-ldap-go"
8+
)
9+
10+
// testEntity is a minimal cacheable used to exercise Cache.remove under
11+
// a controlled DN surface. The ldap.User / ldap.Computer structs embed
12+
// simple-ldap-go's Object struct with unexported dn/cn fields, so there
13+
// is no public way to seed a real DN from outside that package. An
14+
// in-test type sidesteps that without resorting to unsafe pointer
15+
// tricks, and lets us assert DN-indexed behaviour properly.
16+
type testEntity struct {
17+
dn string
18+
v string
19+
}
20+
21+
func (e testEntity) DN() string { return e.dn }
22+
23+
func TestCacheRemove(t *testing.T) {
24+
seed := []testEntity{
25+
{dn: "cn=alice,dc=x", v: "A"},
26+
{dn: "cn=bob,dc=x", v: "B"},
27+
{dn: "cn=carol,dc=x", v: "C"},
28+
}
29+
30+
t.Run("removes the matching entry", func(t *testing.T) {
31+
c := NewCached[testEntity]()
32+
c.setAll(append([]testEntity(nil), seed...))
33+
34+
c.remove("cn=bob,dc=x")
35+
36+
if got := c.Count(); got != 2 {
37+
t.Fatalf("expected 2 items after remove, got %d", got)
38+
}
39+
if _, ok := c.FindByDN("cn=bob,dc=x"); ok {
40+
t.Error("bob still indexed after remove")
41+
}
42+
if _, ok := c.FindByDN("cn=alice,dc=x"); !ok {
43+
t.Error("alice missing after remove")
44+
}
45+
if _, ok := c.FindByDN("cn=carol,dc=x"); !ok {
46+
t.Error("carol missing after remove")
47+
}
48+
})
49+
50+
t.Run("no-op when DN is not present", func(t *testing.T) {
51+
c := NewCached[testEntity]()
52+
c.setAll(append([]testEntity(nil), seed...))
53+
54+
c.remove("cn=does-not-exist,dc=x")
55+
56+
if got := c.Count(); got != 3 {
57+
t.Fatalf("expected 3 items, got %d", got)
58+
}
59+
})
60+
61+
t.Run("tolerates empty cache", func(t *testing.T) {
62+
c := NewCached[testEntity]()
63+
c.remove("cn=whatever")
64+
65+
if got := c.Count(); got != 0 {
66+
t.Fatalf("expected 0 items, got %d", got)
67+
}
68+
})
69+
}
70+
71+
// TestManagerOnDeleteUser_ScrubGroupMembership relies on the fact that
72+
// every group's Members is a []string of user DNs — so even though the
73+
// mock users synthesise DN() == "", we can assert that a user DN we
74+
// seed into a group's member list gets scrubbed by OnDeleteUser.
75+
func TestManagerOnDeleteUser_ScrubGroupMembership(t *testing.T) {
76+
const userDN = "cn=john.doe,ou=users,dc=example,dc=com"
77+
78+
mockClient := &mockLDAPClient{
79+
users: []ldap.User{
80+
NewMockUser(userDN, "john.doe", true, nil),
81+
},
82+
groups: []ldap.Group{
83+
{Members: []string{userDN, "cn=other,dc=example,dc=com"}},
84+
},
85+
}
86+
manager := New(mockClient)
87+
88+
if err := manager.RefreshUsers(); err != nil {
89+
t.Fatalf("refresh users: %v", err)
90+
}
91+
if err := manager.RefreshGroups(); err != nil {
92+
t.Fatalf("refresh groups: %v", err)
93+
}
94+
95+
manager.OnDeleteUser(userDN)
96+
97+
groups := manager.Groups.Get()
98+
if len(groups) != 1 {
99+
t.Fatalf("expected 1 group, got %d", len(groups))
100+
}
101+
for _, m := range groups[0].Members {
102+
if m == userDN {
103+
t.Errorf("group still references deleted user %q", userDN)
104+
}
105+
}
106+
if want := "cn=other,dc=example,dc=com"; len(groups[0].Members) != 1 || groups[0].Members[0] != want {
107+
t.Errorf("expected remaining member %q, got %v", want, groups[0].Members)
108+
}
109+
}
110+
111+
// TestManagerOnDeleteGroup_ScrubMemberOf verifies that deleting a group
112+
// removes it from every user's Groups slice and every computer's Groups
113+
// slice.
114+
func TestManagerOnDeleteGroup_ScrubMemberOf(t *testing.T) {
115+
const groupDN = "cn=old-group,ou=groups,dc=example,dc=com"
116+
117+
mockClient := &mockLDAPClient{
118+
users: []ldap.User{
119+
NewMockUser("cn=john.doe,ou=users,dc=example,dc=com", "john.doe", true,
120+
[]string{groupDN, "cn=keep,dc=example,dc=com"}),
121+
},
122+
groups: []ldap.Group{
123+
{Members: nil},
124+
},
125+
computers: []ldap.Computer{
126+
NewMockComputer("cn=workstation-01,ou=computers,dc=example,dc=com", "workstation-01$", true,
127+
[]string{groupDN, "cn=keep,dc=example,dc=com"}),
128+
},
129+
}
130+
manager := New(mockClient)
131+
manager.Refresh()
132+
133+
manager.OnDeleteGroup(groupDN)
134+
135+
users := manager.Users.Get()
136+
for _, u := range users {
137+
for _, g := range u.Groups {
138+
if g == groupDN {
139+
t.Errorf("user %q still references deleted group %q", u.SAMAccountName, groupDN)
140+
}
141+
}
142+
}
143+
144+
computers := manager.Computers.Get()
145+
for _, c := range computers {
146+
for _, g := range c.Groups {
147+
if g == groupDN {
148+
t.Errorf("computer %q still references deleted group %q", c.SAMAccountName, groupDN)
149+
}
150+
}
151+
}
152+
}
153+
154+
// TestManagerOnDeleteComputer_NoOpOnAbsent confirms that deleting a
155+
// computer DN that isn't cached is a safe no-op (the hook is called
156+
// before bulk_handlers has a chance to check cache presence).
157+
func TestManagerOnDeleteComputer_NoOpOnAbsent(t *testing.T) {
158+
mockClient := &mockLDAPClient{
159+
computers: createMockComputers(),
160+
}
161+
manager := New(mockClient)
162+
if err := manager.RefreshComputers(); err != nil {
163+
t.Fatalf("refresh computers: %v", err)
164+
}
165+
166+
before := manager.Computers.Count()
167+
manager.OnDeleteComputer("cn=ghost,ou=computers,dc=example,dc=com")
168+
169+
if after := manager.Computers.Count(); after != before {
170+
t.Errorf("computer count changed: before=%d after=%d", before, after)
171+
}
172+
}
173+
174+
// TestManagerOnDisableUser_FlipsEnabled uses the `update` code path
175+
// which iterates all users. Because mock users' DN() returns "", we
176+
// exploit the empty-string equality to flip every cached user's
177+
// Enabled bit and assert the write-back took effect. This is the same
178+
// pragmatic approach the existing OnAdd/OnRemove tests use.
179+
func TestManagerOnDisableUser_FlipsEnabled(t *testing.T) {
180+
mockClient := &mockLDAPClient{
181+
users: []ldap.User{
182+
NewMockUser("cn=john.doe,…", "john.doe", true, nil),
183+
NewMockUser("cn=jane.smith,…", "jane.smith", true, nil),
184+
},
185+
}
186+
manager := New(mockClient)
187+
if err := manager.RefreshUsers(); err != nil {
188+
t.Fatalf("refresh users: %v", err)
189+
}
190+
191+
manager.OnDisableUser("") // mock DN()=="" matches all
192+
193+
for _, u := range manager.Users.Get() {
194+
if u.Enabled {
195+
t.Errorf("user %q still enabled after OnDisableUser", u.SAMAccountName)
196+
}
197+
}
198+
}
199+
200+
// TestManagerOnDisableComputer_FlipsEnabled mirrors
201+
// TestManagerOnDisableUser_FlipsEnabled for machine accounts.
202+
func TestManagerOnDisableComputer_FlipsEnabled(t *testing.T) {
203+
mockClient := &mockLDAPClient{
204+
computers: []ldap.Computer{
205+
NewMockComputer("cn=ws01,…", "ws01$", true, nil),
206+
},
207+
}
208+
manager := New(mockClient)
209+
if err := manager.RefreshComputers(); err != nil {
210+
t.Fatalf("refresh computers: %v", err)
211+
}
212+
213+
manager.OnDisableComputer("")
214+
215+
for _, cmp := range manager.Computers.Get() {
216+
if cmp.Enabled {
217+
t.Errorf("computer %q still enabled after OnDisableComputer", cmp.SAMAccountName)
218+
}
219+
}
220+
}

internal/ldap_cache/manager.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,83 @@ func (m *Manager) OnRemoveUserFromGroup(userDN, groupDN string) {
489489
})
490490
}
491491

492+
// OnDeleteUser drops a user entry from the cache by DN and scrubs any
493+
// group memberships that reference it. Call after a successful LDAP
494+
// delete. Idempotent: no-op if the user isn't cached. The trailing
495+
// Refresh() the bulk handler still issues catches up with everything
496+
// else the directory might have changed, but correctness of the user
497+
// list itself no longer depends on that refresh winning against AD
498+
// replication delay.
499+
func (m *Manager) OnDeleteUser(userDN string) {
500+
m.Users.remove(userDN)
501+
502+
m.Groups.update(func(group *ldap.Group) {
503+
for idx, member := range group.Members {
504+
if member == userDN {
505+
group.Members = append(group.Members[:idx], group.Members[idx+1:]...)
506+
507+
return
508+
}
509+
}
510+
})
511+
}
512+
513+
// OnDeleteGroup drops a group entry from the cache by DN and scrubs any
514+
// user/computer memberOf references that point at it. See OnDeleteUser
515+
// for rationale.
516+
func (m *Manager) OnDeleteGroup(groupDN string) {
517+
m.Groups.remove(groupDN)
518+
519+
m.Users.update(func(user *ldap.User) {
520+
for idx, g := range user.Groups {
521+
if g == groupDN {
522+
user.Groups = append(user.Groups[:idx], user.Groups[idx+1:]...)
523+
524+
return
525+
}
526+
}
527+
})
528+
529+
m.Computers.update(func(computer *ldap.Computer) {
530+
for idx, g := range computer.Groups {
531+
if g == groupDN {
532+
computer.Groups = append(computer.Groups[:idx], computer.Groups[idx+1:]...)
533+
534+
return
535+
}
536+
}
537+
})
538+
}
539+
540+
// OnDeleteComputer drops a computer entry from the cache by DN.
541+
// Computers are not referenced from other cached entities (group
542+
// membership is stored on the computer side only for memberOf display),
543+
// so this is a straight remove.
544+
func (m *Manager) OnDeleteComputer(computerDN string) {
545+
m.Computers.remove(computerDN)
546+
}
547+
548+
// OnDisableUser flips the Enabled bit on the cached user to false so
549+
// the UI reflects the disabled state immediately after a successful
550+
// LDAP userAccountControl mutation, without waiting for the next
551+
// background Refresh to rediscover it via the readonly bind.
552+
func (m *Manager) OnDisableUser(userDN string) {
553+
m.Users.update(func(user *ldap.User) {
554+
if user.DN() == userDN {
555+
user.Enabled = false
556+
}
557+
})
558+
}
559+
560+
// OnDisableComputer is the OnDisableUser analogue for machine accounts.
561+
func (m *Manager) OnDisableComputer(computerDN string) {
562+
m.Computers.update(func(computer *ldap.Computer) {
563+
if computer.DN() == computerDN {
564+
computer.Enabled = false
565+
}
566+
})
567+
}
568+
492569
// PopulateGroupsForUserFromData creates a FullLDAPUser with populated group memberships
493570
// using provided data instead of cache. Works identically to PopulateGroupsForUser
494571
// but operates on explicit slices rather than the cache.

0 commit comments

Comments
 (0)