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;
    }
}