mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 01:31:52 +03:00
a0de92fcbe
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>
227 lines
9.2 KiB
Go
227 lines
9.2 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/minio/minio/internal/auth"
|
|
)
|
|
|
|
// The LDAP group refresh computes groups from a snapshot of a server's cache
|
|
// taken before a slow directory lookup, then writes them. These tests take
|
|
// such a snapshot, change the credential the way an administrator would on
|
|
// this or another server, and run the write: it must change only the
|
|
// groups of what is stored now, and never undo the change.
|
|
|
|
type groupRefreshFixture struct {
|
|
ctx context.Context
|
|
sys *IAMSys
|
|
obj ObjectLayer
|
|
parent auth.Credentials
|
|
}
|
|
|
|
func newGroupRefreshFixture(t *testing.T) *groupRefreshFixture {
|
|
t.Helper()
|
|
ctx, sys, obj := prepareIAMRevisionFixture(t)
|
|
batchAuthzPolicy(ctx, t, sys, "refresh-parent", batchAuthzFullAccess)
|
|
parent := iamAdminUser(ctx, t, sys, "refresh-parent", "refresh-parent")
|
|
return &groupRefreshFixture{ctx: ctx, sys: sys, obj: obj, parent: parent}
|
|
}
|
|
|
|
// service creates a service account of the fixture's parent and returns
|
|
// its stored credential, the snapshot a refresh would take.
|
|
func (f *groupRefreshFixture) service(t *testing.T, accessKey string) auth.Credentials {
|
|
t.Helper()
|
|
_, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: accessKey, secretKey: "refresh-original-secret"})
|
|
mustIAM(t, err)
|
|
u, ok := f.sys.store.GetUser(accessKey)
|
|
if !ok {
|
|
t.Fatal("service account missing")
|
|
}
|
|
return u.Credentials
|
|
}
|
|
|
|
// sibling is another server: the same storage, its own cache.
|
|
func (f *groupRefreshFixture) sibling(t *testing.T) *IAMStoreSys {
|
|
t.Helper()
|
|
s := &IAMStoreSys{IAMStorageAPI: newIAMObjectStore(f.obj, f.sys.usersSysType)}
|
|
mustIAM(t, s.LoadIAMCache(f.ctx, false))
|
|
return s
|
|
}
|
|
|
|
// stored is the record as a server that starts now loads it.
|
|
func (f *groupRefreshFixture) stored(t *testing.T, accessKey string) (UserIdentity, bool) {
|
|
t.Helper()
|
|
return f.sibling(t).GetUser(accessKey)
|
|
}
|
|
|
|
func TestIAMGroupRefreshKeepsRotation(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-rotate")
|
|
_, err := f.sys.UpdateServiceAccount(f.ctx, snapshot.AccessKey, updateServiceAccountOpts{secretKey: "refresh-rotated-secret", description: "rotated"})
|
|
mustIAM(t, err)
|
|
|
|
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
|
mustIAM(t, err)
|
|
if !wrote {
|
|
t.Fatal("a refresh of a live credential must write its new groups")
|
|
}
|
|
check := func(where string, u UserIdentity, ok bool) {
|
|
t.Helper()
|
|
switch {
|
|
case !ok:
|
|
t.Fatalf("%s: credential missing", where)
|
|
case u.Credentials.SecretKey != "refresh-rotated-secret":
|
|
t.Errorf("%s: the refresh undid the rotation", where)
|
|
case u.Credentials.Description != "rotated":
|
|
t.Errorf("%s: the refresh undid an edit", where)
|
|
case !slices.Equal(u.Credentials.Groups, []string{"g1"}):
|
|
t.Errorf("%s: groups %v, want [g1]", where, u.Credentials.Groups)
|
|
}
|
|
}
|
|
u, ok := f.sys.store.GetUser(snapshot.AccessKey)
|
|
check("cache", u, ok)
|
|
u, ok = f.stored(t, snapshot.AccessKey)
|
|
check("storage", u, ok)
|
|
if _, valid, _ := f.sys.CheckKey(f.ctx, snapshot.AccessKey); !valid {
|
|
t.Error("the rotated credential must stay valid")
|
|
}
|
|
}
|
|
|
|
func TestIAMGroupRefreshKeepsDeletion(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-delete")
|
|
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
|
|
|
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
|
mustIAM(t, err)
|
|
if wrote {
|
|
t.Error("a refresh wrote a deleted credential")
|
|
}
|
|
if _, ok := f.sys.store.GetUser(snapshot.AccessKey); ok {
|
|
t.Error("a refresh brought a deleted credential back into the cache")
|
|
}
|
|
if _, ok := f.stored(t, snapshot.AccessKey); ok {
|
|
t.Error("a refresh brought a deleted credential back in storage")
|
|
}
|
|
}
|
|
|
|
// Another server changed the credential and this server's cache has not
|
|
// heard of it: its snapshot and its cache are both stale.
|
|
func TestIAMGroupRefreshStaleSiblingCache(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
change func(t *testing.T, f *groupRefreshFixture, accessKey string)
|
|
check func(t *testing.T, u UserIdentity, ok bool)
|
|
}{
|
|
{"rotated", func(t *testing.T, f *groupRefreshFixture, accessKey string) {
|
|
_, err := f.sys.UpdateServiceAccount(f.ctx, accessKey, updateServiceAccountOpts{secretKey: "refresh-rotated-secret"})
|
|
mustIAM(t, err)
|
|
}, func(t *testing.T, u UserIdentity, ok bool) {
|
|
if !ok || u.Credentials.SecretKey != "refresh-rotated-secret" {
|
|
t.Errorf("a stale server undid the rotation of another (present=%v)", ok)
|
|
}
|
|
}},
|
|
{"deleted", func(t *testing.T, f *groupRefreshFixture, accessKey string) {
|
|
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, accessKey, false))
|
|
}, func(t *testing.T, u UserIdentity, ok bool) {
|
|
if ok {
|
|
t.Error("a stale server brought back a credential another deleted")
|
|
}
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-sib-"+tc.name)
|
|
other := f.sibling(t)
|
|
stale, ok := other.GetUser(snapshot.AccessKey)
|
|
if !ok {
|
|
t.Fatal("sibling fixture did not load the credential")
|
|
}
|
|
tc.change(t, f, snapshot.AccessKey)
|
|
if cached, _ := other.GetUser(snapshot.AccessKey); cached.Credentials.SecretKey != stale.Credentials.SecretKey {
|
|
t.Fatal("control: the sibling cache must not have heard of the change")
|
|
}
|
|
_, err := other.RefreshUserGroups(f.ctx, stale.Credentials, []string{"g1"})
|
|
mustIAM(t, err)
|
|
u, ok := f.stored(t, snapshot.AccessKey)
|
|
tc.check(t, u, ok)
|
|
// The refreshing server's own cache follows storage too: it holds
|
|
// the stored record, or nothing, so a lookup loads it.
|
|
u, ok = other.GetUser(snapshot.AccessKey)
|
|
tc.check(t, u, ok)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A stored record may lack its access key; the loaders take it from the
|
|
// path, and so does the refresh. Such a credential still gets its groups,
|
|
// including a removal.
|
|
func TestIAMGroupRefreshRecordWithoutAccessKey(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-nokey")
|
|
path := getUserIdentityPath(snapshot.AccessKey, svcUser)
|
|
var u UserIdentity
|
|
mustIAM(t, f.sys.store.loadIAMConfig(f.ctx, &u, path))
|
|
u.Credentials.AccessKey = ""
|
|
u.Credentials.Groups = []string{"admins"}
|
|
mustIAM(t, f.sys.store.saveIAMConfig(f.ctx, &u, path))
|
|
loaded, ok := f.stored(t, snapshot.AccessKey)
|
|
if !ok || loaded.Credentials.AccessKey != snapshot.AccessKey {
|
|
t.Fatal("control: the loaders must take the access key from the path")
|
|
}
|
|
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, loaded.Credentials, []string{"users"})
|
|
mustIAM(t, err)
|
|
after, ok := f.stored(t, snapshot.AccessKey)
|
|
if !wrote || !ok || !slices.Equal(after.Credentials.Groups, []string{"users"}) {
|
|
t.Errorf("wrote=%v present=%v groups=%v: a record without its access key must still lose a group", wrote, ok, after.Credentials.Groups)
|
|
}
|
|
}
|
|
|
|
// The access key was deleted and created again. Under the same parent the
|
|
// groups the lookup computed are that parent's, and the new record gets
|
|
// them with everything else it has; under another parent it is left alone.
|
|
func TestIAMGroupRefreshRecreated(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-recreate")
|
|
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
|
_, _, err := f.sys.NewServiceAccount(f.ctx, f.parent.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "refresh-original-secret", description: "recreated"})
|
|
mustIAM(t, err)
|
|
_, err = f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
|
mustIAM(t, err)
|
|
u, ok := f.stored(t, snapshot.AccessKey)
|
|
if !ok || u.Credentials.Description != "recreated" || !slices.Equal(u.Credentials.Groups, []string{"g1"}) {
|
|
t.Errorf("same parent: present=%v description=%q groups=%v, want the recreated record with [g1]", ok, u.Credentials.Description, u.Credentials.Groups)
|
|
}
|
|
|
|
other := iamAdminUser(f.ctx, t, f.sys, "refresh-other-parent", "refresh-parent")
|
|
mustIAM(t, f.sys.DeleteServiceAccount(f.ctx, snapshot.AccessKey, false))
|
|
_, _, err = f.sys.NewServiceAccount(f.ctx, other.AccessKey, nil, newServiceAccountOpts{accessKey: snapshot.AccessKey, secretKey: "refresh-other-secret"})
|
|
mustIAM(t, err)
|
|
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g2"})
|
|
mustIAM(t, err)
|
|
u, ok = f.stored(t, snapshot.AccessKey)
|
|
if wrote || !ok || u.Credentials.ParentUser != other.AccessKey || slices.Contains(u.Credentials.Groups, "g2") {
|
|
t.Errorf("another parent: wrote=%v present=%v parent=%q groups=%v, want it left alone", wrote, ok, u.Credentials.ParentUser, u.Credentials.Groups)
|
|
}
|
|
}
|
|
|
|
// Groups the record already has are not written again.
|
|
func TestIAMGroupRefreshUnchanged(t *testing.T) {
|
|
f := newGroupRefreshFixture(t)
|
|
snapshot := f.service(t, "refresh-unchanged")
|
|
_, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
|
mustIAM(t, err)
|
|
before, _ := f.stored(t, snapshot.AccessKey)
|
|
wrote, err := f.sys.store.RefreshUserGroups(f.ctx, snapshot, []string{"g1"})
|
|
mustIAM(t, err)
|
|
after, _ := f.stored(t, snapshot.AccessKey)
|
|
if wrote || !after.UpdatedAt.Equal(before.UpdatedAt) {
|
|
t.Errorf("wrote=%v, revision %s -> %s: unchanged groups must not be written", wrote, before.UpdatedAt, after.UpdatedAt)
|
|
}
|
|
}
|