fix(s3): enforce Object Lock on PutObjectRetention

Merged
ER
6d ago
+666-43

Fixes #2717

PutObjectRetention overwrote a version's retention unconditionally. It now checks the existing retention first, as S3 does:

  • Under an unexpired COMPLIANCE retention, shortening the retain-until date, changing the mode, or clearing the retention returns 403 AccessDenied, with or without x-amz-bypass-governance-retention.
  • Under an unexpired GOVERNANCE retention, the same changes return 403 AccessDenied unless x-amz-bypass-governance-retention: true is sent.
  • Extending the date with the same mode is always allowed, and expired retentions can be replaced.

The bypass-header parsing is shared with the existing overwrite lock check.

A second commit fixes the minor point from the issue: a bucket default retention in Years now adds calendar years instead of years * 365 days.

Test plan

  • New unit tests in fakecloud-s3 covering GOVERNANCE with and without bypass, COMPLIANCE with bypass, mode change, clearing, extending, expired retention, the version-id repro from the issue, and the leap-day Years case. The four regression tests fail on main and pass with this change.
  • cargo test -p fakecloud-s3, cargo clippy -p fakecloud-s3 --all-targets -- -D warnings, cargo fmt --all --check

Summary by cubic

Fixes #2717. PutObjectRetention was overwriting a version's retention unconditionally; it now enforces Object Lock rules like S3. Under an unexpired COMPLIANCE retention, shortening the retain-until date, changing the mode, or clearing the retention returns 403 AccessDenied, with or without the bypass header. Under GOVERNANCE the same changes return 403 unless x-amz-bypass-governance-retention: true is sent. Extending the date with the same mode is always allowed, and expired retentions can be replaced. The bypass-header parse is shared with the existing overwrite lock check.

Sending the bypass header now also requires s3:BypassGovernanceRetention on the object for PutObjectRetention and DeleteObject under IAM enforcement; DeleteObjects is excluded because AWS authorizes it per key and reports a denial as an <Error> entry in a 200 response.

Bucket default retentions specified in Years now add calendar years instead of years * 365 days, so leap days count; Days and Years values are trimmed before parsing, matching the stored-config validator.