Skip to content
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Changelog

## [v1.2.0] (2025-07-10)

### Features

- Document s3/dynamo terraform state, simplify example (#22)
- Update copyright year (#23)
- Add open source collateral (#24)
- Add a CODEOWNERS file (#27)
- Allow arbitrary tags when selecting resources for backup (#33)
- namespace resources to allow more than one vault per source account (#34)
- EBS support (#39)
- Add completion window option to the backup plan (#41)

### Bug Fixes

- Resolved SNS notification KMS permissions for AWS Backup service role (#29)
- Give rights for AWSServiceRoleForBackupReports to write to the report bucket (#28)
- Specify terraform and provider minimum versions (#34)
- Reduce churn in report plans (#38)
- Grant KMS permissions for RDS cross-account copies (#40)
- Fixed cyclic KMS key policy by using wildcard resource scoping (#44)
- Reduce backup policy churn from temporary roles (#47)

## [v1.1.0] (2024-09-26)

### Features

- Complete module renaming (`modules/source` → `modules/aws-backup-source`, etc.) for clarity
- Added fully worked example with passed-in resources in `examples/aws-backups.tf`
- Added S3 bucket versioning requirements documentation

- ### Bug Fixes

- Removed v1.1.0 signposts from documentation
- Fixed cyclic KMS key policy issue

### Documentation

- Major README.md rewrite with clearer structure and usage instruction
- Added detailed backup/restore testing guidance and procedural notes

## v1.0.0

Initial prerelease. Not for direct consumption.
263 changes: 132 additions & 131 deletions README.md

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions examples/source/aws-backups.tf
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ module "source" {
}
]
}
# Note here that we need to explicitly disable DynamoDB backups in the source account.
# Note here that we need to explicitly disable DynamoDB and Aurora backups in the source account.
# The default config in the module enables backups for all resource types.
backup_plan_config_dynamodb = {
"compliance_resource_types": [
Expand All @@ -154,6 +154,15 @@ module "source" {
"enable": false,
"selection_tag": "NHSE-Enable-Backup"
}
backup_plan_config_aurora = {
"compliance_resource_types": [
"Aurora"
],
"rules": [
],
"enable": false,
"selection_tag": "NHSE-Enable-Backup"
}
backup_plan_config_ebsvol = {
"compliance_resource_types": [
"EBS"
Expand All @@ -163,5 +172,4 @@ module "source" {
"enable": false,
"selection_tag": "NHSE-Enable-Backup"
}

}
1 change: 1 addition & 0 deletions modules/aws-backup-source/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ No modules.
| <a name="input_backup_copy_vault_arn"></a> [backup\_copy\_vault\_arn](#input\_backup\_copy\_vault\_arn) | The ARN of the destination backup vault for cross-account backup copies. | `string` | `""` | no |
| <a name="input_backup_plan_config"></a> [backup\_plan\_config](#input\_backup\_plan\_config) | Configuration for backup plans | <pre>object({<br/> selection_tag = string<br/> selection_tag_value = optional(string)<br/> selection_tags = optional(list(object({<br/> key = optional(string)<br/> value = optional(string)<br/> })))<br/> compliance_resource_types = list(string)<br/> rules = list(object({<br/> name = string<br/> schedule = string<br/> enable_continuous_backup = optional(bool)<br/> lifecycle = object({<br/> delete_after = optional(number)<br/> cold_storage_after = optional(number)<br/> })<br/> copy_action = optional(object({<br/> delete_after = optional(number)<br/> }))<br/> }))<br/> })</pre> | <pre>{<br/> "compliance_resource_types": [<br/> "S3"<br/> ],<br/> "rules": [<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 35<br/> },<br/> "name": "daily_kept_5_weeks",<br/> "schedule": "cron(0 0 * * ? *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 90<br/> },<br/> "name": "weekly_kept_3_months",<br/> "schedule": "cron(0 1 ? * SUN *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "cold_storage_after": 30,<br/> "delete_after": 2555<br/> },<br/> "name": "monthly_kept_7_years",<br/> "schedule": "cron(0 2 1 * ? *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "enable_continuous_backup": true,<br/> "lifecycle": {<br/> "delete_after": 35<br/> },<br/> "name": "point_in_time_recovery",<br/> "schedule": "cron(0 5 * * ? *)"<br/> }<br/> ],<br/> "selection_tag": "BackupLocal",<br/> "selection_tag_value": "True",<br/> "selection_tags": []<br/>}</pre> | no |
| <a name="input_backup_plan_config_dynamodb"></a> [backup\_plan\_config\_dynamodb](#input\_backup\_plan\_config\_dynamodb) | Configuration for backup plans with dynamodb | <pre>object({<br/> enable = bool<br/> selection_tag = string<br/> selection_tag_value = optional(string)<br/> selection_tags = optional(list(object({<br/> key = optional(string)<br/> value = optional(string)<br/> })))<br/> compliance_resource_types = list(string)<br/> rules = optional(list(object({<br/> name = string<br/> schedule = string<br/> enable_continuous_backup = optional(bool)<br/> lifecycle = object({<br/> delete_after = number<br/> cold_storage_after = optional(number)<br/> })<br/> copy_action = optional(object({<br/> delete_after = optional(number)<br/> }))<br/> })))<br/> })</pre> | <pre>{<br/> "compliance_resource_types": [<br/> "DynamoDB"<br/> ],<br/> "enable": true,<br/> "rules": [<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 35<br/> },<br/> "name": "dynamodb_daily_kept_5_weeks",<br/> "schedule": "cron(0 0 * * ? *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "delete_after": 90<br/> },<br/> "name": "dynamodb_weekly_kept_3_months",<br/> "schedule": "cron(0 1 ? * SUN *)"<br/> },<br/> {<br/> "copy_action": {<br/> "delete_after": 365<br/> },<br/> "lifecycle": {<br/> "cold_storage_after": 30,<br/> "delete_after": 2555<br/> },<br/> "name": "dynamodb_monthly_kept_7_years",<br/> "schedule": "cron(0 2 1 * ? *)"<br/> }<br/> ],<br/> "selection_tag": "BackupDynamoDB",<br/> "selection_tag_value": "True",<br/> "selection_tags": []<br/>}</pre> | no |
| <a name="input_backup_plan_config_aurora"></a> [backup_plan_config_aurora](#input_backup_plan_config_aurora) | Configuration for backup plans with aurora | <pre>object({<br> enable = bool<br> selection_tag = string<br> compliance_resource_types = list(string)<br> restore_testing_overrides = optional(string)<br> rules = optional(list(object({<br> name = string<br> schedule = string<br> enable_continuous_backup = optional(bool)<br> lifecycle = object({<br> delete_after = number<br> cold_storage_after = optional(number)<br> })<br> copy_action = optional(object({<br> delete_after = optional(number)<br> }))<br> })))<br> })</pre> | <pre>{<br> "compliance_resource_types": [<br> "Aurora"<br> ],<br> "enable": true,<br> "restore_testing_overrides" : "{\"dbsubnetgroupname\": \"test-subnet\"}",<br> "rules": [<br> {<br> "copy_action": {<br> "delete_after": 365<br> },<br> "lifecycle": {<br> "delete_after": 35<br> },<br> "name": "aurora_daily_kept_5_weeks",<br> "schedule": "cron(0 0 * * ? *)"<br> },<br> {<br> "copy_action": {<br> "delete_after": 365<br> },<br> "lifecycle": {<br> "delete_after": 90<br> },<br> "name": "aurora_weekly_kept_3_months",<br> "schedule": "cron(0 1 ? * SUN *)"<br> },<br> {<br> "copy_action": {<br> "delete_after": 365<br> },<br> "lifecycle": {<br> "cold_storage_after": 30,<br> "delete_after": 2555<br> },<br> "name": "aurora_monthly_kept_7_years",<br> "schedule": "cron(0 2 1 * ? *)"<br> }<br> ],<br> "selection_tag": "BackupAurora"<br>}</pre> | no |
| <a name="input_bootstrap_kms_key_arn"></a> [bootstrap\_kms\_key\_arn](#input\_bootstrap\_kms\_key\_arn) | The ARN of the bootstrap KMS key used for encryption at rest of the SNS topic. | `string` | n/a | yes |
| <a name="input_environment_name"></a> [environment\_name](#input\_environment\_name) | The name of the environment where AWS Backup is configured. | `string` | n/a | yes |
| <a name="input_name_prefix"></a> [name\_prefix](#input\_name\_prefix) | Optional name prefix for vault resources | `string` | `null` | no |
Expand Down
40 changes: 40 additions & 0 deletions modules/aws-backup-source/backup_framework.tf
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,43 @@ resource "aws_backup_framework" "ebsvol" {
}
}
}

resource "aws_backup_framework" "aurora" {
count = var.backup_plan_config_aurora.enable ? 1 : 0
# must be underscores instead of dashes
name = replace("${local.resource_name_prefix}-aurora-framework", "-", "_")
description = "${var.project_name} Aurora Backup Framework"

# Evaluates if resources are protected by a backup plan.
control {
name = "BACKUP_RESOURCES_PROTECTED_BY_BACKUP_PLAN"

scope {
compliance_resource_types = var.backup_plan_config_aurora.compliance_resource_types
tags = {
(var.backup_plan_config_aurora.selection_tag) = "True"
}
}
}
# Evaluates if resources have at least one recovery point created within the past 1 day.
control {
name = "BACKUP_LAST_RECOVERY_POINT_CREATED"

input_parameter {
name = "recoveryPointAgeUnit"
value = "days"
}

input_parameter {
name = "recoveryPointAgeValue"
value = "1"
}

scope {
compliance_resource_types = var.backup_plan_config_aurora.compliance_resource_types
tags = {
(var.backup_plan_config_aurora.selection_tag) = "True"
}
}
}
}
44 changes: 44 additions & 0 deletions modules/aws-backup-source/backup_plan.tf
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,37 @@ 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" "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
}
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 = var.backup_copy_vault_arn != "" && var.backup_copy_vault_account_id != "" && rule.value.copy_action != null ? rule.value.copy_action : {}
content {
lifecycle {
delete_after = copy_action.value
}
destination_vault_arn = var.backup_copy_vault_arn
}
}
}
}
}

resource "aws_backup_selection" "default" {
iam_role_arn = aws_iam_role.backup.arn
name = "${local.resource_name_prefix}-selection"
Expand Down Expand Up @@ -155,3 +186,16 @@ resource "aws_backup_selection" "ebsvol" {
}
}
}

resource "aws_backup_selection" "aurora" {
count = var.backup_plan_config_aurora.enable ? 1 : 0
iam_role_arn = aws_iam_role.backup.arn
name = "${local.resource_name_prefix}-aurora-selection"
plan_id = aws_backup_plan.aurora[0].id

selection_tag {
key = var.backup_plan_config_aurora.selection_tag
type = "STRINGEQUALS"
value = "True"
}
}
16 changes: 16 additions & 0 deletions modules/aws-backup-source/backup_restore_testing.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ resource "awscc_backup_restore_testing_selection" "backup_restore_testing_select
}
}

resource "awscc_backup_restore_testing_selection" "backup_restore_testing_selection_aurora" {
count = var.backup_plan_config_aurora.enable ? 1 : 0
iam_role_arn = aws_iam_role.backup.arn
protected_resource_type = "Aurora"
restore_testing_plan_name = awscc_backup_restore_testing_plan.backup_restore_testing_plan.restore_testing_plan_name
restore_testing_selection_name = "backup_restore_testing_selection_aurora"
protected_resource_arns = ["*"]
protected_resource_conditions = {
string_equals = [{
key = "aws:ResourceTag/${var.backup_plan_config_aurora.selection_tag}"
value = "True"
}]
}
restore_metadata_overrides = local.aurora_overrides
}

resource "awscc_backup_restore_testing_selection" "backup_restore_testing_selection_ebsvol" {
count = var.backup_plan_config_ebsvol.enable ? 1 : 0
iam_role_arn = aws_iam_role.backup.arn
Expand Down
5 changes: 3 additions & 2 deletions modules/aws-backup-source/iam.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ data "aws_iam_policy_document" "assume_role" {
}

resource "aws_iam_role" "backup" {
name = "${var.project_name}BackupRole"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
name = "${var.project_name}BackupRole"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
permissions_boundary = length(var.iam_role_permissions_boundary) > 0 ? var.iam_role_permissions_boundary : null
}

resource "aws_iam_role_policy_attachment" "backup" {
Expand Down
4 changes: 2 additions & 2 deletions modules/aws-backup-source/kms.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ data "aws_iam_policy_document" "backup_key_policy" {
sid = "EnableIAMUserPermissions"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root", data.aws_caller_identity.current.arn]
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root", var.terraform_role_arn]
}
actions = ["kms:*"]
resources = ["*"]
Expand All @@ -51,6 +51,6 @@ data "aws_iam_policy_document" "backup_key_policy" {
"kms:ListGrants",
"kms:DescribeKey"
]
resources = [aws_kms_key.aws_backup_key.arn]
resources = ["*"]
}
}
4 changes: 3 additions & 1 deletion modules/aws-backup-source/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ locals {
framework_arn_list = flatten(concat(
[aws_backup_framework.main.arn],
var.backup_plan_config_ebsvol.enable ? [aws_backup_framework.ebsvol[0].arn] : [],
var.backup_plan_config_dynamodb.enable ? [aws_backup_framework.dynamodb[0].arn] : []
var.backup_plan_config_dynamodb.enable ? [aws_backup_framework.dynamodb[0].arn] : [],
var.backup_plan_config_aurora.enable ? [aws_backup_framework.aurora[0].arn] : []
))
aurora_overrides = jsondecode(var.backup_plan_config_aurora.restore_testing_overrides)
}
14 changes: 14 additions & 0 deletions modules/aws-backup-source/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
output "backup_role_arn" {
value = aws_iam_role.backup.arn
description = "ARN of the of the backup role"
}

output "backup_vault_arn" {
value = aws_backup_vault.main.arn
description = "ARN of the of the vault"
}

output "backup_vault_name" {
value = aws_backup_vault.main.name
description = "Name of the of the vault"
}
67 changes: 66 additions & 1 deletion modules/aws-backup-source/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,78 @@ variable "backup_plan_config_dynamodb" {
}
}


variable "name_prefix" {
description = "Optional name prefix for vault resources"
type = string
default = null
}

variable "backup_plan_config_aurora" {
description = "Configuration for backup plans with aurora"
type = object({
enable = bool
selection_tag = string
compliance_resource_types = list(string)
restore_testing_overrides = optional(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 = "BackupAurora"
compliance_resource_types = ["Aurora"]
rules = [
{
name = "aurora_daily_kept_5_weeks"
schedule = "cron(0 0 * * ? *)"
lifecycle = {
delete_after = 35
}
copy_action = {
delete_after = 365
}
},
{
name = "aurora_weekly_kept_3_months"
schedule = "cron(0 1 ? * SUN *)"
lifecycle = {
delete_after = 90
}
copy_action = {
delete_after = 365
}
},
{
name = "aurora_monthly_kept_7_years"
schedule = "cron(0 2 1 * ? *)"
lifecycle = {
cold_storage_after = 30
delete_after = 2555
}
copy_action = {
delete_after = 365
}
}
]
}
}

variable "iam_role_permissions_boundary" {
description = "Optional permissions boundary ARN for backup role"
type = string
default = "" # Empty by default
}

variable "backup_plan_config_ebsvol" {
description = "Configuration for backup plans with EBS"
type = object({
Expand Down