Files
seaweedfs/weed/s3api/s3api_bucket_handlers_misc_test.go
T
Chris Lu 8d59069a0a s3: return BucketAlreadyOwnedByYou when recreating your own bucket (#9822)
* s3: return BucketAlreadyOwnedByYou when recreating your own bucket

PutBucket returned BucketAlreadyExists for every existing bucket, even
when the caller already owns it, so idempotent re-creation (e.g. a
container that creates its bucket on startup) couldn't tell "someone
else took the name" from "it's already mine".

Recreating a bucket you own now returns BucketAlreadyOwnedByYou, unless
the request conflicts with the existing bucket: a different Object Lock
setting, or an ACL on the request or the existing bucket. To detect the
latter, a requested non-default canned/grant ACL is now persisted on
creation instead of being dropped.

* s3: fail PutBucket when the existing bucket's config can't be read

When a bucket already exists, an unreadable config left the recreate
defaulting to BucketAlreadyOwnedByYou, masking the backend error and
possibly accepting a conflicting recreate (Object Lock / ACL unknown).
Surface the read error instead.

* s3: return the stored bucket ACL from GetBucketAcl

GetBucketAcl always returned the owner's default full-control grant and
ignored any stored ACL, so a bucket created with a canned ACL or one set
via PutBucketAcl never read back correctly. Decode the stored grants
instead, sharing one grants-to-XML helper with the object ACL handler.

The shared helper also emits each grantee's real xsi:type (e.g. Group for
public-read) instead of a hardcoded CanonicalUser, so group grants read
back correctly for both bucket and object ACLs.

* s3: resolve the right already-exists error on the concurrent-create race

When two requests create the same bucket at once, the loser's mkdir
fails and the handler fell back to a flat BucketAlreadyExists, bypassing
the same-owner idempotency check. Route both the pre-check and the race
fallback through one existingBucketError helper so a same-owner recreate
still gets BucketAlreadyOwnedByYou.

* s3: record the bucket owner's account id at creation

setBucketOwner only stored the creating identity name, so the canonical
account id wasn't available later. Persist it under ExtAmzOwnerKey too,
the same field PutBucketAcl writes, so the bucket owner can be reported
independently of whoever reads it.

* s3: report the bucket owner from GetBucketAcl, not the caller

GetBucketAcl built the ACL Owner from the caller's account header, so an
admin or cross-account read returned the wrong owner. Use the owner
persisted on the bucket, falling back to the caller only when none is
recorded.
2026-06-04 15:33:03 -07:00

210 lines
6.6 KiB
Go

package s3api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func newMiscTestServer(t *testing.T, bucket string) *S3ApiServer {
t.Helper()
s3a := &S3ApiServer{
iam: &IdentityAccessManagement{isAuthEnabled: true},
bucketConfigCache: NewBucketConfigCache(time.Minute),
}
s3a.bucketConfigCache.Set(bucket, &BucketConfig{
Name: bucket,
Entry: &filer_pb.Entry{Name: bucket},
})
return s3a
}
func newBucketRequest(method, bucket, query, body string) *http.Request {
req := httptest.NewRequest(method, "/"+bucket+"?"+query, strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
return req
}
func TestHasExplicitBucketACL(t *testing.T) {
cases := []struct {
name string
headers map[string]string
want bool
}{
{name: "none", headers: nil, want: false},
{name: "private is default", headers: map[string]string{s3_constants.AmzCannedAcl: "private"}, want: false},
{name: "canned public-read", headers: map[string]string{s3_constants.AmzCannedAcl: "public-read"}, want: true},
{name: "canned case-insensitive private", headers: map[string]string{s3_constants.AmzCannedAcl: "PRIVATE"}, want: false},
{name: "grant read", headers: map[string]string{s3_constants.AmzAclRead: `id="x"`}, want: true},
{name: "grant full control", headers: map[string]string{s3_constants.AmzAclFullControl: `id="x"`}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := newBucketRequest(http.MethodPut, "b", "", "")
for k, v := range tc.headers {
req.Header.Set(k, v)
}
if got := hasExplicitBucketACL(req); got != tc.want {
t.Fatalf("hasExplicitBucketACL = %v, want %v", got, tc.want)
}
})
}
}
func TestGetBucketPolicyStatusIsPublic(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "public allow star",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: true,
},
{
name: "deny is not public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: false,
},
{
name: "condition makes it non-public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`,
want: false,
},
{
name: "specific principal is not public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"arn:aws:iam::1:user/a","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var doc policy_engine.PolicyDocument
if err := json.Unmarshal([]byte(tc.raw), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := isPolicyPublic(&doc); got != tc.want {
t.Fatalf("isPolicyPublic = %v, want %v", got, tc.want)
}
})
}
}
func TestPutBucketRequestPaymentBucketOwner(t *testing.T) {
s3a := newMiscTestServer(t, "b")
body := `<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>`
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
rec := httptest.NewRecorder()
s3a.PutBucketRequestPaymentHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestPutBucketRequestPaymentRequesterRejected(t *testing.T) {
s3a := newMiscTestServer(t, "b")
body := `<RequestPaymentConfiguration><Payer>Requester</Payer></RequestPaymentConfiguration>`
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
rec := httptest.NewRecorder()
s3a.PutBucketRequestPaymentHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "MalformedXML") {
t.Fatalf("body missing MalformedXML: %s", rec.Body.String())
}
}
func TestPutBucketOwnershipControlsRejectsRuleWithoutObjectOwnership(t *testing.T) {
ownerID := AccountAdmin.Id
s3a := &S3ApiServer{
bucketRegistry: &BucketRegistry{
metadataCache: map[string]*BucketMetaData{
"b": {
Name: "b",
Owner: &s3.Owner{
ID: &ownerID,
},
},
},
notFound: map[string]struct{}{},
},
}
body := `<OwnershipControls><Rule></Rule></OwnershipControls>`
req := newBucketRequest(http.MethodPut, "b", "ownershipControls=", body)
req.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
rec := httptest.NewRecorder()
s3a.PutBucketOwnershipControls(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "InvalidRequest") {
t.Fatalf("body missing InvalidRequest: %s", rec.Body.String())
}
}
func TestGetBucketAccelerateConfiguration(t *testing.T) {
s3a := newMiscTestServer(t, "b")
req := newBucketRequest(http.MethodGet, "b", "accelerate=", "")
rec := httptest.NewRecorder()
s3a.GetBucketAccelerateConfigurationHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body, err := io.ReadAll(rec.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
got := string(body)
if !strings.Contains(got, "<AccelerateConfiguration") {
t.Fatalf("missing root element: %s", got)
}
if !strings.Contains(got, "<Status>Suspended</Status>") {
t.Fatalf("missing Suspended status: %s", got)
}
if !strings.Contains(got, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`) {
t.Fatalf("missing xmlns: %s", got)
}
}
func TestGetBucketLogging(t *testing.T) {
s3a := newMiscTestServer(t, "b")
req := newBucketRequest(http.MethodGet, "b", "logging=", "")
rec := httptest.NewRecorder()
s3a.GetBucketLoggingHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
got := rec.Body.String()
if !strings.Contains(got, "<BucketLoggingStatus") {
t.Fatalf("missing root element: %s", got)
}
if strings.Contains(got, "<LoggingEnabled") {
t.Fatalf("unexpected LoggingEnabled element: %s", got)
}
if !strings.Contains(got, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`) {
t.Fatalf("missing xmlns: %s", got)
}
}