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

158 lines
7.2 KiB
Go

// Copyright (c) 2026 PGSTY
// SPDX-License-Identifier: AGPL-3.0-or-later
package cmd
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http"
"github.com/pgsty/silo-pkg/v3/policy"
)
// A tag removal in a newer copy must govern both the authorization and the
// metadata sent by a push. The submitter can read data but cannot read tags.
func TestBatchReviewPushSendsOnlyAuthorizedMetadata(t *testing.T) {
z, bucket := consistencyPools(t)
f := newBatchAuthzFixture(t)
const name = "push/removed-tags"
first := map[string]string{
xhttp.AmzObjectTagging: "classification=private",
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Add(-time.Hour).Format(time.RFC3339Nano),
}
second := map[string]string{
xhttp.AmzObjectTagging: "",
ReservedMetadataPrefixLower + TaggingTimestamp: UTCNow().Format(time.RFC3339Nano),
}
before := putConsistencyObject(t, z, bucket, name, 0, "test payload", ObjectOptions{Versioned: true, UserDefined: first})
putConsistencyObject(t, z, bucket, name, 1, "test payload", ObjectOptions{Versioned: true, VersionID: before.VersionID, MTime: before.ModTime, UserDefined: second})
logical, err := z.replicaObjectInfo(f.ctx, bucket, name, ObjectOptions{VersionID: before.VersionID})
mustIAM(t, err)
if logical.UserTags != "" {
t.Fatal("control: the authoritative version must have no tags")
}
batchAuthzPolicy(f.ctx, t, f.sys, "data-without-tags", fmt.Sprintf(`
{"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"]},
{"Effect":"Deny","Action":["s3:GetObjectTagging","s3:GetObjectVersionTagging"],"Resource":["arn:aws:s3:::%s/*"]}`, bucket, bucket))
cred := iamAdminUser(f.ctx, t, f.sys, "data-without-tags-user", "data-without-tags")
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.GetObjectTaggingAction, bucket: bucket, object: name, versionID: before.VersionID}); !isBatchJobAuthzError(err) {
t.Fatal("control: reading tags must be denied")
}
writes := 0
var sentTags string
target, err := minio.NewCore("target.invalid", &minio.Options{
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-secret", ""),
Transport: batchLockTransport(func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
if r.Method == http.MethodPut {
writes++
sentTags = r.Header.Get(xhttp.AmzObjectTagging)
}
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Etag": {"\"test-etag\""}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
}),
})
mustIAM(t, err)
r := BatchJobReplicateV1{
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
}
err = r.ReplicateToTarget(f.ctx, z, target, before, false, sub)
if err != nil && !isBatchJobAuthzError(err) {
t.Fatalf("unexpected replication failure: %v", err)
}
if writes > 0 && sentTags != logical.UserTags {
t.Errorf("push must send only metadata covered by authorization: writes=%d sentTags=%q authorizedTags=%q err=%v", writes, sentTags, logical.UserTags, err)
}
}
// LDAP's short username and its parent DN are distinct identities. The
// background merge must not turn a deny for the verified short name into
// a non-matching statement by replacing it with the parent DN.
func TestBatchReviewLDAPUsernameDeny(t *testing.T) {
f := newBatchAuthzFixture(t)
batchAuthzPolicy(f.ctx, t, f.sys, "ldap-short-name-deny", batchAuthzFullAccess+`,
{"Effect":"Deny","Action":["s3:DeleteObjectVersion"],"Resource":["arn:aws:s3:::batch-authz/*"],"Condition":{"StringEquals":{"ldap:username":"alice"}}}`)
const dn = "uid=alice,ou=people,dc=example,dc=org"
cred, err := auth.GetNewCredentialsWithMetadata(map[string]any{
"exp": UTCNow().Add(time.Hour).Unix(), ldapUser: dn, ldapActualUser: dn, ldapUserN: "alice",
}, globalActiveCred.SecretKey)
mustIAM(t, err)
cred.ParentUser = dn
_, err = f.sys.SetTempUser(f.ctx, cred.AccessKey, cred, "ldap-short-name-deny")
mustIAM(t, err)
sub, err := newBatchJobSubmitter(cred)
mustIAM(t, err)
p, err := sub.principal(f.ctx)
mustIAM(t, err)
cred.Claims = p.claims
req, err := http.NewRequest(http.MethodDelete, "https://localhost/batch-authz/data", nil)
mustIAM(t, err)
args := policy.Args{
AccountName: cred.AccessKey, Claims: p.claims, Action: policy.DeleteObjectVersionAction,
BucketName: f.bucket, ObjectName: "data", ConditionValues: getConditionValues(req, "", cred),
}
if args.ConditionValues["username"][0] != "alice" || f.sys.isAllowedLocally(args) {
t.Fatal("control: the request evaluator must deny the verified LDAP short username")
}
if err := sub.authorize(f.ctx, batchAuthzRequest{action: policy.DeleteObjectVersionAction, bucket: f.bucket, object: "data", versionID: mustGetUUID()}); !isBatchJobAuthzError(err) {
t.Errorf("batch authorization must preserve the LDAP username deny or fail closed, got %v", err)
}
}
// Failure to read authoritative metadata is incomplete work, even if one
// pool's copy can still be opened. In particular, skipErrs=false must not
// turn an unreadable entry into a successful archive.
func TestBatchReviewSnowballAuthoritativeReadFailure(t *testing.T) {
z, bucket := consistencyPools(t)
const name = "archive/unreadable-copy"
before := putConsistencyObject(t, z, bucket, name, 0, "test payload", ObjectOptions{Versioned: true})
set := z.serverPools[1].sets[0]
getDisks := set.getDisks
disks := append([]StorageAPI(nil), getDisks()...)
for i := range disks {
disks[i] = consistencyReadFaultDisk{StorageAPI: disks[i], bucket: bucket, object: name}
}
set.getDisks = func() []StorageAPI { return disks }
defer func() { set.getDisks = getDisks }()
gr, err := z.GetObjectNInfo(t.Context(), bucket, name, nil, nil, ObjectOptions{VersionID: before.VersionID})
mustIAM(t, err)
_, logicalErr := batchAuthoritativeRead(t.Context(), z, bucket, name, gr.ObjInfo)
gr.Close()
if logicalErr == nil {
t.Fatal("control: the authoritative read must fail")
}
remote, err := minio.New("archive.invalid", &minio.Options{
Region: "us-east-1", Creds: credentials.NewStaticV4("test-access", "test-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)
r := BatchJobReplicateV1{
Source: BatchJobReplicateSource{Type: BatchJobReplicateResourceMinIO, Bucket: bucket},
Target: BatchJobReplicateTarget{Type: BatchJobReplicateResourceMinIO, Bucket: "remote"},
}
r.Source.Snowball.InMemory = ptr(true)
r.Source.Snowball.Compress = ptr(false)
r.Source.Snowball.SkipErrs = ptr(false)
if err := r.writeAsArchive(t.Context(), z, remote, []ObjectInfo{before}, "", batchRootSubmitter(t)); err == nil {
t.Error("archive with an unreadable authoritative version must not report success")
}
}