aboutsummaryrefslogtreecommitdiff
path: root/weed
diff options
context:
space:
mode:
Diffstat (limited to 'weed')
-rw-r--r--weed/s3api/s3_constants/extend_key.go21
-rw-r--r--weed/s3api/s3api_object_handlers_delete.go71
-rw-r--r--weed/s3api/s3api_object_handlers_put.go7
-rw-r--r--weed/s3api/s3api_object_handlers_retention.go356
-rw-r--r--weed/s3api/s3api_object_handlers_skip.go26
-rw-r--r--weed/s3api/s3api_object_retention.go598
-rw-r--r--weed/s3api/s3api_object_retention_test.go726
-rw-r--r--weed/s3api/s3api_server.go10
-rw-r--r--weed/s3api/s3err/s3api_errors.go12
9 files changed, 1786 insertions, 41 deletions
diff --git a/weed/s3api/s3_constants/extend_key.go b/weed/s3api/s3_constants/extend_key.go
index 9806d899e..79fcbb239 100644
--- a/weed/s3api/s3_constants/extend_key.go
+++ b/weed/s3api/s3_constants/extend_key.go
@@ -11,4 +11,25 @@ const (
ExtETagKey = "Seaweed-X-Amz-ETag"
ExtLatestVersionIdKey = "Seaweed-X-Amz-Latest-Version-Id"
ExtLatestVersionFileNameKey = "Seaweed-X-Amz-Latest-Version-File-Name"
+
+ // Object Retention and Legal Hold
+ ExtObjectLockModeKey = "Seaweed-X-Amz-Object-Lock-Mode"
+ ExtRetentionUntilDateKey = "Seaweed-X-Amz-Retention-Until-Date"
+ ExtLegalHoldKey = "Seaweed-X-Amz-Legal-Hold"
+ ExtObjectLockEnabledKey = "Seaweed-X-Amz-Object-Lock-Enabled"
+ ExtObjectLockConfigKey = "Seaweed-X-Amz-Object-Lock-Config"
+)
+
+// Object Lock and Retention Constants
+const (
+ // Retention modes
+ RetentionModeGovernance = "GOVERNANCE"
+ RetentionModeCompliance = "COMPLIANCE"
+
+ // Legal hold status
+ LegalHoldOn = "ON"
+ LegalHoldOff = "OFF"
+
+ // Object lock enabled status
+ ObjectLockEnabled = "Enabled"
)
diff --git a/weed/s3api/s3api_object_handlers_delete.go b/weed/s3api/s3api_object_handlers_delete.go
index d7457fabe..35c842e6c 100644
--- a/weed/s3api/s3api_object_handlers_delete.go
+++ b/weed/s3api/s3api_object_handlers_delete.go
@@ -49,6 +49,16 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone)
}
+ // Check object lock permissions before deletion (only for versioned buckets)
+ if versioningEnabled {
+ bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true"
+ if err := s3a.checkObjectLockPermissions(bucket, object, versionId, bypassGovernance); err != nil {
+ glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+ }
+
if versioningEnabled {
// Handle versioned delete
if versionId != "" {
@@ -117,9 +127,10 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
w.WriteHeader(http.StatusNoContent)
}
-// / ObjectIdentifier carries key name for the object to delete.
+// ObjectIdentifier represents an object to be deleted with its key name and optional version ID.
type ObjectIdentifier struct {
- ObjectName string `xml:"Key"`
+ Key string `xml:"Key"`
+ VersionId string `xml:"VersionId,omitempty"`
}
// DeleteObjectsRequest - xml carrying the object key names which needs to be deleted.
@@ -132,9 +143,10 @@ type DeleteObjectsRequest struct {
// DeleteError structure.
type DeleteError struct {
- Code string
- Message string
- Key string
+ Code string `xml:"Code"`
+ Message string `xml:"Message"`
+ Key string `xml:"Key"`
+ VersionId string `xml:"VersionId,omitempty"`
}
// DeleteObjectsResponse container for multiple object deletes.
@@ -180,18 +192,48 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
if s3err.Logger != nil {
auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone)
}
+
+ // Check for bypass governance retention header
+ bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true"
+
+ // Check if versioning is enabled for the bucket (needed for object lock checks)
+ versioningEnabled, err := s3a.isVersioningEnabled(bucket)
+ if err != nil {
+ if err == filer_pb.ErrNotFound {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
+ return
+ }
+ glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// delete file entries
for _, object := range deleteObjects.Objects {
- if object.ObjectName == "" {
+ if object.Key == "" {
continue
}
- lastSeparator := strings.LastIndex(object.ObjectName, "/")
- parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.ObjectName, true, false
- if lastSeparator > 0 && lastSeparator+1 < len(object.ObjectName) {
- entryName = object.ObjectName[lastSeparator+1:]
- parentDirectoryPath = "/" + object.ObjectName[:lastSeparator]
+
+ // Check object lock permissions before deletion (only for versioned buckets)
+ if versioningEnabled {
+ if err := s3a.checkObjectLockPermissions(bucket, object.Key, object.VersionId, bypassGovernance); err != nil {
+ glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, object.VersionId, err)
+ deleteErrors = append(deleteErrors, DeleteError{
+ Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code,
+ Message: s3err.GetAPIError(s3err.ErrAccessDenied).Description,
+ Key: object.Key,
+ VersionId: object.VersionId,
+ })
+ continue
+ }
+ }
+ lastSeparator := strings.LastIndex(object.Key, "/")
+ parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.Key, true, false
+ if lastSeparator > 0 && lastSeparator+1 < len(object.Key) {
+ entryName = object.Key[lastSeparator+1:]
+ parentDirectoryPath = "/" + object.Key[:lastSeparator]
}
parentDirectoryPath = fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, parentDirectoryPath)
@@ -204,9 +246,10 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
} else {
delete(directoriesWithDeletion, parentDirectoryPath)
deleteErrors = append(deleteErrors, DeleteError{
- Code: "",
- Message: err.Error(),
- Key: object.ObjectName,
+ Code: "",
+ Message: err.Error(),
+ Key: object.Key,
+ VersionId: object.VersionId,
})
}
if auditLog != nil {
diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go
index 8b85a049a..371ab870f 100644
--- a/weed/s3api/s3api_object_handlers_put.go
+++ b/weed/s3api/s3api_object_handlers_put.go
@@ -85,6 +85,13 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
glog.V(1).Infof("PutObjectHandler: bucket %s, object %s, versioningEnabled=%v", bucket, object, versioningEnabled)
+ // Check object lock permissions before PUT operation (only for versioned buckets)
+ bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true"
+ if err := s3a.checkObjectLockPermissionsForPut(bucket, object, bypassGovernance, versioningEnabled); err != nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+
if versioningEnabled {
// Handle versioned PUT
glog.V(1).Infof("PutObjectHandler: using versioned PUT for %s/%s", bucket, object)
diff --git a/weed/s3api/s3api_object_handlers_retention.go b/weed/s3api/s3api_object_handlers_retention.go
new file mode 100644
index 000000000..e92e821c8
--- /dev/null
+++ b/weed/s3api/s3api_object_handlers_retention.go
@@ -0,0 +1,356 @@
+package s3api
+
+import (
+ "encoding/xml"
+ "errors"
+ "net/http"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
+ stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
+)
+
+// PutObjectRetentionHandler Put object Retention
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html
+func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, object := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("PutObjectRetentionHandler %s %s", bucket, object)
+
+ // Check if Object Lock is available for this bucket (requires versioning)
+ if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectRetentionHandler") {
+ return
+ }
+
+ // Get version ID from query parameters
+ versionId := r.URL.Query().Get("versionId")
+
+ // Check for bypass governance retention header
+ bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true"
+
+ // Parse retention configuration from request body
+ retention, err := parseObjectRetention(r)
+ if err != nil {
+ glog.Errorf("PutObjectRetentionHandler: failed to parse retention config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
+ return
+ }
+
+ // Validate retention configuration
+ if err := validateRetention(retention); err != nil {
+ glog.Errorf("PutObjectRetentionHandler: invalid retention config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+
+ // Set retention on the object
+ if err := s3a.setObjectRetention(bucket, object, versionId, retention, bypassGovernance); err != nil {
+ glog.Errorf("PutObjectRetentionHandler: failed to set retention: %v", err)
+
+ // Handle specific error cases
+ if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) || errors.Is(err, ErrLatestVersionNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
+ return
+ }
+
+ if errors.Is(err, ErrComplianceModeActive) || errors.Is(err, ErrGovernanceModeActive) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ // Return success (HTTP 200 with no body)
+ w.WriteHeader(http.StatusOK)
+ glog.V(3).Infof("PutObjectRetentionHandler: successfully set retention for %s/%s", bucket, object)
+}
+
+// GetObjectRetentionHandler Get object Retention
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectRetention.html
+func (s3a *S3ApiServer) GetObjectRetentionHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, object := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("GetObjectRetentionHandler %s %s", bucket, object)
+
+ // Check if Object Lock is available for this bucket (requires versioning)
+ if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectRetentionHandler") {
+ return
+ }
+
+ // Get version ID from query parameters
+ versionId := r.URL.Query().Get("versionId")
+
+ // Get retention configuration for the object
+ retention, err := s3a.getObjectRetention(bucket, object, versionId)
+ if err != nil {
+ glog.Errorf("GetObjectRetentionHandler: failed to get retention: %v", err)
+
+ // Handle specific error cases
+ if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
+ return
+ }
+
+ if errors.Is(err, ErrNoRetentionConfiguration) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration)
+ return
+ }
+
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Marshal retention configuration to XML
+ retentionXML, err := xml.Marshal(retention)
+ if err != nil {
+ glog.Errorf("GetObjectRetentionHandler: failed to marshal retention: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Set response headers
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+
+ // Write XML response
+ if _, err := w.Write([]byte(xml.Header)); err != nil {
+ glog.Errorf("GetObjectRetentionHandler: failed to write XML header: %v", err)
+ return
+ }
+
+ if _, err := w.Write(retentionXML); err != nil {
+ glog.Errorf("GetObjectRetentionHandler: failed to write retention XML: %v", err)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ glog.V(3).Infof("GetObjectRetentionHandler: successfully retrieved retention for %s/%s", bucket, object)
+}
+
+// PutObjectLegalHoldHandler Put object Legal Hold
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html
+func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, object := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("PutObjectLegalHoldHandler %s %s", bucket, object)
+
+ // Check if Object Lock is available for this bucket (requires versioning)
+ if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLegalHoldHandler") {
+ return
+ }
+
+ // Get version ID from query parameters
+ versionId := r.URL.Query().Get("versionId")
+
+ // Parse legal hold configuration from request body
+ legalHold, err := parseObjectLegalHold(r)
+ if err != nil {
+ glog.Errorf("PutObjectLegalHoldHandler: failed to parse legal hold config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
+ return
+ }
+
+ // Validate legal hold configuration
+ if err := validateLegalHold(legalHold); err != nil {
+ glog.Errorf("PutObjectLegalHoldHandler: invalid legal hold config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+
+ // Set legal hold on the object
+ if err := s3a.setObjectLegalHold(bucket, object, versionId, legalHold); err != nil {
+ glog.Errorf("PutObjectLegalHoldHandler: failed to set legal hold: %v", err)
+
+ // Handle specific error cases
+ if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
+ return
+ }
+
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ // Return success (HTTP 200 with no body)
+ w.WriteHeader(http.StatusOK)
+ glog.V(3).Infof("PutObjectLegalHoldHandler: successfully set legal hold for %s/%s", bucket, object)
+}
+
+// GetObjectLegalHoldHandler Get object Legal Hold
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLegalHold.html
+func (s3a *S3ApiServer) GetObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, object := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("GetObjectLegalHoldHandler %s %s", bucket, object)
+
+ // Check if Object Lock is available for this bucket (requires versioning)
+ if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectLegalHoldHandler") {
+ return
+ }
+
+ // Get version ID from query parameters
+ versionId := r.URL.Query().Get("versionId")
+
+ // Get legal hold configuration for the object
+ legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId)
+ if err != nil {
+ glog.Errorf("GetObjectLegalHoldHandler: failed to get legal hold: %v", err)
+
+ // Handle specific error cases
+ if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey)
+ return
+ }
+
+ if errors.Is(err, ErrNoLegalHoldConfiguration) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLegalHold)
+ return
+ }
+
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Marshal legal hold configuration to XML
+ legalHoldXML, err := xml.Marshal(legalHold)
+ if err != nil {
+ glog.Errorf("GetObjectLegalHoldHandler: failed to marshal legal hold: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Set response headers
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+
+ // Write XML response
+ if _, err := w.Write([]byte(xml.Header)); err != nil {
+ glog.Errorf("GetObjectLegalHoldHandler: failed to write XML header: %v", err)
+ return
+ }
+
+ if _, err := w.Write(legalHoldXML); err != nil {
+ glog.Errorf("GetObjectLegalHoldHandler: failed to write legal hold XML: %v", err)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ glog.V(3).Infof("GetObjectLegalHoldHandler: successfully retrieved legal hold for %s/%s", bucket, object)
+}
+
+// PutObjectLockConfigurationHandler Put object Lock configuration
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html
+func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("PutObjectLockConfigurationHandler %s", bucket)
+
+ // Check if Object Lock is available for this bucket (requires versioning)
+ if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLockConfigurationHandler") {
+ return
+ }
+
+ // Parse object lock configuration from request body
+ config, err := parseObjectLockConfiguration(r)
+ if err != nil {
+ glog.Errorf("PutObjectLockConfigurationHandler: failed to parse object lock config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
+ return
+ }
+
+ // Validate object lock configuration
+ if err := validateObjectLockConfiguration(config); err != nil {
+ glog.Errorf("PutObjectLockConfigurationHandler: invalid object lock config: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+
+ // Set object lock configuration on the bucket
+ errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error {
+ if bucketConfig.Entry.Extended == nil {
+ bucketConfig.Entry.Extended = make(map[string][]byte)
+ }
+
+ // Store the configuration as JSON in extended attributes
+ configXML, err := xml.Marshal(config)
+ if err != nil {
+ return err
+ }
+
+ bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] = configXML
+
+ if config.ObjectLockEnabled != "" {
+ bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(config.ObjectLockEnabled)
+ }
+
+ return nil
+ })
+
+ if errCode != s3err.ErrNone {
+ glog.Errorf("PutObjectLockConfigurationHandler: failed to set object lock config: %v", errCode)
+ s3err.WriteErrorResponse(w, r, errCode)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ // Return success (HTTP 200 with no body)
+ w.WriteHeader(http.StatusOK)
+ glog.V(3).Infof("PutObjectLockConfigurationHandler: successfully set object lock config for %s", bucket)
+}
+
+// GetObjectLockConfigurationHandler Get object Lock configuration
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLockConfiguration.html
+func (s3a *S3ApiServer) GetObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("GetObjectLockConfigurationHandler %s", bucket)
+
+ // Get bucket configuration
+ bucketConfig, errCode := s3a.getBucketConfig(bucket)
+ if errCode != s3err.ErrNone {
+ glog.Errorf("GetObjectLockConfigurationHandler: failed to get bucket config: %v", errCode)
+ s3err.WriteErrorResponse(w, r, errCode)
+ return
+ }
+
+ // Check if object lock configuration exists
+ if bucketConfig.Entry.Extended == nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration)
+ return
+ }
+
+ configXML, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey]
+ if !exists {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration)
+ return
+ }
+
+ // Set response headers
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+
+ // Write XML response
+ if _, err := w.Write([]byte(xml.Header)); err != nil {
+ glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err)
+ return
+ }
+
+ if _, err := w.Write(configXML); err != nil {
+ glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err)
+ return
+ }
+
+ // Record metrics
+ stats_collect.RecordBucketActiveTime(bucket)
+
+ glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved object lock config for %s", bucket)
+}
diff --git a/weed/s3api/s3api_object_handlers_skip.go b/weed/s3api/s3api_object_handlers_skip.go
index 160d02475..0b74a0ec7 100644
--- a/weed/s3api/s3api_object_handlers_skip.go
+++ b/weed/s3api/s3api_object_handlers_skip.go
@@ -4,7 +4,7 @@ import (
"net/http"
)
-// GetObjectAclHandler Put object ACL
+// GetObjectAclHandler Get object ACL
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAcl.html
func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Request) {
@@ -19,27 +19,3 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque
w.WriteHeader(http.StatusNoContent)
}
-
-// PutObjectRetentionHandler Put object Retention
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html
-func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) {
-
- w.WriteHeader(http.StatusNoContent)
-
-}
-
-// PutObjectLegalHoldHandler Put object Legal Hold
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html
-func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) {
-
- w.WriteHeader(http.StatusNoContent)
-
-}
-
-// PutObjectLockConfigurationHandler Put object Lock configuration
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html
-func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) {
-
- w.WriteHeader(http.StatusNoContent)
-
-}
diff --git a/weed/s3api/s3api_object_retention.go b/weed/s3api/s3api_object_retention.go
new file mode 100644
index 000000000..bedf693ef
--- /dev/null
+++ b/weed/s3api/s3api_object_retention.go
@@ -0,0 +1,598 @@
+package s3api
+
+import (
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
+)
+
+// Sentinel errors for proper error handling instead of string matching
+var (
+ ErrNoRetentionConfiguration = errors.New("no retention configuration found")
+ ErrNoLegalHoldConfiguration = errors.New("no legal hold configuration found")
+ ErrBucketNotFound = errors.New("bucket not found")
+ ErrObjectNotFound = errors.New("object not found")
+ ErrVersionNotFound = errors.New("version not found")
+ ErrLatestVersionNotFound = errors.New("latest version not found")
+ ErrComplianceModeActive = errors.New("object is under COMPLIANCE mode retention and cannot be deleted or modified")
+ ErrGovernanceModeActive = errors.New("object is under GOVERNANCE mode retention and cannot be deleted or modified without bypass")
+)
+
+const (
+ // Maximum retention period limits according to AWS S3 specifications
+ MaxRetentionDays = 36500 // Maximum number of days for object retention (100 years)
+ MaxRetentionYears = 100 // Maximum number of years for object retention
+)
+
+// ObjectRetention represents S3 Object Retention configuration
+type ObjectRetention struct {
+ XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ Retention"`
+ Mode string `xml:"Mode,omitempty"`
+ RetainUntilDate *time.Time `xml:"RetainUntilDate,omitempty"`
+}
+
+// ObjectLegalHold represents S3 Object Legal Hold configuration
+type ObjectLegalHold struct {
+ XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ LegalHold"`
+ Status string `xml:"Status,omitempty"`
+}
+
+// ObjectLockConfiguration represents S3 Object Lock Configuration
+type ObjectLockConfiguration struct {
+ XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ObjectLockConfiguration"`
+ ObjectLockEnabled string `xml:"ObjectLockEnabled,omitempty"`
+ Rule *ObjectLockRule `xml:"Rule,omitempty"`
+}
+
+// ObjectLockRule represents an Object Lock Rule
+type ObjectLockRule struct {
+ XMLName xml.Name `xml:"Rule"`
+ DefaultRetention *DefaultRetention `xml:"DefaultRetention,omitempty"`
+}
+
+// DefaultRetention represents default retention settings
+type DefaultRetention struct {
+ XMLName xml.Name `xml:"DefaultRetention"`
+ Mode string `xml:"Mode,omitempty"`
+ Days int `xml:"Days,omitempty"`
+ Years int `xml:"Years,omitempty"`
+}
+
+// Custom time unmarshalling for AWS S3 ISO8601 format
+func (or *ObjectRetention) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ type Alias ObjectRetention
+ aux := &struct {
+ *Alias
+ RetainUntilDate *string `xml:"RetainUntilDate,omitempty"`
+ }{
+ Alias: (*Alias)(or),
+ }
+
+ if err := d.DecodeElement(aux, &start); err != nil {
+ return err
+ }
+
+ if aux.RetainUntilDate != nil {
+ t, err := time.Parse(time.RFC3339, *aux.RetainUntilDate)
+ if err != nil {
+ return err
+ }
+ or.RetainUntilDate = &t
+ }
+
+ return nil
+}
+
+// parseXML is a generic helper function to parse XML from an HTTP request body.
+// It uses xml.Decoder for streaming XML parsing, which is more memory-efficient
+// and avoids loading the entire request body into memory.
+//
+// The function assumes:
+// - The request body is not nil (returns error if it is)
+// - The request body will be closed after parsing (deferred close)
+// - The XML content matches the structure of the provided result type T
+//
+// This approach is optimized for small XML payloads typical in S3 API requests
+// (retention configurations, legal hold settings, etc.) where the overhead of
+// streaming parsing is acceptable for the memory efficiency benefits.
+func parseXML[T any](r *http.Request, result *T) error {
+ if r.Body == nil {
+ return fmt.Errorf("error parsing XML: empty request body")
+ }
+ defer r.Body.Close()
+
+ decoder := xml.NewDecoder(r.Body)
+ if err := decoder.Decode(result); err != nil {
+ return fmt.Errorf("error parsing XML: %v", err)
+ }
+
+ return nil
+}
+
+// parseObjectRetention parses XML retention configuration from request body
+func parseObjectRetention(r *http.Request) (*ObjectRetention, error) {
+ var retention ObjectRetention
+ if err := parseXML(r, &retention); err != nil {
+ return nil, err
+ }
+ return &retention, nil
+}
+
+// parseObjectLegalHold parses XML legal hold configuration from request body
+func parseObjectLegalHold(r *http.Request) (*ObjectLegalHold, error) {
+ var legalHold ObjectLegalHold
+ if err := parseXML(r, &legalHold); err != nil {
+ return nil, err
+ }
+ return &legalHold, nil
+}
+
+// parseObjectLockConfiguration parses XML object lock configuration from request body
+func parseObjectLockConfiguration(r *http.Request) (*ObjectLockConfiguration, error) {
+ var config ObjectLockConfiguration
+ if err := parseXML(r, &config); err != nil {
+ return nil, err
+ }
+ return &config, nil
+}
+
+// validateRetention validates retention configuration
+func validateRetention(retention *ObjectRetention) error {
+ // AWS requires both Mode and RetainUntilDate for PutObjectRetention
+ if retention.Mode == "" {
+ return fmt.Errorf("retention configuration must specify Mode")
+ }
+
+ if retention.RetainUntilDate == nil {
+ return fmt.Errorf("retention configuration must specify RetainUntilDate")
+ }
+
+ if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance {
+ return fmt.Errorf("invalid retention mode: %s", retention.Mode)
+ }
+
+ if retention.RetainUntilDate.Before(time.Now()) {
+ return fmt.Errorf("retain until date must be in the future")
+ }
+
+ return nil
+}
+
+// validateLegalHold validates legal hold configuration
+func validateLegalHold(legalHold *ObjectLegalHold) error {
+ if legalHold.Status != s3_constants.LegalHoldOn && legalHold.Status != s3_constants.LegalHoldOff {
+ return fmt.Errorf("invalid legal hold status: %s", legalHold.Status)
+ }
+
+ return nil
+}
+
+// validateObjectLockConfiguration validates object lock configuration
+func validateObjectLockConfiguration(config *ObjectLockConfiguration) error {
+ // ObjectLockEnabled is required for bucket-level configuration
+ if config.ObjectLockEnabled == "" {
+ return fmt.Errorf("object lock configuration must specify ObjectLockEnabled")
+ }
+
+ // Validate ObjectLockEnabled value
+ if config.ObjectLockEnabled != s3_constants.ObjectLockEnabled {
+ return fmt.Errorf("invalid object lock enabled value: %s", config.ObjectLockEnabled)
+ }
+
+ // Validate Rule if present
+ if config.Rule != nil {
+ if config.Rule.DefaultRetention == nil {
+ return fmt.Errorf("rule configuration must specify DefaultRetention")
+ }
+ return validateDefaultRetention(config.Rule.DefaultRetention)
+ }
+
+ return nil
+}
+
+// validateDefaultRetention validates default retention configuration
+func validateDefaultRetention(retention *DefaultRetention) error {
+ // Mode is required
+ if retention.Mode == "" {
+ return fmt.Errorf("default retention must specify Mode")
+ }
+
+ // Mode must be valid
+ if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance {
+ return fmt.Errorf("invalid default retention mode: %s", retention.Mode)
+ }
+
+ // Exactly one of Days or Years must be specified
+ if retention.Days == 0 && retention.Years == 0 {
+ return fmt.Errorf("default retention must specify either Days or Years")
+ }
+
+ if retention.Days > 0 && retention.Years > 0 {
+ return fmt.Errorf("default retention cannot specify both Days and Years")
+ }
+
+ // Validate ranges
+ if retention.Days < 0 || retention.Days > MaxRetentionDays {
+ return fmt.Errorf("default retention days must be between 0 and %d", MaxRetentionDays)
+ }
+
+ if retention.Years < 0 || retention.Years > MaxRetentionYears {
+ return fmt.Errorf("default retention years must be between 0 and %d", MaxRetentionYears)
+ }
+
+ return nil
+}
+
+// getObjectEntry retrieves the appropriate object entry based on versioning and versionId
+func (s3a *S3ApiServer) getObjectEntry(bucket, object, versionId string) (*filer_pb.Entry, error) {
+ var entry *filer_pb.Entry
+ var err error
+
+ if versionId != "" {
+ entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId)
+ } else {
+ // Check if versioning is enabled
+ versioningEnabled, vErr := s3a.isVersioningEnabled(bucket)
+ if vErr != nil {
+ return nil, fmt.Errorf("error checking versioning: %v", vErr)
+ }
+
+ if versioningEnabled {
+ entry, err = s3a.getLatestObjectVersion(bucket, object)
+ } else {
+ bucketDir := s3a.option.BucketsPath + "/" + bucket
+ entry, err = s3a.getEntry(bucketDir, object)
+ }
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve object %s/%s: %w", bucket, object, ErrObjectNotFound)
+ }
+
+ return entry, nil
+}
+
+// getObjectRetention retrieves retention configuration from object metadata
+func (s3a *S3ApiServer) getObjectRetention(bucket, object, versionId string) (*ObjectRetention, error) {
+ entry, err := s3a.getObjectEntry(bucket, object, versionId)
+ if err != nil {
+ return nil, err
+ }
+
+ if entry.Extended == nil {
+ return nil, ErrNoRetentionConfiguration
+ }
+
+ retention := &ObjectRetention{}
+
+ if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists {
+ retention.Mode = string(modeBytes)
+ }
+
+ if dateBytes, exists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; exists {
+ if timestamp, err := strconv.ParseInt(string(dateBytes), 10, 64); err == nil {
+ t := time.Unix(timestamp, 0)
+ retention.RetainUntilDate = &t
+ } else {
+ return nil, fmt.Errorf("failed to parse retention timestamp for %s/%s: corrupted timestamp data", bucket, object)
+ }
+ }
+
+ if retention.Mode == "" || retention.RetainUntilDate == nil {
+ return nil, ErrNoRetentionConfiguration
+ }
+
+ return retention, nil
+}
+
+// setObjectRetention sets retention configuration on object metadata
+func (s3a *S3ApiServer) setObjectRetention(bucket, object, versionId string, retention *ObjectRetention, bypassGovernance bool) error {
+ var entry *filer_pb.Entry
+ var err error
+ var entryPath string
+
+ if versionId != "" {
+ entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId)
+ if err != nil {
+ return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound)
+ }
+ entryPath = object + ".versions/" + s3a.getVersionFileName(versionId)
+ } else {
+ // Check if versioning is enabled
+ versioningEnabled, vErr := s3a.isVersioningEnabled(bucket)
+ if vErr != nil {
+ return fmt.Errorf("error checking versioning: %v", vErr)
+ }
+
+ if versioningEnabled {
+ entry, err = s3a.getLatestObjectVersion(bucket, object)
+ if err != nil {
+ return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound)
+ }
+ // Extract version ID from entry metadata
+ if entry.Extended != nil {
+ if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists {
+ versionId = string(versionIdBytes)
+ entryPath = object + ".versions/" + s3a.getVersionFileName(versionId)
+ }
+ }
+ } else {
+ bucketDir := s3a.option.BucketsPath + "/" + bucket
+ entry, err = s3a.getEntry(bucketDir, object)
+ if err != nil {
+ return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound)
+ }
+ entryPath = object
+ }
+ }
+
+ // Check if object is already under retention
+ if entry.Extended != nil {
+ if existingMode, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists {
+ if string(existingMode) == s3_constants.RetentionModeCompliance && !bypassGovernance {
+ return fmt.Errorf("cannot modify retention on object under COMPLIANCE mode")
+ }
+
+ if existingDateBytes, dateExists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; dateExists {
+ if timestamp, err := strconv.ParseInt(string(existingDateBytes), 10, 64); err == nil {
+ existingDate := time.Unix(timestamp, 0)
+ if existingDate.After(time.Now()) && string(existingMode) == s3_constants.RetentionModeGovernance && !bypassGovernance {
+ return fmt.Errorf("cannot modify retention on object under GOVERNANCE mode without bypass")
+ }
+ }
+ }
+ }
+ }
+
+ // Update retention metadata
+ if entry.Extended == nil {
+ entry.Extended = make(map[string][]byte)
+ }
+
+ if retention.Mode != "" {
+ entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(retention.Mode)
+ }
+
+ if retention.RetainUntilDate != nil {
+ entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte(strconv.FormatInt(retention.RetainUntilDate.Unix(), 10))
+
+ // Also update the existing WORM fields for compatibility
+ entry.WormEnforcedAtTsNs = time.Now().UnixNano()
+ }
+
+ // Update the entry
+ // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention
+ // and PutObjectLegalHold update the same object simultaneously, as they might
+ // overwrite each other's Extended map changes. This is mitigated by the fact
+ // that mkFile operations are typically serialized at the filer level, but
+ // future implementations might consider using atomic update operations or
+ // entry-level locking for complete safety.
+ bucketDir := s3a.option.BucketsPath + "/" + bucket
+ return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) {
+ updatedEntry.Extended = entry.Extended
+ updatedEntry.WormEnforcedAtTsNs = entry.WormEnforcedAtTsNs
+ })
+}
+
+// getObjectLegalHold retrieves legal hold configuration from object metadata
+func (s3a *S3ApiServer) getObjectLegalHold(bucket, object, versionId string) (*ObjectLegalHold, error) {
+ entry, err := s3a.getObjectEntry(bucket, object, versionId)
+ if err != nil {
+ return nil, err
+ }
+
+ if entry.Extended == nil {
+ return nil, ErrNoLegalHoldConfiguration
+ }
+
+ legalHold := &ObjectLegalHold{}
+
+ if statusBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists {
+ legalHold.Status = string(statusBytes)
+ } else {
+ return nil, ErrNoLegalHoldConfiguration
+ }
+
+ return legalHold, nil
+}
+
+// setObjectLegalHold sets legal hold configuration on object metadata
+func (s3a *S3ApiServer) setObjectLegalHold(bucket, object, versionId string, legalHold *ObjectLegalHold) error {
+ var entry *filer_pb.Entry
+ var err error
+ var entryPath string
+
+ if versionId != "" {
+ entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId)
+ if err != nil {
+ return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound)
+ }
+ entryPath = object + ".versions/" + s3a.getVersionFileName(versionId)
+ } else {
+ // Check if versioning is enabled
+ versioningEnabled, vErr := s3a.isVersioningEnabled(bucket)
+ if vErr != nil {
+ return fmt.Errorf("error checking versioning: %v", vErr)
+ }
+
+ if versioningEnabled {
+ entry, err = s3a.getLatestObjectVersion(bucket, object)
+ if err != nil {
+ return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound)
+ }
+ // Extract version ID from entry metadata
+ if entry.Extended != nil {
+ if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists {
+ versionId = string(versionIdBytes)
+ entryPath = object + ".versions/" + s3a.getVersionFileName(versionId)
+ }
+ }
+ } else {
+ bucketDir := s3a.option.BucketsPath + "/" + bucket
+ entry, err = s3a.getEntry(bucketDir, object)
+ if err != nil {
+ return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound)
+ }
+ entryPath = object
+ }
+ }
+
+ // Update legal hold metadata
+ if entry.Extended == nil {
+ entry.Extended = make(map[string][]byte)
+ }
+
+ entry.Extended[s3_constants.ExtLegalHoldKey] = []byte(legalHold.Status)
+
+ // Update the entry
+ // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention
+ // and PutObjectLegalHold update the same object simultaneously, as they might
+ // overwrite each other's Extended map changes. This is mitigated by the fact
+ // that mkFile operations are typically serialized at the filer level, but
+ // future implementations might consider using atomic update operations or
+ // entry-level locking for complete safety.
+ bucketDir := s3a.option.BucketsPath + "/" + bucket
+ return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) {
+ updatedEntry.Extended = entry.Extended
+ })
+}
+
+// isObjectRetentionActive checks if an object is currently under retention
+func (s3a *S3ApiServer) isObjectRetentionActive(bucket, object, versionId string) (bool, error) {
+ retention, err := s3a.getObjectRetention(bucket, object, versionId)
+ if err != nil {
+ // If no retention found, object is not under retention
+ if errors.Is(err, ErrNoRetentionConfiguration) {
+ return false, nil
+ }
+ return false, err
+ }
+
+ if retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now()) {
+ return true, nil
+ }
+
+ return false, nil
+}
+
+// getObjectRetentionWithStatus retrieves retention configuration and returns both the data and active status
+// This is an optimization to avoid duplicate fetches when both retention data and status are needed
+func (s3a *S3ApiServer) getObjectRetentionWithStatus(bucket, object, versionId string) (*ObjectRetention, bool, error) {
+ retention, err := s3a.getObjectRetention(bucket, object, versionId)
+ if err != nil {
+ // If no retention found, object is not under retention
+ if errors.Is(err, ErrNoRetentionConfiguration) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+
+ // Check if retention is currently active
+ isActive := retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now())
+ return retention, isActive, nil
+}
+
+// isObjectLegalHoldActive checks if an object is currently under legal hold
+func (s3a *S3ApiServer) isObjectLegalHoldActive(bucket, object, versionId string) (bool, error) {
+ legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId)
+ if err != nil {
+ // If no legal hold found, object is not under legal hold
+ if errors.Is(err, ErrNoLegalHoldConfiguration) {
+ return false, nil
+ }
+ return false, err
+ }
+
+ return legalHold.Status == s3_constants.LegalHoldOn, nil
+}
+
+// checkObjectLockPermissions checks if an object can be deleted or modified
+func (s3a *S3ApiServer) checkObjectLockPermissions(bucket, object, versionId string, bypassGovernance bool) error {
+ // Get retention configuration and status in a single call to avoid duplicate fetches
+ retention, retentionActive, err := s3a.getObjectRetentionWithStatus(bucket, object, versionId)
+ if err != nil {
+ glog.Warningf("Error checking retention for %s/%s: %v", bucket, object, err)
+ }
+
+ // Check if object is under legal hold
+ legalHoldActive, err := s3a.isObjectLegalHoldActive(bucket, object, versionId)
+ if err != nil {
+ glog.Warningf("Error checking legal hold for %s/%s: %v", bucket, object, err)
+ }
+
+ // If object is under legal hold, it cannot be deleted or modified
+ if legalHoldActive {
+ return fmt.Errorf("object is under legal hold and cannot be deleted or modified")
+ }
+
+ // If object is under retention, check the mode
+ if retentionActive && retention != nil {
+ if retention.Mode == s3_constants.RetentionModeCompliance {
+ return ErrComplianceModeActive
+ }
+
+ if retention.Mode == s3_constants.RetentionModeGovernance && !bypassGovernance {
+ return ErrGovernanceModeActive
+ }
+ }
+
+ return nil
+}
+
+// isObjectLockAvailable checks if Object Lock features are available for the bucket
+// Object Lock requires versioning to be enabled (AWS S3 requirement)
+func (s3a *S3ApiServer) isObjectLockAvailable(bucket string) error {
+ versioningEnabled, err := s3a.isVersioningEnabled(bucket)
+ if err != nil {
+ if errors.Is(err, filer_pb.ErrNotFound) {
+ return ErrBucketNotFound
+ }
+ return fmt.Errorf("error checking versioning status: %v", err)
+ }
+
+ if !versioningEnabled {
+ return fmt.Errorf("object lock requires versioning to be enabled")
+ }
+
+ return nil
+}
+
+// checkObjectLockPermissionsForPut checks object lock permissions for PUT operations
+// This is a shared helper to avoid code duplication in PUT handlers
+func (s3a *S3ApiServer) checkObjectLockPermissionsForPut(bucket, object string, bypassGovernance bool, versioningEnabled bool) error {
+ // Object Lock only applies to versioned buckets (AWS S3 requirement)
+ if !versioningEnabled {
+ return nil
+ }
+
+ // For PUT operations, we check permissions on the current object (empty versionId)
+ if err := s3a.checkObjectLockPermissions(bucket, object, "", bypassGovernance); err != nil {
+ glog.V(2).Infof("checkObjectLockPermissionsForPut: object lock check failed for %s/%s: %v", bucket, object, err)
+ return err
+ }
+ return nil
+}
+
+// handleObjectLockAvailabilityCheck is a helper function to check object lock availability
+// and write the appropriate error response if not available. This reduces code duplication
+// across all retention handlers.
+func (s3a *S3ApiServer) handleObjectLockAvailabilityCheck(w http.ResponseWriter, r *http.Request, bucket, handlerName string) bool {
+ if err := s3a.isObjectLockAvailable(bucket); err != nil {
+ glog.Errorf("%s: object lock not available for bucket %s: %v", handlerName, bucket, err)
+ if errors.Is(err, ErrBucketNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
+ } else {
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ }
+ return false
+ }
+ return true
+}
diff --git a/weed/s3api/s3api_object_retention_test.go b/weed/s3api/s3api_object_retention_test.go
new file mode 100644
index 000000000..0caa50b42
--- /dev/null
+++ b/weed/s3api/s3api_object_retention_test.go
@@ -0,0 +1,726 @@
+package s3api
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+)
+
+// TODO: If needed, re-implement TestPutObjectRetention with proper setup for buckets, objects, and versioning.
+
+func TestValidateRetention(t *testing.T) {
+ tests := []struct {
+ name string
+ retention *ObjectRetention
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "Valid GOVERNANCE retention",
+ retention: &ObjectRetention{
+ Mode: s3_constants.RetentionModeGovernance,
+ RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)),
+ },
+ expectError: false,
+ },
+ {
+ name: "Valid COMPLIANCE retention",
+ retention: &ObjectRetention{
+ Mode: s3_constants.RetentionModeCompliance,
+ RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)),
+ },
+ expectError: false,
+ },
+ {
+ name: "Missing Mode",
+ retention: &ObjectRetention{
+ RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)),
+ },
+ expectError: true,
+ errorMsg: "retention configuration must specify Mode",
+ },
+ {
+ name: "Missing RetainUntilDate",
+ retention: &ObjectRetention{
+ Mode: s3_constants.RetentionModeGovernance,
+ },
+ expectError: true,
+ errorMsg: "retention configuration must specify RetainUntilDate",
+ },
+ {
+ name: "Invalid Mode",
+ retention: &ObjectRetention{
+ Mode: "INVALID_MODE",
+ RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)),
+ },
+ expectError: true,
+ errorMsg: "invalid retention mode",
+ },
+ {
+ name: "Past RetainUntilDate",
+ retention: &ObjectRetention{
+ Mode: s3_constants.RetentionModeGovernance,
+ RetainUntilDate: timePtr(time.Now().Add(-24 * time.Hour)),
+ },
+ expectError: true,
+ errorMsg: "retain until date must be in the future",
+ },
+ {
+ name: "Empty retention",
+ retention: &ObjectRetention{},
+ expectError: true,
+ errorMsg: "retention configuration must specify Mode",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateRetention(tt.retention)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestValidateLegalHold(t *testing.T) {
+ tests := []struct {
+ name string
+ legalHold *ObjectLegalHold
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "Valid ON status",
+ legalHold: &ObjectLegalHold{
+ Status: s3_constants.LegalHoldOn,
+ },
+ expectError: false,
+ },
+ {
+ name: "Valid OFF status",
+ legalHold: &ObjectLegalHold{
+ Status: s3_constants.LegalHoldOff,
+ },
+ expectError: false,
+ },
+ {
+ name: "Invalid status",
+ legalHold: &ObjectLegalHold{
+ Status: "INVALID_STATUS",
+ },
+ expectError: true,
+ errorMsg: "invalid legal hold status",
+ },
+ {
+ name: "Empty status",
+ legalHold: &ObjectLegalHold{
+ Status: "",
+ },
+ expectError: true,
+ errorMsg: "invalid legal hold status",
+ },
+ {
+ name: "Lowercase on",
+ legalHold: &ObjectLegalHold{
+ Status: "on",
+ },
+ expectError: true,
+ errorMsg: "invalid legal hold status",
+ },
+ {
+ name: "Lowercase off",
+ legalHold: &ObjectLegalHold{
+ Status: "off",
+ },
+ expectError: true,
+ errorMsg: "invalid legal hold status",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateLegalHold(tt.legalHold)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestParseObjectRetention(t *testing.T) {
+ tests := []struct {
+ name string
+ xmlBody string
+ expectError bool
+ errorMsg string
+ expectedResult *ObjectRetention
+ }{
+ {
+ name: "Valid retention XML",
+ xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Mode>GOVERNANCE</Mode>
+ <RetainUntilDate>2024-12-31T23:59:59Z</RetainUntilDate>
+ </Retention>`,
+ expectError: false,
+ expectedResult: &ObjectRetention{
+ Mode: "GOVERNANCE",
+ RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)),
+ },
+ },
+ {
+ name: "Valid compliance retention XML",
+ xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Mode>COMPLIANCE</Mode>
+ <RetainUntilDate>2025-01-01T00:00:00Z</RetainUntilDate>
+ </Retention>`,
+ expectError: false,
+ expectedResult: &ObjectRetention{
+ Mode: "COMPLIANCE",
+ RetainUntilDate: timePtr(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
+ },
+ },
+ {
+ name: "Empty XML body",
+ xmlBody: "",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ {
+ name: "Invalid XML",
+ xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Mode>GOVERNANCE</Mode><RetainUntilDate>invalid-date</RetainUntilDate></Retention>`,
+ expectError: true,
+ errorMsg: "cannot parse",
+ },
+ {
+ name: "Malformed XML",
+ xmlBody: "<Retention><Mode>GOVERNANCE</Mode><RetainUntilDate>2024-12-31T23:59:59Z</Retention>",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ {
+ name: "Missing Mode",
+ xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <RetainUntilDate>2024-12-31T23:59:59Z</RetainUntilDate>
+ </Retention>`,
+ expectError: false,
+ expectedResult: &ObjectRetention{
+ Mode: "",
+ RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)),
+ },
+ },
+ {
+ name: "Missing RetainUntilDate",
+ xmlBody: `<Retention xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Mode>GOVERNANCE</Mode>
+ </Retention>`,
+ expectError: false,
+ expectedResult: &ObjectRetention{
+ Mode: "GOVERNANCE",
+ RetainUntilDate: nil,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a mock HTTP request with XML body
+ req := &http.Request{
+ Body: io.NopCloser(strings.NewReader(tt.xmlBody)),
+ }
+
+ result, err := parseObjectRetention(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Errorf("Expected result but got nil")
+ } else {
+ if result.Mode != tt.expectedResult.Mode {
+ t.Errorf("Expected Mode %s, got %s", tt.expectedResult.Mode, result.Mode)
+ }
+ if tt.expectedResult.RetainUntilDate == nil {
+ if result.RetainUntilDate != nil {
+ t.Errorf("Expected RetainUntilDate to be nil, got %v", result.RetainUntilDate)
+ }
+ } else if result.RetainUntilDate == nil {
+ t.Errorf("Expected RetainUntilDate to be %v, got nil", tt.expectedResult.RetainUntilDate)
+ } else if !result.RetainUntilDate.Equal(*tt.expectedResult.RetainUntilDate) {
+ t.Errorf("Expected RetainUntilDate %v, got %v", tt.expectedResult.RetainUntilDate, result.RetainUntilDate)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestParseObjectLegalHold(t *testing.T) {
+ tests := []struct {
+ name string
+ xmlBody string
+ expectError bool
+ errorMsg string
+ expectedResult *ObjectLegalHold
+ }{
+ {
+ name: "Valid legal hold ON",
+ xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Status>ON</Status>
+ </LegalHold>`,
+ expectError: false,
+ expectedResult: &ObjectLegalHold{
+ Status: "ON",
+ },
+ },
+ {
+ name: "Valid legal hold OFF",
+ xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <Status>OFF</Status>
+ </LegalHold>`,
+ expectError: false,
+ expectedResult: &ObjectLegalHold{
+ Status: "OFF",
+ },
+ },
+ {
+ name: "Empty XML body",
+ xmlBody: "",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ {
+ name: "Invalid XML",
+ xmlBody: "<LegalHold><Status>ON</Status>",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ {
+ name: "Missing Status",
+ xmlBody: `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ </LegalHold>`,
+ expectError: false,
+ expectedResult: &ObjectLegalHold{
+ Status: "",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a mock HTTP request with XML body
+ req := &http.Request{
+ Body: io.NopCloser(strings.NewReader(tt.xmlBody)),
+ }
+
+ result, err := parseObjectLegalHold(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Errorf("Expected result but got nil")
+ } else {
+ if result.Status != tt.expectedResult.Status {
+ t.Errorf("Expected Status %s, got %s", tt.expectedResult.Status, result.Status)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestParseObjectLockConfiguration(t *testing.T) {
+ tests := []struct {
+ name string
+ xmlBody string
+ expectError bool
+ errorMsg string
+ expectedResult *ObjectLockConfiguration
+ }{
+ {
+ name: "Valid object lock configuration",
+ xmlBody: `<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <ObjectLockEnabled>Enabled</ObjectLockEnabled>
+ </ObjectLockConfiguration>`,
+ expectError: false,
+ expectedResult: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ },
+ },
+ {
+ name: "Valid object lock configuration with rule",
+ xmlBody: `<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
+ <ObjectLockEnabled>Enabled</ObjectLockEnabled>
+ <Rule>
+ <DefaultRetention>
+ <Mode>GOVERNANCE</Mode>
+ <Days>30</Days>
+ </DefaultRetention>
+ </Rule>
+ </ObjectLockConfiguration>`,
+ expectError: false,
+ expectedResult: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 30,
+ },
+ },
+ },
+ },
+ {
+ name: "Empty XML body",
+ xmlBody: "",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ {
+ name: "Invalid XML",
+ xmlBody: "<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled>",
+ expectError: true,
+ errorMsg: "error parsing XML",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a mock HTTP request with XML body
+ req := &http.Request{
+ Body: io.NopCloser(strings.NewReader(tt.xmlBody)),
+ }
+
+ result, err := parseObjectLockConfiguration(req)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Errorf("Expected result but got nil")
+ } else {
+ if result.ObjectLockEnabled != tt.expectedResult.ObjectLockEnabled {
+ t.Errorf("Expected ObjectLockEnabled %s, got %s", tt.expectedResult.ObjectLockEnabled, result.ObjectLockEnabled)
+ }
+ if tt.expectedResult.Rule == nil {
+ if result.Rule != nil {
+ t.Errorf("Expected Rule to be nil, got %v", result.Rule)
+ }
+ } else if result.Rule == nil {
+ t.Errorf("Expected Rule to be non-nil")
+ } else {
+ if result.Rule.DefaultRetention == nil {
+ t.Errorf("Expected DefaultRetention to be non-nil")
+ } else {
+ if result.Rule.DefaultRetention.Mode != tt.expectedResult.Rule.DefaultRetention.Mode {
+ t.Errorf("Expected DefaultRetention Mode %s, got %s", tt.expectedResult.Rule.DefaultRetention.Mode, result.Rule.DefaultRetention.Mode)
+ }
+ if result.Rule.DefaultRetention.Days != tt.expectedResult.Rule.DefaultRetention.Days {
+ t.Errorf("Expected DefaultRetention Days %d, got %d", tt.expectedResult.Rule.DefaultRetention.Days, result.Rule.DefaultRetention.Days)
+ }
+ }
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestValidateObjectLockConfiguration(t *testing.T) {
+ tests := []struct {
+ name string
+ config *ObjectLockConfiguration
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "Valid config with ObjectLockEnabled only",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ },
+ expectError: false,
+ },
+ {
+ name: "Missing ObjectLockEnabled",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "",
+ },
+ expectError: true,
+ errorMsg: "object lock configuration must specify ObjectLockEnabled",
+ },
+ {
+ name: "Valid config with rule and days",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 30,
+ },
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Valid config with rule and years",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "COMPLIANCE",
+ Years: 1,
+ },
+ },
+ },
+ expectError: false,
+ },
+ {
+ name: "Invalid ObjectLockEnabled value",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "InvalidValue",
+ },
+ expectError: true,
+ errorMsg: "invalid object lock enabled value",
+ },
+ {
+ name: "Invalid rule - missing mode",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Days: 30,
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: "default retention must specify Mode",
+ },
+ {
+ name: "Invalid rule - both days and years",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 30,
+ Years: 1,
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: "default retention cannot specify both Days and Years",
+ },
+ {
+ name: "Invalid rule - neither days nor years",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: "default retention must specify either Days or Years",
+ },
+ {
+ name: "Invalid rule - invalid mode",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "INVALID_MODE",
+ Days: 30,
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: "invalid default retention mode",
+ },
+ {
+ name: "Invalid rule - days out of range",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 50000,
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: fmt.Sprintf("default retention days must be between 0 and %d", MaxRetentionDays),
+ },
+ {
+ name: "Invalid rule - years out of range",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Years: 200,
+ },
+ },
+ },
+ expectError: true,
+ errorMsg: fmt.Sprintf("default retention years must be between 0 and %d", MaxRetentionYears),
+ },
+ {
+ name: "Invalid rule - missing DefaultRetention",
+ config: &ObjectLockConfiguration{
+ ObjectLockEnabled: "Enabled",
+ Rule: &ObjectLockRule{
+ DefaultRetention: nil,
+ },
+ },
+ expectError: true,
+ errorMsg: "rule configuration must specify DefaultRetention",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateObjectLockConfiguration(tt.config)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestValidateDefaultRetention(t *testing.T) {
+ tests := []struct {
+ name string
+ retention *DefaultRetention
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "Valid retention with days",
+ retention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 30,
+ },
+ expectError: false,
+ },
+ {
+ name: "Valid retention with years",
+ retention: &DefaultRetention{
+ Mode: "COMPLIANCE",
+ Years: 1,
+ },
+ expectError: false,
+ },
+ {
+ name: "Missing mode",
+ retention: &DefaultRetention{
+ Days: 30,
+ },
+ expectError: true,
+ errorMsg: "default retention must specify Mode",
+ },
+ {
+ name: "Invalid mode",
+ retention: &DefaultRetention{
+ Mode: "INVALID",
+ Days: 30,
+ },
+ expectError: true,
+ errorMsg: "invalid default retention mode",
+ },
+ {
+ name: "Both days and years specified",
+ retention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ Days: 30,
+ Years: 1,
+ },
+ expectError: true,
+ errorMsg: "default retention cannot specify both Days and Years",
+ },
+ {
+ name: "Neither days nor years specified",
+ retention: &DefaultRetention{
+ Mode: "GOVERNANCE",
+ },
+ expectError: true,
+ errorMsg: "default retention must specify either Days or Years",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateDefaultRetention(tt.retention)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if !strings.Contains(err.Error(), tt.errorMsg) {
+ t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+// Helper function to create a time pointer
+func timePtr(t time.Time) *time.Time {
+ return &t
+}
diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go
index 28eac9951..426535fe0 100644
--- a/weed/s3api/s3api_server.go
+++ b/weed/s3api/s3api_server.go
@@ -206,11 +206,13 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectRetentionHandler, ACTION_WRITE)), "PUT")).Queries("retention", "")
// PutObjectLegalHold
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLegalHoldHandler, ACTION_WRITE)), "PUT")).Queries("legal-hold", "")
- // PutObjectLockConfiguration
- bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "")
// GetObjectACL
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectAclHandler, ACTION_READ_ACP)), "GET")).Queries("acl", "")
+ // GetObjectRetention
+ bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectRetentionHandler, ACTION_READ)), "GET")).Queries("retention", "")
+ // GetObjectLegalHold
+ bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLegalHoldHandler, ACTION_READ)), "GET")).Queries("legal-hold", "")
// objects with query
@@ -272,6 +274,10 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketVersioningHandler, ACTION_READ)), "GET")).Queries("versioning", "")
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketVersioningHandler, ACTION_WRITE)), "PUT")).Queries("versioning", "")
+ // GetObjectLockConfiguration / PutObjectLockConfiguration (bucket-level operations)
+ bucket.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLockConfigurationHandler, ACTION_READ)), "GET")).Queries("object-lock", "")
+ bucket.Methods(http.MethodPut).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "")
+
// GetBucketTagging
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketTaggingHandler, ACTION_TAGGING)), "GET")).Queries("tagging", "")
bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketTaggingHandler, ACTION_TAGGING)), "PUT")).Queries("tagging", "")
diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go
index bcb0a26a8..17057f604 100644
--- a/weed/s3api/s3err/s3api_errors.go
+++ b/weed/s3api/s3err/s3api_errors.go
@@ -110,6 +110,8 @@ const (
OwnershipControlsNotFoundError
ErrNoSuchTagSet
+ ErrNoSuchObjectLockConfiguration
+ ErrNoSuchObjectLegalHold
)
// Error message constants for checksum validation
@@ -197,6 +199,16 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "The TagSet does not exist",
HTTPStatusCode: http.StatusNotFound,
},
+ ErrNoSuchObjectLockConfiguration: {
+ Code: "NoSuchObjectLockConfiguration",
+ Description: "The specified object does not have an ObjectLock configuration",
+ HTTPStatusCode: http.StatusNotFound,
+ },
+ ErrNoSuchObjectLegalHold: {
+ Code: "NoSuchObjectLegalHold",
+ Description: "The specified object does not have a legal hold configuration",
+ HTTPStatusCode: http.StatusNotFound,
+ },
ErrNoSuchCORSConfiguration: {
Code: "NoSuchCORSConfiguration",
Description: "The CORS configuration does not exist",