main.tf
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# Hub vaults ===================================================================
# Central destination vaults for cross-account recovery point copies.
# Created once, in the designated hub/backup account. Spoke accounts never
# create resources here directly — they only reference these vault ARNs as
# copy_action destinations in their own local backup plans.
#
# force_destroy and prevent_destroy are both fixed, non-configurable — this
# vault is the org-wide compliance record and should never be destroyable
# as a side effect of routine changes. Decommissioning is a deliberate,
# reviewed action (temporarily remove the lifecycle block in its own PR),
# not something a stale plan or a fat-fingered apply should be able to do.

resource "aws_backup_vault" "this" {
  for_each = var.vaults

  name          = "${var.name_prefix}-${local.region_abbr}-${each.value.name_suffix}"
  kms_key_arn   = each.value.kms_key_arn
  force_destroy = false

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-${each.value.name_suffix}"
    resource-type = "backup-vault"
  })

  lifecycle {
    prevent_destroy = true
  }
}

# Vault access policy ---------------------------------------------------------
# Grants CopyIntoBackupVault to each vault's own trusted spoke role list.
# This is the only mechanism by which a cross-account copy_action from a
# spoke succeeds — without this statement, the destination vault refuses
# the incoming copy regardless of what the spoke's own IAM role allows.

resource "aws_backup_vault_policy" "this" {
  for_each = var.vaults

  backup_vault_name = aws_backup_vault.this[each.key].name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowTrustedSpokeCopyIntoVault"
        Effect    = "Allow"
        Principal = { AWS = each.value.trusted_spoke_role_arns }
        Action    = ["backup:CopyIntoBackupVault"]
        Resource  = aws_backup_vault.this[each.key].arn
      }
    ]
  })
}

# Vault lock (WORM compliance mode) --------------------------------------------
# Optional per-vault. Once locked in compliance mode, retention cannot be
# shortened or the vault deleted by anyone, including account root, until
# the lock's changeable_for_days grace period has elapsed.

resource "aws_backup_vault_lock_configuration" "this" {
  for_each = { for k, v in var.vaults : k => v if v.lock_enabled }

  backup_vault_name   = aws_backup_vault.this[each.key].name
  changeable_for_days = each.value.lock_changeable_for_days
  min_retention_days  = each.value.lock_min_retention_days
  max_retention_days  = each.value.lock_max_retention_days
}

# Notifications -----------------------------------------------------------------
# Opt-in per vault. If notifications.create_sns_topic is true and no
# sns_topic_arn is provided, the module provisions a topic. Otherwise the
# supplied sns_topic_arn is used as-is (e.g. an existing ops/alerting topic).

resource "aws_sns_topic" "backup_notifications" {
  for_each = {
    for k, v in var.vaults : k => v
    if v.notifications != null && v.notifications.create_sns_topic && v.notifications.sns_topic_arn == null
  }

  name              = "${var.name_prefix}-${local.region_abbr}-${each.value.name_suffix}-notifications"
  kms_master_key_id = each.value.notifications.kms_key_arn

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-${each.value.name_suffix}-notifications"
    resource-type = "backup-notifications-topic"
  })
}

resource "aws_backup_vault_notifications" "this" {
  for_each = { for k, v in var.vaults : k => v if v.notifications != null }

  backup_vault_name = aws_backup_vault.this[each.key].name

  sns_topic_arn = coalesce(
    each.value.notifications.sns_topic_arn,
    try(aws_sns_topic.backup_notifications[each.key].arn, null)
  )

  backup_vault_events = each.value.notifications.backup_vault_events
}