Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Network Security Perimeter Profile Terraform Module

A named collection of access rules — where the empty configuration is the closed one, and every child added to it widens access. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 📦 Creates the profile that access rules and resource associations point at — and nothing else: two arguments, both force-new, and an empty create body.
  • 🔒 An empty profile denies all public traffic once a resource is associated in Enforced mode. Every access rule added to it widens access.
  • ⚠️ Enforces nothing by itself. The access mode lives on the resource association, and its default — Transition, formerly Learning — falls back to the resource's own firewall.
  • 🔁 In Enforced mode the perimeter replaces the resource's own firewall rather than layering on it, and trusted-service exceptions stop being honoured.
  • 📏 Reports a real discrepancy: the provider's name pattern does not enforce the 80-character limit its own error message describes.
  • 🧾 Has no tags, no location, no resource_group_name — all come from the perimeter, or do not exist.

💡 Why it matters: most modules in this suite make the empty call safe by choosing closed defaults. This one has no defaults to choose — the empty profile is already the closed state, and safety erodes as rules are added elsewhere. The risk on this resource is not what it configures; it is what a destroy does, and what a reader assumes an empty profile means.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

flowchart TB
  NSP["azurerm_network_security_perimeter<br/>the boundary - has tags"]
  PROF["THIS MODULE<br/>profile - a named rule collection"]
  RULE["access rule<br/>each one WIDENS the perimeter"]
  ASSOC["resource association<br/>CARRIES THE ACCESS MODE"]
  PAAS["a PaaS resource<br/>storage, Key Vault, SQL"]
  FW["the resource's OWN firewall<br/>BYPASSED in Enforced mode"]
  PE["private endpoint traffic<br/>NEVER subject to these rules"]

  NSP -->|"network_security_perimeter_id"| PROF
  PROF -->|"profile id"| RULE
  PROF -->|"profile id"| ASSOC
  ASSOC -->|"places the resource<br/>under this profile"| PAAS
  ASSOC -.->|"Transition is the DEFAULT<br/>and enforces nothing"| FW
  PAAS --- FW
  PE -.->|"always succeeds"| PAAS

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef keystone fill:#004578,stroke:#002B44,color:#ffffff
  classDef other fill:#F0F3F7,stroke:#B8C4D0,color:#1B1B1B
  class PROF me
  class NSP keystone
  class RULE,ASSOC,PAAS,FW,PE other
Loading

The profile sits between the boundary and its rules, and it is the thing an association names. Note what it does not control: private endpoint traffic, and whether any of it is enforced at all.


🧬 What this module builds

flowchart TB
  IN1["var.name"]
  IN2["var.network_security_perimeter_id"]
  IN3["var.timeouts<br/>THREE keys - no update"]
  R["azurerm_network_security_perimeter_profile.this<br/>created with an EMPTY body"]
  O1["id - consumed by access rules<br/>and by associations"]
  O2["an_empty_profile_is_the<br/>_fully_closed_state"]
  O3["this_profile_enforces<br/>_nothing_by_itself"]
  O4["name_exceeds_the<br/>_documented_length_limit"]
  O5["fields_that_can_change = EMPTY<br/>fields_azure_returns_on_read = EMPTY"]

  IN1 --> R
  IN2 --> R
  IN3 --> R
  R --> O1
  R --> O2
  R --> O3
  R --> O4
  R --> O5

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef io fill:#F0F3F7,stroke:#B8C4D0,color:#1B1B1B
  class R me
  class IN1,IN2,IN3,O1,O2,O3,O4,O5 io
Loading

Resource inventory

Resource Count Note
azurerm_network_security_perimeter_profile 1 (this) Two arguments, both force-new. No properties at all.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures the provider, its authentication, and its mandatory features {} block.

Schema notes that bite

  • 🔴 There is no update function. Both arguments are force-new, fields_that_can_change_after_creation is empty, and the timeouts object has three keys — while its child access rule has four.
  • 🔴 fields_azure_returns_on_read is genuinely empty. The read rebuilds both arguments from the Resource ID and copies nothing from the response body. There is no property that could drift.
  • 🔴 The name pattern does not enforce the 80-character limit its own message describes, for a name made only of letters and numbers — the leading character class is unbounded. A name containing a period or hyphen is capped. Both halves verified offline: a 100-character alphanumeric name is accepted; a 101-character name with hyphens is refused.
  • 🔴 The import guard is conditional on a caller-side provider feature. With the feature that skips the import check enabled, an apply adopts and overwrites an existing profile.
  • ⚠️ No tags, no location, no resource_group_name. The profile inherits the perimeter's resource group; the provider builds this profile's ID from the perimeter's.
  • ⚠️ An empty profile is not a permissive profile. In Enforced mode it denies all public traffic.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Network/networkSecurityPerimeters/profiles/write the perimeter Create the profile.
Microsoft.Network/networkSecurityPerimeters/profiles/read the perimeter Refresh and plan.
Microsoft.Network/networkSecurityPerimeters/profiles/delete the perimeter Destroy — which destroys every access rule inside.
Network Contributor the perimeter The built-in role containing the above.

🔒 The permission worth reviewing is write on this profile's children, not on the profile. Creating a profile grants nothing. Whoever can create access rules inside it decides what may reach every resource associated with it — and in Enforced mode the perimeter overrides those resources' own firewalls, so that decision supersedes controls set by whoever owns them.


Azure Prerequisites

  • Microsoft.Network registered.
  • An existing network security perimeter. The profile is created beneath it and inherits its resource group.
  • The PaaS services you intend to protect must be onboarded to network security perimeter. Microsoft publishes the list; it changes, and several services are documented as preview.
  • Nothing takes effect until a resource association exists and its access mode is Enforced.
  • Microsoft's instruction applies to everything created under this profile: do not put personally identifiable or sensitive data into perimeter rules or configurations — the contents appear in diagnostic access logs.

📁 Module Structure

terraform-azurerm-network-security-perimeter-profile/
├── providers.tf    # required_version + the pinned azurerm; no provider block
├── variables.tf    # three inputs, two validations, the three-key timeouts object
├── main.tf         # locals parsing the perimeter and the name-length report
├── outputs.tf      # 31 outputs, led by identity and the closed-by-default framing
├── README.md       # this file
├── SCOPE.md        # the cross-module contract
├── LICENSE         # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  name                          = "defaultProfile"
  network_security_perimeter_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-perimeter-eastus/providers/Microsoft.Network/networkSecurityPerimeters/nsp-corp"
}

The caller configures the provider, its authentication and its mandatory features {} block. This module declares no provider.


🔌 Cross-Module Contract

Consumes

Input Type Source
network_security_perimeter_id string terraform-azurerm-network-security-perimeter (id)
name string caller
timeouts object (three keys) caller

Emits

Output Description
id The profile's Resource ID — consumed by access rules and by resource associations.
name The profile name.
network_security_perimeter_id, network_security_perimeter_name The parent perimeter.
resource_group_name, subscription_id Parsed from the perimeter's ID.
an_empty_profile_is_the_fully_closed_state The framing to read before anything else.
fields_that_can_change_after_creation Empty.

📚 Example Library

1 · The minimum call
module "profile" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-profile.git?ref=v1.0.0"

  name                          = "defaultProfile"
  network_security_perimeter_id = var.perimeter_id
}

ℹ️ Two arguments is the whole resource. defaultProfile is the name Azure uses for the profile it creates alongside a perimeter, and the one in the provider's own import example.

2 · Reading an empty profile correctly
output "what_this_profile_currently_permits" {
  description = "Nothing public -- once something is associated in Enforced mode."
  value = {
    empty_means_closed = module.profile.an_empty_profile_is_the_fully_closed_state
    but_enforced_yet   = module.profile.this_profile_enforces_nothing_by_itself
  }
}

🔒 Both are constant true. Together they are the two halves people get wrong in opposite directions: an empty profile is not permissive, and a full profile is not necessarily enforced.

3 · Several profiles in one perimeter
module "profiles" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-profile.git?ref=v1.0.0"
  for_each = toset(["frontend", "data", "batch"])

  name                          = each.key
  network_security_perimeter_id = var.perimeter_id
}

💡 A profile groups resources with the same access requirements. The provider takes no lock on the perimeter, so these are created concurrently and safely.

4 · Membership itself is an access decision
output "intra_perimeter" {
  description = "Resources in the SAME perimeter reach each other with no rule at all."
  value       = module.profile.intra_perimeter_traffic_is_always_permitted
}

⚠️ Microsoft: all traffic except intra-perimeter traffic is denied in Enforced mode. Putting two resources in one perimeter permits traffic between them without anyone writing a rule — including across profiles.

5 · What enforcement actually does to the resource
output "enforcement_consequences" {
  description = "Read before switching an association to Enforced mode."
  value = {
    replaces_firewall      = module.profile.enforced_rules_override_the_resources_own_firewall
    no_trusted_services    = module.profile.enforced_mode_does_not_honour_trusted_service_exceptions
    private_link_unaffected = module.profile.private_endpoint_traffic_is_never_subject_to_these_rules
  }
}

⚠️ The middle one is the usual cause of a service breaking on the day a perimeter is enforced. Trusted-service exceptions are not honoured, so an Azure service trusted by the resource's own firewall is blocked unless a perimeter rule permits it.

6 · The name-length discrepancy
module "profile" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-profile.git?ref=v1.0.0"

  # 100 characters, all alphanumeric -- ACCEPTED by the provider despite its
  # own error message describing a 1-80 limit.
  name                          = var.long_profile_name
  network_security_perimeter_id = var.perimeter_id
}

check "name_within_documented_limit" {
  assert {
    condition     = !module.profile.name_exceeds_the_documented_length_limit
    error_message = "The name is longer than the 80 characters the provider documents. Its pattern does not enforce that bound for an all-alphanumeric name, so expect Azure rather than Terraform to object."
  }
}

💡 The module mirrors the provider's pattern rather than adding the limit. Enforcing a bound the provider does not could reject a name Azure accepts — and a failed validation blocks terraform destroy too.

7 · When the discrepancy can arise at all
output "why_the_length_check_matters_here" {
  value = {
    over_80        = module.profile.name_exceeds_the_documented_length_limit
    alphanumeric   = module.profile.name_is_alphanumeric_only
  }
}

ℹ️ A name containing a period or hyphen is genuinely capped at 80, because those characters may appear only in the pattern's bounded middle section. Only a purely alphanumeric name can exceed it.

8 · Nothing about this profile can change
output "change_surface" {
  description = "Both lists are empty, for different reasons."
  value = {
    force_new = module.profile.force_new_fields                      # both arguments
    updatable = module.profile.fields_that_can_change_after_creation # []
    from_azure = module.profile.fields_azure_returns_on_read         # []
  }
}

ℹ️ The last is unusually literal: the read rebuilds both arguments from the Resource ID and copies nothing from the API response. There is no property that could drift.

9 · A destroy here tightens, it does not loosen
output "destroy_consequences" {
  value = module.profile.destroying_this_destroys_every_access_rule_inside_it
}

⚠️ Removing a profile removes approvals. In Enforced mode the traffic those rules permitted is denied immediately, so a destroy breaks connectivity rather than opening anything. That inverts the usual reading of a terraform destroy plan on a security resource.

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

  name                          = "defaultProfile"
  network_security_perimeter_id = var.perimeter_id

  # NOTE: `lifecycle` is NOT valid inside a module block. Use a CanNotDelete
  # management lock -- which prevents deletion, and does not prevent the
  # replacement that editing either argument would cause.
}

⚠️ Both arguments are force-new, so a lock does not protect against a rename. It protects against the delete, which on this resource is the operation that breaks things.

11 · Not every service is covered
output "coverage_caveat" {
  value = module.profile.only_onboarded_services_are_covered_by_a_perimeter
}

⚠️ Microsoft publishes the list of private-link resources onboarded to network security perimeter, and it changes. A service not on it is not protected by any of this, regardless of what the profile contains.

12 · Importing an existing profile
import {
  to = module.profile.azurerm_network_security_perimeter_profile.this
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-perimeter-eastus/providers/Microsoft.Network/networkSecurityPerimeters/nsp-corp/profiles/defaultProfile"
}

ℹ️ Import rather than re-create — a caller whose provider block enables the feature that skips the import check would overwrite the existing profile instead of failing.

13 · Keeping rule contents free of personal data
output "data_protection_note" {
  description = "Microsoft's own instruction, surfaced where a reviewer will see it."
  value       = module.profile.rule_contents_should_not_carry_personal_data
}

🔒 Rule contents — addresses and fully qualified domain names — are stored in configuration and appear in diagnostic access logs. Where a rule would otherwise identify an individual, that is a data-protection question rather than a networking one, and belongs with whoever owns data protection.

14 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

# 1. The perimeter -- the boundary, and the only taggable resource here.
module "perimeter" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"

  name                = "nsp-corp"
  resource_group_name = "rg-perimeter-eastus"
  location            = "eastus"
  tags                = { owner = "platform-networking" }
}

# 2. THIS MODULE -- the profile. Empty means closed.
module "profile" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-profile.git?ref=v1.0.0"

  name                          = "defaultProfile"
  network_security_perimeter_id = module.perimeter.id
}

# 3. An inbound approval. Each rule WIDENS the perimeter.
module "allow_corp_range" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-access-rule.git?ref=v1.0.0"

  name                                  = "allow-corp-range"
  network_security_perimeter_profile_id = module.profile.id

  rule = {
    direction        = "Inbound"
    address_prefixes = ["203.0.113.0/24"]
  }
}

# 4. An outbound approval -- fqdns, and nothing else, for an Outbound rule.
module "allow_storage_egress" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter-access-rule.git?ref=v1.0.0"

  name                                  = "allow-storage-egress"
  network_security_perimeter_profile_id = module.profile.id

  rule = {
    direction = "Outbound"
    fqdns     = ["*.blob.core.windows.net"]
  }
}

output "perimeter_state" {
  description = "What exists, and what still has to be true for any of it to apply."
  value = {
    profile           = module.profile.id
    empty_means_closed = module.profile.an_empty_profile_is_the_fully_closed_state
    inbound_breadth   = module.allow_corp_range.target_count
    outbound_wildcard = module.allow_storage_egress.uses_wildcard_fqdn
    enforced_yet      = module.profile.this_profile_enforces_nothing_by_itself
  }
}

⚠️ Nothing above is enforced. A resource association must exist and its access mode must be Enforced — Transition mode is the default and falls back to each resource's own firewall. That association is a separate resource, deliberately outside this module.


📥 Inputs

Input Type Required Note
name string ✅ Force-new. Pattern mirrored exactly; length reported, not enforced.
network_security_perimeter_id string ✅ Force-new. Anchored at both ends.
timeouts object — Three keys: create, read, delete.
Full schemas
variable "name" {
  type = string
  # (^[a-zA-Z0-9]+[a-zA-Z0-9_.-]{0,78}[a-zA-Z0-9_]+$)|(^[a-zA-Z0-9]$)
  # Mirrors the provider EXACTLY -- including that it does not cap an
  # all-alphanumeric name at 80 despite its own message saying 1-80.
  # Length is REPORTED via name_exceeds_the_documented_length_limit.
}

variable "network_security_perimeter_id" {
  type = string
  # /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Network
  #   /networkSecurityPerimeters/<perimeter>
  # Anchored at both ends, so a PROFILE below the perimeter is rejected too.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # No `update` key: there is no update function and nothing to update.
  # An `update` key written here is SILENTLY DISCARDED by Terraform.
}

🧾 Outputs

Output Description Note
id The profile's Resource ID. first
name The profile name.
network_security_perimeter_id / network_security_perimeter_name The parent perimeter.
resource_group_name / subscription_id Parsed from the perimeter's ID.
name_exceeds_the_documented_length_limit The message-versus-pattern discrepancy. derived
name_is_alphanumeric_only When that discrepancy can arise. derived
a_profile_has_no_configurable_properties Two force-new arguments, empty create body. constant
an_empty_profile_is_the_fully_closed_state Read this first. constant
this_profile_enforces_nothing_by_itself The other half people get wrong. constant
the_default_access_mode_falls_back_to_the_resources_own_firewall The adoption trap. constant
the_module_cannot_see_the_access_mode_of_any_association Stated, not guessed. constant
the_module_cannot_see_which_rules_or_resources_this_profile_holds Nothing points back. constant
enforced_rules_override_the_resources_own_firewall Replaces, not layers. constant
enforced_mode_does_not_honour_trusted_service_exceptions The day-one breakage. constant
intra_perimeter_traffic_is_always_permitted Membership is an access decision. constant
private_endpoint_traffic_is_never_subject_to_these_rules Not controllable here. constant
only_onboarded_services_are_covered_by_a_perimeter Coverage varies. constant
rule_contents_should_not_carry_personal_data Microsoft's instruction. constant
there_is_no_update_function_so_both_fields_are_force_new Why three timeouts. constant
force_new_fields Both arguments. list
fields_that_can_change_after_creation Empty. list
fields_azure_returns_on_read Empty, literally. list
destroying_this_destroys_every_access_rule_inside_it And that tightens. constant
the_import_guard_can_be_switched_off_by_the_caller A provider features {} flag. constant
lifecycle_prevent_destroy_is_not_available_to_a_module_caller Use a management lock. constant
this_is_a_real_azure_resource_not_a_composite Real ID, real delete. constant
the_provider_takes_no_lock_on_the_perimeter Concurrent profiles are safe. constant
this_resource_supports_no_azure_resource_tags Tag the perimeter instead. constant
no_secret_is_accepted_or_emitted_by_this_module Nothing sensitive here. constant

No output is sensitive.


🧠 Architecture Notes

The security framing is inverted, and that is the whole point. Most modules in this suite make an empty call safe by choosing the locked-down value for every boolean and enum. This resource has neither: two force-new arguments, no properties, and an empty body sent to Azure. What makes it a security resource is Microsoft's model — once a PaaS resource is associated with a perimeter in Enforced mode, all public traffic is denied by default, and access rules are how traffic is approved. So the empty profile is already the closed state, every child access rule is an opt-out, and a terraform destroy here is a tightening rather than a loosening. The module states all of that in outputs because none of it is visible in a plan.

And it enforces nothing by itself. Whether any rule in this profile applies depends on the access mode of a resource association — a separate resource, whose default is Transition mode (formerly Learning), in which the resource falls back to its own firewall when no perimeter rule matches. A fully configured profile can sit in place while nothing it says is being applied. The module cannot read associations, so it reports the dependency rather than implying enforcement.

Enforcement replaces rather than layers, and that breaks things on day one. In Enforced mode the profile's rules become the top-level gatekeeper: the resource's own allowed-networks settings are bypassed, and Azure's trusted-service exceptions are not honoured — traffic from an Azure service is blocked even where the resource's own firewall trusts it. Two things stay outside the perimeter's control entirely: private endpoint traffic always succeeds, and resources inside the same perimeter reach each other regardless of any rule. Membership is therefore an access decision made without writing one.

The name-length discrepancy is reported rather than enforced, and it was verified before being written about. The provider's pattern carries an error message promising 1–80 characters, and its leading character class is unbounded — so an all-alphanumeric name of any length passes, while a name containing a period or hyphen genuinely is capped, because those may appear only in the bounded middle section. Both halves were confirmed with an offline harness: 100 alphanumeric characters accepted, 101 characters with hyphens refused. Adding the documented limit as a validation would risk rejecting a name the provider and Azure accept, and a failed validation {} blocks terraform destroy as well as apply.

fields_azure_returns_on_read is empty in the literal sense. The read function rebuilds both arguments from the Resource ID and copies nothing at all from the response body — the resource has no properties for Azure to return. Drift in anything but the profile's existence is undetectable because there is nothing that could drift.


🧱 Design Principles

Concern This module's position Opt-out
Secure by default Already the case, and not by choice. The resource has no defaults to set; the empty profile denies all public traffic in Enforced mode. Every access rule created elsewhere widens it. create access rules
Rejecting legal input The name pattern is mirrored exactly; length is reported. —
Unverifiable facts The association's access mode, and which rules or resources reference this profile, are stated as unknowable. —
Service consequences Emitted as constants, because they decide whether enforcement breaks a working system. —
Secrets None accepted, none emitted. —
Destroy protection lifecycle is unavailable to a module caller; a CanNotDelete lock prevents deletion, not replacement. —
Resource IDs Anchored at both ends; a child profile ID is refused where a perimeter is wanted. —
Personal data Microsoft's instruction surfaced as an output rather than left in a doc. —

🚀 Runbook

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

Pin the source at ?ref=v1.0.0, never a branch. This module is plan-only; a human applies from CI.


🧪 Testing

terraform validate and terraform fmt -check cover the module's structure and both validation {} blocks — the name pattern and the anchored perimeter Resource ID. Both fire offline with no credentials.

terraform console against a root module is the harness that actually exercises them, because validate on a calling configuration does not fire them. Terraform also skips a validation whose referenced variable has already failed, so a short error list is not evidence a check is missing.

The name pattern deserves testing in both directions rather than one, because the interesting behaviour is what it accepts: a 100-character all-alphanumeric name passes while a 101-character name containing hyphens is refused. Asserting only the rejection would have missed the discrepancy entirely.

What only a real apply can reach: whether the perimeter exists, whether the acting identity holds write on it, and every service-level consequence in this document — enforcement, trusted-service behaviour and coverage all depend on associations and on which services Microsoft has onboarded, none of which is visible from here.


💬 Example Output

id                              = "/subscriptions/00000000-.../networkSecurityPerimeters/nsp-corp/profiles/defaultProfile"
name                            = "defaultProfile"
network_security_perimeter_name = "nsp-corp"
resource_group_name             = "rg-perimeter-eastus"
name_exceeds_the_documented_length_limit = false
name_is_alphanumeric_only       = true
force_new_fields                = ["name", "network_security_perimeter_id"]
fields_that_can_change_after_creation = []
fields_azure_returns_on_read    = []
an_empty_profile_is_the_fully_closed_state = true
this_profile_enforces_nothing_by_itself    = true

🔍 Troubleshooting

Symptom Cause Fix
The profile exists but nothing is blocked or allowed A profile enforces nothing on its own, and the default association access mode falls back to the resource's own firewall. Create a resource association and set its access mode to Enforced.
An Azure service stopped working the moment the perimeter was enforced Trusted-service exceptions are not honoured in Enforced mode. Add an access rule — a subscription rule inbound, an FQDN rule outbound — or use a private endpoint.
The resource's own firewall rules appear to be ignored In Enforced mode the perimeter's rules are the top-level gatekeeper and the resource's allowed-networks settings are bypassed. Expected. Express the intent as perimeter access rules, or remove the resource from the perimeter to hand control back.
Traffic between two resources works with no rule permitting it Intra-perimeter traffic is always permitted. Expected. If they must not communicate, put them in different perimeters — profiles do not separate them.
A private endpoint connection succeeds despite the rules Private endpoint traffic is never subject to perimeter rules. Expected, and not changeable from here.
A name longer than 80 characters passed validation The provider's pattern does not enforce the limit its message describes, for an all-alphanumeric name. Check name_exceeds_the_documented_length_limit. Expect Azure rather than Terraform to object.
update = "30m" in timeouts appears to do nothing It does nothing. Terraform silently discards the undeclared key. Remove it — this resource has no update function.
Editing the name plans a destroy and create Both arguments are force-new. Expected, and note the replacement takes every access rule in the profile with it.
Destroying the profile broke working connectivity Rules are approvals; removing them denies that traffic in Enforced mode. Restore the rules, or switch the association to Transition mode while rebuilding.
An apply overwrote an existing profile instead of failing The caller's provider block enables the feature that skips the import check. Remove that feature setting, or import the profile rather than re-creating it.

🔗 Related Docs


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