DEV Community

Cover image for Zero-Trust RAG: Defeating the Shared Private Link Deadlock in Azure Terraform
david
david

Posted on • Originally published at woitzik.dev

Zero-Trust RAG: Defeating the Shared Private Link Deadlock in Azure Terraform

Your Terraform pipeline is green. The deployment completes without errors. You grab a coffee.

Ten minutes later, you test your new Enterprise RAG application. It throws a 403 Forbidden. You open the Azure Portal, check the OpenAI Networking tab, and there it is: your Shared Private Link from AI Search is sitting in Pending.

Nobody told Terraform to approve it. Nobody told you it even needed approving.

This is the CI/CD killer of Azure AI infrastructure.

Why It Happens

AI Search must call OpenAI to vectorize data. The azurerm provider can successfully request this Shared Private Link — but it cannot approve its own request. The target resource (OpenAI) must explicitly accept the inbound connection. The standard provider has no method for this. Pipeline deadlocked. Someone has to click "Approve" in the Portal manually. ClickOps in 2026.

The AzAPI State Machine Fix

We bypass the azurerm provider entirely and talk directly to the Azure Resource Manager REST API using the azapi provider.

Because Azure generates a random GUID for the incoming connection, we can't hardcode the ID — we read it at runtime:

data "azapi_resource_list" "pe_connections" {
  type                   = "Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"
  parent_id              = azurerm_cognitive_account.openai.id
  response_export_values = ["value"]

  depends_on = [azurerm_search_shared_private_link_service.openai_link]
}
Enter fullscreen mode Exit fullscreen mode

The depends_on is critical — without it, Terraform queries the connection list before the link is even requested, returns empty, and the approval silently fails.

Then we filter for the Pending connection and approve it:

resource "azapi_update_resource" "approve_shared_link" {
  type = "Microsoft.CognitiveServices/accounts/privateEndpointConnections@2023-05-01"

  resource_id = try(
    [for conn in jsondecode(data.azapi_resource_list.pe_connections.output).value :
      conn.id
      if conn.properties.privateLinkServiceConnectionState.status == "Pending"
    ][0],
    ""
  )

  body = jsonencode({
    properties = {
      privateLinkServiceConnectionState = {
        status      = "Approved"
        description = "Approved via Terraform AzAPI Pipeline"
      }
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

The try() wrapper is not optional. On terraform destroy, the Shared Private Link is deleted before this resource is evaluated. Without try(), indexing [0] on an empty array crashes the destroy run and leaves orphaned resources in Azure.

After terraform apply, the link goes from Pending to Approved in under 30 seconds. No Portal access required.

Identity Chaining — Kill the API Keys

Auto-approving the link lets AI Search reach OpenAI. But static API keys will fail your compliance audit. Keys leak. Keys get committed to Git.

Disable local auth and use a System Managed Identity instead:

resource "azurerm_search_service" "search" {
  name                         = var.search_service_name
  sku                          = "standard"
  public_network_access_enabled = false
  local_authentication_enabled  = false

  identity {
    type = "SystemAssigned"
  }
}

resource "azurerm_role_assignment" "search_to_openai" {
  scope                = azurerm_cognitive_account.openai.id
  role_definition_name = "Cognitive Services OpenAI User"
  principal_id         = azurerm_search_service.search.identity[0].principal_id
}
Enter fullscreen mode Exit fullscreen mode

Zero credential management. When the AI Search instance is deleted, its identity and permissions are destroyed automatically.

Don't Forget Private DNS

If your-instance.openai.azure.com still resolves to a public IP, the Azure Firewall drops the traffic and you get another opaque 403. Both services need their DNS zones linked to the VNet:

resource "azurerm_private_dns_zone" "openai_dns" {
  name                = "privatelink.openai.azure.com"
  resource_group_name = var.resource_group_name
}

resource "azurerm_private_dns_zone_virtual_network_link" "openai_vnet_link" {
  name                  = "link-openai-vnet"
  resource_group_name   = var.resource_group_name
  private_dns_zone_name = azurerm_private_dns_zone.openai_dns.name
  virtual_network_id    = azurerm_virtual_network.vnet.id
  registration_enabled  = false
}
Enter fullscreen mode Exit fullscreen mode

registration_enabled = false — always. Automatic registration conflicts with centralized Hub & Spoke DNS management.


The free baseline (AzAPI auto-approval + basic networking) is on GitHub. The full article with complete VNet injection, Private DNS automation, and RBAC Identity Chaining is on my blog.

👉 Full article on woitzik.dev
👉 Free GitHub repo

Top comments (0)