Introduction
In this post, I will explain what service tags are, their purpose, and where they can be natively used in Azure. Additionally, I will provide overview on how to utilize service tags in places where they are currently not natively supported by using Terraform. Lastly, I will give an example of how service tags can be used in this context to allow access from Microsoft Power Platform in the UK to Azure KeyVault, Azure SQL, and CosmosDB.
What are service tags and what are they used for?
Service tags are a highly useful feature provided by Microsoft for managing network security in Azure. These tags consist of named groups of IP CIDRs, similar to IP Address groups found in traditional firewall solutions. Microsoft offers a comprehensive overview that you can find here.
To facilitate the integration of service tags into your network infrastructure, Microsoft also provides a convenient option to download the actual data in JSON format. This allows you to easily import the service tags into your firewall or security solution. You can access the JSON download here.
The use case for service tags is you can use them as a reference point in traffic filtering scenario’s. An example could be you have a microservice that uses an Azure SQL backend and Azure storage, and you want to harden the solution. You might want to limit outbound connectivity of the service so that if its compromised its harder for someone to exfiltrate data.
In this scenario you could limit outbound traffic using an NSG so only port 1433 for SQL, and 443 for HTTPs is allowed outbound. This would be a start in that any FTP or SFTP outbound traffic on standard ports would be blocked, but HTTPS could still easily be used to exfiltrate data. With service tags you can go one step further and say the destination for the 1433 rule needs to be to the service tag Sql
so outbound connectivity is now limited to traffic destined for port 1433 and to SQL instances hosted in Azure. You can go even further as some service tags support regional granularity so you could use service tag Sql.EastUS
so only Azure SQL instances in the EastUS region are an allowed destination. The same applies for the HTTPS destination, it could be narrowed down to Azure Storage only.
Its worth noting at this point, the service tags are not specific to your subscription or tenant. I.e. if you allow the service tag PowerPlatformInfra
access to something, then anyone using Power Platform, in any subscription or tenant will be covered by this rule. Likewise if you allow access outbound to Sql.EastUS
then any SQL instance in anyone’s subscription or tenant is covered by the rule.
Native support for service tags
Both Network Security Groups (NSGs) and Azure Firewall support service tags in their rule definitions.
You are out of luck if you are wanting to use service tags with public endpoint network restrictions for services like:
- Storage Accounts
- Azure SQL
- Azure KeyVault
- CosmosDB
It goes without saying, the best way to secure inbound traffic between others services and the above, is always going to be use private endpoint, and disable the public endpoint where this is possible.
In some cases depending on the services in question you may be able to make use of “trusted” services rules – there’s some further info here but the topic is outside the scope of this post – Configure Azure Storage firewalls and virtual networks | Microsoft Learn.
That said it isn’t always that simple. Lets say you have a Power Automate flow that you want to access KeyVault, SQL, or Cosmos. While there are service tags for Power Platform – as mentioned above there is no native support to use these on the network restrictions of the services above. The next section explains how terraform can be leveraged to bridge this gap.
How to use service tags where not natively supported
Terraform offers a data source for service tags. The documentation for the data source can be found here. This means terraform can easily bridge the gap.
Note
There is a caveat I’d like to cover before getting in to this. When using service tags natively in supported services like NSG and Azure Firewall, the rule set is always up to date. When Microsoft add and remove infrastructure and update the tags, the rulesets using those tags are up to date.
Using the following method the rules are only as up to date as the last terraform apply. If you are using drift detection and correction methods then this wont be an issue. If you aren’t then you may find after a period of time with no terraform apply its possible the service tags have been updated and terraform would need to be re-applied to get the updated list of IP’s from the tags.
New IP’s are added to service tags a full week before they are actually used by Microsoft to cater for this type of scenario and allow some grace period.
The below example has two data sources used to get service tags for two different Power Platform regions. In the case of Power Platform, when you provision the environment you get asked which geographical region to provision it in, rather than regions in the context of Azure. In my example the Power Platform region was United Kingdom so the rule set will need to include all Azure regions in that geographical area.
data "azurerm_network_service_tags" "power_platform_uksouth" {
service = "PowerPlatformInfra"
location = "uksouth"
location_filter = "uksouth"
}
data "azurerm_network_service_tags" "power_platform_ukwest" {
service = "PowerPlatformInfra"
location = "ukwest"
location_filter = "ukwest"
}
Within the output given by the data source is list called ipv4_cidrs
which is exactly what we will use. An example of the outputted object from the data source is below, I’ve removed the bulk of the CIDR’s for better readability.
{
"address_prefixes" = tolist([
"20.49.145.249/32",
"2603:1061:2002:3000::/57",
])
"id" = "uksouth-PowerPlatformInfra"
"ipv4_cidrs" = tolist([
"20.49.145.249/32",
"20.49.166.40/32"
])
"ipv6_cidrs" = tolist([
"2603:1061:2002:3000::/57",
])
"location" = "uksouth"
"location_filter" = "uksouth"
"name" = "PowerPlatformInfra.UKSouth"
"service" = "PowerPlatformInfra"
"timeouts" = null /* object */
}
KeyVault
In the case of KeyVault, like most other services it accepts a list IPs or CIDR’s so we can simply pass it the list. In my instance I want IP’s for both service tags so will use concat
(documentation here) to combine the two lists.
resource "azurerm_key_vault" "example" {
name = "examplekeyvault"
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
enabled_for_disk_encryption = true
tenant_id = data.azurerm_client_config.current.tenant_id
soft_delete_retention_days = 7
purge_protection_enabled = false
sku_name = "standard"
network_acls {
# The Default Action to use when no rules match from ip_rules /
# virtual_network_subnet_ids. Possible values are Allow and Deny
default_action = "Deny"
# Allows all azure services to access your keyvault. Can be set to 'None'
bypass = "None"
# The list of allowed ip addresses.
ip_rules = concat(
data.azurerm_network_service_tags.power_platform_uksouth.ipv4_cidrs,
data.azurerm_network_service_tags.power_platform_ukwest.ipv4_cidrs
)
}
}
CosmosDB
Cosmos is slightly different as it wants the list of IP’s and CIDRs in a coma separated list. The join
function (documentation here) can be used to achieve this.
resource "azurerm_cosmosdb_account" "db" {
name = "tfex-cosmos-db-${random_integer.ri.result}"
location = azurerm_resource_group.example.location
resource_group_name = azurerm_resource_group.example.name
offer_type = "Standard"
kind = "GlobalDocumentDB"
enable_automatic_failover = true
ip_range_filter = join(",",concat(
data.azurerm_network_service_tags.power_platform_uksouth.ipv4_cidrs,
data.azurerm_network_service_tags.power_platform_ukwest.ipv4_cidrs
)
)
consistency_policy {
consistency_level = "BoundedStaleness"
max_interval_in_seconds = 300
max_staleness_prefix = 100000
}
geo_location {
location = "uksouth"
failover_priority = 0
}
}
Azure SQL Server
For Azure SQL Server its slightly different again. Each rule needs:
- A unique rule name
- The starting IP of the range
- The ending IP of the range
For singular IP’s the start and end of the range can be the same value. In this scenario service tags always give full CIDR annotation even for singular IP’s by using a suffix of /32. We can use the cidrhost
function (documentation here) to get the start and end IP’s from a CIDR range.
We can use a local to get the data in a suitable format that we can loop over for each rule.
locals {
sql_ip_rules = [for ip in concat(
data.azurerm_network_service_tags.power_platform.uksouth.ipv4_cidrs,
data.azurerm_network_service_tags.power_platform_ukwest.ipv4_cidrs
) : {
rule_name = "PowerPlatform-${cidrhost(ip, 0)}-${cidrhost(ip, -1)}"
start_ip_address = cidrhost(ip, 0)
end_ip_address = cidrhost(ip, -1)
}
]
}
resource "azurerm_mssql_server" "example" {
name = "mssqlserver"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
version = "12.0"
administrator_login = "missadministrator"
administrator_login_password = "thisIsKat11"
minimum_tls_version = "1.2"
azuread_administrator {
login_username = "AzureAD Admin"
object_id = "00000000-0000-0000-0000-000000000000"
}
tags = {
environment = "production"
}
}
resource "azurerm_mssql_firewall_rule" "power_platform_firewall_rule" {
for_each = { for rule in local.sql_ip_rules : rule.rule_name => rule }
name = each.value.rule_name
server_id = azurerm_mssql_server.example.id
start_ip_address = each.value.start_ip_address
end_ip_address = each.value.end_ip_address
}
Closing thoughts
For the scenario’s where getting traffic to services is not possible privately, service tags offer great flexibility to add an additional layer of security vs just leaving a service with a public endpoint open to any network. Maybe native support to use service tags in other area’s will come with time. For now at least Terraform offers a viable solution.