Files
versitygw/tests/integration/PutObjectLockConfiguration.go
T
niksis02 bbad4b5988 fix: store bucket default retention on each object at write time and support Object Lock fields in browser POST
The bucket default retention was never stored on objects: protection was computed at delete time from the live bucket rule (`CreatedAt` + period), so changing or removing the rule unlocked existing objects, `COMPLIANCE` ones included, and a rule set N days ago stopped protecting even new objects. The posix and azure backends now store the rule's retention, `{Mode, now + Days/Years}` truncated to milliseconds, as the object's own retention whenever a write sets no Object Lock parameters: `PutObject` (which also covers POST, RDMA and `CopyObject`), `CreateMultipartUpload` (the rule and start time are fixed at creation, as S3 does) and the unversioned in-place self-copy, which applied no lock settings before. As on S3, any explicit `x-amz-object-lock-legal-hold`, `ON` or `OFF`, means the default retention is not applied. The bucket-rule fallback in `checkObject` and `BucketLockConfig.CreatedAt` are removed, so objects written before this change lose bucket-derived protection, and an expired retention no longer skips the legal hold check.

`PutObjectLockConfiguration` now rejects a `DefaultRetention` with a mode but no `Days`/`Years` with `InvalidRequest` "DefaultRetention must specify a fixed retention (Days or Years) or a DefaultEventHold.", and a `<Rule>` without `DefaultRetention` with `MalformedXML`. Azure `CopyObject` with the `COPY` metadata directive no longer copies the source's `Objectretention`/`Objectlegalhold` metadata to the destination.

Browser POST uploads now validate and apply the `x-amz-object-lock-mode`, `x-amz-object-lock-retain-until-date` and `x-amz-object-lock-legal-hold` form fields. They require `s3:PutObjectRetention`/`s3:PutObjectLegalHold`, for anonymous uploads too, and return `ErrMissingObjectLockConfigurationNoSpaces` on buckets without Object Lock after authorization. A denied POST names the actions in S3's order: retention, legal hold, tagging, then `s3:PutObject`.

`NoSuchObjectLockConfiguration` now returns 404 without a trailing period. `x-amz-object-lock-retain-until-date` on HEAD and GET has millisecond precision and omits zero milliseconds, `GetObjectRetention` returns `<Retention>` with a `.000Z` date, and posix and azure `GetObject` now return the Object Lock headers.
2026-10-04 22:01:57 +02:00

375 lines
12 KiB
Go

// Copyright 2023 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"crypto/md5"
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/s3err"
)
func PutObjectLockConfiguration_non_existing_bucket(s *S3Conf) error {
testName := "PutObjectLockConfiguration_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: getPtr(getBucketName()),
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Mode: types.ObjectLockRetentionModeCompliance,
Days: getPtr(int32(10)),
},
},
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_empty_request_body(s *S3Conf) error {
testName := "PutObjectLockConfiguration_empty_request_body"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMissingRequestBody)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_malformed_body(s *S3Conf) error {
testName := "PutObjectLockConfiguration_malformed_body"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
body := []byte("malformed_body")
hasher := md5.New()
_, err := hasher.Write(body)
if err != nil {
return err
}
sum := hasher.Sum(nil)
md5Sum := base64.StdEncoding.EncodeToString(sum)
req, err := createSignedReq(
http.MethodPut,
s.endpoint,
fmt.Sprintf("%s?object-lock", bucket),
s.awsID,
s.awsSecret,
"s3",
s.awsRegion,
"",
body,
time.Now(),
map[string]string{"Content-Md5": md5Sum},
)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("err sending request: %w", err)
}
if err := checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_not_enabled_on_bucket_creation(s *S3Conf) error {
testName := "PutObjectLockConfiguration_not_enabled_on_bucket_creation"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
var days int32 = 12
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Days: &days,
Mode: types.ObjectLockRetentionModeCompliance,
},
},
},
})
cancel()
// this test cases address the successful object lock status upload
// on versioning-disabled gateway mode, where versioning is not supported
// and object lock may be enabled without bucket versioning status check
// Note: this is not S3 compatible feature.
return err
})
}
func PutObjectLockConfiguration_invalid_status(s *S3Conf) error {
testName := "PutObjectLockConfiguration_invalid_status"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
var days int32 = 12
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabled("invalid_status"),
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Days: &days,
},
},
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_invalid_mode(s *S3Conf) error {
testName := "PutObjectLockConfiguration_invalid_status"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
var days int32 = 12
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Days: &days,
Mode: types.ObjectLockRetentionMode("invalid_mode"),
},
},
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_both_years_and_days(s *S3Conf) error {
testName := "PutObjectLockConfiguration_both_years_and_days"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
var days, years int32 = 12, 24
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Days: &days,
Years: &years,
},
},
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil {
return err
}
return nil
})
}
func PutObjectLockConfiguration_invalid_years_days(s *S3Conf) error {
testName := "PutObjectLockConfiguration_invalid_years_days"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
putObjectLockConfiguration := func(retention *types.DefaultRetention) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: retention,
},
},
})
cancel()
return err
}
tests := []struct {
name string
retention *types.DefaultRetention
err s3err.InvalidArgumentError
}{
{
name: "negative days",
retention: &types.DefaultRetention{
Days: getPtr[int32](-3),
Mode: types.ObjectLockRetentionModeCompliance,
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockRetentionDays, "-3"),
},
{
name: "too many days",
retention: &types.DefaultRetention{
Days: getPtr[int32](36501),
Mode: types.ObjectLockRetentionModeCompliance,
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockRetentionDaysTooLarge, "36501"),
},
{
name: "negative years",
retention: &types.DefaultRetention{
Years: getPtr[int32](-5),
Mode: types.ObjectLockRetentionModeCompliance,
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockRetentionYears, "-5"),
},
{
name: "too many years",
retention: &types.DefaultRetention{
Years: getPtr[int32](101),
Mode: types.ObjectLockRetentionModeCompliance,
},
err: s3err.GetInvalidArgumentErr(s3err.InvalidArgObjectLockRetentionYearsTooLarge, "101"),
},
}
for _, test := range tests {
err := putObjectLockConfiguration(test.retention)
if err := checkApiErr(err, test.err); err != nil {
return fmt.Errorf("%s: %w", test.name, err)
}
}
return nil
})
}
// PutObjectLockConfiguration_default_retention_missing_period covers a
// default retention rule with a mode but neither Days nor Years: it is
// rejected, and the bucket keeps the rule it had.
func PutObjectLockConfiguration_default_retention_missing_period(s *S3Conf) error {
testName := "PutObjectLockConfiguration_default_retention_missing_period"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
putObjectLockConfiguration := func(retention *types.DefaultRetention) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: retention,
},
},
})
cancel()
return err
}
err := putObjectLockConfiguration(&types.DefaultRetention{
Mode: types.ObjectLockRetentionModeGovernance,
Days: getPtr(int32(1)),
})
if err != nil {
return err
}
err = putObjectLockConfiguration(&types.DefaultRetention{
Mode: types.ObjectLockRetentionModeCompliance,
})
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrObjectLockDefaultRetentionPeriodRequired)); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.GetObjectLockConfiguration(ctx, &s3.GetObjectLockConfigurationInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
cfg := out.ObjectLockConfiguration
if cfg == nil || cfg.Rule == nil || cfg.Rule.DefaultRetention == nil {
return fmt.Errorf("expected the default retention rule to be kept, instead got %+v", cfg)
}
retention := cfg.Rule.DefaultRetention
if retention.Mode != types.ObjectLockRetentionModeGovernance || getInt32(retention.Days) != 1 || retention.Years != nil {
return fmt.Errorf("expected the default retention to stay %v for 1 day, instead got %v for %v days, %v years",
types.ObjectLockRetentionModeGovernance, retention.Mode, getInt32(retention.Days), getInt32(retention.Years))
}
return nil
}, withLock())
}
// PutObjectLockConfiguration_rule_without_default_retention covers a <Rule>
// element with no <DefaultRetention> in it.
func PutObjectLockConfiguration_rule_without_default_retention(s *S3Conf) error {
testName := "PutObjectLockConfiguration_rule_without_default_retention"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{},
},
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML))
}, withLock())
}
func PutObjectLockConfiguration_success(s *S3Conf) error {
testName := "PutObjectLockConfiguration_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
},
})
cancel()
if err != nil {
return err
}
return nil
}, withLock())
}