Enforcing Retention Policies on WORM Buckets
This guide sets a bucket-wide default retention policy on a WORM bucket, layers per-object overrides on top of it, and reconciles those windows against statutory limits so no redaction artifact expires before the law allows. It is the policy-level companion to WORM Storage Synchronization, distinct from the per-object upload path.
Prerequisites & Version Matrix Permalink to this section
| Component | Version | Purpose |
|---|---|---|
| Python | 3.10+ | typing and datetime.timezone |
| boto3 | 1.34+ | put_object_lock_configuration support |
| botocore | 1.34+ | Signs lock-configuration requests |
| Bucket | Object Lock enabled | Default retention requires a lock-enabled bucket |
The principal needs s3:PutBucketObjectLockConfiguration and s3:GetBucketObjectLockConfiguration for the bucket default, plus s3:PutObjectRetention for any per-object override. A separate identity holding s3:BypassGovernanceRetention should be tightly scoped, since it is the only way to shorten governance-mode retention.
Step 1: Set a Default Retention Policy Permalink to this section
A default retention configuration means every object written to the bucket inherits a mode and duration without the uploader specifying one. Set the duration in whole Years or Days, not both.
import boto3
s3 = boto3.client("s3", region_name="us-east-1")
BUCKET = "matter-4471-redaction-worm"
s3.put_object_lock_configuration(
Bucket=BUCKET,
ObjectLockConfiguration={
"ObjectLockEnabled": "Enabled",
"Rule": {"DefaultRetention": {"Mode": "COMPLIANCE", "Years": 6}},
},
)
Step 2: Apply a Per-Object Override Permalink to this section
Some artifacts — a document tied to active litigation — need a longer window than the bucket default. Override retention on the individual object version with put_object_retention; the override must extend, not shorten, a compliance-mode date.
from datetime import datetime, timedelta, timezone
extended = datetime.now(timezone.utc) + timedelta(days=365 * 10)
s3.put_object_retention(
Bucket=BUCKET,
Key="2026/07/deposition-0012.redacted.pdf",
Retention={"Mode": "COMPLIANCE", "RetainUntilDate": extended},
)
For documents whose end date is unknown — an open matter with no scheduled close — prefer a legal hold over an arbitrarily long retention date, because a hold can be released cleanly once the matter resolves:
s3.put_object_legal_hold(
Bucket=BUCKET,
Key="2026/07/deposition-0012.redacted.pdf",
LegalHold={"Status": "ON"},
)
Step 3: Reconcile Windows to Statutory Limits Permalink to this section
The default duration is a policy decision, not a guess. Derive it from the governing schedule so the configured window is provably at least the statutory minimum.
# Minimum retention in years, keyed by record class (illustrative).
STATUTORY_MINIMUMS = {"broker_dealer": 6, "tax": 7, "hipaa": 6}
def required_years(record_classes: set[str]) -> int:
# The window must cover the longest applicable obligation.
return max(STATUTORY_MINIMUMS[c] for c in record_classes)
configured = 6
needed = required_years({"broker_dealer", "hipaa"})
assert configured >= needed, f"retention {configured}y under statutory {needed}y"
Compliance Checkpoint Permalink to this section
Default retention operationalizes NIST SP 800-53 Rev. 5 control AU-9 (Protection of Audit Information) by guaranteeing every audit artifact lands non-erasable without relying on the uploader to remember, and control AU-11 (Audit Record Retention) by pinning a minimum retention period derived from the governing schedule. Reconciling configured against STATUTORY_MINIMUMS is the concrete AU-11 control: it fails the deployment if the bucket default falls below the longest applicable obligation, which for broker-dealer records aligns with the retention periods incorporated by SEC Rule 17a-4(f).
Gotchas Permalink to this section
- Governance mode is bypassable by design. Any principal with
s3:BypassGovernanceRetentioncan shorten or delete governance-locked objects. If the threat model includes insiders, use compliance mode, which no identity can bypass before expiry. - Legal hold overrides expiry entirely. An object under a legal hold stays immutable past its retain-until date until the hold is explicitly set to
OFF; a scheduled purge that ignores hold status will silently skip those objects — surface held keys in retention reports. - Default retention is not retroactive. Changing the bucket default only affects objects written after the change; existing versions keep the retention they were stored with. Re-stamp older objects individually with
put_object_retentionif the window must grow. - Years and Days are mutually exclusive. Supplying both in
DefaultRetentionis rejected; pick one unit.
Verification & Testing Permalink to this section
Read the configuration back and assert on it deterministically so a drifted policy fails CI rather than an audit:
cfg = s3.get_object_lock_configuration(Bucket=BUCKET)
rule = cfg["ObjectLockConfiguration"]["Rule"]["DefaultRetention"]
assert cfg["ObjectLockConfiguration"]["ObjectLockEnabled"] == "Enabled"
assert rule["Mode"] == "COMPLIANCE"
assert rule["Years"] >= required_years({"broker_dealer", "hipaa"})
print(f"PASS: default {rule['Mode']} retention {rule['Years']}y")
# -> PASS: default COMPLIANCE retention 6y
Related Permalink to this section
Provision the bucket and upload individual artifacts in Syncing Redaction Artifacts to S3 Object Lock, review the modes and failure handling in WORM Storage Synchronization, and cross-check the integrity records in Cryptographic Audit Manifests. Part of the Immutable Audit Trails & Chain of Custody field guide.