Configuring S3 Object Lock for Lineage Archives
When a lineage archive must be provably tamper-proof for its entire retention period, you enable S3 Object Lock and write the object under a compliance-mode retention date plus a legal hold; use this whenever a finalized provenance record needs infrastructure-enforced immutability rather than a policy promise. This how-to is the hands-on companion to the object storage WORM retention overview and shows the exact boto3 calls end to end.
Prerequisites
- Python 3.10+ with
boto31.34+ andbotocorein an active virtual environment. - AWS credentials granting
s3:CreateBucket,s3:PutBucketVersioning,s3:PutObject,s3:PutObjectRetention,s3:PutObjectLegalHold, ands3:GetObjectRetention. - A region selected via
AWS_DEFAULT_REGIONor an explicitregion_name. - A canonical serialization of the lineage document to archive (deterministic JSON is ideal). The append-only record shape from structuring JSON/XML lineage documents works well here.
Implementation
The script below creates a fresh Object-Lock-enabled bucket, writes a lineage archive under a five-year COMPLIANCE retention with a legal hold switched on, and returns the version ID. Object Lock can only be enabled at bucket creation, so the workflow starts from a new bucket rather than mutating an existing one.
import hashlib
import json
from datetime import datetime, timedelta, timezone
import boto3
REGION = "us-east-1"
BUCKET = "agency-lineage-worm-archive"
KEY = "lineage/2026/scene-8842-provenance.json"
s3 = boto3.client("s3", region_name=REGION)
def ensure_locked_bucket(bucket: str) -> None:
# ObjectLockEnabledForBucket implicitly enables versioning; we set it
# explicitly so the intent is auditable in infrastructure code.
s3.create_bucket(Bucket=bucket, ObjectLockEnabledForBucket=True)
s3.put_bucket_versioning(
Bucket=bucket,
VersioningConfiguration={"Status": "Enabled"},
)
def put_locked_archive(bucket: str, key: str, lineage: dict, years: int = 5) -> str:
# Deterministic bytes => reproducible SHA-256 for chain-of-custody proof.
body = json.dumps(lineage, sort_keys=True, separators=(",", ":")).encode("utf-8")
digest = hashlib.sha256(body).hexdigest()
# UTC-aware arithmetic prevents clock-skew rejection of the retain-until date.
retain_until = datetime.now(timezone.utc) + timedelta(days=365 * years)
resp = s3.put_object(
Bucket=bucket,
Key=key,
Body=body,
ObjectLockMode="COMPLIANCE", # irreversible WORM for the window
ObjectLockRetainUntilDate=retain_until, # no principal can shorten this
ObjectLockLegalHoldStatus="ON", # independent, indefinite freeze
Metadata={"sha256": digest},
)
return resp["VersionId"]
if __name__ == "__main__":
lineage_doc = {
"dataset_id": "scene-8842",
"crs": "EPSG:4326",
"derived_from": ["scene-8840", "scene-8841"],
"process": "mosaic+radiometric_correction",
"actor": "svc-etl-prod",
"committed_at": datetime.now(timezone.utc).isoformat(),
}
ensure_locked_bucket(BUCKET)
version_id = put_locked_archive(BUCKET, KEY, lineage_doc)
print(f"archived version: {version_id}")
The three ObjectLock* parameters on put_object do the work. ObjectLockMode="COMPLIANCE" means the retain-until date cannot be shortened or bypassed by any principal, including the root account. ObjectLockLegalHoldStatus="ON" layers an independent, open-ended freeze on top, so the object stays immutable even if you later decide to reduce or expire scheduled retention. The SHA-256 digest stored in user metadata binds the archived bytes to a verifiable fingerprint you can re-derive at audit time.
Verification
Confirm the retention actually took hold by reading it back. get_object_retention returns the mode and retain-until date; get_object_legal_hold returns the hold status.
def verify_lock(bucket: str, key: str, version_id: str) -> None:
ret = s3.get_object_retention(Bucket=bucket, Key=key, VersionId=version_id)
hold = s3.get_object_legal_hold(Bucket=bucket, Key=key, VersionId=version_id)
print("mode:", ret["Retention"]["Mode"])
print("retain_until:", ret["Retention"]["RetainUntilDate"].isoformat())
print("legal_hold:", hold["LegalHold"]["Status"])
A correct run prints mode: COMPLIANCE, a retain_until roughly five years out, and legal_hold: ON. As a negative check, attempt a delete of that version and confirm S3 rejects it: s3.delete_object(Bucket=BUCKET, Key=KEY, VersionId=version_id) raises an AccessDenied error while the retention is active, which is exactly the immutability guarantee you want to demonstrate to an auditor.
Gotchas & edge cases
- Object Lock is creation-time only. You cannot enable it on an existing bucket —
put_object_lock_configurationreturnsInvalidBucketState. If you inherited an unlocked bucket, create a new locked bucket and copy the archives across, then verify digests match before decommissioning the old one. - Versioning is mandatory. Object Lock retention parameters are rejected unless bucket versioning is
Enabled. Enabling Object Lock at creation turns versioning on for you, but never suspend it afterward or new PUTs lose their lock semantics. - Compliance mode is a one-way commitment. Once written, a compliance
retain-untildate cannot be reduced, and the object cannot be deleted until it passes. Validate the lineage document — especially its CRS and derivation edges — before you commit it, because a mistaken archive will occupy locked, billable storage for the full window. Use governance mode in staging if you need a break-glass path during testing.