Skip to content

Harden Aurora backups: 35-day PITR, AWS Backup enrollment, and monthly logical dumps - #492

Draft
danielbowne wants to merge 2 commits into
mainfrom
feature/aurora-backup-hardening
Draft

danielbowne wants to merge 2 commits into
mainfrom
feature/aurora-backup-hardening

Conversation

@danielbowne

@danielbowne danielbowne commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Gives the Aurora cluster a three-legged backup strategy, sized to how late an unnoticed bad edit can plausibly surface:

discovery lag PITR @ 35d AWS Backup d15_w90 monthly dump
<= 35 days yes yes yes
36-95 days no yes yes
beyond no no yes

The incident that prompted this surfaced 35 days after the edit - exactly the Aurora PITR ceiling - so PITR alone covers the worst case already observed with zero margin. The fix is three legs with different horizons: prod PITR raised to the 35-day maximum, enrollment in the CMS OIT Daily15_Weekly90 AWS Backup plan (tag-selected, no new backup stack), and a monthly pg_dump to a versioned, Object-Locked S3 bucket with no expiry. The dump leg is logical on purpose: recovery for a bad row is "stand up a reference copy and diff", never "roll prod back a month", so readability beats restorability. A daily probe measures the age of the newest dump from the bucket itself and alarms past 40 days - measured, not inferred from the job's own heartbeat, because CloudWatch cannot alarm across a monthly gap.

Riders, each argued inline in the diff: deletion_protection, pinned backup/maintenance windows, copy_tags_to_snapshot, a pinned cluster parameter group, and ignore_changes on engine_version so auto minor upgrades stop producing perpetual downgrade plans.

Testing

terraform fmt, tflint, and terraform validate pass on the rebase against current main (clean rebase, no conflicts). The dump script verifies its own artifact (pg_restore --list) and refuses undersized uploads; runtime behavior is exercised in dev first (db_dump_enabled = true there) before prod depends on it. No application, API, or migration changes - infra and the ops image only.

Deploy notes (read before merging)

  1. Ops image ordering. The ops-image workflow rebuilds on backend/ops/** and updates the ztmf_ops_tag SSM parameter, but the terraform apply in the same pipeline can race it. If the apply wins, the dump/probe task definitions pin the previous image until the next apply. Verify the ops-image run finished, then re-apply (or let the next merge do it).
  2. Day-one ALARM is expected. The probe reports an empty bucket as maximally stale by design, and the monthly schedule may be weeks out. Clear it by running the dump task once by hand (aws ecs run-task with the schedule's subnets/SG); the next daily probe reads the fresh dump and the alarm returns to OK. The enablement sequence is also documented at the top of backup-dumps.tf.
  3. Do not let this sit deployed-to-dev but unmerged. A later main-based apply would try to destroy the dump bucket; once a dump has landed under Object Lock the destroy fails and blocks everyone's dev deploys. Merge promptly after the DEV gate. If dev state does get tangled or reverted mid-window, that is acceptable for dev data - we refresh the dev database from prod routinely, so there is no data-loss concern on the dev side; the sequencing note is about not wedging the deploy lane, not about protecting dev data.

@danielbowne danielbowne added area/infra Terraform, AWS resources, networking, IAM needs-refinement Used on tickets that need to be refined before work. labels Jul 29, 2026
@danielbowne danielbowne self-assigned this Jul 29, 2026
@danielbowne
danielbowne requested a review from jsos3-cms July 29, 2026 04:12
@jsos3-cms

Copy link
Copy Markdown
Contributor

Thanks for this @danielbowne . Let me start digging in!

@jsos3-cms

jsos3-cms commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for the writeup, @danielbowne — this is a lot more considered than "needs-refinement" implies, and several things I went in skeptical of held up under checking (Object Lock vs. lifecycle transitions, arn_without_revision, the write-only S3 IAM shape, the mktemp/readonly/trap ordering, and the "ALARM then clears" claim). I cross-checked the load-bearing claims against the AWS provider docs and the RDS/S3/CloudWatch references, plus an independent review pass. Verdicts on your five, then the changes I'd ask for.

Your five judgment calls

  1. Object Lock GOVERNANCE 365d — keep it. The threat model here is accidental/unnoticed edits, not a malicious admin destroying backups; GOVERNANCE covers that and leaves the documented break-glass path. COMPLIANCE would be over-committing on an account we don't solely control. One thing to keep true: no routine or CI role should hold s3:BypassGovernanceRetention — the dump task role is write-only with no delete, which is exactly right.
  2. Dev stays enrolled in d15_w90 — keep it, strongly. Two cents a month to prove the CMS-managed selection role can actually back up Aurora via the tag path before prod leans on it is a good trade.
  3. Pinned backup/maintenance windows — keep. Non-disruptive. Only nit: the monthly dump fires at 06:30, exactly the end of the 06:00-06:30 backup window — nudge it to 07:00 for a clean gap so a slightly-long backup can't contend.
  4. engine_version — keep auto_minor_version_upgrade = true + ignore_changes, but add drift alerting (C2 below). I'd rather keep CVE-patch currency than manage the version by hand, but not silently — since ignore_changes deliberately blinds terraform plan, the signal has to come from outside Terraform.
  5. Second task definition — keep two. I confirmed ecs_parameters has no command/env override, so the templated ECS target genuinely can't vary either per-invocation. The universal-target form (arn:aws:scheduler:::aws-sdk:ecs:runTask + input JSON with Overrides) would collapse it to one task def, but it trades the typed, provider-validated ecs_parameters/network_configuration blocks for a hand-authored JSON blob — worse for an audited repo. Two small task defs is the readable choice.

Changes I'd ask for

C1 — blocker: check-dump-age.sh crashes on an empty bucket

check-dump-age.sh's S3-listing JMESPath raises a JMESPathTypeError when the prefix is empty, because list-objects-v2 omits Contents entirely (not an empty array). Under set -euo pipefail that aborts the script before the if [[ -z "${newest}" || "${newest}" == "None" ]] fallback that's supposed to publish age_days=9999 — so on a fresh/empty bucket the daily probe crash-loops and never writes its sentinel or log line (the alarm still ends up correct via treat_missing_data=breaching, but for the wrong reason). Null-coalesce before sorting:

sort_by(Contents[?ends_with(Key, `.dump`)] || `[]`, &LastModified)[-1].LastModified

C2 — engine_version drift alerting

Since plan can't see an AWS-applied patch by design, surface it with a native RDS event subscription to the existing alarms topic. The DB-cluster maintenance category carries RDS-EVENT-0173 (engine version upgraded / patch complete) and RDS-EVENT-0290 (patched X⇒Y):

resource "aws_db_event_subscription" "ztmf_db_engine" {
  name        = "ztmf-db-engine-events-${var.environment}"
  sns_topic   = aws_sns_topic.ztmf_alarms.arn
  source_type = "db-cluster"
  source_ids  = [aws_rds_cluster.ztmf.cluster_identifier]
  # maintenance carries the applied-patch events (RDS-EVENT-0173 / -0290) and
  # the "upgrade available" heads-up (RDS-EVENT-0156).
  event_categories = ["maintenance"]
}

Verify RDS can publish to ztmf_alarms (same-account default is usually enough; add an sns:Publish statement for the RDS service principal if delivery fails). Cluster-level only — don't also subscribe the instance, or you'll get duplicate noise.

C3 — alarm sliding-window jitter

ztmf_db_dump_stale uses period=86400, evaluation_periods=1 on a once-daily metric. The default sliding (non-calendar-aligned) window means routine Fargate start-time jitter can momentarily leave a 24h window with neither yesterday's nor today's datapoint → a ~1-minute false ALARM→OK blip straight to the email SNS. The provider pinned here (~> 5.82) has no evaluation_window/WallClockWindow argument to fix it properly, so widen period to 93600 (26h) to absorb the jitter — still far under the 604800s ceiling and far below the 40-day intent.

C4 — IAM PassRole self-containment

aws_iam_role_policy.ztmf_db_dump_check_scheduler grants iam:PassRole only for module.db_dump_check.role_arn, but the check task's execution_role_arn is module.ops_task_execution.role_arn — authorized only by the sibling dbDumpSchedulerPermissions (both attach to the same scheduler role). It works today, but if the dump leg is ever removed the check schedule breaks with an opaque AccessDenied on PassRole. Add module.ops_task_execution.role_arn to the check-scheduler policy's PassRole Resource so each policy stands on its own.

C5 — fresh-enable alarm

The alarm sits in ALARM from first enable until the first monthly dump lands (and with C1 unfixed, the probe crash-loops meanwhile) — not "clears once the probe has run," since the probe reports maximum age on an empty bucket. Fix that line in the description, and add a runbook step: after enabling, run one dump immediately (aws ecs run-task against the dump task def — the image is preloaded) to seed the bucket and clear the alarm. Do this only post-merge — a seeded object in the shared-dev bucket pre-merge can wedge the dev pipeline, since the next PR's apply-from-main would try to revert the bucket and Object Lock turns that into a failed destroy.

C6 — optional / discretion

  • Mirror the cluster's "remove this block for a deliberate version change" comment onto the instance's ignore_changes block. The instance block is redundant (Aurora won't let the instance version diverge from the cluster) but harmless; the comment prevents a future asymmetric edit.
  • The 0.0.0.0/0:443 egress matches the existing ztmf_sync_lambda SG, so it's not new posture. If you want to close it repo-wide, adding "monitoring" to the aws_vpc_endpoint.ztmf for_each would drop the NAT path for CloudWatch here and for kion's PutMetricData too — marginal cost given nine endpoints already exist. Separate hygiene item, not a blocker.

Follow-up

The scheduling-convergence work is tracked in CMS-Enterprise/ztmf-misc#169 — migrate the repo's scheduled Lambdas (incl. ztmf-kion-key-rotate) onto aws_scheduler_schedule, for which this PR is the first reference implementation. Related to this but explicitly not a dependency.

@jsos3-cms

Copy link
Copy Markdown
Contributor

Separately from the design feedback above — the deploy-safety gates to clear before this applies (these are pre-apply checks, not change requests):

  • Prod plan. Prod hasn't been planned yet. Run terraform plan against prod and confirm the blast radius: backup_retention_period 1→35 is the only retention change, the db_cluster_parameter_group_name pin shows no change, the backup/maintenance window pins are in-place (not replacements), and deletion_protection is in-place.
  • Parameter group. Confirm the current db_cluster_parameter_group in both dev and prod is already default.aurora-postgresql16 (aws rds describe-db-clusters). A cluster on a custom group would be force-reverted to default on apply — disruptive.
  • Prod ops image. Confirm the ops image is preloaded to prod ECR with SSM ztmf_ops_tag pointing at it (dev was preloaded; prod wasn't) before the prod task definitions register against an image with no scripts in it.

@jsos3-cms

Copy link
Copy Markdown
Contributor

@danielbowne checking in on this one. Nothing has moved since the review notes on the 29th, and the PR is still a draft, so I want to make sure it isn't waiting on me.

Where it stands: C1 is the only blocker (the check-dump-age.sh JMESPath crash on an empty bucket), and the three pre-apply gates above are still unchecked, notably the prod plan and the prod ops image. Everything else in that comment was design discussion or optional.

Are you picking the C1 fix up, or would you rather I take it? Happy either way, I just want to know whether to plan around it. If this is parked behind other work, say so and I'll leave it alone.

jsos3-cms added a commit that referenced this pull request Aug 5, 2026
…l-questionnaire export (#529)

Backend release train for the code freeze. Approved PRs only,
cherry-picked so each contributor's authorship and signature survive.
Frontend counterpart is CMS-Enterprise/ztmf-ui#682.

Closes #445
Closes #526

## What is in it

| Source | Author | Change | Closes |
| --- | --- | --- | --- |
| #522 | @danielbowne | Expose `last_seen` on the users list, plus a
login event so it means last sign-in | — |
| #524 | @danielbowne | Single home for event-action and score-status
values | #445 |
| #528 | @MackOverflow, commits by @voidspooks | Export the full
questionnaire per system rather than only answered rows | #526 |

#522 declares no closing issue by design: it is the backend piece, and
presentation is deliberately left open on CMS-Enterprise/ztmf-ui#675.

Ordering is by approval. #522 and #524 both touch `events.go` and
`users.go`, and they merged without conflict because the edits sit in
disjoint regions. #528 is disjoint from both.

One thing dropped on purpose: #522's tip was an empty `chore(ci):
retrigger DEV deploy under a fresh image tag` commit, pushed to force a
fresh image tag past the immutable ECR repo. It has no meaning in a
batch that gets its own SHA, so it was skipped rather than carried.

## Verification on the combined branch

Verified the combination rather than trusting the parts, since three
separately green PRs can still interact:

- `go build ./...` and `go vet ./...` clean
- `go test -short ./...` — 13 packages ok
- Full integration suite against a seeded database — 13 packages ok,
zero failures, including the three tests that are sometimes
date-dependent
- **`make generate-openapi` produces no drift**: the committed spec
matches the generated one byte-for-byte. Worth doing here specifically
because #522 regenerated the spec and #524 changed models independently,
so the combination is the first time those two meet.

## Deploy note, and it is a real one

**Do not stack deploys on this branch.** #528's own SHA has two dev
deployments two minutes apart: the first succeeded, the second failed
with `waiting for ECS Service update: timeout while waiting for state to
become 'tfSTABLE' (last state: 'tfPENDING', timeout: 20m0s)`. The second
run raced the first while ECS was still rolling out. #524 lost a deploy
the same way earlier in the day.

So this PR was opened ready rather than draft-then-ready, because that
transition is what produced the double run. If the deploy here fails on
a stabilise timeout, let ECS settle and re-run **one** deploy; do not
push again or trigger a second in parallel.

## Merge order

**This merges first.** The frontend batch follows at least five minutes
later, which is both a courtesy to the prod pipeline and a correctness
requirement: #528's export change has to be live before the frontend's
select-all makes never-started systems selectable, or a user can select
them and download a header-only file.

## What is not in it

- #525 and #492 are drafts.
- #423 is a dependency bump with no approval.

---------

Co-authored-by: Daniel Bowne <daniel.bowne1@cms.hhs.gov>
Co-authored-by: Cameron Testerman <11036339+voidspooks@users.noreply.github.com>
@danielbowne

Copy link
Copy Markdown
Contributor Author

Picking this back up next sprint. I drafted it a while ago out of concern about our data-loss exposure, and the substance still stands - cleaning it up against current main for a re-release.

…and monthly dumps

Prompted by an unintended survey response edit discovered five weeks later,
with no backup old enough to recover from.

Three legs, each covering a different discovery lag:

- Point-in-time recovery raised to the Aurora maximum of 35 days in prod.
  Dev and impl stay at 1 day; neither holds data worth recovering at that
  horizon.
- Enrollment in the CMS OIT Daily15_Weekly90 plan via the AWS_Backup tag the
  plan already selects on. The plans run daily in these accounts against zero
  tagged resources today, so this is one tag rather than a new backup stack.
- A monthly pg_dump to a new Object Lock bucket, driven by EventBridge
  Scheduler. This is the only copy with no expiry, and the only one that can
  be read and diffed without standing up a cluster.

PITR alone is not sufficient: the incident surfaced at 35 days, which is the
Aurora ceiling, so it would have been covered with zero margin and missed
entirely at six weeks.

Staleness is measured rather than inferred. CloudWatch caps an alarm's total
evaluation range at seven days, so a monthly success pulse cannot be alarmed
on across a 40-day window. A daily probe publishes the age of the newest dump
instead, which also catches an upload reported as successful that never landed.

Also closes drift on the cluster: backup and maintenance windows are pinned
rather than left to AWS-assigned random values, the parameter group is pinned
to the value already in use, and engine_version is ignored so AWS-applied
minor patches stop making every plan propose a downgrade.

Verified against dev: plan is 20 to add, 2 to change, 0 to destroy, with both
changes in-place on the cluster and its instance.
…ter-group version pin

The staleness alarm intentionally fires on an empty bucket, the monthly
schedule can be weeks out, and the ops-image build can race the terraform
apply for the task definition tag - spell out the enablement steps next to
the resources so the day-one ALARM is read as the alarm working. Also
breadcrumb the aurora-postgresql16 parameter-group name for the next major
version upgrade.
@danielbowne
danielbowne force-pushed the feature/aurora-backup-hardening branch from c61f01a to 1cccfc4 Compare September 1, 2026 19:41

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Terraform, AWS resources, networking, IAM needs-refinement Used on tickets that need to be refined before work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants