# WAF on both load balancers.
#
# Division of labour, and it is worth being precise because both layers exist on purpose. WAF
# handles IP-level floods and pre-auth abuse, which the application cannot: by the time a request
# reaches the server it has already cost a connection and a validation round trip. Per-tenant
# fairness, daily quotas, and the agent-readable 429 choreography stay in application code, which
# WAF cannot do because it has no idea which API key a request carries.
#
# The exclusions matter more than the inclusions here.

locals {
  waf_rules = {
    # Core protections, with one rule turned down.
    common = {
      priority = 10
      name     = "AWSManagedRulesCommonRuleSet"
      # SizeRestrictions_BODY counts rather than blocks: this is a database MCP server and a long
      # SQL statement in a request body is normal traffic, not an attack.
      count_rules = ["SizeRestrictions_BODY", "NoUserAgent_HEADER"]
    }
    bad_inputs = {
      priority    = 20
      name        = "AWSManagedRulesKnownBadInputsRuleSet"
      count_rules = []
    }
    reputation = {
      priority    = 30
      name        = "AWSManagedRulesAmazonIpReputationList"
      count_rules = []
    }
  }
}

# NoUserAgent_HEADER starts in COUNT, not BLOCK.
#
# Agent clients frequently omit User-Agent, and this service exists to be called by agents. Blocking
# on a missing User-Agent would refuse exactly the traffic the product is for. Left in count mode so
# the data exists if it ever looks like abuse.

# The SQLi rule set is DELIBERATELY ABSENT.
#
# Not an oversight and not a risk accepted quietly: this server carries SQL in request bodies as its
# normal function, and AWSManagedRulesSQLiRuleSet would match legitimate `tidb_execute` calls. The
# protection against SQL injection here is architectural rather than pattern-based: identifiers are
# backtick-escaped (property-tested), values are bound parameters, multi-statement execution is off
# at the driver, and data tools run as ephemeral least-privilege SQL users whose grants, not string
# inspection, decide what a query may do.
#
# The Linux and PHP rule sets are absent for the ordinary reason: this is a Node container.

resource "aws_wafv2_web_acl" "this" {
  for_each = toset(["official", "poc"])

  name  = "mcp-${each.key}"
  scope = "REGIONAL"

  default_action {
    allow {}
  }

  dynamic "rule" {
    for_each = local.waf_rules
    content {
      name     = rule.value.name
      priority = rule.value.priority

      override_action {
        none {}
      }

      statement {
        managed_rule_group_statement {
          vendor_name = "AWS"
          name        = rule.value.name

          dynamic "rule_action_override" {
            for_each = rule.value.count_rules
            content {
              name = rule_action_override.value
              action_to_use {
                count {}
              }
            }
          }
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = true
        metric_name                = "mcp-${each.key}-${rule.value.name}"
        sampled_requests_enabled   = true
      }
    }
  }

  # A blunt ceiling on requests per source address, well above anything a real client does and well
  # below what a flood does. The application's own limiter is the one that knows about tenants; this
  # one exists to stop traffic before it costs us a task.
  rule {
    name     = "rate-limit-per-ip"
    priority = 100

    action {
      block {}
    }

    statement {
      rate_based_statement {
        limit              = 2000 # per 5-minute window, per source IP
        aggregate_key_type = "IP"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "mcp-${each.key}-rate-limit"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "mcp-${each.key}"
    sampled_requests_enabled   = true
  }

  tags = { Name = "mcp-${each.key}" }
}

resource "aws_wafv2_web_acl_association" "official" {
  resource_arn = aws_lb.official.arn
  web_acl_arn  = aws_wafv2_web_acl.this["official"].arn
}

resource "aws_wafv2_web_acl_association" "poc" {
  resource_arn = aws_lb.poc.arn
  web_acl_arn  = aws_wafv2_web_acl.this["poc"].arn
}
