The pointer that lets every other linked service in a Data Factory avoid holding a secret, targeting
hashicorp/azurerm ~> 4.0.
- ποΈ Creates one
azurerm_data_factory_linked_service_key_vaultβ the resource everykey_vault_password,key_vault_connection_string,key_vault_licenseandkey_vault_secret_referenceblock 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_idback empty. - π΄
key_vault_idis 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.
If this module saved you time:
- β Star the repository β it is the cheapest signal that this work is worth continuing.
- πΌ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
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
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.
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
| 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. |
| 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_idis 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_idis 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_propertiesis validated by nothing; a misspelled key is silently ignored.β οΈ annotationsis an ordered list, so reordering is a real diff β and it is nottags.β οΈ Onlynameanddata_factory_idare force-new. There is nolocationand notags.- βΉοΈ The Resource ID's segment is lower-case
linkedservices. - βΉοΈ No cross-field rules and no
CustomizeDiff, so every provider check here fires atterraform validate, offline. - βΉοΈ The requires-import error names this resource correctly.
| 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.
- 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.
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
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.nameinto every sibling'skey_vault_*block. That name β not the Resource ID β is what those blocks take.
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. |
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_idis 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_licenseandkey_vault_secret_referenceblock across the Data Factory family takes alinked_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_idis 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_diffis 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_groupreports 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_idis declared through a shared helper that is Required without ForceNew β there is a...RequiredForceNewvariant 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_runtimereports 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:nameis force-new and it is what every sibling'skey_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 notagsargument; 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 landskey_vault_idempty 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 bysecret_nameis actually in the vault. Nothing checks that until a pipeline runs.
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
}| 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 |
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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.
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...RequiredForceNewvariant does not appear, and that exactly twoForceNewfields 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_fieldsomitskey_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.
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
| 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. |
azurerm_data_factory_linked_service_key_vaultazurerm_data_factory_linked_service_azure_sql_databaseβ a typical consumer- Store credentials in Azure Key Vault β the pattern this resource enables
- Managed identity for Data Factory β the identity that needs the grant
- Azure Key Vault RBAC guide β
Key Vault Secrets Userand friends - Data Factory naming rules β the real authority on names
SCOPE.mdβ this module's cross-module contract
π "Infrastructure as Code should be standardized, consistent, and secure."