// 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()) } }) } } }}) }