Files
seaweedfs/weed/s3api/s3api_bucket_handlers.go
T
Chris LuandGitHub 01bb3b3053 s3api: add Snowflake s3compat API integration tests (#11394)
* s3api: add Snowflake s3compat API integration tests

Run the upstream snowflakedb/snowflake-s3compat-api-test-suite against a
local SeaweedFS server in CI. test/s3/snowflake/run.sh starts weed server
with S3 (-s3.autoCreateBucket=false so missing-bucket PUTs return
NoSuchBucket), prepares the fixtures the suite needs (versioned bucket,
deny-all-policy bucket, >1000-object prefix), clones the suite, patches
it to path-style addressing, and runs mvn -Dtest=S3CompatApiTest.

The suite also exposed that GetBucketLocation returned 404 NoSuchBucket
for a malformed bucket name; validate the name first and return
400 InvalidBucketName like AWS.

* test: harden snowflake s3compat runner per review

- Pin the upstream suite to a tested commit (SUITE_REV) instead of the
  moving default branch
- Bind the test server to loopback only
- Require the AccessDenied error code when verifying the denied bucket
- Fix README so go install runs in a subshell
- checkout with persist-credentials: false
- Make the concurrency group unique per PR, and widen path filters to
  the storage/operation/wdclient/cluster/pb packages the S3 stack uses

* test: advertise loopback ip for snowflake test server

-ip.bind 127.0.0.1 alone left the volume server advertising the host's
primary address, so chunk uploads were refused. Also set -ip 127.0.0.1
and disable the Iceberg/Lance listeners so the harness is loopback-only
and does not collide with other local services.
2026-09-19 03:29:51 -07:00

1499 lines
56 KiB
Go

package s3api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// A bucket creation lists collections, and a bucket deletion deletes one.
// Neither RPC carried a deadline, so a transient failure anywhere down the chain
// -- gateway to filer, filer to master, master to volume server -- held the S3
// request open until the client gave up on it. Both budgets are taken outside
// the filer failover walk, so they cover the whole walk rather than granting
// each filer a fresh one.
//
// The delete is the shorter of the two: the filer has already spent its own
// budget on this collection, under the bucket entry's delete inside s3a.rm, and
// this call is the follow-up for when that did not happen.
const (
collectionListTimeout = 15 * time.Second
collectionDeleteTimeout = 10 * time.Second
)
func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Request) {
glog.V(3).Infof("ListBucketsHandler")
// Get authenticated identity from context (set by Auth middleware)
// For unauthenticated requests, this returns empty string
identityId := s3_constants.GetIdentityNameFromContext(r)
// Get the full identity object for permission and ownership checks
// This is especially important for JWT users whose identity is not in the identities list
// Note: We store the full Identity object in context for simplicity. Future optimization
// could use a lightweight, credential-free view (name, account, actions, principal ARN)
// for better data minimization.
var identity *Identity
if s3a.iam.isEnabled() {
// Try to get the full identity from context first (works for all auth types including JWT)
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
if id, ok := identityObj.(*Identity); ok {
identity = id
} else {
glog.Warningf("ListBucketsHandler: identity object in context has unexpected type: %T", identityObj)
}
}
// Fallback to looking up by name if not in context (backward compatibility)
if identity == nil && identityId != "" {
identity = s3a.iam.lookupByIdentityName(identityId)
}
}
maxBuckets, prefix, startAfter, errCode := getListBucketsArgs(r.URL.Query())
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
var buckets []ListAllMyBucketsEntry
var nextToken string
// Unauthenticated users should not see any buckets
if identity != nil {
var err error
if fromIndex, granted := s3a.iam.canListBucketsFromOwnerIndex(r, identity); fromIndex && s3a.bucketOwnerIndexReady() {
buckets, nextToken, err = s3a.listBucketsFromOwnerIndex(r, identity, granted, prefix, startAfter, maxBuckets)
} else {
buckets, nextToken, err = s3a.scanVisibleBuckets(r, identity, prefix, startAfter, maxBuckets)
}
if err != nil {
glog.Errorf("ListBucketsHandler: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
}
response := ListAllMyBucketsResult{
Owner: CanonicalUser{
ID: identityId,
DisplayName: identityId,
},
Buckets: ListAllMyBucketsList{Bucket: buckets},
ContinuationToken: nextToken,
Prefix: prefix,
}
glog.V(3).Infof("ListBucketsHandler response: %+v", response)
writeSuccessResponseXML(w, r, response)
}
const (
// maxBucketsPerPage caps a single ListBuckets response, matching AWS.
maxBucketsPerPage = 10000
// bucketScanPageSize is the filer page size while scanning /buckets.
bucketScanPageSize = 1000
)
func getListBucketsArgs(values url.Values) (maxBuckets int, prefix string, startAfter string, errCode s3err.ErrorCode) {
maxBuckets = maxBucketsPerPage
if v := values.Get("max-buckets"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > maxBucketsPerPage {
return 0, "", "", s3err.ErrInvalidMaxBuckets
}
maxBuckets = n
}
prefix = values.Get("prefix")
if v := values.Get("continuation-token"); v != "" {
name, err := decodeContinuationToken(v)
if err != nil {
return 0, "", "", s3err.ErrInvalidContinuationToken
}
startAfter = name
}
return maxBuckets, prefix, startAfter, s3err.ErrNone
}
// The continuation token is the last returned bucket name, base64-encoded so
// clients treat it as opaque per the S3 API contract.
func encodeContinuationToken(bucket string) string {
return base64.RawURLEncoding.EncodeToString([]byte(bucket))
}
func decodeContinuationToken(token string) (string, error) {
name, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(name) == 0 {
return "", fmt.Errorf("invalid continuation token %q", token)
}
return string(name), nil
}
// bucketVisibleToIdentity reports whether ListBuckets should include the bucket:
// the identity owns it or has explicit permission to list it.
func (s3a *S3ApiServer) bucketVisibleToIdentity(r *http.Request, bucket, owner string, identity *Identity) bool {
if isBucketOwnedByIdentity(owner, identity) {
return true
}
return s3a.iam.VerifyActionPermission(r, identity, s3_constants.ACTION_LIST, bucket, "") == s3err.ErrNone
}
// scanVisibleBuckets pages through /buckets and collects up to maxBuckets
// entries visible to the identity, returning a continuation token when more
// remain. It never buffers the full bucket set.
func (s3a *S3ApiServer) scanVisibleBuckets(r *http.Request, identity *Identity, prefix, startAfter string, maxBuckets int) (buckets []ListAllMyBucketsEntry, nextToken string, err error) {
startFrom := startAfter
for len(buckets) <= maxBuckets {
entries, isLast, listErr := s3a.list(s3a.option.BucketsPath, prefix, startFrom, false, bucketScanPageSize)
if listErr != nil {
return nil, "", listErr
}
if len(entries) == 0 {
break
}
for _, entry := range entries {
startFrom = entry.Name
if !entry.IsDirectory || strings.HasPrefix(entry.Name, ".") {
continue
}
if !s3a.bucketVisibleToIdentity(r, entry.Name, bucketEntryOwner(entry), identity) {
continue
}
buckets = append(buckets, ListAllMyBucketsEntry{
Name: entry.Name,
CreationDate: time.Unix(entry.Attributes.Crtime, 0).UTC(),
})
if len(buckets) > maxBuckets {
break
}
}
if isLast || len(buckets) > maxBuckets {
break
}
}
if len(buckets) > maxBuckets {
buckets = buckets[:maxBuckets]
nextToken = encodeContinuationToken(buckets[maxBuckets-1].Name)
}
return buckets, nextToken, nil
}
// isBucketOwnedByIdentity checks if a bucket with the given owner id (its
// AmzIdentityId metadata) is owned by the given identity.
//
// Ownership rules:
// - Admin users: considered owners of all buckets
// - Non-admin users: own buckets whose owner id matches identity.Name
// - Buckets without owner metadata are not owned by anyone (except admins)
func isBucketOwnedByIdentity(owner string, identity *Identity) bool {
if identity == nil {
return false
}
// Admin users are considered owners of all buckets
if identity.isAdmin() {
return true
}
// Non-admin users with no name cannot own buckets.
// This prevents misconfigured identities from matching buckets with empty owner IDs.
return identity.Name != "" && identity.Name == owner
}
func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
// validate the bucket name
err := s3bucket.VerifyS3BucketName(bucket)
if err != nil {
glog.Errorf("put invalid bucket name: %v %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
return
}
// Check if bucket already exists and handle ownership/settings
// Get authenticated identity from context (secure, cannot be spoofed)
currentIdentityId := s3_constants.GetIdentityNameFromContext(r)
// Parse any requested bucket ACL (canned ACL or grant headers) up front so it
// can be validated, persisted on creation, and factored into the already-exists
// response. A "private" canned ACL is the default and counts as no explicit ACL.
requestHasACL := hasExplicitBucketACL(r)
var aclGrantsBytes []byte
if requestHasACL {
accountId := getAccountId(r)
_, grants, errCode := ParseAndValidateAclHeaders(r, s3a.iam, "", accountId, accountId, false)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if len(grants) > 0 {
grantsBytes, err := json.Marshal(grants)
if err != nil {
glog.Errorf("PutBucketHandler: marshal ACL grants for %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
aclGrantsBytes = grantsBytes
}
}
// Check collection existence first
collectionExists := false
if s3a.isTableBucket(bucket) {
s3err.WriteErrorResponse(w, r, s3err.ErrBucketAlreadyExists)
return
}
listCtx, cancelList := context.WithTimeout(r.Context(), collectionListTimeout)
if err := s3a.withFilerClient(listCtx, false, func(client filer_pb.SeaweedFilerClient) error {
if resp, err := client.CollectionList(listCtx, &filer_pb.CollectionListRequest{
IncludeEcVolumes: true,
IncludeNormalVolumes: true,
}); err != nil {
return fmt.Errorf("list collections: %w", err)
} else {
for _, c := range resp.Collections {
if s3a.getCollectionName(bucket) == c.Name {
collectionExists = true
break
}
}
}
return nil
}); err != nil {
// Advisory: the answer decides nothing below except whether to log that a
// leftover collection is being reused. s3a.exists is what decides whether
// the bucket already exists, so a listing that failed is no reason to
// refuse the creation.
glog.Warningf("PutBucketHandler: list collections for %s: %v", bucket, err)
}
cancelList()
// Bucket already exists: report whether the caller already owns it or the
// name is taken / the request conflicts.
if exist, err := s3a.exists(s3a.option.BucketsPath, bucket, true); err == nil && exist {
errCode := s3a.existingBucketError(r, bucket, currentIdentityId, requestHasACL)
if errCode == s3err.ErrBucketAlreadyOwnedByYou {
s3a.healBucketOwnerIndex(bucket, currentIdentityId)
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
// If collection exists but bucket directory doesn't, this is an inconsistent state
// that can occur when a previous bucket deletion partially completed (collection
// deletion failed but directory deletion succeeded, or volumes were recreated).
// Recover by proceeding to create the missing bucket directory.
if collectionExists {
glog.Warningf("PutBucketHandler: collection exists but bucket directory missing for %s, recovering by creating bucket directory", bucket)
}
// Check for x-amz-bucket-object-lock-enabled header BEFORE creating bucket
// This allows us to create the bucket with Object Lock configuration atomically
objectLockEnabled := strings.EqualFold(r.Header.Get(s3_constants.AmzBucketObjectLockEnabled), "true")
// Capture any Object Lock configuration error from within the callback
// The mkdir callback doesn't support returning errors, so we capture it here
var objectLockSetupError error
// Create the folder for bucket with all settings atomically
// This ensures Object Lock configuration is set in the same CreateEntry call,
// preventing race conditions where the bucket exists without Object Lock enabled
var bucketCrtime int64
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
// Set bucket owner
setBucketOwner(r)(entry)
// Persist a requested non-default ACL so GetBucketAcl and idempotent
// recreation observe it (private is the default and is not stored).
if len(aclGrantsBytes) > 0 {
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended[s3_constants.ExtAmzAclKey] = aclGrantsBytes
}
// Set Object Lock configuration atomically during bucket creation
if objectLockEnabled {
glog.V(3).Infof("PutBucketHandler: enabling Object Lock and Versioning for bucket %s atomically", bucket)
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
// Enable versioning (required for Object Lock)
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled)
// Create and store Object Lock configuration
objectLockConfig := &ObjectLockConfiguration{
ObjectLockEnabled: s3_constants.ObjectLockEnabled,
}
if err := StoreObjectLockConfigurationInExtended(entry, objectLockConfig); err != nil {
glog.Errorf("PutBucketHandler: failed to store Object Lock config for bucket %s: %v", bucket, err)
objectLockSetupError = err
// Note: The entry will still be created, but we'll roll it back below
} else {
glog.V(3).Infof("PutBucketHandler: set ObjectLockConfig for bucket %s: %+v", bucket, objectLockConfig)
}
}
}); err != nil {
// If mkdir failed because another request created the bucket concurrently,
// return the appropriate already-exists error instead of InternalError.
if exist, checkErr := s3a.exists(s3a.option.BucketsPath, bucket, true); checkErr == nil && exist {
glog.V(3).Infof("PutBucketHandler: bucket %s was created concurrently", bucket)
errCode := s3a.existingBucketError(r, bucket, currentIdentityId, requestHasACL)
if errCode == s3err.ErrBucketAlreadyOwnedByYou {
s3a.healBucketOwnerIndex(bucket, currentIdentityId)
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.Errorf("PutBucketHandler mkdir: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// If Object Lock setup failed, roll back the bucket creation
// This ensures we don't leave a bucket without the requested Object Lock configuration
if objectLockSetupError != nil {
glog.Errorf("PutBucketHandler: rolling back bucket %s creation due to Object Lock setup failure: %v", bucket, objectLockSetupError)
if deleteErr := s3a.rm(context.Background(), s3a.option.BucketsPath, bucket, true, true); deleteErr != nil {
glog.Errorf("PutBucketHandler: failed to rollback bucket %s after Object Lock setup failure: %v", bucket, deleteErr)
}
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// Remove bucket from negative cache after successful creation
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
// Index the bucket under its owner; the metadata subscription would catch
// up eventually, but writing here keeps ListBuckets read-your-writes.
if currentIdentityId != "" {
if err := s3a.addBucketToOwnerIndex(currentIdentityId, bucket, bucketCrtime); err != nil {
glog.Warningf("PutBucketHandler: owner index add %s/%s: %v", currentIdentityId, bucket, err)
}
}
w.Header().Set("Location", "/"+bucket)
writeSuccessResponseEmpty(w, r)
}
// healBucketOwnerIndex re-creates the owner index entry for a bucket the
// caller owns, repairing holes left by crashes between bucket creation and
// index write.
func (s3a *S3ApiServer) healBucketOwnerIndex(bucket, identityId string) {
if identityId == "" {
return
}
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone || config.IdentityId != identityId {
return
}
if err := s3a.addBucketToOwnerIndex(identityId, bucket, config.Crtime); err != nil {
glog.V(1).Infof("owner index heal %s/%s: %v", identityId, bucket, err)
}
}
func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("DeleteBucketHandler %s", bucket)
// The teardown below retries, and a failover walk repeats it once per
// filer, so the backoff comes out of one allowance held here.
r = r.WithContext(withFilerRetryBudget(r.Context(), filerRetryRequestBudget))
if s3a.isTableBucket(bucket) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Check if bucket has object lock enabled
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// If object lock is enabled, check for objects with active locks
if bucketConfig.ObjectLockConfig != nil {
hasLockedObjects, checkErr := s3a.hasObjectsWithActiveLocks(r.Context(), bucket)
if checkErr != nil {
glog.Errorf("DeleteBucketHandler: failed to check for locked objects in bucket %s: %v", bucket, checkErr)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if hasLockedObjects {
glog.V(3).Infof("DeleteBucketHandler: bucket %s has objects with active object locks, cannot delete", bucket)
s3err.WriteErrorResponse(w, r, s3err.ErrBucketNotEmpty)
return
}
}
if !s3a.option.AllowDeleteBucketNotEmpty {
if hasUserObjects, err := s3a.bucketHasUserObjects(bucket); err != nil {
glog.Errorf("failed to list bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
} else if hasUserObjects {
s3err.WriteErrorResponse(w, r, s3err.ErrBucketNotEmpty)
return
}
}
// Delete bucket directory first, then collection. This order ensures that if
// collection deletion fails, the bucket directory is already gone, preventing
// the "collection exists but bucket directory missing" inconsistency that blocks
// bucket recreation. An orphaned collection is harmless and will be cleaned up
// or reused when the bucket is recreated.
err := s3a.rm(r.Context(), s3a.option.BucketsPath, bucket, false, true)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if owner := bucketConfig.IdentityId; owner != "" {
if err := s3a.removeBucketFromOwnerIndex(owner, bucket); err != nil {
glog.Warningf("DeleteBucketHandler: owner index remove %s/%s: %v", owner, bucket, err)
}
}
// Bounded on a background context: the bucket directory is already gone, so
// this follow-up must survive a client disconnect, but it must not outlive the
// client by an unbounded amount either.
deleteCtx, cancelDelete := context.WithTimeout(context.Background(), collectionDeleteTimeout)
err = s3a.withFilerClient(deleteCtx, false, func(client filer_pb.SeaweedFilerClient) error {
deleteCollectionRequest := &filer_pb.DeleteCollectionRequest{
Collection: s3a.getCollectionName(bucket),
}
glog.V(1).Infof("delete collection: %v", deleteCollectionRequest)
if _, err := client.DeleteCollection(deleteCtx, deleteCollectionRequest); err != nil {
return fmt.Errorf("delete collection %s: %v", bucket, err)
}
return nil
})
timedOut := deleteCtx.Err() != nil
cancelDelete()
if err != nil {
// Log but don't fail — the bucket directory is already removed, so the bucket
// is effectively deleted. The orphaned collection will be cleaned up or reused.
if timedOut {
// Our own budget, not a refusal: the master carries on deleting once asked.
glog.Warningf("DeleteBucketHandler: stopped waiting for the collection delete for bucket %s: %v", bucket, err)
} else {
glog.Errorf("DeleteBucketHandler: failed to delete collection for bucket %s: %v", bucket, err)
}
}
// Clean up bucket-related caches, locks, and metrics after successful deletion
s3a.invalidateBucketConfigCache(bucket)
stats_collect.DeleteBucketMetrics(bucket)
// Prune identity actions that were scoped to this bucket via s3.configure.
// Use a bounded background context so the cleanup survives client disconnect;
// the bucket is already gone and this is best-effort bookkeeping.
if s3a.iam != nil {
pruneCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if _, err := s3a.iam.PruneBucketFromConfiguration(pruneCtx, bucket); err != nil {
glog.Errorf("DeleteBucketHandler: failed to prune IAM actions for bucket %s: %v", bucket, err)
}
cancel()
}
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
// bucketHasUserObjects checks whether a bucket contains any non-special entries.
// Special entries (.uploads, *.versions) are internal to S3 and don't count as user objects.
func (s3a *S3ApiServer) bucketHasUserObjects(bucket string) (bool, error) {
bucketPath := s3a.option.BucketsPath + "/" + bucket
startFrom := ""
// Start with a small batch — most non-empty buckets have a real object early.
// If we only find special entries, switch to larger batches to page through quickly.
limit := uint32(10)
for {
entries, isLast, err := s3a.list(bucketPath, "", startFrom, false, limit)
if err != nil {
return false, err
}
for _, entry := range entries {
if entry.Name != s3_constants.MultipartUploadsFolder &&
!strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) {
return true, nil
}
startFrom = entry.Name
}
if isLast {
return false, nil
}
limit = 1000
}
}
// hasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold
// Delegates to the shared HasObjectsWithActiveLocks function in object_lock_utils.go
func (s3a *S3ApiServer) hasObjectsWithActiveLocks(ctx context.Context, bucket string) (bool, error) {
bucketPath := s3a.option.BucketsPath + "/" + bucket
var hasLocks bool
var checkErr error
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
hasLocks, checkErr = HasObjectsWithActiveLocks(ctx, client, bucketPath)
return checkErr
})
if err != nil {
return false, err
}
return hasLocks, nil
}
func (s3a *S3ApiServer) HeadBucketHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("HeadBucketHandler %s", bucket)
if _, err := s3a.getBucketEntry(bucket); err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("HeadBucketHandler: failed to get bucket entry for %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
writeSuccessResponseEmpty(w, r)
}
func (s3a *S3ApiServer) checkBucket(r *http.Request, bucket string) s3err.ErrorCode {
// Use cached bucket config instead of direct getEntry call (optimization)
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
return errCode
}
//if iam is enabled, the access was already checked before
if s3a.iam.isEnabled() {
return s3err.ErrNone
}
if !s3a.hasAccess(r, config.IdentityId) {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}
// ErrAutoCreatePermissionDenied is returned when a user lacks permission to auto-create buckets
var ErrAutoCreatePermissionDenied = errors.New("permission denied - requires Admin permission")
// ErrAutoCreateDisabled is returned when bucket auto-creation is disabled by configuration
var ErrAutoCreateDisabled = errors.New("bucket auto-creation is disabled")
// ErrInvalidBucketName is returned when a bucket name doesn't meet S3 naming requirements
var ErrInvalidBucketName = errors.New("invalid bucket name")
// existingBucketError returns the error for a PutBucket whose target bucket
// already exists: BucketAlreadyOwnedByYou for an idempotent recreate by the
// owner, or BucketAlreadyExists when the name is owned by someone else or the
// request conflicts with the existing bucket (a different Object Lock setting,
// or an ACL on the request or the existing bucket).
func (s3a *S3ApiServer) existingBucketError(r *http.Request, bucket, currentIdentityId string, requestHasACL bool) s3err.ErrorCode {
entry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
if err != nil {
// We just observed the bucket exists but can't read it; report it as taken.
glog.Errorf("PutBucketHandler: failed to read existing bucket %s: %v", bucket, err)
return s3err.ErrBucketAlreadyExists
}
var existingOwnerId string
if entry.Extended != nil {
if id, ok := entry.Extended[s3_constants.AmzIdentityId]; ok {
existingOwnerId = string(id)
}
}
// Different owner: the name is taken in the shared namespace.
if existingOwnerId != "" && existingOwnerId != currentIdentityId {
glog.V(3).Infof("PutBucketHandler: bucket %s owned by %s, requested by %s", bucket, existingOwnerId, currentIdentityId)
return s3err.ErrBucketAlreadyExists
}
// Same owner (or an unowned bucket the caller can claim). Recreating your own
// bucket is idempotent and 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.
// (s3-tests: test_bucket_create_exists vs test_bucket_recreate_*_acl.)
if requestHasACL {
return s3err.ErrBucketAlreadyExists
}
objectLockRequested := strings.EqualFold(r.Header.Get(s3_constants.AmzBucketObjectLockEnabled), "true")
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
// Can't read the existing bucket's settings, so we can't tell whether this
// recreate conflicts; surface the failure instead of assuming idempotency.
glog.Errorf("PutBucketHandler: failed to get bucket config for %s: %v", bucket, errCode)
return errCode
}
currentObjectLockEnabled := bucketConfig.ObjectLockConfig != nil &&
bucketConfig.ObjectLockConfig.ObjectLockEnabled == s3_constants.ObjectLockEnabled
if objectLockRequested != currentObjectLockEnabled || len(bucketConfig.ACL) > 0 {
glog.V(3).Infof("PutBucketHandler: bucket %s already exists", bucket)
return s3err.ErrBucketAlreadyExists
}
glog.V(3).Infof("PutBucketHandler: bucket %s already owned by requester", bucket)
return s3err.ErrBucketAlreadyOwnedByYou
}
// hasExplicitBucketACL reports whether the request carries an explicit, non-default
// bucket ACL via a canned ACL header (other than "private") or grant headers.
func hasExplicitBucketACL(r *http.Request) bool {
if canned := r.Header.Get(s3_constants.AmzCannedAcl); canned != "" && !strings.EqualFold(canned, s3_constants.CannedAclPrivate) {
return true
}
for _, h := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
if r.Header.Get(h) != "" {
return true
}
}
return false
}
// setBucketOwner creates a function that sets the bucket owner from the request context
func setBucketOwner(r *http.Request) func(entry *filer_pb.Entry) {
currentIdentityId := s3_constants.GetIdentityNameFromContext(r)
// Record the canonical account id too so GetBucketAcl can report the bucket
// owner instead of whoever is reading (e.g. an admin or another account).
accountId := r.Header.Get(s3_constants.AmzAccountId)
return func(entry *filer_pb.Entry) {
if currentIdentityId == "" && accountId == "" {
return
}
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
if currentIdentityId != "" {
entry.Extended[s3_constants.AmzIdentityId] = []byte(currentIdentityId)
}
if accountId != "" {
entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(accountId)
}
}
}
// autoCreateBucket creates a bucket if it doesn't exist, setting the owner from the request context
// Only users with admin permissions are allowed to auto-create buckets
func (s3a *S3ApiServer) autoCreateBucket(r *http.Request, bucket string) error {
// Validate the bucket name before auto-creating
if err := s3bucket.VerifyS3BucketName(bucket); err != nil {
return fmt.Errorf("auto-create bucket %s: %w", bucket, errors.Join(ErrInvalidBucketName, err))
}
if !s3a.option.AutoCreateBucket {
return fmt.Errorf("auto-create bucket %s: %w", bucket, ErrAutoCreateDisabled)
}
// Check if user has admin permissions
if !s3a.isUserAdmin(r) {
return fmt.Errorf("auto-create bucket %s: %w", bucket, ErrAutoCreatePermissionDenied)
}
identityId := s3_constants.GetIdentityNameFromContext(r)
var bucketCrtime int64
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
setBucketOwner(r)(entry)
}); err != nil {
// In case of a race condition where another request created the bucket
// in the meantime, check for existence before returning an error.
if exist, err2 := s3a.exists(s3a.option.BucketsPath, bucket, true); err2 != nil {
glog.Warningf("autoCreateBucket: failed to check existence for bucket %s: %v", bucket, err2)
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, errors.Join(err, err2))
} else if exist {
// The bucket exists, which is fine. However, we should ensure it has an owner.
// If it was created by a concurrent request that didn't set an owner,
// we'll set it here to ensure consistency.
if entry, getErr := s3a.getEntry(s3a.option.BucketsPath, bucket); getErr == nil {
if entry.Extended == nil || len(entry.Extended[s3_constants.AmzIdentityId]) == 0 {
// No owner set, assign current admin as owner
setBucketOwner(r)(entry)
if updateErr := s3a.updateEntry(s3a.option.BucketsPath, entry); updateErr != nil {
glog.Warningf("autoCreateBucket: failed to set owner for existing bucket %s: %v", bucket, updateErr)
} else {
glog.V(1).Infof("Set owner for existing bucket %s (created by concurrent request)", bucket)
if owner := bucketEntryOwner(entry); owner != "" {
if indexErr := s3a.addBucketToOwnerIndex(owner, bucket, entry.Attributes.Crtime); indexErr != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", owner, bucket, indexErr)
}
}
}
}
} else {
glog.Warningf("autoCreateBucket: failed to get entry for existing bucket %s: %v", bucket, getErr)
}
// Remove bucket from negative cache — it exists now
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
return nil
}
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, err)
}
// Remove bucket from negative cache after successful creation
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
if identityId != "" {
if err := s3a.addBucketToOwnerIndex(identityId, bucket, bucketCrtime); err != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", identityId, bucket, err)
}
}
glog.V(1).Infof("Auto-created bucket %s", bucket)
return nil
}
// handleAutoCreateBucket attempts to auto-create a bucket and writes appropriate error responses
// Returns true if the bucket was created successfully or already exists, false if an error was written
func (s3a *S3ApiServer) handleAutoCreateBucket(w http.ResponseWriter, r *http.Request, bucket, handlerName string) bool {
if err := s3a.autoCreateBucket(r, bucket); err != nil {
glog.Warningf("%s: %v", handlerName, err)
// Check for specific errors to return appropriate S3 error codes
if errors.Is(err, ErrAutoCreateDisabled) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
} else if errors.Is(err, ErrInvalidBucketName) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
} else if errors.Is(err, ErrAutoCreatePermissionDenied) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
} else {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return false
}
return true
}
// hasAccess checks the caller against the identity recorded at bucket
// creation; buckets with no recorded identity are open to any caller.
func (s3a *S3ApiServer) hasAccess(r *http.Request, bucketIdentityId string) bool {
// Check if user is properly authenticated as admin through IAM system
if s3a.isUserAdmin(r) {
return true
}
if bucketIdentityId == "" {
return true
}
// Get authenticated identity from context (secure, cannot be spoofed)
identityId := s3_constants.GetIdentityNameFromContext(r)
if identityId != bucketIdentityId {
glog.V(3).Infof("hasAccess: %s != %s", identityId, bucketIdentityId)
return false
}
return true
}
// isUserAdmin securely checks if the authenticated user is an admin
// This validates admin status through proper IAM authentication, not spoofable headers
func (s3a *S3ApiServer) isUserAdmin(r *http.Request) bool {
// Reuse the identity the Auth middleware stored; re-authenticating here would
// fail once the request body has been read.
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
if identity, ok := identityObj.(*Identity); ok {
return identity != nil && identity.isAdmin()
}
}
if s3a.iam == nil {
return false
}
// Use a minimal admin action to authenticate and check admin status
adminAction := Action("Admin")
identity, errCode := s3a.iam.authRequest(r, adminAction)
if errCode != s3err.ErrNone {
return false
}
// Check if the authenticated identity has admin privileges
return identity != nil && identity.isAdmin()
}
// isBucketPublicRead checks if a bucket allows anonymous read access based on its cached ACL status
func (s3a *S3ApiServer) isBucketPublicRead(bucket string) bool {
// Get bucket configuration which contains cached public-read status
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
glog.V(4).Infof("isBucketPublicRead: failed to get bucket config for %s: %v", bucket, errCode)
return false
}
glog.V(4).Infof("isBucketPublicRead: bucket=%s, IsPublicRead=%v", bucket, config.IsPublicRead)
// Return the cached public-read status (no JSON parsing needed)
return config.IsPublicRead
}
// isPublicReadGrants checks if the grants allow public read access
func isPublicReadGrants(grants []*s3.Grant) bool {
for _, grant := range grants {
if grant.Grantee != nil && grant.Grantee.URI != nil && grant.Permission != nil {
// Check for AllUsers group with Read permission
if *grant.Grantee.URI == s3_constants.GranteeGroupAllUsers &&
(*grant.Permission == s3_constants.PermissionRead || *grant.Permission == s3_constants.PermissionFullControl) {
return true
}
}
}
return false
}
// buildResourceARN builds a resource ARN from bucket and object
// Used by the policy engine wrapper
func buildResourceARN(bucket, object string) string {
if object == "" || object == "/" {
return fmt.Sprintf("arn:aws:s3:::%s", bucket)
}
// Remove leading slash if present
object = strings.TrimPrefix(object, "/")
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucket, object)
}
// AuthWithPublicRead creates an auth wrapper that allows anonymous access for public-read buckets
func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Action) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bucket, object := s3_constants.GetBucketAndObject(r)
authType := getRequestAuthType(r)
isAnonymous := authType == authTypeAnonymous
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, object=%s, authType=%v, isAnonymous=%v", bucket, object, authType, isAnonymous)
// For anonymous requests, check if bucket allows public read via ACLs or bucket policies
if isAnonymous {
// First check ACL-based public access
isPublic := s3a.isBucketPublicRead(bucket)
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, isPublicACL=%v", bucket, isPublic)
if isPublic {
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to public-read bucket %s (ACL)", bucket)
handler(w, r)
return
}
// Check bucket policy for anonymous access using the policy engine
principal := "*" // Anonymous principal
// Evaluate bucket policy (objectEntry nil - not yet fetched)
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil, nil)
if err != nil {
// SECURITY: Fail-close on policy evaluation errors
// If we can't evaluate the policy, deny access rather than falling through to IAM
glog.Errorf("AuthWithPublicRead: error evaluating bucket policy for %s/%s: %v - denying access", bucket, object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
} else if evaluated {
// A bucket policy exists and was evaluated with a matching statement
if allowed {
// Policy explicitly allows anonymous access
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to bucket %s (bucket policy)", bucket)
handler(w, r)
return
} else {
// Policy explicitly denies anonymous access
glog.V(3).Infof("AuthWithPublicRead: bucket policy explicitly denies anonymous access to %s/%s", bucket, object)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
}
// No matching policy statement - fall through to check ACLs and then IAM auth
glog.V(3).Infof("AuthWithPublicRead: no bucket policy match for %s, checking ACLs", bucket)
}
// For all authenticated requests and anonymous requests to non-public buckets,
// use normal IAM auth to enforce policies
s3a.iam.Auth(handler, action)(w, r)
}
}
// GetBucketAclHandler Get Bucket ACL
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAcl.html
func (s3a *S3ApiServer) GetBucketAclHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketAclHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Report the bucket's owner (recorded at creation or by PutBucketAcl), not the
// caller; fall back to the caller only when no owner was persisted. Likewise
// return any stored ACL, defaulting to the owner's full-control grant.
ownerId := r.Header.Get(s3_constants.AmzAccountId)
var storedGrants []*s3.Grant
if bucketConfig, errCode := s3a.getBucketConfig(bucket); errCode == s3err.ErrNone {
storedGrants = parseAclGrants(bucketConfig.ACL)
if bucketConfig.Owner != "" {
ownerId = bucketConfig.Owner
}
}
ownerDisplayName := s3a.iam.GetAccountNameById(ownerId)
response := AccessControlPolicy{
Owner: CanonicalUser{
ID: ownerId,
DisplayName: ownerDisplayName,
},
AccessControlList: buildAccessControlList(s3a.iam, storedGrants, ownerId, ownerDisplayName),
}
writeSuccessResponseXML(w, r, response)
}
// PutBucketAclHandler Put bucket ACL
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAcl.html //
func (s3a *S3ApiServer) PutBucketAclHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketAclHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Get account information for ACL processing
amzAccountId := r.Header.Get(s3_constants.AmzAccountId)
// Get bucket ownership settings (these would be used for ownership validation in a full implementation)
bucketOwnership := "" // Default/simplified for now - in a full implementation this would be retrieved from bucket config
bucketOwnerId := amzAccountId // Simplified - bucket owner is current account
// Use the existing ACL parsing logic to handle both canned ACLs and XML body
grants, errCode := ExtractAcl(r, s3a.iam, bucketOwnership, bucketOwnerId, amzAccountId, amzAccountId)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.V(3).Infof("PutBucketAclHandler: bucket=%s, extracted %d grants", bucket, len(grants))
isPublic := isPublicReadGrants(grants)
glog.V(3).Infof("PutBucketAclHandler: bucket=%s, isPublicReadGrants=%v", bucket, isPublic)
// Store the bucket ACL in bucket metadata
errCode = s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
if len(grants) > 0 {
grantsBytes, err := json.Marshal(grants)
if err != nil {
glog.Errorf("PutBucketAclHandler: failed to marshal grants: %v", err)
return err
}
config.ACL = grantsBytes
// Cache the public-read status to avoid JSON parsing on every request
config.IsPublicRead = isPublicReadGrants(grants)
glog.V(4).Infof("PutBucketAclHandler: bucket=%s, setting IsPublicRead=%v", bucket, config.IsPublicRead)
} else {
config.ACL = nil
config.IsPublicRead = false
}
config.Owner = amzAccountId
return nil
})
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.V(3).Infof("PutBucketAclHandler: Successfully stored ACL for bucket %s with %d grants", bucket, len(grants))
// Small delay to ensure ACL propagation across distributed caches
// This prevents race conditions in tests where anonymous access is attempted immediately after ACL change
time.Sleep(50 * time.Millisecond)
writeSuccessResponseEmpty(w, r)
}
// GetBucketLifecycleConfigurationHandler Get Bucket Lifecycle configuration
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLifecycleConfiguration.html
func (s3a *S3ApiServer) GetBucketLifecycleConfigurationHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketLifecycleConfigurationHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
if lifecycleXML, transitionMinimumObjectSize, found, errCode := s3a.getStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
} else if found {
w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, transitionMinimumObjectSize)
writeSuccessResponseXMLBytes(w, r, lifecycleXML)
return
}
// ReadFilerConfFromFilers provides multi-filer failover
fc, err := filer.ReadFilerConfFromFilers(s3a.option.Filers, s3a.option.GrpcDialOption, nil)
if err != nil {
glog.Errorf("GetBucketLifecycleConfigurationHandler: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
ttls := fc.GetCollectionTtls(s3a.getCollectionName(bucket))
if len(ttls) == 0 {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchLifecycleConfiguration)
return
}
response := lifecycle_xml.Lifecycle{}
// Sort locationPrefixes to ensure consistent ordering of lifecycle rules
var locationPrefixes []string
for locationPrefix := range ttls {
locationPrefixes = append(locationPrefixes, locationPrefix)
}
sort.Strings(locationPrefixes)
for _, locationPrefix := range locationPrefixes {
internalTtl := ttls[locationPrefix]
ttl, _ := needle.ReadTTL(internalTtl)
days := int(ttl.Minutes() / 60 / 24)
if days == 0 {
continue
}
prefix, found := strings.CutPrefix(locationPrefix, fmt.Sprintf("%s/%s/", s3a.option.BucketsPath, bucket))
if !found {
continue
}
response.Rules = append(response.Rules, lifecycle_xml.Rule{
ID: prefix,
Status: lifecycle_xml.Enabled,
Prefix: lifecycle_xml.NewPrefix(prefix),
Expiration: lifecycle_xml.NewExpirationDays(days),
})
}
if len(response.Rules) > 0 {
w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, defaultLifecycleTransitionMinimumObjectSize)
}
writeSuccessResponseXML(w, r, response)
}
// PutBucketLifecycleConfigurationHandler Put Bucket Lifecycle configuration
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycleConfiguration.html
func (s3a *S3ApiServer) PutBucketLifecycleConfigurationHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketLifecycleConfigurationHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
lifecycleXML, err := readRequestBody(r, maxBucketLifecycleConfigurationSize)
if err != nil {
glog.Warningf("PutBucketLifecycleConfigurationHandler read body: %s", err)
if errors.Is(err, errRequestBodyTooLarge) {
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
lifeCycleConfig := lifecycle_xml.Lifecycle{}
if err := xmlDecoder(bytes.NewReader(lifecycleXML), &lifeCycleConfig, int64(len(lifecycleXML))); err != nil {
glog.Warningf("PutBucketLifecycleConfigurationHandler xml decode: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
// Reject Transition rules — they require storage class migration
// infrastructure that does not exist yet. Validate before touching
// any backing state so a malformed PUT can't half-apply.
for _, rule := range lifeCycleConfig.Rules {
if rule.Status != lifecycle_xml.Enabled {
continue
}
if rule.Transition.Set() || rule.NoncurrentVersionTransition.Set() {
s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented)
return
}
}
// Migration: clear any day-TTL filer.conf entries this handler
// installed in older builds. Per-write TTL is now driven by the
// LifecycleTTLResolver constructed off the stored XML, so leaving a
// stale day-TTL entry under /buckets/<bucket>/ would double-stamp
// (volume server expires under the old rule) or contradict the new
// XML after a rule change. The add path is gone — this loop only
// shrinks the conf, never grows it.
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer.ClearBucketLifecycleDayTTLs(context.Background(), client, s3a.option.BucketsPath, bucket, s3a.getCollectionName(bucket))
}); err != nil {
glog.Errorf("PutBucketLifecycleConfigurationHandler clear legacy day-TTLs: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// The per-write TTL fast path stamps a volume TTL at write time that
// can't be taken back. If it's active on this bucket, warn when the new
// config removes or lengthens a rule: objects already written keep their
// baked-in TTL and won't be rescued by this change (unlike the default
// worker path, which re-evaluates the current rules each pass).
// Compute the reason before storing, but emit only after the store
// succeeds so a failed mutation never carries a warning for a change
// that was not applied.
var fastpathWarnReason string
if cfg, _ := s3a.getBucketConfig(bucket); cfg != nil && cfg.LifecycleTTL != nil {
fastpathWarnReason = fastpathConfigChangeLeavesStampedObjects(cfg.LifecycleXML, lifecycleXML)
}
if errCode := s3a.storeBucketLifecycleConfiguration(bucket, lifecycleXML, r.Header.Get(bucketLifecycleTransitionMinimumObjectSizeHeader)); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if fastpathWarnReason != "" {
glog.Warningf("PutBucketLifecycleConfigurationHandler %s: %s", bucket, fastpathWarnReason)
w.Header().Set(fastpathWarningHeader, fastpathWarnReason)
}
writeSuccessResponseEmpty(w, r)
}
// DeleteBucketLifecycleHandler Delete Bucket Lifecycle
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketLifecycle.html
func (s3a *S3ApiServer) DeleteBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("DeleteBucketLifecycleHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Same legacy day-TTL migration as the PUT handler.
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer.ClearBucketLifecycleDayTTLs(context.Background(), client, s3a.option.BucketsPath, bucket, s3a.getCollectionName(bucket))
}); err != nil {
glog.Errorf("DeleteBucketLifecycleHandler clear legacy day-TTLs: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// If the per-write TTL fast path is active, every previously-stamped
// object keeps its baked-in volume TTL after the config is removed —
// deleting the rules does not rescue them (unlike the default worker
// path). Compute the reason before clearing, but emit only after the
// clear succeeds so a failed mutation never carries a warning for a
// change that was not applied.
var fastpathWarnReason string
if cfg, _ := s3a.getBucketConfig(bucket); cfg != nil && cfg.LifecycleTTL != nil {
fastpathWarnReason = fastpathConfigChangeLeavesStampedObjects(cfg.LifecycleXML, nil)
}
if errCode := s3a.clearStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if fastpathWarnReason != "" {
glog.Warningf("DeleteBucketLifecycleHandler %s: %s", bucket, fastpathWarnReason)
w.Header().Set(fastpathWarningHeader, fastpathWarnReason)
}
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
// GetBucketLocationHandler Get bucket location
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html
func (s3a *S3ApiServer) GetBucketLocationHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3bucket.VerifyS3BucketName(bucket); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
return
}
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
writeSuccessResponseXML(w, r, CreateBucketConfiguration{})
}
// GetBucketRequestPaymentHandler Get bucket location
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketRequestPayment.html
func (s3a *S3ApiServer) GetBucketRequestPaymentHandler(w http.ResponseWriter, r *http.Request) {
writeSuccessResponseXML(w, r, RequestPaymentConfiguration{Payer: "BucketOwner"})
}
// PutBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketOwnershipControls.html
func (s3a *S3ApiServer) PutBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if r.Body == nil || r.Body == http.NoBody {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
var v s3.OwnershipControls
defer util_http.CloseRequest(r)
err := xmlutil.UnmarshalXML(&v, xml.NewDecoder(r.Body), "")
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
if len(v.Rules) != 1 || v.Rules[0] == nil || v.Rules[0].ObjectOwnership == nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
printOwnership := true
ownership := *v.Rules[0].ObjectOwnership
switch ownership {
case s3_constants.OwnershipObjectWriter:
case s3_constants.OwnershipBucketOwnerPreferred:
case s3_constants.OwnershipBucketOwnerEnforced:
printOwnership = false
default:
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// Persist even when it matches the implicit default, so a later delete has something to remove.
if errCode := s3a.setBucketOwnership(bucket, ownership); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if printOwnership {
result := &s3.PutBucketOwnershipControlsInput{
OwnershipControls: &v,
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, result)
} else {
writeSuccessResponseEmpty(w, r)
}
}
// GetBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketOwnershipControls.html
func (s3a *S3ApiServer) GetBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Get ownership using new bucket config system
ownership, errCode := s3a.getBucketOwnership(bucket)
if errCode == s3err.ErrNoSuchBucket {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
} else if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.OwnershipControlsNotFoundError)
return
}
result := &s3.PutBucketOwnershipControlsInput{
OwnershipControls: &s3.OwnershipControls{
Rules: []*s3.OwnershipControlsRule{
{
ObjectOwnership: &ownership,
},
},
},
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, result)
}
// DeleteBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketOwnershipControls.html
func (s3a *S3ApiServer) DeleteBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
bucketEntry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
_, ok := bucketEntry.Extended[s3_constants.ExtOwnershipKey]
if !ok {
s3err.WriteErrorResponse(w, r, s3err.OwnershipControlsNotFoundError)
return
}
delete(bucketEntry.Extended, s3_constants.ExtOwnershipKey)
err = s3a.updateEntry(s3a.option.BucketsPath, bucketEntry)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
emptyOwnershipControls := &s3.OwnershipControls{
Rules: []*s3.OwnershipControlsRule{},
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, emptyOwnershipControls)
}
// GetBucketVersioningHandler Get Bucket Versioning status
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketVersioning.html
func (s3a *S3ApiServer) GetBucketVersioningHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketVersioning %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Get versioning status using new bucket config system
versioningStatus, errCode := s3a.getBucketVersioningStatus(bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// AWS S3 behavior: If versioning was never configured, don't return Status field
var response *s3.PutBucketVersioningInput
if versioningStatus == "" {
// No versioning configuration - return empty response (no Status field)
response = &s3.PutBucketVersioningInput{
VersioningConfiguration: &s3.VersioningConfiguration{},
}
} else {
// Versioning was explicitly configured - return the status
response = &s3.PutBucketVersioningInput{
VersioningConfiguration: &s3.VersioningConfiguration{
Status: aws.String(versioningStatus),
},
}
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, response)
}
// PutBucketVersioningHandler Put bucket Versioning
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketVersioning.html
func (s3a *S3ApiServer) PutBucketVersioningHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketVersioning %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
if r.Body == nil || r.Body == http.NoBody {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
var versioningConfig s3.VersioningConfiguration
defer util_http.CloseRequest(r)
err := xmlutil.UnmarshalXML(&versioningConfig, xml.NewDecoder(r.Body), "")
if err != nil {
glog.Warningf("PutBucketVersioningHandler xml decode: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
if versioningConfig.Status == nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
status := *versioningConfig.Status
if status != s3_constants.VersioningEnabled && status != s3_constants.VersioningSuspended {
glog.Errorf("PutBucketVersioningHandler: invalid status '%s' for bucket %s", status, bucket)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// Check if trying to suspend versioning on a bucket with object lock enabled
if status == s3_constants.VersioningSuspended {
// Get bucket configuration to check for object lock
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode == s3err.ErrNone && bucketConfig.ObjectLockConfig != nil {
// Object lock is enabled, cannot suspend versioning
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketState)
return
}
}
// Update bucket versioning configuration using new bucket config system
if errCode := s3a.setBucketVersioningStatus(bucket, status); errCode != s3err.ErrNone {
glog.Errorf("PutBucketVersioningHandler save config: bucket=%s, status='%s', errCode=%d", bucket, status, errCode)
s3err.WriteErrorResponse(w, r, errCode)
return
}
writeSuccessResponseEmpty(w, r)
}