Configuring S3 Object Lock for WORM Retention
Within the broader WORM Storage for DSR Evidence cluster, this page is the concrete implementation: how to configure S3 Object Lock so that a sealed Data Subject Request (DSR) evidence object is genuinely write-once-read-many, immutable to every principal for a retention window computed from request closure. The engineers who hit this are privacy and platform teams standing up the Audit Logging & Compliance Evidence layer for the first time, and the failure they need to avoid is subtle: object storage that looks protected — a bucket with a lifecycle rule, a policy that denies deletes — but that a privileged principal or a compromised role can still overwrite. Object Lock in compliance mode removes that possibility at the storage layer, and the code below wires it up end to end, computing each object’s retain-until date from a validated retention policy so the GDPR Art. 5(2) accountability duty and the GDPR Art. 5(1)(e) storage-limitation duty are both satisfied by construction.
The write path runs from a lock-enabled bucket, through a Pydantic-modeled retention policy that computes the retain-until date, to a compliance-mode put_object, an optional legal hold, and a read-back verification:
Prerequisites
- Python 3.11+ — for
zoneinfo,datetime.UTC, andstr | Noneunion syntax withoutfrom __future__ import annotations. boto3— the AWS SDK for Python. The examples use an S3 client created withboto3.client("s3"); credentials come from your environment or instance role.pydantic(v2) — strict modeling of the retention policy. The code uses v2 idioms (model_config = ConfigDict(...),@field_validator,model_validate); v1 patterns (class Config,@validator) will not run.- A bucket created with Object Lock enabled. This is the one prerequisite you cannot add later: Object Lock can only be turned on at bucket creation. A bucket that was created without it must be replaced. Object Lock also requires versioning, which is enabled automatically when you create the bucket with
ObjectLockEnabledForBucket=True. - IAM permissions — the writing principal needs
s3:PutObject,s3:PutObjectRetention, ands3:PutObjectLegalHold; the verifying principal needss3:GetObjectRetentionands3:GetObjectLegalHold. Deliberately do not grants3:BypassGovernanceRetentionto pipeline roles.
This page owns only the storage configuration; it assumes a sealed evidence blob and a closure timestamp handed to it by the Closure Manifests & Evidence Packaging stage, and the design rationale for choosing compliance mode over governance mode lives in the parent WORM Storage for DSR Evidence cluster.
Step-by-step implementation
Step 1 — Create the bucket with Object Lock and versioning
Object Lock is a create-time property. Creating the bucket with ObjectLockEnabledForBucket=True also enables versioning implicitly, but we set it explicitly so the intent is auditable and the bucket is safe if it were ever created another way.
import boto3
s3 = boto3.client("s3")
def ensure_worm_bucket(bucket: str, region: str = "eu-west-1") -> None:
"""Create a bucket with Object Lock enabled and versioning on.
Object Lock can ONLY be enabled at creation time; an existing bucket
without it must be replaced, not patched.
"""
s3.create_bucket(
Bucket=bucket,
ObjectLockEnabledForBucket=True,
CreateBucketConfiguration={"LocationConstraint": region},
)
# Explicit — Object Lock requires versioning; make the dependency visible.
s3.put_bucket_versioning(
Bucket=bucket,
VersioningConfiguration={"Status": "Enabled"},
)
Step 2 — Model the retention policy
The retain-until date is a compliance decision, not a literal a caller should ever pass by hand. Modeling it as a frozen Pydantic v2 type means an implausible retention (zero years, a naive datetime, a mode other than compliance) fails loudly before any immutable write happens — and once written under compliance mode, a wrong date cannot be undone.
from datetime import datetime, timedelta, UTC
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class RetentionPolicy(BaseModel):
"""Computes an Object Lock retain-until date from a closure timestamp."""
model_config = ConfigDict(strict=True, frozen=True)
retention_years: int = Field(ge=1, le=30)
mode: Literal["COMPLIANCE", "GOVERNANCE"] = "COMPLIANCE"
@field_validator("mode")
@classmethod
def evidence_must_be_compliance(cls, v: str) -> str:
# Governance mode can be bypassed; accountability evidence cannot rely on it.
if v != "COMPLIANCE":
raise ValueError("DSR evidence must use COMPLIANCE mode")
return v
def retain_until(self, closed_at: datetime) -> datetime:
"""Return the UTC retain-until date anchored to request closure."""
if closed_at.tzinfo is None:
raise ValueError("closed_at must be timezone-aware (UTC)")
# timedelta(days=365 * years) keeps the arithmetic explicit and UTC-safe.
return closed_at.astimezone(UTC) + timedelta(days=365 * self.retention_years)
# Loaded from compliance-owned config, not hard-coded at the call site.
POLICY = RetentionPolicy.model_validate({"retention_years": 6, "mode": "COMPLIANCE"})
Step 3 — Write the evidence under a compliance-mode lock
The write stamps the mode and the computed date onto the object version. From this point the version is immutable until retain-until; a second put_object to the same key creates a new version and never overwrites the sealed one.
def seal_evidence(
bucket: str,
key: str,
body: bytes,
closed_at: datetime,
policy: RetentionPolicy = POLICY,
) -> str:
"""Write a DSR evidence object under a compliance-mode retention lock.
Returns the versionId of the immutable object version.
"""
retain_until = policy.retain_until(closed_at)
response = s3.put_object(
Bucket=bucket,
Key=key,
Body=body,
ObjectLockMode=policy.mode,
ObjectLockRetainUntilDate=retain_until,
)
return response["VersionId"]
Step 4 — Apply a legal hold
A legal hold is independent of the retention date: it blocks deletion for as long as it is set, even after retain-until passes. Apply it only when a request is subject to litigation or a regulator inquiry, and record it in a hold register so it can be released — a forgotten hold keeps personal data with no lawful basis, breaching the Art. 5(1)(e) storage-limitation duty.
def apply_legal_hold(bucket: str, key: str, version_id: str) -> None:
"""Place an indefinite legal hold on a specific object version."""
s3.put_object_legal_hold(
Bucket=bucket,
Key=key,
VersionId=version_id,
LegalHold={"Status": "ON"},
)
def release_legal_hold(bucket: str, key: str, version_id: str) -> None:
"""Release the hold once the matter that justified it closes."""
s3.put_object_legal_hold(
Bucket=bucket,
Key=key,
VersionId=version_id,
LegalHold={"Status": "OFF"},
)
Step 5 — Verify the configuration took effect
Never trust that a write configured the lock — read it back. An object that landed without the retention metadata is evidence that looks protected but is not, and it must fail an alarm immediately rather than at audit time years later.
def read_lock_state(bucket: str, key: str, version_id: str) -> dict:
"""Return the mode, retain-until, and legal-hold status of a version."""
retention = s3.get_object_retention(
Bucket=bucket, Key=key, VersionId=version_id
)["Retention"]
hold = s3.get_object_legal_hold(
Bucket=bucket, Key=key, VersionId=version_id
)["LegalHold"]
return {
"mode": retention["Mode"],
"retain_until": retention["RetainUntilDate"],
"legal_hold": hold["Status"],
}
Configuration reference
| Parameter | Type | Default | Compliance note |
|---|---|---|---|
ObjectLockEnabledForBucket |
bool |
True (at create) |
Can only be set when the bucket is created; enables versioning implicitly. |
ObjectLockMode |
str |
COMPLIANCE |
Compliance mode blocks shortening/deletion for every principal; required so the Art. 5(2) accountability claim survives an insider. |
ObjectLockRetainUntilDate |
datetime (UTC) |
closed_at + retention_years |
Anchored to request closure, not write time; bounds retention against the Art. 5(1)(e) storage-limitation duty. |
retention_years |
int |
6 |
The statutory/limitation period a complaint could be brought within; owned by compliance, not by the call site. |
LegalHold.Status |
str |
OFF |
ON blocks deletion indefinitely regardless of retain-until; must be tracked and released to satisfy storage limitation. |
s3:BypassGovernanceRetention |
IAM permission | not granted | Deliberately withheld from pipeline roles; granting it reintroduces the bypass compliance mode exists to remove. |
Verification
Verification proves the two properties that matter: the object is immutable, and its retain-until is anchored to closure. Run this against a real lock-enabled bucket in a test account — a mocked client will happily report whatever you tell it to.
from datetime import datetime, UTC
def test_seal_is_compliance_locked_to_closure():
bucket, key = "dsr-evidence-test", "req/abc123/manifest.json"
closed_at = datetime(2026, 1, 15, 9, 30, tzinfo=UTC)
version_id = seal_evidence(bucket, key, b"...sealed manifest...", closed_at)
state = read_lock_state(bucket, key, version_id)
assert state["mode"] == "COMPLIANCE"
# retain-until is exactly closure + 6 years, computed in UTC.
assert state["retain_until"] == POLICY.retain_until(closed_at)
Expected output: read_lock_state returns {"mode": "COMPLIANCE", "retain_until": datetime(2032, 1, 14, 9, 30, tzinfo=UTC), "legal_hold": "OFF"}. A follow-up test should attempt s3.delete_object(..., VersionId=version_id) with a fully privileged principal and assert it raises an AccessDenied client error, confirming the lock is enforced by storage rather than by policy.
Troubleshooting
InvalidRequest: Object Lock configuration cannot be enabled on existing buckets — Root cause: the bucket was created without ObjectLockEnabledForBucket=True. Object Lock is a create-time-only property. Fix: create a new bucket with the flag set and migrate evidence into it; there is no way to patch an existing bucket.
Cannot shorten or delete a compliance-locked object — Root cause: this is correct behaviour, not a bug — compliance mode forbids shortening retain-until or deleting the version for any principal, including root, until the date passes. Fix: if you genuinely need longer retention, put_object_retention can extend the date; if a too-long date was set in error, there is no remedy but to wait it out, which is exactly why RetentionPolicy validates the value before the write.
retain-until lands in the past or one day short — Root cause: a naive (timezone-unaware) closed_at, or local-time arithmetic crossing a daylight-saving boundary. A retain-until in the past is rejected by S3 outright. Fix: keep closed_at timezone-aware and let RetentionPolicy.retain_until normalize with astimezone(UTC) before adding the period, so the clock cannot skew a statutory window.
AccessDenied on the write despite s3:PutObject — Root cause: setting ObjectLockMode/ObjectLockRetainUntilDate requires the separate s3:PutObjectRetention permission, and a legal hold requires s3:PutObjectLegalHold. Fix: grant those actions to the writing role explicitly; PutObject alone is not sufficient to stamp retention metadata.
Legal hold left ON after a matter closed — Root cause: no register linking holds to open matters, so the storage-limitation clock never resumes. Fix: track every hold against the matter that justified it and call release_legal_hold when it closes, so the object becomes eligible for the scheduled storage-limitation deletion described in the parent cluster.
Frequently Asked Questions
Can I enable Object Lock on a bucket I already have?
No. Object Lock can only be enabled at bucket creation, so an existing bucket without it cannot be patched — you must create a new bucket with ObjectLockEnabledForBucket=True and migrate the evidence into it. This is the one prerequisite you have to plan for before writing any objects, which is why it is the first step in the implementation above.
Why compute retain-until from the closure timestamp instead of the write time?
Because the legally meaningful clock starts at request closure, not at whenever a worker happened to persist the object. Anchoring ObjectLockRetainUntilDate to the closure timestamp means two requests closed on the same day always age out together, and the retention period traces to a stated rationale under GDPR Art. 5(2). It also bounds the retention so the object is eventually deletable, satisfying the GDPR Art. 5(1)(e) storage-limitation duty. See WORM Storage for DSR Evidence for the full policy rationale.
What is the difference between a retention lock and a legal hold here?
The retention lock expires automatically on its retain-until date; a legal hold has no expiry and blocks deletion until you explicitly set its status to OFF. They are independent — an object can be past its retention date but still undeletable because a hold is active. Use the retention lock for the ordinary statutory period and a legal hold for the exceptional case of active litigation, and always track holds in a register so they are released and the storage-limitation clock resumes.
Should pipeline roles ever hold the governance bypass permission?
No. Granting s3:BypassGovernanceRetention to a pipeline role reintroduces exactly the bypass path that compliance mode exists to remove, undermining the accountability evidence. Writes use compliance mode, which has no bypass at all, and the bypass permission is withheld from automation entirely. If a regulator can point to any principal that could have altered a record, the record stops being proof.
Related
- WORM Storage for DSR Evidence — the parent design guidance on lock modes, legal holds, and retention as a compliance decision.
- Closure Manifests & Evidence Packaging — the sealed manifests and closure timestamps this write path consumes.
- Cryptographic Audit Chains — the hash-chained ledger whose integrity WORM storage makes impossible to rewrite.
- Audit Logging & Compliance Evidence — the parent section this storage configuration serves.
- 30-Day vs 45-Day SLA Mapping — the same pattern of pinning a legally meaningful clock to an auditable timestamp.