One linked service on a Synapse workspace β the connection definition that lets a pipeline, notebook or dataset reach an external system β targeting
hashicorp/azurerm ~> 4.0.
- π Records how the workspace reaches one external system β storage, Key Vault, SQL, Snowflake, and 97 more.
- π‘οΈ Closes two silent override routes:
type_properties_jsonis interpolated into a JSON wrapper as text and can escape it;additional_propertiesis merged last and wins. Both were reproduced against the provider's own code. - π― Mirrors the 101-value
typeenum generated from the provider source, because eleven of the values differ from the constants that name them. - π Emits property names, never property values β the blob is the field most likely to hold a credential, and the provider does not mark it sensitive.
- π Emits
type_was_not_overridden, comparing what Azure recorded against what the configuration asked for. - π·οΈ Carries no
tagsand nolocation;annotationsare artifact labels, not Azure tags.
π‘ Why it matters: this is the resource where the configuration and what Azure receives can disagree without anything appearing in a plan. A properties blob shaped as a JSON fragment rather than an object escapes its wrapper and rewrites the linked service's type; an
additional_propertieskey of the same name does it more directly. This module rejects both, and says why.
If this module saved you time:
- β Star the repository β it helps other people find it.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
flowchart TB
WS["terraform-azurerm-synapse-workspace"]
THIS["terraform-azurerm-synapse-linked-service"]
KVLS["a second instance of this module, of type AzureKeyVault"]
SA["terraform-azurerm-storage-account"]
KV["terraform-azurerm-key-vault"]
IR["terraform-azurerm-synapse-integration-runtime-azure"]
ART["pipelines, datasets and notebooks, which reference it BY NAME"]
WS -->|"synapse_workspace_id"| THIS
SA -->|"endpoint, into type_properties_json"| THIS
IR -->|"name, when auto-resolve cannot reach the target"| THIS
KV -->|"vault_uri, into type_properties_json"| KVLS
WS -->|"synapse_workspace_id"| KVLS
KVLS -.->|"referenced by name so the secret resolves at run time"| THIS
THIS -.->|"renaming it breaks every reference in"| ART
classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733
class THIS me
class WS target
class KVLS,SA,KV,IR,ART sib
Two dotted edges carry the design advice. A second instance of this module, of type AzureKeyVault, is
how a credential stays out of Terraform β the first linked service names it, and Synapse resolves the secret
at run time. And every pipeline and dataset references this linked service by name, which is force-new.
flowchart TB
NAME["name, referenced by every artifact"]
WSIN["synapse_workspace_id"]
TYPE["type, 101 case-sensitive values"]
TPJ["type_properties_json, a JSON object"]
ADDL["additional_properties, merged LAST"]
THIS["azurerm_synapse_linked_service.this"]
OID["id"]
OKEYS["property NAMES only, never values"]
OOVR["type_was_not_overridden"]
OSEC["references_a_key_vault_linked_service"]
NAME --> THIS
WSIN --> THIS
TYPE --> THIS
TPJ --> THIS
ADDL --> THIS
THIS --> OID
THIS --> OKEYS
THIS --> OOVR
THIS --> OSEC
classDef me fill:#0078D4,stroke:#004578,stroke-width:2px,color:#ffffff
classDef target fill:#004578,stroke:#00243d,stroke-width:2px,color:#ffffff
classDef sib fill:#eef3f8,stroke:#9db4c9,color:#1a2733
class THIS me
class OID target
class NAME,WSIN,TYPE,TPJ,ADDL,OKEYS,OOVR,OSEC sib
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_synapse_linked_service |
1 (this) |
one connection definition, workspace-scoped |
/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Synapse/workspaces/WORKSPACE/linkedServices/NAME
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures the provider, its authentication, and the mandatory features {} block |
| Resources created | 1 |
Schema notes that bite
- π΄
type_properties_jsonis interpolated into a JSON wrapper as TEXT. The provider formats this field's raw value into a wrapper object and then unmarshals the result into the property map it has already populated β so a value that is not self-contained JSON escapes the wrapper and adds sibling keys at the top level, silently replacingtype. This module requires the value to parse as JSON on its own, which is exactly the condition such a fragment fails. - π΄
additional_propertiesis merged at the top level, LAST, and wins. Its keys are copied in aftertype,description,parameters,annotationsand the integration runtime are set, so a key namedtypereplaces the argument rather than supplementing it. The six reserved names are rejected here. - π΄ Nothing checks that
typeandtype_properties_jsonagree. The type is validated against a list, the properties as JSON, independently β a mismatch is refused at apply, or accepted and useless. - π΄ The properties blob is NOT marked sensitive. A credential embedded there appears in plan output and is written to state in clear.
- π΄ 101 case-sensitive type values, and eleven differ from the constant that names them:
AzureSqlDatabasenotAzureSQLDatabase,AzureMySql,AzurePostgreSql,HttpServer,SqlServer,CosmosDbMongoDbApi,AmazonRdsForSqlServerβ whileAzureSqlDW,AzureSqlMI,AmazonMWSandAzureBlobFSkeep their capitals. There is no rule to learn. - π΄ Azure has returned HTTP 200 for a failed create. The provider re-inspects the response body afterwards specifically because of it, citing the upstream issue β so an apply here can fail after the operation appeared to succeed.
β οΈ nameis validated by nothing at all β not a pattern, not a length, not even non-empty β while being force-new and the string every artifact in the workspace references.β οΈ Everyparametersentry is typed as a string, and the map value is its default. An int or bool parameter cannot be expressed through this resource.β οΈ annotationsis a list, so reordering the same labels produces a diff. They are artifact labels, not Azure tags.- β JSON key ordering is normalised and diff-suppressed, so a reformatted blob does not show as a change.
- Force-new:
name,synapse_workspace_id,type. The properties blob updates in place.
Least-privilege, at the smallest scope that works:
Microsoft.Synapse/workspaces/readon the workspace, plus Synapse-plane rights to manage artifacts β in practice Synapse Artifact Publisher or Synapse Administrator on the workspace. This resource is created through the workspace's own Synapse endpoint rather than through Azure Resource Manager, so ARM permissions alone are not sufficient.- Nothing on the target system is required of the caller. A linked service is a stored definition; the identity that eventually uses it is the workspace's, or the one named in the properties.
β οΈ Terraform must be able to reach the workspace's Synapse endpoint. A workspace with public network access disabled may not be reachable from the apply environment, and the failure reads as a network error rather than a permissions one.
π Plan access is credential access if the credential is in the blob. The provider does not mark
type_properties_jsonsensitive. Keep the secret in Key Vault and this stays untrue.
- An existing Synapse workspace.
- The target system, and the correct property shape for the chosen
type. - A Key Vault linked service if the connection needs a credential.
- An integration runtime if the target is not reachable from Synapse's auto-resolve runtime.
- The caller configures
provider "azurerm" { features {} }, authentication and subscription.
terraform-azurerm-synapse-linked-service/
βββ providers.tf # required_version + pinned azurerm; no provider block
βββ variables.tf # 10 inputs, 20 validations, both override routes closed
βββ main.tf # the single keystone `this` + a dynamic integration_runtime block
βββ outputs.tf # 33 outputs; property NAMES only, never values
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
type = "AzureBlobFS"
type_properties_json = jsonencode({
url = module.lake.primary_dfs_endpoint
})
}π‘ Note the casing:
AzureBlobFS, notAzureBlobFs. The module's error message lists the traps.
βΉοΈ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source |
|---|---|---|
synapse_workspace_id |
string |
terraform-azurerm-synapse-workspace output id |
type_properties_json |
string |
built with jsonencode() from the target module's outputs |
integration_runtime.name |
string |
terraform-azurerm-synapse-integration-runtime-azure output name |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Resource ID of the linked service | review |
name |
Name every artifact references it by | pipelines and datasets |
synapse_workspace_id |
The workspace it belongs to | sibling modules |
synapse_workspace_name |
Workspace name, parsed at a fixed index | labelling |
resource_group_name |
Resource group, parsed at a fixed index | sibling modules |
type |
The type Azure recorded | override review |
type_in_configuration |
The type that was asked for | override review |
type_was_not_overridden |
True when the two agree | override review |
type_properties_keys |
Property NAMES only, sorted | connection review |
type_properties_key_count |
How many properties were set | connection review |
references_a_key_vault_linked_service |
True when a secret resolves at run time | security review |
description |
The Studio description, or null | review |
annotations |
Artifact labels β not Azure tags | Studio organisation |
parameter_names |
Parameter NAMES only, sorted | review |
integration_runtime_name |
The runtime named, or null | networking review |
uses_the_default_integration_runtime |
True when none was named | networking review |
additional_property_names |
Extra top-level property names | override review |
type_properties_json_is_interpolated_into_a_json_wrapper |
Constant true | design review |
additional_properties_are_merged_last_and_win |
Constant true | design review |
nothing_checks_that_the_type_and_its_properties_agree |
Constant true | design rationale |
nothing_here_tests_the_connection |
Constant true | operational review |
is_a_data_plane_resource_behind_an_arm_shaped_id |
Constant true | design review |
azure_has_returned_200_for_a_failed_create |
Constant true | apply-time expectations |
every_parameter_is_typed_as_a_string |
Constant true | design rationale |
annotations_are_not_azure_resource_tags |
Constant true | tagging policy |
the_properties_blob_is_not_marked_sensitive |
Constant true | security review |
renaming_breaks_every_artifact_that_references_it |
Constant true | change planning |
force_new_fields |
name, the workspace and type |
change planning |
fields_azure_returns_on_read |
Where drift is detectable | drift review |
json_key_ordering_does_not_cause_a_diff |
Constant true | drift review |
create_refuses_an_existing_linked_service |
Constant true | import review |
the_import_guard_can_be_disabled_by_a_provider_feature |
Constant true | design rationale |
this_resource_supports_no_azure_resource_tags |
Constant true | tagging policy |
1 Β· A Data Lake connection
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
description = "Curated zone of the analytics data lake"
type = "AzureBlobFS"
type_properties_json = jsonencode({
url = module.lake.primary_dfs_endpoint
})
}π‘ Build the blob with
jsonencode()rather than a heredoc. The provider normalises the value and suppresses key-ordering differences either way, butjsonencode()puts the object under Terraform's own type checking β a string literal escapes it entirely.
2 Β· π The Key Vault pattern β keeping the secret out of Terraform
# 1. A linked service that IS the vault.
module "vault_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-keyvault"
synapse_workspace_id = module.workspace.id
type = "AzureKeyVault"
type_properties_json = jsonencode({
baseUrl = module.kv.vault_uri
})
}
# 2. A connection that NAMES it, rather than embedding the password.
module "sql_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-sql-reporting"
synapse_workspace_id = module.workspace.id
type = "AzureSqlDatabase"
type_properties_json = jsonencode({
connectionString = "Server=tcp:sqlsrv.database.windows.net;Database=reporting;User ID=svc_reporting;"
password = {
type = "AzureKeyVaultSecret"
secretName = "sql-reporting-password"
store = {
referenceName = module.vault_connection.name
type = "LinkedServiceReference"
}
}
})
}π
the_properties_blob_is_not_marked_sensitiveistrue. A password written inline here would appear in plan output and be stored in state in clear β andsensitive = truewould only redact the plan, not encrypt the state. This pattern removes the secret rather than protecting it better.
π‘
module.sql_connection.references_a_key_vault_linked_serviceistruefor exactly this shape, so a composition can assert it. See example 9.
3 Β· π΄ The properties blob that rewrites the type
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
type = "AzureBlobFS"
# A FRAGMENT, not an object. The provider interpolates this raw text into a
# JSON wrapper, so the second key escapes into the PARENT object.
type_properties_json = "{}, \"type\": \"AzureKeyVault\""
}Error: Invalid value for variable
type_properties_json is not valid JSON on its own. That matters for more than
tidiness: the provider interpolates this value TEXTUALLY into a JSON wrapper,
so a fragment that only parses once embedded -- anything shaped like an empty
object followed by a comma and another key -- escapes the wrapper and
overwrites the top-level type.
π΄ Without this check the linked service would be created as an
AzureKeyVault, while state and the plan both saidAzureBlobFS. The check is a plain "must be valid JSON" β which happens to be exactly the condition the injection shape fails.
4 Β· π΄ The additional property that rewrites the type
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
type = "AzureBlobFS"
type_properties_json = jsonencode({ url = module.lake.primary_dfs_endpoint })
additional_properties = {
type = "AzureKeyVault" # <-- merged LAST, so it wins
}
}Error: Invalid value for variable
additional_properties contains a RESERVED key. The provider merges these
entries into the request at the top level and LAST, so "type",
"typeProperties", "connectVia", "description", "parameters" and "annotations"
would each silently overwrite the argument of the same name -- the
configuration would say one thing and Azure would receive another, with
nothing in the plan to show it.
β οΈ additional_propertiesis for fields this resource has no argument for, and nothing else. Use the argument where one exists.
5 Β· The type casing that catches everyone
module "sql_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-sql-reporting"
synapse_workspace_id = module.workspace.id
type = "AzureSQLDatabase" # <-- rejected
type_properties_json = jsonencode({ connectionString = "Server=x" })
}Error: Invalid value for variable
type must be one of the 101 values the provider accepts, case-sensitively.
Note the casing: it is "AzureSqlDatabase" not "AzureSQLDatabase",
"AzureMySql" not "AzureMySQL", ... -- while "AzureSqlDW", "AzureSqlMI",
"AmazonMWS" and "AzureBlobFS" do keep their capitals. There is no rule;
check the provider's documented list.
π‘ The module's list was generated from the provider's own source, not transcribed, because eleven of the 101 values differ from the constants that name them. Typing the list by hand would have rejected legal input β and a validation failure blocks
terraform destroyas well as apply.
6 Β· Naming a specific integration runtime
module "self_hosted_runtime" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-integration-runtime-self-hosted.git?ref=v1.0.0"
name = "ir-onprem-selfhosted"
synapse_workspace_id = module.workspace.id
}
module "onprem_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-onprem-sql"
synapse_workspace_id = module.workspace.id
type = "SqlServer"
type_properties_json = jsonencode({
connectionString = "Server=onprem-sql01;Database=erp;Integrated Security=False;User ID=svc_synapse;"
})
integration_runtime = {
name = module.self_hosted_runtime.name
}
}βΉοΈ Omit the block and Synapse uses its auto-resolve runtime, which is right for most Azure targets and wrong for anything Azure cannot reach.
uses_the_default_integration_runtimereports which you got.
β οΈ The runtime is referenced by name, and nothing verifies that it exists β a misspelling is accepted here and fails when a pipeline runs.
7 Β· Parameters β and what they cannot be
module "parameterised_lake" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-parameterised"
synapse_workspace_id = module.workspace.id
type = "AzureBlobFS"
type_properties_json = jsonencode({
url = "https://@{linkedService().accountName}.dfs.core.windows.net"
})
# name => DEFAULT VALUE. Every parameter is typed as a string.
parameters = {
accountName = "stanalyticsprod01"
}
}
β οΈ every_parameter_is_typed_as_a_stringistrue: the provider hardcodes the string type and treats the map's value as the default, so an integer or boolean parameter cannot be expressed through this resource. The@{...}expression is Synapse's own syntax and means nothing to Terraform β a property referencing a parameter that does not exist fails at run time, not at apply.
8 Β· Annotations are not tags
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
type = "AzureBlobFS"
type_properties_json = jsonencode({ url = module.lake.primary_dfs_endpoint })
# Studio labels. No Azure Policy, cost report or tag inheritance sees these.
annotations = ["prod", "curated-zone", "owner:analytics-platform"]
}
β οΈ annotations_are_not_azure_resource_tagsistrue, and this resource exposes notagsargument at all. Tag the workspace for anything a policy or a cost report must see.
π‘ It is a list, not a set β reordering the same labels produces a diff.
9 Β· Auditing the connections a workspace holds
locals {
connections = {
lake = module.lake_connection
sql = module.sql_connection
vault = module.vault_connection
}
# A connection that looks like it needs a credential and does not reference one.
credential_bearing_types = ["AzureSqlDatabase", "SqlServer", "Snowflake", "Odbc", "RestService"]
secrets_possibly_inline = [
for k, m in local.connections : k
if contains(local.credential_bearing_types, m.type) && !m.references_a_key_vault_linked_service
]
overridden = [for k, m in local.connections : k if !m.type_was_not_overridden]
}
output "linked_service_review" {
value = {
types = { for k, m in local.connections : k => m.type }
property_names = { for k, m in local.connections : k => m.type_properties_keys }
default_runtime = [for k, m in local.connections : k if m.uses_the_default_integration_runtime]
secrets_possibly_inline = local.secrets_possibly_inline
types_that_do_not_match = local.overridden
}
}π
type_properties_keysis property names only. The values are never emitted, because the blob is the field most likely to hold a credential and re-emitting it would copy that credential into every consuming configuration's state.
π‘
references_a_key_vault_linked_servicematches on the property names Synapse uses for a secret reference. It is a strong hint, not a proof β the blob's shape is type-specific and unpublished.
10 Β· Detecting an override made outside this module
check "linked_service_types_are_what_we_asked_for" {
assert {
condition = alltrue([for k, m in local.connections : m.type_was_not_overridden])
error_message = "A Synapse linked service was recorded with a different type than its configuration requested. This module rejects both override routes, so this means the artifact was written by something else -- an earlier configuration, a Studio edit, or a provider feature toggle that let a create overwrite it."
}
}π‘
typeis read back from the service, andtype_in_configurationis what was asked for. Comparing them is the only way a composition can notice an override, and it works even for artifacts this module did not create the first time.
11 Β· Renaming breaks every reference
# WRONG on a live workspace. `name` is force-new, and every pipeline, dataset
# and notebook references a linked service BY NAME.
#
# name = "ls-adls-curated-v2" # <-- destroys the old artifact
#
# The Terraform plan reads as a clean replacement. The workspace's artifacts
# do not: each one still names "ls-adls-curated", which no longer exists.
#
# RIGHT: create the new linked service alongside, repoint the artifacts, then
# remove the old module block on a later apply.
β οΈ renaming_breaks_every_artifact_that_references_itistrueon every instance. Terraform cannot see the artifacts, so it cannot warn you β the name is a contract with objects this provider does not manage.
12 Β· Several connections with `for_each`
locals {
linked_services = {
lake_curated = { type = "AzureBlobFS", props = { url = "https://stanalyticsprod01.dfs.core.windows.net" } }
lake_raw = { type = "AzureBlobFS", props = { url = "https://strawprod01.dfs.core.windows.net" } }
keyvault = { type = "AzureKeyVault", props = { baseUrl = "https://kv-analytics-prod.vault.azure.net/" } }
}
}
module "linked_services" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
for_each = local.linked_services
name = "ls-${each.key}"
synapse_workspace_id = module.workspace.id
type = each.value.type
type_properties_json = jsonencode(each.value.props)
annotations = ["managed-by-terraform"]
}π‘ Key the map by what it connects to. Because
nameis derived from the key and is force-new, a stable key is what keeps adding one connection from re-creating β and breaking the references to β the others.
13 Β· ποΈ End-to-end composition
Resource group + data lake + Key Vault + Synapse workspace + an integration runtime + the Key Vault linked service + a SQL connection that resolves its password through it.
provider "azurerm" {
features {}
}
data "azurerm_client_config" "current" {}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-analytics-prod"
location = "eastus2"
}
module "lake" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stanalyticsprod01"
resource_group_name = module.rg.name
location = module.rg.location
account_tier = "Standard"
account_replication_type = "ZRS"
is_hns_enabled = true
}
resource "azurerm_storage_data_lake_gen2_filesystem" "root" {
name = "synapse-root"
storage_account_id = module.lake.id
}
module "kv" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-analytics-prod"
resource_group_name = module.rg.name
location = module.rg.location
tenant_id = data.azurerm_client_config.current.tenant_id
}
module "workspace" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-workspace.git?ref=v1.0.0"
name = "synw-analytics-prod"
resource_group_name = module.rg.name
location = module.rg.location
storage_data_lake_gen2_filesystem_id = azurerm_storage_data_lake_gen2_filesystem.root.id
azuread_authentication_only = true
managed_virtual_network_enabled = true
identity = { type = "SystemAssigned" }
tags = {
environment = "prod"
workload = "analytics"
}
}
module "runtime" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-integration-runtime-azure.git?ref=v1.0.0"
name = "ir-managed-eastus2"
synapse_workspace_id = module.workspace.id
location = module.rg.location
}
# The vault connection. Nothing secret passes through Terraform.
module "vault_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-keyvault"
synapse_workspace_id = module.workspace.id
description = "Resolves connection secrets at run time"
type = "AzureKeyVault"
type_properties_json = jsonencode({
baseUrl = module.kv.vault_uri
})
annotations = ["managed-by-terraform"]
}
# The lake connection.
module "lake_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-adls-curated"
synapse_workspace_id = module.workspace.id
description = "Curated zone of the analytics data lake"
type = "AzureBlobFS"
type_properties_json = jsonencode({
url = module.lake.primary_dfs_endpoint
})
integration_runtime = {
name = module.runtime.name
}
annotations = ["managed-by-terraform", "curated-zone"]
}
# The SQL connection -- password resolved through the vault connection above.
module "sql_connection" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-synapse-linked-service.git?ref=v1.0.0"
name = "ls-sql-reporting"
synapse_workspace_id = module.workspace.id
description = "Reporting database, credential resolved from Key Vault"
type = "AzureSqlDatabase"
type_properties_json = jsonencode({
connectionString = "Server=tcp:sqlsrv.database.windows.net;Database=reporting;User ID=svc_reporting;"
password = {
type = "AzureKeyVaultSecret"
secretName = "sql-reporting-password"
store = {
referenceName = module.vault_connection.name
type = "LinkedServiceReference"
}
}
})
integration_runtime = {
name = module.runtime.name
}
annotations = ["managed-by-terraform"]
}
output "linked_service_review" {
value = {
for k, m in {
lake = module.lake_connection
sql = module.sql_connection
vault = module.vault_connection
} : k => {
name = m.name
type = m.type
property_names = m.type_properties_keys
resolves_secret = m.references_a_key_vault_linked_service
type_intact = m.type_was_not_overridden
runtime = m.integration_runtime_name
}
}
}π Read
resolves_secretat review time. The SQL connection's istruebecause its properties name the vault connection rather than carrying the password β which is the difference between a secret that lives in Key Vault and one that lives in your state file.
β οΈ The workspace's managed identity needs Get on the vault's secrets before the SQL connection works. That grant is a sibling module's job, and nothing here checks for it βnothing_here_tests_the_connectionistrue.
βΉοΈ Output names shown for sibling modules are the ones those modules emit at
v1.0.0; align them with the versions you consume.
| Input | Type | Required | Default |
|---|---|---|---|
name |
string |
β | β |
synapse_workspace_id |
string |
β | β |
type |
string |
β | β |
type_properties_json |
string |
β | β |
description |
string |
β | null |
integration_runtime |
object({ name, parameters }) |
β | null |
parameters |
map(string) |
β | {} |
annotations |
list(string) |
β | [] |
additional_properties |
map(string) |
β | {} |
timeouts |
object({ create, read, update, delete }) |
β | null |
Full schemas
variable "name" {
type = string
# Validated by NOTHING in the provider. This module rejects an empty value, a
# slash and control characters. Force-new, and the string every artifact in
# the workspace references.
}
variable "synapse_workspace_id" {
type = string
# Anchored at both ends. Rejects this module's own linked service ID and any
# pool ID.
}
variable "type" {
type = string
# One of 101 case-sensitive values, GENERATED from the provider's source
# because eleven of them differ from the constants that name them.
}
variable "type_properties_json" {
type = string
# Must parse as a JSON OBJECT on its own -- which is exactly the condition a
# wrapper-escaping fragment fails. NOT force-new; NOT marked sensitive by the
# provider, so keep credentials out of it.
}
variable "description" {
type = string
default = null
# Non-empty when supplied -- mirrors the provider's own check.
}
variable "integration_runtime" {
type = object({
name = string
parameters = optional(map(string), {})
})
default = null
# At most one. Omit it for Synapse's auto-resolve runtime. Referenced by
# NAME, and nothing verifies that the runtime exists.
}
variable "parameters" {
type = map(string)
default = {}
# name => DEFAULT VALUE. Every parameter is typed as a string by the
# provider; an int or bool cannot be expressed.
}
variable "annotations" {
type = list(string)
default = []
# Artifact labels, NOT Azure tags. A list, so order matters. Rejects an empty
# entry and a duplicate.
}
variable "additional_properties" {
type = map(string)
default = {}
# Merged at the TOP level and LAST. The six reserved names -- type,
# typeProperties, connectVia, description, parameters, annotations -- are
# rejected, because each would silently overwrite the argument.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
# Provider defaults: create 30m, read 5m, update 30m, delete 30m.
}| Output | Description | Notes |
|---|---|---|
id |
Resource ID of the linked service | Emitted first |
name |
Name every artifact references it by | force-new |
synapse_workspace_id |
The workspace it belongs to | |
synapse_workspace_name |
Workspace name | parsed at a fixed index |
resource_group_name |
Resource group | parsed at a fixed index |
type |
The type Azure recorded | read back from the service |
type_in_configuration |
The type that was asked for | from the variable |
type_was_not_overridden |
True when the two agree | the override detector |
type_properties_keys |
Property names only, sorted | values never emitted |
type_properties_key_count |
How many properties were set | known at plan time |
references_a_key_vault_linked_service |
True when a secret resolves at run time | a hint, not a proof |
description |
The Studio description | null when unset |
annotations |
Artifact labels | not Azure tags |
parameter_names |
Parameter names only, sorted | defaults never emitted |
integration_runtime_name |
The runtime named | null for auto-resolve |
uses_the_default_integration_runtime |
True when none was named | |
additional_property_names |
Extra top-level property names | worth reading in review |
type_properties_json_is_interpolated_into_a_json_wrapper |
Constant true |
|
additional_properties_are_merged_last_and_win |
Constant true |
|
nothing_checks_that_the_type_and_its_properties_agree |
Constant true |
|
nothing_here_tests_the_connection |
Constant true |
|
is_a_data_plane_resource_behind_an_arm_shaped_id |
Constant true |
|
azure_has_returned_200_for_a_failed_create |
Constant true |
|
every_parameter_is_typed_as_a_string |
Constant true |
|
annotations_are_not_azure_resource_tags |
Constant true |
|
the_properties_blob_is_not_marked_sensitive |
Constant true |
|
renaming_breaks_every_artifact_that_references_it |
Constant true |
|
force_new_fields |
["name", "synapse_workspace_id", "type"] |
the blob updates in place |
fields_azure_returns_on_read |
Where drift is detectable | type genuinely is |
json_key_ordering_does_not_cause_a_diff |
Constant true |
|
create_refuses_an_existing_linked_service |
Constant true |
|
the_import_guard_can_be_disabled_by_a_provider_feature |
Constant true |
the toggle is the caller's |
this_resource_supports_no_azure_resource_tags |
Constant true |
tag the workspace instead |
No secret is emitted, and
type_properties_jsonis deliberately not echoed back.
Two ways the configuration and Azure can disagree. The provider builds this resource's request by
populating a property map with type and the integration runtime, then formatting type_properties_json's
raw text into a JSON wrapper and unmarshalling that into the same map, then copying every
additional_properties entry over the top. Both of the later steps can reach keys the earlier ones set. A
properties value shaped as a fragment rather than an object escapes its wrapper and becomes a sibling of
typeProperties β replacing type outright; an additional_properties key called type does it directly.
Both were reproduced from the provider's construction sequence before being written down. This module
rejects both, which is a departure from its usual report-don't-refuse posture and a deliberate one: a
reported problem is one a reader can weigh, and these two produce a resource whose state says one thing and
whose reality is another, with nothing in a plan to show it.
Why the enum was generated rather than typed. type accepts 101 case-sensitive values, and eleven of
them do not match the Go constant that names them β TypeBasicLinkedServiceTypeAzureSQLDatabase is the
string "AzureSqlDatabase", ...HTTPServer is "HttpServer", ...MySQL is "MySql" β while
AzureSqlDW, AzureSqlMI, AmazonMWS and AzureBlobFS keep their capitals with no pattern to them. The
list in variables.tf was extracted from the provider's own source and asserted equal to that extraction.
Hand-typing it would very likely have rejected a legal value, and a validation {} failure blocks
terraform destroy as well as apply β an over-strict enum here would make a working linked service
undestroyable through the module.
Names, not values. type_properties_json is the field most likely to hold a credential and the provider
does not mark it sensitive, so this module emits its keys and never its values. Re-emitting the blob
would copy any embedded secret into every consuming configuration's state as well as this one's. The same
reasoning governs parameter_names: a parameter's map value is its default, which is often
environment-specific. The honest control is the Key Vault pattern in example 2, which removes the secret
rather than protecting it better.
The name is a contract with objects Terraform cannot see. Pipelines, datasets and notebooks reference a
linked service by name, and name is force-new. A rename is a clean replacement to Terraform and a broken
reference to every artifact β none of which this provider manages, so none of which can be warned about.
Create the replacement alongside, repoint the artifacts, and remove the old block later.
Where each check actually fires. The name checks, the workspace-ID pattern, the type enum, the JSON
checks, the reserved-key rejection and the collection checks are module validation {} blocks: they fire at
terraform validate, offline. The provider's own type enum and its non-empty checks fire there too.
Everything else is decided at apply β whether the properties match the type, whether the runtime exists,
whether Terraform can reach the Synapse endpoint β and whether the connection actually works is decided
later still, when a pipeline runs.
A create that can fail after succeeding. Azure has been observed returning HTTP 200 for a create that in fact failed, and the provider re-inspects the response body specifically because of it. That is worth knowing when reading a failure: the operation may have reported success before the error surfaced.
The features {} dependence. The provider will not initialize without a caller-side
provider "azurerm" { features {} } block. That block also carries the toggle that can disable this
resource's create-time existence check.
| Concern | Default in this module | Opt-out |
|---|---|---|
| Properties blob shape | must parse as a JSON object on its own | none β this is what closes the injection |
Reserved additional_properties keys |
rejected | use the module's own argument |
type value set |
the provider's 101, generated not transcribed | none |
| Credentials in the blob | not prevented, but not emitted β use the Key Vault pattern | β |
| Property values in outputs | never emitted; names only | β |
| Integration runtime | omitted, so Synapse auto-resolves | name one explicitly |
| Annotations | empty; documented as not Azure tags | supply them |
| Renaming | documented and emitted; no technical guard exists | β |
| Secrets | none accepted deliberately, none emitted | β |
| Tags | not supported by the resource | tag the workspace |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 β never a branch. This module is authored and verified plan-only; a
human applies from CI.
What validate and fmt cover, offline and without credentials
- All 20
validation {}blocks β three on the name, three on the workspace ID, two ontype, three on the properties blob, one on the description, two on the integration runtime, one onparameters, two onannotations, two onadditional_propertiesand one ontimeoutsβ each proven to fire from a deliberately bad.tfvarsfile, and each proven not to fire from three fully-populated valid ones. - Every fixture was first checked to parse, because a malformed fixture reports no validation failure and reads exactly like a check that does not fire.
- The injection payload from example 3 is proven to be caught by the JSON check.
- The 101-value enum is asserted equal to the extraction from the provider's source, so a transcription error would fail the build rather than ship.
- Every derived output expression, lifted into a console harness and driven in three states: an ordinary
connection with an explicit runtime, a Key-Vault-referencing connection on the default runtime, and a
deliberate type mismatch that proves
type_was_not_overriddenreportsfalse.
What only terraform plan or apply exercises
- Whether the properties match the type.
- Whether the named integration runtime exists.
- Whether Terraform can reach the workspace's Synapse endpoint.
- The create-time existence check, and whether the caller's
features {}block has disabled it.
What nothing in Terraform ever exercises
- Whether the connection works.
nothing_here_tests_the_connectionistrue; the failure appears when a pipeline runs.
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-analytics-prod/providers/Microsoft.Synapse/workspaces/synw-analytics-prod/linkedServices/ls-sql-reporting"
name = "ls-sql-reporting"
synapse_workspace_name = "synw-analytics-prod"
resource_group_name = "rg-analytics-prod"
type = "AzureSqlDatabase"
type_in_configuration = "AzureSqlDatabase"
type_was_not_overridden = true
type_properties_keys = ["connectionString", "password"]
type_properties_key_count = 2
references_a_key_vault_linked_service = true
annotations = ["managed-by-terraform"]
parameter_names = []
integration_runtime_name = "ir-managed-eastus2"
uses_the_default_integration_runtime = false
additional_property_names = []
force_new_fields = ["name", "synapse_workspace_id", "type"]
| Symptom | Cause | Fix |
|---|---|---|
Invalid value for variable ... not valid JSON on its own |
The blob is a fragment, not an object β it would have escaped its wrapper and rewritten type |
Pass one self-contained object, built with jsonencode() |
Invalid value for variable ... RESERVED key |
An additional_properties key would have overwritten an argument |
Use the module's own argument |
Invalid value for variable ... type must be one of the 101 values |
Almost always the SQL casing | AzureSqlDatabase, not AzureSQLDatabase; check the message for the other ten |
| The linked service applied but a pipeline cannot connect | Nothing here tests the connection | Check the target, the credential and the runtime; the definition was stored regardless |
| The apply failed after reporting the operation succeeded | Azure has returned 200 for a failed create; the provider re-checks the body | Read the provider's error rather than the operation status |
type in state does not match the configuration |
The artifact was written by something other than this module | Both override routes are rejected here β check for a Studio edit or a features {} toggle that allowed an overwrite |
| A password appears in the plan output | The credential is inline in the properties blob | Move it to Key Vault and reference it β example 2 |
| Pipelines broke after a rename | name is force-new and artifacts reference it by name |
Create the new one alongside, repoint the artifacts, remove the old block later |
| A reformatted blob produced no diff | Key ordering is normalised and diff-suppressed | Expected |
| An apply fails with a connectivity error before any Azure error | Terraform cannot reach the workspace's Synapse endpoint | Run the apply from a network that can |
| An integer parameter is stored as a string | Every parameter is typed as a string by the provider | Not expressible through this resource |
| An existing linked service was silently overwritten | The caller's features {} block disables the import check |
Re-enable it, or import the existing artifact first |
azurerm_synapse_linked_serviceazurerm_synapse_workspaceazurerm_synapse_integration_runtime_azure- Sibling modules:
terraform-azurerm-synapse-workspace,terraform-azurerm-synapse-integration-runtime-azure,terraform-azurerm-synapse-integration-runtime-self-hosted,terraform-azurerm-key-vault,terraform-azurerm-storage-account - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."