A Data Factory's stored definition of how to reach a SQL Managed Instance, targeting
hashicorp/azurerm ~> 4.0.
- π Creates one
azurerm_data_factory_linked_service_sql_managed_instanceβ the definition a dataset or copy activity uses to reach a SQL Managed Instance. - π The only linked service in this family written on the provider's typed SDK β a model struct, one
encode call, and no
d.Setanywhere. Its import guard derives the resource name, so the wrong-name defect that affects a sibling is impossible here. - β
The service-principal trio is bound mutually β
id,keyandtenant, all three or none. The Kusto sibling binds its tenant one-directionally and accepts a tenant-less principal. - π΄ A password inside an inline connection string cannot be rotated. The read refreshes that field and the shared suppressor strips the password before comparing, so a change produces no diff at all.
β οΈ There is noadditional_propertiesargument β unique in this family, and an absence rather than a behaviour.
π‘ Why it matters: three of those five are contrasts with siblings, not statements about this resource alone. Nothing about the family transfers here without checking.
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"]
THIS["terraform-azurerm-data-factory-linked-service-sql-managed-instance"]
KV["terraform-azurerm-data-factory-linked-service-key-vault"]
VNETIR["terraform-azurerm-data-factory-integration-runtime-self-hosted"]
MI["a SQL Managed Instance, normally inside a virtual network"]
DS["a dataset, then a copy activity"]
RG -->|"name"| ADF
ADF -->|"id"| THIS
ADF -->|"id"| KV
KV -->|"name, for the connection string OR the password, independently"| THIS
VNETIR -->|"name, and usually REQUIRED to reach a managed instance"| THIS
THIS -->|"connects at pipeline runtime, never at apply"| MI
THIS -->|"name, referenced BY NAME and never by id"| DS
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,VNETIR,MI,DS sibling
The runtime edge matters more here than elsewhere: a SQL Managed Instance normally lives inside a virtual network, and the factory's default runtime is Microsoft-hosted with no route into one.
flowchart TB
subgraph INPUTS["Inputs"]
ID["name and data_factory_id, THE ONLY TWO FORCE-NEW FIELDS"]
CS["EXACTLY ONE OF: connection_string, key_vault_connection_string"]
KVP["key_vault_password, INDEPENDENT of that choice"]
SP["service_principal_id, key and tenant, ALL THREE OR NONE"]
META["description, integration_runtime_name, parameters, annotations"]
end
THIS["azurerm_data_factory_linked_service_sql_managed_instance.this"]
subgraph OUTPUTS["Outputs"]
TYPED["this resource uses the TYPED provider SDK, unlike its siblings"]
IMPORT["the import guard CANNOT name the wrong resource"]
MUTUAL["the provider binds all three service principal fields MUTUALLY"]
NOROT["a password inside an inline connection string CANNOT be rotated"]
ROT["password_is_rotatable, the flag to assert on"]
NOAP["this resource has NO additional_properties escape hatch"]
end
ID --> THIS
CS --> THIS
KVP --> THIS
SP --> THIS
META --> THIS
ID --> TYPED
ID --> IMPORT
SP --> MUTUAL
CS --> NOROT
KVP --> ROT
META --> NOAP
classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
class THIS this
class ID,CS,KVP,SP,META,TYPED,IMPORT,MUTUAL,NOROT,ROT,NOAP sibling
| Resource | Count | Notes |
|---|---|---|
azurerm_data_factory_linked_service_sql_managed_instance.this |
1 | The keystone. A metadata record. |
key_vault_connection_string |
dynamic, 0..1 |
One of two connection-string placements. |
key_vault_password |
dynamic, 0..1 |
Independent of that choice. |
timeouts |
dynamic, 0..1 |
All four keys; none bounds a query. |
| 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:
- π The typed SDK. A model struct with
tfschematags,metadata.Encode(&state), and nod.Setcalls at all β the only linked service in this family written this way. - β
The requires-import error derives the resource name from
r.ResourceType(), so it cannot name the wrong one. Several legacy siblings hardcode a string, and one gets it wrong. - β
A mutual
ExactlyOneOfbindsconnection_stringandkey_vault_connection_string, so the empty call is refused atterraform plan, offline and without credentials β unusual in this family. - β
The service-principal trio is bound mutually by three
RequiredWithdeclarations: all three or none. - π΄ The read refreshes
connection_string. With the family's shared diff suppressor stripping the password segment from the configured value, a password inside it cannot be rotated β no diff, no plan, no update. - β The read never assigns the service principal key, so that one is rotatable, and its drift is invisible.
β οΈ Noadditional_propertiesargument exists. Every other linked service in the family has one.β οΈ data_factory_id's ForceNew comes fromcommonschema.ResourceIDReferenceRequiredForceNew, not from the schema block β and the helper library offers a non-force-new variant of the same shape, so a grep forForceNewalone would miss it.- β
Exactly two
Sensitivefields, and they are the two that carry a secret. β οΈ The name validator is nearly inert.- βΉοΈ No
CustomizeDiffand no version gate β every provider check is reachable offline.
| 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 dataset referencing it. |
Data Factory Contributor |
the Data Factory | The built-in role containing the above. |
β Terraform needs no access to the Key Vault secrets β it stores addresses, not values.
- An existing Data Factory.
- A reachable SQL Managed Instance β and, normally, a self-hosted or managed VNet integration runtime.
- For the Key Vault placements: a Key Vault linked service in the same factory, and the secrets in it.
- For the service-principal mode: an Entra ID application, its secret, its tenant, and a login for that principal inside the instance, which nothing here creates or verifies.
terraform-azurerm-data-factory-linked-service-sql-managed-instance/
βββ providers.tf # required_version + the pinned azurerm; no provider block
βββ variables.tf # 13 variables, 22 validations
βββ main.tf # the keystone, its three dynamic blocks, and the derived locals
βββ outputs.tf # 56 outputs: the id first, then the facts no plan shows
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "mi_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-sql-managed-instance.git?ref=v1.0.0"
name = "ls-mi-sales"
data_factory_id = module.data_factory.id
# NOTE: no password in here.
connection_string = "Data Source=mi-corp.public.abc.database.windows.net,3342;Initial Catalog=sales;User ID=svc;"
key_vault_password = {
linked_service_name = "ls-keyvault"
secret_name = "mi-sales-password"
}
integration_runtime_name = "ir-vnet-eastus"
}π΄ The password is deliberately not in the connection string β put it there and it cannot be rotated.
β οΈ The runtime is named because a managed instance normally sits inside a VNet.
Consumes
| Input | Type | Source |
|---|---|---|
data_factory_id |
string |
terraform-azurerm-data-factory (id) |
connection_string |
string (sensitive) |
the call site, without a password |
key_vault_connection_string, key_vault_password |
object |
terraform-azurerm-data-factory-linked-service-key-vault (name) |
integration_runtime_name |
string |
terraform-azurerm-data-factory-integration-runtime-self-hosted (name) |
Emits
| Output | Consumed by |
|---|---|
id |
imports, RBAC scoping |
name |
datasets and copy activities |
password_is_rotatable |
assertions |
auth_mode |
assertions |
1 Β· A connection string plus a Key Vault password
connection_string = "Data Source=mi-corp.public.abc.database.windows.net,3342;Initial Catalog=sales;User ID=svc;"
key_vault_password = {
linked_service_name = "ls-keyvault"
secret_name = "mi-sales-password"
}β The recommended shape. The password lives in the vault, so rotating it changes nothing in Terraform.
2 Β· π΄ The password that cannot be rotated
connection_string = "Data Source=mi-corp...;User ID=svc;Password=hunter2;"π΄ Accepted by the provider and by this module β and unrotatable. The read refreshes
connection_stringfrom the API, and the shared suppressor strips thepasswordsegment from the configured value before comparing, so the two always agree. Changing only the password produces no diff, no plan and no update.password_is_rotatablereportsfalse.
3 Β· The whole connection string in Key Vault
key_vault_connection_string = {
linked_service_name = "ls-keyvault"
secret_name = "mi-sales-connection-string"
}βΉοΈ Exactly one of
connection_stringandkey_vault_connection_stringis required, enforced by the provider atterraform validate. The empty call is refused β unusual in this family.
4 Β· A service principal, all three fields or none
service_principal_id = var.sp_application_id
service_principal_key = var.sp_client_secret
tenant = var.tenant_idβ The provider binds all three mutually with
RequiredWith, so a partial set is refused at plan. The Kusto linked service in this same family does not β its tenant is bound one-directionally, so a tenant-less principal is accepted there and an empty tenant goes to Azure.
5 Β· Asserting the credential can be rotated
check "mi_password_is_rotatable" {
assert {
condition = module.mi_link.password_is_rotatable
error_message = "This linked service has a password inside its connection string; it cannot be rotated."
}
}π‘ Known at plan, so this fails review rather than a rotation attempt that silently does nothing.
6 Β· The Key Vault password block is independent
key_vault_connection_string = { linked_service_name = "ls-keyvault", secret_name = "mi-cs" }
key_vault_password = { linked_service_name = "ls-keyvault", secret_name = "mi-pw" }βΉοΈ Both together are legal. Only
connection_stringandkey_vault_connection_stringare mutually exclusive;key_vault_passwordaccompanies either placement.
7 Β· A host whose name contains the word
connection_string = "Data Source=my-password-host.database.windows.net,3342;Initial Catalog=sales;"
# => password_is_rotatable = trueβΉοΈ The detector is anchored to a segment boundary, so a server named
my-password-hostdoes not register as carrying a credential.
8 Β· Reading the mode at plan time
output "credential" {
value = {
placement = module.mi_link.connection_string_placement
auth = module.mi_link.auth_mode
rotatable = module.mi_link.password_is_rotatable
has_sp_key = module.mi_link.has_service_principal_key
}
}π‘ All four known at plan.
has_service_principal_keyis presence only β the key is never emitted.
9 Β· Why the import guard is trustworthy here
output "import_guard" {
value = module.mi_link.the_import_guard_cannot_name_the_wrong_resource
}β Constant
true. This resource raises its requires-import error astf.ImportAsExistsError(r.ResourceType(), β¦)β the name is derived. Several legacy siblings hardcode a string, and the Cosmos DB MongoDB API one names its SQL API sibling by mistake. That cannot happen here.
10 Β· The escape hatch that is not here
# additional_properties = { ... } # NOT AVAILABLE on this resource
β οΈ Every other linked service in this family exposesadditional_propertiesfor fields the provider has not modelled. This one does not β there is no such argument anywhere in its schema.
11 Β· Reaching a managed instance inside a VNet
integration_runtime_name = "ir-vnet-eastus"
β οΈ Omit it and the factory's default AutoResolve runtime is used β Microsoft-hosted, with no route into the VNet a managed instance normally lives in. Nothing fails at apply.
12 Β· Description, parameters and annotations
description = "Sales MI, read path"
parameters = { env = "prod" }
annotations = ["owner:data-platform", "tier:gold"]
β οΈ annotationsare not Azure resource tags β this resource supports none. They are an ordered list, so reordering them is a real diff.
13 Β· Timeouts
timeouts = {
create = "45m"
read = "10m"
update = "45m"
delete = "45m"
}
β οΈ These bound Terraform's wait on the Data Factory control-plane call, not any query against the managed instance. A fifth key would be silently discarded by object-type conversion.
14 Β· What this module refuses, and what it deliberately does not
# Refused at `terraform plan`, offline and without credentials:
# neither connection-string placement, or both
# any PARTIAL service-principal set (id alone, key alone, tenant alone, or any pair)
# service_principal_id that is not a UUID
# a Key Vault secret_name given as a full vault URI
#
# NOT refused, and reported instead:
# a password inside an inline connection string -- which cannot be rotatedπ‘ That last one is a configuration the provider accepts and many people already have. Refusing it would reject legal input β and because a failed validation blocks
terraform destroytoo, it would strand anyone who has one.
15 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
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 "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
}
module "key_vault_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 = var.key_vault_id
}
module "self_hosted_runtime" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-integration-runtime-self-hosted.git?ref=v1.0.0"
name = "ir-vnet-eastus"
data_factory_id = module.data_factory.id
}
module "mi_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-sql-managed-instance.git?ref=v1.0.0"
name = "ls-mi-sales"
data_factory_id = module.data_factory.id
connection_string = "Data Source=mi-corp.public.abc.database.windows.net,3342;Initial Catalog=sales;User ID=svc;"
key_vault_password = {
linked_service_name = module.key_vault_link.name
secret_name = "mi-sales-password"
}
integration_runtime_name = module.self_hosted_runtime.name
description = "Sales MI, read path"
annotations = ["owner:data-platform"]
}
check "mi_password_is_rotatable" {
assert {
condition = module.mi_link.password_is_rotatable
error_message = "This linked service has a password inside its connection string; it cannot be rotated."
}
}
output "review" {
value = {
linked_service = module.mi_link.name
placement = module.mi_link.connection_string_placement
auth = module.mi_link.auth_mode
rotatable = module.mi_link.password_is_rotatable
vnet_route = !module.mi_link.runs_on_the_factory_default_integration_runtime
}
}ποΈ Both siblings are wired by name, which is how this resource references them β neither creates a dependency the provider verifies.
Required: name, data_factory_id, plus exactly one of connection_string and
key_vault_connection_string.
Optional: key_vault_password, service_principal_id, service_principal_key, tenant,
description, integration_runtime_name, parameters, annotations, timeouts.
Not available: additional_properties β this resource has none.
Full schemas
variable "connection_string" {
type = string
default = null
sensitive = true # marked by the provider too; a password inside it CANNOT be rotated
}
variable "key_vault_password" {
type = object({
linked_service_name = string
secret_name = string
})
default = null # INDEPENDENT of the connection-string ExactlyOneOf
}
variable "service_principal_key" {
type = string
default = null
sensitive = true # bound mutually to service_principal_id and tenant
}| Output | Notes |
|---|---|
id |
The Resource ID. Emitted first. |
name |
What datasets reference. |
data_factory_id, data_factory_name, resource_group_name, subscription_id |
Parent context. |
connection_string_placement, auth_mode |
Which mode. |
password_is_rotatable |
The flag to assert on. |
a_password_inside_an_inline_connection_string_cannot_be_rotated |
The headline. |
this_resource_uses_the_typed_provider_sdk_unlike_its_siblings |
Constant. |
the_import_guard_cannot_name_the_wrong_resource |
Constant. |
the_provider_binds_all_three_service_principal_fields_mutually |
Constant. |
this_resource_has_no_additional_properties_escape_hatch |
Constant. |
the_force_new_on_the_factory_id_comes_from_a_shared_helper |
Constant. |
force_new_fields, fields_that_can_change_after_creation |
Lifecycle. |
No output emits a secret. The full list, with the reasoning behind each constant, is in outputs.tf.
This resource is written differently from every other linked service in the family, and that explains
several of its properties. It uses the provider's typed SDK: a model struct with tfschema tags, a read
that builds that model and encodes it in a single call, and no d.Set calls at all. Its requires-import
error derives the resource name from r.ResourceType() rather than repeating it as a string β so the
wrong-resource-name defect that affects the Cosmos DB MongoDB API sibling cannot occur here.
The service-principal trio is bound mutually, which is worth stating because the Kusto linked service binds its tenant one-directionally and therefore accepts a service principal with no tenant, sending an empty one to Azure. Here it is all three or none, refused offline.
One thing it shares with the wrong half of the family: the read refreshes connection_string. With the
shared diff suppressor stripping the password segment from the configured value before comparing, a
password inside that string cannot be rotated β a change produces no diff at all. The MySQL and PostgreSQL
linked services use the same suppressor and rotation works there, because their reads do not refresh the
field. key_vault_password, a service principal, or a Key Vault connection-string reference are the
placements that work here.
Two cross-field rules, two hubs. Terraform requires a validation to reference its own variable, so the
connection-string rule lives on connection_string and the service-principal rule on
service_principal_id β each on a field it actually mentions, which needs no synthetic weave.
data_factory_id's force-new comes from a shared helper, not from its schema block. The helper library
offers a non-force-new variant of the same shape, so a ForceNew grep alone would have reported this field
as mutable.
There is no additional_properties. An absence is easy to miss, and this one means a property the
provider has not modelled cannot be passed through at all.
Sensitivity is contagious, including to scalars. Each flag derived from a secret is unwrapped
individually with nonsensitive(); wrapping a constructed collection would not be enough.
| Concern | This module's default | Opt-out |
|---|---|---|
| Where the password goes | Key Vault, or a service principal | put it in the connection string β reported, not refused |
| The connection string itself | either placement; exactly one required | β the empty call is refused |
| Secrets in plan output | marked sensitive by the provider and this module | none β and it does not affect state |
| Emitting a secret | never; placement and presence only | none |
| A provider-accepted configuration | never refused | none needed |
The secure-by-default rule applies partly: the provider refuses a connection-string-less call, so there is no empty call to make safe. What the module adds is reporting whether the credential you chose can actually be rotated.
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0, never a branch. Plan-only; a human applies from CI.
terraform validate exercises the module's twenty-two validations and the provider's schema validators β
including the connection-string ExactlyOneOf and all three RequiredWith declarations β offline, without
credentials. This resource has no CustomizeDiff and no version gate, so every provider check is
reachable offline, which is not true of every linked service in this family.
π΄ The rotation failure cannot be reproduced offline, because it needs a refresh against a real API
response. It is asserted from the provider source instead: the harness proves the read assigns
state.ConnectionString and never assigns state.ServicePrincipalKey.
What validate cannot reach: whether the instance exists, whether any credential works, whether the
service principal has a login inside the instance, and whether the named runtime or Key Vault linked
service exists.
id = "/subscriptions/.../linkedservices/ls-mi-sales"
name = "ls-mi-sales"
connection_string_placement = "inline"
auth_mode = "sql_login_with_key_vault_password"
password_is_rotatable = true
has_service_principal_key = false
force_new_fields = ["name", "data_factory_id"]
| Symptom | Cause | Fix |
|---|---|---|
| Rotating the SQL password changes nothing | It is inside the connection string; the read refreshes that field and the suppressor strips the password. | Move it to key_vault_password, or use a service principal. |
EXACTLY ONE of connection_string and key_vault_connection_string |
Neither placement, or both. | Pick one. The provider refuses this too. |
service_principal_id, service_principal_key and tenant must be supplied together |
A partial set. | Supply all three, or none. |
service_principal_id must be a UUID |
A display name or object-ID URL was passed. | Pass the application (client) ID. |
key_vault_connection_string.secret_name takes the secret's NAME |
A full vault URI was passed. | Pass just the secret name. |
name must not begin with a digit. |
The provider's shared validator rejects a leading digit. | Rename. |
| Pipeline cannot reach the instance, but apply succeeded | The default runtime is Microsoft-hosted and a managed instance is usually in a VNet. | Name a self-hosted or managed VNet runtime. |
| A service principal authenticates but has no rights | Nothing here creates a login inside the instance. | Create it in SQL. |
additional_properties is rejected |
This resource has no such argument, unlike its siblings. | There is no escape hatch here. |
| Datasets break after a rename | The record was replaced; datasets reference it by name. | Update the datasets, or avoid renaming. |
azurerm_data_factory_linked_service_sql_managed_instanceazurerm_data_factory_linked_service_kustoβ the sibling whose tenant binding is one-directional- Azure Data Factory naming rules
- This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."