AKSのAGICでWAFを使いたい

Azure Kubernetes Service(AKS)では、イングレスコントローラとしてNGINXイングレスコントローラベースのアプリケーションルーティングとAzure Application Gateway(AppGW)をインターフェースに使用できるApplication Gateway Ingress Controller(AGIC)が利用できます。

AppGWといえばL7ロードバランサであるとともにWeb Application Firewall(WAF)機能を搭載することができます。

セキュリティめちゃツヨAGICにするならAppGWにWAFを乗せたいところ。

普通にAKSをセットアップするとAppGWはStandard V2のSKUでセットアップされてしまいます。そこで、WAF v2のSKUでAppGWを構築しつつAKSと接続してセキュリティめちゃ強いAGICを利用できるようにします。

楽ちんに構築したいので、IaCツール [OpenTofu](https://opentofu.org/) を利用してセットアップしてみます。
OpenTofuはTerraformのOSSフォークです。今のところ大きな差はないのでTerraformでも同様のコードで動作できると思います。

## TL;DR

-   AppGWとAKSは別々に作成して、紐づける形で連携させる
-   AGICのためのマネージドIDが生成されるので、ロール紐づけが必要
    -   AppGWが属するリソースグループに対する `Reader`ロール
    -   AppGWに対する　`Contributer`ロール
    -   AppGWが使用するサブネットに対する `Network Contributer` ロール
    -   AppGWに設定しうるWAFポリシーに対する `Network Contributer` ロール

## バージョン情報

| ツール・プロバイダ | バージョン |
| :----------------: | :--------: |
|      OpenTofu      |   1.9.0    |
| hashicorp/azurerm  |   4.25.0   |

## 構築コード

早速コードです。ちょっと長いので折りたたみで。

:::note warn
色々と誤魔化したり調整しているので、サンプルコードの動作保証はしていないです。
:::

<details><summary>サンプルコード</summary>

```terraform:main.tf
terraform {
  required_version = ">=1.9"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "4.25.0"
    }
  }
}

# 引数定義
variable "tenant_id" {
  type        = string
  description = "テナントID"
}

variable "identity" {
  type        = string
  description = "リソースのID"
}

variable "location" {
  type        = string
  description = "リソースのロケーション"
}

variable "domain" {
  type        = string
  description = "使用するドメイン"
}

variable "azrbac_admin_group_object_ids" {
  type        = list(string)
  description = "AKS RBACで管理ロールとするEntra IDのオブジェクトIDリスト"
}


# リソース定義

## リソースグループ
resource "azurerm_resource_group" "rg" {
  name     = "rg-${var.identity}"
  location = var.location
}

## 仮想ネットワーク
locals {
  address_space_ipv4 = "172.16.0.0/12"
}
resource "azurerm_virtual_network" "vn" {
  name                = "vn-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  address_space = [
    local.address_space_ipv4,
  ]
}

resource "azurerm_subnet" "sub_default" {
  name                 = "vn-${var.identity}-default"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes     = [cidrsubnet(local.address_space_ipv4, 3, 0)]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_subnet" "sub_appgw" {
  name                 = "vn-${var.identity}-appgw"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes = [
    cidrsubnet(local.address_space_ipv4, 3, 1),
  ]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_subnet" "sub_aks_cls" {
  name                 = "vn-${var.identity}-aks-cls"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vn.name
  address_prefixes     = [
    cidrsubnet(local.address_space_ipv4, 3, 2),
  ]

  service_endpoints                 = ["Microsoft.Storage"]
  private_endpoint_network_policies = "Enabled"

  depends_on = [azurerm_virtual_network.vn]
}

## IPアドレス
resource "azurerm_public_ip" "ipv4" {
  name                = "publicip-v4-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  sku                 = "Standard"
  sku_tier            = "Regional"
  allocation_method   = "Static"
  ip_version          = "IPv4"

  depends_on = [azurerm_virtual_network.vn]
}

resource "azurerm_dns_zone" "public_dns" {
  name                = var.domain
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_dns_a_record" "default" {
  name                = "@"
  resource_group_name = azurerm_resource_group.rg.name

  zone_name = azurerm_dns_zone.public_dns.name
  ttl       = 600
  records   = [azurerm_public_ip.ipv4.ip_address]

  depends_on = [
    azurerm_dns_zone.public_dns,
    azurerm_public_ip.ipv4,
  ]
}

## WAFポリシー
resource "azurerm_web_application_firewall_policy" "default" {
  name                = "waf-custom-rules-${var.identity}-default"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  policy_settings {
    enabled = true
    # mode    = "Prevention"
    mode = "Detection"
  }

  managed_rules {
    managed_rule_set {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
    }
  }

  custom_rules {
    name      = "AcmeHttp01Challenge"
    priority  = 10
    action    = "Allow"
    rule_type = "MatchRule"

    match_conditions {
      operator   = "BeginsWith"
      transforms = ["Trim", "Lowercase", "RemoveNulls", "UrlDecode"]

      match_variables {
        variable_name = "RequestUri"
      }

      match_values = [
        "/.well-known/acme-challenge/",
      ]
    }
  }
}

## Application Gateway
locals {
  backend_address_pool_name      = "${var.identity}-beap"
  frontend_port_name_http        = "${var.identity}-feport-http"
  frontend_port_name_https       = "${var.identity}-feport-https"
  frontend_ip_configuration_name = "${var.identity}-feip"
  http_setting_name              = "${var.identity}-be-htst"
  listener_name                  = "${var.identity}-httplstn"
  request_routing_rule_name      = "${var.identity}-rqrt"
}

resource "azurerm_application_gateway" "appgw_waf" {
  name                = "appgw-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  sku {
    name     = "WAF_v2"
    tier     = "WAF_v2"
    capacity = 1
  }

  enable_http2                      = false
  fips_enabled                      = false
  force_firewall_policy_association = false

  waf_configuration {
    enabled       = true
    firewall_mode = "Prevention"

    # 本当はMicrosoft_DefaultRuleSetにしたいがAPIがエラーするのでOWASPを使用
    rule_set_type    = "OWASP"
    rule_set_version = "3.2"

    file_upload_limit_mb = "500"
    request_body_check   = false
  }

  gateway_ip_configuration {
    name      = "appgw-ip-conf"
    subnet_id = azurerm_subnet.sub_appgw.id
  }

  frontend_port {
    name = local.frontend_port_name_http
    port = 80
  }

  frontend_port {
    name = local.frontend_port_name_https
    port = 443
  }

  frontend_ip_configuration {
    name                 = local.frontend_ip_configuration_name
    public_ip_address_id = azurerm_public_ip.ipv4.id
  }

  backend_address_pool {
    name = local.backend_address_pool_name
  }

  backend_http_settings {
    name                                = local.http_setting_name
    cookie_based_affinity               = "Disabled"
    port                                = 80
    protocol                            = "Http"
    request_timeout                     = 30
    pick_host_name_from_backend_address = false
  }

  http_listener {
    name                           = local.listener_name
    frontend_ip_configuration_name = local.frontend_ip_configuration_name
    frontend_port_name             = local.frontend_port_name_http
    protocol                       = "Http"
    require_sni                    = false

    firewall_policy_id = azurerm_web_application_firewall_policy.default.id
  }

  request_routing_rule {
    name                       = local.request_routing_rule_name
    rule_type                  = "Basic"
    http_listener_name         = local.listener_name
    backend_address_pool_name  = local.backend_address_pool_name
    backend_http_settings_name = local.http_setting_name

    priority = 10010
  }


  lifecycle {
    # デプロイ後はAKS AGICで変更されるのですべて無視
    ignore_changes = all
  }
}

resource "azurerm_kubernetes_cluster" "aks" {
  name                = "aks-${var.identity}"
  resource_group_name = azurerm_resource_group.name
  location            = azurerm_resource_group.rg.location

  sku_tier = "Standard"

  dns_prefix                = "aks-${var.identity}"
  automatic_upgrade_channel = "patch"
  kubernetes_version        = "1.30"
  private_cluster_enabled   = false

  cost_analysis_enabled = "true"


  oidc_issuer_enabled       = true
  workload_identity_enabled = true

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled     = true
    tenant_id              = var.tenant_id
    admin_group_object_ids = var.azrbac_admin_group_object_ids
  }

  ingress_application_gateway {
    gateway_id = azurerm_application_gateway.appgw_waf.id
  }

  network_profile {
    network_plugin    = "azure"
    network_policy    = "calico"
    outbound_type     = "loadBalancer"
    load_balancer_sku = "standard"
  }

  workload_autoscaler_profile {
    keda_enabled = true
  }

  default_node_pool {
    name                        = "default"
    vm_size                     = "Standard_D4as_v5"
    os_sku                      = "AzureLinux"
    temporary_name_for_rotation = "deftmp"

    type                 = "VirtualMachineScaleSets"
    auto_scaling_enabled = true
    node_count           = 1
    min_count            = 1
    max_count            = 10
    max_pods             = 100

    vnet_subnet_id = azurerm_subnet.sub_aks_cls.id
    zones = [
      "1",
      "2",
      "3",
    ]
  }


  lifecycle {
    ignore_changes = [
      default_node_pool[0].node_count
    ]
  }
}

resource "azurerm_role_assignment" "k8s_rg" {
  scope                = var.resource_group_id
  principal_id         = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id
  role_definition_name = "Reader"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
resource "azurerm_role_assignment" "k8s_gateway" {
  scope        = var.appgw_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_gateway_network" {
  scope        = var.appgw_subnet_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_waf_policy_default" {
  scope        = var.waf_policy_default_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
```

</details>

## Azure Application Gatewayの構成

OpenTofuでAzure Application Gateway WAF v2を構成するためには2つのリソースが必要です。

-   azurerm_web_application_firewall_policy
-   azurerm_application_gateway

WAF v2でゲートウェイを構成する場合は1つ以上の`http_listener`で`firewall_policy_id`の指定が必要なので何らかのWAFポリシーの作成が必要です。

また、AGICの制御により**リスナー〜バックエンドの設定はすべて書き換わってしまいます** が、定義上必須なので、ダミーの`backend_address_pool` `backend_http_settings` `http_listener` `request_routing_rule` を設定します。
当然、`lifecycle.ignore_changes`の設定も必須です。

```terraform; main.tf
resource "azurerm_web_application_firewall_policy" "default" {
  name                = "waf-custom-rules-${var.identity}-default"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  policy_settings {
    enabled = true
    # mode    = "Prevention"
    mode = "Detection"
  }

  managed_rules {
    managed_rule_set {
      type    = "Microsoft_DefaultRuleSet"
      version = "2.1"
    }
  }

  custom_rules {
    name      = "AcmeHttp01Challenge"
    priority  = 10
    action    = "Allow"
    rule_type = "MatchRule"

    match_conditions {
      operator   = "BeginsWith"
      transforms = ["Trim", "Lowercase", "RemoveNulls", "UrlDecode"]

      match_variables {
        variable_name = "RequestUri"
      }

      match_values = [
        "/.well-known/acme-challenge/",
      ]
    }
  }
}
```

サンプルコードではマネージドルールセットとしてMDR 2.1を指定し、カスタムルールとして、ACMEにおけるHTTP01方式のアクセス許可を設定しています。

```terraform; main.tf
locals {
  backend_address_pool_name      = "${var.identity}-beap"
  frontend_port_name_http        = "${var.identity}-feport-http"
  frontend_port_name_https       = "${var.identity}-feport-https"
  frontend_ip_configuration_name = "${var.identity}-feip"
  http_setting_name              = "${var.identity}-be-htst"
  listener_name                  = "${var.identity}-httplstn"
  request_routing_rule_name      = "${var.identity}-rqrt"
}

resource "azurerm_application_gateway" "appgw_waf" {
  name                = "appgw-${var.identity}"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location

  sku {
    name     = "WAF_v2"
    tier     = "WAF_v2"
    capacity = 1
  }

  enable_http2                      = false
  fips_enabled                      = false
  force_firewall_policy_association = false

  waf_configuration {
    enabled       = true
    firewall_mode = "Prevention"

    # 本当はMicrosoft_DefaultRuleSetにしたいがAPIがエラーするのでOWASPを使用
    rule_set_type    = "OWASP"
    rule_set_version = "3.2"

    file_upload_limit_mb = "500"
    request_body_check   = false
  }

  gateway_ip_configuration {
    name      = "appgw-ip-conf"
    subnet_id = azurerm_subnet.sub_appgw.id
  }

  frontend_port {
    name = local.frontend_port_name_http
    port = 80
  }

  frontend_port {
    name = local.frontend_port_name_https
    port = 443
  }

  frontend_ip_configuration {
    name                 = local.frontend_ip_configuration_name
    public_ip_address_id = azurerm_public_ip.ipv4.id
  }

  backend_address_pool {
    name = local.backend_address_pool_name
  }

  backend_http_settings {
    name                                = local.http_setting_name
    cookie_based_affinity               = "Disabled"
    port                                = 80
    protocol                            = "Http"
    request_timeout                     = 30
    pick_host_name_from_backend_address = false
  }

  http_listener {
    name                           = local.listener_name
    frontend_ip_configuration_name = local.frontend_ip_configuration_name
    frontend_port_name             = local.frontend_port_name_http
    protocol                       = "Http"
    require_sni                    = false

    firewall_policy_id = azurerm_web_application_firewall_policy.default.id
  }

  request_routing_rule {
    name                       = local.request_routing_rule_name
    rule_type                  = "Basic"
    http_listener_name         = local.listener_name
    backend_address_pool_name  = local.backend_address_pool_name
    backend_http_settings_name = local.http_setting_name

    priority = 10010
  }


  lifecycle {
    # デプロイ後はAKS AGICで変更されるのですべて無視
    ignore_changes = all
  }
}
```

SKUは当然`WAF_v2`です。
全体のWAFルールはMDR 2.1にしたかったのですが、どうもARM APIがエラーしてしまうので、OWASP 3.2にしています。
`gateway_ip_configuration`ではあらかじめ構成したAppGW向けサブネットを紐づけます。`frontend_port`では80と443を紐づけます。`frontend_ip_configuration`でパブリックIPを紐づけます。
`backend_address_pool`は名前だけ定義しておきます。
`backend_address_pool` `backend_http_settings` `http_listener` `request_routing_rule`はどうせAGICによって書き換えが発生するのでそれっぽければOKです。ただし、`http_listener`に限っては`firewall_policy_id`の設定が必要です。これがないと「WAFv2のSKUにできないよ！」と怒られます。
そして、`ignore_changes`で無視設定をちゃんと入れないと、AGICによる変更を壊してしまいますので設定必須です。どこが書き換わるかわからなければ`all`を設定しておきましょう。

### Azure Kubernetes Serviceの構成

```terraform: main.tf
resource "azurerm_kubernetes_cluster" "aks" {
  name                = "aks-${var.identity}"
  resource_group_name = azurerm_resource_group.name
  location            = azurerm_resource_group.rg.location

  sku_tier = "Standard"

  dns_prefix                = "aks-${var.identity}"
  automatic_upgrade_channel = "patch"
  kubernetes_version        = "1.30"
  private_cluster_enabled   = false

  cost_analysis_enabled = "true"


  oidc_issuer_enabled       = true
  workload_identity_enabled = true

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled     = true
    tenant_id              = var.tenant_id
    admin_group_object_ids = var.azrbac_admin_group_object_ids
  }

  ingress_application_gateway {
    gateway_id = azurerm_application_gateway.appgw_waf.id
  }

  network_profile {
    network_plugin    = "azure"
    network_policy    = "calico"
    outbound_type     = "loadBalancer"
    load_balancer_sku = "standard"
  }

  workload_autoscaler_profile {
    keda_enabled = true
  }

  default_node_pool {
    name                        = "default"
    vm_size                     = "Standard_D4as_v5"
    os_sku                      = "AzureLinux"
    temporary_name_for_rotation = "deftmp"

    type                 = "VirtualMachineScaleSets"
    auto_scaling_enabled = true
    node_count           = 1
    min_count            = 1
    max_count            = 10
    max_pods             = 100

    vnet_subnet_id = azurerm_subnet.sub_aks_cls.id
    zones = [
      "1",
      "2",
      "3",
    ]
  }


  lifecycle {
    ignore_changes = [
      default_node_pool[0].node_count
    ]
  }
}

resource "azurerm_role_assignment" "k8s_rg" {
  scope                = var.resource_group_id
  principal_id         = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id
  role_definition_name = "Reader"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
resource "azurerm_role_assignment" "k8s_gateway" {
  scope        = var.appgw_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_gateway_network" {
  scope        = var.appgw_subnet_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}

resource "azurerm_role_assignment" "k8s_waf_policy_default" {
  scope        = var.waf_policy_default_id
  principal_id = azurerm_kubernetes_cluster.aks.ingress_application_gateway[0].ingress_application_gateway_identity[0].object_id

  role_definition_name = "Network Contributor"

  depends_on = [azurerm_kubernetes_cluster.aks]
}
```

`ingress_application_gateway.gateway_id` に構成したAppGWのIDを設定することで既存AppGWとの接続が可能です。

**しかし！**

紐づけただけでは全く動作しません。なぜかというと、権限が噛み合っていないからです。
AKSを構成したあとにAzure Portalを眺めるとわかるのですが、AKSのサービス自体は構成したリソースグループにいるのですが、`MC_<aksリソースグループ名>_<AKS名>_<リージョン>`といった名前のリソースグループが生えているかと思います。これはノードリソースグループといい、AKSを構成するコンポーネント(Azure VM Scale Setなど)が入っています。
このリソースグループの中にマネージドIDが生成されているのですが、これがAGICの制御に使用するマネージドIDです。
このマネージドIDがどれなのかというと、`azurerm_kubernetes_cluster.<リソース名>.ingress_application_gateway[0].ingress_application_gateway_identity[0]` で参照することができます。
このIDと各コンポーネントのロールを紐づける必要があります。

AGICを動作させるためには、以下のロール紐づけが必要です

|      対象リソース       |     必要なロール      | コード上での構成箇所                             |
| :---------------------: | :-------------------: | :----------------------------------------------- |
| AppGW所属リソースグループ |      `Reader`　       | `azurerm_role_assignment.k8s_rg`                 |
|          AppGW          |     `Contributor`     | `azurerm_role_assignment.k8s_gateway`            |
|  AppGW使用のサブネット  | `Network Contributor` | `azurerm_role_assignment.k8s_gateway_network`    |
|       WAFポリシー       | `Network Contributor` | `azurerm_role_assignment.k8s_waf_policy_default` |

ノードリソースグループ所属のマネージドIDなので、AppGWが所属しているリソースグループに対する閲覧者ロールが必要です。
関連コンポーネント（AppGW, サブネット、WAFポリシー）についてはくっつけたり外したりをするためのロールが必要です。

## 最後に

オフィシャルのドキュメントにはAKSとAppGWを別に作成して紐づける手順は記載されているののですが、ロール紐づけに関するところは以下の文言だけ……

> AKS クラスター リソース グループとは異なるリソース グループのアプリケーション ゲートウェイを使う場合は、作成されるマネージド ID ingressapplicationgateway-{AKSNAME} に、アプリケーション ゲートウェイ リソース グループでのネットワーク共同作成者と閲覧者ロールを設定する必要があります。

これはわからないべ……

-   [チュートリアル: 既存の Application Gateway を使用して既存の AKS クラスターで Application Gateway イングレス コントローラー アドオンを有効にする - Microsoft Azure](https://learn.microsoft.com/ja-jp/azure/application-gateway/tutorial-ingress-controller-add-on-existing)