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 β€” Azure Blob Storage Terraform Module

A Data Factory's stored definition of how to reach a Blob Storage account, targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • πŸ—„οΈ Creates one azurerm_data_factory_linked_service_azure_blob_storage β€” the definition datasets and copy activities use to reach a storage account.
  • πŸ”’ Four mutually exclusive connection forms, and they are not equivalent. Exactly one of connection_string, connection_string_insecure, sas_uri and service_endpoint is required β€” none is as invalid as two.
  • πŸ”΄ connection_string_insecure is worse than its name suggests, in two compounding ways. Azure stores it unencrypted, so it is readable in the Azure portal; and it is the only one of the four the provider does not mark sensitive, so it prints in clear text in plan output too.
  • βœ… One combination carries no secret at all: service_endpoint plus use_managed_identity. The module reports whether you achieved it.
  • 🧾 It creates a definition, not a connection. Nothing here verifies the account, the credential, or the endpoint.

πŸ’‘ Why it matters: on this resource the choice of connection form is a security decision before it is a connectivity one, and three of the four put a credential into Terraform state.


❀️ 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"]
  ST["terraform-azurerm-storage-account, never verified by Terraform"]
  THIS["terraform-azurerm-data-factory-linked-service-azure-blob-storage"]
  KVLS["terraform-azurerm-data-factory-linked-service-key-vault, referenced BY NAME"]
  GRANT["terraform-azurerm-role-assignments, Storage Blob Data Reader for the factory identity"]
  IR["an integration runtime, needed for a private storage account"]
  DS["a dataset, then a copy activity"]

  RG -->|"name"| ADF
  ADF -->|"id"| THIS
  ST -->|"the blob service endpoint, the ONLY credential free form"| THIS
  KVLS -->|"name, into sas_token or service_principal key references"| THIS
  ADF -->|"identity_principal_id"| GRANT
  GRANT -->|"data plane access, without which managed identity fails at first use"| ST
  IR -->|"name, into integration_runtime_name"| THIS
  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,ST,KVLS,GRANT,IR,DS sibling
Loading

Two edges carry the point: the service endpoint comes straight from the storage account module and is the only credential-free way in; and the role assignment is what makes managed identity actually work β€” the linked service applies perfectly without it.


🧬 What this module builds

flowchart TB
  subgraph INPUTS["Inputs"]
    ID["name and data_factory_id, THE ONLY TWO FORCE-NEW FIELDS"]
    FORM["EXACTLY ONE of connection_string, connection_string_insecure, sas_uri, service_endpoint"]
    KVREF["sas_token_linked_key_vault_key, service_principal_linked_key_vault_key"]
    AUTH["use_managed_identity OR service_principal_id plus key, plus tenant_id"]
    META["storage_kind, integration_runtime_name, parameters, annotations, additional_properties"]
  end

  THIS["azurerm_data_factory_linked_service_azure_blob_storage.this"]

  subgraph OUTPUTS["Outputs"]
    PORTAL["connection_is_readable_in_the_azure_portal, true only for the insecure form"]
    UNMARKED["the insecure form is the ONLY one the provider leaves unmarked"]
    NOSEC["carries_no_secret, true only for service_endpoint plus managed identity"]
    MODE["connection_form and authentication_mode, both known at plan"]
    SPGAP["a_service_principal_id_was_supplied_with_no_key"]
    DEPR["the deprecated sas token block is deliberately not exposed"]
  end

  ID --> THIS
  FORM --> THIS
  KVREF --> THIS
  AUTH --> THIS
  META --> THIS

  FORM --> PORTAL
  FORM --> UNMARKED
  FORM --> NOSEC
  AUTH --> NOSEC
  FORM --> MODE
  AUTH --> MODE
  AUTH --> SPGAP
  KVREF --> SPGAP
  KVREF --> DEPR

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
  classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
  class THIS this
  class ID,FORM,KVREF,AUTH,META,PORTAL,UNMARKED,NOSEC,MODE,SPGAP,DEPR sibling
Loading
Resource Count Notes
azurerm_data_factory_linked_service_azure_blob_storage.this 1 The keystone. A metadata record.
sas_token_linked_key_vault_key dynamic, 0..1 Requires sas_uri. The surviving spelling.
service_principal_linked_key_vault_key dynamic, 0..1 Keeps the SP key out of state.
timeouts dynamic, 0..1 All four keys; none bounds anything slow.

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

  • πŸ”΄ connection_string_insecure is stored unencrypted by Azure and is readable in the portal, where connection_string shows as ******. Both travel over HTTPS; transport is not the difference.
  • πŸ”΄ It is also the only one of the four connection forms the provider does not mark sensitive, so it prints in clear text in plan and apply output. connection_string, sas_uri and service_endpoint are all marked.
  • πŸ”΄ service_principal_key carries no sensitive flag either.
  • πŸ”΄ EXACTLY ONE of the four connection forms is required β€” supplying none is as invalid as supplying two, and the rule is a schema rule, so it refuses a bad configuration at terraform plan, offline.
  • ⚠️ use_managed_identity conflicts with service_principal_id, and the provider enforces it.
  • ⚠️ service_principal_id and service_principal_key are NOT paired here β€” unlike the Azure SQL Database linked service, where the provider requires each with the other. Either may be supplied alone.
  • ⚠️ A deprecated second spelling of the SAS-token block exists on this provider line. key_vault_sas_token conflicts with sas_token_linked_key_vault_key and is removed in the next major version. This module exposes only the surviving one.
  • ⚠️ storage_kind is a closed, case-sensitive set of four β€” and it is a declaration, not a setting.
  • ⚠️ The name validator is nearly inert β€” anchored end to end, it refuses only a value composed entirely of the punctuation its message lists. Shared by the whole linked-service and dataset family.
  • ⚠️ Only name and data_factory_id are force-new. No location, no tags.
  • ℹ️ The Resource ID's segment is lower-case linkedservices.
  • ℹ️ No CustomizeDiff, so every provider check here is a schema rule and refuses a bad configuration at terraform plan, offline and without credentials.
  • ℹ️ The requires-import error names this resource correctly.

πŸ”‘ 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.

Planning this resource is not credential access β€” nothing is read back as a secret, and this module emits none.

⚠️ Separately, whatever identity is chosen needs a DATA-PLANE role on the storage account β€” Storage Blob Data Reader or Storage Blob Data Contributor. Control-plane Contributor is not sufficient to read blobs. Nothing in this module creates that grant, and the linked service applies perfectly without it.

⚠️ A connection string or SAS URI is not scoped by any Azure role. It grants whatever it was minted with, to whoever holds it. Whoever can read the state file holds it.


Azure Prerequisites

  • An existing Data Factory.
  • An existing storage account, reachable from whichever integration runtime is used.
  • For managed identity: a system-assigned identity on the factory, granted a data-plane blob role.
  • For the Key Vault forms: an existing Key Vault linked service in the same factory, plus the secret.
  • For an account behind a private endpoint or a network rule: a self-hosted or managed-virtual-network integration runtime, named in integration_runtime_name.

πŸ“ Module Structure

terraform-azurerm-data-factory-linked-service-azure-blob-storage/
β”œβ”€β”€ providers.tf    # required_version + the pinned azurerm; no provider block
β”œβ”€β”€ variables.tf    # 19 inputs, 27 validations, 5 of them marked sensitive
β”œβ”€β”€ main.tf         # the keystone, 3 dynamic blocks, and the derived facts
β”œβ”€β”€ outputs.tf      # 47 outputs; none is sensitive, because none carries a secret
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

module "blob_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-blob-storage.git?ref=v1.0.0"

  name            = "ls-blob-corp"
  data_factory_id = module.data_factory.id

  # The credential-free form: an endpoint, and an identity.
  service_endpoint     = module.storage.primary_blob_endpoint
  use_managed_identity = true
}

The caller configures the provider, its authentication, and the mandatory features {} block:

provider "azurerm" {
  features {}
}

βœ… That Quick Start is the one combination on this resource that puts no credential into the configuration or into Terraform state. module.blob_link.carries_no_secret reports true for it.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
data_factory_id string terraform-azurerm-data-factory (id)
service_endpoint string terraform-azurerm-storage-account (primary_blob_endpoint)
sas_token_linked_key_vault_key.linked_service_name string terraform-azurerm-data-factory-linked-service-key-vault (name)
integration_runtime_name string the integration runtime modules (name)

Emits

Output Description
name What datasets and activities reference.
connection_form Which of the four was used. Known at plan.
carries_no_secret Whether the credential-free combination was achieved.
connection_is_readable_in_the_azure_portal πŸ”΄ True only for the insecure form.
authentication_mode One of three, known at plan.
force_new_fields Only the name and the factory.

πŸ“š Example Library

1 Β· βœ… The credential-free call
module "blob" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-blob-storage.git?ref=v1.0.0"

  name            = "ls-blob-corp"
  data_factory_id = module.data_factory.id

  service_endpoint     = module.storage.primary_blob_endpoint
  use_managed_identity = true
}

βœ… This is the target configuration. An endpoint carries no credential, and the managed identity removes the need for one β€” nothing to store, nothing to rotate, nothing to leak. ⚠️ The factory's identity still needs a data-plane blob role on the account. Control-plane Contributor is not enough, and the linked service applies perfectly without it.

2 Β· πŸ”΄ The form to avoid, and exactly why
# Both of these hold the same string. They are not equally exposed.
connection_string          = var.blob_connection_string # Azure stores it ENCRYPTED; portal shows ******
connection_string_insecure = var.blob_connection_string # Azure stores it PLAIN;    portal shows the value

πŸ”΄ The name understates it, twice. Azure stores connection_string_insecure unencrypted within the payload, so anyone who can view the Data Factory in the portal can read your storage account key. And it is the only one of the four connection forms the provider does not mark sensitive, so it also prints in clear text in plan and apply output and in CI logs. ℹ️ Both are sent over HTTPS. Transport is not the difference; visibility is. πŸ’‘ This module marks it sensitive anyway, which fixes the plan-output half and does nothing about the portal half β€” that is a property of what Azure stores, and no Terraform setting changes it.

3 Β· The four-way rule
# Refused at `terraform plan`, offline and without credentials:
#   (none of the four supplied)                -> "EXACTLY ONE of connection_string, ..."
#   connection_string + service_endpoint       -> "EXACTLY ONE of connection_string, ..."
#   sas_uri + service_endpoint                 -> "EXACTLY ONE of connection_string, ..."

service_endpoint = module.storage.primary_blob_endpoint # exactly one

πŸ’‘ Supplying none is as invalid as supplying two. module.blob.connection_form reports which of the four a configuration used, and it is known at plan time.

4 Β· A SAS URI, with the token kept in Key Vault
sas_uri = "https://stcorp.blob.core.windows.net/"

sas_token_linked_key_vault_key = {
  linked_service_name = "ls-keyvault" # the Key Vault linked service, BY NAME -- not a Resource ID
  secret_name         = "blob-sas-token"
}

πŸ’‘ The URI still has to be supplied β€” it says which account and container the token applies to β€” but the token itself stays in the vault. Supplying the block without sas_uri is refused here. ⚠️ A SAS URI is a bearer credential with the secret in its query string: it cannot be revoked without rotating the storage key or the stored access policy behind it.

5 · ⚠️ The deprecated block this module does not offer
# The provider still accepts this on the 4.x line:
#   key_vault_sas_token { ... }
# It conflicts with sas_token_linked_key_vault_key and is REMOVED in the next major version.
# This module exposes only the surviving spelling:

sas_token_linked_key_vault_key = {
  linked_service_name = "ls-keyvault"
  secret_name         = "blob-sas-token"
}

ℹ️ The provider's own deprecation message says key_vault_sas_token "has been deprecated in favour of the sas_token_linked_key_vault_key property and will be removed in v5.0". Omitting it is deliberate: configuration written against this module does not have to be rewritten later.

6 Β· A service principal
service_endpoint     = module.storage.primary_blob_endpoint
service_principal_id = "11111111-2222-3333-4444-555555555555" # the APPLICATION (client) ID
tenant_id            = "contoso.onmicrosoft.com"

service_principal_linked_key_vault_key = {
  linked_service_name = "ls-keyvault"
  secret_name         = "blob-sp-key"
}

πŸ’‘ The Key Vault form keeps the secret out of this configuration and out of Terraform state β€” better than service_principal_key, which the provider does not even mark sensitive. ⚠️ service_principal_id is the application (client) ID, not the object ID. Both are UUIDs.

7 · ⚠️ The pairing this resource does not enforce
service_endpoint     = module.storage.primary_blob_endpoint
service_principal_id = "11111111-2222-3333-4444-555555555555"
# ...and no key of any kind. ACCEPTED.

⚠️ On the Azure SQL Database linked service the provider requires the ID and the key with each other, in both directions. Here it does not β€” either may be supplied alone and nothing complains. The configuration applies cleanly and fails to authenticate at first use. πŸ’‘ Reported, not refused: the key may legitimately arrive by another route. module.blob.a_service_principal_id_was_supplied_with_no_key catches it at plan.

8 Β· The identity conflict the provider does enforce
# Refused at `terraform plan`, offline and without credentials:
#   use_managed_identity = true
#   service_principal_id = "1111...."
#     -> "service_principal_id CONFLICTS WITH use_managed_identity"

use_managed_identity = true # choose one

πŸ’‘ Managed identity is the stronger of the two: no key to store, none to rotate, and nothing that expires.

9 Β· `storage_kind` is a declaration, not a setting
storage_kind = "StorageV2" # Storage | StorageV2 | BlobStorage | BlockBlobStorage, CASE-SENSITIVE

⚠️ This tells Data Factory what kind of account it is talking to. It does not change the account, and a value that disagrees with reality is accepted here, by the provider, and at apply β€” failing only when the service tries to use a capability the real account does not have.

10 Β· Reaching a private storage account
service_endpoint         = module.storage.primary_blob_endpoint
use_managed_identity     = true
integration_runtime_name = "ir-vnet" # a NAME, from a managed-vnet or self-hosted runtime module

⚠️ Without this, the factory's default AutoResolve runtime is used, and it reaches only what is reachable from the public Azure network β€” which an account behind a private endpoint or a network rule is not. πŸ’‘ module.blob.runs_on_the_factory_default_integration_runtime reports whether one was named.

11 Β· The name rule the provider almost does not have
name = "ls-blob-corp"  # accepted, despite '-' being in the "not allowed" list
name = "ls.blob/corp"  # also accepted, despite '.' and '/'
# name = "---"         # REFUSED -- the one shape the provider's validator actually catches
# name = ""            # refused BY THIS MODULE; the provider accepts it

⚠️ The provider's message says - . + ? / < > * % & : \ "are not allowed", and its expression refuses a value only when the value is composed of nothing but those characters. The same validator is shared across the whole linked-service and dataset family.

12 Β· Annotations, which are not tags
annotations = ["owner:data-platform", "classification:internal"]

⚠️ Not Azure resource tags. Nothing outside Data Factory reads them. This resource supports no tags argument; tag the factory or the storage account. ⚠️ Modelled as an ordered list, so reordering is a real diff.

13 Β· Asserting what a plan will not tell you
check "no_credential_in_state" {
  assert {
    condition     = module.blob.carries_no_secret
    error_message = "This linked service puts a credential into Terraform state; service_endpoint + managed identity would not."
  }
}

check "not_the_portal_readable_form" {
  assert {
    condition     = !module.blob.connection_is_readable_in_the_azure_portal
    error_message = "connection_string_insecure is stored unencrypted and is readable by anyone who can view the factory."
  }
}

check "the_principal_has_a_key" {
  assert {
    condition     = !module.blob.a_service_principal_id_was_supplied_with_no_key
    error_message = "A service principal ID was supplied with no key by any route; the provider does not enforce the pairing on this resource."
  }
}

πŸ’‘ All three are derived from configuration, so each fails at plan time β€” before anything exists.

14 Β· πŸ—οΈ End-to-end composition
module "resource_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-data-eastus"
  location = "eastus"
}

module "storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stcorpdataeastus"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
}

module "data_factory" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory.git?ref=v1.0.0"

  name                   = "adf-corp-eastus"
  resource_group_name    = module.resource_group.name
  location               = module.resource_group.location
  public_network_enabled = false
}

# The blob linked service, carrying no credential at all.
module "blob" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-azure-blob-storage.git?ref=v1.0.0"

  name            = "ls-blob-corp"
  data_factory_id = module.data_factory.id
  description     = "Corporate data lake landing zone."

  service_endpoint     = module.storage.primary_blob_endpoint
  use_managed_identity = true
  storage_kind         = "StorageV2"

  annotations = ["owner:data-platform", "classification:internal"]
}

# Without this, the managed identity above authenticates as nobody.
module "blob_data_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.storage.id

  role_assignments = {
    factory_reads_blobs = {
      principal_id         = module.data_factory.identity_principal_id
      role_definition_name = "Storage Blob Data Reader"
      principal_type       = "ServicePrincipal"
    }
  }
}

check "the_composition_carries_no_secret" {
  assert {
    condition     = module.blob.carries_no_secret
    error_message = "This composition was written to hold no credential; something reintroduced one."
  }
}

output "blob_linked_service_name" {
  value = module.blob.name
}

πŸ—οΈ Five modules and not one secret between them. The endpoint comes from the storage account, the identity comes from the factory, and the role assignment is what joins them β€” a data-plane role, not Contributor. ⚠️ Nothing here verifies that the account is reachable or that the grant has propagated. Both surface at the first copy activity, not at apply.


πŸ“₯ Inputs

Identity (both force-new): name, data_factory_id. Connection (exactly one): connection_string, connection_string_insecure, sas_uri, service_endpoint β€” all four marked sensitive by this module. Credential: sas_token_linked_key_vault_key, service_principal_linked_key_vault_key, use_managed_identity, service_principal_id, service_principal_key (sensitive), tenant_id. The account: storage_kind. Metadata: description, integration_runtime_name, parameters, annotations, additional_properties, timeouts.

There is no tags input β€” the resource exposes none, and annotations are not tags.

Full input schemas
variable "name" { type = string }            # required, force-new
variable "data_factory_id" { type = string } # required, force-new

# EXACTLY ONE of the four below. All are sensitive in this module; the provider marks only three.
variable "connection_string" { type = string, default = null, sensitive = true }
variable "connection_string_insecure" { type = string, default = null, sensitive = true } # portal-readable
variable "sas_uri" { type = string, default = null, sensitive = true }
variable "service_endpoint" { type = string, default = null, sensitive = true }           # carries no credential

variable "sas_token_linked_key_vault_key" {   # REQUIRES sas_uri
  type = object({
    linked_service_name = string # a NAME, not a Resource ID
    secret_name         = string
  })
  default = null
}

variable "service_principal_linked_key_vault_key" {
  type = object({
    linked_service_name = string
    secret_name         = string
  })
  default = null
}

variable "use_managed_identity" { type = bool, default = false } # conflicts with service_principal_id
variable "service_principal_id" { type = string, default = null } # a UUID; the APPLICATION id
variable "service_principal_key" { type = string, default = null, sensitive = true }
variable "tenant_id" { type = string, default = null }            # a GUID or a tenant NAME
variable "storage_kind" { type = string, default = null }         # Storage|StorageV2|BlobStorage|BlockBlobStorage

variable "description" { type = string, default = null }
variable "integration_runtime_name" { type = string, default = null } # a NAME
variable "parameters" { type = map(string), default = {} }
variable "annotations" { type = list(string), default = [] }          # ORDERED; not tags
variable "additional_properties" { type = map(string), default = {} } # validated by nothing

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Notes
id The linked service's Resource ID Emitted first; lower-case linkedservices segment
name What datasets and activities reference Force-new
data_factory_id, data_factory_name The parent factory Parsed from the end of the ID
resource_group_name, subscription_id Where the factory lives
description Metadata
connection_form Which of the four was used Known at plan
connection_is_readable_in_the_azure_portal πŸ”΄ True only for the insecure form Derived
the_insecure_connection_string_is_the_only_form_the_provider_leaves_unmarked It compounds Constant
uses_service_endpoint The form to prefer Derived
uses_sas_uri A bearer credential Derived
sas_token_is_in_key_vault The token stays in the vault Derived
the_deprecated_sas_token_block_is_deliberately_not_exposed Why one spelling is missing Constant
authentication_mode One of three Known at plan
uses_managed_identity The strongest option Derived
uses_service_principal Its secret expires Derived
service_principal_key_is_in_key_vault Better than the literal Derived
a_service_principal_id_was_supplied_with_no_key ⚠️ Not paired on this resource Derived
carries_no_secret βœ… The one credential-free combination Derived
secrets_were_supplied_in_plaintext The headline security fact Derived
the_provider_does_not_mark_the_service_principal_key_sensitive It prints in clear text Constant
no_secret_is_emitted_by_this_module Booleans and modes only Constant
service_principal_id, tenant_id As configured
storage_kind The declared account kind
storage_kind_is_a_declaration_not_a_setting It changes nothing Constant
name_contains_characters_the_provider_message_disallows Reported, not refused Derived
the_provider_name_validator_is_nearly_inert Shared by the family Constant
integration_runtime_name Where it runs
runs_on_the_factory_default_integration_runtime Public network only Derived
this_linked_service_is_referenced_by_name_not_by_id Renaming breaks references Constant
the_module_cannot_see_what_references_this_linked_service Read before deleting Constant
parameters, annotations Metadata
annotations_are_ordered_and_reordering_them_is_a_diff Ordered list Constant
additional_properties_are_not_validated_by_anything A misspelling is ignored Constant
this_resource_supports_no_azure_resource_tags Tag the factory or the account Constant
force_new_fields, fields_that_can_change_after_creation Only two are force-new
this_module_creates_a_connection_definition_not_a_connection Read this first Constant
no_customize_diff_guards_this_resource Every check is reachable offline Constant
import_address, the_import_guard_names_this_resource_correctly Imports
destroying_the_factory_destroys_this_linked_service_too The account is unaffected Constant
lifecycle_prevent_destroy_is_not_available_to_a_module_caller Use a management lock Constant
the_timeouts_bound_terraform_not_the_storage_account Nothing here is slow Constant

🧠 Architecture Notes

The choice of connection form is a security decision. Four are offered, exactly one is required, and three of them put a credential into Terraform state. Only service_endpoint does not: it names the account and carries nothing else, so paired with an identity it is the one combination on this resource that holds no secret anywhere. carries_no_secret is true for exactly that combination and false for every other, including the near-misses β€” an endpoint without an identity, or an identity alongside a plaintext service principal key.

connection_string_insecure is worse than its name, and the two reasons compound. Azure stores it unencrypted within the payload, so it is readable in the Azure portal by anyone who can view the Data Factory β€” where the ordinary connection_string displays as ****** because Azure stores that one as a secure string. Both are sent over HTTPS, so transport is not the difference; visibility is. On top of that, it is the only one of the four the provider does not mark sensitive, so a configuration using the resource directly prints it in clear text in plan output, apply output and CI logs. This module marks its own input sensitive, which fixes the second problem and does nothing about the first β€” the portal visibility is a property of what Azure stores, and no Terraform setting reaches it.

The provider's pairing rules differ from its siblings', and the difference matters. Here use_managed_identity conflicts with service_principal_id, and that is enforced. But service_principal_id and service_principal_key are not required with each other β€” unlike the Azure SQL Database linked service, where the provider pairs them in both directions. So a principal ID with no key by any route is accepted, applies cleanly, and fails at first use. The module reports that case rather than refusing it, because the key may legitimately arrive through service_principal_linked_key_vault_key.

One block has two spellings and only one survives. The provider carries key_vault_sas_token alongside sas_token_linked_key_vault_key; they conflict with each other, the former is deprecated, and its own message says it is removed in the next major version. This module exposes only the survivor, so configuration written against it does not need rewriting later. That is a deliberate reduction of the provider's surface, and it is the kind that costs a caller nothing.

Nothing here is verified. Creating the linked service does not connect to the account, does not parse the connection string beyond non-emptiness, does not check that the SAS token is valid, and does not check that the identity can read a blob. A completely wrong linked service applies cleanly and fails when a dataset or copy activity first uses it. The most common instance is a managed identity with no data-plane role β€” control-plane Contributor on the storage account does not grant blob access, and nothing in the plan says so.


🧱 Design Principles

Concern This module's default (empty call) Opt-out (the caller must type it)
Connection form None β€” one of four must be chosen deliberately β€”
Secret handling All five secret-bearing inputs marked sensitive; the provider marks three None
Secret avoidance Endpoint + identity available and reported when not used Supply a connection string or SAS URI
Portal exposure Reported, never silently accepted Use connection_string_insecure knowingly
Deprecated surface Not exposed at all β€”
Identity None β€” credentials come from the connection form unless one is chosen Set an identity argument
Name rules The provider's expression, mirrored exactly None
Unenforced provider claims Reported, never enforced None

Two library rules shape this table. Secure-by-default cannot fully apply, because one of the four connection forms must be supplied and the safest cannot be a default β€” so the compensations are a Quick Start that uses the credential-free form, an output that says whether you achieved it, and an explicit statement of what sensitive does not do. And no validation {} refuses a value the provider accepts: the insecure form, a dangling service principal ID, and a mismatched storage_kind are all reported rather than refused.


πŸš€ Runbook

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

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

πŸ”’ Before any apply that carries a connection string or SAS URI, confirm where state is stored. An encrypted, access-controlled remote backend is the control that protects those values; sensitive = true is not β€” and service_endpoint with a managed identity removes the question entirely.


πŸ§ͺ Testing

terraform validate covers every check the provider applies here β€” there is no CustomizeDiff.

Offline, without credentials, the module's proof harness confirms:

  • the four-way connection rule refuses none of the four and every pair and triple tried, and accepts each of the four alone;
  • πŸ”΄ the sensitivity asymmetry is read from the binary schema, not asserted β€” connection_string, sas_uri and service_endpoint must be marked sensitive by the provider, and connection_string_insecure and service_principal_key must not be;
  • πŸ”΄ the deliberate omission is asserted in both directions β€” the deprecated key_vault_sas_token must exist in the provider schema and be absent from this module's variables;
  • carries_no_secret has one fixture per branch, including all three near-misses;
  • the service-principal pairing gap is exercised in each of its four states;
  • sas_token_linked_key_vault_key without sas_uri is refused;
  • all four storage_kind values are accepted and a lowercase one is refused;
  • every name the provider's own message disowns is accepted, and only the all-punctuation shape refused;
  • exactly five inputs are marked sensitive and no output is, checked per block with an anchored expression.

Only terraform plan and apply exercise the ARM call. Nothing at any stage verifies the account, the credential, the endpoint, or the data-plane grant.


πŸ’¬ Example Output

id                                        = "/subscriptions/00000000-.../factories/adf-corp/linkedservices/ls-blob-corp"
name                                      = "ls-blob-corp"
data_factory_name                         = "adf-corp"
connection_form                           = "service_endpoint"
authentication_mode                       = "managed_identity"
carries_no_secret                         = true
secrets_were_supplied_in_plaintext        = false
connection_is_readable_in_the_azure_portal = false
uses_service_endpoint                     = true
a_service_principal_id_was_supplied_with_no_key = false
storage_kind                              = "StorageV2"
runs_on_the_factory_default_integration_runtime = true
force_new_fields                          = ["name", "data_factory_id"]

πŸ” Troubleshooting

Symptom Cause Fix
The storage account key is visible to anyone who opens the factory in the portal connection_string_insecure was used; Azure stores it unencrypted Switch to connection_string, or better to service_endpoint with an identity. Rotate the key β€” it has been readable.
A connection string appeared in clear text in a CI log connection_string_insecure is the one form the provider does not mark sensitive Same fix. This module marks it, but a configuration using the resource directly does not.
EXACTLY ONE of connection_string, connection_string_insecure, sas_uri and service_endpoint is required None of the four was supplied, or more than one was Supply exactly one.
sas_token_linked_key_vault_key REQUIRES sas_uri The Key Vault block was supplied on its own Add sas_uri. The URI says which account and container the token applies to.
service_principal_id CONFLICTS WITH use_managed_identity Two different identities were configured Choose one. Managed identity has no key to store or rotate.
service_principal_id must be a UUID. A display name or app registration name was passed Use the application (client) ID β€” not the object ID; both are UUIDs.
The linked service applied cleanly and the first copy activity got 403 The identity has no data-plane role on the account Grant Storage Blob Data Reader or Storage Blob Data Contributor. Control-plane Contributor does not grant blob access.
Authentication fails and a service principal was configured An ID was supplied with no key by any route; this resource does not pair them Supply service_principal_key or service_principal_linked_key_vault_key. Assert on a_service_principal_id_was_supplied_with_no_key.
storage_kind must be Storage, StorageV2, BlobStorage or BlockBlobStorage. A wrong value, or the right value in the wrong case The comparison is case-sensitive: StorageV2, not storagev2.
An activity fails on a capability the account should have storage_kind disagrees with the real account; it is a declaration and changes nothing Correct it to match the account.
An argument named "key_vault_sas_token" is not expected here. That deprecated block is deliberately not exposed by this module. Terraform reports it at init, when the module is installed, rather than at validate Use sas_token_linked_key_vault_key. The deprecated spelling is removed in the next provider major version anyway.
A dataset broke after a rename name is force-new, and datasets reference a linked service by name Recreate the reference. Nothing in Terraform tracks it.

πŸ”— Related Docs


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