BackupFactory.java
package com.cloudforgeci.api.storage;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforge.core.annotation.SystemContext;
import com.cloudforge.core.enums.SecurityProfile;
import io.github.cdklabs.cdknag.NagPackSuppression;
import io.github.cdklabs.cdknag.NagSuppressions;
import software.amazon.awscdk.Duration;
import software.amazon.awscdk.Fn;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.Stack;
import software.amazon.awscdk.services.backup.BackupPlan;
import software.amazon.awscdk.services.backup.BackupPlanCopyActionProps;
import software.amazon.awscdk.services.backup.BackupPlanRule;
import software.amazon.awscdk.services.backup.BackupResource;
import software.amazon.awscdk.services.backup.BackupSelectionOptions;
import software.amazon.awscdk.services.backup.BackupVault;
import software.amazon.awscdk.services.events.CronOptions;
import software.amazon.awscdk.services.events.Schedule;
import software.amazon.awscdk.services.iam.Role;
import software.amazon.awscdk.services.iam.ServicePrincipal;
import software.constructs.Construct;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
/**
* Factory for creating AWS Backup resources for EFS and RDS.
*
* <p>This factory creates backup infrastructure based on security profile settings:</p>
* <ul>
* <li><strong>Backup Vault:</strong> Encrypted vault for storing backups</li>
* <li><strong>Backup Plan:</strong> Daily backups with configurable retention</li>
* <li><strong>Backup Selection:</strong> Targets EFS and RDS resources in the stack</li>
* <li><strong>Cross-Region Copy:</strong> Optional geographic redundancy (PRODUCTION)</li>
* </ul>
*
* <p><strong>Compliance Coverage:</strong></p>
* <ul>
* <li>SOC2-A1.3: Automated backups for disaster recovery</li>
* <li>PCI-DSS: efs-resources-protected-by-backup-plan</li>
* <li>HIPAA: Data backup and recovery requirements</li>
* </ul>
*
* <p><strong>Security Profile Behavior:</strong></p>
* <ul>
* <li><strong>DEV:</strong> Backups disabled by default</li>
* <li><strong>STAGING:</strong> Daily backups, 14-day retention</li>
* <li><strong>PRODUCTION:</strong> Daily backups, 90-day retention, cross-region copy</li>
* </ul>
*
* @see com.cloudforgeci.api.interfaces.SecurityProfileConfiguration#isAutomatedBackupEnabled()
* @see com.cloudforgeci.api.interfaces.SecurityProfileConfiguration#getBackupRetentionDays()
*/
public class BackupFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(BackupFactory.class.getName());
@SystemContext("security")
private SecurityProfile security;
@SystemContext("stackName")
private String stackName;
public BackupFactory(Construct scope, String id) {
super(scope, id);
}
@Override
public void create() {
// Check if automated backups are enabled for this security profile
if (!config.isAutomatedBackupEnabled()) {
LOG.info("Automated backups disabled for security profile: " + security);
return;
}
LOG.info("Creating AWS Backup infrastructure for security profile: " + security);
// Create backup vault
BackupVault vault = createBackupVault();
// Create backup plan with rules
BackupPlan plan = createBackupPlan(vault);
// Add resources to backup
addBackupResources(plan);
LOG.info("AWS Backup infrastructure created successfully");
LOG.info(" Retention: " + config.getBackupRetentionDays() + " days");
LOG.info(" Cross-region copy: " + config.isCrossRegionBackupEnabled());
}
/**
* Creates an encrypted backup vault.
* Vault name must be 2-50 characters, alphanumeric with hyphens and underscores only.
*/
private BackupVault createBackupVault() {
// Vault lock is gated by the compliance matrix (see isBackupVaultLockEnabled()). AWS
// Backup never deletes a locked vault, so a torn-down stack leaves it behind and a
// redeploy with the same stackName would collide on the name. Locked vaults therefore get
// a suffix derived from the stack ID, which is new for each stack but stable across
// updates. Unlocked vaults can be deleted, so they keep the plain stackName-derived name.
boolean lockEnabled = config.isBackupVaultLockEnabled();
String vaultName = lockEnabled
? sanitizeResourceName(stackName, "-vault-" + Fn.select(0, Fn.split("-", Stack.of(this).getStackId())))
: sanitizeResourceName(stackName, "-vault");
// Determine removal policy based on security profile configuration
RemovalPolicy removalPolicy = config.isBackupVaultRetentionEnabled()
? RemovalPolicy.RETAIN
: RemovalPolicy.DESTROY;
BackupVault.Builder builder = BackupVault.Builder.create(this, "BackupVault")
.backupVaultName(vaultName)
// AWS Backup encrypts with AWS-managed key by default
// For customer-managed KMS key, add .encryptionKey(key)
.removalPolicy(removalPolicy);
// Add vault lock based on security profile configuration
if (lockEnabled) {
int retentionDays = config.getBackupRetentionDays();
builder.lockConfiguration(software.amazon.awscdk.services.backup.LockConfiguration.builder()
.minRetention(Duration.days(retentionDays))
// AWS Backup requires minimum 72-hour cooling-off period before lock becomes immutable
.changeableFor(Duration.days(3))
.build());
LOG.info("Vault lock enabled for compliance: min retention = " + retentionDays + " days, changeable for 3 days");
}
BackupVault vault = builder.build();
LOG.info("Created backup vault: " + vaultName + " (RemovalPolicy: " + removalPolicy + ")");
return vault;
}
/**
* Creates a backup plan with daily backup rules.
* Plan name must follow AWS naming conventions.
*/
private BackupPlan createBackupPlan(BackupVault vault) {
String planName = sanitizeResourceName(stackName, "-plan");
int retentionDays = config.getBackupRetentionDays();
List<BackupPlanRule> rules = new ArrayList<>();
// Daily backup rule
BackupPlanRule.Builder dailyRuleBuilder = BackupPlanRule.Builder.create()
.ruleName("DailyBackup")
.backupVault(vault)
.scheduleExpression(Schedule.cron(CronOptions.builder()
.hour("3") // 3 AM UTC
.minute("0")
.build()))
.startWindow(Duration.hours(1)) // Must start within 1 hour
.completionWindow(Duration.hours(8)) // Must complete within 8 hours
.deleteAfter(Duration.days(retentionDays));
// Copy to a vault in another region. AWS Backup needs that vault to exist already, so the copy
// is only added when its ARN is configured.
if (config.isCrossRegionBackupEnabled() && security == SecurityProfile.PRODUCTION) {
String destinationVaultArn = cfc.backupCrossRegionVaultArn();
if (destinationVaultArn == null || destinationVaultArn.isBlank()) {
LOG.warning("Cross-region backup is enabled but backupCrossRegionVaultArn is not set; "
+ "backups will not be copied to another region");
} else {
dailyRuleBuilder.copyActions(List.of(BackupPlanCopyActionProps.builder()
.destinationBackupVault(BackupVault.fromBackupVaultArn(this, "CrossRegionVault", destinationVaultArn))
.deleteAfter(Duration.days(retentionDays))
.build()));
LOG.info("Cross-region backup copy enabled to: " + destinationVaultArn);
}
}
rules.add(dailyRuleBuilder.build());
// Weekly backup with longer retention for PRODUCTION
if (security == SecurityProfile.PRODUCTION) {
BackupPlanRule weeklyRule = BackupPlanRule.Builder.create()
.ruleName("WeeklyBackup")
.backupVault(vault)
.scheduleExpression(Schedule.cron(CronOptions.builder()
.weekDay("SUN")
.hour("2") // 2 AM UTC on Sundays
.minute("0")
.build()))
.startWindow(Duration.hours(1))
.completionWindow(Duration.hours(12))
.deleteAfter(Duration.days(retentionDays * 4)) // 4x retention for weekly
.build();
rules.add(weeklyRule);
LOG.info("Weekly backup rule added for PRODUCTION profile");
}
BackupPlan plan = BackupPlan.Builder.create(this, "BackupPlan")
.backupPlanName(planName)
.backupPlanRules(rules)
.build();
// Suppress IAM4 for AWS Backup service role - CDK automatically creates a role with
// AWSBackupServiceRolePolicyForBackup managed policy which is AWS-recommended
NagSuppressions.addResourceSuppressions(
plan,
List.of(
NagPackSuppression.builder()
.id("AwsSolutions-IAM4")
.reason("AWS Backup service role uses AWSBackupServiceRolePolicyForBackup " +
"managed policy which is AWS-recommended for backup operations. " +
"CDK automatically creates this role with appropriate permissions.")
.build(),
NagPackSuppression.builder()
.id("AwsSolutions-IAM5")
.reason("AWS Backup service role requires wildcard permissions for backup " +
"operations across multiple resource types. This is AWS-recommended.")
.build()
),
Boolean.TRUE
);
LOG.info("Created backup plan: " + planName);
return plan;
}
/**
* Adds EFS and RDS resources to the backup plan.
*/
private void addBackupResources(BackupPlan plan) {
List<BackupResource> resources = new ArrayList<>();
// Add EFS if present
ctx.efs.get().ifPresent(efs -> {
resources.add(BackupResource.fromEfsFileSystem(efs));
LOG.info("Added EFS to backup plan: " + efs.getFileSystemId());
});
// Add RDS if present
ctx.rdsDatabase.get().ifPresent(rds -> {
resources.add(BackupResource.fromRdsDatabaseInstance(rds));
LOG.info("Added RDS to backup plan: " + rds.getInstanceIdentifier());
});
if (resources.isEmpty()) {
LOG.warning("No resources found to backup (EFS or RDS not yet created)");
LOG.warning("BackupFactory should run AFTER EfsFactory and RdsFactory");
return;
}
// Create backup selection -- an explicit roleName (see createSelectionRole) instead of
// letting BackupSelection auto-create one, so Manager's operator-role grant
// (ManagerOperatorIamSupport#IAM_ROLE_MANAGE_RESOURCES) can match it with a stable
// wildcard suffix regardless of stack-name-prefix truncation -- same reasoning and pattern
// as RdsFactory#createMonitoringRole's -CfcRdsMonitor role.
plan.addSelection("BackupSelection", BackupSelectionOptions.builder()
.resources(resources)
.role(createSelectionRole())
.build());
LOG.info("Added " + resources.size() + " resource(s) to backup plan");
}
/** Fixed suffix of the AWS Backup selection role this factory creates, so Manager's
* operator-role grant ({@code ManagerOperatorIamSupport}) can match it with a wildcard
* prefix. See this method's own javadoc for why the role is named explicitly. */
private static final String SELECTION_ROLE_SUFFIX = "-CfcBackupSelection";
/**
* Without an explicit {@code role}, {@code BackupSelection} auto-creates one at a construct id
* nested several levels deep. CloudFormation truncates over-length physical names to fit IAM's
* 64-character limit, so for long stack/app names the distinguishing part of that construct id
* may not appear in the physical name at all, leaving no stable substring for an operator
* policy to match -- {@code iam:DeleteRole}/{@code iam:DetachRolePolicy} then fail with
* AccessDenied when a rollback needs to delete it, because its truncated physical name shares
* only a generic tail with unrelated roles in the same stack.
*
* <p>An explicit {@code roleName} avoids CloudFormation's truncation, the same fix already
* applied to {@code RdsFactory#createMonitoringRole}'s {@code -CfcRdsMonitor} role. {@code
* BackupSelection} still attaches its own default backup/restore managed policies to whatever
* role is passed in (see {@code BackupSelectionOptions#role}'s own javadoc), so this only needs
* to supply the identity, not any AWS Backup permissions itself.</p>
*/
private Role createSelectionRole() {
int maxPrefixLength = 64 - SELECTION_ROLE_SUFFIX.length();
String prefix = stackName.length() > maxPrefixLength
? stackName.substring(0, maxPrefixLength)
: stackName;
return Role.Builder.create(this, "BackupSelectionRole")
.roleName(prefix + SELECTION_ROLE_SUFFIX)
.assumedBy(new ServicePrincipal("backup.amazonaws.com"))
.build();
}
/**
* Sanitizes a resource name to comply with AWS naming conventions.
* AWS Backup vault/plan names must be 2-50 characters, alphanumeric with hyphens and underscores.
*
* @param baseName The base name (typically stack name)
* @param suffix The suffix to append (e.g., "-vault", "-plan")
* @return A sanitized name that complies with AWS naming rules
*/
private String sanitizeResourceName(String baseName, String suffix) {
// Replace invalid characters with hyphens
String sanitized = baseName.replaceAll("[^a-zA-Z0-9-_]", "-");
// Remove consecutive hyphens
sanitized = sanitized.replaceAll("-+", "-");
// Remove leading/trailing hyphens
sanitized = sanitized.replaceAll("^-|-$", "");
// Truncate to fit within 50 char limit (accounting for suffix)
int maxBaseLength = 50 - suffix.length();
if (sanitized.length() > maxBaseLength) {
sanitized = sanitized.substring(0, maxBaseLength);
}
return sanitized + suffix;
}
}