seahaven-org-baseline/lib/alarm-topic-stack.ts
Adam Moussa 4d3c846b88
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.
2026-07-23 15:29:55 -04:00

68 lines
2.9 KiB
TypeScript

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 });
}
}