diff --git a/quickstart/101-standard-service-endpoint-storage/main.tf b/quickstart/101-standard-service-endpoint-storage/main.tf new file mode 100644 index 000000000..920323312 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/main.tf @@ -0,0 +1,211 @@ +# Create a random name for the resource group using random_pet +resource "random_pet" "rg_name" { + prefix = var.resource_group_name_prefix +} + +# Create the resource group using the generated random name +resource "azurerm_resource_group" "rg" { + location = var.resource_group_location + name = random_pet.rg_name.id +} + +# Create the virtual network +resource "azurerm_virtual_network" "vnet" { + name = "vnet-1" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name +} + +# Create the public IP address for the NAT gateway +resource "azurerm_public_ip" "nat" { + name = "public-ip-nat" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + sku = "Standard" +} + +# Create the NAT gateway that provides outbound internet connectivity for the +# virtual machine. The virtual machine has no public IP address of its own. +resource "azurerm_nat_gateway" "nat" { + name = "nat-gateway" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku_name = "Standard" + idle_timeout_in_minutes = 4 +} + +# Associate the public IP address with the NAT gateway +resource "azurerm_nat_gateway_public_ip_association" "nat" { + nat_gateway_id = azurerm_nat_gateway.nat.id + public_ip_address_id = azurerm_public_ip.nat.id +} + +# Create the public IP prefix that backs the network identifier. The prefix +# range is what the network security perimeter inbound access rule authorizes. +resource "azurerm_public_ip_prefix" "network_identifier" { + name = "public-ip-prefix" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + prefix_length = 31 + sku = "Standard" + ip_version = "IPv4" +} + +# Create the public IP address that is used as the network identifier for the +# standard service endpoint. It must be Standard SKU, Static, and IPv4, and it +# must exist before it is associated with the subnet service endpoint. +resource "azurerm_public_ip" "network_identifier" { + name = "public-ip-1" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + allocation_method = "Static" + sku = "Standard" + ip_version = "IPv4" + public_ip_prefix_id = azurerm_public_ip_prefix.network_identifier.id +} + +# Create the subnet with a standard service endpoint. The azapi provider is used +# because the service endpoint networkIdentifier property is not yet exposed by +# the azurerm provider. Associating the network identifier with the service +# endpoint is the key configuration step for a standard service endpoint. +resource "azapi_resource" "subnet" { + type = "Microsoft.Network/virtualNetworks/subnets@2025-07-01" + name = "subnet-1" + parent_id = azurerm_virtual_network.vnet.id + + body = { + properties = { + addressPrefix = "10.0.0.0/24" + + natGateway = { + id = azurerm_nat_gateway.nat.id + } + + serviceEndpoints = [ + { + service = var.service_endpoint_service + + networkIdentifier = { + id = azurerm_public_ip.network_identifier.id + } + } + ] + } + } + + response_export_values = ["properties.serviceEndpoints"] + + depends_on = [azurerm_nat_gateway_public_ip_association.nat] +} + +# Create the network interface for the virtual machine. The virtual machine uses +# a private IP address only and reaches Azure Storage through the service endpoint. +resource "azurerm_network_interface" "nic" { + name = "nic-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + + ip_configuration { + name = "ipconfig-1" + subnet_id = azapi_resource.subnet.id + private_ip_address_allocation = "Dynamic" + } +} + +# Create the Linux virtual machine that represents the IaaS workload +resource "azurerm_linux_virtual_machine" "vm" { + name = "vm-1" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + network_interface_ids = [azurerm_network_interface.nic.id] + size = var.vm_size + + os_disk { + name = "osdisk-1" + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-gen2" + version = "latest" + } + + computer_name = "vm-1" + admin_username = var.username + + admin_ssh_key { + username = var.username + public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey + } + + boot_diagnostics {} +} + +# Generate a unique suffix for the globally unique storage account name +resource "random_string" "name" { + length = 8 + special = false + upper = false + lower = true + numeric = true +} + +# Create the Azure Storage account that acts as the PaaS resource behind the +# standard service endpoint. Access is governed by the network security +# perimeter rather than by subnet-scoped storage network rules. +resource "azurerm_storage_account" "sa" { + name = "sa${random_string.name.result}" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false +} + +# Create a file share used to validate connectivity through the service endpoint +resource "azurerm_storage_share" "share" { + name = "fileshare-1" + storage_account_id = azurerm_storage_account.sa.id + quota = 50 +} + +# Create the network security perimeter that secures the PaaS resource +resource "azurerm_network_security_perimeter" "nsp" { + name = "nsp-1" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location +} + +# Create a profile in the network security perimeter. Access rules are defined +# on the profile and applied to every resource associated with it. +resource "azurerm_network_security_perimeter_profile" "profile" { + name = "profile-1" + network_security_perimeter_id = azurerm_network_security_perimeter.nsp.id +} + +# Associate the storage account with the network security perimeter profile. +# Learning mode logs traffic without blocking it, which is the recommended +# starting point when you evaluate access rules. +resource "azurerm_network_security_perimeter_association" "storage" { + name = "assoc-storage" + network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.profile.id + resource_id = azurerm_storage_account.sa.id + access_mode = var.nsp_access_mode +} + +# Add the IP-based inbound access rule that authorizes traffic from the network +# identifier. The rule matches the public IP prefix that the network identifier +# was allocated from. +resource "azurerm_network_security_perimeter_access_rule" "inbound" { + name = "allow-se-standard" + network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.profile.id + direction = "Inbound" + address_prefixes = [azurerm_public_ip_prefix.network_identifier.ip_prefix] +} diff --git a/quickstart/101-standard-service-endpoint-storage/outputs.tf b/quickstart/101-standard-service-endpoint-storage/outputs.tf new file mode 100644 index 000000000..dc65173e3 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/outputs.tf @@ -0,0 +1,45 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "virtual_network_name" { + value = azurerm_virtual_network.vnet.name +} + +output "subnet_name" { + value = azapi_resource.subnet.name +} + +output "subnet_service_endpoints" { + description = "The service endpoints on the subnet, including the associated network identifier." + value = azapi_resource.subnet.output.properties.serviceEndpoints +} + +output "network_identifier_public_ip_name" { + value = azurerm_public_ip.network_identifier.name +} + +output "network_identifier_public_ip_address" { + value = azurerm_public_ip.network_identifier.ip_address +} + +output "network_identifier_prefix" { + description = "The public IP prefix range authorized by the network security perimeter inbound access rule." + value = azurerm_public_ip_prefix.network_identifier.ip_prefix +} + +output "storage_account_name" { + value = azurerm_storage_account.sa.name +} + +output "storage_share_name" { + value = azurerm_storage_share.share.name +} + +output "network_security_perimeter_name" { + value = azurerm_network_security_perimeter.nsp.name +} + +output "virtual_machine_name" { + value = azurerm_linux_virtual_machine.vm.name +} diff --git a/quickstart/101-standard-service-endpoint-storage/providers.tf b/quickstart/101-standard-service-endpoint-storage/providers.tf new file mode 100644 index 000000000..50612e571 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azapi = { + source = "azure/azapi" + version = "~>2.0" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~>4.0" + } + random = { + source = "hashicorp/random" + version = "~>3.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/quickstart/101-standard-service-endpoint-storage/readme.md b/quickstart/101-standard-service-endpoint-storage/readme.md new file mode 100644 index 000000000..3c4137602 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/readme.md @@ -0,0 +1,51 @@ +# Azure standard service endpoint for Azure Storage + +This template deploys a [standard service endpoint](https://learn.microsoft.com/azure/private-link/service-endpoint-standard-overview) for Azure Storage. + +A standard service endpoint connects IaaS workloads to PaaS resources by using a **network identifier** (a public IP address) and a **network security perimeter**. The network identifier marks service endpoint traffic leaving the subnet, and an IP-based inbound access rule on the perimeter authorizes it. This addresses the scale limits of basic service endpoints, because one public IP address can represent many virtual networks and subnets in the same region and subscription. + +## Resources deployed + +| Resource | Purpose | +|---|---| +| Virtual network and subnet | Hosts the IaaS workload | +| Public IP prefix and public IP address | The network identifier for the service endpoint | +| Subnet service endpoint (`azapi`) | Associates the network identifier with `Microsoft.Storage` | +| NAT gateway and public IP address | Outbound internet connectivity for the virtual machine | +| Linux virtual machine and network interface | The IaaS workload; private IP address only | +| Storage account and file share | The PaaS resource behind the service endpoint | +| Network security perimeter, profile, and association | Secures the storage account | +| Network security perimeter inbound access rule | Authorizes traffic from the network identifier prefix | + +The subnet is created with the `azapi` provider because the service endpoint `networkIdentifier` property is not yet exposed by the `azurerm` provider. + +## Prerequisites + +Standard service endpoint is in public preview and is gated behind a feature flag. Register the feature in your subscription before you deploy: + +```azurecli +az feature register \ + --namespace Microsoft.Network \ + --name AllowServiceEndpointNetworkIdentifier + +az feature show \ + --namespace Microsoft.Network \ + --name AllowServiceEndpointNetworkIdentifier \ + --query "properties.state" \ + --output tsv +``` + +Wait for the state to show `Registered`, then refresh the resource provider registration: + +```azurecli +az provider register --namespace Microsoft.Network +``` + +You also need the `Microsoft.Network/publicIPAddresses/joinServiceEndpointNetworkIdentifier/action` permission, which is included in the **Network Contributor** role. + +## Notes + +- The network security perimeter association defaults to `Learning` mode, which logs traffic without blocking it. Set `nsp_access_mode` to `Enforced` to apply the access rules. +- The public IP address used as the network identifier must be Standard SKU, Static, and IPv4, and it must exist before it is associated with the service endpoint. +- The default location is `eastus2`. +- The SSH key for the virtual machine is generated at deploy time. Only the public key is exposed in the outputs. diff --git a/quickstart/101-standard-service-endpoint-storage/ssh.tf b/quickstart/101-standard-service-endpoint-storage/ssh.tf new file mode 100644 index 000000000..cd475dccd --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/ssh.tf @@ -0,0 +1,24 @@ +resource "random_pet" "ssh_key_name" { + prefix = "ssh" + separator = "" +} + +resource "azapi_resource_action" "ssh_public_key_gen" { + type = "Microsoft.Compute/sshPublicKeys@2022-11-01" + resource_id = azapi_resource.ssh_public_key.id + action = "generateKeyPair" + method = "POST" + + response_export_values = ["publicKey", "privateKey"] +} + +resource "azapi_resource" "ssh_public_key" { + type = "Microsoft.Compute/sshPublicKeys@2022-11-01" + name = random_pet.ssh_key_name.id + location = azurerm_resource_group.rg.location + parent_id = azurerm_resource_group.rg.id +} + +output "key_data" { + value = azapi_resource_action.ssh_public_key_gen.output.publicKey +} diff --git a/quickstart/101-standard-service-endpoint-storage/variables.tf b/quickstart/101-standard-service-endpoint-storage/variables.tf new file mode 100644 index 000000000..9ae26fd62 --- /dev/null +++ b/quickstart/101-standard-service-endpoint-storage/variables.tf @@ -0,0 +1,40 @@ +variable "resource_group_location" { + type = string + default = "eastus2" + description = "Location of the resource group." +} + +variable "resource_group_name_prefix" { + type = string + default = "rg" + description = "Prefix of the resource group name that's combined with a random ID so name is unique in your Azure subscription." +} + +variable "username" { + type = string + default = "azureadmin" + description = "The username for the local account that will be created on the new virtual machine." +} + +variable "vm_size" { + type = string + default = "Standard_DS1_v2" + description = "The size of the virtual machine." +} + +variable "service_endpoint_service" { + type = string + default = "Microsoft.Storage" + description = "The service that the standard service endpoint is configured for." +} + +variable "nsp_access_mode" { + type = string + default = "Learning" + description = "The access mode of the network security perimeter association. Learning logs traffic without blocking it; Enforced applies the access rules." + + validation { + condition = contains(["Learning", "Enforced", "Audit"], var.nsp_access_mode) + error_message = "The nsp_access_mode value must be Learning, Enforced, or Audit." + } +}