Repository navigation
feat(s3-bucket): add a private, versioned S3 bucket component - #30
Merged
Merged
Conversation
Adds the AWS `s3-bucket` component: one or more buckets from a `buckets` map, private by construction. Public access blocked on all four settings, ACLs disabled via BucketOwnerEnforced, SSE always on (AES256 by default, SSE-KMS with `kms_key_arn`), versioning on by default, and a DenyInsecureTransport bucket policy unless `enforce_tls = false`. Lifecycle rules are inputs, not opinions: each `lifecycle_rules` entry sets a prefix plus any of expiration, noncurrent-version expiration, noncurrent-version count and incomplete-multipart abort. Outputs `buckets` keyed by map key, carrying the name and ARN an environment scopes an IAM policy to. Three plan-time guards stand in for apply-time S3 errors: a rule with no action, a rule setting both `expiration_days` and `expired_object_delete_marker` (S3 rejects the pair), and two rules sharing one prefix (S3 answers InvalidRequest). The README states what S3 lifecycle cannot express: it expires by age under a prefix and cannot thin a set down to one copy per day. A retention shape like that needs the writer to split the classes across two prefixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 Changelog previewGenerated from this branch's Conventional Commits — this is what the next [Unreleased]Added
|
A security review of the first consumer raised the gap that `s3:PutObject`
on a prefix lets a caller replace an object under it. Versioning turns that
into a new version rather than a loss, so the caller's real exposure is
`noncurrent_version_expiration_days`: the window in which a bad or
malicious overwrite is still recoverable. A 1-day window makes a write-only
grant nearly as destructive as a delete grant. The README now says so, so a
consumer sizes that number against detection time rather than storage cost.
Adds the hard version of the same guarantee: `object_lock_enabled` and
`object_lock_default_retention` ({ mode, days }), both off by default.
Inside the window no principal and no lifecycle rule can remove a version.
S3 cannot retrofit Object Lock, so the README says the decision is
creation-time only, and warns that retention outranks the lifecycle rule —
set `days` below the lifecycle total or objects outlive the retention you
promised.
Three more plan-time guards: Object Lock without versioning, a retention
without `object_lock_enabled`, and a mode that is neither GOVERNANCE nor
COMPLIANCE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Oct 5, 2026
Closed
# Conflicts: # README.md
This was referenced Nov 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the AWS
s3-bucketcomponent: reusable, private-by-construction S3 buckets from abucketsmap, with lifecycle rules driven by inputs rather than baked in. It unblocksofficialdad/financial-health-check#14(OPS-2, hourly SQLite backups to S3), which had nowhere to define a bucket, its lifecycle, or the instance's write grant.First consumer:
officialdad/infra-environments-dev#45.Component(s)
s3-bucket(new) · root READMEType
Changes
s3-bucket/terraform/—aws_s3_bucketperbucketsentry, named<environment_name>-<key>or that entry'sbucket_nameoverride.aws_s3_bucket_public_access_blockwith all four settingstrueandaws_s3_bucket_ownership_controlsatBucketOwnerEnforcedare hardcoded. A consumer cannot opt out, so nobucketsentry can make a bucket public or leave ACLs live.AES256(SSE-S3) by default,aws:kmsplus an S3 Bucket Key whenkms_key_arnis set.versioning = falsesuspends it.DenyInsecureTransportbucket policy on by default, off withenforce_tls = false.lifecycle_rulesentry sets a prefix plus any ofexpiration_days,noncurrent_version_expiration_days,noncurrent_versions_to_keep,abort_incomplete_multipart_upload_days,expired_object_delete_marker.object_lock_enabledandobject_lock_default_retention({ mode, days }), both off by default. Added after the security review below.expiration_daysandexpired_object_delete_marker(S3 rejects the pair); two rules sharing one prefix (S3 answersInvalidRequest: Found two rules with same prefix); Object Lock without versioning; a retention withoutobject_lock_enabled; a mode that is neitherGOVERNANCEnorCOMPLIANCE.bucketsoutput — name, ARN, both domain names and region per key, so an environment scopes aniam-policydocument against it.ebs-volume,iam-policyands3-bucket.What the README states plainly
S3 lifecycle cannot thin a set. It expires objects by age under a prefix. A retention shape like "hourly for 7 days, then daily for 30" needs the writer to split the classes across two prefixes first. The README says so rather than implying the module covers it.
On a versioned bucket, retention is the sum of two numbers.
expiration_daysonly writes a delete marker; the bytes survive untilnoncurrent_version_expiration_dayselapses after that. A consumer sizing onlyexpiration_daysagainst a retention promise will overshoot it.An overwrite is a write, not a delete. A principal holding only
s3:PutObjecton a prefix can replace an object under it, and aDenyons3:DeleteObject*will not fire.noncurrent_version_expiration_daysis therefore the window in which a bad or malicious overwrite is still recoverable — size it against detection time, not storage cost.Object Lock is decided once. S3 cannot retrofit it, and its retention outranks the lifecycle rule: a version under retention cannot be removed by anyone, the lifecycle included. Set
daysbelow the lifecycle total or objects outlive the retention you promised.Security review
Ran
ecc:security-reviewerover the module and its first consumer's IAM policy. No CRITICAL. One HIGH, fixed here and in the consumer:PutObjectoverwrite defeats the "no delete" guarantee. Fixed on both sides: this module gained opt-in Object Lock as the hard guarantee, and the README now names the overwrite window explicitly so a consumer cannot sizenoncurrent_version_expiration_daysby accident. The consumer PR rebalanced its lifecycle split from 29/1 to 7/23 within the same 30-day budget.object_lock_enabled+object_lock_default_retention, default off.The reviewer confirmed, with no finding: the public-access block, ownership controls and encryption are hardcoded and unreachable from any input;
force_destroydefaultsfalse; the TLS-only policy is applied after the public access block; no real account id, ARN, bucket name or credential is committed.Validation
terraform fmt/validate/tflintpass on the changed components (the gate)type(scope): subjectwith scope = componentCI on this PR:
discover,docs,precommit-coverage,validate (s3-bucket),pr-preview— all pass.Run locally, no cloud credentials:
terraform fmt -check -recursive,init -backend=false,validate—Success! The configuration is valid.tflint --config=.tflint.hcl— exit 0scripts/gen-docs.sh --check,scripts/check-readme-structure.sh,pre-commit run --all-files— passscripts/check-precommit-tf-coverage.sh—OK — the Terraform hooks matched the changed files (no silent skip)terraform planin/tmpagainst a copy of the module, with dummy credentials andskip_credentials_validation, rendered the lifecycle rule and the Object Lock configuration correctly. All six negative cases failed at plan as intended. Nothing was applied and no AWS resource was created.Real-plan evidence from the consumer:
officialdad/infra-environments-dev#45's CI ranterragrunt planagainst AWS with credentials for itss3_bucketcomponent and passed —Plan: 7 to add, with all four public-access settingstrue,BucketOwnerEnforced,AES256, versioningEnabled, and the lifecycle rule as configured.Not verified
No
applyran anywhere. The S3 API's acceptance of this configuration is confirmed only as far asplangoes. The Object Lock path in particular has never been planned against AWS — it is unused by the first consumer.🤖 Generated with Claude Code