Skip to content

Commit 6f657ae

Browse files
authored
Harden AWS stack lifecycle protections (#175)
## What changed - enforce a CloudFormation stack policy after every applied deploy or upgrade so retained DynamoDB, S3, and inbound KMS resources cannot be replaced or deleted by an ordinary update - enable and verify CloudFormation termination protection after deployment, including no-template-change reconciliation - add `status aws --detect-drift` with bounded polling and metadata-only drift output - require `operational` status to have verified termination protection, the retained-resource policy, and an `IN_SYNC` drift result - make protected cleanup plan-able but require the exact stack name plus `--disable-termination-protection`; restore protection when delete submission fails - update the dedicated AWS integration workflow and operator documentation for the protected lifecycle ## Why The AWS path is the active Certified candidate, but a successful CloudFormation deployment alone did not protect the stack from accidental deletion, did not prevent updates from replacing retained customer-data resources, and only exposed the last reported drift state. These controls are required before the path can be treated as a credible Resend migration target. This is the first implementation tranche of #174. It does not claim SES production readiness or close the terminal-delivery and dogfood evidence gates. ## User and operator impact Applied deploys and upgrades now finish only after both stack protections are verified. A fresh drift check is explicit because it performs an AWS operation. Deletion remains possible, but the normal protected stack needs a separate, visible acknowledgement before the CLI disables termination protection. ## Validation - `npm ci` — 0 vulnerabilities - `npm outdated --json` — no outdated npm dependencies - `npm run check` - `npm test` — 607 passed, 6 skipped - `npm run build` - `npm run check:conformance` - `npm run lint:openapi` - `npm run check:workers` — 38 passed and Wrangler dry-run passed - `npm run site:build && npm run site:verify` - `actionlint .github/workflows/aws-integration.yml` - `git diff --check` ## Safety No AWS resources are changed by this PR itself. All AWS mutation remains exact-account gated and explicit. Drift output excludes property differences and values. Cleanup never purges retained customer data.
1 parent 01d5f36 commit 6f657ae

10 files changed

Lines changed: 1111 additions & 335 deletions

File tree

.github/workflows/aws-integration.yml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ jobs:
228228
'require("node:fs").readFileSync(process.argv[1], "utf8").trim().split("\n").forEach(JSON.parse)' \
229229
"$RUNNER_TEMP/hayasend-retention-update.jsonl"
230230
231+
npm run --silent cli -- status aws \
232+
--account "$AWS_TEST_ACCOUNT_ID" \
233+
--stack "$STACK_NAME" \
234+
--region "$AWS_REGION" \
235+
--detect-drift \
236+
> "$RUNNER_TEMP/hayasend-status.json"
237+
node -e \
238+
'const value=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")); if (value.stack.termination_protection !== true || value.stack.stack_policy.retained_resources_protected !== true || value.stack.drift.status !== "IN_SYNC" || value.stack.drift.detected_now !== true) process.exit(1)' \
239+
"$RUNNER_TEMP/hayasend-status.json"
240+
231241
updated_retention_resource="$(
232242
aws cloudformation list-stack-resources \
233243
--stack-name "$STACK_NAME" \
@@ -462,10 +472,17 @@ jobs:
462472
fi
463473
464474
if [[ "$stack_exists" = true ]]; then
465-
sam delete \
466-
--stack-name "$STACK_NAME" \
475+
npm run --silent cli -- cleanup aws \
476+
--account "$AWS_TEST_ACCOUNT_ID" \
477+
--stack "$STACK_NAME" \
467478
--region "$AWS_REGION" \
468-
--no-prompts || {
479+
--apply \
480+
--confirm-stack "$STACK_NAME" \
481+
--disable-termination-protection || {
482+
aws cloudformation update-termination-protection \
483+
--disable-termination-protection \
484+
--stack-name "$STACK_NAME" \
485+
--region "$AWS_REGION" >/dev/null 2>&1 || true
469486
aws cloudformation delete-stack \
470487
--stack-name "$STACK_NAME" \
471488
--region "$AWS_REGION"

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -420,27 +420,31 @@ npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" deploy aws \
420420
Apply creates but does not immediately execute a CloudFormation change set.
421421
HayaSend retrieves the exact new change-set ARN, prints its resource changes,
422422
and refuses removals, indeterminate actions, or possible replacements unless
423-
`--allow-destructive-changes` is also present. It never changes DNS. See the
423+
`--allow-destructive-changes` is also present. It then verifies a
424+
retained-resource stack policy and termination protection. It never changes
425+
DNS. See the
424426
[CLI guide](docs/cli.md#plan-and-deploy-to-aws) for inbound options, parameter
425427
preservation, finite Lambda log retention, failure recovery, and output
426428
privacy.
427429

428430
The same CLI covers the complete stack lifecycle:
429431

430432
```bash
431-
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws
433+
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws --detect-drift
432434
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" upgrade aws
433435
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" upgrade aws --apply
434436
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws
435437
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws \
436-
--apply --confirm-stack hayasend
438+
--apply --confirm-stack hayasend --disable-termination-protection
437439
```
438440

439-
`status aws` combines CloudFormation state and drift, SES sending readiness,
440-
stack-resource failures, CloudWatch alarms, public API health, and the
441-
dashboard link. `cleanup aws` is plan-first and deliberately retains the
442-
DynamoDB table, payload bucket, and enabled inbound data resources; it prints
443-
their physical IDs for a separate retention or destruction decision. See the
441+
`status aws --detect-drift` runs a fresh bounded CloudFormation drift check and
442+
combines its metadata-only result with protection state, SES sending
443+
readiness, stack-resource failures, CloudWatch alarms, public API health, and
444+
the dashboard link. `cleanup aws` is plan-first, requires a separate explicit
445+
protection-disable acknowledgement, and deliberately retains the DynamoDB
446+
table, payload bucket, and enabled inbound data resources; it prints their
447+
physical IDs for a separate retention or destruction decision. See the
444448
copy-paste [AWS quickstart](docs/aws-quickstart.md) and the
445449
[operations runbook](docs/operations.md).
446450

docs/aws-integration-testing.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ non-mutating plan mode. It then repeats the exact account, Region, stack, log
1010
retention, and tags with explicit `--apply`. This proves account pinning, SES
1111
and stack preflight, clean SAM validation/build, creation of an unexecuted
1212
change set, change-set inspection, and execution by the retrieved change-set
13-
ARN.
13+
ARN. It also verifies that the CLI enabled termination protection, installed
14+
the retained-resource stack policy, and completed a fresh `IN_SYNC` drift
15+
check.
1416

1517
After the clean install, the workflow creates empty groups at the four legacy
1618
Lambda-generated names with no retention policy, changes `LogRetentionDays`
@@ -39,8 +41,9 @@ AWS Budget and root-account alerts before the first run. The workflow:
3941
- sends no email to SES;
4042
- cancels every test schedule and deletes its temporary SES identity;
4143
- deletes the synthetic legacy and stack-owned CloudWatch log groups;
42-
- deletes the stack, then explicitly deletes the S3 bucket and DynamoDB table
43-
retained by HayaSend's production-safe deletion policies.
44+
- explicitly acknowledges termination-protection disable, deletes the stack
45+
through `cleanup aws`, then deletes the S3 bucket and DynamoDB table retained
46+
by HayaSend's production-safe deletion policies.
4447

4548
Cleanup allows up to 60 seconds for CloudWatch's post-stack deletion view to
4649
converge. A stack-owned group still visible after that bound is reported,
@@ -54,11 +57,11 @@ debugging, and delete the retained resources immediately afterward.
5457
Create an environment named `aws-integration`. Restrict deployments to the
5558
default branch and add a required reviewer. Set these environment variables:
5659

57-
| Variable | Example | Purpose |
58-
|---|---|---|
59-
| `AWS_TEST_ACCOUNT_ID` | `123456789012` | hard account allowlist |
60-
| `AWS_TEST_ROLE_ARN` | `arn:aws:iam::123456789012:role/HayaSendGitHubIntegration` | OIDC role |
61-
| `AWS_TEST_REGION` | `ap-northeast-1` | isolated test Region |
60+
| Variable | Example | Purpose |
61+
| --------------------- | ---------------------------------------------------------- | ---------------------- |
62+
| `AWS_TEST_ACCOUNT_ID` | `123456789012` | hard account allowlist |
63+
| `AWS_TEST_ROLE_ARN` | `arn:aws:iam::123456789012:role/HayaSendGitHubIntegration` | OIDC role |
64+
| `AWS_TEST_REGION` | `ap-northeast-1` | isolated test Region |
6265

6366
No AWS access-key secret is required.
6467

docs/aws-quickstart.md

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,29 @@ npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" deploy aws --apply
4343
Apply builds the packaged SAM application, creates an unexecuted
4444
CloudFormation change set, prints every resource action, and executes it only
4545
when no unacknowledged removal or possible replacement exists. HayaSend never
46-
changes DNS.
46+
changes DNS. After a successful create or update, the CLI enforces a stack
47+
policy that denies replacement or deletion of retained data resources and
48+
enables CloudFormation termination protection. Apply fails loudly if either
49+
protection cannot be verified.
4750

4851
## 3. Check whether it is ready
4952

5053
```bash
5154
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws
55+
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws --detect-drift
5256
```
5357

58+
The first command is a read-only snapshot of the last reported drift state.
59+
The second explicitly starts a new CloudFormation drift check and waits up to
60+
10 minutes for it to finish. Fresh drift output contains logical IDs, resource
61+
types, and statuses only; it does not print property values.
62+
5463
The result keeps two decisions separate:
5564

56-
- `operational` requires a stable stack, no drift reported, no problematic
57-
stack resources, all discovered HayaSend alarms in `OK`, and a successful
58-
public `/healthz` request;
65+
- `operational` requires a stable stack, verified termination protection, the
66+
HayaSend retained-resource stack policy, an `IN_SYNC` drift result, no
67+
problematic stack resources, all discovered HayaSend alarms in `OK`, and a
68+
successful public `/healthz` request;
5969
- `send_ready` additionally requires SES production access and account
6070
sending to be enabled.
6171

@@ -105,16 +115,20 @@ First print the deletion plan:
105115
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws
106116
```
107117

108-
Cleanup refuses an unmanaged stack, a non-terminal stack, or a stack with
109-
termination protection enabled. To delete the stack, repeat the exact name:
118+
Cleanup refuses an unmanaged or non-terminal stack. A protected stack remains
119+
plan-able, but deletion requires both its exact name and a separate
120+
acknowledgement that termination protection will be disabled:
110121

111122
```bash
112123
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws \
113124
--apply \
114-
--confirm-stack hayasend
125+
--confirm-stack hayasend \
126+
--disable-termination-protection
115127
```
116128

117-
The CLI waits for `stack-delete-complete` and verifies that the stack no longer
129+
The CLI verifies the protection change before submitting deletion. If the
130+
delete request itself fails, it attempts to re-enable termination protection.
131+
It then waits for `stack-delete-complete` and verifies that the stack no longer
118132
exists. It does not purge retained customer data. The DynamoDB table, payload
119133
bucket, and enabled inbound bucket and KMS key have `DeletionPolicy: Retain`;
120134
their physical IDs are printed before and after deletion. Decide their
@@ -123,8 +137,9 @@ backup, audit, and privacy policy.
123137

124138
## Routine operating loop
125139

126-
Run `status aws` after deployment, after updates, after AWS incidents, and
127-
before production canaries. Subscribe a real on-call destination to the
128-
`AlarmTopicArn`, confirm that subscription, configure an AWS Budget, and use
129-
the generated dashboard as the first operational view. The complete response
130-
procedures are in the [operations runbook](operations.md).
140+
Run `status aws --detect-drift` after deployment, after updates, after AWS
141+
incidents, and before production canaries. Subscribe a real on-call
142+
destination to the `AlarmTopicArn`, confirm that subscription, configure an
143+
AWS Budget, and use the generated dashboard as the first operational view.
144+
The complete response procedures are in the
145+
[operations runbook](operations.md).

docs/cli.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -678,21 +678,32 @@ secret and never creates DNS records. If CloudFormation fails, the error
678678
includes redacted recent stack events for recovery. Follow the
679679
[operations runbook](operations.md) before retrying.
680680

681+
A successful apply also installs a CloudFormation stack policy that rejects
682+
replacement or deletion of the retained DynamoDB, S3, and inbound KMS
683+
resources and enables termination protection. The result is not successful
684+
unless both controls are read back and verified. This protection is also
685+
reconciled when an apply has no template changes.
686+
681687
## Inspect AWS status
682688

683689
Use the same expected-account gate for an infrastructure and sending-readiness
684690
snapshot:
685691

686692
```bash
687693
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws
688-
```
689-
690-
This is read-only and does not require SAM or build the application. It checks
691-
the AWS CLI and caller identity, SES production/sending state and quota,
692-
CloudFormation stack state and last reported drift, individual stack
693-
resources, stack-owned CloudWatch alarms, and the public `/healthz` endpoint.
694-
Only problematic resources and alarms are expanded in the result. The output
695-
also links the generated dashboard and prints exact `upgrade aws`, `cleanup
694+
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" status aws --detect-drift
695+
```
696+
697+
Without `--detect-drift`, this is read-only and does not require SAM or build
698+
the application. It checks the AWS CLI and caller identity, SES
699+
production/sending state and quota, CloudFormation stack state, termination
700+
protection, the retained-resource stack policy, last reported drift,
701+
individual stack resources, stack-owned CloudWatch alarms, and the public
702+
`/healthz` endpoint. `--detect-drift` explicitly starts and awaits a fresh
703+
CloudFormation drift check. It reports only drifted logical IDs, resource
704+
types, and statuses, never property differences or property values. Only
705+
problematic resources and alarms are expanded in the result. The output also
706+
links the generated dashboard and prints exact drift, `upgrade aws`, `cleanup
696707
aws`, and authenticated `doctor` next steps.
697708

698709
`operational` covers infrastructure, alarms, and public health. `send_ready`
@@ -722,14 +733,16 @@ Cleanup is also plan-first:
722733
```bash
723734
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws
724735
npx --yes "@haya-inc/hayasend@${HAYASEND_VERSION}" cleanup aws \
725-
--apply --confirm-stack hayasend
736+
--apply --confirm-stack hayasend --disable-termination-protection
726737
```
727738

728739
The CLI refuses stacks without both `Project=HayaSend` and
729-
`ManagedBy=HayaSendCLI`, non-terminal stacks, and stacks with termination
730-
protection enabled. Apply requires the exact stack name, starts ordinary
731-
CloudFormation deletion, waits for `stack-delete-complete`, and verifies that
732-
the stack is absent.
740+
`ManagedBy=HayaSendCLI` and non-terminal stacks. Apply requires the exact
741+
stack name. A protected stack additionally requires
742+
`--disable-termination-protection`; the CLI disables and verifies that control
743+
immediately before ordinary CloudFormation deletion. If submission of the
744+
delete request fails, it attempts to restore the protection. It waits for
745+
`stack-delete-complete` and verifies that the stack is absent.
733746

734747
Cleanup never purges resources protected by `DeletionPolicy: Retain`. Its
735748
plan and result identify the retained DynamoDB table, payload bucket, and any

0 commit comments

Comments
 (0)