Compare commits

..

No commits in common. "7cce026f4a16813f372852612208ee9cd53c835d" and "cc54b1e28b3f21ed276f5b2cc2aa8e69b3f4eb4e" have entirely different histories.

6 changed files with 11 additions and 127 deletions

View file

@ -55,7 +55,7 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@3cde673b9d05c0e68aac4d997d582f2543853d20 # main
with:
node-version: "24"
stacks: "prod-baseline dynamodb-cmk-prod alarm-topic-prod"
stacks: "prod-baseline"
stack-name: "seahaven-prod-baseline"
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN_PROD }}

View file

@ -2,7 +2,6 @@
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";
@ -166,32 +165,6 @@ 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

View file

@ -1,68 +0,0 @@
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 });
}
}

View file

@ -269,17 +269,21 @@ export class BackupStack extends cdk.Stack {
backupSelectionName: "phase2-offsite-everything",
role: backupRole,
resources: [
// DynamoDB — all remaining tables (10)
// DynamoDB — all remaining tables (15)
...[
"SiteAssignments",
"VendorReplies",
"WorkOrderComments",
"WorkOrders",
"afterhours-shifts",
"exec-aide",
"front-sla-alerts",
"internal-portal-data",
"last-war-bot",
"meal-order-manager-orders",
"pending-site-review",
"seahaven-conversations",
"seahaven-unanswered-questions",
"verified-sites",
].map((t) =>
backup.BackupResource.fromArn(

View file

@ -143,16 +143,6 @@ export class OrgGovernanceStack extends cdk.Stack {
// are carved out of the general deny and re-denied only outside
// {us-east-1, us-west-2, us-east-2} so cross-region inference profiles
// (us.anthropic.*) that route to us-east-2 are not blocked.
// chatbot:* is exempted because AWS Chatbot ("Amazon Q Developer in chat
// applications") is a global management service: the console setup flow
// hits its control plane in us-east-2 (observed deny:
// chatbot:GetSlackOauthParameters at aws:RequestedRegion=us-east-2), which
// is outside the approved region set, so without this exemption the region
// deny blocks Slack workspace/channel setup for site-alerts → Slack. A
// full-prefix NotAction (region-agnostic, like iam:*/cloudfront:*) is the
// right shape: it names a global management service, not a workload running
// in an unapproved region. The notification-side resources (the site-alerts
// SNS topic, alarms) stay in us-east-1 and remain region-locked.
const workloadsRegionLock = new organizations.CfnPolicy(this, "WorkloadsRegionLock", {
name: "workloads-region-lock",
type: "SERVICE_CONTROL_POLICY",
@ -170,7 +160,7 @@ export class OrgGovernanceStack extends cdk.Stack {
"route53domains:*", "cloudfront:*", "waf:*", "shield:*",
"globalaccelerator:*", "budgets:*", "ce:*", "cur:*", "health:*",
"support:*", "supportplans:*", "trustedadvisor:*", "artifact:*",
"aws-portal:*", "chatbot:*",
"aws-portal:*",
...BEDROCK_INVOKE_ACTIONS,
],
Resource: "*",

View file

@ -12,43 +12,28 @@
# `feedback_cfn_decommission_and_remediation`.
#
# Usage:
# scripts/cfn-stack-decommission.sh --account-id ID [--profile NAME] [--execute] STACK
# scripts/cfn-stack-decommission.sh [--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=""; EXPECTED_ACCOUNT=""
PROFILE_ARG=(); EXECUTE=0; STACK=""
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,28p' "$0"; exit 0 ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) STACK="$1"; shift ;;
esac
done
[[ -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; }
[[ -z "$STACK" ]] && { echo "usage: $0 [--profile NAME] [--execute] STACK" >&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