Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Subnet Service Endpoint Storage Policy Terraform Module

A single-purpose module that provisions an azurerm_subnet_service_endpoint_storage_policy — a policy that narrows a subnet's Microsoft.Storage service-endpoint access down to an explicit allow-list of Storage accounts or aliases. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Version Type Resources

🧩 Overview

  • Creates one azurerm_subnet_service_endpoint_storage_policy in a supplied resource group and region.
  • Accepts up to two policy definitions, each naming the Storage accounts (by resource ID) or Storage aliases the subnet may reach.
  • Carries tags and optional per-operation timeouts — the universal tail on every module in this suite.
  • Emits the policy id for a subnet to consume through its service_endpoint_policy_ids.

💡 Why it matters: A bare Microsoft.Storage service endpoint on a subnet grants that subnet optimized-route access to all Storage in the region. A service-endpoint storage policy tightens that from "all storage" to a named allow-list, so a compromised or misconfigured workload cannot reach arbitrary Storage accounts over the service endpoint.

❤️ Support this project

If this module saves you time, please consider supporting the work:

🗺️ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  sa["terraform-azurerm-storage-account"]
  me["terraform-azurerm-subnet-service-endpoint-storage-policy"]
  v["azurerm_subnet_service_endpoint_storage_policy"]
  subnet["terraform-azurerm-subnet"]
  rg -->|"resource_group_name"| me
  sa -->|"service_resources"| me
  me -->|"creates"| v
  v -->|"service_endpoint_policy_ids"| subnet
  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
  classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
  class me me;
  class v target;
  class rg,sa,subnet ext;
Loading

A resource group supplies resource_group_name; a Storage account supplies the IDs that populate service_resources. This module creates the policy, and a subnet consumes the policy's id through service_endpoint_policy_ids.

🧬 What this module builds

flowchart LR
  in_id["name / resource_group_name / location"]
  in_def["definitions[] (service + service_resources)"]
  in_tags["tags"]
  res["azurerm_subnet_service_endpoint_storage_policy.this"]
  out["id / name"]
  in_id -->|"input"| res
  in_def -->|"input"| res
  in_tags -->|"input"| res
  res -->|"output"| out
  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  class res me;
Loading

Resource inventory

Resource Cardinality Role
azurerm_subnet_service_endpoint_storage_policy.this single (keystone) The service-endpoint storage policy, including its inline definition blocks.
definition (nested) up to 2 per policy Each names the Storage accounts (by ID) or aliases the subnet may reach.

The policy and its definition blocks share one lifecycle; the definitions are rendered from the definitions list with a dynamic block, so there is no separate child resource to manage.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm", including the mandatory features {} block, plus auth, subscription, and region.

Schema notes that bite

  • name, resource_group_name, and location are force-new — changing any of them destroys and recreates the policy.
  • At most two definition blocks are supported; a third is rejected at parse time by a validation {} block.
  • Within a single definition, service_resources must be either Storage account resource IDs (with service = "Microsoft.Storage") or aliases (with service = "Global") — never a mix. Mixing IDs and aliases in one definition is rejected by the Azure API.
  • service is validated to be "Microsoft.Storage" or "Global" when set; leaving it null defers to the provider default.

🔑 Required Azure RBAC Roles / Permissions

Least-privilege at the resource group scope:

  • Microsoft.Network/serviceEndpointPolicies/write
  • Microsoft.Network/serviceEndpointPolicies/read
  • Microsoft.Network/serviceEndpointPolicies/delete

The built-in Network Contributor role covers these.

🧰 Azure Prerequisites

  • An existing resource group (this module does not create one).
  • The Storage account IDs (or aliases) the policy should permit — referenced by ID, never created here.
  • The Microsoft.Network resource provider registered on the target subscription.

📁 Module Structure

terraform-azurerm-subnet-service-endpoint-storage-policy/
├── providers.tf     # required_version + azurerm ~> 4.0 pin; no provider block
├── variables.tf     # typed inputs: name, resource_group_name, location, definitions, tags, timeouts
├── main.tf          # keystone azurerm_subnet_service_endpoint_storage_policy.this + dynamic definition/timeouts
├── outputs.tf       # id (first), name
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "storage_endpoint_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "prod-storage-endpoint-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [{
    name              = "allow-prod-storage"
    service           = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-prod/providers/Microsoft.Storage/storageAccounts/prodsa",
    ]
  }]

  tags = {
    environment = "prod"
    owner       = "platform-network"
  }
}

ℹ️ The caller owns the provider "azurerm" block — including features {}, authentication, subscription, and region. This module never declares one.

🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group
definitions[*].service_resources list(string) terraform-azurerm-storage-account (account IDs)

Emits

Output Description Consumed by
id Resource ID of the service endpoint storage policy terraform-azurerm-subnet / terraform-azurerm-virtual-network (service_endpoint_policy_ids)
name Policy name references

📚 Example Library

1 · Minimal — a single Storage account allow-list
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "min-storage-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [{
    name              = "allow-single-account"
    service           = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/appdata01",
    ]
  }]
}

💡 The smallest useful call: one definition, one Storage account. Any subnet that attaches this policy can reach only appdata01 over the Microsoft.Storage service endpoint.

2 · Multiple Storage accounts in one definition
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "multi-account-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus2"

  definitions = [{
    name    = "allow-app-storage"
    service = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/appdata01",
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/appdata02",
    ]
  }]
}

🔒 A single definition can list many account IDs — all must belong to the Microsoft.Storage service class.

3 · Global aliases instead of account IDs
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "alias-policy"
  resource_group_name = "rg-network-prod"
  location            = "westus2"

  definitions = [{
    name              = "allow-region-aliases"
    service           = "Global"
    service_resources = ["/services/Azure/Batch", "/services/Azure/MachineLearning"]
  }]
}

⚠️ When a definition uses aliases, its service must be "Global" and every entry in service_resources must be an alias path — never a Storage account resource ID.

4 · Two definitions — IDs and aliases kept separate
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "split-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [
    {
      name              = "allow-accounts"
      service           = "Microsoft.Storage"
      service_resources = [
        "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/appdata01",
      ]
    },
    {
      name              = "allow-aliases"
      service           = "Global"
      service_resources = ["/services/Azure/Batch"]
    },
  ]
}

💡 The two-definition limit is used here to keep account IDs and aliases in separate definitions — the correct pattern when you need both.

5 · With a definition description
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "documented-policy"
  resource_group_name = "rg-network-prod"
  location            = "centralus"

  definitions = [{
    name              = "allow-audited-storage"
    description       = "Restrict subnet egress to the audited data-lake accounts only."
    service           = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-lake/providers/Microsoft.Storage/storageAccounts/datalake01",
    ]
  }]
}

ℹ️ description is optional per definition and shows up in the Azure portal to explain the definition's intent.

6 · Tagged for cost and ownership
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "tagged-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [{
    name              = "allow-prod-storage"
    service           = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/prodsa",
    ]
  }]

  tags = {
    environment = "prod"
    cost_center = "cc-4821"
    owner       = "platform-network"
    managed_by  = "terraform"
  }
}

💡 Every resource that supports tags carries them here; use them for cost allocation and ownership reporting.

7 · Referencing a Storage account module output
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "wired-policy"
  resource_group_name = module.resource_group.name
  location            = "eastus"

  definitions = [{
    name              = "allow-app-storage"
    service           = "Microsoft.Storage"
    service_resources = [module.storage_account.id]
  }]
}

🔒 Prefer wiring module.storage_account.id over hand-copying a resource ID — the plan then fails loudly if the account is renamed or removed.

8 · Several accounts from a for_each of Storage modules
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "fleet-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus2"

  definitions = [{
    name              = "allow-fleet-storage"
    service           = "Microsoft.Storage"
    service_resources = [for sa in module.storage_accounts : sa.id]
  }]
}

💡 A for expression collapses a keyed map of Storage account modules into one allow-list, so the policy tracks the fleet automatically.

9 · Custom timeouts for slow control-plane operations
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "timed-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [{
    name              = "allow-prod-storage"
    service           = "Microsoft.Storage"
    service_resources = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/prodsa",
    ]
  }]

  timeouts = {
    create = "30m"
    delete = "30m"
  }
}

ℹ️ timeouts accepts Go duration strings; leave it null to use provider defaults.

10 · Empty definitions — a placeholder policy
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "placeholder-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  # definitions defaults to [] — the policy exists but restricts nothing yet.
}

⚠️ A policy with no definitions is a scaffold you fill in later. Attach it to a subnet only once it carries the definitions you intend to enforce.

11 · Multi-region — one policy per region
locals {
  regions = {
    east = { location = "eastus", account = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-east/providers/Microsoft.Storage/storageAccounts/easastsa" }
    west = { location = "westus2", account = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-west/providers/Microsoft.Storage/storageAccounts/westsa" }
  }
}

module "policy" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"
  for_each = local.regions

  name                = "storage-policy-${each.key}"
  resource_group_name = "rg-network-${each.key}"
  location            = each.value.location

  definitions = [{
    name              = "allow-regional-storage"
    service           = "Microsoft.Storage"
    service_resources = [each.value.account]
  }]
}

💡 The policy is a regional resource — instantiate one per region with for_each, since location is force-new and cannot be changed in place.

12 · Two definitions with descriptions and tags
module "policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "full-policy"
  resource_group_name = "rg-network-prod"
  location            = "eastus"

  definitions = [
    {
      name              = "allow-tier1-accounts"
      description       = "Tier-1 production Storage accounts."
      service           = "Microsoft.Storage"
      service_resources = [
        "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/tier1a",
        "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/tier1b",
      ]
    },
    {
      name              = "allow-platform-aliases"
      description       = "Platform service aliases."
      service           = "Global"
      service_resources = ["/services/Azure/Batch"]
    },
  ]

  tags = {
    environment = "prod"
    owner       = "platform-network"
  }
}

🔒 This exercises the module's full breadth: both definitions (one IDs, one aliases), descriptions, and tags — all at once.

13 · 🏗️ End-to-end composition — resource group + storage account → policy → subnet
provider "azurerm" {
  features {}
}

# 1. Resource group
module "resource_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-network-prod"
  location = "eastus"
}

# 2. Storage accounts whose IDs the policy will permit
module "storage_account" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "prodappdata01"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
}

# 3. The service-endpoint storage policy — permits the account above plus a Global alias
module "storage_endpoint_policy" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet-service-endpoint-storage-policy.git?ref=v1.0.0"

  name                = "prod-storage-endpoint-policy"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  definitions = [
    {
      name              = "allow-prod-accounts"
      service           = "Microsoft.Storage"
      service_resources = [module.storage_account.id]
    },
    {
      name              = "allow-platform-aliases"
      service           = "Global"
      service_resources = ["/services/Azure/Batch"]
    },
  ]

  tags = {
    environment = "prod"
    owner       = "platform-network"
  }
}

# 4. A subnet that applies the policy via service_endpoint_policy_ids
module "subnet" {
  source                      = "git::https://github.com/microsoftexpert/terraform-azurerm-subnet.git?ref=v1.0.0"

  name                        = "snet-app"
  resource_group_name         = module.resource_group.name
  virtual_network_name        = "vnet-prod"
  address_prefixes            = ["10.10.1.0/24"]

  service_endpoints           = ["Microsoft.Storage"]
  service_endpoint_policy_ids = [module.storage_endpoint_policy.id]
}

💡 The full wiring: the resource group feeds resource_group_name and location, the Storage account feeds service_resources, this module produces the policy id, and the subnet enables the Microsoft.Storage service endpoint and binds the policy — completing the least-exposure path from "all storage" to a named allow-list.

📥 Inputs

Identity & placement

Name Type Required Description
name string ✅ Policy name. Force-new.
resource_group_name string ✅ Resource group. Force-new.
location string ✅ Azure region. Force-new.

Policy definitions & universal tail

Name Type Default Description
definitions list(object(...)) [] Up to two definitions; each names Storage account IDs (service = "Microsoft.Storage") or aliases (service = "Global").
tags map(string) {} Tags applied to the policy.
timeouts object({ create, read, update, delete }) null Optional per-operation timeouts (Go duration strings).
Full input schemas
variable "name" {
  type = string
}

variable "resource_group_name" {
  type = string
}

variable "location" {
  type = string
}

variable "definitions" {
  type = list(object({
    name              = string
    service_resources = list(string)
    service           = optional(string)
    description       = optional(string)
  }))
  default = []

  # length(var.definitions) <= 2
  # service, when set, must be "Microsoft.Storage" or "Global"
}

variable "tags" {
  type    = map(string)
  default = {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Kind
id Resource ID of the service endpoint storage policy (emitted first) Passthrough
name Name of the service endpoint storage policy, as recorded in state Passthrough
resource_group_name Resource group holding the policy Passthrough
location Azure region of the policy, in the provider's normalized form (lower case, spaces removed) regardless of how it was written on input Passthrough
tags Tags applied to the policy Passthrough
definition_count Number of definition blocks on the policy, 0 to 2 Passthrough
definition_names Definition names in declaration order Passthrough
duplicate_definition_names Definition names used more than once, or an empty list Passthrough
service_by_definition Map of definition name to its service value (Microsoft.Storage for Resource-ID definitions, Global for alias definitions) Passthrough
service_resources_by_definition Map of definition name to the deduplicated entries it permits Passthrough
service_resource_count_by_definition Map of definition name to how many distinct entries it permits Derived
all_service_resources Every distinct entry across all definitions -- the policy's complete allow list in one place, which is what a reviewer actually needs to see Passthrough
total_service_resource_count Number of distinct entries across all definitions Passthrough
storage_account_ids Entries that name an individual Storage account, matched against the exact Microsoft.Storage/storageAccounts type and anchored so a child resource ID under an account is not counted as the account itself Passthrough
subscription_scoped_service_resources Entries that name a whole subscription (/subscriptions/<sub>) Passthrough
resource_group_scoped_service_resources Entries that name a whole resource group Passthrough
management_group_scoped_service_resources Entries that name a management group Passthrough
alias_service_resources Entries that are service aliases Passthrough
grants_broader_than_a_single_storage_account Passthrough
definitions_with_duplicate_service_resources Definitions that list the same entry more than once, or an empty list Passthrough
default_service_resources_per_definition_limit 200 Passthrough
definitions_at_or_over_default_service_resource_limit Definitions whose distinct entry count has reached 200, or an empty list Passthrough
force_new_inputs The inputs that force replacement rather than an in-place update Derived
effective_timeouts The timeouts actually in force, with the provider's own defaults substituted for anything left unset (create 30m, read 5m, update 30m, delete 30m) Derived
is_allow_list_not_deny_list Constant
takes_effect_only_when_attached_to_a_subnet Constant
requires_storage_service_endpoint_on_the_subnet Constant
attachment_upgrades_storage_endpoint_scope_to_global Constant
denies_all_storage_when_attached_with_no_definitions Passthrough
multiple_policies_on_a_subnet_union_their_allow_lists Constant
denies_classic_storage_accounts Constant
denies_managed_storage_accounts Constant
ra_grs_secondary_allowed_with_primary Always true Constant
must_share_region_and_subscription_with_the_virtual_network Constant

🧠 Architecture Notes

  • Force-new fields. name, resource_group_name, and location all force replacement. Because location is immutable, model a multi-region estate as one module instance per region rather than trying to move a policy.
  • Two-definition ceiling. Azure supports at most two definition blocks per policy; the module enforces this at parse time with a validation {} block, so a third definition fails plan — not apply.
  • IDs vs. aliases are exclusive within a definition. A definition's service_resources must be all Storage account resource IDs (service = "Microsoft.Storage") or all aliases (service = "Global") — never both. When you need each, split them across the two available definitions (see Example 4).
  • Subnet wiring is external. This module owns only the policy. The subnet that enforces it is created elsewhere and points at this module's id through service_endpoint_policy_ids; the subnet must also enable the Microsoft.Storage service endpoint for the policy to take effect.
  • features {} dependence. The policy is created against the caller's provider "azurerm", which will not initialize without a features {} block. That block, and all authentication, belong to the caller's root module.

🧱 Design Principles

Concern This module's default How to change it
Definition count Capped at 2, validated at parse time Not adjustable — it is an Azure platform limit.
service value Validated to "Microsoft.Storage" or "Global" when set Set the field explicitly per definition.
Storage exposure Least-exposure — a subnet that attaches this policy reaches only the named accounts/aliases, not all regional Storage List every account or alias the subnet legitimately needs.
Tags Carried via the universal tail (tags, default {}) Pass a tags map.

The empty call produces a policy that restricts everything (no definitions) rather than one that permits everything — the caller must type each account or alias it wants to allow.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is authored plan-only; a human applies it from CI.

🧪 Testing

  • terraform init -backend=false resolves the provider without a backend.
  • terraform validate proves the type contract: a malformed definition, a third definition, or an out-of-range service value fails here, before any Azure call.
  • terraform fmt -check enforces canonical formatting.
  • What only terraform plan exercises: resolution of real Storage account IDs and the runtime rejection of IDs mixed with aliases in one definition — neither is visible to validate alone.

💬 Example Output

$ terraform output
id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/serviceEndpointPolicies/prod-storage-endpoint-policy"
name = "prod-storage-endpoint-policy"

🔍 Troubleshooting

Symptom Cause Fix
error: definition.service must be "Microsoft.Storage" or "Global" A definition set service to an unsupported value. Use "Microsoft.Storage" (for account IDs) or "Global" (for aliases).
error: A maximum of two definitions is supported The definitions list has more than two entries. Consolidate into at most two definitions; group account IDs and aliases into one definition each.
API rejects the policy: mixed resources in a definition A single definition's service_resources mixed Storage account IDs with aliases. Split IDs and aliases into two separate definitions — one Microsoft.Storage, one Global.
Subnet still reaches unlisted Storage over the endpoint The policy is not attached, or the subnet lacks the Microsoft.Storage service endpoint. Set the subnet's service_endpoint_policy_ids to this module's id and enable Microsoft.Storage in service_endpoints.
Plan shows the policy being replaced after a rename name, resource_group_name, or location changed — all force-new. Expect replacement; treat these as immutable, or create a new instance for a new region.

🔗 Related Docs

💙 "Infrastructure as Code should be standardized, consistent, and secure."