fix(iam): refresh LDAP groups on the stored credential, not a stale snapshot

The hourly LDAP group refresh, which every server runs, reads the STS and
service-account credentials from its own cache, queries the directory,
and wrote each credential whose groups changed back whole through
UpdateUserIdentity. A secret rotation or an edit made meanwhile, on this
server or one whose notification it missed, was undone, and a credential
deleted meanwhile came back enabled with its old secret.

RefreshUserGroups replaces that writer, whose only caller was the
refresh. Under the identity's cross-node revision lock it loads the
stored record, refuses one that is absent, deleted, expired or now has
another parent (whose groups the lookup did not compute), and otherwise
changes only the groups of the stored record. A record without its
access key gets it from the path, as the loaders do. A refused refresh
drops the stale entry from the cache, which loads what is stored on next
use; a written or unchanged one caches the stored record. An STS record
keeps the lease of its token's remaining lifetime.

Tests cover rotation and edit, deletion, a sibling server whose cache did
not hear of a rotation or a deletion, recreation under the same and under
another parent, unchanged groups, and a stored record without its access
key. Written back as the old snapshot write, every case fails.

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 12:00:20 +08:00
parent af0afd2321
commit a0de92fcbe
5 changed files with 312 additions and 20 deletions
+31 -15
View File
@@ -3144,27 +3144,43 @@ func (store *IAMStoreSys) getSTSAndServiceAccounts(cache *iamCache) []auth.Crede
return res
}
// UpdateUserIdentity - updates a user credential.
func (store *IAMStoreSys) UpdateUserIdentity(ctx context.Context, cred auth.Credentials) error {
// RefreshUserGroups sets the groups of the STS or service-account
// credential that cred was read from to groups, on the record stored now
// and under its revision lock (see iamGroupRefresh). Nothing else of the
// record changes. A credential that is gone, expired or now has another
// parent is left alone in storage and dropped from the cache, which then
// loads what is stored on its next use. It reports whether it wrote.
func (store *IAMStoreSys) RefreshUserGroups(ctx context.Context, cred auth.Credentials, groups []string) (bool, error) {
cache := store.lock()
defer store.unlock()
cache.updatedAt = time.Now()
userType := regUser
if cred.IsServiceAccount() {
userType = svcUser
} else if cred.IsTemp() {
userType := svcUser
if !cred.IsServiceAccount() {
if !cred.IsTemp() {
return false, errInvalidArgument
}
userType = stsUser
}
ui := newUserIdentity(cred)
// Overwrite the user identity here. As store should be
// atomic, it shouldn't cause any corruption.
if err := store.saveUserIdentity(ctx, cred.AccessKey, userType, &ui); err != nil {
return err
op := &iamGroupRefresh{accessKey: cred.AccessKey, parentUser: cred.ParentUser, groups: groups}
err := store.saveIAMRevision(ctx, getUserIdentityPath(cred.AccessKey, userType), op)
if errors.Is(err, errIAMStaleUpdate) {
// The cache still holds the stale record; storage says otherwise.
delete(cache.iamUsersMap, cred.AccessKey)
delete(cache.iamSTSAccountsMap, cred.AccessKey)
cache.updatedAt = time.Now()
if store.group != nil {
store.group.Forget(cred.AccessKey)
}
return false, nil
}
return cache.updateUserWithClaims(cred.AccessKey, ui)
if err != nil {
return false, err
}
// The stored record is authoritative, also when it had the groups.
if err := cache.updateUserWithClaims(cred.AccessKey, op.UserIdentity); err != nil {
return false, err
}
return !op.unchanged, nil
}
// LoadUser - attempts to load user info from storage and updates cache.