Files
seaweedfs/weed/s3api/s3api_bucket_config.go
T
Chris LuandClaude Opus 5.5 b0c6297041 s3api: per-bucket Block Public Access (BlockPublicAcls, IgnorePublicAcls) (#12381)
* s3api: store and serve a bucket's PublicAccessBlock configuration

PutPublicAccessBlock, GetPublicAccessBlock and DeletePublicAccessBlock
answered NotImplemented. Store the configuration in the bucket entry's
extended attributes next to the ownership controls, carry it in the
cached BucketConfig, and answer Get on an unset bucket with
NoSuchPublicAccessBlockConfiguration like S3 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: refuse public ACLs when a bucket blocks them

With BlockPublicAcls set, a PutObject, CopyObject, CreateMultipartUpload,
POST Object, PutObjectAcl or PutBucketAcl whose canned ACL or grants name
the AllUsers or AuthenticatedUsers group is refused with AccessDenied.
Private and account grants keep working, so clients that send
x-amz-acl: private on every upload are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: ignore public ACL grants for anonymous reads under IgnorePublicAcls

A bucket with IgnorePublicAcls no longer lets an AllUsers grant on the
bucket or on an object answer an anonymous read or listing. Bucket
policies and identity permissions are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject PublicAccessBlock bodies with unknown elements

A misspelled element such as BlockPublicAcl was ignored, so the request
succeeded and stored the flag as false. Answer MalformedXML instead, and
store the configuration without the request's namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: fail closed when a bucket's public access block cannot be read

A stored PublicAccessBlock that does not parse now blocks and ignores
public ACLs instead of acting as if none were set. PutBucketAcl and POST
Object return the bucket lookup error rather than skipping the
BlockPublicAcls check when the bucket config cannot be loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test BlockPublicAcls on copy, multipart initiate and a PUT-then-GET

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: keep stored public access block bytes on unrelated bucket updates

An unreadable stored configuration was replaced with the in-memory
all-true fallback whenever another bucket setting was updated, losing
the original value. Write the key only when the update changes the
public access block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:20 -08:00

1148 lines
41 KiB
Go

package s3api
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go/service/s3"
lru "github.com/hashicorp/golang-lru/v2"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/kms"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/cors"
"github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// BucketConfig represents cached bucket configuration. Only fields parsed
// from the bucket's filer entry are retained — not the entry itself — so a
// cached config stays small; the write path (updateBucketConfig) re-reads
// the entry from the filer.
type BucketConfig struct {
Name string
Versioning string // "Enabled", "Suspended", or ""
Ownership string
ACL []byte
Owner string
IdentityId string // identity that created the bucket
Crtime int64 // bucket creation time, unix seconds
IsPublicRead bool // Cached flag to avoid JSON parsing on every request
CORS *cors.CORSConfiguration
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
BucketPolicy *policy_engine.PolicyDocument // Cached bucket policy for performance
// LifecycleTTL answers "what volume TTL should this PutObject get?"
// using only fast-path-safe predicates (prefix + size; tags excluded
// because they're mutable post-PUT). nil = no TTL applies (fast path
// not enabled on the bucket, no lifecycle config, versioned bucket,
// or only ineligible rules). The fast path is opt-in per bucket. The
// lifecycle worker reads bucket entries directly off the meta-log
// rather than this cache.
LifecycleTTL *LifecycleTTLResolver
// LifecycleXML is the stored lifecycle configuration as served by
// GetBucketLifecycle, with its companion transition minimum object size.
LifecycleXML []byte
LifecycleTransitionMinSize string
Tags map[string]string // parsed from entry content
Encryption *s3_pb.EncryptionConfiguration // parsed from entry content
DeclaredEncryption *s3_pb.EncryptionConfiguration // declared by a table bucket's record
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
LastModified time.Time
PublicAccessBlock *PublicAccessBlockConfiguration
}
// BucketKMSCache represents per-bucket KMS key caching for SSE-KMS operations
// This provides better isolation and automatic cleanup compared to global caching
type BucketKMSCache struct {
cache map[string]*BucketKMSCacheEntry // Key: contextHash, Value: cached data key
mutex sync.RWMutex
bucket string // Bucket name for logging/debugging
lastTTL time.Duration // TTL used for cache entries (typically 1 hour)
}
// BucketKMSCacheEntry represents a single cached KMS data key
type BucketKMSCacheEntry struct {
DataKey interface{} // Could be *kms.GenerateDataKeyResponse or similar
ExpiresAt time.Time
KeyID string
ContextHash string // Hash of encryption context for cache validation
}
// NewBucketKMSCache creates a new per-bucket KMS key cache
func NewBucketKMSCache(bucketName string, ttl time.Duration) *BucketKMSCache {
return &BucketKMSCache{
cache: make(map[string]*BucketKMSCacheEntry),
bucket: bucketName,
lastTTL: ttl,
}
}
// Get retrieves a cached KMS data key if it exists and hasn't expired
func (bkc *BucketKMSCache) Get(contextHash string) (*BucketKMSCacheEntry, bool) {
if bkc == nil {
return nil, false
}
bkc.mutex.RLock()
defer bkc.mutex.RUnlock()
entry, exists := bkc.cache[contextHash]
if !exists {
return nil, false
}
// Check if entry has expired
if time.Now().After(entry.ExpiresAt) {
return nil, false
}
return entry, true
}
// Set stores a KMS data key in the cache
func (bkc *BucketKMSCache) Set(contextHash, keyID string, dataKey interface{}, ttl time.Duration) {
if bkc == nil {
return
}
bkc.mutex.Lock()
defer bkc.mutex.Unlock()
bkc.cache[contextHash] = &BucketKMSCacheEntry{
DataKey: dataKey,
ExpiresAt: time.Now().Add(ttl),
KeyID: keyID,
ContextHash: contextHash,
}
bkc.lastTTL = ttl
}
// CleanupExpired removes expired entries from the cache
func (bkc *BucketKMSCache) CleanupExpired() int {
if bkc == nil {
return 0
}
bkc.mutex.Lock()
defer bkc.mutex.Unlock()
now := time.Now()
expiredCount := 0
for key, entry := range bkc.cache {
if now.After(entry.ExpiresAt) {
// Clear sensitive data before removing from cache
bkc.clearSensitiveData(entry)
delete(bkc.cache, key)
expiredCount++
}
}
return expiredCount
}
// Size returns the current number of cached entries
func (bkc *BucketKMSCache) Size() int {
if bkc == nil {
return 0
}
bkc.mutex.RLock()
defer bkc.mutex.RUnlock()
return len(bkc.cache)
}
// clearSensitiveData securely clears sensitive data from a cache entry
func (bkc *BucketKMSCache) clearSensitiveData(entry *BucketKMSCacheEntry) {
if dataKeyResp, ok := entry.DataKey.(*kms.GenerateDataKeyResponse); ok {
// Zero out the plaintext data key to prevent it from lingering in memory
if dataKeyResp.Plaintext != nil {
for i := range dataKeyResp.Plaintext {
dataKeyResp.Plaintext[i] = 0
}
dataKeyResp.Plaintext = nil
}
}
}
// Clear clears all cached KMS entries, securely zeroing sensitive data first
func (bkc *BucketKMSCache) Clear() {
if bkc == nil {
return
}
bkc.mutex.Lock()
defer bkc.mutex.Unlock()
// Clear sensitive data from all entries before deletion
for _, entry := range bkc.cache {
bkc.clearSensitiveData(entry)
}
// Clear the cache map
bkc.cache = make(map[string]*BucketKMSCacheEntry)
}
// bucketCacheCapacity bounds the per-bucket caches (bucket config cache,
// bucket registry, and their negative caches). Only the hot working set
// stays resident; evicted buckets reload from the filer on next access.
const bucketCacheCapacity = 65536
// BucketConfigCache provides caching for bucket configurations
// Cache entries are automatically updated/invalidated through metadata subscription events,
// so TTL serves as a safety fallback rather than the primary consistency mechanism.
// Both caches are size-capped LRUs so a gateway serving millions of buckets
// only keeps its hot working set resident.
type BucketConfigCache struct {
cache *lru.Cache[string, *BucketConfig]
negativeCache *lru.Cache[string, time.Time] // Cache for non-existent buckets
ttl time.Duration // Safety fallback TTL; real-time consistency maintained via events
negativeTTL time.Duration // TTL for negative cache entries
}
// BucketMetadata represents the complete metadata for a bucket
type BucketMetadata struct {
Tags map[string]string `json:"tags,omitempty"`
CORS *cors.CORSConfiguration `json:"cors,omitempty"`
Encryption *s3_pb.EncryptionConfiguration `json:"encryption,omitempty"`
// Future extensions can be added here:
// Versioning *s3_pb.VersioningConfiguration `json:"versioning,omitempty"`
// Lifecycle *s3_pb.LifecycleConfiguration `json:"lifecycle,omitempty"`
// Notification *s3_pb.NotificationConfiguration `json:"notification,omitempty"`
// Replication *s3_pb.ReplicationConfiguration `json:"replication,omitempty"`
// Analytics *s3_pb.AnalyticsConfiguration `json:"analytics,omitempty"`
// Logging *s3_pb.LoggingConfiguration `json:"logging,omitempty"`
// Website *s3_pb.WebsiteConfiguration `json:"website,omitempty"`
// RequestPayer *s3_pb.RequestPayerConfiguration `json:"requestPayer,omitempty"`
// PublicAccess *s3_pb.PublicAccessConfiguration `json:"publicAccess,omitempty"`
}
// NewBucketMetadata creates a new BucketMetadata with default values
func NewBucketMetadata() *BucketMetadata {
return &BucketMetadata{
Tags: make(map[string]string),
}
}
// IsEmpty returns true if the metadata has no configuration set
func (bm *BucketMetadata) IsEmpty() bool {
return len(bm.Tags) == 0 && bm.CORS == nil && bm.Encryption == nil
}
// HasEncryption returns true if bucket has encryption configuration
func (bm *BucketMetadata) HasEncryption() bool {
return bm.Encryption != nil
}
// HasCORS returns true if bucket has CORS configuration
func (bm *BucketMetadata) HasCORS() bool {
return bm.CORS != nil
}
// HasTags returns true if bucket has tags
func (bm *BucketMetadata) HasTags() bool {
return len(bm.Tags) > 0
}
// NewBucketConfigCache creates a new bucket configuration cache
// TTL can be set to a longer duration since cache consistency is maintained
// through real-time metadata subscription events rather than TTL expiration
func NewBucketConfigCache(ttl time.Duration) *BucketConfigCache {
negativeTTL := ttl / 4 // Negative cache TTL is shorter than positive cache
if negativeTTL < 30*time.Second {
negativeTTL = 30 * time.Second // Minimum 30 seconds for negative cache
}
cache, _ := lru.New[string, *BucketConfig](bucketCacheCapacity)
negativeCache, _ := lru.New[string, time.Time](bucketCacheCapacity)
return &BucketConfigCache{
cache: cache,
negativeCache: negativeCache,
ttl: ttl,
negativeTTL: negativeTTL,
}
}
// Get retrieves bucket configuration from cache
func (bcc *BucketConfigCache) Get(bucket string) (*BucketConfig, bool) {
config, exists := bcc.cache.Get(bucket)
if !exists {
return nil, false
}
// Check if cache entry is expired (safety fallback; entries are normally updated via events)
if time.Since(config.LastModified) > bcc.ttl {
return nil, false
}
return config, true
}
// Set stores bucket configuration in cache
func (bcc *BucketConfigCache) Set(bucket string, config *BucketConfig) {
config.LastModified = time.Now()
bcc.cache.Add(bucket, config)
}
// Contains reports whether the bucket is resident in the cache, regardless of TTL
func (bcc *BucketConfigCache) Contains(bucket string) bool {
return bcc.cache.Contains(bucket)
}
// Remove removes bucket configuration from cache
func (bcc *BucketConfigCache) Remove(bucket string) {
bcc.cache.Remove(bucket)
}
// Clear clears all cached configurations
func (bcc *BucketConfigCache) Clear() {
bcc.cache.Purge()
bcc.negativeCache.Purge()
}
// IsNegativelyCached checks if a bucket is in the negative cache (doesn't exist)
func (bcc *BucketConfigCache) IsNegativelyCached(bucket string) bool {
if cachedTime, exists := bcc.negativeCache.Get(bucket); exists {
// Check if the negative cache entry is still valid
if time.Since(cachedTime) < bcc.negativeTTL {
return true
}
// Entry expired, remove it
bcc.negativeCache.Remove(bucket)
}
return false
}
// SetNegativeCache marks a bucket as non-existent in the negative cache
func (bcc *BucketConfigCache) SetNegativeCache(bucket string) {
bcc.negativeCache.Add(bucket, time.Now())
}
// RemoveNegativeCache removes a bucket from the negative cache
func (bcc *BucketConfigCache) RemoveNegativeCache(bucket string) {
bcc.negativeCache.Remove(bucket)
}
// loadBucketPolicyFromExtended loads and parses bucket policy from entry extended attributes
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy_engine.PolicyDocument {
if entry.Extended == nil {
return nil
}
policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY]
if !exists || len(policyJSON) == 0 {
glog.V(4).Infof("loadBucketPolicyFromExtended: no bucket policy found for bucket %s", bucket)
return nil
}
var policyDoc policy_engine.PolicyDocument
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
glog.Errorf("loadBucketPolicyFromExtended: failed to parse bucket policy for %s: %v", bucket, err)
return nil
}
glog.V(3).Infof("loadBucketPolicyFromExtended: loaded bucket policy for bucket %s", bucket)
return &policyDoc
}
// getBucketConfig retrieves bucket configuration with caching
func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.ErrorCode) {
// Check negative cache first
if s3a.bucketConfigCache.IsNegativelyCached(bucket) {
return nil, s3err.ErrNoSuchBucket
}
// Try positive cache
if config, found := s3a.bucketConfigCache.Get(bucket); found {
return config, s3err.ErrNone
}
// Try to get from filer
entry, err := s3a.getBucketEntry(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
// Bucket doesn't exist - set negative cache
s3a.bucketConfigCache.SetNegativeCache(bucket)
return nil, s3err.ErrNoSuchBucket
}
glog.Errorf("getBucketConfig: failed to get bucket entry for %s: %v", bucket, err)
return nil, s3err.ErrInternalError
}
config := s3a.newBucketConfigFromEntry(bucket, entry)
// A cold load is the first time this gateway learns the bucket's policy
// exists, and the metadata subscription only mirrors changes - a policy
// that predates the IAM integration would otherwise never reach the
// advanced-IAM mirror and its grants would not bind on the IAM path.
// Synchronous: the IAM auth path primes the bucket through here before
// evaluating the mirror, so the backfill has to land first - a one-time
// cost on the load that discovers the policy. Raw entry bytes, not the
// parsed document, so the backfill can byte-compare against a later
// entry read when it reconciles.
if policyJSON := entry.Extended[BUCKET_POLICY_METADATA_KEY]; len(policyJSON) > 0 && config.BucketPolicy != nil && s3a.bucketPolicyIAMManager() != nil {
s3a.ensureBucketPolicyInIAM(bucket, policyJSON)
}
// Cache the result
s3a.bucketConfigCache.Set(bucket, config)
return config, s3err.ErrNone
}
// newBucketConfigFromEntry builds a BucketConfig from the bucket's filer
// entry, parsing Entry.Extended / Entry.Content into the cached fields
// (versioning flag, ACL, owner, object lock, bucket policy, CORS, tags,
// encryption, lifecycle TTL resolver). It is the single source of truth for
// that mapping; the read path (getBucketConfig), the write path
// (updateBucketConfig), and the meta-log subscription cache refresher all
// funnel through here so a missed field can't silently keep stale data —
// e.g. a stale LifecycleTTL after a Put/DeleteBucketLifecycle would keep
// stamping the old policy's irreversible volume TTL onto new writes.
func (s3a *S3ApiServer) newBucketConfigFromEntry(bucket string, entry *filer_pb.Entry) *BucketConfig {
config := &BucketConfig{
Name: bucket,
}
if entry == nil {
return config
}
if entry.Attributes != nil {
config.Crtime = entry.Attributes.Crtime
}
if entry.Extended != nil {
if versioning, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists {
config.Versioning = string(versioning)
}
if ownership, exists := entry.Extended[s3_constants.ExtOwnershipKey]; exists {
config.Ownership = string(ownership)
}
if data, exists := entry.Extended[s3_constants.ExtPublicAccessBlockKey]; exists && len(data) > 0 {
if pab, err := parsePublicAccessBlock(data); err == nil {
config.PublicAccessBlock = pab
} else {
glog.Errorf("newBucketConfigFromEntry: bucket %s public access block, blocking all public access: %v", bucket, err)
config.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: true, RestrictPublicBuckets: true}
}
}
if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists {
config.ACL = acl
// Parse ACL once and cache public-read status.
config.IsPublicRead = !config.PublicAccessBlock.ignoresPublicAcls() && parseAndCachePublicReadStatus(acl)
}
config.Owner = bucketOwnerAccountId(s3a.iam, entry)
if identityId, exists := entry.Extended[s3_constants.AmzIdentityId]; exists {
config.IdentityId = string(identityId)
}
if objectLockConfig, found := LoadObjectLockConfigurationFromExtended(entry); found {
config.ObjectLockConfig = objectLockConfig
}
config.BucketPolicy = loadBucketPolicyFromExtended(entry, bucket)
if algorithm := s3tables.DeclaredBucketEncryption(entry); algorithm != "" {
config.DeclaredEncryption = &s3_pb.EncryptionConfiguration{SseAlgorithm: algorithm}
}
if lifecycleXML, exists := entry.Extended[bucketLifecycleConfigurationXMLKey]; exists && len(lifecycleXML) > 0 {
config.LifecycleXML = lifecycleXML
config.LifecycleTransitionMinSize = string(entry.Extended[bucketLifecycleTransitionMinimumObjectSizeKey])
}
// The lifecycle TTL fast path is opt-in per bucket: a volume TTL
// stamped at write time can't honor a later policy change (rule
// removed or lengthened) the way worker-driven expiration does,
// so it stays off unless explicitly enabled. Skip the XML parse
// entirely when off. nil on parse error so the PUT path falls
// through to "no TTL" rather than rejecting writes.
if bytes.Equal(entry.Extended[s3_constants.ExtLifecycleTtlFastPathKey], []byte("true")) && len(config.LifecycleXML) > 0 {
if rules, err := lifecycle_xml.ParseCanonical(config.LifecycleXML); err == nil {
// Object Lock requires versioning, so an ObjectLockConfig
// implies the bucket is versioned even when the explicit
// Versioning header was never written. BucketIsVersioned
// in this file uses the same OR — keep them aligned.
versioned := config.Versioning == s3_constants.VersioningEnabled ||
config.Versioning == s3_constants.VersioningSuspended ||
config.ObjectLockConfig != nil
config.LifecycleTTL = NewLifecycleTTLResolver(rules, versioned)
} else {
glog.V(1).Infof("newBucketConfigFromEntry: bucket %s lifecycle xml parse: %v", bucket, err)
}
}
}
// Sync bucket policy to the policy engine for evaluation.
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
// Parse tags, CORS, and encryption from the entry's Content field.
if len(entry.Content) > 0 {
var protoMetadata s3_pb.BucketMetadata
if err := proto.Unmarshal(entry.Content, &protoMetadata); err != nil {
glog.Errorf("newBucketConfigFromEntry: failed to unmarshal metadata for bucket %s: %v", bucket, err)
} else {
config.Tags = protoMetadata.Tags
config.CORS = corsConfigFromProto(protoMetadata.Cors)
config.Encryption = protoMetadata.Encryption
}
}
return config
}
// updateBucketConfig updates bucket configuration and invalidates cache.
// It reads the bucket entry fresh from the filer so the patch diff is
// computed against current state rather than a possibly stale cached copy.
func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketConfig) error) s3err.ErrorCode {
entry, err := s3a.getBucketEntry(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.SetNegativeCache(bucket)
}
return s3err.ErrNoSuchBucket
}
glog.Errorf("updateBucketConfig: failed to get bucket entry for %s: %v", bucket, err)
return s3err.ErrInternalError
}
config := s3a.newBucketConfigFromEntry(bucket, entry)
loadedPAB := config.PublicAccessBlock
loadedPABXML, _ := xml.Marshal(loadedPAB)
// Apply update function
if err := updateFn(config); err != nil {
glog.Errorf("updateBucketConfig: update function failed for bucket %s: %v", bucket, err)
return s3err.ErrInternalError
}
oldExt := entry.GetExtended()
newExt := make(map[string][]byte, len(oldExt))
for k, v := range oldExt {
newExt[k] = v
}
if err := applyBucketConfigToExtended(config, newExt); err != nil {
glog.Errorf("updateBucketConfig: failed to serialize config for bucket %s: %v", bucket, err)
return s3err.ErrInternalError
}
// Keep the stored bytes, readable or not, unless updateFn changed the setting.
if loadedPAB != nil && config.PublicAccessBlock == loadedPAB && bytes.Equal(newExt[s3_constants.ExtPublicAccessBlockKey], loadedPABXML) {
newExt[s3_constants.ExtPublicAccessBlockKey] = oldExt[s3_constants.ExtPublicAccessBlockKey]
}
// Patch only the changed/removed extended keys, leaving Entry.content
// untouched so a concurrent content write (e.g. encryption) is preserved.
set := make(map[string][]byte)
for k, v := range newExt {
if ov, ok := oldExt[k]; !ok || !bytes.Equal(ov, v) {
set[k] = v
}
}
var del []string
for k := range oldExt {
if _, ok := newExt[k]; !ok {
del = append(del, k)
}
}
glog.V(3).Infof("updateBucketConfig: patching %d/%d extended keys for bucket %s", len(set), len(del), bucket)
if err := s3a.patchBucketEntry(bucket, &filer_pb.ObjectMutation{SetExtended: set, DeleteExtended: del}); err != nil {
if isBucketNotFoundErr(err) {
return s3err.ErrNoSuchBucket
}
glog.Errorf("updateBucketConfig: failed to patch bucket entry for %s: %v", bucket, err)
return s3err.ErrInternalError
}
// Invalidate rather than cache the updated config: it may be stale relative
// to a concurrent write. The next read re-fetches the merged entry.
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.Remove(bucket)
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
return s3err.ErrNone
}
// applyBucketConfigToExtended maps the persisted BucketConfig fields onto an
// extended-attribute map; keys not derived from BucketConfig are left alone.
func applyBucketConfigToExtended(config *BucketConfig, ext map[string][]byte) error {
setOrDelete := func(key string, value []byte) {
if len(value) > 0 {
ext[key] = value
} else {
delete(ext, key)
}
}
setOrDelete(s3_constants.ExtVersioningKey, []byte(config.Versioning))
setOrDelete(s3_constants.ExtOwnershipKey, []byte(config.Ownership))
if config.PublicAccessBlock != nil {
data, err := xml.Marshal(config.PublicAccessBlock)
if err != nil {
return err
}
ext[s3_constants.ExtPublicAccessBlockKey] = data
} else {
delete(ext, s3_constants.ExtPublicAccessBlockKey)
}
setOrDelete(s3_constants.ExtAmzAclKey, config.ACL)
setOrDelete(s3_constants.ExtAmzOwnerKey, []byte(config.Owner))
setOrDelete(bucketLifecycleConfigurationXMLKey, config.LifecycleXML)
if len(config.LifecycleXML) > 0 && config.LifecycleTransitionMinSize != "" {
ext[bucketLifecycleTransitionMinimumObjectSizeKey] = []byte(config.LifecycleTransitionMinSize)
} else {
delete(ext, bucketLifecycleTransitionMinimumObjectSizeKey)
}
// Object Lock, once enabled, is never deleted; a nil config leaves any
// existing keys untouched.
if config.ObjectLockConfig != nil {
return StoreObjectLockConfigurationInExtended(&filer_pb.Entry{Extended: ext}, config.ObjectLockConfig)
}
return nil
}
// patchBucketEntry applies a field-level PATCH_EXTENDED mutation to the bucket's
// entry via ObjectTransaction, routed to the bucket's owner filer so its per-path
// lock serializes concurrent config writes cluster-wide rather than racing
// whole-entry rewrites. The transaction requires the bucket entry to exist: a
// PATCH on an absent entry is otherwise a silent no-op, which would acknowledge
// a config write that never landed. An empty mutation still ships so the
// existence condition runs on its own.
func (s3a *S3ApiServer) patchBucketEntry(bucket string, m *filer_pb.ObjectMutation) error {
dir := s3a.option.BucketsPath
bucketPath := dir + "/" + bucket
req := &filer_pb.ObjectTransactionRequest{
LockKey: bucketPath,
RouteKey: objectWriteRouteKeyPrefix + bucketPath,
Condition: clause(filer_pb.WriteCondition_IF_EXISTS),
}
if m != nil && (len(m.SetExtended) > 0 || len(m.DeleteExtended) > 0 || m.SetContent) {
m.Type = filer_pb.ObjectMutation_PATCH_EXTENDED
m.Directory = dir
m.Name = bucket
req.Mutations = []*filer_pb.ObjectMutation{m}
}
respErr := func(resp *filer_pb.ObjectTransactionResponse, err error) error {
if err != nil {
return err
}
// The only precondition on this request is the bucket's own existence,
// so a failed condition is a missing bucket, not a policy violation.
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED || resp.Error == "precondition failed" {
return fmt.Errorf("patch bucket %s: %w", bucket, filer_pb.ErrNotFound)
}
if resp.Error != "" {
return fmt.Errorf("patch bucket %s: %s", bucket, resp.Error)
}
return nil
}
if s3a.objectWriteLockClient != nil {
if owner := s3a.objectWriteLockClient.PrimaryForKey(req.RouteKey); owner != "" {
return respErr(s3a.objectTxnOnFiler(owner, req))
}
}
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return respErr(client.ObjectTransaction(context.Background(), req))
})
}
// isVersioningEnabled checks if versioning is enabled for a bucket (with caching)
func (s3a *S3ApiServer) isVersioningEnabled(bucket string) (bool, error) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
if errCode == s3err.ErrNoSuchBucket {
return false, filer_pb.ErrNotFound
}
return false, fmt.Errorf("failed to get bucket config: %v", errCode)
}
// Versioning is enabled if explicitly set to "Enabled" OR if object lock is enabled
// (since object lock requires versioning to be enabled)
return config.Versioning == s3_constants.VersioningEnabled || config.ObjectLockConfig != nil, nil
}
// isVersioningConfigured checks if versioning has been configured (either Enabled or Suspended)
func (s3a *S3ApiServer) isVersioningConfigured(bucket string) (bool, error) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
if errCode == s3err.ErrNoSuchBucket {
return false, filer_pb.ErrNotFound
}
return false, fmt.Errorf("failed to get bucket config: %v", errCode)
}
// Versioning is configured if explicitly set to either "Enabled" or "Suspended"
// OR if object lock is enabled (which forces versioning)
return config.Versioning != "" || config.ObjectLockConfig != nil, nil
}
// checkVersionIdConfigured reports ErrInvalidArgument when versionId names a
// specific version on a bucket that has never had versioning configured. The
// implicit "null" version of pre-versioning objects stays addressable.
func (s3a *S3ApiServer) checkVersionIdConfigured(bucket, versionId string) s3err.ErrorCode {
if versionId == "" || versionId == "null" {
return s3err.ErrNone
}
configured, err := s3a.isVersioningConfigured(bucket)
if err != nil {
// Missing buckets and lookup failures are answered by the handler path.
return s3err.ErrNone
}
if !configured {
return s3err.ErrInvalidArgument
}
return s3err.ErrNone
}
// isObjectLockEnabled checks if Object Lock is enabled for a bucket (with caching)
func (s3a *S3ApiServer) isObjectLockEnabled(bucket string) (bool, error) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
if errCode == s3err.ErrNoSuchBucket {
return false, filer_pb.ErrNotFound
}
return false, fmt.Errorf("failed to get bucket config: %v", errCode)
}
return config.ObjectLockConfig != nil, nil
}
// getVersioningState returns the detailed versioning state for a bucket
func (s3a *S3ApiServer) getVersioningState(bucket string) (string, error) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
if errCode == s3err.ErrNoSuchBucket {
// Signal to callers that the bucket does not exist so they can
// decide whether to auto-create it (e.g., in PUT handlers).
return "", filer_pb.ErrNotFound
}
glog.Errorf("getVersioningState: failed to get bucket config for %s: %v", bucket, errCode)
return "", fmt.Errorf("failed to get bucket config: %v", errCode)
}
// If object lock is enabled, versioning must be enabled regardless of explicit setting
if config.ObjectLockConfig != nil {
return s3_constants.VersioningEnabled, nil
}
// Return the explicit versioning status (empty string means never configured)
return config.Versioning, nil
}
// getBucketVersioningStatus returns the versioning status for a bucket
func (s3a *S3ApiServer) getBucketVersioningStatus(bucket string) (string, s3err.ErrorCode) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
return "", errCode
}
// Return exactly what's stored - empty string means versioning was never configured
// This matches AWS S3 behavior where new buckets have no Status field in GetBucketVersioning response
return config.Versioning, s3err.ErrNone
}
// setBucketVersioningStatus sets the versioning status for a bucket
func (s3a *S3ApiServer) setBucketVersioningStatus(bucket, status string) s3err.ErrorCode {
errCode := s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
config.Versioning = status
return nil
})
return errCode
}
// getBucketOwnership returns the ownership setting for a bucket
func (s3a *S3ApiServer) getBucketOwnership(bucket string) (string, s3err.ErrorCode) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
return "", errCode
}
return s3_constants.EffectiveOwnership(config.Ownership), s3err.ErrNone
}
// setBucketOwnership sets the ownership setting for a bucket
func (s3a *S3ApiServer) setBucketOwnership(bucket, ownership string) s3err.ErrorCode {
return s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
config.Ownership = ownership
return nil
})
}
// getCORSConfiguration retrieves CORS configuration with caching
func (s3a *S3ApiServer) getCORSConfiguration(bucket string) (*cors.CORSConfiguration, s3err.ErrorCode) {
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
return nil, errCode
}
return config.CORS, s3err.ErrNone
}
// updateCORSConfiguration updates the CORS configuration for a bucket
func (s3a *S3ApiServer) updateCORSConfiguration(bucket string, corsConfig *cors.CORSConfiguration) s3err.ErrorCode {
// Update using structured API
// Note: UpdateBucketCORS -> UpdateBucketMetadata -> setBucketMetadata
// already invalidates the cache synchronously after successful update
err := s3a.UpdateBucketCORS(bucket, corsConfig)
if err != nil {
if isBucketNotFoundErr(err) {
return s3err.ErrNoSuchBucket
}
glog.Errorf("updateCORSConfiguration: failed to update CORS config for bucket %s: %v", bucket, err)
return s3err.ErrInternalError
}
return s3err.ErrNone
}
// removeCORSConfiguration removes the CORS configuration for a bucket
func (s3a *S3ApiServer) removeCORSConfiguration(bucket string) s3err.ErrorCode {
// Update using structured API
// Note: ClearBucketCORS -> UpdateBucketMetadata -> setBucketMetadata
// already invalidates the cache synchronously after successful update
err := s3a.ClearBucketCORS(bucket)
if err != nil {
if isBucketNotFoundErr(err) {
return s3err.ErrNoSuchBucket
}
glog.Errorf("removeCORSConfiguration: failed to remove CORS config for bucket %s: %v", bucket, err)
return s3err.ErrInternalError
}
return s3err.ErrNone
}
// Conversion functions between CORS types and protobuf types
// corsRuleToProto converts a CORS rule to protobuf format
func corsRuleToProto(rule cors.CORSRule) *s3_pb.CORSRule {
return &s3_pb.CORSRule{
AllowedHeaders: rule.AllowedHeaders,
AllowedMethods: rule.AllowedMethods,
AllowedOrigins: rule.AllowedOrigins,
ExposeHeaders: rule.ExposeHeaders,
MaxAgeSeconds: int32(getMaxAgeSecondsValue(rule.MaxAgeSeconds)),
Id: rule.ID,
}
}
// corsRuleFromProto converts a protobuf CORS rule to standard format
func corsRuleFromProto(protoRule *s3_pb.CORSRule) cors.CORSRule {
var maxAge *int
// Always create the pointer if MaxAgeSeconds is >= 0
// This prevents nil pointer dereferences in tests and matches AWS behavior
if protoRule.MaxAgeSeconds >= 0 {
age := int(protoRule.MaxAgeSeconds)
maxAge = &age
}
// Only leave maxAge as nil if MaxAgeSeconds was explicitly set to a negative value
return cors.CORSRule{
AllowedHeaders: protoRule.AllowedHeaders,
AllowedMethods: protoRule.AllowedMethods,
AllowedOrigins: protoRule.AllowedOrigins,
ExposeHeaders: protoRule.ExposeHeaders,
MaxAgeSeconds: maxAge,
ID: protoRule.Id,
}
}
// corsConfigToProto converts CORS configuration to protobuf format
func corsConfigToProto(config *cors.CORSConfiguration) *s3_pb.CORSConfiguration {
if config == nil {
return nil
}
protoRules := make([]*s3_pb.CORSRule, len(config.CORSRules))
for i, rule := range config.CORSRules {
protoRules[i] = corsRuleToProto(rule)
}
return &s3_pb.CORSConfiguration{
CorsRules: protoRules,
}
}
// corsConfigFromProto converts protobuf CORS configuration to standard format
func corsConfigFromProto(protoConfig *s3_pb.CORSConfiguration) *cors.CORSConfiguration {
if protoConfig == nil {
return nil
}
rules := make([]cors.CORSRule, len(protoConfig.CorsRules))
for i, protoRule := range protoConfig.CorsRules {
rules[i] = corsRuleFromProto(protoRule)
}
return &cors.CORSConfiguration{
CORSRules: rules,
}
}
// getMaxAgeSecondsValue safely extracts max age seconds value; -1 stores an unset MaxAgeSeconds
func getMaxAgeSecondsValue(maxAge *int) int {
if maxAge == nil {
return -1
}
return *maxAge
}
// parseAndCachePublicReadStatus parses the ACL and caches the public-read status
func parseAndCachePublicReadStatus(acl []byte) bool {
var grants []*s3.Grant
if err := json.Unmarshal(acl, &grants); err != nil {
return false
}
// Check if any grant gives read permission to "AllUsers" group
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
}
// getBucketMetadata retrieves bucket metadata as a structured object with caching
func (s3a *S3ApiServer) getBucketMetadata(bucket string) (*BucketMetadata, error) {
if s3a.bucketConfigCache != nil {
// Check negative cache first
if s3a.bucketConfigCache.IsNegativelyCached(bucket) {
return nil, fmt.Errorf("bucket directory not found %s", bucket)
}
// Build from the cached parsed config; copy the tags so callers
// can't mutate the cached map.
if config, found := s3a.bucketConfigCache.Get(bucket); found {
metadata := NewBucketMetadata()
for k, v := range config.Tags {
metadata.Tags[k] = v
}
metadata.CORS = config.CORS
metadata.Encryption = config.Encryption
return metadata, nil
}
}
// Load directly from filer
return s3a.loadBucketMetadataFromFiler(bucket)
}
// loadBucketMetadataFromFiler loads bucket metadata directly from the filer
func (s3a *S3ApiServer) loadBucketMetadataFromFiler(bucket string) (*BucketMetadata, error) {
// Validate bucket name to prevent path traversal attacks
if bucket == "" || strings.Contains(bucket, "/") || strings.Contains(bucket, "\\") ||
strings.Contains(bucket, "..") || strings.Contains(bucket, "~") {
return nil, fmt.Errorf("invalid bucket name: %s", bucket)
}
// Clean the bucket name further to prevent any potential path traversal
bucket = filepath.Clean(bucket)
if bucket == "." || bucket == ".." {
return nil, fmt.Errorf("invalid bucket name: %s", bucket)
}
// Get bucket directory entry to access its content
entry, err := s3a.getBucketEntry(bucket)
if err != nil {
// Check if this is a "not found" error
if errors.Is(err, filer_pb.ErrNotFound) {
// Set negative cache for non-existent bucket
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.SetNegativeCache(bucket)
}
}
return nil, fmt.Errorf("error retrieving bucket directory %s: %w", bucket, err)
}
if entry == nil {
// Set negative cache for non-existent bucket
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.SetNegativeCache(bucket)
}
return nil, fmt.Errorf("bucket directory not found %s", bucket)
}
// If no content, return empty metadata
if len(entry.Content) == 0 {
return NewBucketMetadata(), nil
}
// Unmarshal metadata from protobuf
var protoMetadata s3_pb.BucketMetadata
if err := proto.Unmarshal(entry.Content, &protoMetadata); err != nil {
glog.Errorf("getBucketMetadata: failed to unmarshal protobuf metadata for bucket %s: %v", bucket, err)
return nil, fmt.Errorf("failed to unmarshal bucket metadata for %s: %w", bucket, err)
}
// Convert protobuf CORS to standard CORS
corsConfig := corsConfigFromProto(protoMetadata.Cors)
// Create and return structured metadata
metadata := &BucketMetadata{
Tags: protoMetadata.Tags,
CORS: corsConfig,
Encryption: protoMetadata.Encryption,
}
return metadata, nil
}
// setBucketMetadata stores bucket metadata from a structured object
func (s3a *S3ApiServer) setBucketMetadata(bucket string, metadata *BucketMetadata) error {
// Validate bucket name to prevent path traversal attacks
if bucket == "" || strings.Contains(bucket, "/") || strings.Contains(bucket, "\\") ||
strings.Contains(bucket, "..") || strings.Contains(bucket, "~") {
return fmt.Errorf("invalid bucket name: %s", bucket)
}
// Clean the bucket name further to prevent any potential path traversal
bucket = filepath.Clean(bucket)
if bucket == "." || bucket == ".." {
return fmt.Errorf("invalid bucket name: %s", bucket)
}
// Default to empty metadata if nil
if metadata == nil {
metadata = NewBucketMetadata()
}
// Create protobuf metadata
protoMetadata := &s3_pb.BucketMetadata{
Tags: metadata.Tags,
Cors: corsConfigToProto(metadata.CORS),
Encryption: metadata.Encryption,
}
// Marshal metadata to protobuf
metadataBytes, err := proto.Marshal(protoMetadata)
if err != nil {
return fmt.Errorf("failed to marshal bucket metadata to protobuf: %w", err)
}
// Patch only Entry.content so a concurrent extended-attribute write
// (e.g. versioning) is preserved.
err = s3a.patchBucketEntry(bucket, &filer_pb.ObjectMutation{
SetContent: true,
Content: metadataBytes,
})
// Invalidate cache after successful update
if err == nil && s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.Remove(bucket)
s3a.bucketConfigCache.RemoveNegativeCache(bucket) // Remove from negative cache too
}
return err
}
// New structured API functions using BucketMetadata
// GetBucketMetadata retrieves complete bucket metadata as a structured object
func (s3a *S3ApiServer) GetBucketMetadata(bucket string) (*BucketMetadata, error) {
return s3a.getBucketMetadata(bucket)
}
// SetBucketMetadata stores complete bucket metadata from a structured object
func (s3a *S3ApiServer) SetBucketMetadata(bucket string, metadata *BucketMetadata) error {
return s3a.setBucketMetadata(bucket, metadata)
}
// UpdateBucketMetadata updates specific parts of bucket metadata while preserving others
//
// DISTRIBUTED SYSTEM DESIGN NOTE:
// This function implements a read-modify-write pattern with "last write wins" semantics.
// In the rare case of concurrent updates to different parts of bucket metadata
// (e.g., simultaneous tag and CORS updates), the last write may overwrite previous changes.
//
// This is an acceptable trade-off because:
// 1. Bucket metadata updates are infrequent in typical S3 usage
// 2. Traditional locking doesn't work in distributed systems across multiple nodes
// 3. The complexity of distributed consensus (e.g., Raft) for metadata updates would
// be disproportionate to the low frequency of bucket configuration changes
// 4. Most bucket operations (tags, CORS, encryption) are typically configured once
// during setup rather than being frequently modified
//
// If stronger consistency is required, consider implementing optimistic concurrency
// control with version numbers or ETags at the storage layer.
func (s3a *S3ApiServer) UpdateBucketMetadata(bucket string, update func(*BucketMetadata) error) error {
// Get current metadata
metadata, err := s3a.GetBucketMetadata(bucket)
if err != nil {
return fmt.Errorf("failed to get current bucket metadata: %w", err)
}
// Apply update function
if err := update(metadata); err != nil {
return fmt.Errorf("failed to apply metadata update: %w", err)
}
// Store updated metadata (last write wins)
return s3a.SetBucketMetadata(bucket, metadata)
}
// Helper functions for specific metadata operations using structured API
// UpdateBucketTags sets bucket tags using the structured API
func (s3a *S3ApiServer) UpdateBucketTags(bucket string, tags map[string]string) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.Tags = tags
return nil
})
}
// UpdateBucketCORS sets bucket CORS configuration using the structured API
func (s3a *S3ApiServer) UpdateBucketCORS(bucket string, corsConfig *cors.CORSConfiguration) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.CORS = corsConfig
return nil
})
}
// UpdateBucketEncryption sets bucket encryption configuration using the structured API
func (s3a *S3ApiServer) UpdateBucketEncryption(bucket string, encryptionConfig *s3_pb.EncryptionConfiguration) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.Encryption = encryptionConfig
return nil
})
}
// ClearBucketTags removes all bucket tags using the structured API
func (s3a *S3ApiServer) ClearBucketTags(bucket string) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.Tags = make(map[string]string)
return nil
})
}
// ClearBucketCORS removes bucket CORS configuration using the structured API
func (s3a *S3ApiServer) ClearBucketCORS(bucket string) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.CORS = nil
return nil
})
}
// ClearBucketEncryption removes bucket encryption configuration using the structured API
func (s3a *S3ApiServer) ClearBucketEncryption(bucket string) error {
return s3a.UpdateBucketMetadata(bucket, func(metadata *BucketMetadata) error {
metadata.Encryption = nil
return nil
})
}