From 64ef25dc5b19f0d70355b1eb4bc283f8cf8973e4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 29 May 2026 18:06:17 -0400 Subject: [PATCH] Add AWS Backup with offsite vault (audit C-7) (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- README.md | 74 +++++++++++++- bin/app.ts | 16 +++ lib/backup-offsite-stack.ts | 87 +++++++++++++++++ lib/backup-stack.ts | 188 ++++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 lib/backup-offsite-stack.ts create mode 100644 lib/backup-stack.ts diff --git a/README.md b/README.md index 2c73852..854fbcd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # seahaven-account-baseline Account-level security and governance baseline for Sea Haven Industries -(AWS account **328440206208**, region **us-east-1**), managed as a single CDK -TypeScript app. This is where account-wide detective controls live, so they are +(AWS account **328440206208**), managed as a single CDK TypeScript app. Most +resources are in **us-east-1**; the offsite backup vault is in **us-west-2**. +This is where account-wide detective and recovery controls live, so they are versioned, reviewed, and drift-checked like any other stack. +Stacks (all deployed by `cdk deploy --all` / the CD workflow): + +| Stack | Region | Purpose | +|---|---|---| +| `seahaven-account-baseline` | us-east-1 | CloudTrail + future detective controls (C-1) | +| `seahaven-backup` | us-east-1 | Primary AWS Backup vault + plan + role (C-7) | +| `seahaven-backup-offsite` | us-west-2 | Governance-locked offsite copy vault (C-7) | + ## What it deploys ### CloudTrail (audit finding C-1) @@ -36,11 +45,52 @@ finding H-1 (metric filters + alarms now have a log group to target). - **RETAIN** on the bucket and KMS key so a stack teardown never destroys the audit trail. +### AWS Backup (audit finding C-7) + +Phase 1 ("critical data first") of fixing the account's complete lack of AWS +Backup. Protects the data stores with no offsite leg today and copies each +recovery point cross-region into a governance-locked vault. + +| Resource | Logical ID | Notes | +|---|---|---| +| Primary vault | `seahaven-primary` (us-east-1) | KMS-CMK encrypted, unlocked (working copy), RETAIN | +| Offsite vault | `seahaven-offsite` (us-west-2) | KMS-CMK encrypted, **Vault Lock GOVERNANCE** (min-retention 30d, no cooling-off window), RETAIN | +| Backup plan | `seahaven-critical-daily` | Daily 06:00 UTC, delete-after 35d, **cross-region CopyAction → offsite** (retain 90d) | +| Service role | `seahaven-backup-service-role` | **Backup-only** (Backup + S3-Backup managed policies); restore perms intentionally deferred | + +**Phase-1 scope** (selected by explicit ARN, not tags, to avoid drifting other +stacks): RDS `database-1`, RDS `proposal-system-db`, DynamoDB `PaymentsDashboard`, +DynamoDB `purchase-orders`, S3 `accounting.seahaven.com`, +`seahaven-payments-csv-328440206208`, `google-workspace-seahavenind.com`. + +**Coexists with** existing EBS DLM snapshots and DynamoDB PITR — it supplements +them with the missing offsite + immutable leg; it does not replace them. + +**Design decisions:** + +- **Governance lock first, not compliance.** Recovery points can't be silently + deleted, but a principal with explicit permission can still intervene while + we validate. Graduate to COMPLIANCE (irreversible) later by adding + `changeableFor` to the offsite vault lock + redeploy. +- **Backup-only role.** Restore policies and `allowRestores` are not granted; + restores get a separate audited path once a restore-test process exists. + +**Pre-deploy gates** (must clear before the first scheduled run): + +1. Enable S3 versioning on `seahaven-payments-csv-328440206208` and + `google-workspace-seahavenind.com` (`accounting.seahaven.com` already has it, + audit C-9), or their jobs fail silently (folds in H-21). +2. `database-1` is unencrypted (H-19): smoke-test an on-demand backup + copy of + it to us-west-2 first; if the copy fails, encrypt it or drop it from the copy. +3. Enable DynamoDB PITR (H-7) on the two tables for between-window recovery. + ## Roadmap (same stack) -Account-level detective controls with no current home, to be added here: -AWS Config (H-2), GuardDuty (H-3), Security Hub (H-4), IAM Access Analyzer -(M-5), Inspector2 (M-6). +Account-level detective controls with no current home, to be added to the +`account-baseline` stack: AWS Config (H-2), GuardDuty (H-3), Security Hub (H-4), +IAM Access Analyzer (M-5), Inspector2 (M-6). Backup phase 2: expand past the +phase-1 set via tag-based selection and graduate the offsite vault to compliance +mode. ## Deploy @@ -59,3 +109,17 @@ aws cloudtrail get-trail-status --name seahaven-org-trail # IsLogging: tr aws cloudtrail describe-trails --trail-name-list seahaven-org-trail aws cloudtrail validate-logs --trail-arn --start-time # digest integrity ``` + +AWS Backup (C-7): + +``` +aws backup list-backup-vaults # seahaven-primary +aws backup list-backup-vaults --region us-west-2 # seahaven-offsite +aws backup describe-backup-vault --backup-vault-name seahaven-offsite --region us-west-2 # Locked, MinRetentionDays +aws backup get-backup-plan --backup-plan-id # daily rule + CopyAction +# Smoke test: on-demand backup of one resource, then confirm the cross-region copy lands +aws backup start-backup-job --backup-vault-name seahaven-primary \ + --resource-arn arn:aws:rds:us-east-1:328440206208:db:database-1 \ + --iam-role-arn arn:aws:iam::328440206208:role/seahaven-backup-service-role +aws backup list-copy-jobs --region us-west-2 # copy to offsite present + COMPLETED +``` diff --git a/bin/app.ts b/bin/app.ts index 6235fba..5a7d9ff 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -2,6 +2,8 @@ import "source-map-support/register"; import * as cdk from "aws-cdk-lib"; import { AccountBaselineStack } from "../lib/account-baseline-stack"; +import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; +import { BackupStack } from "../lib/backup-stack"; const app = new cdk.App(); @@ -9,3 +11,17 @@ new AccountBaselineStack(app, "account-baseline", { stackName: "seahaven-account-baseline", env: { account: "328440206208", region: "us-east-1" }, }); + +// AWS Backup (audit C-7). Offsite vault (us-west-2) must exist before the +// primary plan that copies to it, hence the explicit dependency. +const backupOffsite = new BackupOffsiteStack(app, "backup-offsite", { + stackName: "seahaven-backup-offsite", + env: { account: "328440206208", region: "us-west-2" }, +}); + +const backupPrimary = new BackupStack(app, "backup", { + stackName: "seahaven-backup", + env: { account: "328440206208", region: "us-east-1" }, +}); + +backupPrimary.addDependency(backupOffsite); diff --git a/lib/backup-offsite-stack.ts b/lib/backup-offsite-stack.ts new file mode 100644 index 0000000..e555bcd --- /dev/null +++ b/lib/backup-offsite-stack.ts @@ -0,0 +1,87 @@ +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 }); + } +} diff --git a/lib/backup-stack.ts b/lib/backup-stack.ts new file mode 100644 index 0000000..591d91b --- /dev/null +++ b/lib/backup-stack.ts @@ -0,0 +1,188 @@ +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 }); + } +}