Files
minio/cmd/batch-authz-review_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

288 lines
12 KiB
Go

// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
polplugin "github.com/minio/minio/internal/config/policy/plugin"
"github.com/minio/minio/internal/crypto"
xhttp "github.com/minio/minio/internal/http"
xnet "github.com/pgsty/silo-pkg/v3/net"
"github.com/pgsty/silo-pkg/v3/policy"
)
func batchPullJob(bucket string) BatchJobReplicateV1 {
return BatchJobReplicateV1{
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
}
}
// A pull does not read the destination version it may replace, so a policy
// that conditions the write on that version's tags refuses it; a version
// condition sees the version the pull writes.
func TestBatchPullDestinationConditions(t *testing.T) {
for _, tc := range []struct {
name string
action policy.Action
marker bool
condition func(versionID string) string
}{
{"tags-put", policy.ReplicateObjectAction, false, func(string) string {
return `{"StringEquals":{"s3:ExistingObjectTag/class":"protected"}}`
}},
{"tags-marker", policy.ReplicateDeleteAction, true, func(string) string {
return `{"StringEquals":{"s3:ExistingObjectTag/class":"protected"}}`
}},
{"version-put", policy.PutObjectAction, false, func(v string) string {
return fmt.Sprintf(`{"StringEquals":{"s3:versionid":%q}}`, v)
}},
} {
t.Run(tc.name, func(t *testing.T) {
f := newBatchAuthzFixture(t)
before := batchPutVersion(t, f.obj, f.bucket, "pull/guarded", map[string]string{xhttp.AmzObjectTagging: "class=protected"})
batchAuthzPolicy(f.ctx, t, f.sys, "pull-guard", batchAuthzFullAccess+fmt.Sprintf(`,
{"Effect":"Deny","Action":[%q],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":%s}`, tc.action, tc.condition(before.VersionID)))
cred := iamAdminUser(f.ctx, t, f.sys, "pull-guard-user", "pull-guard")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
job := batchPullJob(f.bucket)
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "replacement"), ObjectInfo{
Name: before.Name, VersionID: before.VersionID, ModTime: UTCNow(), ETag: "batchetag",
DeleteMarker: tc.marker, UserDefined: map[string]string{},
}, false, sub)
if !isBatchJobAuthzError(err) {
t.Errorf("pull returned %v, want a refusal", err)
}
after, err := f.obj.GetObjectInfo(f.ctx, f.bucket, before.Name, ObjectOptions{VersionID: before.VersionID})
if err != nil || after.ETag != before.ETag {
t.Errorf("refused pull changed the destination version: %v", err)
}
})
}
}
// A pull does not encrypt what it writes; encryption headers reported by the
// source do not satisfy a destination policy that requires encryption.
func TestBatchPullEncryptionCondition(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "pull-encryption", `
{"Effect":"Allow","Action":["s3:ReplicateObject"],"Resource":["arn:aws:s3:::batch-authz/*"]},
{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:x-amz-server-side-encryption":"aws:kms","s3:x-amz-server-side-encryption-aws-kms-key-id":"required-key"}}}`)
cred := iamAdminUser(f.ctx, t, f.sys, "pull-encryption-user", "pull-encryption")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
job := batchPullJob(f.bucket)
version := mustGetUUID()
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "content"), ObjectInfo{
Name: "pull/encrypted", VersionID: version, ModTime: UTCNow(), ETag: "batchetag",
UserDefined: map[string]string{xhttp.AmzServerSideEncryption: "aws:kms", xhttp.AmzServerSideEncryptionKmsID: "required-key"},
}, false, sub)
if err == nil {
oi, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, "pull/encrypted", ObjectOptions{VersionID: version})
mustIAM(t, gerr)
if !crypto.S3KMS.IsEncrypted(oi.UserDefined) {
t.Fatal("a pull was allowed by an encryption condition it did not meet")
}
}
}
// A conditional Deny on an S3 Tables action covers the S3 data actions it
// implies, under the tables resource form; the job refuses rather than skip it.
func TestBatchJobTableConditionalDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "table-context", batchAuthzFullAccess+`,
{"Effect":"Deny","Action":["s3tables:GetTableData"],"Resource":["arn:aws:s3tables:::bucket/batch-authz/table/table-id"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
cred := iamAdminUser(f.ctx, t, f.sys, "table-context-user", "table-context")
if f.sys.isAllowedLocally(policy.Args{AccountName: cred.AccessKey, Action: policy.GetObjectAction, BucketName: f.bucket, ObjectName: "table-id/data", ConditionValues: map[string][]string{"SecureTransport": {"false"}}}) {
t.Fatal("control: with the request context the Deny applies")
}
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "table-id/data"}); !isBatchJobAuthzError(err) {
t.Fatalf("authorize returned %v, want a refusal", err)
}
}
type batchArchiveRevocation struct {
ObjectLayer
revoke func()
}
func (a *batchArchiveRevocation) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
if object == "archive/second" {
a.revoke()
}
return a.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
}
// Each snowball entry is authorized for the submitter as it is then.
func TestBatchPushSnowballEntryRevalidation(t *testing.T) {
f := newBatchAuthzFixture(t)
cred := iamAdminUser(f.ctx, t, f.sys, "archive-user", "")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
pluginURL, err := xnet.ParseHTTPURL("https://authz.invalid/")
mustIAM(t, err)
previous := newGlobalAuthZPluginFn()
setGlobalAuthZPlugin(polplugin.New(polplugin.Args{
URL: pluginURL, CloseRespFn: func(r io.ReadCloser) { _ = r.Close() },
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"result":true}`)), Request: r}, nil
}),
}))
t.Cleanup(func() { setGlobalAuthZPlugin(previous) })
entries := []ObjectInfo{
batchPutVersion(t, f.obj, f.bucket, "archive/first", nil),
batchPutVersion(t, f.obj, f.bucket, "archive/second", nil),
}
remote, err := minio.New("archive.invalid", &minio.Options{
Region: "us-east-1", Creds: credentials.NewStaticV4("batch-access", "batch-secret", ""),
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
}
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
}),
})
mustIAM(t, err)
job := BatchJobReplicateV1{Source: BatchJobReplicateSource{Bucket: f.bucket}, Target: BatchJobReplicateTarget{Bucket: "remote"}}
job.Source.Snowball.InMemory = ptr(true)
job.Source.Snowball.Compress = ptr(false)
job.Source.Snowball.SkipErrs = ptr(false)
api := &batchArchiveRevocation{ObjectLayer: f.obj, revoke: func() { mustIAM(t, f.sys.DeleteUser(f.ctx, cred.AccessKey, false)) }}
if err = job.writeAsArchive(f.ctx, api, remote, entries, "", sub); !isBatchJobAuthzError(err) {
t.Fatalf("archive with a revoked submitter returned %v, want a refusal", err)
}
}
// A definition holds one job type; one with several is refused, so that what
// is authorized is what runs.
func TestBatchJobSeveralSections(t *testing.T) {
f := newBatchAuthzFixture(t)
older := f.putVersions(t, "data/a")
job := f.expireYAML("") + `keyrotate:
apiVersion: v1
bucket: other
encryption:
type: sse-s3
`
rec, queued := f.submit(t, globalActiveCred, job)
if queued != nil {
f.run(t, queued)
}
if rec.Code != http.StatusBadRequest {
t.Errorf("submission status %d, want 400: %s", rec.Code, rec.Body.String())
}
if !f.versionExists(t, "data/a", older) {
t.Fatal("a definition with two job types ran")
}
}
// Prefixes are walked once each in one order: a cursor in a later prefix is
// after every key of the earlier ones, and an overlapping prefix is not
// walked twice.
func TestBatchExpirePrefixes(t *testing.T) {
if got := batchCanonicalPrefixes([]string{"z/", "a/", "a/b/", "z/", ""}); len(got) != 1 || got[0] != "" {
t.Fatalf("canonical prefixes %q", got)
}
if got := batchCanonicalPrefixes([]string{"z/", "a/b/", "a/"}); strings.Join(got, ",") != "a/,z/" {
t.Fatalf("canonical prefixes %q", got)
}
f := newBatchAuthzFixture(t)
done := f.putVersions(t, "a/done")
todo := f.putVersions(t, "z/todo")
batchPutVersion(t, f.obj, f.bucket, "x/data", batchLockMeta("LEGALHOLD"))
batchPutVersion(t, f.obj, f.bucket, "x/data", nil)
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
apiVersion: v1
bucket: %s
prefix: ["z/", "x/", "a/", "x/"]
rules:
- type: object
purge:
retainVersions: 1
`, f.bucket))
if job == nil {
t.Fatalf("submission status %d: %s", rec.Code, rec.Body.String())
}
// A run that completed a/ and stopped.
ri := &batchJobInfo{JobID: job.ID, JobType: string(job.Type()), StartTime: job.Started, Bucket: f.bucket, Object: "a/done"}
mustIAM(t, ri.updateAfter(f.ctx, f.obj, 0, *job))
f.run(t, job)
if !f.versionExists(t, "a/done", done) {
t.Error("resume did again a key before the cursor")
}
if f.versionExists(t, "z/todo", todo) {
t.Error("resume skipped a later prefix")
}
if got := f.jobInfo(t, job).VersionsSkippedLocked; got != 1 {
t.Errorf("a locked version under two equal prefixes was counted %d times", got)
}
}
type batchPartialWalk struct {
ObjectLayer
version ObjectInfo
cancel context.CancelFunc
read chan struct{}
}
func (a *batchPartialWalk) Walk(_ context.Context, _ string, _ string, results chan<- itemOrErr[ObjectInfo], _ WalkOptions) error {
go func() {
defer close(results)
results <- itemOrErr[ObjectInfo]{Item: a.version}
if a.cancel != nil {
<-a.read
a.cancel()
return
}
results <- itemOrErr[ObjectInfo]{Err: errors.New("test: listing failed within a key")}
}()
return nil
}
func (a *batchPartialWalk) GetObjectInfo(context.Context, string, string, ObjectOptions) (ObjectInfo, error) {
if a.read != nil {
close(a.read)
}
return a.version, nil
}
// A rotation whose walk fails or is canceled within a key keeps that key
// after its cursor and does not report completion.
func TestBatchKeyRotationIncompleteWalk(t *testing.T) {
for _, canceled := range []bool{false, true} {
t.Run(fmt.Sprintf("canceled=%v", canceled), func(t *testing.T) {
f := newBatchAuthzFixture(t)
ctx, cancel := context.WithCancel(f.ctx)
defer cancel()
meta := batchLockMeta("LEGALHOLD")
meta[crypto.MetaSealedKeyS3] = "test-sealed-key"
api := &batchPartialWalk{ObjectLayer: f.obj, version: ObjectInfo{Bucket: f.bucket, Name: "rotation/key", VersionID: mustGetUUID(), UserDefined: meta}}
if canceled {
api.cancel, api.read = cancel, make(chan struct{})
}
job := BatchJobRequest{
ID: "keyrotate-partial" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
KeyRotate: &BatchJobKeyRotateV1{Bucket: f.bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}},
}
_ = job.KeyRotate.Start(ctx, api, job)
ri := f.jobInfo(t, &job)
if ri.Complete || ri.Object == api.version.Name || ri.VersionsSkippedLocked != 0 {
t.Fatalf("incomplete key committed: complete=%v cursor=%q skips=%d", ri.Complete, ri.Object, ri.VersionsSkippedLocked)
}
})
}
}