Files
minio/cmd/batch-submitter-authz_test.go
T
Feng Ruohang ae54718d84 test(batch): cover submitter authorization and locked versions
Submission and start with changed, expired or restricted credentials,
session policies with and without an authorization plugin, request
context in policy conditions, per-object denies, jobs without a
recorded submitter and describe output; expire, pull, push and
rotation over versions under retention or legal hold, on one and
sixteen drives; cursor and counter persistence; and the cases found in
review: destination versions and tags of pulls, encryption values,
S3 Tables statements, copies in several pools, claims, prefixes,
listing errors and retry accounting.

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

457 lines
17 KiB
Go

// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/pgsty/silo-pkg/v3/policy"
"go.yaml.in/yaml/v3"
)
// batchAuthzFixture runs batch jobs submitted through the admin API, one job
// at a time and in the test's goroutine.
type batchAuthzFixture struct {
ctx context.Context
sys *IAMSys
obj ObjectLayer
bucket string
}
func newBatchAuthzFixture(t *testing.T) *batchAuthzFixture {
t.Helper()
ctx, sys := iamAdminFixture(t)
obj := newObjectLayerFn()
batchTestJobPool(ctx, t, obj)
f := &batchAuthzFixture{ctx: ctx, sys: sys, obj: obj, bucket: "batch-authz"}
mustIAM(t, obj.MakeBucket(ctx, f.bucket, MakeBucketOptions{VersioningEnabled: true}))
_, err := globalBucketMetadataSys.Update(ctx, f.bucket, bucketVersioningConfig, enabledBucketVersioningConfig)
mustIAM(t, err)
return f
}
// batchTestJobPool queues submitted jobs without running them.
func batchTestJobPool(ctx context.Context, t *testing.T, obj ObjectLayer) {
t.Helper()
prevPool := globalBatchJobPool
globalBatchJobPool = &BatchJobPool{
ctx: ctx,
objLayer: obj,
jobCh: make(chan *BatchJobRequest, 10),
jobCancelers: make(map[string]context.CancelFunc),
}
globalBatchJobsMetrics.Lock()
globalBatchJobsMetrics.metrics = make(map[string]*batchJobInfo)
globalBatchJobsMetrics.Unlock()
t.Cleanup(func() { globalBatchJobPool = prevPool })
}
// putVersions writes two versions of key and returns the older one.
func (f *batchAuthzFixture) putVersions(t *testing.T, key string) string {
t.Helper()
var older string
for i := range 2 {
data := []byte(fmt.Sprintf("version %d", i))
oi, err := f.obj.PutObject(f.ctx, f.bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Versioned: true})
mustIAM(t, err)
if i == 0 {
older = oi.VersionID
}
}
return older
}
func (f *batchAuthzFixture) versionExists(t *testing.T, key, versionID string) bool {
t.Helper()
_, err := f.obj.GetObjectInfo(f.ctx, f.bucket, key, ObjectOptions{VersionID: versionID})
if err == nil {
return true
}
if isErrVersionNotFound(err) || isErrObjectNotFound(err) {
return false
}
t.Fatal(err)
return false
}
func (f *batchAuthzFixture) expireYAML(prefix string) string {
return fmt.Sprintf(`expire:
apiVersion: v1
bucket: %s
prefix: %q
rules:
- type: object
purge:
retainVersions: 1
`, f.bucket, prefix)
}
// submit sends a job definition to StartBatchJob as cred. An accepted job is
// returned from the queue, not run.
func (f *batchAuthzFixture) submit(t *testing.T, cred auth.Credentials, job string) (*httptest.ResponseRecorder, *BatchJobRequest) {
t.Helper()
headers := map[string]string{}
if cred.IsTemp() && !cred.IsServiceAccount() {
headers["X-Amz-Security-Token"] = cred.SessionToken
}
body := []byte(job)
req, err := newTestSignedRequestV4(http.MethodPost, "http://localhost/minio/admin/v3/start-job", int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
mustIAM(t, err)
mustIAM(t, req.ParseForm())
rec := httptest.NewRecorder()
req = req.WithContext(newContext(req, rec, "StartBatchJob"))
adminAPIHandlers{}.StartBatchJob(rec, req)
if rec.Code != http.StatusOK {
return rec, nil
}
select {
case queued := <-globalBatchJobPool.jobCh:
return rec, queued
default:
t.Fatal("accepted job was not queued")
return rec, nil
}
}
func (f *batchAuthzFixture) run(t *testing.T, job *BatchJobRequest) {
t.Helper()
ctx := job.ctx
if ctx == nil {
ctx = f.ctx
}
switch {
case job.Expire != nil:
if err := job.Expire.Start(ctx, f.obj, *job); err != nil {
t.Logf("start: %v", err)
}
default:
t.Fatalf("unexpected job type %s", job.Type())
}
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type())}
if err := ri.load(f.ctx, f.obj, *job); err == nil {
t.Logf("job %s: %+v", job.ID, ri.clone())
}
}
func (f *batchAuthzFixture) jobInfo(t *testing.T, job *BatchJobRequest) *batchJobInfo {
t.Helper()
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type())}
mustIAM(t, ri.load(f.ctx, f.obj, *job))
return ri
}
func batchAuthzPolicy(ctx context.Context, t *testing.T, sys *IAMSys, name, statements string) {
t.Helper()
p, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[` + statements + `]}`))
mustIAM(t, err)
_, err = sys.SetPolicy(ctx, name, *p)
mustIAM(t, err)
}
const batchAuthzFullAccess = `{"Effect":"Allow","Action":["admin:StartBatchJob"]},
{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]}`
// A credential that may start batch jobs but has no access to the bucket
// cannot delete through a job.
func TestBatchJobSubmitterWithoutDataAccess(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-only", `{"Effect":"Allow","Action":["admin:StartBatchJob","admin:ListBatchJobs"]}`)
cred := iamAdminUser(f.ctx, t, f.sys, "batch-only-user", "batch-only")
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job != nil {
f.run(t, job)
}
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job submitted without data access deleted a version")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("submission status %d, want 403: %s", rec.Code, rec.Body.String())
}
}
// A job acts only while the credential that submitted it is the same valid
// credential.
func TestBatchJobSubmitterChangedBeforeStart(t *testing.T) {
for _, tc := range []struct {
name string
change func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials)
}{
{"unchanged", nil},
{"deleted", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
}},
{"disabled", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
_, err := f.sys.SetUserStatus(f.ctx, cred.AccessKey, madmin.AccountDisabled)
mustIAM(t, err)
}},
{"secret-changed", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
mustIAM(t, f.sys.SetUserSecretKey(f.ctx, cred.AccessKey, "batch-authz-changed-secret"))
}},
{"access-key-reused", func(t *testing.T, f *batchAuthzFixture, cred auth.Credentials) {
mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false))
time.Sleep(10 * time.Millisecond)
iamAdminUser(f.ctx, t, f.sys, cred.AccessKey, "batch-full")
}},
} {
t.Run(tc.name, func(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job == nil {
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
}
if tc.change != nil {
tc.change(t, f, cred)
}
f.run(t, job)
exists := f.versionExists(t, "data/a", older)
if tc.change == nil && exists {
t.Fatal("control: an authorized job did not expire the older version")
}
if tc.change != nil && !exists {
t.Fatal("a job ran as a credential that changed after submission")
}
})
}
}
// An expired temporary credential does not keep its job running.
func TestBatchJobSubmitterExpired(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{parentClaim: parent.AccessKey, "exp": UTCNow().Add(2 * time.Second).Unix()}, globalActiveCred.SecretKey)
mustIAM(t, err)
cred.ParentUser = parent.AccessKey
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
mustIAM(t, err)
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job == nil {
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
}
time.Sleep(time.Until(cred.Expiration) + time.Second)
f.run(t, job)
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job ran as an expired credential")
}
}
// A session policy limits the job as it limits requests, also when an
// authorization plugin decides the rest.
func TestBatchJobSessionPolicyDeny(t *testing.T) {
session := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["admin:StartBatchJob"]},
{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]},
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"]}]}`
for _, tc := range []struct {
name string
plugin bool
}{{"local", false}, {"plugin-allows", true}} {
t.Run(tc.name, func(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
cred := iamAdminServiceAccount(f.ctx, t, f.sys, parent.AccessKey, "batch-restricted", session)
if tc.plugin {
iamAdminPlugin(t, true)
}
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job != nil {
f.run(t, job)
} else {
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
}
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job deleted a version its session policy denies")
}
})
}
}
// A temporary credential's session policy limits its job too.
func TestBatchJobTemporarySessionPolicyDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
parent := iamAdminUser(f.ctx, t, f.sys, "batch-parent", "batch-full")
session := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["admin:StartBatchJob"]},
{"Effect":"Allow","Action":["s3:ListBucket","s3:ListBucketVersions","s3:GetObject","s3:GetObjectVersion","s3:DeleteObject"],"Resource":["arn:aws:s3:::batch-authz","arn:aws:s3:::batch-authz/*"]}]}`
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(),
policy.SessionPolicyName: base64.StdEncoding.EncodeToString([]byte(session)),
}, globalActiveCred.SecretKey)
mustIAM(t, err)
cred.ParentUser = parent.AccessKey
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
mustIAM(t, err)
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job != nil {
f.run(t, job)
} else {
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
}
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job deleted a version its session policy does not allow")
}
}
// A Deny that depends on request context the job does not have refuses the
// action instead of being skipped.
func TestBatchJobAbsentContextDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-context", batchAuthzFullAccess+`,
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-context")
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job != nil {
f.run(t, job)
} else {
t.Logf("submission status %d: %s", rec.Code, rec.Body.String())
}
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job skipped a Deny that depends on request context")
}
}
// Each object is authorized: a Deny under an allowed prefix keeps its objects.
func TestBatchJobPerObjectDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "batch-keep", batchAuthzFullAccess+`,
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/data/keep*"]}`)
cred := iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-keep")
kept := f.putVersions(t, "data/keep")
rec, job := f.submit(t, cred, f.expireYAML("data/"))
if job == nil {
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
}
f.run(t, job)
if !f.versionExists(t, "data/keep", kept) {
t.Fatal("a job deleted a version that a per-object Deny protects")
}
if ri := f.jobInfo(t, job); !ri.Failed || ri.Complete {
t.Fatalf("refused job: complete=%v failed=%v", ri.Complete, ri.Failed)
}
}
// A job queued without its submitter, as an earlier release stored it, fails
// and stays failed across another start.
func TestBatchJobWithoutSubmitter(t *testing.T) {
f := newBatchAuthzFixture(t)
older := f.putVersions(t, "data/a")
job := &BatchJobRequest{}
mustIAM(t, yaml.Unmarshal([]byte(f.expireYAML("")), job))
job.ID = "expire-legacy" + getKeySeparator() + "0"
job.User = globalActiveCred.AccessKey
job.Started = time.Now()
mustIAM(t, job.save(f.ctx, f.obj))
for range 2 {
loaded := &BatchJobRequest{}
mustIAM(t, loaded.load(f.ctx, f.obj, getJobPath(*job)))
f.run(t, loaded)
if !f.versionExists(t, "data/a", older) {
t.Fatal("a job without a recorded submitter deleted a version")
}
if ri := f.jobInfo(t, job); !ri.Failed || ri.Complete {
t.Fatalf("job without submitter: complete=%v failed=%v", ri.Complete, ri.Failed)
}
}
}
// Root and an authorized user can still run jobs.
func TestBatchJobAuthorizedControls(t *testing.T) {
for _, root := range []bool{true, false} {
t.Run(fmt.Sprintf("root=%v", root), func(t *testing.T) {
f := newBatchAuthzFixture(t)
cred := globalActiveCred
if !root {
batchAuthzPolicy(f.ctx, t, f.sys, "batch-full", batchAuthzFullAccess)
cred = iamAdminUser(f.ctx, t, f.sys, "batch-user", "batch-full")
}
older := f.putVersions(t, "data/a")
rec, job := f.submit(t, cred, f.expireYAML(""))
if job == nil {
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
}
f.run(t, job)
if f.versionExists(t, "data/a", older) {
t.Fatal("an authorized job did not expire the older version")
}
if ri := f.jobInfo(t, job); !ri.Complete || ri.Failed {
t.Fatalf("authorized job: complete=%v failed=%v", ri.Complete, ri.Failed)
}
})
}
}
// The describe output has no secrets of the job.
func TestBatchJobDescribeRedactsSecrets(t *testing.T) {
f := newBatchAuthzFixture(t)
secrets := []string{"describe-source-secret", "describe-source-token", "describe-target-secret", "describe-target-token", "describe-notify-token"}
for _, job := range []BatchJobRequest{
{ID: "replicate-describe" + getKeySeparator() + "0", Replicate: &BatchJobReplicateV1{
APIVersion: batchReplJobAPIVersion,
Source: BatchJobReplicateSource{Bucket: "remote", Endpoint: "https://remote.invalid", Creds: BatchJobReplicateCredentials{
AccessKey: "describe-source-access", SecretKey: secrets[0], SessionToken: secrets[1],
}},
Target: BatchJobReplicateTarget{Bucket: f.bucket, Creds: BatchJobReplicateCredentials{
AccessKey: "describe-target-access", SecretKey: secrets[2], SessionToken: secrets[3],
}},
Flags: BatchJobReplicateFlags{Notify: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]}},
}},
{ID: "expire-describe" + getKeySeparator() + "0", Expire: &BatchJobExpire{
APIVersion: batchExpireAPIVersion, Bucket: f.bucket,
NotificationCfg: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]},
}},
{ID: "keyrotate-describe" + getKeySeparator() + "0", KeyRotate: &BatchJobKeyRotateV1{
APIVersion: batchKeyRotateAPIVersion, Bucket: f.bucket,
Flags: BatchJobKeyRotateFlags{Notify: BatchJobNotification{Endpoint: "https://notify.invalid", Token: secrets[4]}},
}},
} {
data, err := job.MarshalMsg(nil)
mustIAM(t, err)
mustIAM(t, saveConfig(f.ctx, f.obj, getJobPath(job), data))
cred := globalActiveCred
req, err := newTestSignedRequestV4(http.MethodGet, "http://localhost/minio/admin/v3/describe-job?jobId="+job.ID, 0, nil, cred.AccessKey, cred.SecretKey, nil)
mustIAM(t, err)
mustIAM(t, req.ParseForm())
rec := httptest.NewRecorder()
req = req.WithContext(newContext(req, rec, "DescribeBatchJob"))
adminAPIHandlers{}.DescribeBatchJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: describe status %d: %s", job.ID, rec.Code, rec.Body.String())
}
for _, s := range secrets {
if strings.Contains(rec.Body.String(), s) {
t.Errorf("%s: describe output contains %s", job.ID, s)
}
}
if job.Replicate != nil && !strings.Contains(rec.Body.String(), "describe-source-access") {
t.Errorf("%s: describe output lost the access key", job.ID)
}
// The stored job keeps what a run needs.
stored := &BatchJobRequest{}
mustIAM(t, stored.load(f.ctx, f.obj, getJobPath(job)))
if stored.Replicate != nil && stored.Replicate.Source.Creds.SecretKey != secrets[0] {
t.Errorf("%s: describe changed the stored job", job.ID)
}
}
}