Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Data Factory Linked Service β€” Key Vault Terraform Module

The pointer that lets every other linked service in a Data Factory avoid holding a secret, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ—οΈ Creates one azurerm_data_factory_linked_service_key_vault β€” the resource every key_vault_password, key_vault_connection_string, key_vault_license and key_vault_secret_reference block in the factory references by name.
  • πŸ”’ It holds no secret at all, and that is the point. Nothing here takes a credential and nothing here returns one. Its whole job is to let siblings name a secret instead of carrying one.
  • πŸ”΄ A vault in a different subscription from the factory never converges. Data Factory stores the vault's base URL, not its Resource ID, and the read resolves it back by listing vaults in the factory's subscription β€” finding nothing, reporting no error, and writing key_vault_id back empty.
  • πŸ”΄ key_vault_id is required and not force-new. Repointing it is an in-place update that silently redirects every secret reference in every sibling.
  • ⚠️ Creating this grants nothing. The factory's identity still needs read access on the vault, and nothing in this module creates it.

πŸ’‘ Why it matters: this is the one linked service whose refresh behaviour depends on where the vault lives and what the caller may list β€” not on anything visible in its own configuration.


❀️ Support this project

If this module saved you time:


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

flowchart TB
  RG["terraform-azurerm-resource-group"]
  ADF["terraform-azurerm-data-factory"]
  KV["terraform-azurerm-key-vault"]
  THIS["terraform-azurerm-data-factory-linked-service-key-vault"]
  GRANT["terraform-azurerm-role-assignments, the factory identity on the vault, NOT created here"]
  SQL["terraform-azurerm-data-factory-linked-service-azure-sql-database"]
  SRV["terraform-azurerm-data-factory-linked-service-sql-server"]
  SSIS["terraform-azurerm-data-factory-integration-runtime-azure-ssis"]

  RG -->|"name"| ADF
  ADF -->|"id"| THIS
  KV -->|"id, and its base URL is what Data Factory actually stores"| THIS
  ADF -->|"identity_principal_id"| GRANT
  GRANT -->|"read access on the vault, without which every reference below fails"| KV
  THIS -->|"name, into key_vault_connection_string and key_vault_password"| SQL
  THIS -->|"name, into key_vault_connection_string and key_vault_password"| SRV
  THIS -->|"name, into key_vault_password and key_vault_license"| SSIS

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
  classDef keystone fill:#004578,stroke:#00243d,color:#ffffff,stroke-width:2px
  classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
  class THIS this
  class ADF keystone
  class RG,KV,GRANT,SQL,SRV,SSIS sibling
Loading

The three edges leaving this module are the reason it exists β€” and the edge that does not leave it is the grant. Creating the linked service tells the factory where the vault is; a separate role assignment is what lets it read anything.


🧬 What this module builds

flowchart TB
  subgraph INPUTS["Inputs"]
    ID["name and data_factory_id, THE ONLY TWO FORCE-NEW FIELDS"]
    VAULT["key_vault_id, REQUIRED and NOT force-new"]
    META["integration_runtime_name, description, parameters, annotations, additional_properties"]
  end

  THIS["azurerm_data_factory_linked_service_key_vault.this"]

  subgraph OUTPUTS["Outputs"]
    DIFF["a_key_vault_in_another_subscription_produces_a_permanent_diff"]
    URL["data_factory stores the vault URL, not its resource id"]
    PERM["reading this requires permission to LIST key vaults in the factory subscription"]
    REPOINT["repointing_the_vault_is_an_in_place_update, redirecting every sibling silently"]
    GRANT["this linked service grants NO access to the vault"]
    NAMEOUT["name, consumed by every secret bearing sibling BY NAME"]
  end

  ID --> THIS
  VAULT --> THIS
  META --> THIS

  VAULT --> DIFF
  ID --> DIFF
  THIS --> URL
  THIS --> PERM
  VAULT --> REPOINT
  THIS --> GRANT
  THIS --> NAMEOUT

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
  classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
  class THIS this
  class ID,VAULT,META,DIFF,URL,PERM,REPOINT,GRANT,NAMEOUT sibling
Loading
Resource Count Notes
azurerm_data_factory_linked_service_key_vault.this 1 The keystone. A pointer, not a connection.
timeouts dynamic, 0..1 All four keys; none bounds anything slow.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None. The caller configures the provider, including the mandatory features {} block.

Schema notes that bite β€” verified against the live provider source:

  • πŸ”΄ Data Factory stores the vault's base URL, not its Resource ID. The provider derives the URL from the ID on create, and on every read reconstructs the ID from the URL by listing the Key Vaults in the factory's subscription.
  • πŸ”΄ A vault outside that subscription is never found, and the lookup returns no error. The helper returns nothing with the comment that callers must handle it separately β€” and this resource does not, so key_vault_id is written back empty and every plan afterwards proposes to set it again.
  • πŸ”΄ Refreshing therefore requires permission to LIST Key Vaults across the factory's subscription, which is broader than reading the Data Factory.
  • πŸ”΄ key_vault_id is Required and NOT force-new. It is declared through a shared helper that is Required without ForceNew β€” a ForceNew variant of that helper exists and this resource does not use it.
  • ⚠️ The name validator is nearly inert. Its expression is anchored end to end, so it refuses only a value composed entirely of the punctuation its message lists. a/b, 100%done, a Resource ID and the empty string all pass. The same validator is shared by the whole linked-service and dataset family.
  • ⚠️ additional_properties is validated by nothing; a misspelled key is silently ignored.
  • ⚠️ annotations is an ordered list, so reordering is a real diff β€” and it is not tags.
  • ⚠️ Only name and data_factory_id are force-new. There is no location and no tags.
  • ℹ️ The Resource ID's segment is lower-case linkedservices.
  • ℹ️ No cross-field rules and no CustomizeDiff, so every provider check here fires at terraform validate, offline.
  • ℹ️ The requires-import error names this resource correctly.

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.DataFactory/factories/linkedservices/write the Data Factory Create and update.
Microsoft.DataFactory/factories/linkedservices/read the Data Factory Refresh and plan.
Microsoft.DataFactory/factories/linkedservices/delete the Data Factory Destroy β€” which breaks every secret reference that used it.
Microsoft.KeyVault/vaults/read the factory's subscription The read lists vaults to resolve the base URL back to a Resource ID.
Data Factory Contributor the Data Factory Covers the linked-service actions, but not the vault listing.

πŸ”΄ The Terraform principal needs more than Data Factory Contributor here. Refreshing this resource enumerates the Key Vaults in the factory's subscription, so a principal that can manage the factory but not list vaults will not populate key_vault_id β€” and the resource will appear to drift on every plan.

⚠️ Separately, the FACTORY'S identity needs read access on the vault, through a Key Vault role assignment or an access policy. That is a different principal, a different scope, and nothing in this module creates it.


Azure Prerequisites

  • An existing Data Factory.
  • An existing Key Vault β€” preferably in the same subscription as the factory; see the caveat above.
  • A read grant for the factory's identity on that vault, made elsewhere.
  • For a vault restricted to a private endpoint: a self-hosted or managed-virtual-network integration runtime, named in integration_runtime_name.

πŸ“ Module Structure

terraform-azurerm-data-factory-linked-service-key-vault/
β”œβ”€β”€ providers.tf    # required_version + the pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 9 inputs, 13 validations, none sensitive -- there is no secret here
β”œβ”€β”€ main.tf         # the keystone, plus the cross-subscription derivation
β”œβ”€β”€ outputs.tf      # 40 outputs; none is sensitive, because none can be
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

module "keyvault_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-key-vault.git?ref=v1.0.0"

  name            = "ls-keyvault"
  data_factory_id = module.data_factory.id
  key_vault_id    = module.key_vault.id
}

The caller configures the provider, its authentication, and the mandatory features {} block:

provider "azurerm" {
  features {}
}

πŸ’‘ Then pass module.keyvault_link.name into every sibling's key_vault_* block. That name β€” not the Resource ID β€” is what those blocks take.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
data_factory_id string terraform-azurerm-data-factory (id)
key_vault_id string terraform-azurerm-key-vault (id)
integration_runtime_name string the integration runtime modules (name)

Emits

Output Description
name The value every secret-bearing sibling consumes.
id The linked service's Resource ID.
a_key_vault_in_another_subscription_produces_a_permanent_diff Known at plan time.
key_vault_subscription_id, subscription_id The two subscriptions being compared.
this_linked_service_grants_no_access_to_the_vault The grant is elsewhere.
force_new_fields The name and the factory β€” not the vault.

πŸ“š Example Library

1 Β· The minimum call
module "kv_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-key-vault.git?ref=v1.0.0"

  name            = "ls-keyvault"
  data_factory_id = module.data_factory.id
  key_vault_id    = module.key_vault.id
}

ℹ️ Three arguments, two of them force-new. key_vault_id is the third and is not.

2 Β· Wiring it into the siblings that need it
module "sql_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-sql-database.git?ref=v1.0.0"

  name            = "ls-sales-db"
  data_factory_id = module.data_factory.id

  key_vault_connection_string = {
    linked_service_name = module.kv_link.name # a NAME, never a Resource ID
    secret_name         = "sales-db-connection"
  }
}

πŸ’‘ This is the whole purpose of the module. Every key_vault_password, key_vault_connection_string, key_vault_license and key_vault_secret_reference block across the Data Factory family takes a linked_service_name, and it is always a name.

3 Β· πŸ”΄ The cross-subscription trap
# The factory is in subscription A; the vault is in subscription B.
module "kv_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-key-vault.git?ref=v1.0.0"

  name            = "ls-shared-keyvault"
  data_factory_id = module.data_factory.id # /subscriptions/AAAA-.../factories/adf-corp
  key_vault_id    = "/subscriptions/BBBB-0000-0000-0000-000000000000/resourceGroups/rg-security/providers/Microsoft.KeyVault/vaults/kv-shared"
}

πŸ”΄ This applies cleanly, works perfectly in Azure, and never converges in Terraform. Data Factory stores only the vault's base URL. On read, the provider resolves that URL back to a Resource ID by listing the vaults in the factory's subscription β€” where this one is not. The lookup returns nothing and no error, so key_vault_id is written into state as empty, and the next plan proposes to set a required argument the configuration already sets correctly. Forever. πŸ’‘ module.kv_link.a_key_vault_in_another_subscription_produces_a_permanent_diff is derived from configuration, so it is true at plan time, before anything is created.

4 Β· A vault in another resource group, which is fine
data_factory_id = module.data_factory.id # rg-data-eastus
key_vault_id    = "/subscriptions/AAAA-0000-0000-0000-000000000000/resourceGroups/rg-security/providers/Microsoft.KeyVault/vaults/kv-shared" # rg-security, SAME subscription

βœ… Entirely benign. The provider's lookup is scoped by subscription, not by resource group, so this resolves normally. module.kv_link.key_vault_is_in_another_resource_group reports it only so the two cases are not confused with each other.

5 Β· πŸ”΄ Repointing the vault β€” an in-place update with reach
# Changing this is NOT a replacement. It is an in-place update.
key_vault_id = "/subscriptions/AAAA-0000-0000-0000-000000000000/resourceGroups/rg-security/providers/Microsoft.KeyVault/vaults/kv-replacement"

πŸ”΄ key_vault_id is declared through a shared helper that is Required without ForceNew β€” there is a ...RequiredForceNew variant and this resource does not use it. Changing the vault therefore updates the linked service in place and silently redirects every secret reference that names this linked service, in every sibling, to a different vault. ⚠️ The siblings' own plans show no change at all, because nothing about them changed. Nothing in Terraform connects the two.

6 Β· What this resource does not grant
module "kv_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-key-vault.git?ref=v1.0.0"

  name            = "ls-keyvault"
  data_factory_id = module.data_factory.id
  key_vault_id    = module.key_vault.id
}

# WITHOUT THIS, every secret reference through the linked service above fails at run time.
module "vault_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.key_vault.id

  role_assignments = {
    factory_reads_secrets = {
      principal_id         = module.data_factory.identity_principal_id
      role_definition_name = "Key Vault Secrets User"
      principal_type       = "ServicePrincipal"
    }
  }
}

⚠️ Creating the linked service tells Data Factory where the vault is. It does not give the factory permission to read anything from it. The grant is a different principal at a different scope, and this module does not make it β€” so a configuration that looks complete fails at first use, not at apply. ℹ️ On a vault still using access policies rather than RBAC, the equivalent is an access policy entry.

7 Β· A vault behind a private endpoint
integration_runtime_name = "ir-vnet" # a NAME, from a managed-vnet or self-hosted runtime module

⚠️ Without this, the factory's default AutoResolve runtime is used, and it reaches only what is reachable from the public Azure network. A vault restricted to a private endpoint needs a self-hosted or managed-virtual-network runtime named here. πŸ’‘ It is a name, not a Resource ID. module.kv_link.runs_on_the_factory_default_integration_runtime reports whether one was named.

8 Β· The vault reference values that are refused
# Each of these is rejected at `terraform plan`, offline, with a targeted message:
#   key_vault_id = ".../vaults/kv-corp/secrets/sql-password"  -> a SECRET id is too long
#   key_vault_id = "https://kv-corp.vault.azure.net/"         -> that is the BASE URL, not the id
#   key_vault_id = "kv-corp"                                   -> a bare name
#   key_vault_id = module.data_factory.id                      -> the wrong resource type

key_vault_id = module.key_vault.id # correct

πŸ’‘ The base-URL case earns its own message because Data Factory does store the base URL internally β€” so reaching for it here is a reasonable mistake rather than a careless one.

9 Β· The name rule the provider almost does not have
name = "ls-keyvault"   # accepted, despite '-' being in the "not allowed" list
name = "ls.kv/shared"  # also accepted, despite '.' and '/'
# name = "---"         # REFUSED -- the one shape the provider's validator actually catches
# name = ""            # refused BY THIS MODULE; the provider accepts it

⚠️ The provider's message says - . + ? / < > * % & : \ "are not allowed", and its expression refuses a value only when the value is composed of nothing but those characters. The same validator is shared across the whole linked-service and dataset family. πŸ”΄ Renaming matters more here than almost anywhere: name is force-new and it is what every sibling's key_vault_* block references.

10 Β· One vault link, many consumers
locals {
  databases = {
    sales   = "sales-db-connection"
    finance = "finance-db-connection"
    hr      = "hr-db-connection"
  }
}

module "db_link" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-sql-database.git?ref=v1.0.0"
  for_each = local.databases

  name            = "ls-${each.key}-db"
  data_factory_id = module.data_factory.id

  key_vault_connection_string = {
    linked_service_name = module.kv_link.name
    secret_name         = each.value
  }
}

πŸ’‘ One Key Vault linked service normally serves an entire factory. Creating several is legal and rarely useful β€” each is just a pointer.

11 Β· Annotations, which are not tags
annotations = ["owner:data-platform", "scope:secrets"]

⚠️ These are not Azure resource tags. Nothing outside Data Factory reads them. This resource supports no tags argument; tag the factory or the vault. ⚠️ The provider models them as an ordered list, so reordering is a real diff.

12 Β· Importing an existing link
import {
  to = module.kv_link.azurerm_data_factory_linked_service_key_vault.this
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-data-eastus/providers/Microsoft.DataFactory/factories/adf-corp/linkedservices/ls-keyvault"
}

⚠️ Import is where the cross-subscription trap surfaces first. The import reads the resource, which resolves the base URL against the factory's subscription β€” so importing a link to a vault in another subscription lands key_vault_id empty immediately.

13 Β· Asserting what a plan will not tell you
check "the_vault_is_in_the_factory_subscription" {
  assert {
    condition     = !module.kv_link.a_key_vault_in_another_subscription_produces_a_permanent_diff
    error_message = "The vault is in another subscription; the provider cannot resolve it back and this resource will never converge."
  }
}

check "a_runtime_that_can_reach_a_private_vault" {
  assert {
    condition     = !module.kv_link.runs_on_the_factory_default_integration_runtime
    error_message = "No integration runtime was named, so this resolves to the public-network default."
  }
}

πŸ’‘ Both are derived from configuration, so each fails at plan time β€” before anything exists, and long before the never-converging diff would be noticed.

14 Β· πŸ—οΈ End-to-end composition
module "resource_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

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

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

  name                = "kv-data-eastus"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
  tenant_id           = var.tenant_id
}

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

  name                   = "adf-corp-eastus"
  resource_group_name    = module.resource_group.name
  location               = module.resource_group.location
  public_network_enabled = false
}

# The pointer.
module "kv_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-key-vault.git?ref=v1.0.0"

  name            = "ls-keyvault"
  data_factory_id = module.data_factory.id
  key_vault_id    = module.key_vault.id
  description     = "Secret source for every credential-bearing linked service in this factory."
}

# The grant, without which the pointer resolves to nothing usable.
module "vault_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.key_vault.id

  role_assignments = {
    factory_reads_secrets = {
      principal_id         = module.data_factory.identity_principal_id
      role_definition_name = "Key Vault Secrets User"
      principal_type       = "ServicePrincipal"
    }
  }
}

# A consumer, carrying no secret of its own.
module "sales_db" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-sql-database.git?ref=v1.0.0"

  name            = "ls-sales-db"
  data_factory_id = module.data_factory.id

  key_vault_connection_string = {
    linked_service_name = module.kv_link.name
    secret_name         = "sales-db-connection"
  }
}

check "the_vault_is_in_the_factory_subscription" {
  assert {
    condition     = !module.kv_link.a_key_vault_in_another_subscription_produces_a_permanent_diff
    error_message = "The vault is in another subscription and this resource will never converge."
  }
}

output "key_vault_linked_service_name" {
  value = module.kv_link.name
}

πŸ—οΈ Four modules, and the third and fourth are the ones people forget. The linked service is a pointer; the role assignment is what makes the pointer usable; and the consumer holds no credential because both of the first two exist. ⚠️ The composition still cannot tell you whether the secret named by secret_name is actually in the vault. Nothing checks that until a pipeline runs.


πŸ“₯ Inputs

Identity (both force-new): name, data_factory_id. The vault: key_vault_id β€” required, and not force-new. Metadata: description, integration_runtime_name, parameters, annotations, additional_properties, timeouts.

There is no tags input β€” the resource exposes none, and annotations are not tags. No input is sensitive, because this resource accepts no secret.

Full input schemas
variable "name" { type = string }            # required, force-new; consumed BY NAME by every sibling
variable "data_factory_id" { type = string } # required, force-new
variable "key_vault_id" { type = string }    # required, NOT force-new

variable "description" { type = string, default = null }
variable "integration_runtime_name" { type = string, default = null } # a NAME
variable "parameters" { type = map(string), default = {} }
variable "annotations" { type = list(string), default = [] }          # ORDERED; not tags
variable "additional_properties" { type = map(string), default = {} } # validated by nothing

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Notes
id The linked service's Resource ID Emitted first; lower-case linkedservices segment
name What every secret-bearing sibling consumes Force-new
data_factory_id, data_factory_name The parent factory Parsed from the end of the ID
resource_group_name, subscription_id Where the factory lives The subscription that is searched
description Metadata
key_vault_id The vault Not force-new
key_vault_name, key_vault_resource_group_name, key_vault_subscription_id Parsed from the vault ID
a_key_vault_in_another_subscription_produces_a_permanent_diff πŸ”΄ Never converges Known at plan
key_vault_is_in_another_resource_group Entirely benign Derived
data_factory_stores_the_vault_url_not_its_resource_id The root of it all Constant
the_key_vault_id_is_reconstructed_by_querying_azure_on_every_read Why refresh depends on permissions Constant
reading_this_resource_requires_permission_to_list_key_vaults_in_the_factory_subscription Broader than Data Factory Contributor Constant
repointing_the_vault_is_an_in_place_update Redirects every sibling silently Constant
name_contains_characters_the_provider_message_disallows Reported, not refused Derived
the_provider_name_validator_is_nearly_inert Shared by the whole family Constant
this_linked_service_grants_no_access_to_the_vault The grant is elsewhere Constant
no_secret_is_accepted_or_emitted_by_this_module The point of the resource Constant
this_module_creates_a_pointer_not_a_connection Nothing is verified Constant
integration_runtime_name Where it runs
runs_on_the_factory_default_integration_runtime Public network only Derived
every_secret_bearing_sibling_references_this_by_name How to wire it Constant
the_module_cannot_see_what_references_this_linked_service Read before deleting Constant
this_linked_service_is_referenced_by_name_not_by_id Renaming breaks references Constant
parameters, annotations Metadata
annotations_are_ordered_and_reordering_them_is_a_diff Ordered list Constant
additional_properties_are_not_validated_by_anything A misspelling is ignored Constant
this_resource_supports_no_azure_resource_tags Tag the factory or the vault Constant
force_new_fields, fields_that_can_change_after_creation The vault is in the second list
no_customize_diff_guards_this_resource Every check is reachable offline Constant
import_address, the_import_guard_names_this_resource_correctly Imports
destroying_the_factory_destroys_this_linked_service_too The vault is unaffected Constant
lifecycle_prevent_destroy_is_not_available_to_a_module_caller Use a management lock Constant
the_timeouts_bound_terraform_not_the_vault Nothing here is slow Constant

🧠 Architecture Notes

This resource is a pointer, and it is the reason the rest of the family can be secret-free. A key_vault_password on a SQL linked service, a key_vault_license on the SSIS runtime, a key_vault_secret_reference anywhere β€” all of them name this linked service and a secret name, and the value is fetched by the service at run time. Nothing here takes a credential and nothing here returns one. That is why no input and no output in this module is marked sensitive: there is nothing to mark.

The identifier round-trip does not round-trip, and that is where the trouble is. Data Factory persists the vault's base URL β€” https://<vault>.vault.azure.net/ β€” and not its Resource ID. On create, the provider derives the URL from the ID you supply. On every read it must go the other way, and it does so by listing the Key Vaults in the factory's subscription, caching them by name, and matching the URL against that cache. Two consequences follow, and neither is visible in the resource's own configuration. First, refreshing requires permission to list vaults across that subscription β€” broader than Data Factory Contributor. Second, a vault in a different subscription is never found: the lookup returns nothing and no error, so the provider writes key_vault_id back as empty, and every subsequent plan proposes to set a required argument that the configuration already sets correctly. The linked service works perfectly in Azure the entire time. a_key_vault_in_another_subscription_produces_a_permanent_diff is derived from the two Resource IDs, so it is true at plan time, before anything is created.

A required reference that is not force-new is unusual, and here it has reach. key_vault_id is declared through a shared helper that is Required without ForceNew; a ...RequiredForceNew variant of that same helper exists, and this resource does not use it. So repointing the linked service at a different vault is an in-place update β€” and it silently redirects every secret reference in every sibling that names this linked service. Those siblings' plans show no change, because nothing about them changed. Nothing in Terraform connects the two facts.

Creating this grants nothing. The linked service tells the factory where the vault is; the factory's identity still needs a Key Vault role assignment, or an access policy on a vault still using access policies. That is a different principal at a different scope and this module does not create it. A configuration that looks complete therefore fails at first use rather than at apply β€” which is the single most common way this resource disappoints.

The name is load-bearing in a way it is not elsewhere. It is force-new, it is what every sibling references, and the provider's own validator for it is nearly inert β€” refusing a value only when it consists of nothing but the punctuation its message forbids. The module mirrors that expression rather than the message, adds the non-empty and not-a-Resource-ID checks the provider omits, and reports the rest.


🧱 Design Principles

Concern This module's default (empty call) Opt-out (the caller must type it)
Secrets None accepted, none emitted β€” the resource has no secret-bearing field β€”
Vault reference Anchored to the vaults type; base URL and child IDs refused β€”
Cross-subscription placement Reported at plan time, never refused Place the vault elsewhere and accept the diff
Access to the vault Not granted β€” deliberately out of scope Create the role assignment separately
Reachability Default AutoResolve runtime, reported as such Name a private-network runtime
Name rules The provider's expression, mirrored exactly None
Unenforced provider claims Reported, never enforced None

Secure-by-default applies cleanly here for once: the empty call carries no secret because the resource cannot carry one. The rules that do bite are the two the module reports rather than refuses β€” the cross-subscription placement and the in-place vault change β€” because the provider accepts both, and a failing validation {} would block terraform destroy for anyone who already owns such a linked service.


πŸš€ Runbook

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

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

πŸ”΄ Before the first apply, check that the vault and the factory share a subscription, and that the Terraform principal can list Key Vaults in it. Both are refresh-time concerns that no amount of configuration review will surface later.


πŸ§ͺ Testing

terraform validate covers every check the provider applies here β€” this resource declares no cross-field rules and no CustomizeDiff at all.

Offline, without credentials, the module's proof harness confirms:

  • every wrong shape of vault reference is refused with a targeted message β€” a secret ID, a factory ID, an unrelated resource, a bare name, and the base URL, which earns its own message because Data Factory really does store the base URL internally;
  • the cross-subscription condition has one fixture per branch, including the same-subscription-different- resource-group case that must not be flagged, and a casing-only variant that must not be either;
  • πŸ”΄ the force-new claim is read from the provider's Go source, not asserted β€” the harness requires that the resource uses commonschema.ResourceIDReferenceRequired, that the ...RequiredForceNew variant does not appear, and that exactly two ForceNew fields exist;
  • πŸ”΄ the base-URL round-trip and its subscription scoping are likewise read from the source, including that the lookup is scoped by the factory's own subscription;
  • every name the provider's own message disowns is accepted, and only the all-punctuation shapes refused;
  • the schema really does mark nothing on this resource sensitive, and the module marks nothing either;
  • force_new_fields omits key_vault_id, which is the whole point of the finding.

Only terraform plan and apply exercise the ARM call. Nothing at any stage verifies that the vault exists, that a named secret is in it, or that the factory can read it.


πŸ’¬ Example Output

id                                    = "/subscriptions/00000000-.../factories/adf-corp/linkedservices/ls-keyvault"
name                                  = "ls-keyvault"
data_factory_name                     = "adf-corp"
subscription_id                       = "8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8"
key_vault_name                        = "kv-data-eastus"
key_vault_subscription_id             = "8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8"
key_vault_is_in_another_resource_group = false
a_key_vault_in_another_subscription_produces_a_permanent_diff = false
runs_on_the_factory_default_integration_runtime = true
force_new_fields                      = ["name", "data_factory_id"]
this_linked_service_grants_no_access_to_the_vault = true
no_secret_is_accepted_or_emitted_by_this_module   = true

πŸ” Troubleshooting

Symptom Cause Fix
Every plan proposes to set key_vault_id, and applying never settles it The vault is in a different subscription from the factory, so the read cannot resolve its base URL back to a Resource ID and writes it back empty Move the vault or the factory so they share a subscription. Assert on a_key_vault_in_another_subscription_produces_a_permanent_diff to catch it at plan.
key_vault_id comes back empty for a vault in the same subscription The Terraform principal cannot list Key Vaults in that subscription, so the lookup finds nothing Grant Microsoft.KeyVault/vaults/read at subscription scope. Data Factory Contributor alone is not enough.
A sibling linked service suddenly reads secrets from a different vault key_vault_id was changed; it is not force-new, and the change redirects every reference silently Check key_vault_id against what the siblings expect. Their own plans will show nothing.
key_vault_id must be a Key Vault Resource ID A secret ID, a factory ID, a bare name, or an unrelated resource Pass the vault's own Resource ID, ending at /vaults/<name>.
key_vault_id must be the vault's Resource ID, not its base URL. https://<vault>.vault.azure.net/ was supplied Pass the Resource ID. Data Factory stores the URL internally, but this argument takes the ID.
Every pipeline using a secret fails, though everything applied cleanly The factory's identity has no read access on the vault; this module does not grant it Create a Key Vault role assignment (or access policy) for the factory's identity.
A secret reference fails but the vault is reachable and granted The named secret is not in the vault; nothing checks that at any stage Check the secret name. Neither the provider nor this module can see it.
name must not be empty. An empty name Supply one. The provider accepts an empty name; this check is the module's.
name must not consist entirely of the characters A name like --- Use a real name. This is the only shape the provider's own validator catches.
Renaming this linked service broke unrelated pipelines name is force-new and every sibling's key_vault_* block references it by name Update every reference. Nothing in Terraform tracks them.

πŸ”— Related Docs


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