Files
seaweedFS/test/s3/retention/s3_object_lock_headers_test.go
Chris Lu 55f0fbf364 s3: optimize DELETE by skipping lock check for buckets without Object Lock (#7642)
This optimization avoids an expensive filer gRPC call for every DELETE
operation on buckets that don't have Object Lock enabled.

Before this change, enforceObjectLockProtections() would always call
getObjectEntry() to fetch object metadata to check for retention/legal
hold, even for buckets that never had Object Lock configured.

Changes:
1. Add early return in enforceObjectLockProtections() if bucket has no
   Object Lock config or bucket doesn't exist
2. Add isObjectLockEnabled() helper function to check if a bucket has
   Object Lock configured
3. Fix validateObjectLockHeaders() to check ObjectLockConfig instead of
   just versioningEnabled - this ensures object-lock headers are properly
   rejected on buckets without Object Lock enabled, which aligns with
   AWS S3 semantics
4. Make bucket creation with Object Lock atomic - set Object Lock config
   in the same CreateEntry call as bucket creation, preventing race
   conditions where bucket exists without Object Lock enabled
5. Properly handle Object Lock setup failures during bucket creation -
   if StoreObjectLockConfigurationInExtended fails, roll back the bucket
   creation and return an error instead of leaving a bucket without
   the requested Object Lock configuration

This significantly improves DELETE latency for non-Object-Lock buckets,
which is the common case (lockCheck time reduced from 1-10ms to ~1µs).
2025-12-06 21:37:25 -08:00

308 lines
10 KiB
Go

package retention
import (
"context"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPutObjectWithLockHeaders tests that object lock headers in PUT requests
// are properly stored and returned in HEAD responses
func TestPutObjectWithLockHeaders(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
// Create bucket with object lock enabled and versioning
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
key := "test-object-lock-headers"
content := "test content with object lock headers"
retainUntilDate := time.Now().Add(24 * time.Hour)
// Test 1: PUT with COMPLIANCE mode and retention date
t.Run("PUT with COMPLIANCE mode", func(t *testing.T) {
testKey := key + "-compliance"
// PUT object with lock headers
putResp := putObjectWithLockHeaders(t, client, bucketName, testKey, content,
"COMPLIANCE", retainUntilDate, "")
require.NotNil(t, putResp.VersionId)
// HEAD object and verify lock headers are returned
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(testKey),
})
require.NoError(t, err)
// Verify object lock metadata is present in response
assert.Equal(t, types.ObjectLockModeCompliance, headResp.ObjectLockMode)
assert.NotNil(t, headResp.ObjectLockRetainUntilDate)
assert.WithinDuration(t, retainUntilDate, *headResp.ObjectLockRetainUntilDate, 5*time.Second)
})
// Test 2: PUT with GOVERNANCE mode and retention date
t.Run("PUT with GOVERNANCE mode", func(t *testing.T) {
testKey := key + "-governance"
putResp := putObjectWithLockHeaders(t, client, bucketName, testKey, content,
"GOVERNANCE", retainUntilDate, "")
require.NotNil(t, putResp.VersionId)
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(testKey),
})
require.NoError(t, err)
assert.Equal(t, types.ObjectLockModeGovernance, headResp.ObjectLockMode)
assert.NotNil(t, headResp.ObjectLockRetainUntilDate)
assert.WithinDuration(t, retainUntilDate, *headResp.ObjectLockRetainUntilDate, 5*time.Second)
})
// Test 3: PUT with legal hold
t.Run("PUT with legal hold", func(t *testing.T) {
testKey := key + "-legal-hold"
putResp := putObjectWithLockHeaders(t, client, bucketName, testKey, content,
"", time.Time{}, "ON")
require.NotNil(t, putResp.VersionId)
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(testKey),
})
require.NoError(t, err)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, headResp.ObjectLockLegalHoldStatus)
})
// Test 4: PUT with both retention and legal hold
t.Run("PUT with both retention and legal hold", func(t *testing.T) {
testKey := key + "-both"
putResp := putObjectWithLockHeaders(t, client, bucketName, testKey, content,
"GOVERNANCE", retainUntilDate, "ON")
require.NotNil(t, putResp.VersionId)
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(testKey),
})
require.NoError(t, err)
assert.Equal(t, types.ObjectLockModeGovernance, headResp.ObjectLockMode)
assert.NotNil(t, headResp.ObjectLockRetainUntilDate)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, headResp.ObjectLockLegalHoldStatus)
})
}
// TestGetObjectWithLockHeaders verifies that GET requests also return object lock metadata
func TestGetObjectWithLockHeaders(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
key := "test-get-object-lock"
content := "test content for GET with lock headers"
retainUntilDate := time.Now().Add(24 * time.Hour)
// PUT object with lock headers
putResp := putObjectWithLockHeaders(t, client, bucketName, key, content,
"COMPLIANCE", retainUntilDate, "ON")
require.NotNil(t, putResp.VersionId)
// GET object and verify lock headers are returned
getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err)
defer getResp.Body.Close()
// Verify object lock metadata is present in GET response
assert.Equal(t, types.ObjectLockModeCompliance, getResp.ObjectLockMode)
assert.NotNil(t, getResp.ObjectLockRetainUntilDate)
assert.WithinDuration(t, retainUntilDate, *getResp.ObjectLockRetainUntilDate, 5*time.Second)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, getResp.ObjectLockLegalHoldStatus)
}
// TestVersionedObjectLockHeaders tests object lock headers work with versioned objects
func TestVersionedObjectLockHeaders(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
key := "test-versioned-lock"
content1 := "version 1 content"
content2 := "version 2 content"
retainUntilDate1 := time.Now().Add(12 * time.Hour)
retainUntilDate2 := time.Now().Add(24 * time.Hour)
// PUT first version with GOVERNANCE mode
putResp1 := putObjectWithLockHeaders(t, client, bucketName, key, content1,
"GOVERNANCE", retainUntilDate1, "")
require.NotNil(t, putResp1.VersionId)
// PUT second version with COMPLIANCE mode
putResp2 := putObjectWithLockHeaders(t, client, bucketName, key, content2,
"COMPLIANCE", retainUntilDate2, "ON")
require.NotNil(t, putResp2.VersionId)
require.NotEqual(t, *putResp1.VersionId, *putResp2.VersionId)
// HEAD latest version (version 2)
headResp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err)
assert.Equal(t, types.ObjectLockModeCompliance, headResp.ObjectLockMode)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, headResp.ObjectLockLegalHoldStatus)
// HEAD specific version 1
headResp1, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
VersionId: putResp1.VersionId,
})
require.NoError(t, err)
assert.Equal(t, types.ObjectLockModeGovernance, headResp1.ObjectLockMode)
assert.NotEqual(t, types.ObjectLockLegalHoldStatusOn, headResp1.ObjectLockLegalHoldStatus)
}
// TestObjectLockHeadersErrorCases tests various error scenarios
func TestObjectLockHeadersErrorCases(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
createBucketWithObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
key := "test-error-cases"
content := "test content for error cases"
// Test 1: Invalid retention mode should be rejected
t.Run("Invalid retention mode", func(t *testing.T) {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key + "-invalid-mode"),
Body: strings.NewReader(content),
ObjectLockMode: "INVALID_MODE", // Invalid mode
ObjectLockRetainUntilDate: aws.Time(time.Now().Add(24 * time.Hour)),
})
require.Error(t, err)
})
// Test 2: Retention date in the past should be rejected
t.Run("Past retention date", func(t *testing.T) {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key + "-past-date"),
Body: strings.NewReader(content),
ObjectLockMode: "GOVERNANCE",
ObjectLockRetainUntilDate: aws.Time(time.Now().Add(-24 * time.Hour)), // Past date
})
require.Error(t, err)
})
// Test 3: Mode without date should be rejected
t.Run("Mode without retention date", func(t *testing.T) {
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key + "-no-date"),
Body: strings.NewReader(content),
ObjectLockMode: "GOVERNANCE",
// Missing ObjectLockRetainUntilDate
})
require.Error(t, err)
})
}
// TestObjectLockHeadersNonVersionedBucket tests that object lock fails on non-versioned buckets
func TestObjectLockHeadersNonVersionedBucket(t *testing.T) {
client := getS3Client(t)
bucketName := getNewBucketName()
// Create regular bucket without object lock/versioning
createBucketWithoutObjectLock(t, client, bucketName)
defer deleteBucket(t, client, bucketName)
key := "test-non-versioned"
content := "test content"
retainUntilDate := time.Now().Add(24 * time.Hour)
// Attempting to PUT with object lock headers should fail
_, err := client.PutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: strings.NewReader(content),
ObjectLockMode: "GOVERNANCE",
ObjectLockRetainUntilDate: aws.Time(retainUntilDate),
})
require.Error(t, err)
}
// Helper Functions
// putObjectWithLockHeaders puts an object with object lock headers
func putObjectWithLockHeaders(t *testing.T, client *s3.Client, bucketName, key, content string,
mode string, retainUntilDate time.Time, legalHold string) *s3.PutObjectOutput {
input := &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
Body: strings.NewReader(content),
}
// Add retention mode and date if specified
if mode != "" {
switch mode {
case "COMPLIANCE":
input.ObjectLockMode = types.ObjectLockModeCompliance
case "GOVERNANCE":
input.ObjectLockMode = types.ObjectLockModeGovernance
}
if !retainUntilDate.IsZero() {
input.ObjectLockRetainUntilDate = aws.Time(retainUntilDate)
}
}
// Add legal hold if specified
if legalHold != "" {
switch legalHold {
case "ON":
input.ObjectLockLegalHoldStatus = types.ObjectLockLegalHoldStatusOn
case "OFF":
input.ObjectLockLegalHoldStatus = types.ObjectLockLegalHoldStatusOff
}
}
resp, err := client.PutObject(context.TODO(), input)
require.NoError(t, err)
return resp
}
// createBucketWithObjectLock creates a bucket with object lock enabled
func createBucketWithObjectLock(t *testing.T, client *s3.Client, bucketName string) {
_, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
ObjectLockEnabledForBucket: aws.Bool(true),
})
require.NoError(t, err)
// Enable versioning (required for object lock)
enableVersioning(t, client, bucketName)
}