Files
minio/cmd/batch-submitter-edits_test.go
T
Feng Ruohang 633c9bfe09 fix(batch): keep a job across edits that keep its credential
The submitter binding compared the UpdatedAt of the identity record, so
any rewrite of the record ended every running job of the credential with
"changed since submission": a service account's description, name or
later expiry, and the hourly LDAP group refresh of every LDAP-backed
service account and STS credential whose groups changed.

Compare only what identifies the credential: its type, access key and
parent, the revocation boundary of the record, and the keyed binding of
its secret and session token. Status, expiry, groups and policies are
read again on every start and action. A deletion advances the
revocation boundary, so a user created again under the access key with
the same secret still fails the binding, and adding an expiry to a
service account turns it into a temporary credential, which still ends
the job. UpdatedAt stays in the binding for diagnosis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Feng Ruohang <rh@vonng.com>
2026-11-08 11:39:03 +08:00

123 lines
4.6 KiB
Go

// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
)
// Edits that keep the credential keep a job running; edits of the
// credential itself, and its status, still end it.
func TestBatchSubmitterCredentialEdits(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "edit-parent", batchAuthzFullAccess)
parent := iamAdminUser(f.ctx, t, f.sys, "edit-parent", "edit-parent")
soon := UTCNow().Add(time.Hour)
later := UTCNow().Add(24 * time.Hour)
newService := func(t *testing.T, expiring bool) (auth.Credentials, *batchJobSubmitter) {
t.Helper()
var opts newServiceAccountOpts
if expiring {
opts.expiration = &soon
}
created, _, err := f.sys.NewServiceAccount(f.ctx, parent.AccessKey, nil, opts)
mustIAM(t, err)
stored, ok := f.sys.GetUser(f.ctx, created.AccessKey)
if !ok {
t.Fatal("service account missing")
}
sub, err := newBatchJobSubmitter(stored.Credentials)
mustIAM(t, err)
mustIAM(t, sub.authorize(f.ctx, batchContextRead(f)))
return stored.Credentials, sub
}
for _, tc := range []struct {
name string
expiring bool
edit func(t *testing.T, cred auth.Credentials)
keep bool
}{
{"description", false, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{description: "cosmetic"})
mustIAM(t, err)
}, true},
{"name", false, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{name: "renamed"})
mustIAM(t, err)
}, true},
{"expiry-extended", true, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{expiration: &later})
mustIAM(t, err)
}, true},
// An expiry makes a service account a temporary credential, which
// IsAllowed evaluates differently.
{"expiry-added", false, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{expiration: &later})
mustIAM(t, err)
}, false},
{"groups-refreshed", false, func(t *testing.T, cred auth.Credentials) {
// As the LDAP group refresh rewrites a derived credential.
u, ok := f.sys.GetUser(f.ctx, cred.AccessKey)
if !ok {
t.Fatal("service account missing")
}
c := u.Credentials
c.Groups = append(c.Groups, "refreshed-group")
mustIAM(t, f.sys.store.UpdateUserIdentity(f.ctx, c))
}, true},
{"secret-rotated", false, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{secretKey: "edit-rotated-secret-key"})
mustIAM(t, err)
}, false},
{"disabled", false, func(t *testing.T, cred auth.Credentials) {
_, err := f.sys.UpdateServiceAccount(f.ctx, cred.AccessKey, updateServiceAccountOpts{status: string(madmin.AccountDisabled)})
mustIAM(t, err)
}, false},
} {
t.Run(tc.name, func(t *testing.T) {
cred, sub := newService(t, tc.expiring)
before, _ := f.sys.GetUser(f.ctx, cred.AccessKey)
time.Sleep(2 * time.Millisecond) // a distinct UpdatedAt
tc.edit(t, cred)
after, _ := f.sys.GetUser(f.ctx, cred.AccessKey)
if after.UpdatedAt.Equal(before.UpdatedAt) {
t.Fatal("control: the edit did not change the identity record")
}
err := sub.authorize(f.ctx, batchContextRead(f))
if tc.keep && err != nil {
t.Errorf("an edit that keeps the credential ended the job: %v", err)
}
if !tc.keep && !isBatchJobAuthzError(err) {
t.Errorf("an edit of the credential must end the job, got %v", err)
}
})
}
}
// A user deleted and created again under its access key, with the same
// secret, is not the credential that submitted the job.
func TestBatchSubmitterRecreatedSameSecret(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "recreate-full", batchAuthzFullAccess)
cred := iamAdminUser(f.ctx, t, f.sys, "recreate-user", "recreate-full")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
mustIAM(t, sub.authorize(f.ctx, batchContextRead(f)))
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
time.Sleep(10 * time.Millisecond)
again := iamAdminUser(f.ctx, t, f.sys, cred.AccessKey, "recreate-full")
if again.SecretKey != cred.SecretKey {
t.Fatal("control: the user must be created again with the same secret")
}
err = sub.authorize(f.ctx, batchContextRead(f))
t.Logf("recreated: %v", err)
if !isBatchJobAuthzError(err) {
t.Errorf("a recreated user must not run the job of the deleted one: %v", err)
}
}