Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Network Security Perimeter Terraform Module

Creates and manages an Azure network security perimeter — the logical, default-deny isolation boundary that groups PaaS resources into one governed trust zone. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Version Type Resources


🧩 Overview

  • 🛡️ Provisions one azurerm_network_security_perimeter — the boundary that wraps PaaS resources (Storage, Key Vault, SQL, and similar) in a single logical isolation zone.
  • 🚧 Creates the perimeter object only. It has no members and enforces nothing until a profile, access rules and resource associations exist -- and a new association's platform default access mode is Transition, which falls back to the resource's own firewall. Default-deny is a property of Enforced associations, not of the perimeter: once a resource is associated in enforced mode, only explicit access rules permit traffic in or out.
  • 🧱 Owns only the boundary itself. Profiles, access rules, and resource associations are separate sibling modules that attach to this perimeter by id, so the boundary is versioned and reasoned about independently.
  • 🏷️ Carries the universal tags and timeouts tail and emits the perimeter id first for downstream wiring.
  • 🔒 Exposes no relaxation knob — the resource has none; every relaxation is expressed as an explicit access rule in the sibling access-rule module.

💡 Why it matters: A network security perimeter is a stronger control than per-resource firewalls. Per-resource firewalls are configured independently and drift apart; a perimeter enforces one boundary policy across every associated resource, and it enforces nothing on its own -- only an association in Enforced mode denies anything. Modeling the boundary as its own module lets a platform stand up the trust zone first, then layer the traffic policy on top through composable siblings.


❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart TD
  rg["terraform-azurerm-resource-group"]
  nsp["terraform-azurerm-network-security-perimeter (this module)"]
  profile["terraform-azurerm-network-security-perimeter-profile (sibling)"]
  rule["terraform-azurerm-network-security-perimeter-access-rule (sibling)"]
  assoc["terraform-azurerm-network-security-perimeter-association (sibling)"]
  paas["Protected PaaS resources (Storage / Key Vault / SQL)"]

  rg -->|"resource_group_name + location"| nsp
  nsp -->|"perimeter id"| profile
  profile -->|"profile id"| rule
  profile -->|"profile id"| assoc
  assoc -->|"associates resource id (Learning or Enforced)"| paas

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef key fill:#004578,stroke:#002a4a,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
  class nsp me;
  class paas key;
  class rg,profile,rule,assoc sib;
Loading

The resource group supplies resource_group_name and location. This module creates the perimeter and emits its id. Sibling modules consume that id to attach a profile, then attach inbound/outbound access rules and resource associations that bring protected PaaS resources under the boundary.

🧬 What this module builds

flowchart LR
  subgraph inputs["Inputs"]
    n["name (force-new)"]
    rgn["resource_group_name (force-new)"]
    loc["location (force-new)"]
    tg["tags"]
  end

  nsp["azurerm_network_security_perimeter.this"]

  subgraph outputs["Outputs"]
    oid["id (emitted first)"]
    onm["name"]
  end

  subgraph siblings["Governed by sibling modules (attach by id)"]
    profile["profile"]
    rule["access rule (inbound / outbound)"]
    assoc["association (Learning / Enforced)"]
  end

  n --> nsp
  rgn --> nsp
  loc --> nsp
  tg --> nsp
  nsp --> oid
  nsp --> onm
  oid -->|"network_security_perimeter_id"| profile
  profile -->|"profile id"| rule
  profile -->|"profile id"| assoc

  classDef key fill:#004578,stroke:#002a4a,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
  class nsp key;
  class profile,rule,assoc sib;
Loading

Resource inventory

Resource Count Role
azurerm_network_security_perimeter.this 1 (keystone) The isolation boundary. Carries name, resource_group_name, location, tags, and an optional timeouts block.

Profiles, access rules, and associations are not created here — they are separate catalog modules wired to this perimeter's id.


✅ Provider / Versions

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

Schema notes that bite (verified against the live provider schema):

  • name, resource_group_name, and location are effectively immutable. Changing any of them forces replacement of the perimeter, which fails: the provider never requests forceDeletion, so Azure refuses to delete a perimeter that still has child associations and the apply errors out rather than detaching anything.
  • The perimeter resource carries only name, resource_group_name, location, and tags. There is no public-access, TLS, or encryption argument on the boundary itself — those concerns are governed by profiles, access rules, and associations (sibling modules), not by this resource.
  • Network security perimeter availability varies by region. Confirm the target region supports it before applying.
  • The provider will not initialize without a caller-side features {} block; that is expected and belongs to the root module.

🔑 Required Azure RBAC Roles / Permissions

  • Network Contributor on the target resource group, or a custom role granting Microsoft.Network/networkSecurityPerimeters/*, scoped to the resource group (least privilege at the smallest scope that works).

Azure Prerequisites

  • An existing resource group in a network-security-perimeter-supported US Azure region.
  • The Microsoft.Network resource provider registered on the target subscription.
  • The protected PaaS resources already exist, so their ids are available for the sibling association module.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

📁 Module Structure

terraform-azurerm-network-security-perimeter/
├── providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf   # name, resource_group_name, location + tags/timeouts tail
├── main.tf        # keystone azurerm_network_security_perimeter.this + dynamic timeouts
├── outputs.tf     # id first, then name, resource_group_name, location
├── README.md      # this document
├── SCOPE.md        # the cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

The caller configures the provider, authentication, and the mandatory features {} block; the module never does.

provider "azurerm" {
  features {}
}

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-data-platform"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  tags = {
    environment = "production"
    owner       = "platform-security"
  }
}

ℹ️ Pin ?ref=v1.0.0 — never a branch — so a consuming composition is reproducible.


🔌 Cross-Module Contract

Consumes

Input Type Source
resource_group_name string terraform-azurerm-resource-group (name)
location string caller / terraform-azurerm-resource-group (location)

Emits

Output Description Consumed by
id Network security perimeter Resource ID (first) profile / association / access-rule / diagnostics / role-assignment modules
name Perimeter name diagnostics / tagging
resource_group_name Containing resource group sibling modules
location Azure region sibling modules

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "log_analytics_id" {
  description = "id of an existing log analytics that these examples reference but do not create."
  type        = string
}
1 · Minimal perimeter (the secure base)

The smallest real call. The empty configuration already produces a default-deny boundary — there is no exposure knob to turn off.

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-core"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"
}

🔒 A perimeter with no associations governs nothing yet; it becomes an active control once a resource is associated (see examples 9–11).

2 · Perimeter with governance tags

Tags flow straight through to the boundary for cost and ownership reporting.

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-finance"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  tags = {
    environment  = "production"
    data_class   = "confidential"
    cost_center  = "cc-4820"
    owner        = "platform-security"
  }
}

💡 Adopt a consistent tag set across every perimeter so the trust zones are attributable in cost and compliance reports.

3 · Custom operation timeouts

Supply create/read/update/delete timeouts when perimeter operations run long in a busy subscription.

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-core"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

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

ℹ️ Only the timeout fields you set are applied; omitted fields fall back to the provider defaults.

4 · Many perimeters at scale with for_each

Drive a fleet of trust zones from a single keyed map. Because the keys are stable, adding or removing one perimeter never re-indexes the rest.

locals {
  perimeters = {
    data      = { name = "nsp-data", location = "eastus" }
    analytics = { name = "nsp-analytics", location = "eastus2" }
    edge      = { name = "nsp-edge", location = "westus2" }
  }
}

module "perimeter" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
  for_each = local.perimeters

  name                = each.value.name
  resource_group_name = "rg-security-${each.value.location}"
  location            = each.value.location

  tags = { workload = each.key }
}

💡 Use a meaningful, stable map key (data, analytics, edge) rather than an index so the plan stays surgical.

5 · One perimeter per environment

Keep production, staging, and development boundaries fully separate.

variable "environment" {
  type = string
}

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-${var.environment}"
  resource_group_name = "rg-security-${var.environment}"
  location            = "centralus"

  tags = { environment = var.environment }
}

⚠️ name, resource_group_name, and location are force-new. Renaming a perimeter to fold environments together replaces it and detaches everything associated with it.

6 · Attaching a profile (sibling module)

A profile is the container for access rules and associations. It is a separate module that consumes this perimeter's id.

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-data-platform"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"
}

resource "azurerm_network_security_perimeter_profile" "perimeter_profile" {
  name                         = "default"
  network_security_perimeter_id = module.perimeter.id
}

ℹ️ This module deliberately does not own profiles. Keeping them separate lets one perimeter carry several profiles that evolve on their own cadence.

7 · Adding an inbound access rule (sibling module)

Access rules attach to a profile, not to the perimeter directly. An inbound rule permits traffic into the boundary from named sources.

resource "azurerm_network_security_perimeter_access_rule" "perimeter_inbound_rule" {
  direction                    = "Inbound"
  name                         = "allow-corp-ingress"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  address_prefixes             = ["203.0.113.0/24"]
}

🔒 The perimeter denies by default. Every inbound rule is an explicit, reviewable exception scoped to specific prefixes, subscriptions, or service tags.

8 · Adding an outbound access rule (sibling module)

An outbound rule permits traffic leaving the boundary to named destinations (for example approved FQDNs).

resource "azurerm_network_security_perimeter_access_rule" "perimeter_outbound_rule" {
  direction                    = "Outbound"
  name                         = "allow-approved-egress"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  fqdns                        = ["updates.example.com", "telemetry.example.com"]
}

⚠️ Egress from a hardened trust zone should be minimal. List only the destinations a workload genuinely needs.

9 · Associating a resource in Learning mode (sibling module)

An association brings a protected resource under the perimeter. Learning mode logs what would be blocked without enforcing — use it to validate rules before cutover.

resource "azurerm_network_security_perimeter_association" "perimeter_association" {
  access_mode                  = "Learning"
  name                         = "assoc-storage-data"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  resource_id                  = module.storage.id
}

💡 Start in Learning mode, review the diagnostic logs the perimeter emits, then move to Enforced once the access rules cover legitimate traffic.

10 · Associating a resource in Enforced mode (sibling module)

Once validated, switch the association to Enforced so the default-deny boundary is actively applied.

resource "azurerm_network_security_perimeter_association" "perimeter_association" {
  access_mode                  = "Enforced"
  name                         = "assoc-storage-data"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  resource_id                  = module.storage.id
}

🔒 In Enforced mode the perimeter denies everything not permitted by an explicit access rule. Confirm your rules are complete before flipping this, or legitimate traffic will be blocked.

11 · Learning-to-Enforced rollout across many resources

Roll a fleet of resources through the same lifecycle by parameterizing the association mode.

variable "perimeter_mode" {
  type    = string
  default = "Learning" # promote to "Enforced" after review
}

resource "azurerm_network_security_perimeter_association" "perimeter_associations" {
  access_mode                  = var.perimeter_mode
  name                         = "assoc-${each.key}"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  resource_id                  = each.value.id
}

💡 Flip a single variable to promote the entire trust zone from observation to enforcement in one reviewed change.

12 · Wiring diagnostics to a Log Analytics workspace

Send perimeter platform logs to a workspace using the diagnostic-setting sibling module and this perimeter's id.

module "perimeter_diagnostics" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"

  name                       = "diag-nsp-core"
  target_resource_id         = module.perimeter.id
  log_analytics_workspace_id = var.log_analytics_id
}

💡 Diagnostics are the feedback loop for Learning mode: the logs show exactly what an Enforced perimeter would block.

13 · Assigning RBAC at the perimeter scope

Grant a team management rights over the boundary using the role-assignments sibling module at this perimeter's id.

module "perimeter_rbac" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.perimeter.id

  role_assignments = {
    net_admins = {
      role_definition_name = "Network Contributor"
      principal_id         = var.network_admins_group_object_id
    }
  }
}

🔒 Scope role assignments to the perimeter id, not to the whole subscription, so boundary management stays least-privilege.

14 · 🏗️ End-to-end composition (resource group + perimeter + profile + rules + association protecting a storage account)

A complete trust zone: a resource group, a storage account, this perimeter, a profile, one inbound rule, and an enforced association that brings the storage account under the boundary.

provider "azurerm" {
  features {}
}

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

  name     = "rg-data-platform-eastus"
  location = "eastus"
}

module "storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

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

module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-data-platform"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  tags = { environment = "production", data_class = "confidential" }
}

resource "azurerm_network_security_perimeter_profile" "perimeter_profile" {
  name                         = "default"
  network_security_perimeter_id = module.perimeter.id
}

resource "azurerm_network_security_perimeter_access_rule" "perimeter_inbound_rule" {
  direction                    = "Inbound"
  name                         = "allow-corp-ingress"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  address_prefixes             = ["203.0.113.0/24"]
}

resource "azurerm_network_security_perimeter_association" "perimeter_association" {
  access_mode                  = "Enforced"
  name                         = "assoc-storage-data"
  network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
  resource_id                  = module.storage.id
}

🔒 The storage account is now governed by a default-deny boundary: only the corporate ingress prefix reaches it, and every other path is denied at the perimeter regardless of the account's own firewall.


📥 Inputs

Required

Name Type Description
name string Perimeter name, unique within the resource group. Force-new.
resource_group_name string Existing resource group that will contain the perimeter. Force-new.
location string Azure region for the perimeter. Force-new.

Optional (universal tail)

Name Type Default Description
tags map(string) {} Tags applied to the perimeter.
timeouts object(...) null Optional create/read/update/delete timeouts.
Full variable schemas
variable "name" {
  type = string
  # Immutable: changing this forces replacement of the perimeter.
}

variable "resource_group_name" {
  type = string
  # Existing resource group; the module does not create it. Immutable / force-new.
}

variable "location" {
  type = string
  # Region availability for network security perimeter varies; confirm support. Immutable / force-new.
}

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 Azure Resource ID of the network security perimeter Passthrough
name Name of the perimeter, as created Passthrough
resource_group_name Resource group containing the perimeter Passthrough
location Azure region of the perimeter, read back from Azure in normalised form Passthrough
tags Tags in force on the perimeter, read back from Azure Passthrough
perimeter_alone_enforces_nothing Constant true, and the single most important fact about this module Constant
enforcement_is_decided_by_each_association_not_by_this_resource Constant true Constant
default_association_access_mode_blocks_nothing Constant true, and the trap this module exists to warn about Constant
enforced_is_the_only_authoritative_access_mode Constant true, stated positively so a review is not reading a double negative Constant
audit_access_mode_is_accepted_by_the_api_but_undocumented Constant true, recorded because the two sources disagree Constant
private_endpoint_traffic_bypasses_the_perimeter Constant true, and a genuine hole in any threat model built on this boundary Constant
trusted_service_exceptions_are_not_honoured_in_enforced_mode Constant true, and the usual cause of breakage at cutover Constant
service_endpoint_traffic_may_be_denied_despite_an_allow_all_rule Constant true Constant
sas_token_authentication_is_rejected_on_some_paths Constant true Constant
destroy_never_requests_force_deletion Constant true Constant
removing_an_association_can_lock_the_resource_out Constant true, and the reason a rollback is not free Constant
only_tags_update_in_place Constant true Constant
force_new_fields The inputs that force replacement rather than an in-place update Derived
replacement_is_a_new_boundary_not_a_rename Constant true Constant
perimeter_guid_is_not_exposed_by_this_resource Constant true Constant
sibling_child_ids_are_built_in_the_providers_subscription Constant true, and a cross-subscription trap Constant
sentinel_enabled_workspaces_silently_lose_their_analytics_rules Constant true, and the most damaging silent interaction documented for this feature Constant
azure_backup_is_unsupported_for_perimeter_member_storage_accounts Constant true Constant
generally_available_in_every_azure_public_cloud_region Constant true, recorded because the opposite is widely assumed and was true until recently Constant
some_member_services_are_still_in_public_preview Constant true, and the qualifier the GA headline hides Constant
documented_scale_limits Microsoft's published scale limits for network security perimeter, none of which is visible in state or enforceable by this module Derived
creates_no_profile_association_or_access_rule Constant true Constant

No secret is emitted; the perimeter resource has no secret-bearing attributes.


🧠 Architecture Notes

  • The boundary is the whole module. The azurerm_network_security_perimeter resource carries only name, resource_group_name, location, and tags. There is no traffic control on the resource itself — inbound/outbound behavior is set by the sibling profile and access-rule resources, and whether a resource is observed or enforced is set by the sibling association's access mode. This module owns the boundary; the traffic graph is composed on top.
  • Force-new identity fields. name, resource_group_name, and location cannot be updated in place. Changing any of them replaces the perimeter, which detaches every associated resource and drops attached profiles and access rules. Treat a rename as a migration, not an edit.
  • for_each, never count, for fleets. When standing up multiple perimeters, drive them from a keyed map (example 4) so a stable key means adding or removing one boundary never re-indexes the others.
  • Optional timeouts renders only when set. The timeouts block is emitted through a dynamic block guarded on a non-null value, with try(...) on each field, so an omitted timeout is absent rather than an error.
  • features {} is the caller's. If the configuration appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} } block. Library modules never carry it.

🧱 Design Principles

The perimeter is itself the security control, so its secure posture is structural rather than a set of toggles.

Concern Secure default (empty call) Opt-out
Cross-perimeter traffic Default-deny — an enforced perimeter permits only what an explicit access rule allows Add explicit inbound/outbound access rules (sibling module)
Enforcement posture Recommend Enforced mode for associations once rules are validated Use Learning mode to observe without enforcing during rollout
Boundary exposure knobs None on the resource — there is no public-access or TLS argument to leave open n/a — relaxations are explicit access rules, not resource flags
Secrets The resource has no secret-bearing attributes; none are accepted or emitted n/a

🔒 Enforced mode over Learning mode is the recommended end state. Learning mode is a rollout aid, not a resting posture.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module at ?ref=v1.0.0 — never a branch — so consuming compositions are reproducible.
  • This is plan-only during authoring. A human runs terraform plan and terraform apply from CI against real credentials.

🧪 Testing

The offline proof gate runs without any cloud call:

  • terraform init -backend=false resolves the pinned provider without configuring a backend.
  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema — it surfaces every typing mistake the variable schemas are designed to catch.
  • terraform fmt -check enforces canonical formatting.

Neither validate nor fmt contacts Azure. Only terraform plan (run by a human from CI) exercises the ARM API and confirms region availability, RBAC, and resource-provider registration.


💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id                  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-security-eastus/providers/Microsoft.Network/networkSecurityPerimeters/nsp-data-platform"
location            = "eastus"
name                = "nsp-data-platform"
resource_group_name = "rg-security-eastus"

🔍 Troubleshooting

Symptom Cause Fix
Error: Insufficient features blocks on init/plan The caller's root module has no provider "azurerm" { features {} }. Add the features {} block to the root provider — it is a caller concern, never the module's.
Plan shows the perimeter being replaced after a small edit You changed name, resource_group_name, or location — all force-new. Treat the change as a migration; expect associations, profiles, and rules to detach and be recreated by their sibling modules.
The resource type is not available in the location The chosen region does not support network security perimeter. Choose a supported region; confirm availability before applying.
Legitimate traffic blocked after enabling the boundary The association is in Enforced mode but the access rules do not cover that traffic. Add the required inbound/outbound access rule, or move the association to Learning mode while you finalize rules.
AuthorizationFailed creating the perimeter The caller identity lacks perimeter write permission on the resource group. Grant Network Contributor (or Microsoft.Network/networkSecurityPerimeters/*) at the resource-group scope.

🔗 Related Docs

  • Provider resource: azurerm_network_security_perimeter
  • Sibling modules: terraform-azurerm-network-security-perimeter-profile, terraform-azurerm-network-security-perimeter-access-rule, terraform-azurerm-network-security-perimeter-association, terraform-azurerm-resource-group, terraform-azurerm-monitor-diagnostic-setting, terraform-azurerm-role-assignments
  • This module's SCOPE.md — the cross-module contract.

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