// Copyright (c) 2026 Feng Ruohang
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
package cmd
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/bucket/replication"
xhttp "github.com/minio/minio/internal/http"
)
func TestAPITaggingProxyAuthorization(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, root auth.Credentials, t *testing.T) {
conditional := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[
{"Effect":"Allow","Action":["s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":["arn:aws:s3:::%s/*"]},
{"Effect":"Deny","Action":["s3:PutObjectTagging","s3:DeleteObjectTagging"],"Resource":["arn:aws:s3:::%s/*"],"Condition":{"StringEquals":{"s3:ExistingObjectTag/secret":"true"}}}
]`, bucket, bucket))
versionID := mustGetUUID()
for _, method := range []string{http.MethodPut, http.MethodDelete} {
for _, tc := range []struct {
name string
tags []string
cred auth.Credentials
readStatus, writeStatus, wantStatus int
wantWrites int32
disabledFirst bool
}{
{name: "deny-second-target", tags: []string{"false", "true"}, cred: conditional, wantStatus: http.StatusForbidden},
{name: "deny-first-target", tags: []string{"true", "false"}, cred: conditional, wantStatus: http.StatusForbidden},
{name: "all-authorized", tags: []string{"false", "false"}, cred: conditional, wantWrites: 2},
{name: "anonymous", tags: []string{"false"}, wantStatus: http.StatusForbidden},
{name: "invalid-credentials", tags: []string{"false"}, cred: auth.Credentials{AccessKey: root.AccessKey, SecretKey: "wrong-secret-key"}, wantStatus: http.StatusForbidden},
{name: "unreadable", tags: []string{"false"}, cred: root, readStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable},
{name: "missing-version", tags: []string{"false"}, cred: root, readStatus: http.StatusNotFound, writeStatus: http.StatusNotFound, wantStatus: http.StatusNotFound, wantWrites: 1},
{name: "disabled-target", tags: []string{"true", "false"}, cred: conditional, disabledFirst: true, wantWrites: 1},
{name: "skipped-target-cannot-mask-failure", tags: []string{"false", "false"}, cred: root, disabledFirst: true, writeStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable, wantWrites: 1},
} {
t.Run(backend+"/"+method+"/"+tc.name, func(t *testing.T) {
var reads, writes atomic.Int32
globalBucketTargetSys.Lock()
globalBucketTargetSys.targetsMap[bucket] = nil
globalBucketTargetSys.Unlock()
cfg := replication.Config{}
for i, value := range tc.tags {
target := newILMTestTarget(t)
registerILMTestTarget(t, obj, bucket, target, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("versionId") != versionID || !r.URL.Query().Has("tagging") || r.Header.Get(xhttp.MinIOSourceProxyRequest) != "true" {
t.Errorf("remote request lost addressed version or proxy marker: %s", r.URL)
}
status := tc.writeStatus
if r.Method == http.MethodGet {
reads.Add(1)
status = tc.readStatus
} else {
writes.Add(1)
}
w.Header().Set("Content-Type", "application/xml")
if status != 0 {
w.WriteHeader(status)
code := "ServiceUnavailable"
if status == http.StatusNotFound {
code = "NoSuchVersion"
}
fmt.Fprintf(w, "%sinjected remote result", code)
return
}
switch r.Method {
case http.MethodGet:
fmt.Fprintf(w, "secret%s", value)
case http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
}
})
globalBucketTargetSys.Lock()
globalBucketTargetSys.targetsMap[bucket][i].Credentials = &madmin.Credentials{AccessKey: "proxy-test", SecretKey: "proxy-test-secret"}
globalBucketTargetSys.arnRemotesMap[target.arn].Client.disableProxy = tc.disabledFirst && i == 0
globalBucketTargetSys.Unlock()
rule := configs[0].Rules[0]
rule.Priority = i + 1
rule.Destination = replication.Destination{ARN: target.arn, Bucket: target.bucket}
cfg.Rules = append(cfg.Rules, rule)
}
meta, err := globalBucketMetadataSys.Get(bucket)
if err != nil {
t.Fatal(err)
}
meta.replicationConfig = &cfg
globalBucketMetadataSys.Set(bucket, meta)
body := ""
if method == http.MethodPut {
body = "newvalue"
}
path := "/" + bucket + "/remote-only?tagging&versionId=" + versionID
var req *http.Request
if tc.cred.AccessKey == "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
} else {
req, err = newTestSignedRequestV4(method, path, int64(len(body)), strings.NewReader(body), tc.cred.AccessKey, tc.cred.SecretKey, nil)
if err != nil {
t.Fatal(err)
}
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
wantStatus := tc.wantStatus
if wantStatus == 0 {
wantStatus = http.StatusOK
if method == http.MethodDelete {
wantStatus = http.StatusNoContent
}
}
if rec.Code != wantStatus || writes.Load() != tc.wantWrites {
t.Errorf("status=%d writes=%d, want status=%d writes=%d: %s", rec.Code, writes.Load(), wantStatus, tc.wantWrites, rec.Body.String())
}
if tc.name == "invalid-credentials" && reads.Load() != 0 {
t.Errorf("invalid credentials reached %d remote reads", reads.Load())
}
})
}
}
}})
}