Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Monitor Private Link Scoped Service Terraform Module

Adds one Azure Monitor resource to an Azure Monitor Private Link Scope. Four arguments, all immutable. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • βš™οΈ Creates one azurerm_monitor_private_link_scoped_service, named this β€” the membership record that brings a workspace, an Application Insights component, or a Data Collection Endpoint inside a scope.
  • πŸ”΄ The harm is done by the memberships that do not exist. A scope set to PrivateOnly blocks every Azure Monitor resource that is not in it, across every network sharing the same DNS, in any subscription or tenant.
  • 🧊 Every one of the four arguments is immutable, which is why the provider's timeouts block has no update key. This module's timeouts type has three keys to match.
  • 🎯 Rejects the two values most likely to be transposed: the scope's Resource ID where its name belongs, and a bare name where an ID belongs.
  • πŸ” Parses the linked resource's own resource group and subscription out of its ID, because resource_group_name on this resource is the scope's resource group and the argument name does not say so.
  • πŸ“£ States plainly what it cannot see: whether the scope is complete, and whether the link crosses a subscription.

πŸ’‘ Why it matters: this is a four-argument association with no settings, and it decides whether telemetry is reachable. Getting it wrong produces no Terraform error β€” just a resource that a whole network can no longer talk to, or a scope quietly missing a member. Both are invisible in a plan.


❀️ Support this project

If this module saves you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  scope["terraform-azurerm-monitor-private-link-scope"]
  this["terraform-azurerm-monitor-private-link-scoped-service"]
  law["terraform-azurerm-log-analytics-workspace"]
  appi["terraform-azurerm-application-insights"]
  dce["terraform-azurerm-monitor-data-collection-endpoint"]
  pe["terraform-azurerm-private-endpoint"]

  rg -->|"name"| scope
  scope -->|"name plus resource_group_name"| this
  law -->|"id, one of three legal types"| this
  appi -->|"id, one of three legal types"| this
  dce -->|"id, one of three legal types"| this
  pe -.->|"attaches to the scope, not to this record"| scope

  style this fill:#0078D4,stroke:#004578,color:#ffffff
  style scope fill:#004578,stroke:#004578,color:#ffffff
  style rg fill:#F3F6F9,stroke:#8A8886,color:#201F1E
  style law fill:#F3F6F9,stroke:#8A8886,color:#201F1E
  style appi fill:#F3F6F9,stroke:#8A8886,color:#201F1E
  style dce fill:#F3F6F9,stroke:#8A8886,color:#201F1E
  style pe fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Loading

Note the dashed edge. The private endpoint attaches to the scope, not to this membership record β€” a membership on its own routes nothing, and an endpoint on its own reaches nothing that has not been added.


🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs, all four force-new"]
    i1["scope_name, a NAME"]
    i2["resource_group_name, the SCOPE's"]
    i3["linked_resource_id, one of three types"]
    i4["name, the association record"]
  end

  this["azurerm_monitor_private_link_scoped_service.this"]

  subgraph outputs["Outputs"]
    o1["id"]
    o2["linked_resource_type, in words"]
    o3["linked_resource_group_name, parsed"]
    o4["links_across_resource_groups"]
    o5["omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only"]
  end

  i1 --> this
  i2 --> this
  i3 --> this
  i4 --> this
  this --> o1
  this --> o2
  this --> o3
  this --> o4
  this --> o5

  style this fill:#0078D4,stroke:#004578,color:#ffffff
  style inputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
  style outputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Loading

Resource inventory

Resource Count Notes
azurerm_monitor_private_link_scoped_service.this 1 The keystone; all four arguments force-new
timeouts block 0–1 create / read / delete only β€” no update exists

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures the provider, its auth, and its features {} block
ARM API version Microsoft.Insights 2019-10-17-preview

Schema notes that bite

  • πŸ”΄ All four arguments are force-new. There is no in-place change this resource accepts.
  • πŸ”΄ timeouts has no update key, because no update operation exists. A four-key timeouts object would have its fourth key silently discarded.
  • πŸ”΄ resource_group_name is the SCOPE's resource group, not the linked resource's. The provider's own example uses one resource group for everything, which hides it.
  • πŸ”΄ scope_name wants a name; linked_resource_id wants an ID. The two likeliest wrong values are each other.
  • ⚠️ Exactly three linked types are accepted, and the provider reports all three validators' errors at once when none matches.
  • ⚠️ Case differences in linked_resource_id are suppressed β€” re-casing does not force replacement.
  • ⚠️ No tags, no location. Tag the scope instead.
  • ⚠️ A virtual network connects to exactly one scope, so that scope must hold everything the network needs.

πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Microsoft.Insights/privateLinkScopes/scopedResources/write The private link scope Create the association
The Terraform identity Microsoft.Insights/privateLinkScopes/scopedResources/read The association Refresh and plan
The Terraform identity Microsoft.Insights/privateLinkScopes/scopedResources/delete The association Destroy
The Terraform identity Microsoft.Insights/privateLinkScopes/read The private link scope The scope is located by name, so it must be readable
The Terraform identity read on the linked resource The workspace, component or DCE Azure validates the target on create

Monitoring Contributor covers every row. Note that the write is granted at the scope, not at the linked resource β€” so an identity that adds a workspace to a scope needs no write access to the workspace itself.

Plan access is read-only in the useful sense. id is the only computed attribute and no secret is readable through this resource.

⚠️ The privilege that matters is not in the table. Adding or removing a membership changes network reachability for every network attached to the scope, in any subscription or tenant sharing the DNS. scopedResources/delete on a PrivateOnly scope cuts a resource off, and no role boundary limits that blast radius to the resource group the association lives in.


Azure Prerequisites

  • Microsoft.Insights registered on the subscription.
  • An existing Azure Monitor Private Link Scope and its resource group. This module locates the scope by name and never creates it.
  • An existing linked resource of one of the three accepted types.
  • A private endpoint attached to the scope, if private connectivity is the goal β€” a membership alone routes nothing.
  • The private DNS zones that endpoint requires.
  • A subnet of at least /27. Microsoft's guidance is that an Azure Monitor private link setup needs eleven addresses beyond Azure's five reserved ones, even for a single workspace.

πŸ“ Module Structure

terraform-azurerm-monitor-private-link-scoped-service/
β”œβ”€β”€ providers.tf    # required_version, pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 5 inputs, 11 validations
β”œβ”€β”€ main.tf         # the keystone plus the ID parsing a plan cannot show
β”œβ”€β”€ outputs.tf      # id first, then identity, then the facts worth asserting on
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "workspace_in_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.log_analytics_workspace_id
}

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


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
scope_name Scope name terraform-azurerm-monitor-private-link-scope β†’ name
resource_group_name The scope's resource group terraform-azurerm-monitor-private-link-scope β†’ resource_group_name
linked_resource_id ARM Resource ID, one of three types terraform-azurerm-log-analytics-workspace β†’ id, terraform-azurerm-application-insights β†’ id, terraform-azurerm-monitor-data-collection-endpoint β†’ id

Emits

Output Description
id Association Resource ID, emitted first
linked_resource_type Which of the three types, in words
linked_resource_group_name The linked resource's resource group, parsed
links_across_resource_groups The argument-name trap, made assertable
every_argument_is_force_new Change planning
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only The one to read
ampls_limits_as_of_authoring Published limits, as a snapshot

πŸ“š Example Library

1 Β· The whole module
module "workspace_in_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.log_analytics_workspace_id
}

ℹ️ There is no larger version of this call. Four required arguments, one optional timeouts block, and no settings β€” everything that can be configured about private access lives on the scope, not here.

2 Β· An Application Insights component
module "component_in_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-appi-shop"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.application_insights_id
}

output "what_was_linked" {
  value = module.component_in_scope.linked_resource_type
}

πŸ’‘ linked_resource_type reads back "Application Insights component". The type is buried mid-way through a long Resource ID where it is easy to misread, and it also determines which private endpoint DNS records the link will need.

3 Β· A Data Collection Endpoint
module "dce_in_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-dce-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.data_collection_endpoint_id
}

⚠️ These three types β€” workspace, component, Data Collection Endpoint β€” are the only ones a scope accepts. Agents that send data through a DCE need the DCE in the scope; adding only the workspace is a common and silent omission.

4 Β· The transposition this module rejects
# REJECTED at plan time by this module.
module "id_where_a_name_belongs" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = module.private_link_scope.resource_group_name
  scope_name          = module.private_link_scope.id # wrong: this field wants the NAME
  linked_resource_id  = module.workspace.id
}

πŸ”’ scope_name and linked_resource_id sit next to each other and want opposite forms, and the scope module emits both name and id. Passing the ID gets you:

scope_name must be the private link scope's NAME, not its Resource ID. Pass the scope module's name
output rather than its id output - linked_resource_id is the field that wants an ID.

Without the check, Azure rejects it at apply with an error that names neither field.

5 Β· The other half of the transposition
# REJECTED at plan time by this module.
module "name_where_an_id_belongs" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = "law-core" # wrong: this field wants the full Resource ID
}

πŸ”’ A bare name is rejected with a message that names the other field, so the fix is obvious in both directions.

6 Β· A resource type no scope can hold
# REJECTED at plan time by this module.
module "wrong_type" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-storage"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.storage_account_id
}

⚠️ The provider enforces the same three types, but does it with a combinator that prints all three validators' failures at once when none matches β€” which does not read as an answer. This module gives one message naming the three legal types. A near miss like an action group ID under Microsoft.Insights is rejected the same way.

7 Β· The resource group that belongs to the scope
module "cross_rg_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-appi-shop"
  resource_group_name = "rg-networking"           # the SCOPE's resource group
  scope_name          = "ampls-platform"
  linked_resource_id  = var.application_insights_id # lives in rg-application
}

output "check_the_two_resource_groups" {
  value = {
    scope_is_in           = module.cross_rg_link.resource_group_name
    linked_resource_is_in = module.cross_rg_link.linked_resource_group_name
    they_differ           = module.cross_rg_link.links_across_resource_groups
  }
}

πŸ”’ links_across_resource_groups is true here, and that is perfectly legal β€” a scope routinely holds resources from other resource groups. It is emitted because this is the shape a caller creates by accident when they read resource_group_name as belonging to the thing being linked. If it is true and you did not mean it, one of the two values is wrong.

8 Β· Asserting the resource groups match, when they should
module "same_rg_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.observability_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.log_analytics_workspace_id
}

check "the_scope_and_workspace_share_a_resource_group" {
  assert {
    condition     = !module.same_rg_link.links_across_resource_groups
    error_message = "The scope and the linked workspace are in different resource groups. In this estate they should match, so one of resource_group_name or linked_resource_id is wrong."
  }
}

πŸ’‘ A check block is right here because whether the two resource groups should match is an estate convention, not an Azure rule. The module reports; the composition decides.

9 Β· Every argument is force-new
module "repointed_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.replacement_workspace_id # any change here replaces the record
}

⚠️ Changing any of the four arguments destroys and recreates the association. Between the destroy and the create, the linked resource is not inside the scope β€” which, on a PrivateOnly scope, means it is unreachable from every attached network for the duration.

A module block cannot carry a lifecycle block, so a caller cannot add create_before_destroy here. Where that window matters, add the new membership under a second module instance first, then remove the old one in a later apply.

10 Β· The `timeouts` block with three keys
module "slow_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.log_analytics_workspace_id

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

⚠️ There is no update key, because the resource has no update operation β€” every argument is force-new. Writing update = "30m" here is silently discarded by object type conversion rather than rejected: the plan succeeds and the key does nothing. That is the one place a wrong input to this module produces no signal at all.

11 Β· A whole scope's membership, driven by `for_each`
locals {
  # The explicit inventory of everything this scope must contain. This map is the real control -
  # anything missing from it is unreachable once the scope is PrivateOnly. In a real composition these
  # values come from the workspace, component and DCE module outputs; see example 13.
  monitored = {
    law_core   = var.log_analytics_workspace_id
    appi_shop  = var.application_insights_id
    dce_agents = var.data_collection_endpoint_id
  }
}

module "scope_members" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
  for_each = local.monitored

  name                = "amplssvc-${each.key}"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = each.value
}

check "the_scope_contains_everything_the_inventory_lists" {
  assert {
    condition     = length(module.scope_members) == length(local.monitored)
    error_message = "A monitored resource in the inventory has no membership in the private link scope."
  }
}

output "membership_types" {
  value = { for k, m in module.scope_members : k => m.linked_resource_type }
}

πŸ”’ This is where completeness can actually be checked. A single instance of this module cannot see the scope's other memberships β€” one_module_instance_cannot_see_the_scope_s_other_memberships says so β€” so the inventory and the assertion have to live in the composition that owns the scope. A keyed map rather than a list means removing one member never re-indexes the rest.

12 Β· Reading the published limits
module "workspace_in_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"

  name                = "amplssvc-law-core"
  resource_group_name = var.private_link_scope_resource_group_name
  scope_name          = var.private_link_scope_name
  linked_resource_id  = var.log_analytics_workspace_id
}

output "scope_capacity" {
  value = module.workspace_in_scope.ampls_limits_as_of_authoring
}

ℹ️ The output name carries as_of_authoring because these numbers move β€” Microsoft's own pages disagreed while this module was written, one listing 300 workspaces and another 3,000. The higher set is reported because the stated increase date has passed. The entry that constrains a design rather than a scale is scopes_per_virtual_network = 1: a network reaches exactly one scope, so that scope must contain everything the network needs.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-observability-eastus2"
  location = "eastus2"
  tags     = { workload = "platform", managed_by = "terraform" }
}

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

  name                = "law-platform-eastus2"
  location            = module.observability_rg.location
  resource_group_name = module.observability_rg.name
  tags                = { workload = "platform", managed_by = "terraform" }
}

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

  name                = "appi-shop-eastus2"
  location            = module.observability_rg.location
  resource_group_name = module.observability_rg.name
  application_type    = "web"

  # The workspace's ARM Resource ID, not the workspace_id GUID the same module also emits.
  workspace_id = module.workspace.id

  tags = { workload = "platform", managed_by = "terraform" }
}

module "private_link_scope" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scope.git?ref=v1.0.0"

  name                = "ampls-platform"
  resource_group_name = module.observability_rg.name

  # Both access modes default to PrivateOnly in that module. That is the correct default,
  # and it is exactly what makes a missing membership below expensive.
  tags = { workload = "platform", managed_by = "terraform" }
}

locals {
  # Everything that must stay reachable once the scope is PrivateOnly.
  monitored = {
    law_platform = module.workspace.id
    appi_shop    = module.app_insights.id
  }
}

module "scope_members" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
  for_each = local.monitored

  name                = "amplssvc-${each.key}"
  resource_group_name = module.private_link_scope.resource_group_name
  scope_name          = module.private_link_scope.name
  linked_resource_id  = each.value
}

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

  name                = "pe-ampls-platform"
  location            = module.observability_rg.location
  resource_group_name = module.observability_rg.name
  subnet_id           = var.private_endpoint_subnet_id

  private_service_connection = {
    name                           = "psc-ampls-platform"
    private_connection_resource_id = module.private_link_scope.id
    subresource_names              = ["azuremonitor"]
    is_manual_connection           = false
  }

  tags = { workload = "platform", managed_by = "terraform" }
}

check "every_monitored_resource_is_in_the_scope" {
  assert {
    condition     = length(module.scope_members) == length(local.monitored)
    error_message = "A resource in the monitored inventory has no membership in the private link scope, so it will be unreachable once the scope is PrivateOnly."
  }
}

check "memberships_and_scope_share_a_resource_group" {
  assert {
    condition = alltrue([
      for k, m in module.scope_members : !m.links_across_resource_groups
    ])
    error_message = "A membership links a resource from a different resource group. Legal, but unintended in this estate."
  }
}

output "private_monitoring_posture" {
  value = {
    scope_id             = module.private_link_scope.id
    ingestion_access     = module.private_link_scope.ingestion_access_mode
    query_access         = module.private_link_scope.query_access_mode
    member_count         = length(module.scope_members)
    member_types         = { for k, m in module.scope_members : k => m.linked_resource_type }
    endpoint_id          = module.monitor_endpoint.id
    omission_is_the_risk = values(module.scope_members)[0].omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only
  }
}

πŸ—οΈ Read the ordering here, because Terraform's dependency direction runs opposite to Microsoft's advice. The memberships reference the scope, so the scope and its PrivateOnly access modes are created first and the members are added after β€” meaning a single apply passes through a state where the scope is PrivateOnly with fewer members than intended. For a greenfield estate that window is harmless, because the private endpoint does not exist yet either. For an existing estate, follow Microsoft's sequence instead: add every membership while the scope is still Open, then tighten the access modes in a later apply.

Note also that module.workspace.id feeds Application Insights, not module.workspace.workspace_id β€” that module emits both, and only the first is an ARM Resource ID.


πŸ“₯ Inputs

Required (4)

Name Type Description
name string Name of the association record. Force-new.
resource_group_name string The scope's resource group. Force-new.
scope_name string The scope's name, not its ID. Force-new.
linked_resource_id string ARM Resource ID of one of three types. Force-new.

Optional (1)

Name Type Default Description
timeouts object(...) null create / read / delete only. No update.
Full input schemas
variable "scope_name" {
  type = string
  # The private link scope's NAME. Rejected if a Resource ID is passed.
  # 1-255 characters, must not end with a period. Immutable.
}

variable "resource_group_name" {
  type = string
  # The SCOPE's resource group, not the linked resource's. Immutable.
}

variable "linked_resource_id" {
  type = string
  # Exactly one of these three forms:
  #   /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.OperationalInsights/workspaces/<name>
  #   /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/components/<name>
  #   /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/dataCollectionEndpoints/<name>
  # For a workspace, pass the module's id output - not its workspace_id GUID. Immutable.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # Three keys, matching the provider. There is no update operation on this resource.
}

This resource supports neither tags nor location, so this module omits both. Tag the private link scope instead.


🧾 Outputs

Output Description Notes
id Association Resource ID Emitted first; nests under the scope's path
name The association record's name Not the linked resource's name
scope_name The scope this belongs to
resource_group_name The scope's resource group
linked_resource_id The linked resource
linked_resource_type Which of the three types, in words
linked_resource_name Parsed from the ID
linked_resource_group_name The linked resource's resource group Parsed
linked_resource_subscription_id Parsed from the ID
links_across_resource_groups Whether the two resource groups differ Legal; assertable
crossing_subscriptions_is_not_determinable_here Constant true An honest limit
every_argument_is_force_new Constant true Change planning
timeouts_has_no_update_key_because_nothing_can_be_updated Constant true
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only Constant true Read this one
one_module_instance_cannot_see_the_scope_s_other_memberships Constant true Composition design
ampls_limits_as_of_authoring Published limits A snapshot, not a check
accepts_no_credential Constant true

No secret is emitted. Nothing here is marked sensitive, because nothing here is a secret.


🧠 Architecture Notes

The risk is an absence. Everything else in this library guards against a resource being configured wrongly. This one guards against a resource not being configured at all. A scope in PrivateOnly mode blocks Azure Monitor resources that are not in it β€” across every network sharing the same DNS, regardless of subscription or tenant. So the failure is a membership nobody created, and no plan of this module can show you that. one_module_instance_cannot_see_the_scope_s_other_memberships states the limit, and the for_each examples show where the real check belongs: an explicit inventory in the composition that owns the scope.

A correct default that raises the stakes. The scope module in this suite defaults both access modes to PrivateOnly, which is right. It also means the strictest configuration is the one you get without asking, which makes a forgotten membership expensive rather than merely untidy. That is worth knowing about a secure default rather than being surprised by it.

Terraform's ordering is the reverse of Microsoft's advice. Microsoft's documented sequence for an existing estate is to add every Azure Monitor resource to the scope first and switch to PrivateOnly afterwards. But a membership references its scope, so Terraform must create the scope β€” with whatever access modes it was declared with β€” before any membership exists. A single apply therefore passes through a state where the scope is PrivateOnly and incomplete. Greenfield estates are unaffected because no private endpoint exists yet; existing estates should tighten the access modes in a separate, later apply.

Total immutability, and its evidence. All four arguments force replacement, so this record supports no in-place change at all. The provider's timeouts block confirms it by offering only create, read and delete β€” the missing update is a signal, not an oversight, and this module's type matches it exactly. That matters because object type conversion drops undeclared keys silently: a four-key timeouts would give a caller an update timeout that appears configured and does nothing.

The two fields that want opposite forms. scope_name needs a name; linked_resource_id, immediately below it, needs a full Resource ID. The scope module emits both name and id, so the wrong one is always in reach. Both directions are rejected at plan time with a message naming the other field, because Azure's own error names neither.

One resource group belongs to something else. resource_group_name here identifies the scope, not the linked resource β€” the two are frequently different, the argument name gives no hint, and the provider's example uses one resource group throughout, which hides it. The linked resource's own resource group and subscription are parsed out of its ID and emitted so the distinction is visible in a plan rather than discovered at apply.

Membership is not connectivity. A membership brings a resource inside the scope. Reaching it privately also needs a private endpoint attached to the scope and the private DNS zones that endpoint requires. Neither is this module's concern, and neither is implied by a successful apply here.


🧱 Design Principles

Concern This module's behaviour Opt-out
Scope reference form the scope's name required; an ID is rejected none
Linked resource form a full ARM ID required; a bare name is rejected none
Linked resource type exactly three types accepted none; no other type can be added
Immutability all four arguments force-new, and it is emitted none; this is the resource
timeouts shape three keys, matching the provider exactly none
Secrets none accepted, none emitted none

⚠️ This module has no empty call and no security toggle, so the suite's secure-by-default rule cannot apply here. Every argument is required and none of them is a switch. What the module does instead is refuse the wrong kind of value in the two places a wrong value is plausible, and report the consequences a plan cannot show. There were no provider defaults to examine, because there are no optional arguments beyond timeouts.


πŸš€ Runbook

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

Pin the module with ?ref=v1.0.0 β€” never a branch. This module is plan-only in CI; a human applies.


πŸ§ͺ Testing

terraform validate proves the configuration parses and every type resolves; terraform fmt -check proves the formatting. Neither runs a variable validation block on this module as a called module β€” but terraform console does, when this directory is the root:

echo 'null' | terraform console -var-file=bad.tfvars

All 11 validations were proven to fire this way, identified by line number rather than message text. One fixture necessarily trips two checks: a bare name in linked_resource_id fails both the "must start with /subscriptions/" check and the three-type check, since a name satisfies neither. The broad check supplies the pointed message; the narrow one is isolated with a real Resource ID of a rejected type.

Seven good fixtures were confirmed to fire nothing β€” one for each accepted linked type, a lower-cased provider namespace (which the provider suppresses and this module must not reject), a 255-character scope name, a scope name containing legal punctuation, and a full three-key timeouts block.

The ID parsing was verified against all three linked types rather than assumed, because an off-by-one in segment arithmetic yields a plausible wrong answer that passes every other check.

What only a real plan or apply can exercise: whether the scope exists, whether the linked resource exists, whether the scope already holds this resource, and β€” the thing no tooling can tell you β€” whether the scope holds everything it needs to.


πŸ’¬ Example Output

id                              = "/subscriptions/.../resourceGroups/rg-observability-eastus2/providers/Microsoft.Insights/privateLinkScopes/ampls-platform/scopedResources/amplssvc-law-platform"
name                            = "amplssvc-law-platform"
scope_name                      = "ampls-platform"
resource_group_name             = "rg-observability-eastus2"
linked_resource_type            = "Log Analytics workspace"
linked_resource_name            = "law-platform-eastus2"
linked_resource_group_name      = "rg-observability-eastus2"
linked_resource_subscription_id = "00000000-0000-0000-0000-000000000000"
links_across_resource_groups    = false
every_argument_is_force_new     = true
timeouts_has_no_update_key_because_nothing_can_be_updated = true
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only = true
one_module_instance_cannot_see_the_scope_s_other_memberships = true
ampls_limits_as_of_authoring = {
  "application_insights_components_per_scope" = 10000
  "log_analytics_workspaces_per_scope"        = 3000
  "private_endpoints_per_scope"               = 10
  "scopes_per_azure_monitor_resource"         = 100
  "scopes_per_virtual_network"                = 1
}
accepts_no_credential = true

πŸ” Troubleshooting

Symptom Cause Fix
A workspace became unreachable after tightening the scope It has no membership, and the scope is PrivateOnly Add a membership for it; see the for_each inventory example
Everything in one subscription broke, including other teams' resources PrivateOnly blocks non-members across every network sharing the DNS, in any subscription or tenant Add the missing members, or set the scope back to Open until the inventory is complete
Apply fails naming neither field The scope's ID was passed to scope_name, or a bare name to linked_resource_id The module now rejects both at plan time
Apply fails with three validation errors at once linked_resource_id is not one of the three accepted types Pass a workspace, component, or Data Collection Endpoint ID
linked_resource_group_name differs from resource_group_name unexpectedly resource_group_name is the scope's Check links_across_resource_groups; correct whichever value is wrong
A workspace was added but agents still cannot send data The Data Collection Endpoint is not in the scope Add a membership for the DCE too
The membership exists but nothing resolves privately No private endpoint on the scope, or missing private DNS zones Attach a private endpoint to the scope and create its DNS zones
An update timeout appears to do nothing There is no update operation; the key was silently discarded Remove it; use create, read or delete
Any argument change destroys the record All four are force-new Expected; add the replacement membership before removing the old one
Portal monitoring views fail over the private link Portal and extension traffic needs its own service tags Allow AzureActiveDirectory, AzureResourceManager, AzureFrontDoor.FirstParty and AzureFrontdoor.Frontend
Private endpoint will not fit the subnet Azure Monitor private link needs 11 addresses beyond Azure's 5 reserved Use a /27 or larger
A second scope cannot be attached to the same network A virtual network connects to exactly one scope Consolidate into one scope; see ampls_limits_as_of_authoring

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."