Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Data Factory Linked Service — PostgreSQL Terraform Module

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

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 🔌 Creates one azurerm_data_factory_linked_service_postgresql — the definition a dataset or copy activity uses to reach a PostgreSQL server.
  • 🔴 The provider marks nothing on this resource as sensitive. Sensitive: does not appear once in the entire resource file, while connection_string is required and routinely carries the credential inline. This module marks it sensitive itself.
  • 🔴 The read never refreshes the connection string, so Terraform cannot see drift in it. A portal edit produces no diff, ever.
  • ✅ Which is also why rotation works here — unlike the ODBC and OData siblings, whose block password is frozen behind a sentinel, a connection-string change is a genuine in-place update.
  • 🔀 The MySQL sibling is near-identical and carries a trap this one does not.

💡 Why it matters: every check this resource performs is offline and cheap, and every fact that actually bites is invisible in the plan. This module moves those facts into outputs.


❤️ 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"]
  MY["terraform-azurerm-data-factory-linked-service-mysql"]
  PG["terraform-azurerm-data-factory-linked-service-postgresql"]
  SHIR["terraform-azurerm-data-factory-integration-runtime-self-hosted"]
  SRC["a MySQL or PostgreSQL server, never contacted by Terraform"]
  DS["a dataset, then a copy activity"]

  RG -->|"name"| ADF
  ADF -->|"id"| MY
  ADF -->|"id"| PG
  SHIR -->|"name, required to reach a private or on-premises server"| MY
  SHIR -->|"name, required to reach a private or on-premises server"| PG
  MY -->|"connects at pipeline runtime, never at apply"| SRC
  PG -->|"connects at pipeline runtime, never at apply"| SRC
  MY -->|"name, referenced BY NAME and never by id"| DS
  PG -->|"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 MY,PG this
  class ADF keystone
  class RG,SHIR,SRC,DS sibling
Loading

Both this module and its MySQL sibling hang off the same factory and share the same runtime requirement. They differ in exactly one thing, described in Provider / Versions below.


🧬 What this module builds

flowchart TB
  subgraph INPUTS["Inputs"]
    ID["name and data_factory_id, THE ONLY TWO FORCE-NEW FIELDS"]
    CS["connection_string, REQUIRED, and the provider marks NOTHING sensitive"]
    META["integration_runtime_name, parameters, annotations, additional_properties"]
  end

  THIS["azurerm_data_factory_linked_service_postgresql.this"]

  subgraph OUTPUTS["Outputs"]
    BRANCH["which suppressor branch the connection string takes"]
    DRIFT["read never refreshes the connection string, so drift is invisible"]
    IMP["importing brings no connection string with it"]
    ROT["rotating the connection string IS a real update"]
    PRINT["the provider does not mark the connection string sensitive"]
    NOCD["no CustomizeDiff, unlike the MySQL sibling"]
  end

  ID --> THIS
  CS --> THIS
  META --> THIS

  CS --> BRANCH
  CS --> DRIFT
  CS --> IMP
  CS --> ROT
  CS --> PRINT
  META --> NOCD

  classDef this fill:#0078D4,stroke:#004578,color:#ffffff,stroke-width:2px
  classDef sibling fill:#eef3f8,stroke:#b9c8d8,color:#1b2733
  class THIS this
  class ID,CS,META,BRANCH,DRIFT,IMP,ROT,PRINT,NOCD sibling
Loading
Resource Count Notes
azurerm_data_factory_linked_service_postgresql.this 1 The keystone. A metadata record.
timeouts dynamic, 0..1 All four keys; none bounds a query.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None. The caller configures the provider, including the mandatory features {} block.

Schema notes that bite — verified against the live provider source:

  • 🔴 Sensitive: does not appear once in this resource, and connection_string is required. There is no Key Vault block here, unlike the SQL Server and Azure SQL Database linked services.
  • 🔴 The read function never sets connection_string. State keeps whatever was last applied, so drift in it is invisible and an imported resource carries no connection string at all.
  • ✅ A connection-string change is a real in-place update. The credential is rotatable.
  • 🔀 The shared diff suppressor sorts and lowercases segments first, so reordering a connection string or changing only its case is not a diff.
  • 🔀 Its password strip is a RAW PREFIX MATCH on an UNTRIMMED segment, and that is sharper than it reads. It removes a segment whose lowercased form begins with the literal password. So ;Password=x is stripped, ; Password=x — the same credential with the space almost everyone types — is NOT, ;PasswordExpiry=30 is stripped despite not being a credential, and ;Pwd=x never is.
  • ⚠️ The provider checks only that connection_string is non-empty. It does not parse it and does not contact anything.
  • ℹ️ No CustomizeDiff and no cross-field rules. Every check the provider performs is a schema validator that refuses a bad configuration at terraform plan, offline, without credentials.
  • 🔀 azurerm_data_factory_linked_service_mysql adds a driver_version argument whose default on this provider line collides with a check refusing that same value on create. This resource has neither.
  • ⚠️ The name validator is weaker than its own error message. Its pattern is anchored at both ends, so it rejects only a name composed entirely of the twelve characters the message forbids. It applies no length rule, no leading-character rule, and no emptiness check at all. Azure's published rules are stricter — and Azure's two pages disagree with each other about the leading character and the dash — so this module reports both conditions rather than refusing either.
  • ⚠️ Only name and data_factory_id are force-new. There is no location and no tags.

🔑 Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.DataFactory/factories/linkedservices/write the Data Factory Create and update.
Microsoft.DataFactory/factories/linkedservices/read the Data Factory Refresh and plan.
Microsoft.DataFactory/factories/linkedservices/delete the Data Factory Destroy — which breaks every dataset referencing it.
Data Factory Contributor the Data Factory The built-in role containing the above.

⚠️ The PostgreSQL credential is scoped by no Azure role. It authenticates to a database Azure RBAC does not govern, and its blast radius is whatever that database role can do.


Azure Prerequisites

  • An existing Data Factory.
  • A reachable PostgreSQL server. For a private or on-premises server, a self-hosted integration runtime — the factory's default runtime is Microsoft-hosted and has no route to one.
  • A PostgreSQL role whose privileges match what the pipelines actually need.

📁 Module Structure

terraform-azurerm-data-factory-linked-service-postgresql/
├── providers.tf     # required_version + the pinned azurerm; no provider block
├── variables.tf     # 9 variables, 11 validations
├── main.tf          # the keystone, its timeouts block, and the derived locals
├── outputs.tf       # 46 outputs: the id first, then the facts no plan shows
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = var.postgres_connection_string
}

🔒 The caller configures the provider, its authentication, and the mandatory features {} block. The connection string should come from a secret store, never a literal in a repository.


🔌 Cross-Module Contract

Consumes

Input Type Source
data_factory_id string terraform-azurerm-data-factory (id)
connection_string string (sensitive) a secret store, at the call site
integration_runtime_name string terraform-azurerm-data-factory-integration-runtime-self-hosted (name)

Emits

Output Consumed by
id imports, RBAC scoping
name datasets and copy activities
connection_string_takes_the_suppressor_password_branch assertions
read_never_refreshes_the_connection_string operations

📚 Example Library

1 · The smallest real call
module "postgres_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = var.postgres_connection_string
}

ℹ️ Three arguments are all this resource requires. Everything else is metadata.

2 · Reaching a private server through a self-hosted runtime
module "postgres_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"

  name                     = "ls-postgres-onprem"
  data_factory_id          = module.data_factory.id
  connection_string        = var.postgres_connection_string
  integration_runtime_name = module.self_hosted_runtime.name
}

⚠️ Omit integration_runtime_name and the factory's default AutoResolve runtime is used. It is Microsoft-hosted and has no route to a private or on-premises server. Nothing fails at apply — the pipeline fails later.

3 · Reading the credential from a secret store
data "azurerm_key_vault_secret" "postgres" {
  name         = "postgres-warehouse-connection-string"
  key_vault_id = var.key_vault_id
}

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

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = data.azurerm_key_vault_secret.postgres.value
}

🔒 This resource has no Key Vault block, so the value must pass through the configuration. Reading it from a vault at plan time keeps it out of the repository — it does not keep it out of state.

4 · Checking where the credential ended up
output "credential_placement" {
  value = {
    detected = module.postgres_link.connection_string_appears_to_contain_a_password
    branch   = module.postgres_link.connection_string_takes_the_suppressor_password_branch
  }
}

💡 Both are known at plan. The detector is anchored to a segment boundary, so a host named my-password-host does not register as a credential.

5 · The synonym that changes the comparison
# Both of these are functionally identical to PostgreSQL.
# They are NOT identical to the provider's diff suppressor.
locals {
  spelled_password = "Server=pg01;Database=sales;UID=svc;Password=..."
  spelled_pwd      = "Server=pg01;Database=sales;UID=svc;Pwd=..."
}

🔀 The suppressor strips segments beginning with the literal password before comparing, and leaves pwd in place — and, because the match is on an untrimmed segment, ; Password= is left in place too. the_password_strip_matches_password_and_not_pwd and the_password_strip_is_defeated_by_a_space_after_the_semicolon record both halves; the branch a given string lands in is reported by connection_string_takes_the_suppressor_password_branch.

6 · Asserting that drift cannot be detected
output "connection_string_drift_is_invisible" {
  value = module.postgres_link.read_never_refreshes_the_connection_string
}

⚠️ Constant true. The read never sets the field, so a portal edit to this linked service produces no diff. If you need to know what is actually stored, read it from the Data Factory — not from Terraform.

7 · Rotating the credential
module "postgres_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = data.azurerm_key_vault_secret.postgres.value # rotated in the vault
}

✅ This works, and it is worth stating because the sibling ODBC and OData modules report the opposite for their block password. Here a changed connection string is a real in-place update.

8 · Description, parameters and annotations
module "postgres_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = var.postgres_connection_string

  description = "Warehouse reporting replica"
  parameters  = { env = "prod" }
  annotations = ["owner:data-platform", "tier:gold"]
}

⚠️ annotations are not Azure resource tags — this resource supports none. They are an ordered list, so reordering them is a real diff.

9 · Why an empty description is rejected
# description = ""   # rejected: the provider validates it as non-empty

ℹ️ Omit the argument to mean "no description". Passing "" fails at terraform validate, offline.

10 · Several linked services from one map
module "postgres_links" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"
  for_each = var.postgres_sources

  name                     = each.key
  data_factory_id          = module.data_factory.id
  connection_string        = each.value.connection_string
  integration_runtime_name = each.value.runtime_name
}

💡 Keys are the linked-service names, which is what datasets reference. Renaming a key destroys and recreates the record, and every dataset pointing at the old name breaks at that moment.

11 · Timeouts
module "postgres_link" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-linked-service-postgresql.git?ref=v1.0.0"

  name              = "ls-postgres-warehouse"
  data_factory_id   = module.data_factory.id
  connection_string = var.postgres_connection_string

  timeouts = {
    create = "45m"
    read   = "10m"
    update = "45m"
    delete = "45m"
  }
}

⚠️ These bound Terraform's wait on the Data Factory control-plane call. They do not bound a query against PostgreSQL. A fifth key would be silently discarded by object-type conversion, with no error at all.

12 · What this module refuses, and what it deliberately does not
# Refused at `terraform validate` on a calling configuration, offline and without credentials:
#   name              = "-.-"            entirely punctuation; mirrors the provider's own rule exactly
#   data_factory_id   = "<a dataset id>" anchored to the factory resource type
#   connection_string = "https://..."    that is the OData or Web resource, not this one
#
# NOT refused, and reported instead:
#   a name containing characters the provider's own message disallows
#   name              = "1warehouse"     Azure's two naming pages disagree about a leading digit

💡 The provider accepts those names, so refusing them would reject legal input — and a failed validation {} blocks terraform destroy as well as apply.

13 · Comparing against the MySQL sibling
output "sibling_difference" {
  value = module.postgres_link.the_mysql_sibling_carries_a_driver_version_trap_that_this_one_does_not
}

🔀 Constant true. The two resources have near-identical schemas. MySQL adds a driver_version whose default on this provider line collides with a check refusing that value on create; this one has neither. Do not carry an expectation from one module to the other.

14 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

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

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

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

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

module "self_hosted_runtime" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-factory-integration-runtime-self-hosted.git?ref=v1.0.0"

  name            = "ir-onprem-eastus"
  data_factory_id = module.data_factory.id
}

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

  name                     = "ls-postgres-warehouse"
  data_factory_id          = module.data_factory.id
  connection_string        = var.postgres_connection_string
  integration_runtime_name = module.self_hosted_runtime.name

  description = "Warehouse reporting replica"
  annotations = ["owner:data-platform"]
}

output "review" {
  value = {
    linked_service   = module.postgres_link.name
    credential_found = module.postgres_link.connection_string_appears_to_contain_a_password
    drift_invisible  = module.postgres_link.read_never_refreshes_the_connection_string
    rotatable        = module.postgres_link.rotating_the_connection_string_is_a_real_update
    private_route    = !module.postgres_link.runs_on_the_factory_default_integration_runtime
  }
}

🏗️ The runtime is wired by name, which is how this resource references it — there is no dependency edge from the linked service to the runtime, so nothing proves the runtime exists.


📥 Inputs

Required: name, data_factory_id, connection_string. Optional: description, integration_runtime_name, parameters, annotations, additional_properties, timeouts.

Full schemas
variable "connection_string" {
  type      = string
  sensitive = true # marked by THIS MODULE; the provider marks nothing here
}

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

🧾 Outputs

Output Notes
id The Resource ID. Emitted first.
name What datasets reference.
data_factory_id, data_factory_name, resource_group_name, subscription_id Parent context.
description, parameters, annotations, integration_runtime_name Metadata as stored.
connection_string_appears_to_contain_a_password Anchored detection, known at plan.
connection_string_takes_the_suppressor_password_branch One of four branches.
the_password_strip_is_defeated_by_a_space_after_the_semicolon The trap in that branch.
name_does_not_start_with_a_letter Reported, not refused. Azure's two naming pages disagree.
read_never_refreshes_the_connection_string Constant. Drift is invisible.
rotating_the_connection_string_is_a_real_update Constant.
the_provider_does_not_mark_the_connection_string_sensitive Constant.
force_new_fields, fields_that_can_change_after_creation Lifecycle.
no_customize_diff_guards_this_resource Constant.
the_mysql_sibling_carries_a_driver_version_trap_that_this_one_does_not Constant.

No output emits a secret. The full list, with the reasoning behind each constant, is in outputs.tf.


🧠 Architecture Notes

The resource is a metadata record, and almost everything that matters about it is a consequence of that.

Renaming is destructive in a way the plan does not show. name and data_factory_id are the only force-new fields. Datasets and copy activities select a linked service by name, and nothing points back from here to them, so Terraform will happily replace this record without any indication of what stops resolving.

The connection string is the whole security surface, and the provider does not treat it as one. There is not one Sensitive: flag in the resource. This module marks the input sensitive, which redacts plan output for callers coming through the module. It does not encrypt state, and it changes nothing for anyone using the resource directly. The control that matters is an encrypted, access-controlled backend.

Drift in that string is invisible. The read function never sets it. Whatever was last applied is what state believes, indefinitely. This is also why an import brings no connection string with it, and why the first plan after an import proposes writing the configuration's value without ever having read the stored one.

The diff suppressor is stranger than it looks. It lowercases both values, splits on ;, sorts, and compares — so order and case are irrelevant. Before comparing it removes, from the new value, a segment whose lowercased form begins with the literal password. It does not trim the segment first and does not require an =, which makes the match sharper than it reads: ;Password=x is stripped, ; Password=x is not, ;PasswordExpiry=30 is, and ;Pwd=x never is. Two functionally identical connection strings therefore compare differently on the synonym typed — or on a single space.

Sensitivity is contagious, including to scalars. Every derived flag in main.tf is unwrapped individually with nonsensitive(); wrapping a constructed collection would not be enough, because element-level marks persist.


🧱 Design Principles

Concern This module's default Opt-out
Connection string in plan output sensitive = true, applied by this module none — and it does not affect state
Emitting the credential never emitted; presence only none
Route to a private server reported through runs_on_the_factory_default_integration_runtime name a self-hosted runtime
A name the provider accepts reported, never refused none needed
Values the provider genuinely rejects refused offline at terraform plan none

The secure-by-default rule cannot fully apply here: connection_string is required, so there is no empty call to make safe. The module compensates by marking it, detecting a credential inside it, and saying plainly what the marking does and does not do.


🚀 Runbook

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

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


🧪 Testing

terraform validate exercises every check this module and this resource perform — the module's eleven validations and the provider's schema validators alike. There is no CustomizeDiff on this resource, so unlike its MySQL sibling there is nothing that fires only at plan.

What validate cannot reach: whether the PostgreSQL server exists, whether the credential works, whether the named integration runtime exists, and whether the connection string is even syntactically a connection string. None of those is checked by anything until a pipeline runs.


💬 Example Output

id                                              = "/subscriptions/.../factories/adf-corp-eastus/linkedservices/ls-postgres-warehouse"
name                                            = "ls-postgres-warehouse"
connection_string_appears_to_contain_a_password = true
connection_string_takes_the_suppressor_password_branch = "password_segment_is_stripped_before_comparison"
read_never_refreshes_the_connection_string      = true
rotating_the_connection_string_is_a_real_update = true
runs_on_the_factory_default_integration_runtime = false
force_new_fields                                = ["name", "data_factory_id"]

🔍 Troubleshooting

Symptom Cause Fix
connection_string must not be empty or whitespace. The value resolved to blank — often a secret lookup that returned nothing. Check the secret exists before blaming the module.
connection_string looks like a URL. The OData or Web linked service was wanted, not this one. Use azurerm_data_factory_linked_service_odata or _web.
description must not be an empty or whitespace-only string. "" was passed to mean "none". Omit the argument.
name must not consist ENTIRELY of the characters ... The name is all punctuation. This mirrors the provider's own shared validator, which rejects exactly that shape and nothing else. Use a name with at least one other character.
must be a Data Factory Resource ID A dataset, linked service or pipeline ID was passed. Pass the factory's id.
Pipeline fails to connect, but apply succeeded Nothing here contacts PostgreSQL. Check the server, the credential, and the runtime's route to it.
A portal change to the linked service never appears in a plan The read never refreshes the connection string. Expected. Read the Data Factory directly.
Datasets break after a rename The record was replaced; datasets reference it by name. Update the datasets, or avoid renaming.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."