Parameterize baseline constructs for multi-account reuse

DetectiveControls, FlowLogs, and GovernanceToggles were forked into
seahaven-external-dev-baseline with only physical-name and VPC-sourcing
differences. Prefix/name props let one implementation serve both
accounts; synthesized templates are unchanged (verified: empty cdk diff
against all deployed stacks).
This commit is contained in:
Adam Moussa 2026-07-14 13:27:38 -04:00
parent f3c37d5b20
commit 1041a157da
No known key found for this signature in database
4 changed files with 68 additions and 32 deletions

View file

@ -31,6 +31,11 @@ export interface AccountBaselineStackProps extends cdk.StackProps {
readonly monthlyBudgetUsd: number;
/** Email for budget threshold alerts (M-10). */
readonly budgetAlertEmail: string;
/**
* VPC ids to attach ALL-traffic flow logs to (H-14). Logical IDs are
* index-derived — only append, never reorder (see lib/flow-logs.ts).
*/
readonly flowLogVpcIds: string[];
}
export class AccountBaselineStack extends cdk.Stack {
@ -225,9 +230,12 @@ export class AccountBaselineStack extends cdk.Stack {
// ── Day 1 detective layer + governance toggles ──
// Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5).
new DetectiveControls(this, "DetectiveControls");
new DetectiveControls(this, "DetectiveControls", {
namePrefix: "seahaven",
});
// Monthly cost budget (M-10). Other governance toggles are CLI + documented.
new GovernanceToggles(this, "GovernanceToggles", {
budgetName: "seahaven-monthly-cost",
monthlyLimitUsd: props.monthlyBudgetUsd,
alertEmail: props.budgetAlertEmail,
});
@ -239,7 +247,10 @@ export class AccountBaselineStack extends cdk.Stack {
alarmEmail: props.budgetAlertEmail,
trailLogGroup,
});
new FlowLogs(this, "FlowLogs");
new FlowLogs(this, "FlowLogs", {
namePrefix: "seahaven",
vpcIds: props.flowLogVpcIds,
});
new SesMonitoring(this, "SesMonitoring");
// Shared CloudFront WAF WebACL (M-17); ARN published to SSM for app stacks.
new AppWebAcl(this, "AppWebAcl");

View file

@ -16,14 +16,29 @@ import { Construct } from "constructs";
* H-4 Security Hub with AWS FSBP + CIS v3.0 standards
* M-5 IAM Access Analyzer (account-scoped external-access analyzer)
*
* Serves both the management-account baseline (namePrefix "seahaven") and
* member-account baselines (e.g. "seahaven-extdev") — physical resource names
* are prefix-parameterized, structure is identical.
*
* Scope is us-east-1 only — all workloads live here (Adam's call, Day 1).
* Multi-region coverage is a documented follow-up.
*/
export interface DetectiveControlsProps {
/**
* Physical-name prefix for account-scoped resources (bucket, roles, recorder,
* delivery channel, analyzer). Also baked into the custom resources'
* PhysicalResourceId strings — changing it on a deployed stack REPLACES the
* custom resources; keep it stable per account.
*/
readonly namePrefix: string;
}
export class DetectiveControls extends Construct {
constructor(scope: Construct, id: string) {
constructor(scope: Construct, id: string, props: DetectiveControlsProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
const prefix = props.namePrefix;
// ──────────────────────────────────────────────────────────────────────
// H-2 AWS Config
@ -33,7 +48,7 @@ export class DetectiveControls extends Construct {
// versioned, SSE-S3 (Config writes here; SSE-S3 avoids a second KMS-grant
// failure mode and is sufficient — CIS does not require a CMK here).
const configBucket = new s3.Bucket(this, "ConfigBucket", {
bucketName: `seahaven-config-${stack.account}`,
bucketName: `${prefix}-config-${stack.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
@ -85,7 +100,7 @@ export class DetectiveControls extends Construct {
// grants delivery to the bucket above. **This role is the Day 1 cross-review
// item (IAM change per CLAUDE.md).**
const recorderRole = new iam.Role(this, "ConfigRecorderRole", {
roleName: "seahaven-config-recorder-role",
roleName: `${prefix}-config-recorder-role`,
assumedBy: new iam.ServicePrincipal("config.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWS_ConfigRole"),
@ -143,7 +158,7 @@ export class DetectiveControls extends Construct {
this,
"ConfigCustomResourceRole",
{
roleName: "seahaven-config-custom-resource-role",
roleName: `${prefix}-config-custom-resource-role`,
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
@ -191,7 +206,7 @@ export class DetectiveControls extends Construct {
action: "putConfigurationRecorder",
parameters: {
ConfigurationRecorder: {
name: "seahaven-config-recorder",
name: `${prefix}-config-recorder`,
roleARN: recorderRole.roleArn,
recordingGroup: {
allSupported: true,
@ -200,7 +215,7 @@ export class DetectiveControls extends Construct {
},
},
// No meaningful response data to extract.
physicalResourceId: cr.PhysicalResourceId.of("seahaven-config-recorder"),
physicalResourceId: cr.PhysicalResourceId.of(`${prefix}-config-recorder`),
};
const putChannelCall: cr.AwsSdkCall = {
@ -208,7 +223,7 @@ export class DetectiveControls extends Construct {
action: "putDeliveryChannel",
parameters: {
DeliveryChannel: {
name: "seahaven-config-delivery",
name: `${prefix}-config-delivery`,
s3BucketName: configBucket.bucketName,
configSnapshotDeliveryProperties: {
deliveryFrequency: "TwentyFour_Hours",
@ -216,7 +231,7 @@ export class DetectiveControls extends Construct {
},
},
physicalResourceId: cr.PhysicalResourceId.of(
"seahaven-config-delivery"
`${prefix}-config-delivery`
),
};
@ -224,10 +239,10 @@ export class DetectiveControls extends Construct {
service: "ConfigService",
action: "startConfigurationRecorder",
parameters: {
ConfigurationRecorderName: "seahaven-config-recorder",
ConfigurationRecorderName: `${prefix}-config-recorder`,
},
physicalResourceId: cr.PhysicalResourceId.of(
"seahaven-config-recorder-start"
`${prefix}-config-recorder-start`
),
};
@ -265,10 +280,10 @@ export class DetectiveControls extends Construct {
service: "ConfigService",
action: "stopConfigurationRecorder",
parameters: {
ConfigurationRecorderName: "seahaven-config-recorder",
ConfigurationRecorderName: `${prefix}-config-recorder`,
},
physicalResourceId: cr.PhysicalResourceId.of(
"seahaven-config-recorder-stop"
`${prefix}-config-recorder-stop`
),
},
role: configCustomResourceRole,
@ -336,7 +351,7 @@ export class DetectiveControls extends Construct {
// M-5 IAM Access Analyzer (free, account-scoped external-access)
// ──────────────────────────────────────────────────────────────────────
new accessanalyzer.CfnAnalyzer(this, "AccountAnalyzer", {
analyzerName: "seahaven-account-analyzer",
analyzerName: `${prefix}-account-analyzer`,
type: "ACCOUNT",
});

View file

@ -5,32 +5,39 @@ import * as ec2 from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
/**
* VPC flow logs for every VPC, delivered to a hardened S3 bucket (audit H-14,
* CIS 3.9/5.6). S3 destination (not CloudWatch Logs) for cost — query
* forensically via Athena. ALL traffic (accept + reject).
* VPC flow logs delivered to a hardened S3 bucket (audit H-14, CIS 3.9/5.6).
* S3 destination (not CloudWatch Logs) for cost — query forensically via
* Athena. ALL traffic (accept + reject).
*
* Serves both the management-account baseline and member-account baselines:
* VPC ids are passed via props (the management account pins its 5 audited
* VPCs in bin/app.ts; member accounts source theirs from cdk context because
* their VPCs change over time). Pass an empty list to create the hardened
* destination bucket without any flow logs attached yet.
*
* S3 delivery needs no IAM role; instead the bucket policy grants the
* `delivery.logs.amazonaws.com` service principal write access, scoped to this
* account. That bucket policy is the Day 2 cross-review item.
*/
// All 5 VPCs in 328440206208 / us-east-1 (4 custom + default), audit Agent 7.
const VPC_IDS = [
"vpc-061d66990b6a4d1fb",
"vpc-0542a9e934b417d23",
"vpc-062d200c68bd4ca0e",
"vpc-0d3d4b67bd0cf8a68",
"vpc-02c10a89d66f6f9b8",
];
export interface FlowLogsProps {
/** Physical-name prefix for the destination bucket (e.g. "seahaven" or "seahaven-extdev"). */
readonly namePrefix: string;
/**
* VPC ids to attach ALL-traffic flow logs to. May be empty. Logical IDs are
* index-derived (FlowLog0, FlowLog1, ...) — REORDERING this list replaces
* deployed flow logs; only append.
*/
readonly vpcIds: string[];
}
export class FlowLogs extends Construct {
constructor(scope: Construct, id: string) {
constructor(scope: Construct, id: string, props: FlowLogsProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
const bucket = new s3.Bucket(this, "FlowLogsBucket", {
bucketName: `seahaven-vpc-flow-logs-${stack.account}`,
bucketName: `${props.namePrefix}-vpc-flow-logs-${stack.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
@ -90,7 +97,7 @@ export class FlowLogs extends Construct {
})
);
VPC_IDS.forEach((vpcId, i) => {
props.vpcIds.forEach((vpcId, i) => {
const flowLog = new ec2.CfnFlowLog(this, `FlowLog${i}`, {
resourceId: vpcId,
resourceType: "VPC",

View file

@ -3,6 +3,8 @@ import * as budgets from "aws-cdk-lib/aws-budgets";
import { Construct } from "constructs";
export interface GovernanceTogglesProps {
/** Physical name of the AWS Budget (account-scoped, e.g. "seahaven-monthly-cost"). */
readonly budgetName: string;
/** Monthly cost budget ceiling in USD. */
readonly monthlyLimitUsd: number;
/** Email that receives the budget threshold alerts. */
@ -11,7 +13,8 @@ export interface GovernanceTogglesProps {
/**
* Account-level governance toggles that *are* expressible as CloudFormation
* (audit Day 1).
* (audit Day 1). Serves both the management-account baseline and member-account
* baselines (budget name parameterized per account).
*
* Closes:
* M-10 Monthly AWS Budget with 80% / 100% actual + 100% forecast alerts
@ -37,7 +40,7 @@ export class GovernanceToggles extends Construct {
new budgets.CfnBudget(this, "MonthlyCostBudget", {
budget: {
budgetName: "seahaven-monthly-cost",
budgetName: props.budgetName,
budgetType: "COST",
timeUnit: "MONTHLY",
budgetLimit: {