From 2e178531bcf876e54ab1ca860c6739c297e05c2e Mon Sep 17 00:00:00 2001 From: Josiel Souza Date: Thu, 26 Feb 2026 11:32:31 +0000 Subject: [PATCH] feat(iac): add Azure Bastion module with public IP and diagnostic settings Adds a new module for deploying an Azure Bastion host with a dedicated public IP address and diagnostic settings. Ensures consistent string interpolation in the diagnostic-settings module. Refs: DTOSS-12318 --- infrastructure/modules/bastion/README.md | 135 ++++++++++++++ infrastructure/modules/bastion/main.tf | 44 +++++ infrastructure/modules/bastion/outputs.tf | 19 ++ infrastructure/modules/bastion/tfdocs.md | 170 ++++++++++++++++++ infrastructure/modules/bastion/variables.tf | 114 ++++++++++++ .../modules/cdn-frontdoor-endpoint/tfdocs.md | 2 +- .../cdn-frontdoor-endpoint/variables.tf | 2 +- 7 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 infrastructure/modules/bastion/README.md create mode 100644 infrastructure/modules/bastion/main.tf create mode 100644 infrastructure/modules/bastion/outputs.tf create mode 100644 infrastructure/modules/bastion/tfdocs.md create mode 100644 infrastructure/modules/bastion/variables.tf diff --git a/infrastructure/modules/bastion/README.md b/infrastructure/modules/bastion/README.md new file mode 100644 index 00000000..959a1808 --- /dev/null +++ b/infrastructure/modules/bastion/README.md @@ -0,0 +1,135 @@ +# bastion + +Deploy an [Azure Bastion host](https://learn.microsoft.com/en-us/azure/bastion/bastion-overview) with a dedicated public IP address and diagnostic settings. Integrates with the [subnet module](../subnet/) and [log-analytics-workspace module](../log-analytics-workspace/). + +## Terraform documentation + +For the list of inputs, outputs, resources... check the [terraform module documentation](tfdocs.md). + +## Prerequisites + +Azure Bastion requires a dedicated subnet named exactly `AzureBastionSubnet` with a minimum `/26` prefix. Use the [subnet module](../subnet/) to create it with the required NSG rules: + +```hcl +module "subnet_bastion" { + source = "../subnet" + + name = "AzureBastionSubnet" + resource_group_name = azurerm_resource_group.main.name + vnet_name = module.vnet.name + address_prefixes = [cidrsubnet(var.vnet_address_space, 10, 0)] + location = var.region + create_nsg = true + network_security_group_name = "nsg-bastion-${var.env_config}-uks" + network_security_group_nsg_rules = local.bastion_nsg_rules + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_network_security_group_enabled_logs = [] +} +``` + +See [Azure Bastion NSG requirements](https://learn.microsoft.com/en-us/azure/bastion/bastion-nsg) for the required inbound and outbound rules. + +## Usage + +Deploy a Standard Bastion host with audit logging: + +```hcl +module "bastion" { + source = "../../../dtos-devops-templates/infrastructure/modules/bastion" + + name = "bas-myapp-dev-uks" + public_ip_name = "pip-bastion-myapp-dev-uks" + resource_group_name = azurerm_resource_group.main.name + location = "uksouth" + sku = "Standard" + subnet_id = module.subnet_bastion.id + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_bastion_enabled_logs = ["BastionAuditLogs"] + monitor_diagnostic_setting_bastion_metrics = ["AllMetrics"] +} +``` + +## SKU selection + +| SKU | Scale units | Native client | File copy | IP connect | Session recording | Public IP required | +|-----|-------------|---------------|-----------|------------|-------------------|--------------------| +| Basic | Fixed (2) | No | No | No | No | Yes | +| Standard | 2–50 | Yes | Yes | Yes | No | Yes | +| Premium | 2–50 | Yes | Yes | Yes | Yes | Yes | + +- **Standard** is recommended for most production workloads. It supports native client connections, host scaling, and all advanced features. +- **Premium** adds session recording for compliance requirements and a private-only deployment option (no public IP). +- **Basic** provides a fixed-capacity dedicated deployment at lower cost. Advanced features are not available. +- **Developer** SKU is not supported by this module — it uses shared infrastructure and does not require a public IP or dedicated subnet. + +## Zone redundancy (recommended for production) + +By default the public IP is deployed with no availability zone pinning. For production, pass `zones = ["1", "2", "3"]` to make the public IP zone-redundant: + +```hcl +module "bastion" { + ... + zones = ["1", "2", "3"] +} +``` + +Zone-redundant public IPs are available in UK South at no additional cost. See [Reliability in Azure Bastion](https://learn.microsoft.com/en-us/azure/reliability/reliability-bastion) for supported regions. + +## Standard/Premium features + +The following features are disabled by default and require Standard SKU or higher: + +**Native client / tunneling** — enables SSH and RDP connections via the Azure CLI (`az network bastion ssh`, `az network bastion rdp`) instead of the browser-based client. Recommended for operations teams: + +```hcl +module "bastion" { + ... + tunneling_enabled = true +} +``` + +**IP connect** — allows connecting to VMs using their private IP address rather than requiring them to be in the same virtual network: + +```hcl +module "bastion" { + ... + ip_connect_enabled = true +} +``` + +**File copy** — enables file transfer between the local machine and target VMs via native client connections: + +```hcl +module "bastion" { + ... + file_copy_enabled = true + tunneling_enabled = true # file copy requires native client +} +``` + +## Scaling + +Each scale unit supports approximately 20 concurrent RDP sessions or 40 concurrent SSH sessions. The default of 2 units (40 RDP / 80 SSH) is sufficient for most environments. Increase `scale_units` for larger deployments: + +```hcl +module "bastion" { + ... + scale_units = 5 # ~100 concurrent RDP / ~200 concurrent SSH +} +``` + +`scale_units` is only configurable for Standard and Premium SKUs. Basic is fixed at 2 units. + +## Diagnostic logs + +The module creates a diagnostic setting targeting the provided Log Analytics workspace. The only available log category for Azure Bastion is `BastionAuditLogs`, which records user connections, source IPs, session metadata, and audit trail information: + +```hcl +module "bastion" { + ... + monitor_diagnostic_setting_bastion_enabled_logs = ["BastionAuditLogs"] + monitor_diagnostic_setting_bastion_metrics = ["AllMetrics"] +} +``` diff --git a/infrastructure/modules/bastion/main.tf b/infrastructure/modules/bastion/main.tf new file mode 100644 index 00000000..00b163cc --- /dev/null +++ b/infrastructure/modules/bastion/main.tf @@ -0,0 +1,44 @@ +module "pip" { + source = "../public-ip" + + name = var.public_ip_name + resource_group_name = var.resource_group_name + location = var.location + allocation_method = "Static" + sku = "Standard" + zones = var.zones + + tags = var.tags +} + +resource "azurerm_bastion_host" "bastion" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + sku = var.sku + + copy_paste_enabled = var.copy_paste_enabled + file_copy_enabled = var.file_copy_enabled + ip_connect_enabled = var.ip_connect_enabled + tunneling_enabled = var.tunneling_enabled + shareable_link_enabled = var.shareable_link_enabled + scale_units = var.scale_units + + ip_configuration { + name = "configuration" + subnet_id = var.subnet_id + public_ip_address_id = module.pip.id + } + + tags = var.tags +} + +module "diagnostic_settings" { + source = "../diagnostic-settings" + + name = "${var.name}-diagnostic-setting" + target_resource_id = azurerm_bastion_host.bastion.id + log_analytics_workspace_id = var.log_analytics_workspace_id + enabled_log = var.monitor_diagnostic_setting_bastion_enabled_logs + enabled_metric = var.monitor_diagnostic_setting_bastion_metrics +} diff --git a/infrastructure/modules/bastion/outputs.tf b/infrastructure/modules/bastion/outputs.tf new file mode 100644 index 00000000..87538dfc --- /dev/null +++ b/infrastructure/modules/bastion/outputs.tf @@ -0,0 +1,19 @@ +output "id" { + description = "The ID of the Bastion host." + value = azurerm_bastion_host.bastion.id +} + +output "name" { + description = "The name of the Bastion host." + value = azurerm_bastion_host.bastion.name +} + +output "dns_name" { + description = "The FQDN of the Bastion host." + value = azurerm_bastion_host.bastion.dns_name +} + +output "public_ip_address" { + description = "The public IP address associated with the Bastion host." + value = module.pip.ip_address +} diff --git a/infrastructure/modules/bastion/tfdocs.md b/infrastructure/modules/bastion/tfdocs.md new file mode 100644 index 00000000..a995f598 --- /dev/null +++ b/infrastructure/modules/bastion/tfdocs.md @@ -0,0 +1,170 @@ +# Module documentation + +## Required Inputs + +The following input variables are required: + +### [log\_analytics\_workspace\_id](#input\_log\_analytics\_workspace\_id) + +Description: The ID of the Log Analytics workspace to send diagnostic logs to. + +Type: `string` + +### [monitor\_diagnostic\_setting\_bastion\_enabled\_logs](#input\_monitor\_diagnostic\_setting\_bastion\_enabled\_logs) + +Description: List of log categories to enable for the Bastion diagnostic setting (e.g. ["BastionAuditLogs"]). + +Type: `list(string)` + +### [monitor\_diagnostic\_setting\_bastion\_metrics](#input\_monitor\_diagnostic\_setting\_bastion\_metrics) + +Description: List of metric categories to enable for the Bastion diagnostic setting (e.g. ["AllMetrics"]). + +Type: `list(string)` + +### [name](#input\_name) + +Description: The name of the Azure Bastion host. + +Type: `string` + +### [public\_ip\_name](#input\_public\_ip\_name) + +Description: The name of the public IP address resource created for the Bastion host. + +Type: `string` + +### [resource\_group\_name](#input\_resource\_group\_name) + +Description: The name of the resource group in which to create the Bastion host. + +Type: `string` + +### [subnet\_id](#input\_subnet\_id) + +Description: The ID of the AzureBastionSubnet in which the Bastion host will be deployed. + +Type: `string` + +## Optional Inputs + +The following input variables are optional (have default values): + +### [copy\_paste\_enabled](#input\_copy\_paste\_enabled) + +Description: Is copy/paste feature enabled for the Bastion host. + +Type: `bool` + +Default: `true` + +### [file\_copy\_enabled](#input\_file\_copy\_enabled) + +Description: Is file copy feature enabled for the Bastion host. Requires Standard SKU or higher. + +Type: `bool` + +Default: `false` + +### [ip\_connect\_enabled](#input\_ip\_connect\_enabled) + +Description: Is IP connect feature enabled for the Bastion host. Requires Standard SKU or higher. + +Type: `bool` + +Default: `false` + +### [location](#input\_location) + +Description: The location/region where the Bastion host will be created. + +Type: `string` + +Default: `"uksouth"` + +### [scale\_units](#input\_scale\_units) + +Description: The number of scale units for the Bastion host. Each unit supports ~20 concurrent RDP / ~40 concurrent SSH sessions. Must be between 2 and 50 for Standard/Premium SKU; Basic is fixed at 2. + +Type: `number` + +Default: `2` + +### [shareable\_link\_enabled](#input\_shareable\_link\_enabled) + +Description: Is shareable link feature enabled for the Bastion host. Requires Standard SKU or higher. + +Type: `bool` + +Default: `false` + +### [sku](#input\_sku) + +Description: The SKU tier of the Bastion host. Possible values are Basic, Standard, and Premium. Standard is recommended for most production workloads; Premium adds session recording and private-only deployment. Developer SKU is not supported by this module as it does not use a public IP or dedicated subnet. + +Type: `string` + +Default: `"Standard"` + +### [tags](#input\_tags) + +Description: A mapping of tags to assign to the resource. + +Type: `map(string)` + +Default: `{}` + +### [tunneling\_enabled](#input\_tunneling\_enabled) + +Description: Is tunneling (native client support) feature enabled for the Bastion host. Enables native SSH/RDP client connections via az network bastion ssh/rdp. Recommended for Standard SKU or higher. + +Type: `bool` + +Default: `false` + +### [zones](#input\_zones) + +Description: Availability zones for the public IP address. Use ["1", "2", "3"] for zone-redundant deployment, which is recommended for production environments. An empty list deploys with no zone redundancy. + +Type: `list(string)` + +Default: `[]` +## Modules + +The following Modules are called: + +### [diagnostic\_settings](#module\_diagnostic\_settings) + +Source: ../diagnostic-settings + +Version: + +### [pip](#module\_pip) + +Source: ../public-ip + +Version: +## Outputs + +The following outputs are exported: + +### [dns\_name](#output\_dns\_name) + +Description: The FQDN of the Bastion host. + +### [id](#output\_id) + +Description: The ID of the Bastion host. + +### [name](#output\_name) + +Description: The name of the Bastion host. + +### [public\_ip\_address](#output\_public\_ip\_address) + +Description: The public IP address associated with the Bastion host. +## Resources + +The following resources are used by this module: + +- [azurerm_bastion_host.bastion](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/bastion_host) (resource) diff --git a/infrastructure/modules/bastion/variables.tf b/infrastructure/modules/bastion/variables.tf new file mode 100644 index 00000000..d57f59a2 --- /dev/null +++ b/infrastructure/modules/bastion/variables.tf @@ -0,0 +1,114 @@ +variable "name" { + description = "The name of the Azure Bastion host." + type = string + validation { + condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9.-]{0,78}[a-zA-Z0-9]$", var.name)) + error_message = "The Bastion host name must be between 2 and 80 characters, start and end with an alphanumeric character, and can contain alphanumeric characters, hyphens, and periods." + } +} + +variable "resource_group_name" { + description = "The name of the resource group in which to create the Bastion host." + type = string +} + +variable "location" { + description = "The location/region where the Bastion host will be created." + type = string + default = "uksouth" + validation { + condition = contains(["uksouth", "ukwest"], var.location) + error_message = "The location must be either uksouth or ukwest." + } +} + +variable "sku" { + description = "The SKU tier of the Bastion host. Possible values are Basic, Standard, and Premium. Standard is recommended for most production workloads; Premium adds session recording and private-only deployment. Developer SKU is not supported by this module as it does not use a public IP or dedicated subnet." + type = string + default = "Standard" + validation { + condition = contains(["Basic", "Standard", "Premium"], var.sku) + error_message = "The SKU must be one of: Basic, Standard, Premium. Developer SKU is not supported by this module." + } +} + +variable "subnet_id" { + description = "The ID of the AzureBastionSubnet in which the Bastion host will be deployed." + type = string +} + +variable "public_ip_name" { + description = "The name of the public IP address resource created for the Bastion host." + type = string +} + +variable "copy_paste_enabled" { + description = "Is copy/paste feature enabled for the Bastion host." + type = bool + default = true +} + +variable "file_copy_enabled" { + description = "Is file copy feature enabled for the Bastion host. Requires Standard SKU or higher." + type = bool + default = false +} + +variable "ip_connect_enabled" { + description = "Is IP connect feature enabled for the Bastion host. Requires Standard SKU or higher." + type = bool + default = false +} + +variable "tunneling_enabled" { + description = "Is tunneling (native client support) feature enabled for the Bastion host. Enables native SSH/RDP client connections via az network bastion ssh/rdp. Recommended for Standard SKU or higher." + type = bool + default = false +} + +variable "shareable_link_enabled" { + description = "Is shareable link feature enabled for the Bastion host. Requires Standard SKU or higher." + type = bool + default = false +} + +variable "scale_units" { + description = "The number of scale units for the Bastion host. Each unit supports ~20 concurrent RDP / ~40 concurrent SSH sessions. Must be between 2 and 50 for Standard/Premium SKU; Basic is fixed at 2." + type = number + default = 2 + validation { + condition = var.scale_units >= 2 && var.scale_units <= 50 + error_message = "scale_units must be between 2 and 50." + } +} + +variable "zones" { + description = "Availability zones for the public IP address. Use [\"1\", \"2\", \"3\"] for zone-redundant deployment, which is recommended for production environments. An empty list deploys with no zone redundancy." + type = list(string) + default = [] + validation { + condition = length(var.zones) <= 3 + error_message = "A maximum of 3 availability zones can be specified." + } +} + +variable "log_analytics_workspace_id" { + description = "The ID of the Log Analytics workspace to send diagnostic logs to." + type = string +} + +variable "monitor_diagnostic_setting_bastion_enabled_logs" { + description = "List of log categories to enable for the Bastion diagnostic setting (e.g. [\"BastionAuditLogs\"])." + type = list(string) +} + +variable "monitor_diagnostic_setting_bastion_metrics" { + description = "List of metric categories to enable for the Bastion diagnostic setting (e.g. [\"AllMetrics\"])." + type = list(string) +} + +variable "tags" { + description = "A mapping of tags to assign to the resource." + type = map(string) + default = {} +} diff --git a/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md b/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md index eaaa13a0..4ea695b0 100644 --- a/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md +++ b/infrastructure/modules/cdn-frontdoor-endpoint/tfdocs.md @@ -129,7 +129,7 @@ Type: ```hcl map(object({ - associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain + associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain cdn_frontdoor_firewall_policy_id = optional(string, null) # Pass ID directly to avoid data source lookup when policy is created in the same apply cdn_frontdoor_firewall_policy_name = optional(string, null) cdn_frontdoor_firewall_policy_rg_name = optional(string, null) diff --git a/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf b/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf index 67317fde..d49208bc 100644 --- a/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf +++ b/infrastructure/modules/cdn-frontdoor-endpoint/variables.tf @@ -89,7 +89,7 @@ variable "route" { variable "security_policies" { description = "Optional map of security policies to apply. Each must include the WAF policy and domain associations" type = map(object({ - associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain + associated_domain_keys = list(string) # From var.custom_domains above, use "endpoint" for the default domain cdn_frontdoor_firewall_policy_id = optional(string, null) # Pass ID directly to avoid data source lookup when policy is created in the same apply cdn_frontdoor_firewall_policy_name = optional(string, null) cdn_frontdoor_firewall_policy_rg_name = optional(string, null)