mirror of
https://github.com/pgsty/minio.git
synced 2026-11-10 01:31:52 +03:00
ae54718d84
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>
357 lines
16 KiB
Go
357 lines
16 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/minio/minio/internal/crypto"
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
"github.com/minio/minio/internal/kms"
|
|
"github.com/pgsty/silo-pkg/v3/policy"
|
|
)
|
|
|
|
// A pull into a suspended bucket replaces the null version. Its policy
|
|
// decision must name that version even when the source is unversioned S3.
|
|
func TestBatchReviewPullImplicitNullVersionAuthorization(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
_, err := globalBucketMetadataSys.Update(f.ctx, f.bucket, bucketVersioningConfig, []byte(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></VersioningConfiguration>`))
|
|
mustIAM(t, err)
|
|
data := []byte("original null version")
|
|
before, err := f.obj.PutObject(f.ctx, f.bucket, "pull/null", mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{VersionSuspended: true})
|
|
mustIAM(t, err)
|
|
batchAuthzPolicy(f.ctx, t, f.sys, "null-deny", batchAuthzFullAccess+`,
|
|
{"Effect":"Deny","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"s3:versionid":"null"}}}`)
|
|
cred := iamAdminUser(f.ctx, t, f.sys, "null-deny-user", "null-deny")
|
|
sub, err := newBatchJobSubmitter(cred)
|
|
mustIAM(t, err)
|
|
job := batchPullJob(f.bucket)
|
|
job.Source.Type = BatchJobReplicateResourceS3
|
|
err = job.ReplicateFromSource(f.ctx, f.obj, batchLockRemote(t, "replacement"), ObjectInfo{
|
|
Name: before.Name, ModTime: UTCNow(), ETag: "batchetag", UserDefined: map[string]string{},
|
|
}, false, sub)
|
|
if !isBatchJobAuthzError(err) {
|
|
t.Errorf("null version policy must refuse the write, got %v", err)
|
|
}
|
|
after, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, before.Name, ObjectOptions{VersionID: nullVersionID})
|
|
if gerr != nil || before.ETag != after.ETag {
|
|
t.Errorf("denied null version must remain unchanged: read=%v unchanged=%v", gerr, before.ETag == after.ETag)
|
|
}
|
|
}
|
|
|
|
type batchReviewPrefixWalkFailure struct{ ObjectLayer }
|
|
|
|
func (a batchReviewPrefixWalkFailure) Walk(ctx context.Context, bucket, prefix string, results chan<- itemOrErr[ObjectInfo], opts WalkOptions) error {
|
|
if prefix != "a/" {
|
|
return a.ObjectLayer.Walk(ctx, bucket, prefix, results, opts)
|
|
}
|
|
go func() {
|
|
defer close(results)
|
|
results <- itemOrErr[ObjectInfo]{Err: errors.New("test: incomplete first prefix")}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
type batchReviewWalkCutoff struct {
|
|
ObjectLayer
|
|
cutoff string
|
|
}
|
|
|
|
func (a batchReviewWalkCutoff) Walk(ctx context.Context, bucket, prefix string, results chan<- itemOrErr[ObjectInfo], opts WalkOptions) error {
|
|
inner := make(chan itemOrErr[ObjectInfo], 1)
|
|
if err := a.ObjectLayer.Walk(ctx, bucket, prefix, inner, opts); err != nil {
|
|
close(results)
|
|
return err
|
|
}
|
|
go func() {
|
|
defer close(results)
|
|
stopped := false
|
|
for result := range inner {
|
|
if stopped {
|
|
continue
|
|
}
|
|
if result.Err == nil && result.Item.Name >= a.cutoff {
|
|
stopped = true
|
|
result = itemOrErr[ObjectInfo]{Err: errors.New("test: listing stopped before remaining child keys")}
|
|
}
|
|
select {
|
|
case results <- result:
|
|
case <-ctx.Done():
|
|
stopped = true
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Listing a directory prefix returns the directory object before its child
|
|
// keys. A durable cursor for that directory must preserve unfinished children.
|
|
func TestBatchReviewDirectoryCursorKeepsChildrenPending(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
for range 12 {
|
|
batchPutVersion(t, f.obj, f.bucket, "a/", nil)
|
|
}
|
|
var pending string
|
|
for i := 0; i < 13; i++ {
|
|
older := f.putVersions(t, fmt.Sprintf("a/key-%02d", i))
|
|
if i == 11 {
|
|
pending = older
|
|
}
|
|
}
|
|
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
|
|
apiVersion: v1
|
|
bucket: %s
|
|
prefix: ["a/"]
|
|
rules:
|
|
- type: object
|
|
purge:
|
|
retainVersions: 1
|
|
`, f.bucket))
|
|
if job == nil {
|
|
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
_ = job.Expire.Start(f.ctx, batchReviewWalkCutoff{ObjectLayer: f.obj, cutoff: "a/key-11"}, *job)
|
|
ri := f.jobInfo(t, job)
|
|
if ri.Object != "a/" {
|
|
t.Fatalf("control: the completed directory batch must be the cursor, got %q", ri.Object)
|
|
}
|
|
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
|
|
if f.versionExists(t, "a/key-11", pending) {
|
|
t.Error("a directory cursor must not suppress the unvisited child key on resume")
|
|
}
|
|
}
|
|
|
|
// Later prefixes must not advance the completion cursor past an earlier
|
|
// prefix whose listing failed. This state is produced by Start itself.
|
|
func TestBatchReviewExpireFailedPrefixKeepsWorkPending(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
older := f.putVersions(t, "a/todo")
|
|
for i := 0; i < 13; i++ {
|
|
f.putVersions(t, fmt.Sprintf("z/key-%02d", i))
|
|
}
|
|
rec, job := f.submit(t, globalActiveCred, fmt.Sprintf(`expire:
|
|
apiVersion: v1
|
|
bucket: %s
|
|
prefix: ["z/", "a/"]
|
|
rules:
|
|
- type: object
|
|
purge:
|
|
retainVersions: 1
|
|
`, f.bucket))
|
|
if job == nil {
|
|
t.Fatalf("submit: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
_ = job.Expire.Start(f.ctx, batchReviewPrefixWalkFailure{f.obj}, *job)
|
|
ri := f.jobInfo(t, job)
|
|
if ri.Object >= "a/todo" {
|
|
t.Errorf("cursor must not cross an incomplete prefix, got %q", ri.Object)
|
|
}
|
|
mustIAM(t, job.Expire.Start(f.ctx, f.obj, *job))
|
|
if f.versionExists(t, "a/todo", older) {
|
|
t.Error("a fresh Start must revisit the key omitted by the failed listing")
|
|
}
|
|
}
|
|
|
|
// A repeated marker replication error must remain a failure on the second
|
|
// attempt, including after the retry loop resets its counters.
|
|
func TestBatchReviewMarkerRetryRemainsFailed(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
batchPutVersion(t, f.obj, f.bucket, "marker", nil)
|
|
_, err := f.obj.DeleteObject(f.ctx, f.bucket, "marker", ObjectOptions{Versioned: true})
|
|
mustIAM(t, err)
|
|
oldTransport := globalRemoteTargetTransport
|
|
globalRemoteTargetTransport = batchLockTransport(func(r *http.Request) (*http.Response, error) {
|
|
code, body := http.StatusForbidden, `<Error><Code>AccessDenied</Code><Message>test refusal</Message></Error>`
|
|
if r.URL.Query().Has("location") {
|
|
code, body = http.StatusOK, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-east-1</LocationConstraint>`
|
|
}
|
|
return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
|
})
|
|
t.Cleanup(func() { globalRemoteTargetTransport = oldTransport })
|
|
job := BatchJobRequest{
|
|
ID: "replicate-marker-retry" + getKeySeparator() + "0", Started: UTCNow(), Submitter: batchRootSubmitter(t),
|
|
Replicate: &BatchJobReplicateV1{
|
|
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
|
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote", Endpoint: "https://target.invalid", Creds: BatchJobReplicateCredentials{AccessKey: "test-access", SecretKey: "test-secret"}},
|
|
},
|
|
}
|
|
job.Replicate.Flags.Retry.Attempts = 2
|
|
job.Replicate.Flags.Retry.Delay = time.Millisecond
|
|
mustIAM(t, job.Replicate.Start(f.ctx, f.obj, job))
|
|
ri := f.jobInfo(t, &job)
|
|
if ri.Complete || !ri.Failed || ri.DeleteMarkersFailed != 1 {
|
|
t.Errorf("both marker attempts failed: complete=%v failed=%v markerFailures=%d attempts=%d", ri.Complete, ri.Failed, ri.DeleteMarkersFailed, ri.RetryAttempts)
|
|
}
|
|
}
|
|
|
|
// A current parent mapping suppresses the OpenID policy-name fallback.
|
|
// Background-condition validation must inspect the same effective policies.
|
|
func TestBatchReviewMappedPolicySuppressesClaimFallback(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
batchAuthzPolicy(f.ctx, t, f.sys, "mapped-full", batchAuthzFullAccess)
|
|
batchAuthzPolicy(f.ctx, t, f.sys, "unused-claim-policy", `{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}`)
|
|
parent := iamAdminUser(f.ctx, t, f.sys, "mapped-parent", "mapped-full")
|
|
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
|
|
parentClaim: parent.AccessKey, "exp": UTCNow().Add(time.Hour).Unix(), iamPolicyClaimNameOpenID(): "unused-claim-policy",
|
|
}, globalActiveCred.SecretKey)
|
|
mustIAM(t, err)
|
|
cred.ParentUser = parent.AccessKey
|
|
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "")
|
|
mustIAM(t, err)
|
|
sub, err := newBatchJobSubmitter(cred)
|
|
mustIAM(t, err)
|
|
p, err := sub.principal(f.ctx)
|
|
mustIAM(t, err)
|
|
req := batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "mapped/key", versionID: mustGetUUID()}
|
|
if !f.sys.isAllowedLocally(policy.Args{AccountName: p.accessKey, Claims: p.claims, Action: req.action, BucketName: req.bucket, ObjectName: req.object, ConditionValues: p.conditionValues(req)}) {
|
|
t.Fatal("control: the current mapped policy must allow this read")
|
|
}
|
|
if err := sub.authorize(f.ctx, req); err != nil {
|
|
t.Errorf("an unused fallback policy must not refuse a mapped-policy grant: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBatchReviewBindingAndExpiringServiceControls(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
root := batchRootSubmitter(t)
|
|
priorRoot := globalActiveCred
|
|
globalActiveCred.SecretKey = "different-test-root-secret"
|
|
_, err := root.principal(f.ctx)
|
|
globalActiveCred = priorRoot
|
|
if !isBatchJobAuthzError(err) {
|
|
t.Errorf("root-secret change must invalidate the binding: %v", err)
|
|
}
|
|
missing := *root
|
|
missing.Binding = nil
|
|
if _, err := missing.principal(f.ctx); !isBatchJobAuthzError(err) {
|
|
t.Errorf("missing fingerprint must refuse the principal: %v", err)
|
|
}
|
|
batchAuthzPolicy(f.ctx, t, f.sys, "service-parent-full", batchAuthzFullAccess)
|
|
parent := iamAdminUser(f.ctx, t, f.sys, "service-parent", "service-parent-full")
|
|
sp, err := policy.ParseConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::batch-authz/*"]}]}`))
|
|
mustIAM(t, err)
|
|
expires := UTCNow().Add(time.Hour)
|
|
cred, _, err := f.sys.NewServiceAccount(f.ctx, parent.AccessKey, nil, newServiceAccountOpts{sessionPolicy: sp, expiration: &expires})
|
|
mustIAM(t, err)
|
|
stored, ok := f.sys.GetUser(f.ctx, cred.AccessKey)
|
|
if !ok {
|
|
t.Fatal("created service account must resolve")
|
|
}
|
|
cred = stored.Credentials
|
|
if !cred.IsTemp() || !cred.IsServiceAccount() {
|
|
t.Fatal("fixture must be an expiring service account")
|
|
}
|
|
sub, err := newBatchJobSubmitter(cred)
|
|
mustIAM(t, err)
|
|
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectAction, bucket: f.bucket, object: "allowed"}); err != nil {
|
|
t.Errorf("expiring service account must retain its allowed read: %v", err)
|
|
}
|
|
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.DeleteObjectAction, bucket: f.bucket, object: "allowed"}); !isBatchJobAuthzError(err) {
|
|
t.Errorf("expiring service account must remain session-policy restricted: %v", err)
|
|
}
|
|
}
|
|
|
|
// An interrupted cross-pool copy can leave the same version in both pools.
|
|
// A newer legal-hold field in either copy must prevent key rotation.
|
|
func TestBatchReviewRotationUsesAuthoritativePoolHold(t *testing.T) {
|
|
z, bucket := consistencyPools(t)
|
|
const name = "rotation/split-hold"
|
|
previousKMS := GlobalKMS
|
|
GlobalKMS = kms.NewStub("batch-rotation-key")
|
|
t.Cleanup(func() { GlobalKMS = previousKMS })
|
|
dek, err := GlobalKMS.GenerateKey(t.Context(), &kms.GenerateKeyRequest{AssociatedData: kms.Context{bucket: bucket + "/" + name}})
|
|
mustIAM(t, err)
|
|
objectKey := crypto.GenerateKey(dek.Plaintext, nil)
|
|
sealed := objectKey.Seal(dek.Plaintext, crypto.GenerateIV(nil), crypto.S3.String(), bucket, name)
|
|
first := crypto.S3.CreateMetadata(nil, dek.KeyID, dek.Ciphertext, sealed)
|
|
first[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "OFF"
|
|
first[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = UTCNow().Add(-time.Hour).Format(time.RFC3339Nano)
|
|
second := maps.Clone(first)
|
|
second[strings.ToLower(xhttp.AmzObjectLockLegalHold)] = "ON"
|
|
second[ReservedMetadataPrefixLower+ObjectLockLegalHoldTimestamp] = UTCNow().Format(time.RFC3339Nano)
|
|
ciphertext, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("protected payload"), objectKey))
|
|
mustIAM(t, err)
|
|
before := putConsistencyObject(t, z, bucket, name, 0, string(ciphertext), ObjectOptions{Versioned: true, UserDefined: first})
|
|
putConsistencyObject(t, z, bucket, name, 1, string(ciphertext), ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
|
|
logical, err := z.replicaObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: before.VersionID})
|
|
mustIAM(t, err)
|
|
if !enforceRetentionForDeletion(t.Context(), logical) {
|
|
t.Fatal("control: the authoritative logical version must be held")
|
|
}
|
|
r := BatchJobKeyRotateV1{Bucket: bucket, Encryption: BatchJobKeyRotateEncryption{Type: sses3}}
|
|
err = r.KeyRotate(t.Context(), z, before, batchRootSubmitter(t))
|
|
if !isErrObjectLocked(err) {
|
|
t.Errorf("rotation must refuse the authoritative legal hold, got %v", err)
|
|
}
|
|
after, gerr := z.serverPools[0].GetObjectInfo(t.Context(), bucket, name, ObjectOptions{VersionID: before.VersionID})
|
|
mustIAM(t, gerr)
|
|
if after.UserDefined[crypto.MetaSealedKeyS3] != before.UserDefined[crypto.MetaSealedKeyS3] {
|
|
t.Error("held logical version must not have its key rewritten")
|
|
}
|
|
}
|
|
|
|
// An encryption condition must describe what rotation actually commits,
|
|
// even when the requested encryption type differs from the existing type.
|
|
func TestBatchReviewRotationEncryptionConditionMatchesResult(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
previousKMS := GlobalKMS
|
|
GlobalKMS = kms.NewStub("required-key")
|
|
t.Cleanup(func() { GlobalKMS = previousKMS })
|
|
const name = "rotation/encryption-condition"
|
|
meta := make(map[string]string)
|
|
key, err := newEncryptMetadata(f.ctx, crypto.S3, "", nil, f.bucket, name, meta, nil)
|
|
mustIAM(t, err)
|
|
body, err := io.ReadAll(crypto.EncryptSinglePart(strings.NewReader("encrypted payload"), key))
|
|
mustIAM(t, err)
|
|
before, err := f.obj.PutObject(f.ctx, f.bucket, name, mustGetPutObjReader(t, bytes.NewReader(body), int64(len(body)), "", ""), ObjectOptions{Versioned: true, UserDefined: meta})
|
|
mustIAM(t, err)
|
|
batchAuthzPolicy(f.ctx, t, f.sys, "kms-write-only", `
|
|
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"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, "kms-write-user", "kms-write-only")
|
|
sub, err := newBatchJobSubmitter(cred)
|
|
mustIAM(t, err)
|
|
r := BatchJobKeyRotateV1{Bucket: f.bucket, Encryption: BatchJobKeyRotateEncryption{Type: ssekms, Key: "required-key"}}
|
|
err = r.KeyRotate(f.ctx, f.obj, before, sub)
|
|
after, gerr := f.obj.GetObjectInfo(f.ctx, f.bucket, name, ObjectOptions{VersionID: before.VersionID})
|
|
mustIAM(t, gerr)
|
|
if err == nil && !crypto.S3KMS.IsEncrypted(after.UserDefined) {
|
|
t.Error("rotation allowed by an aws:kms-only write policy must produce SSE-KMS or refuse the operation")
|
|
}
|
|
if err != nil && before.UserDefined[crypto.MetaSealedKeyS3] != after.UserDefined[crypto.MetaSealedKeyS3] {
|
|
t.Error("a refused rotation must leave the version unchanged")
|
|
}
|
|
}
|
|
|
|
// An explicit null marker collision must retain a held null data version.
|
|
func TestBatchReviewPullMarkerNullLock(t *testing.T) {
|
|
f := newBatchAuthzFixture(t)
|
|
meta := batchLockMeta("LEGALHOLD")
|
|
before, err := f.obj.PutObject(f.ctx, f.bucket, "pull/null", mustGetPutObjReader(t, strings.NewReader("held null"), 9, "", ""), ObjectOptions{VersionID: nullVersionID, Versioned: true, UserDefined: meta})
|
|
mustIAM(t, err)
|
|
job := BatchJobReplicateV1{
|
|
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
|
|
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: f.bucket},
|
|
}
|
|
err = job.ReplicateFromSource(f.ctx, f.obj, nil, ObjectInfo{
|
|
Name: before.Name, VersionID: nullVersionID, ModTime: UTCNow(), DeleteMarker: true,
|
|
}, false, batchRootSubmitter(t))
|
|
if !isErrObjectLocked(err) {
|
|
t.Errorf("marker collision with protected null data must return ObjectLocked, got %v", err)
|
|
}
|
|
if !batchVersionExists(t, f.obj, f.bucket, before.Name, nullVersionID) {
|
|
t.Fatal("protected null data must remain present")
|
|
}
|
|
}
|