From 4d3c846b886a6c28079323a7288663b43d6a222f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:29:55 -0400 Subject: [PATCH] feat(prod): seahaven-prod DynamoDB CMK + site-alerts alarm topic (procurement-ingest migration Phase 0a) (#57) * feat(prod): add seahaven-prod DynamoDB CMK and site-alerts alarm-topic stacks Provisions the two shared dependencies procurement-ingest imports by name, ahead of its migration from mgmt to seahaven-prod: - dynamodb-cmk-prod: second DynamoDbCmkStack instance (same stack name, prod account) creating alias/seahaven-dynamodb + the /seahaven/dynamodb/cmk-arn SSM param. Adds a cross-account key-policy statement so the mgmt seahaven-slack-bot roles can keep reading the CMK-encrypted purchase-orders table after it moves (ViaService + PrincipalArn-wildcard scoped; identity-policy half lands in the slack-bot repo's cutover PR). - alarm-topic-prod: codified site-alerts SNS topic + seahaven-alarm-topics CMK with the cloudwatch.amazonaws.com publish grant (mirrors the working mgmt pattern; mgmt's topic remains CLI-managed debt). - deploy.yaml: both appended to the deploy-prod job's explicit stack list (SH-ORG-005 rule: unlisted stacks silently never deploy). * fix(scripts): account-id assertion in cfn-stack-decommission; complete the aws-cdk-lib 2.262.0 bump (patched brace-expansion); document CMK cutover trap - cfn-stack-decommission.sh: --account-id is now REQUIRED and asserted against sts get-caller-identity before anything runs. Stack names are no longer org-unique (seahaven-dynamodb-cmk now exists in mgmt AND prod), so a name-only lookup under the wrong ambient profile could report or delete the wrong account's stack (security-review LOGIC-001). - package.json/lock: PR #56's bump-for-patched-brace-expansion landed the commit title but not the pin; package.json still said 2.261.0 and the lockfile still resolved brace-expansion 5.0.6 (GHSA-3jxr-9vmj-r5cp HIGH, blocking the pre-commit scanner). Pin 2.262.0 and regenerate; npm audit now clean. - bin/app.ts comments: slack-bot cutover MUST grant the PROD key ARN, never the account-local mgmt SSM param (LOGIC-005); failed-first-create orphan CMK recovery note (LOGIC-004). * refactor(prod): drop cross-account CMK grant (slack-bot decommissioned 2026-07-23) The AllowMgmtSlackBotReadViaDynamoDb key-policy statement targeted the seahaven-slack-bot roles, which were decommissioned 2026-07-23. Its successor sh-mcp is undeployed and uses same-account DynamoDB access, so no cross-account reader of the CMK-encrypted purchase-orders table exists. The prod CMK + SSM param + alarm-topic stacks remain (procurement-ingest still imports them). Add a scoped cross-account grant if/when a real cross-account consumer deploys. --- .github/workflows/deploy.yaml | 2 +- bin/app.ts | 27 ++++++++++++ lib/alarm-topic-stack.ts | 68 +++++++++++++++++++++++++++++++ scripts/cfn-stack-decommission.sh | 23 +++++++++-- 4 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 lib/alarm-topic-stack.ts diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index b537091..f956e0c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -55,7 +55,7 @@ jobs: uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@3cde673b9d05c0e68aac4d997d582f2543853d20 # main with: node-version: "24" - stacks: "prod-baseline" + stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod" stack-name: "seahaven-prod-baseline" secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }} diff --git a/bin/app.ts b/bin/app.ts index b24f3c6..18b4194 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -2,6 +2,7 @@ import "source-map-support/register"; import * as cdk from "aws-cdk-lib"; import { AccountBaselineStack } from "../lib/account-baseline-stack"; +import { AlarmTopicStack } from "../lib/alarm-topic-stack"; import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; @@ -165,6 +166,32 @@ new DynamoDbCmkStack(app, "dynamodb-cmk", { env: { account: ACCOUNT, region: "us-east-1" }, }); +// ── seahaven-prod copies for the procurement-ingest migration ──────────────── +// procurement-ingest is moving from mgmt to seahaven-prod; its stacks resolve +// the DynamoDB CMK via SSM /seahaven/dynamodb/cmk-arn and import the SNS topic +// `site-alerts` by constructed in-account ARN, so both must exist in prod +// BEFORE that app's first prod deploy. Same stack names as mgmt (unique +// per-account); distinct CDK ids. +// No cross-account key-policy statement: the only cross-account reader of the +// CMK-encrypted purchase-orders table (seahaven-slack-bot) was decommissioned +// 2026-07-23, and its successor sh-mcp is undeployed and uses same-account +// DynamoDB access. When/if a cross-account consumer materializes, add a +// correctly-scoped grant then (target its real roles + account). +// +// Recovery note (failed FIRST create): the key is RETAIN, its alias/SSM param +// are not — a CREATE_FAILED rollback orphans an unaliased rotation-enabled +// key. Before re-running the deploy, list unaliased CMKs in prod and schedule +// deletion of the orphan. +new DynamoDbCmkStack(app, "dynamodb-cmk-prod", { + stackName: "seahaven-dynamodb-cmk", + env: { account: PROD_ACCOUNT, region: "us-east-1" }, +}); + +new AlarmTopicStack(app, "alarm-topic-prod", { + stackName: "seahaven-alarm-topic", + env: { account: PROD_ACCOUNT, region: "us-east-1" }, +}); + // ── Secondary-region baselines (INFRA-16, INFRA-91) ────────────────────────── // The us-east-1 baseline above is region-pinned by design. These stacks extend // a minimal detective/logging footprint into the secondary regions, codifying diff --git a/lib/alarm-topic-stack.ts b/lib/alarm-topic-stack.ts new file mode 100644 index 0000000..26c5389 --- /dev/null +++ b/lib/alarm-topic-stack.ts @@ -0,0 +1,68 @@ +import * as cdk from "aws-cdk-lib"; +import * as kms from "aws-cdk-lib/aws-kms"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as sns from "aws-cdk-lib/aws-sns"; +import { Construct } from "constructs"; + +/** + * Shared CloudWatch-alarm SNS topic (`site-alerts`) + its CMK for a member + * account. First tenant: seahaven-prod, for the procurement-ingest migration + * (its stacks import the topic by constructed ARN `site-alerts` in-account). + * + * mgmt's equivalent topic is unmanaged (created via CLI, acknowledged debt in + * cis-monitoring.ts) - this stack codifies the same working pattern instead of + * replicating the debt: + * - CMK `alias/seahaven-alarm-topics`, NOT alias/aws/sns: the AWS-managed SNS + * key's policy cannot grant cloudwatch.amazonaws.com, so alarms silently + * fail to publish through it. + * - Key policy grants cloudwatch.amazonaws.com only (SourceAccount-scoped). + * Subscribers (AWS Chatbot -> Slack) need no KMS grant: SNS decrypts at + * delivery. Verified live by mgmt's site-alerts + Chatbot wiring. + * - Chatbot workspace auth + channel config are console-only (per-account) + * and deliberately out of scope here; verify delivery post-deploy with + * `aws sns publish` + a Slack message check. + */ +export class AlarmTopicStack extends cdk.Stack { + public readonly topic: sns.Topic; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const alarmTopicKey = new kms.Key(this, "AlarmTopicKey", { + alias: "seahaven-alarm-topics", + description: + "SSE for SNS alarm topics; grants CloudWatch alarms publish-side usage", + enableKeyRotation: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + // GenerateDataKey* is the publish-side envelope-encryption call CloudWatch + // makes when writing to an encrypted topic; Decrypt covers retried + // deliveries re-reading its own envelope. Both are required for alarms to + // publish at all. + alarmTopicKey.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AllowCloudWatchAlarmsUse", + principals: [new iam.ServicePrincipal("cloudwatch.amazonaws.com")], + actions: ["kms:GenerateDataKey*", "kms:Decrypt", "kms:DescribeKey"], + resources: ["*"], + conditions: { + StringEquals: { "aws:SourceAccount": this.account }, + }, + }), + ); + + this.topic = new sns.Topic(this, "SiteAlertsTopic", { + topicName: "site-alerts", + displayName: "Sea Haven operational alarms", + masterKey: alarmTopicKey, + }); + + cdk.Tags.of(this).add("Project", "account-baseline"); + cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); + cdk.Tags.of(this).add("Environment", "prod"); + cdk.Tags.of(this).add("ManagedBy", "cdk"); + + new cdk.CfnOutput(this, "SiteAlertsTopicArn", { value: this.topic.topicArn }); + new cdk.CfnOutput(this, "AlarmTopicKeyArn", { value: alarmTopicKey.keyArn }); + } +} diff --git a/scripts/cfn-stack-decommission.sh b/scripts/cfn-stack-decommission.sh index 2dfe60f..1f29a31 100755 --- a/scripts/cfn-stack-decommission.sh +++ b/scripts/cfn-stack-decommission.sh @@ -12,28 +12,43 @@ # `feedback_cfn_decommission_and_remediation`. # # Usage: -# scripts/cfn-stack-decommission.sh [--profile NAME] [--execute] STACK +# scripts/cfn-stack-decommission.sh --account-id ID [--profile NAME] [--execute] STACK # +# --account-id REQUIRED. Asserted against the credentials' actual account +# before anything runs. Stack names are NOT org-unique +# (seahaven-dynamodb-cmk exists in both mgmt and prod), so a +# name-only lookup with the wrong ambient profile would +# report — or with --execute, DELETE — the wrong account's +# stack and then purge its Retain orphans. # (no --execute) REPORT only: termination protection, consumed exports, # Retain resources (orphans-to-be), in-stack S3 buckets. # --execute Disable termination protection, empty Delete-policy buckets, # delete the stack, wait, then delete the Retain orphans. # set -euo pipefail -PROFILE_ARG=(); EXECUTE=0; STACK="" +PROFILE_ARG=(); EXECUTE=0; STACK=""; EXPECTED_ACCOUNT="" while [[ $# -gt 0 ]]; do case "$1" in --profile) PROFILE_ARG=(--profile "$2"); shift 2 ;; + --account-id) EXPECTED_ACCOUNT="$2"; shift 2 ;; --execute) EXECUTE=1; shift ;; - -h|--help) sed -n '2,22p' "$0"; exit 0 ;; + -h|--help) sed -n '2,28p' "$0"; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) STACK="$1"; shift ;; esac done -[[ -z "$STACK" ]] && { echo "usage: $0 [--profile NAME] [--execute] STACK" >&2; exit 2; } +[[ -z "$STACK" ]] && { echo "usage: $0 --account-id ID [--profile NAME] [--execute] STACK" >&2; exit 2; } +[[ -z "$EXPECTED_ACCOUNT" ]] && { echo "ERROR: --account-id is required (stack names are not org-unique)." >&2; exit 2; } aws_() { aws "${PROFILE_ARG[@]}" "$@"; } R="us-east-1" +CALLER_ACCOUNT="$(aws_ sts get-caller-identity --query Account --output text)" +if [[ "$CALLER_ACCOUNT" != "$EXPECTED_ACCOUNT" ]]; then + echo "ABORT: credentials resolve to account $CALLER_ACCOUNT, expected $EXPECTED_ACCOUNT." >&2 + exit 1 +fi +echo "== account: $CALLER_ACCOUNT (verified) ==" + echo "== stack: $STACK ==" aws_ cloudformation describe-stacks --stack-name "$STACK" --region "$R" \ --query 'Stacks[0].{Status:StackStatus,TermProt:EnableTerminationProtection}' --output table