seahaven-account-baseline/lib/backup-stack.ts
Adam Moussa 64ef25dc5b
Some checks failed
Deploy / deploy (push) Has been cancelled
Add AWS Backup with offsite vault (audit C-7) (#3)
* Add AWS Backup with offsite vault (audit C-7)

The account had zero AWS Backup vaults/plans, so 22 of 23 data stores
had no immutable, cross-region recovery path (audit finding C-7). One
ransomware event or rogue delete would erase primary plus same-region
snapshots/PITR.

Phase 1 ("critical data first") protects the seven highest-risk stores
with no offsite leg today (2 RDS, 2 DynamoDB, 3 S3) via a daily plan in
a new us-east-1 vault, copied cross-region into a governance-locked
us-west-2 vault. Governance (not compliance) mode first so the plan can
be validated before committing to irreversible immutability.

The backup service role is backup-only (no restore policies) to stay
least-privilege; restores get a separate audited path later. Resources
are selected by explicit ARN to avoid drifting the stacks that own them.

Deploys via the shared cdk deploy --all alongside the C-1 CloudTrail
stack. See the README pre-deploy gates (S3 versioning, database-1
unencrypted copy smoke-test, DynamoDB PITR) before the first run.

* Grant AWS Backup service use of vault CMKs

The L2 BackupVault does not grant the backup service principal use of a
customer-managed key; the synthesized key policy only delegated to
account IAM. Cross-region copy of encrypted RDS/EBS recovery points uses
KMS grants on the destination key, so without an explicit grant those
copy jobs fail — and silently, since the account has no CloudTrail yet.

Add backup.amazonaws.com crypto + CreateGrant statements to both vault
keys, scoped by aws:SourceAccount (cross-review BLOCK 2; mirrors the
discipline used on the C-1 CloudTrail key). Same class of bug the C-1
cross-review caught on the CloudTrail CMK.
2026-05-29 18:06:17 -04:00

188 lines
8.2 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.
*
* PRE-DEPLOY GATES (validate before the first scheduled run):
* - S3 backup requires bucket versioning. `accounting.seahaven.com` already
* has it (audit C-9); `seahaven-payments-csv-328440206208` and
* `google-workspace-seahavenind.com` must have versioning enabled first or
* their jobs fail silently (folds in audit H-21).
* - `database-1` is unencrypted (audit H-19). Cross-region copy of an
* unencrypted RDS recovery point may fail or land unencrypted. Smoke-test
* an on-demand backup of `database-1` FIRST and confirm the copy job to
* us-west-2 succeeds; if not, encrypt database-1 (H-19) or drop it from the
* copy until then.
* - DynamoDB PITR (H-7) is independent of this plan; enable it on the two
* tables for between-window point-in-time recovery.
*/
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 is intentionally NOT locked — it is the working copy; the
// offsite vault carries the immutability guarantee.
const primaryVault = new backup.BackupVault(this, "PrimaryVault", {
backupVaultName: "seahaven-primary",
encryptionKey: vaultKey,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// 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 addDependency 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 (identifiers verified against the
// live account 2026-05-29).
plan.addSelection("CriticalResources", {
backupSelectionName: "critical-data",
role: backupRole,
// allowRestores omitted (defaults false) — backup-only, see role comment.
resources: [
// RDS
backup.BackupResource.fromArn(
`arn:aws:rds:us-east-1:${this.account}:db:database-1`
),
backup.BackupResource.fromArn(
`arn:aws:rds:us-east-1:${this.account}:db:proposal-system-db`
),
// DynamoDB (financial)
backup.BackupResource.fromArn(
`arn:aws:dynamodb:us-east-1:${this.account}:table/PaymentsDashboard`
),
backup.BackupResource.fromArn(
`arn:aws:dynamodb:us-east-1:${this.account}:table/purchase-orders`
),
// S3 (single-copy critical buckets) — versioning required (see header)
backup.BackupResource.fromArn("arn:aws:s3:::accounting.seahaven.com"),
backup.BackupResource.fromArn(
"arn:aws:s3:::seahaven-payments-csv-328440206208"
),
backup.BackupResource.fromArn(
"arn:aws:s3:::google-workspace-seahavenind.com"
),
],
});
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 });
}
}