mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-10-03 22:23:12 +00:00
Nightly backup jobs fail on resources that have left the management account. The CloudFront WebACL is already gone while CloudFormation still owns it, so the deletion policy has to be Retain before a later change can remove it.
276 lines
12 KiB
TypeScript
276 lines
12 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 events from "aws-cdk-lib/aws-events";
|
|
import * as backup from "aws-cdk-lib/aws-backup";
|
|
import { Construct } from "constructs";
|
|
|
|
/**
|
|
* Primary AWS Backup vault + plan for Sea Haven (account 328440206208), us-east-1.
|
|
*
|
|
* Closes audit finding C-7 (AWS Backup entirely unused) together with
|
|
* backup-offsite-stack. Phase 1 ("critical data first"): protect the data
|
|
* stores with no offsite leg today and copy each recovery point cross-region
|
|
* to the GOVERNANCE-locked `seahaven-offsite` vault (us-west-2).
|
|
*
|
|
* Coexistence: this SUPPLEMENTS the existing EBS DLM snapshots and DynamoDB
|
|
* PITR — it does not replace them. It adds the missing Copy3 (offsite) +
|
|
* immutability leg. The DLM/PITR overlap is rationalized in a later phase.
|
|
*
|
|
* Selection is by explicit ARN (not tag-based) so we don't have to tag — and
|
|
* drift — resources owned by other stacks (proposal-system, payments-dashboard).
|
|
* Switch to tag-based selection when expanding past the phase-1 set.
|
|
*
|
|
* Departed resources were removed from the selections on 2026-10-01 so the
|
|
* daily job stops failing on missing ARNs. The vaults, plan, role, and lock
|
|
* stay. `amazon-po` stays selected: the bucket exists and is versioned, and
|
|
* its job fails for a reason that is not "bucket gone".
|
|
*/
|
|
export class BackupStack extends cdk.Stack {
|
|
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
|
super(scope, id, props);
|
|
|
|
// CMK encrypting the primary (operational) vault. RETAIN + rotation.
|
|
const vaultKey = new kms.Key(this, "PrimaryVaultKey", {
|
|
alias: "backup-primary-vault",
|
|
description: "Encrypts primary AWS Backup recovery points (us-east-1)",
|
|
enableKeyRotation: true,
|
|
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
});
|
|
|
|
// The L2 BackupVault does NOT grant the backup service use of a customer
|
|
// CMK; the default key policy only delegates to account IAM. Grant
|
|
// backup.amazonaws.com the minimum KMS actions (incl. CreateGrant for
|
|
// RDS/EBS recovery points) so backup jobs can write to this vault.
|
|
vaultKey.addToResourcePolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "AllowAwsBackupUseOfTheKey",
|
|
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
|
// Action set matches AWS's documented Backup vault-key policy; scoped
|
|
// to this account so only this account's Backup service can use it.
|
|
actions: [
|
|
"kms:Decrypt",
|
|
"kms:GenerateDataKey",
|
|
"kms:GenerateDataKeyWithoutPlaintext",
|
|
"kms:ReEncrypt*",
|
|
"kms:DescribeKey",
|
|
],
|
|
resources: ["*"],
|
|
conditions: { StringEquals: { "aws:SourceAccount": this.account } },
|
|
})
|
|
);
|
|
vaultKey.addToResourcePolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "AllowAwsBackupCreateGrant",
|
|
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
|
actions: ["kms:CreateGrant"],
|
|
resources: ["*"],
|
|
conditions: {
|
|
Bool: { "kms:GrantIsForAWSResource": "true" },
|
|
StringEquals: { "aws:SourceAccount": this.account },
|
|
},
|
|
})
|
|
);
|
|
|
|
// Primary vault — GOVERNANCE Vault Lock (INFRA-89). Codifies the lock applied
|
|
// out-of-band 2026-06: MinRetention 1d, MaxRetention 2555d (~7y), no
|
|
// `changeableFor` (LockDate null = admin-removable, GOVERNANCE not
|
|
// COMPLIANCE) so it stays adjustable while the plan is validated. This block
|
|
// is written to MATCH the live lock exactly, so the deploy diff is a no-op
|
|
// adoption — it does not change the live vault.
|
|
//
|
|
// NOTE: the scoped vault access policy is (re)introduced below (INFRA-94)
|
|
// with the fix for the two lockout-class bugs that got it split out of
|
|
// INFRA-89: the deny statement now exempts THREE principals via
|
|
// StringNotLike on aws:PrincipalArn — the SSO AdministratorAccess role (break
|
|
// glass), the backup service role, AND the CDK CFN execution role. The
|
|
// CFN-exec-role exemption is MANDATORY: without it CloudFormation cannot
|
|
// re-assert the vault lock config / manage the vault and the deploy strands
|
|
// the policy (this happened 2026-06-08). NotPrincipal is deliberately NOT
|
|
// used (it rejects wildcard ARNs). Governance lock codify + backup
|
|
// selections land here.
|
|
const primaryVault = new backup.BackupVault(this, "PrimaryVault", {
|
|
backupVaultName: "seahaven-primary",
|
|
encryptionKey: vaultKey,
|
|
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
|
lockConfiguration: {
|
|
minRetention: cdk.Duration.days(1),
|
|
maxRetention: cdk.Duration.days(2555),
|
|
// No `changeableFor` → GOVERNANCE mode, admin-removable (matches live).
|
|
},
|
|
});
|
|
|
|
// Vault access policy (INFRA-94): Deny the destructive recovery-point and
|
|
// vault-lifecycle actions to EVERY principal EXCEPT the three operational
|
|
// identities below. This is defense-in-depth on top of the GOVERNANCE lock —
|
|
// it blocks manual deletion / lifecycle tampering even from accounts that
|
|
// hold the equivalent IAM permissions.
|
|
//
|
|
// Deny (not Allow): a resource-policy Deny overrides any identity-based
|
|
// Allow, which is exactly what we want for a guardrail. The StringNotLike
|
|
// condition means "this Deny applies UNLESS the caller's ARN matches one of
|
|
// the exempted patterns" — i.e. the three exempt principals are NOT denied.
|
|
//
|
|
// Exemptions (all THREE required):
|
|
// 1. SSO AdministratorAccess role — break-glass human admin path. Matched by
|
|
// wildcard because the AWSReservedSSO role name carries a permission-set
|
|
// hash suffix.
|
|
// 2. seahaven-backup-service-role — AWS Backup uses it for lifecycle
|
|
// expiry of recovery points; denying it would break the plan's
|
|
// deleteAfter cleanup.
|
|
// 3. cdk-hnb659fds-cfn-exec-role — the CloudFormation execution role. CFN
|
|
// re-asserts the vault lock config and manages the vault on every deploy;
|
|
// omitting it strands the policy and fails the deploy (INFRA-94,
|
|
// 2026-06-08). Wildcard-suffixed to cover the region-qualified name.
|
|
//
|
|
// NotPrincipal is intentionally avoided — it does not accept wildcard ARNs.
|
|
primaryVault.addToAccessPolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "DenyDestructiveActionsExceptOperationalRoles",
|
|
effect: iam.Effect.DENY,
|
|
principals: [new iam.AnyPrincipal()],
|
|
actions: [
|
|
"backup:DeleteRecoveryPoint",
|
|
"backup:UpdateRecoveryPointLifecycle",
|
|
"backup:DeleteBackupVault",
|
|
"backup:DeleteBackupVaultAccessPolicy",
|
|
"backup:DeleteBackupVaultLockConfiguration",
|
|
"backup:PutBackupVaultLockConfiguration",
|
|
],
|
|
resources: ["*"],
|
|
conditions: {
|
|
StringNotLike: {
|
|
"aws:PrincipalArn": [
|
|
// 1. SSO AdministratorAccess (break-glass human admin)
|
|
`arn:aws:iam::${this.account}:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_AdministratorAccess*`,
|
|
// 2. AWS Backup service role (lifecycle expiry of recovery points)
|
|
`arn:aws:iam::${this.account}:role/seahaven-backup-service-role`,
|
|
// 3. CDK CloudFormation execution role (MANDATORY — manages vault)
|
|
`arn:aws:iam::${this.account}:role/cdk-hnb659fds-cfn-exec-role-*`,
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
|
|
// Cross-region copy destination, referenced by literal ARN (the offsite
|
|
// stack is in another region; a literal ARN avoids crossRegionReferences /
|
|
// SSM exports). Stack ordering is enforced via addStackDependency in bin/app.ts.
|
|
const offsiteVault = backup.BackupVault.fromBackupVaultArn(
|
|
this,
|
|
"OffsiteVaultRef",
|
|
`arn:aws:backup:us-west-2:${this.account}:backup-vault:seahaven-offsite`
|
|
);
|
|
|
|
// AWS Backup service role. Explicit (not auto-generated) because S3 backup
|
|
// needs the S3-specific managed policy on top of the standard backup one.
|
|
// Least-privilege: BACKUP + S3-backup only. Restore policies
|
|
// (AWSBackupServiceRolePolicyForRestores / ...ForS3Restore) and
|
|
// BackupSelection allowRestores are intentionally NOT granted — restores
|
|
// are a deliberate, audited action and will get their own scoped role/path
|
|
// once a restore-test process exists (cross-review F-1/F-2). A known role
|
|
// name lets the deploy role's iam:PassRole be scoped to this exact ARN.
|
|
// NOTE: creating this role is an IAM change → Sea Haven cross-review gate.
|
|
const backupRole = new iam.Role(this, "BackupRole", {
|
|
roleName: "seahaven-backup-service-role",
|
|
assumedBy: new iam.ServicePrincipal("backup.amazonaws.com"),
|
|
description: "AWS Backup service role (backup-only) for seahaven-primary",
|
|
managedPolicies: [
|
|
iam.ManagedPolicy.fromAwsManagedPolicyName(
|
|
"service-role/AWSBackupServiceRolePolicyForBackup"
|
|
),
|
|
iam.ManagedPolicy.fromAwsManagedPolicyName(
|
|
"AWSBackupServiceRolePolicyForS3Backup"
|
|
),
|
|
],
|
|
});
|
|
|
|
// Daily backup → primary vault (35d), cross-region copy → offsite (90d).
|
|
const plan = new backup.BackupPlan(this, "Plan", {
|
|
backupPlanName: "seahaven-critical-daily",
|
|
backupVault: primaryVault,
|
|
backupPlanRules: [
|
|
new backup.BackupPlanRule({
|
|
ruleName: "daily-crr-offsite",
|
|
backupVault: primaryVault,
|
|
// 06:00 UTC — offset from the file-share DLM run.
|
|
scheduleExpression: events.Schedule.cron({ hour: "6", minute: "0" }),
|
|
startWindow: cdk.Duration.hours(1),
|
|
completionWindow: cdk.Duration.hours(6),
|
|
deleteAfter: cdk.Duration.days(35),
|
|
copyActions: [
|
|
{
|
|
destinationBackupVault: offsiteVault,
|
|
deleteAfter: cdk.Duration.days(90),
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Phase-1 critical set, by explicit ARN.
|
|
// Removed 2026-10-01 (resources gone; jobs were failing or about to):
|
|
// RDS proposal-system-db, DynamoDB PaymentsDashboard, S3
|
|
// seahaven-payments-csv-328440206208. database-1 was removed 2026-06-03
|
|
// (audit H-19); its final recovery point stays in the offsite vault.
|
|
plan.addSelection("CriticalResources", {
|
|
backupSelectionName: "critical-data",
|
|
role: backupRole,
|
|
// allowRestores omitted (defaults false) — backup-only, see role comment.
|
|
resources: [
|
|
backup.BackupResource.fromArn(
|
|
`arn:aws:dynamodb:us-east-1:${this.account}:table/purchase-orders`
|
|
),
|
|
backup.BackupResource.fromArn("arn:aws:s3:::accounting.seahaven.com"),
|
|
backup.BackupResource.fromArn(
|
|
"arn:aws:s3:::google-workspace-seahavenind.com"
|
|
),
|
|
],
|
|
});
|
|
|
|
// Phase-2 selection, same plan and role as phase-1. Explicit ARN, not tags:
|
|
// the tables are owned by other stacks.
|
|
//
|
|
// Removed 2026-10-01 (resources gone; jobs failing nightly): DynamoDB
|
|
// afterhours-shifts, front-sla-alerts, meal-order-manager-orders; every
|
|
// EBS volume (no instances remain); S3 seahaven-kb-docs-328440206208,
|
|
// seahaven-payroll-emails-328440206208,
|
|
// proposal-system-uploads-328440206208,
|
|
// proposal-system-generated-328440206208.
|
|
// amazon-po stays. The bucket exists and versioning is enabled, but the
|
|
// backup job fails with a generic message.
|
|
// LedgerFlow tables were excluded 2026-06-03 when that stack was deleted.
|
|
plan.addSelection("Phase2Resources", {
|
|
backupSelectionName: "phase2-offsite-everything",
|
|
role: backupRole,
|
|
resources: [
|
|
...[
|
|
"SiteAssignments",
|
|
"VendorReplies",
|
|
"WorkOrderComments",
|
|
"WorkOrders",
|
|
"last-war-bot",
|
|
"pending-site-review",
|
|
"verified-sites",
|
|
].map((t) =>
|
|
backup.BackupResource.fromArn(
|
|
`arn:aws:dynamodb:us-east-1:${this.account}:table/${t}`
|
|
)
|
|
),
|
|
...["amazon-po", "extracted-amazon-po"].map((b) =>
|
|
backup.BackupResource.fromArn(`arn:aws:s3:::${b}`)
|
|
),
|
|
],
|
|
});
|
|
|
|
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, "PrimaryVaultName", { value: "seahaven-primary" });
|
|
new cdk.CfnOutput(this, "PrimaryVaultKmsKeyArn", { value: vaultKey.keyArn });
|
|
new cdk.CfnOutput(this, "BackupPlanId", { value: plan.backupPlanId });
|
|
new cdk.CfnOutput(this, "BackupRoleArn", { value: backupRole.roleArn });
|
|
}
|
|
}
|