Skip to content

feat(s3-bucket): add a private, versioned S3 bucket component - #30

Merged
opariffazman merged 3 commits into
mainfrom
feat/s3-bucket
Oct 5, 2026
Merged

opariffazman merged 3 commits into
mainfrom
feat/s3-bucket

Conversation

@opariffazman

@opariffazman opariffazman commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds the AWS s3-bucket component: reusable, private-by-construction S3 buckets from a buckets map, with lifecycle rules driven by inputs rather than baked in. It unblocks officialdad/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 README

Type

  • Added — new component or feature (MINOR)

Changes

  • s3-bucket/terraform/ — aws_s3_bucket per buckets entry, named <environment_name>-<key> or that entry's bucket_name override.
  • Private by construction, not by input — aws_s3_bucket_public_access_block with all four settings true and aws_s3_bucket_ownership_controls at BucketOwnerEnforced are hardcoded. A consumer cannot opt out, so no buckets entry can make a bucket public or leave ACLs live.
  • Encryption always on — AES256 (SSE-S3) by default, aws:kms plus an S3 Bucket Key when kms_key_arn is set.
  • Versioning on by default — versioning = false suspends it.
  • DenyInsecureTransport bucket policy on by default, off with enforce_tls = false.
  • Lifecycle rules are inputs — each lifecycle_rules entry sets a prefix plus any of expiration_days, noncurrent_version_expiration_days, noncurrent_versions_to_keep, abort_incomplete_multipart_upload_days, expired_object_delete_marker.
  • Opt-in Object Lock — object_lock_enabled and object_lock_default_retention ({ mode, days }), both off by default. Added after the security review below.
  • Six 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); two rules sharing one prefix (S3 answers InvalidRequest: Found two rules with same prefix); Object Lock without versioning; a retention without object_lock_enabled; a mode that is neither GOVERNANCE nor COMPLIANCE.
  • buckets output — name, ARN, both domain names and region per key, so an environment scopes an iam-policy document against it.
  • Root README — component table row, and the standalone-components sentence now names ebs-volume, iam-policy and s3-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_days only writes a delete marker; the bytes survive until noncurrent_version_expiration_days elapses after that. A consumer sizing only expiration_days against a retention promise will overshoot it.

An overwrite is a write, not a delete. A principal holding only s3:PutObject on a prefix can replace an object under it, and a Deny on s3:DeleteObject* will not fire. noncurrent_version_expiration_days is 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 days below the lifecycle total or objects outlive the retention you promised.

Security review

Ran ecc:security-reviewer over the module and its first consumer's IAM policy. No CRITICAL. One HIGH, fixed here and in the consumer:

  • HIGH — PutObject overwrite 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 size noncurrent_version_expiration_days by accident. The consumer PR rebalanced its lifecycle split from 29/1 to 7/23 within the same 30-day budget.
  • MEDIUM — Object Lock not supported. Fixed: 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_destroy defaults false; 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 / tflint pass on the changed components (the gate)
  • Commit subjects are consumer-facing, type(scope): subject with scope = component
  • Component README / root README updated if inputs or outputs changed

CI 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 0
  • scripts/gen-docs.sh --check, scripts/check-readme-structure.sh, pre-commit run --all-files — pass
  • scripts/check-precommit-tf-coverage.sh — OK — the Terraform hooks matched the changed files (no silent skip)
  • A throwaway terraform plan in /tmp against a copy of the module, with dummy credentials and skip_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 ran terragrunt plan against AWS with credentials for its s3_bucket component and passed — Plan: 7 to add, with all four public-access settings true, BucketOwnerEnforced, AES256, versioning Enabled, and the lifecycle rule as configured.

Not verified

No apply ran anywhere. The S3 API's acceptance of this configuration is confirmed only as far as plan goes. The Object Lock path in particular has never been planned against AWS — it is unused by the first consumer.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

📝 Changelog preview

Generated from this branch's Conventional Commits — this is what the next vX.Y.Z tag publishes verbatim to CHANGELOG.md and the GitHub Release (no manual curation). Tighten an entry by tightening its commit subject; scope it to the component.

[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>
@opariffazman
opariffazman merged commit 5a7ad06 into main Oct 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant