diff --git a/lib/backup-stack.ts b/lib/backup-stack.ts index f53d089..a18f28b 100644 --- a/lib/backup-stack.ts +++ b/lib/backup-stack.ts @@ -87,13 +87,16 @@ export class BackupStack extends cdk.Stack { // 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 that previously lived here was DROPPED - // from this deploy (INFRA-94). Attaching a BackupVaultAccessPolicy that - // denies DeleteBackupVault / PutBackupVaultLockConfiguration to all but a - // break-glass principal made the vault unmanageable by CloudFormation/CDK. - // The deny-manual-deletion control is tracked separately in INFRA-94 and - // will be reintroduced via a safe mechanism. Governance lock codify + - // backup selections land here. + // 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, @@ -105,6 +108,59 @@ export class BackupStack extends cdk.Stack { }, }); + // 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 addDependency in bin/app.ts.