Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS Network Firewall Terraform Module

A logged, deletion-protected, secure-by-default AWS Network Firewall — the firewall itself, its one-and-only firewall policy, any number of stateful/stateless rule groups, and a FLOW/ALERT logging configuration — from a single module call, wired into a VPC's dedicated firewall subnets (or a transit gateway) with change-protection on and CMK encryption available. Route-table wiring is deliberately the caller's job. Built for the AWS provider v6.x.

Terraform aws module type resources


🧩 Overview

  • 🧱 Provisions an aws_networkfirewall_firewall keystone attached to a VPC (subnet_mapping, one endpoint per firewall subnet) or a transit gateway (availability_zone_mapping).
  • 📜 Creates the firewall's single firewall policy (aws_networkfirewall_firewall_policy — AWS enforces exactly one per firewall) with a secure default that forwards stateless traffic to the stateful engine for full inspection.
  • 🛡️ Creates any number of stateful and stateless rule groups as a for_each child collection — 5-tuple rules, Suricata strings, domain allow/deny lists, or raw Suricata files — each referenceable from the policy by a stable key (no ARN hand-wiring).
  • 🔒 Defaults delete_protection, subnet_change_protection, and firewall_policy_change_protection to true, so an in-use firewall cannot be silently deleted or re-pointed.
  • 📈 Optionally streams FLOW / ALERT / TLS logs to CloudWatch Logs, S3, or Kinesis Data Firehose — one destination per log type — created automatically whenever a destination is supplied.
  • 🗝️ Encrypts every in-scope resource with a customer-managed KMS key the moment kms_key_arn is supplied (AWS-owned key otherwise).
  • 🧭 Emits firewall_endpoint_ids (subnet key → vpce-...) — the one output this module exists to produce — for the caller's own aws_route resources.

💡 Why it matters: a firewall that is deployed but not routed inspects zero traffic — silently (apply succeeds). This module ships the hardened, observable inspection layer and makes the routing responsibility explicit, so the boundary between "firewall exists" and "traffic is actually inspected" is never accidentally blurred in an PII/privacy-regulation environment.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

flowchart LR
 subgraph up["Upstream (consumed by reference)"]
 VPC["terraform-aws-vpc<br/>(vpc_id, firewall subnet ids,<br/>route table ids)"]
 TGW["terraform-aws-transit-gateway<br/>(transit_gateway_id)"]
 KMS["terraform-aws-kms<br/>(kms_key_arn — CMK)"]
 LOGS["terraform-aws-cloudwatch-log-group<br/>/ terraform-aws-s3-bucket<br/>/ terraform-aws-kinesis-firehose<br/>(log destinations)"]
 end
 NFW["terraform-aws-network-firewall<br/>(firewall + policy + rule groups<br/>+ logging configuration)"]
 ROUTE["CALLER's own aws_route<br/>(NOT this module)"]

 VPC -->|"vpc_id + subnet_mapping"| NFW
 TGW -->|"transit_gateway_id"| NFW
 KMS -->|"encryption_configuration"| NFW
 LOGS -->|"log_destination"| NFW
 NFW -->|"firewall_endpoint_ids (vpce-...)"| ROUTE
 VPC -->|"route_table_ids"| ROUTE

 classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
 classDef caller fill:#FFF3E0,color:#5a3d00,stroke:#FF9900,stroke-dasharray:4 3;
 class NFW me;
 class ROUTE caller;
Loading
  • Upstream: terraform-aws-vpc supplies vpc_id and the dedicated firewall subnet ids; terraform-aws-transit-gateway supplies transit_gateway_id; terraform-aws-kms supplies the CMK; terraform-aws-cloudwatch-log-group / terraform-aws-s3-bucket / terraform-aws-kinesis-firehose supply the log destination.
  • Downstream: the caller's own aws_route resources consume this module's firewall_endpoint_ids together with route_table_ids from terraform-aws-vpc. This module never creates or modifies a route table (see Architecture Notes).

🧬 What this module builds

flowchart TD
 RG["aws_networkfirewall_rule_group.this<br/>for_each var.rule_groups<br/>(STATEFUL / STATELESS)"]
 POL["aws_networkfirewall_firewall_policy.this<br/>(singleton — 1 per firewall)"]
 FW["aws_networkfirewall_firewall.this<br/>(keystone — subnet/AZ mapping,<br/>delete + change protection)"]
 LOG["aws_networkfirewall_logging_configuration.this<br/>(optional — FLOW / ALERT / TLS)"]
 EP["firewall_endpoint_ids output<br/>(vpce-... per firewall subnet)"]

 RG -.->|"rule_group_key resolves to arn"| POL
 POL -->|"firewall_policy_arn"| FW
 FW -->|"firewall_arn"| LOG
 FW -->|"firewall_status.sync_states"| EP

 classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
 classDef child fill:#FFF3E0,color:#5a3d00,stroke:#FF9900;
 classDef out fill:#E8F5E9,color:#1b5e20,stroke:#2E7D32;
 class FW me;
 class RG,POL,LOG child;
 class EP out;
Loading
Resource Cardinality Driven by
aws_networkfirewall_firewall.this exactly 1 (keystone) always
aws_networkfirewall_firewall_policy.this exactly 1 (singleton) always (secure defaults)
aws_networkfirewall_rule_group.this 0..N (for_each) var.rule_groups
aws_networkfirewall_logging_configuration.this 0..1 var.logging

✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws >= 6.0, < 7.0 (GA v6.5x)

This module declares only required_providers — no provider {} block, no configuration_aliases. Network Firewall is a regional service, so it inherits the single caller-supplied aws provider; the caller chooses the Region.


🔑 Required IAM Permissions

The Terraform identity (profile / SSO / OIDC-assumed role) needs the following, scoped least-privilege to the firewall and its children.

Action Required for Notes
network-firewall:CreateFirewall, network-firewall:DeleteFirewall, network-firewall:DescribeFirewall, network-firewall:UpdateFirewallDescription Firewall lifecycle (keystone)
network-firewall:AssociateSubnets, network-firewall:DisassociateSubnets Firewall-endpoint (subnet) association Driven by subnet_mapping changes
network-firewall:UpdateSubnetChangeProtection, network-firewall:UpdateFirewallPolicyChangeProtection, network-firewall:UpdateFirewallDeleteProtection, network-firewall:UpdateAvailabilityZoneChangeProtection The four change-protection flags This module defaults all to true
network-firewall:AssociateFirewallPolicy Attaching the policy to the firewall
network-firewall:CreateFirewallPolicy, network-firewall:DeleteFirewallPolicy, network-firewall:DescribeFirewallPolicy, network-firewall:UpdateFirewallPolicy Firewall policy lifecycle Singleton child
network-firewall:CreateRuleGroup, network-firewall:DeleteRuleGroup, network-firewall:DescribeRuleGroup, network-firewall:UpdateRuleGroup, network-firewall:ListRuleGroups Rule group lifecycle Only when rule_groups is non-empty
network-firewall:UpdateLoggingConfiguration, network-firewall:DescribeLoggingConfiguration Logging configuration Only when logging is set
network-firewall:TagResource, network-firewall:UntagResource, network-firewall:ListTagsForResource var.tags / tags_all on firewall, policy, rule groups Logging config is not taggable
kms:CreateGrant, kms:DescribeKey CMK-encrypted resources Only when kms_key_arn is supplied
ec2:DescribeSubnets, ec2:DescribeVpcs Validating vpc_id / firewall subnets at plan time
logs:CreateLogDelivery or s3:PutBucketPolicy or firehose:DescribeDeliveryStream Wiring the chosen log destination to accept firewall delivery On the destination side — usually owned by the log-group / bucket / Firehose module, listed here for completeness

ℹ️ No iam:PassRole is required — this module passes no role to any resource. network-firewall:PutResourcePolicy / DescribeResourcePolicy are only needed if you share a policy / rule group cross-account via a resource policy (not exercised by the default variables).


📋 AWS Prerequisites

  • No service-linked role is required for Network Firewall itself.
  • Firewall subnets are dedicated and pre-existing. AWS recommends a /28 subnet per AZ, separate from the protected ("customer") subnets, existing solely to host the firewall endpoint (vpce-...) in that AZ. Create them with terraform-aws-vpc before calling this module. Network Firewall creates one endpoint per subnet_mapping entry, and each subnet must be in a distinct AZ.
  • Route tables are a hard prerequisite for a functioning firewall — and are OUT OF SCOPE here. A firewall with no routing pointed at its endpoints passes zero traffic, silently. The three route table types and who owns them:
flowchart LR
 subgraph vpcmod["terraform-aws-vpc creates"]
 IGWRT["IGW route table"]
 FWSUB["Firewall subnet<br/>(/28 per AZ)"]
 FWRT["Firewall subnet<br/>route table"]
 CUSTSUB["Protected subnet"]
 CUSTRT["Protected subnet<br/>route table"]
 end
 subgraph nfwmod["terraform-aws-network-firewall creates"]
 EP["Firewall endpoint<br/>vpce-... (per AZ)"]
 end
 subgraph callermod["CALLER creates (aws_route)"]
 R1["route: protected -> endpoint"]
 R2["route: IGW -> endpoint"]
 R3["route: endpoint -> IGW / NAT"]
 end

 FWSUB --- EP
 EP -.->|"firewall_endpoint_ids"| R1
 EP -.->|"firewall_endpoint_ids"| R2
 CUSTRT --- R1
 IGWRT --- R2
 FWRT --- R3

 classDef me fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px;
 class EP me;
Loading

This module creates the ENDPOINTS, terraform-aws-vpc creates the SUBNETS, and the CALLER creates the aws_route resources connecting the two.

  • Capacity is immutable per rule group. capacity on aws_networkfirewall_rule_group is FORCE-NEW — undersizing requires rule-group replacement, not an in-place update. Size generously.
  • Quotas (soft, per account/Region unless noted): 100 firewalls; 300 rule groups; 15 firewall policies; 1 firewall policy per firewall (hard); one firewall endpoint per firewall per AZ (hard); stateful capacity up to ~30,000. Verify current values in the Service Quotas console before large deployments.
  • KMS key policy must grant the Network Firewall service principal (network-firewall.amazonaws.com) kms:GenerateDataKey / kms:Decrypt when a customer-managed key is supplied.
  • us-east-1: no global-resource constraint — Network Firewall is regional (unlike CloudFront / ACM-for-CloudFront / WAFv2-CLOUDFRONT).

📁 Module Structure

terraform-aws-network-firewall/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); regional, no aliases
├── variables.tf # deeply-typed object schemas; secure defaults; validation blocks
├── main.tf # rule_group.this (for_each) -> firewall_policy.this -> firewall.this + logging
├── outputs.tf # id + arn + name + firewall_endpoint_ids + rule_group ARNs + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM, prerequisites, gotchas, secure defaults

⚙️ Quick Start

Smallest useful VPC-attached firewall, wiring firewall subnets from terraform-aws-vpc, with FLOW + ALERT logging:

module "nfw" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"

  name   = "casey-prod-nfw"
  vpc_id = module.vpc.id

  # Dedicated firewall subnets (one /28 per AZ) from terraform-aws-vpc.
  subnet_mapping = {
    az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] }
    az_b = { subnet_id = module.vpc.firewall_subnet_ids["az-b"] }
  }

  # A minimal deny-list rule group, referenced by key from the policy.
  rule_groups = {
    block_bad_domains = {
      type     = "STATEFUL"
      capacity = 100
      rule_group = {
        rules_source = {
          rules_source_list = {
            generated_rules_type = "DENYLIST"
            target_types         = ["HTTP_HOST", "TLS_SNI"]
            targets              = [".malware.test", "evil.example.com"]
          }
        }
      }
    }
  }

  firewall_policy = {
    stateful_rule_group_references = {
      bad_domains = { rule_group_key = "block_bad_domains" }
    }
  }

  logging = {
    log_destinations = {
      FLOW  = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
      ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
    }
  }

  tags = { application = "member-portal", data_class = "npi" }
}

# The firewall inspects nothing until YOU route traffic through its endpoints:
resource "aws_route" "protected_to_fw" {
  for_each               = module.vpc.protected_route_table_ids # { az-a = rtb-..., az-b = rtb-... }
  route_table_id         = each.value
  destination_cidr_block = "0.0.0.0/0"
  vpc_endpoint_id        = module.nfw.firewall_endpoint_ids[each.key]
}

⚠️ Pin the source with ?ref=v1.0.0 — never a branch.


🔌 Cross-Module Contract

Consumes

Input Type Source module
vpc_id string terraform-aws-vpc (id / vpc_id)
subnet_mapping[*].subnet_id string terraform-aws-vpc — dedicated firewall subnets, one per AZ
transit_gateway_id (alt. to vpc_id) string terraform-aws-transit-gateway (id)
kms_key_arn (optional CMK) string terraform-aws-kms (arn)
logging.log_destinations[*].log_destination map(string) terraform-aws-cloudwatch-log-group / terraform-aws-s3-bucket / terraform-aws-kinesis-firehose
firewall_policy.*_rule_group_references[*].resource_arn string (optional) externally-created rule group
firewall_policy.tls_inspection_configuration_arn string (optional, create-only) externally-managed TLS inspection configuration

Emits

Output Description Consumed by
id Firewall ARN (id == arn on this resource) reference / import
arn Firewall ARN — cross-resource reference type IAM policy conditions, terraform-aws-cloudtrail filters
name Firewall friendly name tagging / dashboards
firewall_endpoint_ids Map of subnet key → endpoint id (vpce-...) the caller's aws_route resources
firewall_status Full nested status object diagnostics
firewall_policy_arn / firewall_policy_id Policy ARN / id reference / import / reuse
rule_group_arns / rule_group_ids Map of rule-group key → ARN / id policy references / audit
logging_configuration_id Logging config id (== firewall ARN) or null audit
update_token Optimistic-locking token rarely needed
tags_all All tags incl. provider default_tags governance / audit

📚 Example Library

1 · Minimal VPC-attached firewall (subnets from terraform-aws-vpc)
module "nfw" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"

  name   = "casey-baseline-nfw"
  vpc_id = module.vpc.id
  subnet_mapping = {
    az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] }
    az_b = { subnet_id = module.vpc.firewall_subnet_ids["az-b"] }
  }
  # firewall_policy defaults to {}: stateless traffic is forwarded to the
  # stateful engine (aws:forward_to_sfe). delete/subnet/policy change protection
  # all default to true. No rules yet — add rule_groups + references next.
}
2 · STRICT_ORDER deny-by-default stateful posture
module "nfw" {
  source         = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
  name           = "casey-strict-nfw"
  vpc_id         = module.vpc.id
  subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }

  rule_groups = {
    allow_egress = {
      type     = "STATEFUL"
      capacity = 100
      rule_group = {
        stateful_rule_options = { rule_order = "STRICT_ORDER" } # MUST match the policy
        rules_source = {
          stateful_rule = [{
            action      = "PASS"
            header      = { source = "10.0.0.0/16", source_port = "ANY", direction = "FORWARD", protocol = "TCP", destination = "ANY", destination_port = "443" }
            rule_option = [{ keyword = "sid", settings = ["1"] }]
          }]
        }
      }
    }
  }

  firewall_policy = {
    # Deny everything not explicitly passed above:
    stateful_engine_options  = { rule_order = "STRICT_ORDER" }
    stateful_default_actions = ["aws:drop_established", "aws:alert_established"]
    stateful_rule_group_references = {
      egress = { rule_group_key = "allow_egress", priority = 100 } # priority required under STRICT_ORDER
    }
  }
}

STRICT_ORDER is viral — every rule group referenced by a STRICT_ORDER policy must itself set stateful_rule_options.rule_order = "STRICT_ORDER", and stateful_default_actions is only valid under STRICT_ORDER.

3 · DEFAULT_ACTION_ORDER 5-tuple stateful rule group
rule_groups = {
  block_ssh = {
    type     = "STATEFUL"
    capacity = 100
    rule_group = {
      # No stateful_rule_options => DEFAULT_ACTION_ORDER (the schema default).
      rules_source = {
        stateful_rule = [{
          action = "DROP"
          header = { source = "ANY", source_port = "ANY", direction = "ANY", protocol = "TCP", destination = "ANY", destination_port = "22" }
          rule_option = [
            { keyword = "sid", settings = ["100"] },
            { keyword = "msg", settings = ["\"block inbound ssh\""] },
          ]
        }]
      }
    }
  }
}

firewall_policy = {
  stateful_rule_group_references = { ssh = { rule_group_key = "block_ssh" } } # no priority needed
}
4 · Stateless rule group (5-tuple + match attributes)
rule_groups = {
  drop_udp = {
    type     = "STATELESS"
    capacity = 100
    rule_group = {
      rules_source = {
        stateless_rules_and_custom_actions = {
          stateless_rule = [{
            priority = 1
            rule_definition = {
              actions = ["aws:drop"]
              match_attributes = {
                protocols        = [17] # UDP
                source           = [{ address_definition = "0.0.0.0/0" }]
                destination      = [{ address_definition = "10.0.0.0/16" }]
                destination_port = [{ from_port = 53, to_port = 53 }]
              }
            }
          }]
        }
      }
    }
  }
}

firewall_policy = {
  stateless_rule_group_references = {
    udp = { rule_group_key = "drop_udp", priority = 10 } # priority required for stateless refs
  }
}
5 · Domain allow-list / deny-list (rules_source_list)
rule_groups = {
  allow_only_partners = {
    type     = "STATEFUL"
    capacity = 200
    rule_group = {
      rules_source = {
        rules_source_list = {
          generated_rules_type = "ALLOWLIST" # or DENYLIST
          target_types         = ["TLS_SNI", "HTTP_HOST"]
          targets              = ["farmcredit.com", ".partner.example"]
        }
      }
    }
  }
}

target_types are TLS_SNI and/or HTTP_HOST. A leading dot (.partner.example) matches all subdomains.

6 · Raw Suricata rules file (STATEFUL only)
rule_groups = {
  suricata = {
    type     = "STATEFUL"
    capacity = 500
    rules    = file("${path.module}/rules/nfw.rules") # a Suricata flat file
    # `rules` and `rule_group` are mutually exclusive — set exactly one.
  }
}
7 · Logging — FLOW + ALERT to CloudWatch (the baseline)
logging = {
  enable_monitoring_dashboard = true
  log_destinations = {
    FLOW  = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
    ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
  }
}

One destination per log type. S3 uses { bucketName = "...", prefix = "nfw/" }; Firehose uses { deliveryStream = "..." }. Omit logging entirely and no logging configuration is created.

8 · Customer-managed KMS encryption
module "nfw" {
  source         = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
  name           = "casey-cmk-nfw"
  vpc_id         = module.vpc.id
  subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }

  kms_key_arn = module.kms.arn # from terraform-aws-kms
  # -> encryption_configuration { type = "CUSTOMER_KMS", key_id = <arn> } is rendered
  # on the firewall, the policy, AND every rule group. The key policy must grant
  # network-firewall.amazonaws.com kms:GenerateDataKey / kms:Decrypt.
}
9 · Secure-by-default opt-out — delete_protection = false (dev, documented exception)
# ⚠️ Exception posture for a throwaway/dev firewall you intend to tear down often.
# Document the risk acceptance; production firewalls keep all protections on.
module "nfw_dev" {
  source         = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
  name           = "casey-dev-nfw"
  vpc_id         = module.vpc.id
  subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }

  delete_protection                 = false # ← opt-out (default true)
  subnet_change_protection          = false
  firewall_policy_change_protection = false
}
10 · tags — merge with provider default_tags
provider "aws" {
  region = "us-east-2"
  default_tags { tags = { managed_by = "terraform", cost_center = "1234" } }
}

module "nfw" {
  source         = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
  name           = "casey-portal-nfw"
  vpc_id         = module.vpc.id
  subnet_mapping = { az_a = { subnet_id = module.vpc.firewall_subnet_ids["az-a"] } }

  tags = { application = "member-portal", data_class = "npi" }
  # tags_all = default_tags ∪ tags; on key conflict, these resource tags win.

  rule_groups = {
    rg = {
      type     = "STATEFUL"
      capacity = 100
      rules    = "alert tcp any any -> any any (msg:\"x\"; sid:1;)"
      tags     = { tier = "inspection" } # per-rule-group tags merge OVER module tags
    }
  }
}
11 · for_each pattern — several rule groups, referenced by key
rule_groups = {
  bad_domains = {
    type       = "STATEFUL", capacity = 100
    rule_group = { rules_source = { rules_source_list = { generated_rules_type = "DENYLIST", target_types = ["TLS_SNI"], targets = [".malware.test"] } } }
  }
  block_ssh = {
    type       = "STATEFUL", capacity = 100
    rule_group = { rules_source = { stateful_rule = [{ action = "DROP", header = { source = "ANY", source_port = "ANY", direction = "ANY", protocol = "TCP", destination = "ANY", destination_port = "22" }, rule_option = [{ keyword = "sid", settings = ["1"] }] }] } }
  }
  rate_limit = {
    type       = "STATELESS", capacity = 100
    rule_group = { rules_source = { stateless_rules_and_custom_actions = { stateless_rule = [{ priority = 1, rule_definition = { actions = ["aws:drop"], match_attributes = { source = [{ address_definition = "192.0.2.0/24" }] } } }] } } }
  }
}

firewall_policy = {
  stateful_rule_group_references  = { bad = { rule_group_key = "bad_domains" }, ssh = { rule_group_key = "block_ssh" } }
  stateless_rule_group_references = { rl = { rule_group_key = "rate_limit", priority = 10 } }
}
# module.nfw.rule_group_arns => { bad_domains = "arn:...", block_ssh = "arn:...", rate_limit = "arn:..." }
12 · import an existing firewall
# Network Firewall resources import by their ARN (id == arn).
import {
  to = module.nfw.aws_networkfirewall_firewall.this
  id = "arn:aws:network-firewall:us-east-2:111122223333:firewall/casey-prod-nfw"
}
13 · Full routing — the caller's own aws_route resources
# This module NEVER creates routes. Redirect protected-subnet egress through the
# firewall endpoints, using route table ids from terraform-aws-vpc and endpoint ids
# from this module (same stable keys as subnet_mapping):
resource "aws_route" "protected_default_via_fw" {
  for_each               = module.vpc.protected_route_table_ids # { az-a = "rtb-...", az-b = "rtb-..." }
  route_table_id         = each.value
  destination_cidr_block = "0.0.0.0/0"
  vpc_endpoint_id        = module.nfw.firewall_endpoint_ids[each.key]
}

# Ingress return path: the IGW edge route table sends inbound traffic to the
# firewall endpoint in the matching AZ before it reaches the protected subnet.
resource "aws_route" "igw_edge_to_fw" {
  for_each               = module.vpc.protected_subnet_cidrs # { az-a = "10.0.1.0/24",... }
  route_table_id         = module.vpc.igw_edge_route_table_id
  destination_cidr_block = each.value
  vpc_endpoint_id        = module.nfw.firewall_endpoint_ids[each.key]
}
14 · End-to-end composition (VPC → KMS → log group → firewall → routing)
provider "aws" { region = "us-east-2" }

module "vpc" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-vpc?ref=v1.0.0"
  name   = "casey-inspection-vpc"
  #... provisions protected subnets AND dedicated /28 firewall subnets per AZ...
}

module "kms" {
  source                 = "git::https://github.com/microsoftexpert/terraform-aws-kms?ref=v1.0.0"
  name                   = "casey-nfw"
  key_service_principals = ["network-firewall.amazonaws.com"] # allow the service to use the CMK
}

module "flow_logs" {
  source      = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
  name        = "/casey/network-firewall/flow"
  kms_key_arn = module.kms.arn
}
module "alert_logs" {
  source      = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
  name        = "/casey/network-firewall/alert"
  kms_key_arn = module.kms.arn
}

module "nfw" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-network-firewall?ref=v1.0.0"
  name   = "casey-prod-nfw"
  vpc_id = module.vpc.id

  subnet_mapping = {
    for k, id in module.vpc.firewall_subnet_ids : k => { subnet_id = id }
  }

  kms_key_arn = module.kms.arn

  rule_groups = {
    block_bad_domains = {
      type     = "STATEFUL"
      capacity = 200
      rule_group = {
        stateful_rule_options = { rule_order = "STRICT_ORDER" }
        rules_source          = { rules_source_list = { generated_rules_type = "DENYLIST", target_types = ["TLS_SNI", "HTTP_HOST"], targets = [".malware.test"] } }
      }
    }
  }

  firewall_policy = {
    stateful_engine_options        = { rule_order = "STRICT_ORDER", stream_exception_policy = "DROP" }
    stateful_default_actions       = ["aws:drop_established", "aws:alert_established"]
    stateful_rule_group_references = { bad = { rule_group_key = "block_bad_domains", priority = 100 } }
  }

  logging = {
    log_destinations = {
      FLOW  = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.flow_logs.name } }
      ALERT = { log_destination_type = "CloudWatchLogs", log_destination = { logGroup = module.alert_logs.name } }
    }
  }

  tags = { application = "member-portal", data_class = "npi" }
}

# The caller owns routing (this module does not):
resource "aws_route" "protected_via_fw" {
  for_each               = module.vpc.protected_route_table_ids
  route_table_id         = each.value
  destination_cidr_block = "0.0.0.0/0"
  vpc_endpoint_id        = module.nfw.firewall_endpoint_ids[each.key]
}

📥 Inputs

ℹ️ High-level groups:

  • Identityname (required, FORCE-NEW).
  • Attachment (set exactly one mode)vpc_id + subnet_mapping (VPC-attached) or transit_gateway_id + availability_zone_mapping (transit-gateway-attached).
  • Firewall policyfirewall_policy (singleton object: stateless/stateful default actions, stateful_engine_options, policy_variables, stateful/stateless rule group references by key or ARN, custom actions, tls_inspection_configuration_arn).
  • Rule groupsrule_groups (map(object), for_each: type, capacity (FORCE-NEW), and one of rules or a structured rule_group).
  • Protection (secure defaults, all ON)delete_protection, subnet_change_protection, firewall_policy_change_protection, availability_zone_change_protection.
  • Configurationdescription, enabled_analysis_types.
  • Encryptionkms_key_arn (CMK; null = AWS-owned key).
  • Logginglogging (per-log-type destinations + monitoring dashboard).
  • Tags / timeoutstags (universal), timeouts.

🧾 Outputs

  • id — firewall ARN (id == arn on this resource).
  • arn — firewall ARN (the cross-resource reference type).
  • name — firewall friendly name.
  • firewall_endpoint_ids — map of subnet_mapping key → endpoint id (vpce-...); the routing output. Unknown until after apply; VPC-attached mode only.
  • firewall_status — full nested status object (per-AZ sync state, TGW attachment sync states).
  • firewall_policy_arn / firewall_policy_id — policy ARN / id.
  • rule_group_arns / rule_group_ids — maps keyed by rule_groups key (empty when none).
  • logging_configuration_idtry(..., null); logging config id or null.
  • update_token — optimistic-locking token.
  • tags_all — merged tags including provider default_tags.

No output is sensitive — the module emits no secrets.


🧠 Architecture Notes

  • id == arn. Unlike most AWS resources, aws_networkfirewall_firewall (and its policy / rule-group siblings) return the full ARN for both id and arn — there is no separate short id (no fw-xxxx). Import uses the ARN.
  • ARN format: arn:<partition>:network-firewall:<region>:<account>:firewall/<name> (and .../firewall-policy/<name>, .../stateful-rulegroup/<name> or .../stateless-rulegroup/<name>).
  • FORCE-NEW fields: name, vpc_id, and transit_gateway_id on the firewall; capacity on every rule group; firewall_arn on the logging configuration. Switching VPC↔TGW attachment or renaming the firewall destroys and recreates it (new ARN, new endpoints), breaking every aws_route that targets an old endpoint id — plan topology up front.
  • Route tables are out of scope — permanently. This module owns inspection resources only. Route tables belong to terraform-aws-vpc and the caller's own aws_route resources, so a firewall change never re-routes unrelated VPC traffic, and the same module serves single-AZ, multi-AZ, IGW-fronted, or TGW-attached topologies (each needs a different route shape this module has no visibility into).
  • firewall_endpoint_ids keying. Endpoint ids appear only after apply, three levels deep in firewall_status.sync_states[*].attachment.endpoint_id (a set, not stable-ordered). The module matches each sync state's subnet_id back to var.subnet_mapping and re-keys the result by the caller's stable keys, so route resources get a predictable firewall_endpoint_ids["az-a"] reference.
  • Eventual consistency on subnet association. AssociateSubnets / DisassociateSubnets (driven by subnet_mapping changes) can lag before firewall_status reflects the new endpoint — a next-run refresh may be needed before route resources see a new endpoint id.
  • STRICT_ORDER is viral, and stateful_default_actions requires it. A STRICT_ORDER policy rejects (at the API, not just discouraged) any referenced rule group not itself created with stateful_rule_options.rule_order = "STRICT_ORDER"; and stateful_default_actions cannot be set under DEFAULT_ACTION_ORDER. Both couplings are enforced by variable validation.
  • tls_inspection_configuration_arn is a one-way door. Per AWS, it can only be set when the policy is first created — it cannot be added to an existing policy nor removed once set. Model it deliberately; there is no safe "unset" path.
  • tagstags_alldefault_tags. var.tags flows to the firewall, policy, and rule groups; per-rule-group tags merge over module tags. tags_all is the computed union with provider default_tags, where resource tags win on key conflict. The logging configuration is not taggable. default_tags is the caller's provider-block concern, never set here.
  • Destroy ordering. The logging configuration is destroyed before/with the firewall; the firewall is destroyed before its firewall subnets can be deleted (endpoint ENIs are still attached). Critically, the caller must remove their aws_route entries pointing at the firewall endpoints before/while destroying this module — otherwise routes point at endpoints that no longer exist (a live outage in a running VPC, not just a Terraform error).

🧱 Design Principles

Secure-by-default posture and the exact opt-out for each hardened default:

Hardened default Variable Opt-out
delete_protection = true delete_protection set false (example 9 — dev/throwaway only)
subnet_change_protection = true subnet_change_protection set false to allow in-place subnet changes
firewall_policy_change_protection = true firewall_policy_change_protection set false to allow re-pointing the policy
availability_zone_change_protection = true availability_zone_change_protection set false (TGW mode)
Stateless default = aws:forward_to_sfe (inspect, don't implicitly pass) firewall_policy.stateless_default_actions / _fragment_default_actions override to ["aws:pass"] / ["aws:drop"]
Logging created when a destination is supplied (FLOW + ALERT baseline) logging omit logging (no config created)
Deny-by-default (STRICT_ORDER + stateful_default_actions) available as opt-in firewall_policy.stateful_engine_options.rule_order leave at DEFAULT_ACTION_ORDER (schema default, backward-compatible)
CMK encryption available on every in-scope resource kms_key_arn leave null (AWS-owned key)
Rule group capacity is required — no silent under-provisioning rule_groups[*].capacity n/a — correctness requirement, not a toggle

Additional principles:

  • One composite, one posture — firewall + policy + rule groups + logging come from a single call, so a complete, observable inspection layer is atomic.
  • Reference by key, not ARN — rule groups are referenced from the policy by a stable key the module resolves to the created ARN, eliminating manual ARN wiring and destroy-ordering hazards; supply resource_arn for externally-created groups.
  • Routing by the caller, never by the module — the blast radius of a firewall change never includes VPC re-routing.
  • No credentials, no region variable — regional service; credentials resolve through the standard chain at the caller.

🚀 Runbook

cd C:/GitHubCode/newawsmodules/terraform-aws-network-firewall
terraform init -backend=false
terraform validate
terraform fmt -check
# plan/apply require valid AWS credentials (profile / SSO / OIDC) AND a region:
terraform plan
terraform apply
terraform output firewall_endpoint_ids # then wire your aws_route resources

⚠️ Pin the module source with ?ref=v1.0.0, never a branch. ℹ️ After apply, remember the firewall inspects nothing until the caller's aws_route resources point traffic at firewall_endpoint_ids.


🧪 Testing

  • terraform init -backend=false && terraform validate — offline structural validation, no credentials needed.
  • terraform fmt -check — style gate.
  • Variable validation blocks enforce: attachment-mode XOR, rule-group rules/rule_group XOR, exactly-one rules_source variant, STRICT_ORDERstateful_default_actions coupling, and all enums. (Cross-variable rules — e.g. attachment XOR — evaluate at plan/console time, not validate.)
  • For an apply-time smoke test: create a two-AZ VPC-attached firewall with one deny-list rule group and FLOW+ALERT logging, confirm firewall_endpoint_ids returns one vpce-... per subnet key, wire a test aws_route, then terraform destroy (removing routes first).

💬 Example Output

arn = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
id = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
name = "casey-prod-nfw"
firewall_endpoint_ids = {
 "az_a" = "vpce-0a1b2c3d4e5f60011"
 "az_b" = "vpce-0a1b2c3d4e5f60022"
}
firewall_policy_arn = "arn:aws:network-firewall:us-east-2:123456789012:firewall-policy/casey-prod-nfw-policy"
rule_group_arns = {
 "block_bad_domains" = "arn:aws:network-firewall:us-east-2:123456789012:stateful-rulegroup/block_bad_domains"
}
logging_configuration_id = "arn:aws:network-firewall:us-east-2:123456789012:firewall/casey-prod-nfw"
tags_all = { application = "member-portal", cost_center = "1234", data_class = "npi", managed_by = "terraform" }

🔍 Troubleshooting

  • Firewall applies but inspects no traffic — you have not routed traffic through the endpoints. This module creates endpoints, not routes. Wire aws_route resources (protected subnet → endpoint, IGW edge → endpoint) using firewall_endpoint_ids. See example 13.
  • firewall_endpoint_ids is empty / unknown at plan — endpoint ids only exist after apply (nested in firewall_status.sync_states). Reference them from resources created in the same or a later apply; a first plan shows them as unknown, which is expected.
  • InvalidRequestException: only one of vpc_id / transit_gateway_id (or the plan-time validation error) — set exactly one attachment mode: vpc_id + subnet_mapping or transit_gateway_id + availability_zone_mapping.
  • InsufficientCapacityException / rule group rejected — the rule group's capacity is too small for its rules, and capacity is FORCE-NEW. Raise it (which replaces the group) and re-reference it from the policy.
  • InvalidRequestException mixing rule orders — a STRICT_ORDER policy references a rule group created with DEFAULT_ACTION_ORDER. Set stateful_rule_options.rule_order = "STRICT_ORDER" on every referenced group. STRICT_ORDER is viral.
  • stateful_default_actions rejected — it is only valid when stateful_engine_options.rule_order = "STRICT_ORDER". The module's validation catches this at plan time.
  • Cannot delete the firewalldelete_protection (or subnet/firewall_policy change protection) is on (this module defaults them to true). Set the relevant flag to false, apply, then destroy.
  • Destroy fails on the firewall subnet / a dangling route — the firewall must be destroyed before its subnets (endpoint ENIs), and the caller's aws_route entries pointing at the endpoints must be removed first. Order the teardown: routes → this module → firewall subnets.
  • AccessDeniedException at apply (validate passed) — the executing role is missing an action from the Required IAM Permissions table; grant it least-privilege, do not broaden to *.
  • KMS AccessDenied on a CMK-encrypted firewall — the key policy must grant network-firewall.amazonaws.com kms:GenerateDataKey / kms:Decrypt. Wire key_service_principals = ["network-firewall.amazonaws.com"] on terraform-aws-kms.
  • Cannot remove tls_inspection_configuration_arn — it is create-only and cannot be removed once set. Replacing the policy is the only path.
  • Tag drift on apply — a key set both in provider default_tags and var.tags shows the resource value winning in tags_all. Expected; align or drop the duplicate at the caller.

🔗 Related Docs

  • Terraform Registry — aws_networkfirewall_firewall, aws_networkfirewall_firewall_policy, aws_networkfirewall_rule_group, aws_networkfirewall_logging_configuration
  • AWS Network Firewall Developer Guide — Firewall subnets and route tables, Stateful rule evaluation order (default vs strict), Logging network traffic, Encryption at rest
  • AWS General Reference — AWS Network Firewall service quotas
  • Sibling modules — terraform-aws-vpc, terraform-aws-transit-gateway, terraform-aws-kms, terraform-aws-cloudwatch-log-group, terraform-aws-s3-bucket, terraform-aws-kinesis-firehose, terraform-aws-cloudtrail

🧡 "Infrastructure as Code should be standardized, consistent, and secure."