A Data Factory's stored definition of how to reach a Blob Storage account, targeting
hashicorp/azurerm ~> 4.0.
- ποΈ 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_uriandservice_endpointis required β none is as invalid as two. - π΄
connection_string_insecureis 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_endpointplususe_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.
If this module saved you time:
- β Star the repository β it is the cheapest signal that this work is worth continuing.
- πΌ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
RG["terraform-azurerm-resource-group"]
ADF["terraform-azurerm-data-factory"]
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
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.
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
| 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. |
| 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_insecureis stored unencrypted by Azure and is readable in the portal, whereconnection_stringshows 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_uriandservice_endpointare all marked. - π΄
service_principal_keycarries 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_identityconflicts withservice_principal_id, and the provider enforces it.β οΈ service_principal_idandservice_principal_keyare 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_tokenconflicts withsas_token_linked_key_vault_keyand is removed in the next major version. This module exposes only the surviving one.β οΈ storage_kindis 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.β οΈ Onlynameanddata_factory_idare force-new. Nolocation, notags.- βΉοΈ 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 atterraform plan, offline and without credentials. - βΉοΈ The requires-import error names this resource correctly.
| 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.
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.
- 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.
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
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_secretreportstruefor it.
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. |
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-planeContributoris 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_insecureunencrypted 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_formreports 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_uriis 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 thesas_token_linked_key_vault_keyproperty 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_idis 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_keycatches 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_runtimereports 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 notagsargument; 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.
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
}| 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 |
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 |
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.
| 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.
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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 = trueis not β andservice_endpointwith a managed identity removes the question entirely.
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_uriandservice_endpointmust be marked sensitive by the provider, andconnection_string_insecureandservice_principal_keymust not be; - π΄ the deliberate omission is asserted in both directions β the deprecated
key_vault_sas_tokenmust exist in the provider schema and be absent from this module's variables; carries_no_secrethas 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_keywithoutsas_uriis refused;- all four
storage_kindvalues 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
sensitiveand 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.
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"]
| 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. |
azurerm_data_factory_linked_service_azure_blob_storageazurerm_data_factory_linked_service_key_vaultβ the linked service the Key Vault blocks reference- Store credentials in Azure Key Vault β the pattern behind both Key Vault blocks
- Managed identity for Data Factory β the identity that needs the data-plane grant
- Copy and transform data in Azure Blob Storage β the connector this linked service configures
- Azure built-in roles for storage β
Storage Blob Data Readerand friends SCOPE.mdβ this module's cross-module contract
π "Infrastructure as Code should be standardized, consistent, and secure."