fix(iam): drop a stale cached identity when its edit is refused

70914759e refused an edit whose stored record no longer matched the
caller's view, but left the stale copy in the serving server's cache in
two cases: a service-account edit bound to a parent whose account was
deleted and created again for another parent, and a replicated edit older
than what is stored, which site replication acknowledges. That server
kept authenticating the old credential until a notification or the
periodic load.

Both refusals now drop the cache entry of the addressed kind, as a
refusal for an absent or deleted identity does: the service-account
closure reports such a record as gone (callers still get
errNoSuchServiceAccount), and editStoredIdentity also drops the entry on
errIAMStaleUpdate. The LDAP group refresh again treats a stale update as
a refusal that writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-08 14:18:41 +08:00
parent 70914759e5
commit 7eed95bc8e
2 changed files with 48 additions and 6 deletions
+9 -6
View File
@@ -2854,12 +2854,12 @@ func (store *IAMStoreSys) UpdateServiceAccount(ctx context.Context, accessKey st
u, _, err := store.editStoredIdentity(ctx, cache, accessKey, svcUser, func(stored *UserIdentity) error {
if !stored.Credentials.IsServiceAccount() {
return errNoSuchServiceAccount
return errIAMIdentityGone
}
if opts.parentUser != "" && stored.Credentials.ParentUser != opts.parentUser {
// Not the account the caller was authorized for: it was
// deleted and created again for another parent meanwhile.
return errNoSuchServiceAccount
return errIAMIdentityGone
}
cr, err := updatedServiceAccountCredentials(ctx, accessKey, stored.Credentials, opts)
if err != nil {
@@ -3168,7 +3168,7 @@ func (store *IAMStoreSys) RefreshUserGroups(ctx context.Context, cred auth.Crede
stored.Credentials.Groups = groups
return nil
})
if errors.Is(err, errIAMIdentityGone) {
if errors.Is(err, errIAMIdentityGone) || errors.Is(err, errIAMStaleUpdate) {
return false, nil
}
if err != nil {
@@ -3181,12 +3181,15 @@ func (store *IAMStoreSys) RefreshUserGroups(ctx context.Context, cred auth.Crede
// is stored now, under its revision lock (see iamIdentityEdit), and caches
// the result. The caller holds the cache lock. When the identity is absent
// or deleted in storage, or edit returns errIAMIdentityGone because it is
// no longer the identity the edit addressed, the cached copy is dropped, so
// a lookup loads what is stored, and errIAMIdentityGone is returned.
// no longer the identity the edit addressed, errIAMIdentityGone is
// returned; a replicated edit older than what is stored returns
// errIAMStaleUpdate. Either way storage holds something else than the
// cached copy, which is dropped, so a lookup loads what is stored.
func (store *IAMStoreSys) editStoredIdentity(ctx context.Context, cache *iamCache, accessKey string, userType IAMUserType, edit func(u *UserIdentity) error) (UserIdentity, bool, error) {
op := &iamIdentityEdit{accessKey: accessKey, edit: edit}
err := store.saveIAMRevision(ctx, getUserIdentityPath(accessKey, userType), op)
if errors.Is(err, errIAMIdentityGone) {
if errors.Is(err, errIAMIdentityGone) || errors.Is(err, errIAMStaleUpdate) {
// Storage holds something else than the cached copy: drop it.
store.forgetCachedIdentity(cache, accessKey, userType)
}
if err != nil {
+39
View File
@@ -231,3 +231,42 @@ func TestIAMUserStatusOnServiceAccount(t *testing.T) {
t.Error("a user secret change of a service account touched the account")
}
}
// A server whose cache still holds the account of the first parent drops
// it when its edit is refused, whether the edit was bound to that parent or
// replicated from before the deletion: storage says the cached copy is gone.
func TestIAMRefusedEditDropsStaleCache(t *testing.T) {
t.Run("rebound-parent", func(t *testing.T) {
f := newGroupRefreshFixture(t)
snapshot := f.service(t, "edit-rebind")
other := f.sibling(t)
next := iamAdminUser(f.ctx, t, f.sys, "edit-rebind-next", "refresh-parent")
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
_, _, err := f.sys.NewServiceAccount(f.ctx, next.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "edit-rebind-secret"})
mustIAM(t, err)
if cached, ok := other.GetUser(snapshot.AccessKey); !ok || cached.Credentials.ParentUser != snapshot.ParentUser {
t.Fatal("control: the stale server must still cache the first account")
}
_, err = other.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{description: "stale", parentUser: snapshot.ParentUser})
if !errors.Is(err, errNoSuchServiceAccount) {
t.Errorf("got %v, want errNoSuchServiceAccount", err)
}
if _, ok := other.GetUser(snapshot.AccessKey); ok {
t.Error("the stale server still caches the account of the first parent")
}
})
t.Run("replicated-stale", func(t *testing.T) {
f := newGroupRefreshFixture(t)
snapshot := f.service(t, "edit-repl-stale")
other := f.sibling(t)
before := UTCNow()
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
_, err := other.UpdateServiceAccount(withIAMReplicationTime(f.ctx, before), snapshot.AccessKey, updateServiceAccountOpts{description: "late"})
if iamReplicationError(err) != nil {
t.Errorf("an older replicated edit must be acknowledged, got %v", err)
}
if _, ok := other.GetUser(snapshot.AccessKey); ok {
t.Error("the server that acknowledged a stale edit still caches the deleted account")
}
})
}