feat(observability): wire SNS notification action to DLQ CloudWatch alarms (#629) - #739
Conversation
…larms (#629) Follow-up to #117/#208: the DLQ-depth alarms shipped without an addAlarmAction. Add a reusable OperationalAlerts construct — a stack-wide, CMK-encrypted SNS topic — and wire all three DLQ-depth alarms (FanOut, ApprovalMetricsPublisher, GitHubScreenshot processor) to it via addAlarmAction(SnsAction). - Topic uses a customer-managed KMS key (not alias/aws/sns): CloudWatch cannot publish to an AWS-managed-key topic, so the key policy grants cloudwatch.amazonaws.com Decrypt/GenerateDataKey. Satisfies SNS2. - Delivery target is configurable via -c alertEmail=...; with none set the topic ships with no subscription and its ARN is exported for manual Slack/PagerDuty/email wiring. - Bootstrap deploy role: grant SNS topic + CMK create/lifecycle so a fresh cdk deploy can create these resources (bump bundle to 1.4.0, regenerate artifacts, update DEPLOYMENT_ROLES.md golden baseline). - Update CEDAR_HITL_GATES.md §11.5 + deferred note; regenerate Starlight.
Address review findings on the DLQ-alarm SNS wiring: - Security (confused-deputy): pin aws:SourceAccount on the CloudWatch→KMS key grant so an alarm in another account can't induce this key's use, matching the lambda-microvm-compute precedent. - Security (deploy-role blast radius): split KMSCustomerManagedKeys — CreateKey/read/tag stay on * (unscopable), but PutKeyPolicy/ ScheduleKeyDeletion/rotation are now gated on the ABCA=operational-alerts resource tag the construct stamps on its key, so the CFN deploy role can no longer take over or delete unrelated account CMKs. - removalPolicy now applies to the topic as well as the key (shared lifecycle); prop doc widened accordingly. - Validate alertEmail at synth — throw on malformed input instead of shipping a permanently-unconfirmed subscription. - Comments: drop the inert AwsSolutions-SNS3 suppression (the CMK already satisfies SNS3); correct SNS2-vs-SNS3 and AlarmBase-vs-Alarm claims. - Tests: assert topic KmsMasterKeyId is a GetAtt to the CMK (not anyValue), removalPolicy reaches both resources, malformed-email throws, and per-verb bootstrap guards for sns:CreateTopic/Subscribe + kms:CreateKey and the tag-scoped lifecycle statement. Tag the third stack test #629. Regenerated bootstrap artifacts + DEPLOYMENT_ROLES.md golden baseline and Starlight mirror. mise run build green (3844 cdk + 745 cli tests).
ayushtr-aws
left a comment
There was a problem hiding this comment.
PR #739 Review — feat(observability): wire SNS notification action to DLQ CloudWatch alarms (#629)
- Verdict
Approve with nits. The change is correct, well-tested for its core behavior, CI is green, docs and the bootstrap bundle are complete and in sync. Two things should be tightened before
merge — both are polish, neither is functional: a code comment that overstates a security bound it doesn't actually deliver, and two untested security properties. No blocking defects.
- Vision alignment
Directly advances bounded blast radius & observability: DLQ poison-pill accumulation was silent (console-poll only); this closes the §11.5 "notification channel wiring" follow-up so
alarms push. It's purely additive, preserves fire-and-forget, and keeps outcomes reviewable (exported topic ARN, documented in CEDAR_HITL_GATES.md). Issue #629 is approved + assigned to
the author; branch name conforms. Fits cleanly.
- Blocking issues
None.
- Non-blocking suggestions / nits
N1 — Comment overstates the KMS blast-radius bound (fix wording or tighten the grant). cdk/src/bootstrap/policies/observability.ts:158-166,179-201
The lifecycle statement gates kms:PutKeyPolicy / kms:ScheduleKeyDeletion on aws:ResourceTag/ABCA=operational-alerts, but the sibling KMSCustomerManagedKeys statement grants
kms:TagResource unconditioned on *. The same role can therefore stamp ABCA=operational-alerts onto any account CMK and then satisfy the gate to take over or schedule-delete that foreign
key. So the comments "none can escalate privilege on an existing key" and "bounds the blast radius to keys this solution owns" are not true against a misused/compromised role — the
tag-gate is an accident-guard (works for normal CloudFormation operation), not a security boundary.
In practice the incremental risk is bounded: this is a CloudFormation execution role that already holds iam:CreateRole/PutRolePolicy (infrastructure.ts:63-84), so it is already able to
escalate to KMS admin by other means — which is why I score this a nit, not a blocker. But the misleading wording shouldn't land as-is. Pick one:
- (a) Reword to state honestly that the tag scopes against accidental CloudFormation targeting, not a compromised-role bound; or
- (b) Drop kms:ScheduleKeyDeletion (and ideally PutKeyPolicy) from the identity grant and let the key's own resource policy + RemovalPolicy govern deletion — that actually shrinks the
exposure the comment claims to bound. If TagResource stays, condition it on aws:RequestTag/ABCA/aws:TagKeys to at least document intent.
N2 — DenyInsecureTransport covers only sns:Publish. cdk/src/constructs/operational-alerts.ts:136-143 — canonical secure-transport guardrails deny all actions;
Subscribe/SetTopicAttributes/etc. over non-TLS are still allowed. Belt-and-suspenders (SNS endpoints are HTTPS in practice), but broaden the Deny to sns:* so it's as strong as it reads.
N3 — kms:GenerateDataKey* wildcard is broader than needed. operational-alerts.ts:118 — SNS envelope encryption needs only kms:GenerateDataKey. The * also matches the data-key-pair
variants. Common CDK idiom, key-scoped, low risk — optional tighten.
N4 — No warning when the topic ships with zero subscribers. operational-alerts.ts — with no alertEmail and nothing wired later, alarms publish to a subscriber-less topic and notify no
one while looking wired — the exact silent-DLQ failure #629 exists to fix. Consider Annotations.of(this).addWarning(...) at synth when no subscription exists. (Fail-closed on bad email
input is already correctly handled — good.)
- Documentation
Complete and in sync. CEDAR_HITL_GATES.md §11.5 rewritten from "follow-up — deferred" to "shipped (#629)", the §17 future-work item struck through, and DEPLOYMENT_ROLES.md golden
baseline updated with both new statements. I regenerated the Starlight mirror locally and confirmed zero drift — Cedar-hitl-gates.md and Deployment-roles.md mirrors match their sources;
CI's "Fail build on mutation" will pass.
- Tests & CI
All 8 CI checks green (build incl. 3844 cdk tests, CodeQL ×3, secrets/deps, PR-lint). The pre-existing security:sast:masking noise is not run in CI here and, as the author notes, is
baseline (unrelated files).
Bootstrap synth-coverage: PASS / complete. This is the highest-risk area for this repo (ADR-002 / #350) and it's done right — BOOTSTRAP_VERSION 1.3.0→1.4.0 (correct minor bump for added
actions), regenerated application.json/observability.json/bootstrap-template.yaml, BOOTSTRAP_HASH matches the version.test.ts snapshot (verified: 4591304…d77d in both),
resource-action-map.ts registers AWS::SNS::Topic/Subscription/TopicPolicy and AWS::KMS::Key, and policies.test.ts adds strong per-verb guards. I verified the SNS resource pattern
arn:aws:sns:::backgroundagent-dev-* matches the auto-generated topic name (stack name defaults to backgroundagent-dev, main.ts:35).
Test gaps (from pr-test-analyzer, both worth adding):
- The ABCA=operational-alerts tag on the key is never asserted on the construct that stamps it. policies.test.ts asserts the policy expects the tag, but nothing asserts the key carries
it — the linchpin of the whole scoping model. Drop/rename the tag and every test stays green while production key-lifecycle silently breaks. Add hasResourceProperties('AWS::KMS::Key', {
Tags: Match.arrayWith([{ Key: 'ABCA', Value: 'operational-alerts' }]) }). - The aws:SourceAccount confused-deputy condition is not asserted. operational-alerts.test.ts:72-81 matches only Principal+Action; deleting the conditions block keeps the test green
(AI005 — tests what it does, not what it should). Add the condition to the objectLike. - Minor: the DenyInsecureTransport test omits Principal/Resource (a deny scoped to the wrong ARN would pass); and the 7 construct tests re-synth per-test rather than caching in beforeAll
(cdk/AGENTS.md convention, #366) — 5 use the identical no-props config. No bundling is re-enabled (correct).
- Review agents run
- security-review (IAM/KMS/SNS least-privilege, confused-deputy, fail-open) — ran. Surfaced N1 (its one MEDIUM), N2, N3, N4; cleared aws:SourceAccount sufficiency, the SNS action set,
CreateKey-on-*, and dual removalPolicy. - pr-test-analyzer (coverage) — ran. Surfaced the three test gaps in §6.
- comment-analyzer (comment accuracy) — ran. Verified all five load-bearing claims (CloudWatch-can't-use-alias/aws/sns; CMK satisfies SNS2 and SNS3 short-circuits; addAlarmAction on
AlarmBase; atomic tag-at-create; Resource:'*' is key-local) against cdk-nag source and aws-cdk-lib type defs — all accurate. (Note the tension: comment-analyzer confirmed the
AWS-behavior claims true, while security-review flagged the blast-radius claim as overstated — N1. Both hold: the mechanics are described correctly; the security guarantee is not.) - code-reviewer / silent-failure-hunter / type-design-analyzer — folded into the hand-review rather than run as separate agents: the diff is a single small construct + IAM/doc deltas
already covered by security + comment + test agents. Error handling is one synth-time throw (fail-closed, tested); the one new type (OperationalAlertsProps) is minimal and
well-documented. No separate findings.
- Human heuristics
- Proportionality — Pass. A reusable ~172-line construct for a stack-wide channel is right-sized; not over-abstracted (it's genuinely shared by 3 alarms and designed for more).
- Coherence — Pass. Belongs in cdk/src/constructs/; aws:SourceAccount pinning mirrors the lambda-microvm-compute.ts precedent; bootstrap changes follow the established split.
- Clarity — Concern (observability.ts:158-166). Names and structure are excellent, but the KMS-lifecycle comment asserts a bound the unconditioned kms:TagResource doesn't deliver (N1).
This is the one place a future reader is invited to trust something false. - Appropriateness — Pass. Maintainable; the load-bearing CMK rationale is documented so the next contributor won't "simplify" it back to alias/aws/sns. Verified against real cdk-nag/AWS
behavior, not just self-written mocks.
…ples#665, model-config stack, DLQ alarms) into feat/645-lambda-microvm-p2 Upstream gained 18 commits across five overlapping areas: the standalone Agent Registry (aws-samples#548 ADR-022, aws-samples#755, aws-samples#664, aws-samples#665), the ADR-019 tool Gateway (aws-samples#663, aws-samples#755), the model-configuration stack (aws-samples#752 run.sh, aws-samples#753 docs, aws-samples#754 + aws-samples#768 Opus 5, aws-samples#763 budget docs, aws-samples#764 geo-configurable inference profiles), the Jira orchestration work (aws-samples#725/aws-samples#726/aws-samples#727, aws-samples#710) and the OperationalAlerts SNS/KMS channel (aws-samples#208, aws-samples#739). 26 files overlap this branch; 11 needed manual resolution. Bootstrap bundle: 1.4.0 -> 1.6.0 -------------------------------- Both sides bumped from the merge-base 1.3.0. Upstream took 1.4.0 (aws-samples#739: SNS topic + customer-managed-KMS create/lifecycle for OperationalAlerts) and then 1.5.0 (aws-samples#664: Step Functions, Cognito group, CloudFormation nested-stack actions for the registry), so this branch's `MicrovmPassRoles` statement becomes 1.6.0 rather than re-using a published number — the version is an operator-visible contract (`CDKToolkit`'s `BootstrapPolicyVersion` output) and the guidance we ship is a `>=` check. The policy sets are disjoint and unioned cleanly: theirs edited `application.ts` / `infrastructure.ts` / `observability.ts`, ours only `compute-lambda-microvm.ts`. `resource-action-map.ts` auto-merged (their registry/SNS/KMS entries plus our `iam:PassRole` on `AWS::Lambda::MicrovmImage` and `AWS::Lambda::NetworkConnector`). Artifacts regenerated with `mise //cdk:bootstrap:generate` — never hand-edited — and re-run to confirm a zero diff; new hash `d30eb8e6…`, snapshot updated to match. Every operator-facing ">= 1.4.0" reference we wrote is now 1.6.0: DEPLOYMENT_GUIDE.md, DEPLOYMENT_ROLES.md (whose "bootstrapped at 1.3.0 or earlier" becomes "1.5.0 or earlier"), USER_GUIDE.md, ADR-021 (sub-decision 4 + the parity table), the `lambda-microvm-compute.ts` synth warning, `package-microvm-artifact.sh` (4 sites) and `cdk/AGENTS.md`. No test hardcodes the number. Geo resolver: our constant becomes a derived value -------------------------------------------------- aws-samples#764 landed first with `resolveBedrockGeoRegion` + `BEDROCK_GEO_REGIONS` + `GEO_PREFIX_RE`, and hardcoded the haiku literal a second time as `` `${bedrockGeoRegion}.anthropic.claude-haiku-4-5-20251001-v1:0` ``. Adopted their resolver shape and derived our haiku value through it, exactly as the heads-up on this PR asked: - `DEFAULT_HAIKU_MODEL_ID` (bare id) is kept and still spliced into `DEFAULT_BEDROCK_MODEL_IDS` alongside their new `anthropic.claude-opus-5` entry, so grant and delivery cannot drift. - `DEFAULT_HAIKU_INFERENCE_PROFILE_ID` (a `us.`-baked const) is REPLACED by `haikuInferenceProfileId(geoRegion)`. A const could only ever carry one geography, which is the split aws-samples#764 exists to prevent. - Both delivery sites call it with the same resolved geography: the AgentCore runtime env block, and the lambda-microvm `platform_config` block — the "third site" flagged on aws-samples#746. A geo change that missed the second would leave one substrate calling a profile its role does not grant. aws-samples#768's Opus 5 default needs nothing from `platform_config`: it carries no main model (that arrives per-task from the repo config), only the auxiliary haiku id. aws-samples#752's run.sh fix is Docker-invocation-only and does not touch the `platform_config` env installs in server.py. Resolved manually ----------------- - `cdk/src/bootstrap/version.ts` — union bump history, 1.6.0, with the reason it is not 1.4.0 recorded in the JSDoc. - `cdk/src/constructs/bedrock-models.ts` — as above; their Opus 5 entry plus our constant in the model list, `haikuInferenceProfileId` seated after `resolveBedrockGeoRegion`. - `cdk/src/stacks/agent.ts` — import unions `haikuInferenceProfileId` with their `resolveBedrockGeoRegion`; the runtime env var and our `agentPlatformConfig.anthropicDefaultHaikuModel` both derive from `bedrockGeoRegion`; their `agentRegistryId` prop sits alongside our `agentPlatformConfig` block on the TaskOrchestrator call. - `cdk/src/constructs/task-orchestrator.ts` — `AGENT_REGISTRY_ID` and our `platform_config` env block are both emitted; disjoint keys. - `agent/src/runner.py` + `agent/tests/test_runner.py` — both helpers land after `_resolve_setting_sources` in call order (`_log_claude_cli_version` then `_register_gateway_server`), both call sites survive, both test classes kept, import lists unioned. - `docs/guides/DEPLOYMENT_GUIDE.md` — our "Lambda MicroVMs backend (experimental)" section and their "Optional Agent Registry" section are both additive under the same heading level; kept in that order. - `cdk/bootstrap/{BOOTSTRAP_VERSION,BOOTSTRAP_HASH,bootstrap-template.yaml}` and `test/bootstrap/__snapshots__/version.test.ts.snap` — regenerated, not merged. - The two Starlight mirrors that conflicted (`Per-repo-overrides.md`, `Deployment-guide.md`) were regenerated by `mise //docs:sync`, which is idempotent on a second run. Auto-merged, verified by hand (no re-seating needed) ---------------------------------------------------- - `agent/src/server.py` — their `resolved_assets` threading (aws-samples#665) lands in `_extract_invocation_params` and `_run_task_background`, both of which the MicroVM `/run` hook already reuses; `_spawn_background` forwards `**params`, so registry assets reach the guest on this backend for free. Our review-wave changes (`_PayloadFetchError`, ARN pinning, the no-`platform_config` 400, control-char rejection) are in disjoint regions and their seam-guard tests still pass. - `cdk/src/handlers/shared/orchestrator.ts` — `resolveRegistryAssets` and `resolved_assets` go onto the shared `agentPayload`, which the lambda-microvm strategy forwards verbatim (inline or via S3), so no strategy change was needed. `heartbeatLivenessApplies` / `buildComputeMetadata` / `reconcileMicrovmSubstrateState` untouched. - `cdk/src/handlers/shared/types.ts` + `cli/src/types.ts` — their `resolved_assets` sits after `resolved_workflow`, our `agent_heartbeat_at` after `completed_at`, in the same order in both packages, so `check:types-sync` still matches exactly. - `agent/README.md`, `docs/design/DEPLOYMENT_ROLES.md`, `docs/guides/USER_GUIDE.md` — prose additions in different sections. Verified: `mise run build` and `mise run drift-prevention` exit 0 (4261 cdk + 768 cli + 1739 agent tests), `//cdk:eslint` and `//cli:eslint` produce no changes, `//cdk:bootstrap:generate` and `//docs:sync` are both a zero diff on re-run, link-check clean.
Summary
Follow-up to #117 / #208 (§11.5 "notification channel wiring"). The DLQ-depth alarms shipped without an
addAlarmAction, so poison-pill accumulation was only visible by polling the CloudWatch Alarms console. This adds a push notification channel.OperationalAlertsconstruct (cdk/src/constructs/operational-alerts.ts): a single stack-wide SNS topic.addAlarmAction(new SnsAction(topic)):FanOutConsumer.dlqDepthAlarmApprovalMetricsPublisherConsumer.dlqAlarmGitHubScreenshotIntegration.processorDlqDepthAlarmOperationalAlertsTopicArn.Design notes
alias/aws/sns. This is load-bearing: CloudWatch cannot publish to a topic encrypted with the AWS-managed key (its policy can't be edited to grantcloudwatch.amazonaws.comkms:Decrypt/GenerateDataKey*), so the alarm action would fail silently at delivery. The CMK grants CloudWatch exactly those actions and satisfies cdk-nagAwsSolutions-SNS2. TLS-on-publish is enforced via an explicitDenyInsecureTransporttopic policy (AwsSolutions-SNS3).-c [email protected]creates an email subscription (AWS sends a confirmation link). With no context set, the topic ships with no subscription — operators wire Slack / PagerDuty / email manually against the exported ARN. Not hard-coded.Deploy-role changes (required)
For a fresh
cdk deployto create these resources, the CFN execution role needs SNS + CMK create/lifecycle permissions:SNSstatement (arn:aws:sns:*:*:backgroundagent-dev-*).KMSCustomerManagedKeysstatement.1.3.0 → 1.4.0, regenerated JSON artifacts + template + hash, updatedDEPLOYMENT_ROLES.mdgolden baseline, and theresource-action-mapsynth-coverage guard.Docs
docs/design/CEDAR_HITL_GATES.md§11.5 + deferred note updated; Starlight mirrors regenerated.Testing
mise run build— green (3839 cdk tests pass).operational-alerts.test.ts(topic/CMK/key-policy/TLS/subscription/alarm-action, 100% coverage) + 3 newagent.test.tsstack assertions (single topic, all-3-alarms-wired, no-email-by-default).Closes #629.