seahaven-account-baseline/lib/backup-offsite-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

87 lines
3.6 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 backup from "aws-cdk-lib/aws-backup";
import { Construct } from "constructs";
/**
* Offsite AWS Backup vault for Sea Haven (account 328440206208), in us-west-2.
*
* This is the Copy3 / offsite leg of the 3-2-1 strategy and the only immutable
* recovery path in the account. The primary plan (see backup-stack.ts, us-east-1)
* copies recovery points here cross-region. Closes audit finding C-7 together
* with backup-stack.
*
* Vault Lock is GOVERNANCE mode for now (minRetention only, no `changeableFor`):
* recovery points cannot be silently deleted, but a principal with explicit
* `backup:DeleteRecoveryPoint` / `backup:DeleteBackupVaultLockConfiguration`
* permission can still intervene while we validate the plan. Graduate to
* COMPLIANCE mode later by adding `changeableFor` (irreversible after the
* cooling-off window) — a one-line change + redeploy.
*/
export class BackupOffsiteStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// CMK encrypting offsite recovery points (rotation on; RETAIN so a stack
// teardown never strands/destroys the only immutable copy).
const vaultKey = new kms.Key(this, "OffsiteVaultKey", {
alias: "backup-offsite-vault",
description: "Encrypts offsite AWS Backup recovery points (us-west-2)",
enableKeyRotation: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// The L2 BackupVault does NOT grant the backup service use of a customer
// CMK — without this, cross-region COPY jobs of encrypted RDS/EBS recovery
// points fail (and silently, with no CloudTrail). Grant backup.amazonaws.com
// the minimum KMS actions on this destination key, incl. CreateGrant.
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 },
},
})
);
new backup.BackupVault(this, "OffsiteVault", {
backupVaultName: "seahaven-offsite",
encryptionKey: vaultKey,
removalPolicy: cdk.RemovalPolicy.RETAIN,
// Governance-mode Vault Lock: no `changeableFor`, so it stays adjustable.
lockConfiguration: {
minRetention: cdk.Duration.days(30),
},
});
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, "OffsiteVaultName", { value: "seahaven-offsite" });
new cdk.CfnOutput(this, "OffsiteVaultKmsKeyArn", { value: vaultKey.keyArn });
}
}