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 β€” SQL Managed Instance Terraform Module

A Data Factory's stored definition of how to reach a SQL Managed Instance, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ”Œ 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.Set anywhere. 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, key and tenant, 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 no additional_properties argument β€” 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.


❀️ 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"]
  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
Loading

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.


🧬 What this module builds

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
Loading
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.

βœ… 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:

  • πŸ”€ The typed SDK. A model struct with tfschema tags, metadata.Encode(&state), and no d.Set calls 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 ExactlyOneOf binds connection_string and key_vault_connection_string, so the empty call is refused at terraform plan, offline and without credentials β€” unusual in this family.
  • βœ… The service-principal trio is bound mutually by three RequiredWith declarations: 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.
  • ⚠️ No additional_properties argument exists. Every other linked service in the family has one.
  • ⚠️ data_factory_id's ForceNew comes from commonschema.ResourceIDReferenceRequiredForceNew, not from the schema block β€” and the helper library offers a non-force-new variant of the same shape, so a grep for ForceNew alone would miss it.
  • βœ… Exactly two Sensitive fields, and they are the two that carry a secret.
  • ⚠️ The name validator is nearly inert.
  • ℹ️ No CustomizeDiff and no version gate β€” every provider check is reachable offline.

πŸ”‘ 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 dataset referencing it.
Data Factory Contributor the Data Factory The built-in role containing the above.

⚠️ The SQL credential is governed by Azure not at all β€” whether a SQL login or a service principal, its blast radius is whatever that principal can do inside the instance.

βœ… Terraform needs no access to the Key Vault secrets β€” it stores addresses, not values.


Azure Prerequisites

  • 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.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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.


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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_string from the API, and the shared suppressor strips the password segment 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_rotatable reports false.

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_string and key_vault_connection_string is required, enforced by the provider at terraform 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_string and key_vault_connection_string are mutually exclusive; key_vault_password accompanies 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-host does 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_key is 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 as tf.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 exposes additional_properties for 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"]

⚠️ annotations are 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 destroy too, 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.


πŸ“₯ Inputs

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
}

🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.


πŸš€ Runbook

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

Pin ?ref=v1.0.0, never a branch. Plan-only; a human applies from CI.


πŸ§ͺ Testing

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.


πŸ’¬ Example Output

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"]

πŸ” Troubleshooting

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.

πŸ”— Related Docs


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