diff --git a/modules/aws-backup-source/backup_plan.tf b/modules/aws-backup-source/backup_plan.tf index e187fe8..1d4a71d 100644 --- a/modules/aws-backup-source/backup_plan.tf +++ b/modules/aws-backup-source/backup_plan.tf @@ -91,13 +91,44 @@ resource "aws_backup_plan" "ebsvol" { } } -# this backup plan shouldn't include a continous backup rule as it isn't supported for Aurora +resource "aws_backup_plan" "rds" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + name = "${var.name_prefix}-rds-plan" + + dynamic "rule" { + for_each = var.backup_plan_config_rds.rules + + content { + recovery_point_tags = { + backup_rule_name = rule.value.name + } + rule_name = rule.value.name + target_vault_name = aws_backup_vault.main.name + schedule = rule.value.schedule + lifecycle { + delete_after = rule.value.lifecycle.delete_after != null ? rule.value.lifecycle.delete_after : null + cold_storage_after = rule.value.lifecycle.cold_storage_after != null ? rule.value.lifecycle.cold_storage_after : null + } + dynamic "copy_action" { + for_each = rule.value.copy_action != null ? rule.value.copy_action : {} + content { + lifecycle { + delete_after = copy_action.value + } + destination_vault_arn = aws_backup_vault.intermediary-vault[0].arn + } + } + } + } +} + resource "aws_backup_plan" "aurora" { count = var.backup_plan_config_aurora.enable ? 1 : 0 name = "${local.resource_name_prefix}-aurora-plan" dynamic "rule" { for_each = var.backup_plan_config_aurora.rules + content { recovery_point_tags = { backup_rule_name = rule.value.name @@ -165,6 +196,28 @@ resource "aws_backup_selection" "dynamodb" { } } +resource "aws_backup_selection" "rds" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + iam_role_arn = aws_iam_role.backup.arn + name = "${var.name_prefix}-rds-selection" + plan_id = aws_backup_plan.rds[0].id + + selection_tag { + key = var.backup_plan_config_rds.selection_tag + type = "STRINGEQUALS" + value = (var.backup_plan_config_rds.selection_tag_value == null) ? "True" : var.backup_plan_config_rds.selection_tag_value + } + condition { + dynamic "string_equals" { + for_each = local.selection_tags_rds_null_checked + content { + key = (try(string_equals.value.key, null) == null) ? null : "aws:ResourceTag/${string_equals.value.key}" + value = try(string_equals.value.value, null) + } + } + } +} + resource "aws_backup_selection" "ebsvol" { count = var.backup_plan_config_ebsvol.enable ? 1 : 0 iam_role_arn = aws_iam_role.backup.arn diff --git a/modules/aws-backup-source/backup_vault.tf b/modules/aws-backup-source/backup_vault.tf index 4f00c05..a7312f5 100644 --- a/modules/aws-backup-source/backup_vault.tf +++ b/modules/aws-backup-source/backup_vault.tf @@ -2,3 +2,9 @@ resource "aws_backup_vault" "main" { name = "${var.name_prefix}-vault" kms_key_arn = aws_kms_key.aws_backup_key.arn } + +resource "aws_backup_vault" "intermediary-vault" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + name = "${var.name_prefix}-intermediary-vault" + kms_key_arn = aws_kms_key.aws_backup_key.arn +} diff --git a/modules/aws-backup-source/eventbridge.tf b/modules/aws-backup-source/eventbridge.tf new file mode 100644 index 0000000..c5fd57f --- /dev/null +++ b/modules/aws-backup-source/eventbridge.tf @@ -0,0 +1,36 @@ +module "eventbridge" { + source = "terraform-aws-modules/eventbridge/aws" + version = "3.14.3" + + create_bus = false + create_role = false + + rules = { + "Cross-Account-Copy-Job" = { + description = "Identify when a new recovery point is created in the intermediary vault" + event_pattern = jsonencode( + { + "source" : ["aws.backup"], + "account" : ["${data.aws_caller_identity.current.account_id}"], + "region" : ["eu-west-2"], + "detail" : { + "eventName" : ["RecoveryPointCreated"], + "serviceEventDetails" : { + "backupVaultName" : [{ "wildcard" : "*-intermediary-vault" }] + } + } + } + ) + enabled = true + } + } + + targets = { + "Cross-Account-Copy-Job" = [ + { + name = "start_cross_account_copy_job" + arn = "arn:aws:lambda:eu-west-2:${data.aws_caller_identity.current.account_id}:function:start_cross_account_copy_job" + } + ] + } +} \ No newline at end of file diff --git a/modules/aws-backup-source/kms.tf b/modules/aws-backup-source/kms.tf index 447d274..08ebe4a 100644 --- a/modules/aws-backup-source/kms.tf +++ b/modules/aws-backup-source/kms.tf @@ -2,7 +2,6 @@ resource "aws_kms_key" "aws_backup_key" { description = "AWS Backup KMS Key" deletion_window_in_days = 30 enable_key_rotation = true - policy = data.aws_iam_policy_document.backup_key_policy.json } resource "aws_kms_alias" "backup_key" { diff --git a/modules/aws-backup-source/lambda_copy_job.tf b/modules/aws-backup-source/lambda_copy_job.tf new file mode 100644 index 0000000..de35056 --- /dev/null +++ b/modules/aws-backup-source/lambda_copy_job.tf @@ -0,0 +1,86 @@ +data "aws_iam_policy_document" "lambda_assume_role" { + statement { + effect = "Allow" + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + actions = ["sts:AssumeRole"] + } +} + +resource "aws_iam_role" "iam_for_lambda_copy_job" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + name = "iam_for_cross_account_copy_job_lambda" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json +} + +data "aws_iam_policy_document" "lambda_copy_job_permissions" { + version = "2012-10-17" + statement { + effect = "Allow" + actions = ["kms:Decrypt", "kms:GenerateDataKey"] + resources = [aws_kms_key.aws_backup_key.arn] + } + statement { + effect = "Allow" + actions = [ + "backup:StartCopyJob", + "backup:DescribeRecoveryPoint", + "backup:ListRecoveryPointsByBackupVault" + ] + resources = ["*"] + } + statement { + effect = "Allow" + actions = ["iam:PassRole"] + resources = [aws_iam_role.backup.arn] + } +} + +resource "aws_iam_role_policy_attachment" "lambda_role_policy_attachment" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + role = aws_iam_role.iam_for_lambda_copy_job[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "cross_account_iam_permissions" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + name = "cross_account_iam_permissions_policy" + role = aws_iam_role.iam_for_lambda_copy_job[0].id + policy = data.aws_iam_policy_document.lambda_copy_job_permissions.json +} + +data "archive_file" "start_cross_account_copy_job_lambda_zip" { + type = "zip" + source_dir = "${path.module}/resources" + output_path = "${path.module}/.terraform/archive_files/start_cross_account_copy_job_lambda.zip" +} + +resource "aws_lambda_function" "start_cross_account_copy_job_lambda" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + filename = data.archive_file.start_cross_account_copy_job_lambda_zip.output_path + source_code_hash = data.archive_file.start_cross_account_copy_job_lambda_zip.output_base64sha256 + function_name = "start_cross_account_copy_job" + role = aws_iam_role.iam_for_lambda_copy_job[0].arn + handler = "start_cross_account_copy_job.lambda_handler" + runtime = "python3.12" + environment { + variables = { + aws_account_id = data.aws_caller_identity.current.account_id, + backup_account_id = var.backup_copy_vault_account_id, + backup_copy_vault_arn = var.backup_copy_vault_arn, + backup_role_arn = aws_iam_role.backup.arn, + destination_vault_retention_period = var.destination_vault_retention_period + } + } +} + +resource "aws_lambda_permission" "allow_eventbridge" { + count = var.backup_plan_config_rds.enable ? 1 : 0 + statement_id = "AllowExecutionFromEventbridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.start_cross_account_copy_job_lambda[0].function_name + principal = "events.amazonaws.com" + source_arn = "arn:aws:events:eu-west-2:${data.aws_caller_identity.current.account_id}:rule/Cross-Account-Copy-Job-rule" +} \ No newline at end of file diff --git a/modules/aws-backup-source/locals.tf b/modules/aws-backup-source/locals.tf index 965d62c..90da44a 100644 --- a/modules/aws-backup-source/locals.tf +++ b/modules/aws-backup-source/locals.tf @@ -2,8 +2,10 @@ locals { resource_name_prefix = var.name_prefix != null ? var.name_prefix : "${data.aws_region.current.name}-${data.aws_caller_identity.current.account_id}-backup" selection_tag_value_null_checked = (var.backup_plan_config.selection_tag_value == null) ? "True" : var.backup_plan_config.selection_tag_value selection_tag_value_dynamodb_null_checked = (var.backup_plan_config_dynamodb.selection_tag_value == null) ? "True" : var.backup_plan_config_dynamodb.selection_tag_value + selection_tag_value_rds_null_checked = (var.backup_plan_config_rds.selection_tag_value == null) ? "True" : var.backup_plan_config_rds.selection_tag_value selection_tags_null_checked = (var.backup_plan_config.selection_tags == null) ? [{ "key" : var.backup_plan_config.selection_tag, "value" : local.selection_tag_value_null_checked }] : var.backup_plan_config.selection_tags selection_tags_dynamodb_null_checked = (var.backup_plan_config_dynamodb.selection_tags == null) ? [{ "key" : var.backup_plan_config_dynamodb.selection_tag, "value" : local.selection_tag_value_dynamodb_null_checked }] : var.backup_plan_config_dynamodb.selection_tags + selection_tags_rds_null_checked = (var.backup_plan_config_rds.selection_tags == null) ? [{ "key" : var.backup_plan_config_rds.selection_tag, "value" : local.selection_tag_value_rds_null_checked }] : var.backup_plan_config_rds.selection_tags selection_tag_value_ebsvol_null_checked = (var.backup_plan_config_ebsvol.selection_tag_value == null) ? "True" : var.backup_plan_config_ebsvol.selection_tag_value selection_tags_ebsvol_null_checked = (var.backup_plan_config_ebsvol.selection_tags == null) ? [{ "key" : var.backup_plan_config_ebsvol.selection_tag, "value" : local.selection_tag_value_ebsvol_null_checked }] : var.backup_plan_config_ebsvol.selection_tags framework_arn_list = flatten(concat( diff --git a/modules/aws-backup-source/resources/start_cross_account_copy_job.py b/modules/aws-backup-source/resources/start_cross_account_copy_job.py new file mode 100644 index 0000000..bfa833c --- /dev/null +++ b/modules/aws-backup-source/resources/start_cross_account_copy_job.py @@ -0,0 +1,43 @@ +import json +import boto3 +import logging +import os + +# Initialize AWS Backup client and logger +backup_client = boto3.client('backup') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Create a Secrets Manager client +region_name = os.environ.get('AWS_REGION') + +aws_account_id = os.environ.get('aws_account_id') +backup_account_id = os.environ.get('backup_account_id') +backup_copy_vault_arn = os.environ.get('backup_copy_vault_arn') +backup_role_arn = os.environ.get('backup_role_arn') +destination_vault_retention_period = int(os.environ.get('destination_vault_retention_period')) + +def lambda_handler(event, context): + # Log the incoming event for debugging purposes + logger.info(f"Received Event: {json.dumps(event)}") + + # Extract the recovery point ARN from the event + recovery_point_arn = event['detail']['serviceEventDetails']['recoveryPointArn'] + source_vault_name = event['detail']['serviceEventDetails']['backupVaultName'] + logger.info(f"Detected new recovery point in vault {source_vault_name}: {recovery_point_arn}") + + # Start the copy job to the destination vault in another AWS account + backup_client.start_copy_job( + RecoveryPointArn=recovery_point_arn, + SourceBackupVaultName=source_vault_name, + DestinationBackupVaultArn=backup_copy_vault_arn, + IamRoleArn=backup_role_arn, + Lifecycle={ + 'DeleteAfterDays': destination_vault_retention_period + } + ) + + return { + 'statusCode': 200, + 'body': json.dumps('Copy job started successfully.') + } \ No newline at end of file diff --git a/modules/aws-backup-source/variables.tf b/modules/aws-backup-source/variables.tf index 1551b73..62c16b3 100644 --- a/modules/aws-backup-source/variables.tf +++ b/modules/aws-backup-source/variables.tf @@ -30,7 +30,7 @@ variable "terraform_role_arn" { default = "" validation { - condition = var.terraform_role_arn == null + condition = var.terraform_role_arn == "" error_message = "Warning: 'terraform_role_arn' is deprecated and should not be used." } } @@ -90,6 +90,12 @@ variable "backup_copy_vault_account_id" { default = "" } +variable "destination_vault_retention_period" { + description = "Retention period for recovery points made with the copy job lambda" + type = number + default = 365 +} + variable "backup_plan_config" { description = "Configuration for backup plans" type = object({ @@ -233,6 +239,72 @@ variable "backup_plan_config_dynamodb" { } } +variable "backup_plan_config_rds" { + description = "Configuration for backup plans with rds" + type = object({ + enable = bool + selection_tag = string + selection_tag_value = optional(string) + selection_tags = optional(list(object({ + key = optional(string) + value = optional(string) + }))) + compliance_resource_types = list(string) + rules = optional(list(object({ + name = string + schedule = string + enable_continuous_backup = optional(bool) + lifecycle = object({ + delete_after = number + cold_storage_after = optional(number) + }) + copy_action = optional(object({ + delete_after = optional(number) + })) + }))) + }) + default = { + enable = true + selection_tag = "BackupRDS" + selection_tag_value = "True" + selection_tags = [] + compliance_resource_types = ["RDS"] + rules = [ + { + name = "rds_daily_kept_5_weeks" + schedule = "cron(0 0 * * ? *)" + lifecycle = { + delete_after = 35 + } + copy_action = { + delete_after = 365 + } + }, + { + name = "rds_weekly_kept_3_months" + schedule = "cron(0 1 ? * SUN *)" + lifecycle = { + delete_after = 90 + } + copy_action = { + delete_after = 365 + } + }, + { + name = "rds_monthly_kept_7_years" + schedule = "cron(0 2 1 * ? *)" + lifecycle = { + cold_storage_after = 30 + delete_after = 2555 + } + copy_action = { + delete_after = 365 + } + } + ] + } +} + variable "name_prefix" { description = "Name prefix for vault resources" type = string @@ -304,6 +376,7 @@ variable "backup_plan_config_ebsvol" { } ] } + } variable "backup_plan_config_aurora" {