Gives an Azure Data Explorer cluster a private route out to a data source (
azurerm_kusto_cluster_managed_private_endpoint) — the outbound direction, which is the opposite of what the name suggests. Targetshashicorp/azurerm ~> 4.0.
- 🔌 Declares one managed private endpoint for a Kusto cluster, as a keystone resource named
this. - ➡️ This is private egress from the cluster. It lets ingestion read from a storage account or event hub whose firewall denies public access. It does not make the cluster privately reachable — that is
terraform-azurerm-private-endpoint, a different resource in your own virtual network. - ⏳ Creating it only requests the connection. Someone with rights on the target has to approve it, quite possibly a different team, and until they do ingestion from that target keeps failing. A clean apply is not a working private path.
- 💬 Recommends
request_message— the only updatable field, and the only thing that lets an approver act on the request confidently. - 🎯 Validates
private_link_resource_idfor shape but deliberately notgroup_id, whose legal set is target-specific and grows. - 📛 Notes the provider inconsistency: this resource takes the cluster by name, while its customer-managed-key sibling takes
cluster_id.
💡 Why it matters: Two failure modes here are quiet rather than loud. Confusing this with a private endpoint gives you a working resource that does nothing you wanted. And a connection left in
Pendinglooks identical in Terraform to one that was approved — the apply succeeded either way, while the ingestion it was meant to enable keeps failing for a reason nothing in your state file mentions.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
cluster["terraform-azurerm-kusto-cluster"]
db["terraform-azurerm-kusto-database"]
adc["terraform-azurerm-kusto-attached-database-configuration"]
script["terraform-azurerm-kusto-script"]
cosmos["terraform-azurerm-kusto-cosmosdb-data-connection"]
eg["terraform-azurerm-kusto-eventgrid-data-connection"]
eh["terraform-azurerm-kusto-eventhub-data-connection"]
iot["terraform-azurerm-kusto-iothub-data-connection"]
cmk["terraform-azurerm-kusto-cluster-customer-managed-key"]
mpe["terraform-azurerm-kusto-cluster-managed-private-endpoint"]
cpa["terraform-azurerm-kusto-cluster-principal-assignment"]
dpa["terraform-azurerm-kusto-database-principal-assignment"]
cluster -->|"hosts"| db
cluster -->|"attaches leader db"| adc
db -->|"target of"| script
db -->|"ingests via"| cosmos
db -->|"ingests via"| eg
db -->|"ingests via"| eh
db -->|"ingests via"| iot
cluster -->|"encrypted by, BY ID"| cmk
cluster -->|"private egress via, BY NAME"| mpe
cluster -->|"data-plane roles: ALL databases"| cpa
db -->|"data-plane roles: ONE database, prefer this"| dpa
mpe -->|"makes the source reachable"| eh
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
class mpe me;
class cluster,db,adc,script,cosmos,eg,eh,iot,cmk,cpa,dpa ext;
flowchart TB
dir["THE OUTBOUND DIRECTION, and the opposite of what the name suggests to most readers"]
mpe["MANAGED private endpoint (this): the Kusto SERVICE operates it in ITS managed network, giving the CLUSTER private egress TO a data source"]
pe["PRIVATE endpoint (terraform-azurerm-private-endpoint): lives in YOUR virtual network, giving clients private ingress INTO something"]
both["neither replaces the other. A fully private ingestion path usually needs BOTH."]
pending["CREATING THIS ONLY REQUESTS THE CONNECTION: it arrives on the target as Pending"]
approve["someone with rights on the TARGET must approve it, quite possibly a different team"]
clean["so a clean apply does NOT mean a working private path, and ingestion keeps failing until approval"]
msg["request_message: the ONLY updatable field, and the only thing that lets an approver act confidently"]
gid["group_id: target-type specific, e.g. blob or dfs or namespace or sqlServer. Deliberately NOT enum-validated because the legal set grows."]
byname["takes cluster_name BY NAME, unlike the customer-managed-key sibling which takes cluster_id: the inconsistency is the provider's"]
region["private_link_resource_region needed only for a CROSS-REGION target, and its absence fails at apply not plan"]
this["terraform-azurerm-kusto-cluster-managed-private-endpoint"]
res["azurerm_kusto_cluster_managed_private_endpoint.this"]
out["outputs: id, name, target, group_id, has_request_message. Approval STATE is not exposed by the provider."]
dir -->|"read this first"| mpe
mpe -->|"contrast"| pe
pe -->|"conclusion"| both
both -->|"choose knowingly"| this
pending -->|"read this too"| approve
approve -->|"consequence"| clean
clean -->|"disclosed"| this
msg -->|"smooths approval"| approve
gid -->|"the target sub-resource"| this
byname -->|"identity"| this
region -->|"cross-region only"| this
this -->|"creates"| res
res -->|"emits"| out
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class res keystone;
class dir,mpe,pe,both,pending,approve,clean,msg,gid,byname,region,out sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_kusto_cluster_managed_private_endpoint.this |
1 | The keystone. A child of the cluster; supports no tags, so the universal tail is timeouts only. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite — confirmed against the live provider schema:
- Everything except
request_messageis force-new. Replacing the endpoint tears down the cluster's private route to the target — and the new request needs approving again. - The cluster is taken by
cluster_name, notcluster_id— unlikeazurerm_kusto_cluster_customer_managed_keyin the same family, which takescluster_id. The inconsistency is the provider's. group_idhas no enum in the schema. The legal set depends on the target's resource type, so a wrong value fails at apply.- Creation does not wait for approval. The apply returns once the request is registered.
private_link_resource_regionis required for a cross-region target, and its absence fails at apply rather than at plan.- The approval state is not exposed on this resource. There is no attribute to read it from.
- No
tags. The universal tail istimeoutsonly.
| Scope | Role / permission | Why |
|---|---|---|
| The Kusto cluster | Contributor, or a custom role with Microsoft.Kusto/clusters/managedPrivateEndpoints/write |
The endpoint is a child of the cluster, so this is where the write lands. |
| The target resource | Microsoft.Network/privateEndpointConnections/write on that resource — commonly held by its Owner or Contributor |
⚠️ Note the asymmetry, because it explains the whole operational shape of this resource: requesting needs nothing on the target. So a caller with rights only on the cluster can create a request that no one is empowered to approve — and nothing in the apply signals that. The approval permission usually belongs to a different team, which is whyrequest_messagematters.
- The
Microsoft.Kustoresource provider registered on the subscription. - An existing Kusto cluster.
- An existing target resource that supports Azure Private Link, and the correct
group_idfor its resource type. - Someone with approval rights on the target, and an agreement that they will act on the request. Treat this as a real prerequisite rather than a formality — an unapproved request is the most common reason a private ingestion path never completes.
- ℹ️ If the target is in a different region from the cluster,
private_link_resource_regionmust be supplied.
terraform-azurerm-kusto-cluster-managed-private-endpoint/
├── providers.tf # required_version + the pinned azurerm provider. No provider block.
├── variables.tf # name, cluster_name, resource_group_name, the target, group_id, request_message, timeouts
├── main.tf # the keystone azurerm_kusto_cluster_managed_private_endpoint.this
├── outputs.tf # id, name, cluster_name, target, group_id, has_request_message
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "adx_mpe_storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
name = "mpe-landing-blob"
cluster_name = module.adx.name
resource_group_name = module.rg.name
private_link_resource_id = module.st_landing.id
group_id = "blob"
request_message = "ADX prod cluster needs private read access to the landing container. Contact: data-platform@example.com"
}ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
⚠️ This apply succeeds and the connection isPending. Nothing flows until someone with rights on the storage account approves it — see example 4.
Consumes
| Input | Type | Source module |
|---|---|---|
cluster_name |
string |
terraform-azurerm-kusto-cluster → name |
resource_group_name |
string |
terraform-azurerm-resource-group → name |
private_link_resource_id |
string |
the target's own module → id |
group_id |
string |
caller decision — target-type specific |
private_link_resource_region |
string |
required only for a cross-region target |
request_message |
string |
caller decision |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The endpoint's Resource ID, nested beneath the cluster. | imports, state reading |
name |
The endpoint name. Force-new. | runbooks, troubleshooting |
cluster_name |
The cluster whose managed network the endpoint lives in. | review |
private_link_resource_id |
The target reached through this endpoint. | review, dependency documentation |
group_id |
The private-link sub-resource on the target. | review |
has_request_message |
Derived — false means an unexplained request. |
review |
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 "ehns_id" {
description = "id of an existing ehns that these examples reference but do not create."
type = string
}
variable "st_archive_id" {
description = "id of an existing st archive that these examples reference but do not create."
type = string
}
variable "st_lake_id" {
description = "id of an existing st lake that these examples reference but do not create."
type = string
}
variable "vnet_subnet_ids" {
description = "subnet ids of an existing vnet that these examples reference but do not create."
type = map(string)
}1 · Managed private endpoint vs private endpoint — read this first
MANAGED private endpoint (this module)
lives in : the Kusto service's own managed network
direction: CLUSTER ----> a data source
use when : ingestion must read from a storage account / event hub / SQL server
whose firewall denies public access
PRIVATE endpoint (terraform-azurerm-private-endpoint)
lives in : YOUR virtual network
direction: your clients ----> THE CLUSTER
use when : analysts and dashboards must reach the cluster privately
⚠️ These are not alternatives and neither replaces the other. Reaching for this module to make the cluster privately reachable produces a resource that applies cleanly and does nothing you wanted.
💡 A fully private analytics path usually needs both: a private endpoint for people to reach the cluster, and one managed private endpoint per locked-down data source the cluster reads.
2 · A storage account source
module "adx_mpe_storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
name = "mpe-landing-blob"
cluster_name = module.adx.name
resource_group_name = module.rg.name
private_link_resource_id = module.st_landing.id
group_id = "blob"
request_message = "ADX prod needs private read access to the landing container."
}
⚠️ group_idmatters and is easy to get wrong on storage:blobanddfsare different sub-resources. A Data Lake Gen2 account accessed through the hierarchical namespace wantsdfs; plain blob access wantsblob. Guessing costs an apply.
3 · An Event Hubs source, paired with the data connection
module "adx_mpe_eventhub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
name = "mpe-telemetry-ns"
cluster_name = module.adx.name
resource_group_name = module.rg.name
private_link_resource_id = var.ehns_id
group_id = "namespace"
request_message = "ADX prod ingests telemetry from this namespace."
}💡 This module makes the namespace reachable; it does not read from it. The reading is
terraform-azurerm-kusto-eventhub-data-connection, and the two are complementary — a data connection to a firewalled namespace fails until this endpoint exists and is approved.
⚠️ Order matters on first apply: create and get this approved, then the data connection. A data connection created first will fail its own validation against an unreachable namespace.
4 · The approval step, which is not Terraform's
# On the TARGET resource, by someone with rights there:
az network private-endpoint-connection approve \
--resource-group rg-data-landing \
--name <connection-name> \
--resource-name stlandingprod \
--type Microsoft.Storage/storageAccounts \
--description "Approved for ADX prod ingestion"
⚠️ Until this runs, the connection sits inPendingand no traffic flows. Terraform reports success either way, so nothing in a plan or state file tells you which state you are in.
💡 Check the target side, not the Kusto side:
az network private-endpoint-connection list --id <target resource id>.
5 · Why `request_message` is worth writing properly
# ❌ Nothing to act on.
# request_message omitted
# ⚠️ Technically present, useless in practice.
request_message = "please approve"
# ✅ An approver can act on this without asking anyone.
request_message = "ADX cluster 'adxprod' (rg-analytics-prod) requires private read access to the landing container for scheduled ingestion. Owner: data-platform@example.com. Change ref: CHG-4821."💡 The approver is often on another team and sees a connection request with no context. Naming the cluster, the purpose, an owner and a change reference is the difference between same-day approval and a request that sits unexamined for a fortnight.
ℹ️ This is the only updatable field on the resource, so wording can be corrected without recreating the endpoint — and without needing re-approval.
6 · A cross-region target
module "adx_mpe_remote" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
name = "mpe-westus-archive"
cluster_name = module.adx.name # eastus
resource_group_name = module.rg.name
private_link_resource_id = var.st_archive_id # westus2
group_id = "blob"
private_link_resource_region = "westus2"
request_message = "ADX prod (eastus) reads archive blobs from westus2."
}
⚠️ Required when the target is not in the cluster's region — the service cannot infer it, and omitting it fails at apply rather than at plan. Force-new, like everything else here except the message.
💡 Worth a moment's thought before doing it: cross-region ingestion means egress charges and higher latency on every read.
7 · Several sources on one cluster
locals {
sources = {
landing = { id = module.st_landing.id, group = "blob" }
lake = { id = var.st_lake_id, group = "dfs" }
telemetry = { id = var.ehns_id, group = "namespace" }
}
}
module "adx_mpe" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
for_each = local.sources
name = "mpe-${each.key}"
cluster_name = module.adx.name
resource_group_name = module.rg.name
private_link_resource_id = each.value.id
group_id = each.value.group
request_message = "ADX prod private access to ${each.key}."
}💡 One endpoint per source, keyed by a stable string so adding a fourth does not disturb the first three. Note
landingandlakediffer only ingroup_id— the same account type can need either.
⚠️ Each one needs its own approval on its own target. Three endpoints can mean three different approvers.
8 · Why `group_id` is not validated
group_id = "blob" # storage: blob access
group_id = "dfs" # storage: Data Lake Gen2 hierarchical namespace
group_id = "table" # storage: table service
group_id = "queue" # storage: queue service
group_id = "namespace" # Event Hubs / Service Bus
group_id = "sqlServer" # SQL
group_id = "vault" # Key Vaultℹ️ These are common values, not an exhaustive list — the legal set depends entirely on the target's resource type and grows as Azure adds private-link support. A hard-coded enum here would reject valid values in a future provider release, so this module validates nothing on it. The same reasoning applies to the chaos-studio capability module elsewhere in this suite.
💡
private_link_resource_idis shape-validated, because "must be a full Resource ID" is a stable rule:
Error: Invalid value for variable
private_link_resource_id must be a full Azure Resource ID beginning
"/subscriptions/..." — not a name, and not an endpoint URL.
9 · Correcting the message without disruption
# Changing only request_message:
~ resource "azurerm_kusto_cluster_managed_private_endpoint" "this" {
~ request_message = "please approve" -> "ADX prod ingestion; owner data-platform@example.com"
}
Plan: 0 to add, 1 to change, 0 to destroy.
💡 An in-place update — the only one this resource supports. The endpoint is untouched and an already-approved connection stays approved.
⚠️ Contrast any other field: changinggroup_id, the target, the region or the name is a replacement, which drops the private route and requires fresh approval.
10 · The provider takes the cluster by name here
# THIS module — by name:
cluster_name = module.adx.name
# Its customer-managed-key sibling — by ID:
cluster_id = module.adx.idℹ️ Both are correct for their resource. The inconsistency is the provider's, and it is worth knowing because copying a line between the two modules in this family produces a type error rather than something subtle.
💡 Taking the value from the cluster module's output either way makes the dependency real, so ordering is implicit.
11 · Destroying the endpoint is ingestion-visible
# module.adx_mpe_storage.azurerm_kusto_cluster_managed_private_endpoint.this will be destroyed
⚠️ The target survives — only the cluster's private route to it goes. But if that target's firewall denies public access, every data connection reading from it starts failing, and the failure surfaces as an ingestion error rather than as anything pointing back at this resource.
💡 Remove or re-point the data connections first, then the endpoint.
12 · What a review should check
output "adx_private_sources" {
value = {
for k, m in module.adx_mpe : k => {
target = m.private_link_resource_id
group = m.group_id
explained = m.has_request_message # expect true
}
}
}💡
has_request_messageis emitted precisely because a request with no context is the usual reason a private path stalls.falseis worth a comment in review.
⚠️ What this output cannot tell you is whether the connection was approved — the provider exposes no attribute for it. Confirm on the target resource; this module documents that rather than approximating it.
13 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-analytics-prod"
location = "eastus"
}
# 1 · A locked-down source: no public network access.
module "st_landing" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stlandingprod"
resource_group_name = module.rg.name
location = module.rg.location
containers = {
landing = {}
}
}
# 2 · The cluster.
module "adx" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster.git?ref=v1.0.0"
name = "adxprod"
resource_group_name = module.rg.name
location = module.rg.location
sku = {
name = "Standard_E8ads_v5"
capacity = 2
}
}
module "adx_db" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-database.git?ref=v1.0.0"
name = "telemetry"
resource_group_name = module.rg.name
location = module.rg.location
cluster_name = module.adx.name
}
# 3 · Private EGRESS: the cluster reaches the locked-down storage account. This is the module.
module "adx_mpe_landing" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-cluster-managed-private-endpoint.git?ref=v1.0.0"
name = "mpe-landing-blob"
cluster_name = module.adx.name
resource_group_name = module.rg.name
private_link_resource_id = module.st_landing.id
group_id = "blob"
request_message = "ADX 'adxprod' ingests from the landing container. Owner: data-platform@example.com."
}
# 4 · Private INGRESS: analysts reach the cluster. A DIFFERENT resource — see example 1.
module "adx_private_endpoint" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-endpoint.git?ref=v1.0.0"
name = "pe-adxprod"
resource_group_name = module.rg.name
location = module.rg.location
subnet_id = var.vnet_subnet_ids["private-endpoints"]
private_service_connection = {
name = "adxprod"
private_connection_resource_id = module.adx.id
subresource_names = ["cluster"]
is_manual_connection = false
}
}
# 5 · A least-privilege data-plane grant for the ingestion identity: write, not read.
module "adx_ingest_role" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-kusto-database-principal-assignment.git?ref=v1.0.0"
name = "ingest-pipeline-ingestor"
resource_group_name = module.rg.name
cluster_name = module.adx.name
database_name = module.adx_db.name
principal_id = var.ingest_group_object_id
principal_type = "Group"
tenant_id = var.tenant_id
role = "Ingestor"
}
output "analytics_private_paths" {
value = {
egress_target = module.adx_mpe_landing.private_link_resource_id
egress_explained = module.adx_mpe_landing.has_request_message
ingress_endpoint = module.adx_private_endpoint.id
}
}
⚠️ One step remains outside this configuration: the managed private endpoint's connection must be approved on the storage account. Until then the egress path does not work, and the apply gives no hint of it — see example 4.
🔒 What the composition gets right: both directions are private and are handled by the correct resource for each, and the ingestion identity gets
Ingestor— able to write, unable to query — rather thanAdmin.
💡 Note the two modules in steps 3 and 4 are easy to mistake for each other. They point in opposite directions, and a private analytics platform needs both.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
— | Required. Force-new. |
cluster_name |
string |
— | Required. Force-new. By name, not ID. |
resource_group_name |
string |
— | Required. Force-new. The cluster's group. |
private_link_resource_id |
string |
— | Required. Force-new. Validated as a Resource ID. |
group_id |
string |
— | Required. Force-new. Target-specific; not enum-validated. |
private_link_resource_region |
string |
null |
Force-new. Required for a cross-region target. |
request_message |
string |
null |
The only updatable field. Strongly recommended. |
timeouts |
object(...) |
null |
create / read / update / delete — but read and delete are accepted and silently ignored; see Architecture Notes. |
Full schemas
variable "name" {
type = string
# ⚠️ Force-new. Replacing tears down the private route and needs fresh approval.
}
variable "cluster_name" {
type = string
# ⚠️ By NAME. The customer-managed-key sibling takes cluster_id — the provider is inconsistent.
}
variable "resource_group_name" {
type = string
# The CLUSTER's resource group. The target may live elsewhere.
}
variable "private_link_resource_id" {
type = string
# Validated: must begin "/subscriptions/". Force-new.
}
variable "group_id" {
type = string
# ⚠️ Target-type specific (blob, dfs, table, queue, namespace, sqlServer, vault, …) and deliberately
# NOT enum-validated: the legal set grows, so a hard-coded list would reject valid future values.
}
variable "private_link_resource_region" {
type = string
default = null
# Required only for a cross-region target; absence fails at apply, not plan.
}
variable "request_message" {
type = string
default = null
# 💡 The only updatable field. Name the cluster, the purpose and an owner — the approver is
# usually on another team and sees no other context.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
# ℹ️ Creation returns when the request is REGISTERED, not when it is approved.
}| Output | Description | Sensitive |
|---|---|---|
id |
The endpoint's Resource ID, nested beneath the cluster. | no |
name |
The endpoint name. Force-new. | no |
cluster_name |
The cluster whose managed network the endpoint lives in. | no |
private_link_resource_id |
The target reached through this endpoint. | no |
group_id |
The private-link sub-resource on the target. | no |
has_request_message |
Derived. false means an approver must act on an unexplained request. |
no |
ℹ️ The connection's approval state is not emitted, because the provider exposes no attribute for it. Confirm approval on the target resource's private-endpoint connections — this module says so rather than emitting a guess.
-
The name misleads, and the cost of the misunderstanding is silence. A managed private endpoint gives the cluster egress; a private endpoint gives clients ingress. Someone wanting private access to the cluster who reaches for this module gets a resource that applies cleanly, reports healthy, and does nothing they intended. That is why the direction leads both the design intent and the diagram.
-
A successful apply means "requested", not "connected". The connection arrives on the target as
Pendingand needs approval from someone who may have no involvement in the Terraform run. Nothing in state distinguishes pending from approved, so the natural assumption — clean apply means working path — is wrong here. -
The permission asymmetry explains that. Requesting needs nothing on the target; only approving does. So a caller with cluster-only rights can create requests indefinitely without being able to complete any of them.
-
request_messageis the only lever on the human part of the process, and the only updatable field. It can be corrected without recreating the endpoint or re-triggering approval, which makes it cheap to improve. -
Two of the four
timeoutsknobs do nothing, and nothing reports that. The provider's read and delete functions both acquire the combined create/update timeout rather than their own, and since neither runs against a new resource, both resolve to the update timeout. Sotimeouts.readis never consulted — a refresh is governed bytimeouts.update, defaulting to 60 minutes rather than the 5 the schema advertises — andtimeouts.deleteis never consulted either. Setting either one is accepted, changes nothing, and produces no warning. -
group_idis unvalidated on purpose. Its legal values depend entirely on the target's resource type and grow as Azure extends private-link support. An enum would be wrong within a release or two.private_link_resource_idis validated instead, because "must be a Resource ID" does not go stale. -
Everything else is force-new, and replacement is expensive twice over: it drops the private route, and the replacement request must be approved again.
-
The cluster is referenced by name here and by ID in the sibling CMK module. Copying between the two produces a type error rather than a subtle bug, which is the better failure — but it is worth knowing before it happens.
-
No
tags. Tag the cluster and the target instead.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Direction of traffic | documented first, because the wrong choice is silent | — |
| Approval requirement | disclosed as a prerequisite with a named owner, not implied | — |
| Approver context | request_message recommended and its absence emitted as a boolean |
omit it, knowingly |
| Target identification | private_link_resource_id shape-validated |
— |
| Sub-resource selection | group_id left unvalidated deliberately, with common values documented |
— |
| Approval state | reported as unavailable rather than approximated | — |
| Secrets | none accepted, none emitted | — |
- Before applying: confirm the direction you actually need — egress (this) or ingress (
terraform-azurerm-private-endpoint). - Before applying: confirm the correct
group_idfor the target's resource type. - Before applying: identify who will approve, and write a
request_messagethey can act on. - After applying: verify approval on the target. A clean apply is not enough.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag —
?ref=v1.0.0— never a branch. - Plan-only from here. A human applies from CI.
⚠️ After apply, check the target's private-endpoint connections and get the request approved. This step is not Terraform's and is not visible in state.⚠️ Create and approve this before the data connection that depends on it.⚠️ Any change other thanrequest_messageis a replacement, which drops the route and needs fresh approval.
terraform validate and terraform fmt -check are the offline gate. They confirm:
- the five required inputs are present and typed
string; private_link_resource_idbegins/subscriptions/;- the
timeoutsvalue is an object of Go duration strings - but note that a key the type does not declare is SILENTLY DISCARDED rather than refused, so a misspelling here produces no error anywhere and the provider default quietly applies; - the module declares no
providerblock.
What only plan and apply exercise:
- whether
group_idis legal for the target's resource type; - whether
private_link_resource_regionis needed and correct; - whether the cluster exists and the caller can write managed private endpoints to it.
What no Terraform command checks at any stage:
- whether the connection was ever approved — the provider exposes no attribute for it, and the apply succeeds either way;
- whether anyone with approval rights on the target is aware the request exists.
Outputs:
cluster_name = "adxprod"
group_id = "blob"
has_request_message = true
id = "/subscriptions/00000000-.../clusters/adxprod/managedPrivateEndpoints/mpe-landing-blob"
name = "mpe-landing-blob"
private_link_resource_id = "/subscriptions/00000000-.../storageAccounts/stlandingprod"
⚠️ Nothing here tells you the connection is approved. That is the point of the note in the Outputs table — check the target.
| Symptom | Cause | Fix |
|---|---|---|
| The cluster still cannot reach the target after a clean apply | The connection is Pending. |
Approve it on the target (example 4). |
| Nobody knows what the pending request is for | No request_message. |
Set one; it is updatable (example 5). |
Apply fails on group_id |
The value is not legal for the target's resource type. | Check the target's private-link documentation (example 8). |
| Ingestion works for blob but not for a Data Lake path | blob was used where dfs is needed. |
Create an endpoint with group_id = "dfs". |
| Apply fails for a target in another region | private_link_resource_region missing. |
Supply it (example 6). |
Plan rejects private_link_resource_id |
A name or URL was passed instead of a Resource ID. | Wire it from the target module's id. |
| A type error when copying from the CMK sibling | That module takes cluster_id; this one takes cluster_name. |
Use module.adx.name here (example 10). |
| A small edit triggered a replacement | Everything except request_message is force-new. |
Expected. Replacement needs fresh approval (example 9). |
| Data connections started failing after a destroy | The cluster lost its private route to a firewalled source. | Recreate and re-approve, or open the target's firewall. |
| A data connection created first fails validation | The source is unreachable until this endpoint is approved. | Create and approve this endpoint first (example 3). |
| Trying to reach the cluster privately does not work | Wrong resource — this is egress. | Use terraform-azurerm-private-endpoint (example 1). |
azurerm_kusto_cluster_managed_private_endpoint— provider documentation for this resource.azurerm_private_endpoint— the other direction, for private access into the cluster.- Managed private endpoints in Azure Data Explorer — the service's own account, including approval.
- Azure Private Link sub-resource names — the authoritative source for
group_idvalues per resource type. - Sibling modules in this family:
terraform-azurerm-kusto-cluster,terraform-azurerm-kusto-database,terraform-azurerm-kusto-eventhub-data-connection,terraform-azurerm-kusto-cluster-customer-managed-key,terraform-azurerm-kusto-cluster-principal-assignment,terraform-azurerm-kusto-database-principal-assignment. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

