Object lock for documents and backups: research
As of: 2026-09-28. [stale] marks sources older than about 12 months. AWS doc pages are
undated (“live doc”).
For: RUNBOOK §1.1 (bucket lock before the first upload) and progress doc §3 (governance
vs compliance). It follows 2026-09-28-malaysia-hosting-research.md,
which recommends S3 in ap-southeast-5.
Status: research and a proposed configuration. Nothing here has been run against a real bucket.
1. Decision this supports: compliance mode, 8 years
Section titled “1. Decision this supports: compliance mode, 8 years”- Compliance: nobody, not even the AWS root user, can delete or overwrite a locked version, change its mode or shorten its period (docs). AWS names only one way out: delete the whole AWS account.
- Governance: anyone with
s3:BypassGovernanceRetentioncan remove the lock. A leaked admin key could erase records, which is exactly the threat the lock exists for. - Legal hold has no expiry and can be removed by anyone with
s3:PutObjectLegalHold. It is not a substitute.
Compliance fits a 7-year statutory record. RUNBOOK §1.1 says governance and should be changed to compliance when this is accepted.
Because compliance can’t be undone, every setting is tried first in governance mode with a 1-day retention (§3, step 2).
2. Findings
Section titled “2. Findings”Region, price, lifecycle
Section titled “Region, price, lifecycle”-
Object Lock in
ap-southeast-5is implied, not stated. AWS says “all Regions” at launch (2018 [stale]) and for the 2026-09 variable-retention feature (2026). The dry run proves it. -
Prices per GB-month in
ap-southeast-5(official Price List API, 2026-09):Class USD / GB-month Standard 0.0225 Standard-IA 0.01242 Glacier Instant Retrieval 0.0045 Glacier Deep Archive 0.0018 -
The lock is kept when lifecycle moves an object to another class (docs). Old backups can sit in Deep Archive. Restoring from there takes hours, so recent backups stay warm.
-
Versioning is required, and once the lock is on, versioning can’t be suspended (docs). S3 can now add a lock to an existing bucket. MinIO community builds may not; only the commercial AIStor docs say so.
-
New in 2026-09: variable retention with event holds. It could express “7 years from the end of the year of assessment” directly, instead of a flat 8 years from upload. Worth a look once the basic setup works.
Deletes on a locked bucket
Section titled “Deletes on a locked bucket”- A delete that names a version returns 403.
- A plain delete returns 200 and adds a delete marker. The locked version survives, and the
marker is not protected. The app never deletes (there is no
RemoveObjectin the code), so this only matters for tools such as backup expiry.
Upload integrity: the app’s thumbnail upload will be rejected
Section titled “Upload integrity: the app’s thumbnail upload will be rejected”-
AWS:
Content-MD5or a checksum header “is required for any request to upload an object with a retention period configured using Object Lock” (PutObject). -
Browser uploads (
PresignPut) already signX-Amz-Checksum-Sha256. That is probably accepted: S3’s own error text, quoted in a third-party report, names “x-amz-checksum-” headers (loki#20088). AWS docs don’t say so directly, so the dry run confirms it. -
Thumbnail uploads (
Put,s3store.go:126) send neither header. Verified in minio-go v7.3.0 source:SendContentMd5defaults to false.- The automatic CRC32C checksum is added only when the client sets
TrailingHeaders(api.go:327,api-put-object.go:354), and ours doesn’t. - Small objects take the single-part path, which adds only
Content-MD5and only when asked (api-put-object-streaming.go:658).
Fix:
minio.PutObjectOptions{ContentType: contentType, SendContentMd5: true}. -
Local testing will not catch this. MinIO does not enforce the rule (minio#16480 [stale], still open).
Thumbnails re-uploaded to the same key
Section titled “Thumbnails re-uploaded to the same key”- Each re-upload creates a new version, and every version is locked for 8 years and billed (docs).
- Open question: is a thumbnail a business record? If not, either never re-upload an existing thumbnail, or keep thumbnails in a separate bucket without the lock.
Backups
Section titled “Backups”- WAL-G can lock what it writes:
S3_RETENTION_MODE=COMPLIANCE,S3_RETENTION_PERIODin seconds (docs). - pgBackRest has no lock setting. It only notes that locking the bucket “may be useful”,
and offers
repo-target-timeto read a versioned repository as it was (guide). A bucket-level default retention does the locking instead. - Not verified for either tool:
- How their expiry behaves against locked objects. From the delete rules above, it would appear to succeed while storage keeps growing until the lock ends.
- Whether they send the integrity header.
- Pattern: a separate backup bucket, compliance 8 years, lifecycle to Deep Archive, and old versions expired only after the lock period.
Other bucket protections
Section titled “Other bucket protections”- Block public access and TLS only (
aws:SecureTransport) (best practices). - Encryption: SSE-S3, not SSE-KMS. The lock “does not protect against losing access to the encryption keys”. Deleting a KMS key would make locked records unreadable, which is a second way to destroy them (docs).
- MFA delete: skip. It can’t be used with lifecycle rules, and compliance mode already covers the same threat (docs).
- Deny
s3:PutBucketObjectLockConfigurationin the bucket policy except for a break-glass role. - CloudTrail data events on both buckets, written to a third locked bucket.
- The app’s credentials:
s3:PutObject,s3:GetObjectands3:ListBucketonly. No retention, bypass or bucket-configuration permissions.
MinIO (fallback, and the dev stack)
Section titled “MinIO (fallback, and the dev stack)”- Commands:
mc mb --with-lock ALIAS/erp-docs, thenmc retention set --default COMPLIANCE 8y ALIAS/erp-docs(AIStor docs). - The
minio/miniorepository was archived on 2026-04-25 (“NO LONGER MAINTAINED”). It is still AGPLv3, and the old binaries get no updates (repo). The dev stack’squay.io/minio/minio:RELEASE.2025-09-07…image still works locally. As a production fallback, MinIO now means the commercial AIStor or an unmaintained build. That strengthens the case for S3.
3. Proposed setup (not yet run)
Section titled “3. Proposed setup (not yet run)”REGION=ap-southeast-5DOCS=erp-docs-prod
# 1. Create with Object Lock (turns versioning on)aws s3api create-bucket --bucket $DOCS --region $REGION \ --create-bucket-configuration LocationConstraint=$REGION \ --object-lock-enabled-for-bucket
# 2. Dry run: GOVERNANCE, 1 day. Exercise the app: browser upload, thumbnail, download.aws s3api put-object-lock-configuration --bucket $DOCS --object-lock-configuration \ '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"GOVERNANCE","Days":1}}}'
# 3. Only after step 2 passes: COMPLIANCE, 8 years. Cannot be undone.aws s3api put-object-lock-configuration --bucket $DOCS --object-lock-configuration \ '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Years":8}}}'
# 4. Block public access; 5. SSE-S3aws s3api put-public-access-block --bucket $DOCS --public-access-block-configuration \ BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueaws s3api put-bucket-encryption --bucket $DOCS --server-side-encryption-configuration \ '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
# 6. Bucket policy: deny non-TLS; deny lock-config and policy changes except a break-glass role# 7. Same for the backup bucket, plus lifecycle to DEEP_ARCHIVE# 8. CloudTrail S3 data events → a third locked bucket4. Code changes this implies (1, 2 and 4 made on ops-groundwork)
Section titled “4. Code changes this implies (1, 2 and 4 made on ops-groundwork)”| # | Where | Change | Needs a decision? |
|---|---|---|---|
| 1 | s3store.go Put |
SendContentMd5: true, or AWS rejects thumbnails |
Done |
| 2 | s3store.go EnsureBucket |
In production, don’t create an unlocked bucket: fail at startup if it is missing (optionally check GetObjectLockConfig). minio-go has MakeBucketOptions.ObjectLocking for dev |
Done: fails unless OBJECT_STORE_CREATE_BUCKET=true |
| 3 | Thumbnail keys | Write once, or a separate unlocked bucket | Yes: is a thumbnail a record? |
| 4 | Tests | MinIO can’t catch #1, so a test asserts the header on the request instead | Done |
5. Not verified
Section titled “5. Not verified”- An AWS page naming Object Lock in
ap-southeast-5(only “all Regions”) - Whether
x-amz-checksum-sha256alone satisfies the lock rule (third-party wording only) - Any AWS Support process to shorten compliance retention; what happens to locked data after an account is closed
- Whether community MinIO
RELEASE.2025-09-07can lock an existing bucket - Whether
quay.io/minio/minioimages are still published - pgBackRest / WAL-G expiry and integrity headers against locked buckets
- Minimum storage durations and per-object fees for the cheaper classes in
ap-southeast-5 - The exact
LocationConstraintrequirement for this region; the policy JSON is untested