Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure App Service Connection Terraform Module

Wires an Azure App Service to a backend target via Service Connector, for hashicorp/azurerm ~> 4.0, with managed-identity authentication as the default path.

Terraform azurerm Module Type Resources

🧩 Overview

  • Creates an azurerm_app_service_connection (the keystone this) — a Service Connector link from a web app to a target backend (Storage, SQL, Key Vault, Service Bus, Cosmos DB, Event Hubs, and more).
  • Configures authentication: system- or user-assigned managed identity (preferred), service principal (secret/certificate), or a plain secret.
  • Optionally routes traffic over a VNet solution (serviceEndpoint / privateLink) and persists secrets to a Key Vault secret_store.
  • Emits the connection id, name, and target_resource_id; never emits a secret.

💡 Why it matters: Service Connector is where an app's backend credentials usually live. This module makes managed identity the default so the common path involves no secret at all, and marks any unavoidable secret/certificate sensitive.

❤️ Support this project

If this module saves you time:

🗺️ Where this fits in the family

flowchart LR
  web["terraform-azurerm-linux-web-app / windows-web-app"]
  tgt["target service: Storage / SQL / Key Vault / Service Bus / Cosmos"]
  kv["terraform-azurerm-key-vault"]
  sib["terraform-azurerm-function-app-connection (sibling)"]
  this["terraform-azurerm-app-service-connection"]
  conn["azurerm_app_service_connection"]

  web -->|"app_service_id"| this
  tgt -->|"target_resource_id"| this
  kv -.->|"secret_store.key_vault_id (optional)"| this
  this -->|"creates"| conn
  sib -.->|"same Service Connector family"| conn

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
  classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
  class this me;
  class conn target;
  class web,tgt,kv,sib ext;
Loading

🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs"]
    app["app_service_id"]
    target["target_resource_id"]
    auth["authentication {type, secret, certificate}"]
    ss["secret_store {key_vault_id} (optional)"]
  end
  this["azurerm_app_service_connection.this"]
  out["Outputs: id, name, target_resource_id"]

  app -->|"source web app"| this
  target -->|"backend"| this
  auth -->|"managed identity preferred"| this
  ss -.->|"persist secret in Key Vault"| this
  this --> out

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
  classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
  class this target;
  class out me;
  class app,target,auth,ss ext;
Loading

Resource inventory

Resource Role Cardinality
azurerm_app_service_connection keystone this single

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
azurerm provider ~> 4.0
Provider block None — the caller configures provider "azurerm" { features {} }, auth, and subscription

Schema notes that bite (verified against the live schema):

  • Force-new: name, app_service_id, target_resource_id, and authentication.type are immutable — changing any replaces the connection.
  • No tags: the resource does not support tags; the universal tail here is timeouts only.
  • authentication.secret and authentication.certificate are provider-sensitive and wrapped sensitive() here.
  • authentication is required (a connection must say how it authenticates). Identity types need the identity configured on the App Service.
  • The target is any supported Service Connector backend (target_resource_id); the connection's identity still needs a data-plane role on that target, granted separately.

🔑 Required Azure RBAC Roles / Permissions

  • Website Contributor (or equivalent) on the App Service.
  • The connection's identity/principal needs a data-plane role on the target (e.g. Storage Blob Data Contributor, Key Vault Secrets User) — grant it separately.

Azure Prerequisites

  • The Microsoft.ServiceLinker resource provider registered on the subscription.
  • An existing App Service and an existing target resource.
  • For identity-based auth: a system- or user-assigned identity configured on the App Service.

📁 Module Structure

terraform-azurerm-app-service-connection/
├── providers.tf   # required_version + azurerm ~> 4.0 pin; no provider block
├── variables.tf   # keystone inputs, authentication + secret_store blocks, timeouts (no tags)
├── main.tf        # azurerm_app_service_connection.this
├── outputs.tf     # id first, then name, target_resource_id
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

The smallest real call uses the app's system-assigned identity — no secret:

module "app_conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "storage"
  app_service_id     = var.web_app_id
  target_resource_id = var.storage_account_id
  authentication     = { type = "systemAssignedIdentity" }
}

ℹ️ The caller configures provider "azurerm" { features {} }, authentication, and the subscription — this module declares none of them. The system-assigned identity must be enabled on the App Service.

🔌 Cross-Module Contract

Consumes

Input Type Source module
app_service_id string terraform-azurerm-linux-web-app / windows-web-app (id)
target_resource_id string the target service module (id)
secret_store.key_vault_id string terraform-azurerm-key-vault (id)

Emits

Output Description
id Connection Resource ID (first)
name Connection name
target_resource_id Bound target resource ID
app_service_id The app this connection mutates
target_resource_id / target_resource_type What it binds to
authentication_type Unwrapped with nonsensitive() on purpose
uses_managed_identity No credential at all
supplies_a_credential Presence only, never the value
credential_is_persisted_in_key_vault A secret store is configured
credential_supplied_without_a_secret_store Credential lives in the app's settings
secret_store_key_vault_id The vault, or null
client_type / vnet_solution / traffic_avoids_the_public_path Config shape and networking
settings_written_to_the_app_are_not_owned_here Constant: this module mutates the App Service
connection_may_create_a_role_assignment_on_the_target Constant: and it is unmanaged
secrets_supplied_here_are_stored_in_state Constant: sensitive redacts plan, not state
this_module_emits_no_credential Constant: presence flags only
the_target_and_the_identity_are_not_verified Constant: almost nothing is checked

📚 Example Library

1 · System-assigned identity to Storage (recommended)
module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "storage"
  app_service_id     = var.web_app_id
  target_resource_id = var.storage_account_id
  authentication     = { type = "systemAssignedIdentity" }
}

🔒 No secret is handled at all — the app's identity authenticates to the target.

2 · User-assigned identity
authentication = {
  type            = "userAssignedIdentity"
  client_id       = var.uami_client_id
  subscription_id = var.subscription_id # pair client_id + subscription_id
}
3 · Service principal with secret (+ Key Vault store)
module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "sql"
  app_service_id     = var.web_app_id
  target_resource_id = var.sql_server_id
  authentication = {
    type         = "servicePrincipalSecret"
    client_id    = var.sp_client_id
    principal_id = var.sp_principal_id
    secret       = var.sp_secret # sensitive; provision out of band
  }
  secret_store = { key_vault_id = var.key_vault_id } # persist the secret in Key Vault
}

🔒 secret is wrapped sensitive; secret_store keeps it in Key Vault rather than the connection config.

4 · Service principal with certificate
authentication = {
  type         = "servicePrincipalCertificate"
  client_id    = var.sp_client_id
  principal_id = var.sp_principal_id
  certificate  = var.sp_certificate # sensitive
}
5 · Secret (username + key) auth
authentication = {
  type   = "secret"
  name   = var.db_user
  secret = var.db_password # name and secret both set (or both omitted)
}

⚠️ Prefer a managed identity where the target supports it; secret auth stores credentials.

6 · Target: Key Vault
module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "kv"
  app_service_id     = var.web_app_id
  target_resource_id = var.key_vault_id
  authentication     = { type = "systemAssignedIdentity" }
}
7 · Target: SQL database
target_resource_id = var.mssql_database_id
authentication     = { type = "systemAssignedIdentity" }
8 · Target: Service Bus
target_resource_id = var.servicebus_namespace_id
authentication     = { type = "systemAssignedIdentity" }
9 · Client type for a .NET app
module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "storage"
  app_service_id     = var.web_app_id
  target_resource_id = var.storage_account_id
  authentication     = { type = "systemAssignedIdentity" }
  client_type        = "dotnet" # generates .NET-flavored app settings
}
10 · Private Link networking
vnet_solution = "privateLink" # keep target traffic off the public internet

💡 Requires the target and app to support Private Link and the private connectivity to exist.

11 · Service Endpoint networking
vnet_solution = "serviceEndpoint"
12 · Custom timeouts
module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "storage"
  app_service_id     = var.web_app_id
  target_resource_id = var.storage_account_id
  authentication     = { type = "systemAssignedIdentity" }
  timeouts           = { create = "30m", delete = "30m" }
}
13 · 🏗️ End-to-end composition

Create a web app and a storage account, connect them with the app's system identity, and grant that identity data-plane access to the target:

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-web-eastus"
  location = "eastus"
}

module "plan" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-service-plan.git?ref=v1.0.0"
  name                = "asp-contoso"
  resource_group_name = module.rg.name
  location            = module.rg.location
  os_type             = "Linux"
  sku_name            = "P1v3"
}

module "web" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-linux-web-app.git?ref=v1.0.0"
  name                = "contoso-api"
  resource_group_name = module.rg.name
  location            = module.rg.location
  service_plan_id     = module.plan.id
  identity            = { type = "SystemAssigned" } # enable the identity the connection uses
}

module "storage" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                = "contosodata"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "conn" {
  source             = "git::https://github.com/microsoftexpert/terraform-azurerm-app-service-connection.git?ref=v1.0.0"
  name               = "storage"
  app_service_id     = module.web.id
  target_resource_id = module.storage.id
  authentication     = { type = "systemAssignedIdentity" }
  client_type        = "dotnet"
}

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

  scope = module.storage.id
  role_assignments = {
    blob = {
      scope                = module.storage.id
      role_definition_name = "Storage Blob Data Contributor"
      principal_id         = module.web.identity_principal_id
      principal_type       = "ServicePrincipal"
    }
  }
}

💡 The connection wires the app to the target; the role assignment is what actually grants the identity access — Service Connector does not create it.

📥 Inputs

Required: name, app_service_id, target_resource_id, authentication (with type). Optional: client_type, vnet_solution, secret_store. Universal tail: timeouts (no tags — the resource does not support them).

Full schemas
name               = string # required, force-new
app_service_id     = string # required, force-new
target_resource_id = string # required, force-new

authentication = object({
  type            = string           # required, force-new: systemAssignedIdentity | userAssignedIdentity
                                      #   | servicePrincipalSecret | servicePrincipalCertificate | secret
  name            = optional(string) # username for secret auth
  secret          = optional(string) # sensitive
  certificate     = optional(string) # sensitive
  client_id       = optional(string)
  subscription_id = optional(string)
  principal_id    = optional(string)
})

client_type   = optional(string)      # none | dotnet | java | python | go | php | ruby | django | nodejs | springBoot
vnet_solution = optional(string)      # serviceEndpoint | privateLink
secret_store  = optional(object({ key_vault_id = string }))

timeouts = optional(object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) }))

🧾 Outputs

Output Description Notes
id Connection Resource ID Emitted first
name Connection name
target_resource_id Bound target resource ID
app_service_id The source App Service 🔴 This module writes settings onto it
target_resource_id The target backend Force-new
target_resource_type e.g. Microsoft.Storage/storageAccounts Parsed — the provider exposes no such attribute
authentication_type The auth type in use Unwrapped with nonsensitive() so plan review can see it
uses_managed_identity Identity rather than a credential Categorically stronger: nothing to rotate or leak
supplies_a_credential A secret or certificate was supplied Presence only — the value is never emitted
credential_is_persisted_in_key_vault A secret store is set Does not remove the secret from state
credential_supplied_without_a_secret_store Credential in the app's settings ⚠️ Readable by anyone who can read the app config
secret_store_key_vault_id The vault, or null The provider checks only non-emptiness; this module anchors the type
client_type The stack settings are generated for 🔴 Wrong value = app cannot find its configuration
vnet_solution serviceEndpoint / privateLink / null Configures the expectation; does not build the path
traffic_avoids_the_public_path A VNet solution is set Not a posture verdict — the target's own firewall matters more
settings_written_to_the_app_are_not_owned_here Always true 🔴 A sibling module managing app_settings will fight with this
connection_may_create_a_role_assignment_on_the_target Always true 🔴 Needs User Access Administrator on the target; the assignment is unmanaged
secrets_supplied_here_are_stored_in_state Always true 🔴 sensitive redacts plan, not state
this_module_emits_no_credential Always true Presence flags only, by design
the_target_and_the_identity_are_not_verified Always true A connection can be created and be completely non-functional

🧠 Architecture Notes

  • Managed identity first. The default and recommended path is systemAssignedIdentity / userAssignedIdentity, which needs no secret. Secret- and certificate-based types are supported but handle material out of band and are marked sensitive.
  • Wiring ≠ authorization. Service Connector configures app settings and networking, but the connection's identity still needs a data-plane role on the target — grant it with terraform-azurerm-role-assignments.
  • Immutable core. name, app_service_id, target_resource_id, and authentication.type are force-new; changing the target or auth mechanism replaces the connection.
  • No tags. The resource type does not support tags, so the module omits the variable — the universal tail is timeouts only.
  • secret_store. When a secret-based auth type is unavoidable, secret_store persists the secret to Key Vault instead of the connection configuration.
  • features {} dependence. The provider will not initialize without a caller-side features {} block — expected, and owned by the root module.

🧱 Design Principles

Concern Secure default Opt-out (caller must type it)
Authentication steer to managed identity (no secret) choose servicePrincipalSecret / secret and supply material
Secret handling secret / certificate wrapped sensitive; none emitted —
Secret persistence secret_store in Key Vault when a secret is used omit secret_store
Networking leave vnet_solution null unless private connectivity exists set serviceEndpoint / privateLink

🚀 Runbook

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

Pin the module by immutable tag (?ref=v1.0.0), never a branch. This is plan-only; a human applies from CI.

🧪 Testing

  • terraform validate + fmt -check prove the type contract and the enum validations (authentication.type, client_type, vnet_solution) offline, before any Azure call.
  • Only terraform plan against a subscription exercises the identity configured on the App Service, the target's existence, and the data-plane role the identity needs.

💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id                 = "/subscriptions/.../providers/Microsoft.Web/sites/contoso-api/providers/Microsoft.ServiceLinker/linkers/storage"
name               = "storage"
target_resource_id = "/subscriptions/.../providers/Microsoft.Storage/storageAccounts/contosodata"

🔍 Troubleshooting

Symptom Cause Fix
authentication.type must be one of ... Unsupported auth type Use one of the five documented values
App still gets 403 from the target No data-plane role for the identity Assign the identity the right role on the target (e.g. Storage Blob Data Contributor)
Identity auth fails at apply Identity not enabled on the App Service Enable system- or user-assigned identity on the app first
Connection replaced on every change Changed a force-new field (target_resource_id, authentication.type, …) Expected — those fields replace the connection
Secret visible concern Using a secret-based auth type Prefer a managed identity; if not possible, set secret_store to keep it in Key Vault

🔗 Related Docs

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