Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Synapse Linked Service Terraform Module

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.

Terraform azurerm Module Type Resources Secret-aware


🧩 Overview

  • πŸ”Œ Records how the workspace reaches one external system β€” storage, Key Vault, SQL, Snowflake, and 97 more.
  • πŸ›‘οΈ Closes two silent override routes: type_properties_json is interpolated into a JSON wrapper as text and can escape it; additional_properties is merged last and wins. Both were reproduced against the provider's own code.
  • 🎯 Mirrors the 101-value type enum 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 tags and no location; annotations are 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_properties key of the same name does it more directly. This module rejects both, and says why.


❀️ Support this project

If this module saved you time:


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

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
Loading

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.


🧬 What this module builds

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
Loading

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

βœ… Provider / Versions

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_json is 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 replacing type. This module requires the value to parse as JSON on its own, which is exactly the condition such a fragment fails.
  • πŸ”΄ additional_properties is merged at the top level, LAST, and wins. Its keys are copied in after type, description, parameters, annotations and the integration runtime are set, so a key named type replaces the argument rather than supplementing it. The six reserved names are rejected here.
  • πŸ”΄ Nothing checks that type and type_properties_json agree. 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: AzureSqlDatabase not AzureSQLDatabase, AzureMySql, AzurePostgreSql, HttpServer, SqlServer, CosmosDbMongoDbApi, AmazonRdsForSqlServer β€” while AzureSqlDW, AzureSqlMI, AmazonMWS and AzureBlobFS keep 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.
  • ⚠️ name is 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.
  • ⚠️ Every parameters entry is typed as a string, and the map value is its default. An int or bool parameter cannot be expressed through this resource.
  • ⚠️ annotations is 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.

πŸ”‘ Required Azure RBAC Roles / Permissions

Least-privilege, at the smallest scope that works:

  • Microsoft.Synapse/workspaces/read on 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_json sensitive. Keep the secret in Key Vault and this stays untrue.


Azure Prerequisites

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

πŸ“ Module Structure

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

βš™οΈ Quick Start

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, not AzureBlobFs. 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.


πŸ”Œ Cross-Module Contract

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

πŸ“š Example Library

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, but jsonencode() 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_sensitive is true. A password written inline here would appear in plan output and be stored in state in clear β€” and sensitive = true would 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_service is true for 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 said AzureBlobFS. 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_properties is 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 destroy as 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_runtime reports 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_string is true: 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_tags is true, and this resource exposes no tags argument 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_keys is 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_service matches 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."
  }
}

πŸ’‘ type is read back from the service, and type_in_configuration is 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_it is true on 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 name is 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_secret at review time. The SQL connection's is true because 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_connection is true.

ℹ️ Output names shown for sibling modules are the ones those modules emit at v1.0.0; align them with the versions you consume.


πŸ“₯ Inputs

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

🧾 Outputs

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_json is deliberately not echoed back.


🧠 Architecture Notes

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.


🧱 Design Principles

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

πŸš€ Runbook

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

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


πŸ§ͺ Testing

What validate and fmt cover, offline and without credentials

  • All 20 validation {} blocks β€” three on the name, three on the workspace ID, two on type, three on the properties blob, one on the description, two on the integration runtime, one on parameters, two on annotations, two on additional_properties and one on timeouts β€” each proven to fire from a deliberately bad .tfvars file, 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_overridden reports false.

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_connection is true; the failure appears when a pipeline runs.

πŸ’¬ Example Output

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

πŸ” Troubleshooting

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

πŸ”— Related Docs


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