Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Integration Account Agreement Terraform Module

Declares one B2B trading agreement — AS2, X12, or Edifact — inside an existing integration account (azurerm_logic_app_integration_account_agreement). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🤝 Declares one trading agreement — the record binding two partners, two business identities, and a B2B protocol — as a keystone resource named this.
  • ✅ agreement_type (AS2 / X12 / Edifact) is required and validated; there is no safe default, because the three describe genuinely different relationships.
  • ⚠️ States the quiet failure plainly: the service does not cross-check the business identities against the partners, so a mismatch produces an agreement that applies cleanly and never matches a document.
  • 🔒 Treats content as a security artifact, not configuration — for AS2 it decides whether messages are signed, whether they are encrypted, and with which algorithms.
  • 🗝️ Accepts no key material. Certificates are referenced by name and owned by the certificate module, which keeps the private key in Key Vault.
  • 📤 Emits agreement_type so a review can see which protocol governs a relationship without opening the document; content is deliberately not emitted.

💡 Why it matters: Two facts about this resource cause most of the trouble. Terraform checks only that content parses as JSON — and through a module boundary that check is the module's own, at variable evaluation, because a provider schema check reaches only a literal written in a resource block — and nothing about its meaning is validated anywhere, and the service validates nothing about the identity pairs — so an agreement can be entirely wrong and still apply green. This module cannot check either, so it does the next best thing: it wires the identities from the partner module rather than letting you retype them, and it says exactly which claims are unverified.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

flowchart LR
  acct["terraform-azurerm-logic-app-integration-account"]
  hostp["partner module: YOUR side, emits business_identities"]
  guestp["partner module: the COUNTERPARTY"]
  cert["integration-account-certificate: private key stays in Key Vault"]
  schema["integration-account-schema: what EDI documents validate against"]
  this["terraform-azurerm-logic-app-integration-account-agreement"]
  ag["azurerm_logic_app_integration_account_agreement"]
  wf["terraform-azurerm-logic-app-workflow: the AS2 or EDI actions"]
  batch["integration-account-batch-configuration"]

  acct -->|"integration_account_name, NOT its id"| this
  hostp -->|"host_partner_name plus host_identity"| this
  guestp -->|"guest_partner_name plus guest_identity"| this
  cert -->|"referenced BY NAME inside content, for AS2"| this
  schema -->|"referenced by the EDI settings in content"| this
  this -->|"creates"| ag
  ag -->|"what the decode and encode actions match on"| wf
  batch -->|"sibling artifact, independent lifecycle"| acct

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class ag keystone;
  class acct,hostp,guestp,cert,schema,wf,batch sib;
Loading

🧬 What this module builds

flowchart TB
  addr["name, resource_group_name, integration_account_name: ALL force-new"]
  at["agreement_type AS2 or X12 or Edifact: validated, drives the content shape"]
  hp["host_partner_name plus guest_partner_name: must already exist in the account"]
  hi["host_identity and guest_identity: qualifier plus value"]
  nocheck["the service does NOT cross-check identities against the partners"]
  content["content: the JSON agreement body, UNVALIDATED by Terraform"]
  sec["for AS2 the content decides signing, encryption, and algorithms"]
  md["metadata: a map of strings here, a plain string on certificate, partner, schema"]
  this["terraform-azurerm-logic-app-integration-account-agreement"]
  ag["azurerm_logic_app_integration_account_agreement.this"]
  out["outputs: id, name, agreement_type, both partner names"]
  withheld["content deliberately NOT emitted"]

  addr -->|"identity"| this
  at -->|"protocol"| this
  hp -->|"the two parties"| this
  hi -->|"how documents are matched"| this
  nocheck -->|"a mismatch matches nothing, silently"| hi
  content -->|"the settings"| this
  sec -->|"review as a security artifact"| content
  md -->|"onboarding provenance"| this
  this -->|"creates"| ag
  ag -->|"emits"| out
  ag -->|"withheld"| withheld

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class ag keystone;
  class addr,at,hp,hi,nocheck,content,sec,md,out,withheld sib;
Loading

Resource inventory

Resource Count Role
azurerm_logic_app_integration_account_agreement.this 1 The keystone agreement, with its required host_identity and guest_identity blocks and its optional timeouts block.

✅ Provider / Versions

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

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

  • name, resource_group_name, and integration_account_name are all force-new. For a live trading relationship a rename means a window in which inbound documents have no agreement to match.
  • The content document's required shape depends entirely on agreement_type — an X12 body under agreement_type = "AS2" is rejected at apply, not at plan.
  • Terraform checks only that content parses as JSON — and through a module boundary that check is the module's own, at variable evaluation, because a provider schema check reaches only a literal written in a resource block — and nothing about its meaning is validated anywhere. Envelope settings, acknowledgement behaviour, and — for AS2 — the signing and encryption settings are opaque to every offline check.
  • The service does not cross-check host_identity / guest_identity against the partners. A pair no partner declares produces an agreement that exists, applies cleanly, and never matches a document.
  • Both partners must already exist in the same integration account; a name that does not resolve is rejected at apply.
  • metadata is a map(string) here — the same field is a plain string on the certificate, partner, and schema artifacts in this family. An easy inconsistency to trip over when copying between them.
  • The parent is addressed by name, not by Resource ID — the pattern shared by every artifact in this family.
  • Agreement capacity belongs to the account's SKU, and Basic is the smallest tier of the three. Microsoft's published artifact limits allow 10 EDI trading agreements on Free, 1 on Basic and 1,000 on Standard. Nothing in this module can see the parent account's SKU, so the ceiling never appears in a plan.
  • The create-time collision check is a caller-side conditional, not a property of the resource. The create path GETs this exact agreement and refuses to continue if one exists — unless the caller sets features { skip_import_check_on_create_and_allow_overwriting_existing_resources = true }, in which case an apply silently overwrites an existing agreement of the same name, content document and AS2 security settings included.
  • This resource type has no tags surface.

🔑 Required Azure RBAC Roles / Permissions

  • Logic App Contributor or Contributor on the integration account's resource group, covering Microsoft.Logic/integrationAccounts/agreements/write. Logic App Contributor grants Microsoft.Logic/*, which covers it.
  • Microsoft.Logic/integrationAccounts/agreements/read — the create path's collision check GETs this agreement, not the account. There is no integrationAccounts/read call anywhere in this resource.
  • Three built-in roles look sufficient and are not. Logic App Operator holds only Microsoft.Logic/*/read (plus workflow enable/disable/validate), so it can refresh and plan this resource but not create, update or delete it. Logic Apps Standard Contributor and Logic Apps Standard Developer are Microsoft.Web/* roles with no Microsoft.Logic action at all, so a team on single-tenant Logic Apps holding only those cannot manage integration-account artifacts despite the names.
  • ⚠️ Plan rights are review rights here. The read path returns the whole content document, so anyone who can refresh this resource can read the exchange's AS2 signing and encryption settings. That is not credential access — no key or certificate private half is returned — but it is more than a name.
  • No data-plane or Key Vault permission is needed here — certificates are referenced by name and owned by the certificate module.

Azure Prerequisites

  • An existing integration account. Its SKU sets the agreement ceiling: Free permits 10, Basic only 1, Standard 1,000 — so a real trading estate needs Standard, and moving a Free account to Basic reduces agreement capacity from ten to one.
  • Both partners already created in that same account, each declaring the business identity this agreement names.
  • For AS2: the signing and encryption certificates already uploaded as certificate artifacts, referenced by name in content.
  • For X12 or Edifact: any schema the agreement's settings reference already uploaded as schema artifacts.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

📁 Module Structure

terraform-azurerm-logic-app-integration-account-agreement/
├── providers.tf   # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf   # validated agreement_type, required identity objects, content, timeouts tail
├── main.tf        # keystone azurerm_logic_app_integration_account_agreement.this
├── outputs.tf     # id, name, parent addressing, agreement_type, both partner names
├── README.md      # this document
├── SCOPE.md       # cross-module contract
├── LICENSE        # MIT
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "contoso_x12" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-agreement.git?ref=v1.0.0"

  name                     = "contoso-x12-850"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name # the NAME, not the id

  agreement_type = "X12"

  host_partner_name  = module.our_partner.name
  guest_partner_name = module.contoso_partner.name

  # Wired from the partner modules so the two records cannot drift apart.
  host_identity  = module.our_partner.business_identities["primary"]
  guest_identity = module.contoso_partner.business_identities["primary"]

  content = file("${path.module}/agreements/contoso-x12-850.json")
}

ℹ️ The caller owns the provider, its authentication, and the mandatory features {} block. This module never declares them.


🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group (name)
integration_account_name string terraform-azurerm-logic-app-integration-account (name)
host_partner_name string terraform-azurerm-logic-app-integration-account-partner (name)
guest_partner_name string terraform-azurerm-logic-app-integration-account-partner (name)
host_identity, guest_identity object({ qualifier, value }) the respective partner module's business_identities output
content string (JSON) caller — jsonencode() or file()

Emits

Output Description Consumed by
id Agreement Resource ID (first) audit inventories
name Agreement name terraform-azurerm-logic-app-workflow — the AS2/EDI actions reference it by name
integration_account_name The parent account composition wiring
resource_group_name The account's resource group composition wiring
agreement_type AS2, X12, or Edifact governance review — the content's shape and its security settings both follow from this
host_partner_name Your side of the relationship trading-partner inventory
guest_partner_name The counterparty trading-partner inventory
host_identity The { qualifier, value } this agreement claims for the host a composition assertion against the host partner's business_identities
guest_identity The { qualifier, value } this agreement claims for the guest a composition assertion against the guest partner's business_identities
content_declares_section_for_agreement_type Whether the content document carries a top-level section matching agreement_type pre-apply review — advisory, derived from the caller's own JSON
basic_tier_permits_fewer_agreements_than_free Constant true: Free 10, Basic 1, Standard 1,000 tier review
delete_fails_if_the_account_is_already_gone Constant true: the delete has no not-found tolerance destroy-ordering review
create_collision_check_is_disabled_by_a_caller_feature Constant true: a caller-side features {} switch turns a create into a silent overwrite provider-configuration review

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "b2b_vault_id" {
  description = "id of an existing b2b vault that these examples reference but do not create."
  type        = string
}

variable "eurofoods_partner_business_identities" {
  description = "business identities of an existing eurofoods partner that these examples reference but do not create."
  type        = map(string)
}
1 · An X12 agreement, identities wired not retyped
module "contoso_x12" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-agreement.git?ref=v1.0.0"

  name                     = "contoso-x12-850"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  agreement_type     = "X12"
  host_partner_name  = module.our_partner.name
  guest_partner_name = module.contoso_partner.name

  host_identity  = module.our_partner.business_identities["primary"]
  guest_identity = module.contoso_partner.business_identities["primary"]

  content = file("${path.module}/agreements/contoso-x12-850.json")
}

💡 The identity pairs come from the partner modules. That is the whole point: the service performs no cross-check, so wiring is the only mechanism that stops the agreement and the partner from drifting apart.

2 · The silent failure this module cannot prevent
# ❌ Retyped by hand — and the guest partner actually declares "ZZ" / "CONTOSO-PROD".
guest_identity = {
  qualifier = "ZZ"
  value     = "CONTOSO"
}

⚠️ This applies cleanly and never matches a document. The service does not verify that a business identity named in an agreement is declared on the partner. There is nothing for Terraform to check either — the partner is a separate resource whose identities are not inputs here. It surfaces as inbound documents being rejected as "no matching agreement," often days later during a partner's first real send.

3 · An AS2 agreement, with signing and encryption on
agreement_type = "AS2"

host_identity  = module.our_partner.business_identities["as2"]
guest_identity = module.contoso_partner.business_identities["as2"]

content = file("${path.module}/agreements/contoso-as2.json")

🔒 For AS2 the content document decides whether messages are signed, whether they are encrypted, and which algorithms are used — and it names the certificates by reference. An agreement that turns signing off is a perfectly valid agreement and a weakened channel. Review this file as a security artifact, with the same care as a firewall rule.

4 · Certificates are referenced, never embedded
module "contoso_signing_cert" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-certificate.git?ref=v1.0.0"

  name                     = "contoso-signing"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  # The private key stays in Key Vault; only a reference is stored.
  key_vault_key = {
    key_name      = "contoso-as2-signing"
    key_vault_id  = var.b2b_vault_id
  }
}
// Inside the AS2 content document — a NAME, not key material:
{ "receiveAgreement": { "protocolSettings": { "securitySettings": { "signingCertificateName": "contoso-signing" } } } }

🔒 No key material belongs in this module. The certificate artifact owns it, keeps the private key in Key Vault, and the agreement refers to it by name. ⚠️ The certificate must exist in the same integration account, or the agreement applies and then fails at run time.

5 · Keeping the content document reviewable
# ✅ A real file, in the repository, that a reviewer can diff.
content = file("${path.module}/agreements/contoso-x12-850.json")
# ✅ Also fine — malformed JSON fails at plan.
content = jsonencode({
  receiveAgreement = { protocolSettings = { /* ... */ } }
  sendAgreement    = { protocolSettings = { /* ... */ } }
})

💡 Prefer either of these over an inline heredoc. jsonencode() fails at plan on malformed JSON; file() keeps a security-relevant document where a reviewer will actually look at it. A heredoc gets you neither.

6 · `agreement_type` must match the body
agreement_type = "AS2"
content        = file("${path.module}/agreements/contoso-x12-850.json") # ❌ an X12 body
Error: creating Agreement: the agreement content does not match the specified agreementType

⚠️ This fails at apply, not at plan. Terraform sees a valid string and a valid JSON string; only the service knows they disagree. Name the file after the protocol so the mismatch is visible in the configuration itself.

7 · Enum validation at plan
agreement_type = "EDIFACT" # ❌
Error: Invalid value for variable

  agreement_type must be exactly one of: AS2, X12, Edifact. The check is
  case-sensitive — note that Edifact is mixed-case, so EDIFACT and edifact are both
  rejected.

ℹ️ The casing is the service's: Edifact, not EDIFACT or EDIFact. This is required rather than defaulted on purpose — the three protocols describe genuinely different relationships, and a guessed default would produce the content mismatch from example 6.

8 · An Edifact agreement
name           = "eurofoods-edifact-orders"
agreement_type = "Edifact"

host_identity  = module.our_partner.business_identities["edifact"]
guest_identity = var.eurofoods_partner_business_identities["edifact"]

content = file("${path.module}/agreements/eurofoods-edifact.json")

ℹ️ Edifact is the predominant EDI standard outside North America; X12 is the North American one. A partner exchanging both needs two agreements, each naming the appropriate business identity — which is why partners declare identities as a keyed collection rather than a single value.

9 · Recording the onboarding trail in metadata
metadata = {
  onboarding_ticket = "B2B-4471"
  business_owner    = "supply-chain-ops"
  effective_date    = "2026-08-01"
  protocol_contact  = "edi-team@example.com"
}

ℹ️ metadata is a map(string) on this resource — but a plain string on the certificate, partner, and schema artifacts in this same family. Copying a metadata block between artifacts is the most likely way to hit a type error here.

10 · Renaming is not free
name = "contoso-x12-850-v2" # forces replacement

⚠️ All three addressing fields are force-new. For a live trading relationship, replacement means a window in which inbound documents have no agreement to match — they are rejected, not queued. Schedule it, or create the new agreement alongside the old one and retire the old one after the cutover.

11 · Several agreements from a keyed map
locals {
  agreements = {
    "contoso-x12-850"   = { type = "X12", guest = module.contoso_partner, identity = "primary" }
    "contoso-as2"       = { type = "AS2", guest = module.contoso_partner, identity = "as2" }
    "eurofoods-edifact" = { type = "Edifact", guest = module.eurofoods_partner, identity = "edifact" }
  }
}

module "agreements" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-agreement.git?ref=v1.0.0"
  for_each = local.agreements

  name                     = each.key
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  agreement_type     = each.value.type
  host_partner_name  = module.our_partner.name
  guest_partner_name = each.value.guest.name

  host_identity  = module.our_partner.business_identities[each.value.identity]
  guest_identity = each.value.guest.business_identities[each.value.identity]

  content = file("${path.module}/agreements/${each.key}.json")
}

⚠️ The account's SKU caps how many of these can exist: 10 on Free, 1 on Basic, 1,000 on Standard. So these three fit on Free and none of them fits on Basic past the first — the apply succeeds for one and errors on the next, with nothing in the plan to warn you. A real trading estate needs Standard; Basic is the tier that makes this worse.

12 · Reviewing the trading estate from state
output "trading_estate" {
  description = "One row per relationship. agreement_type tells you what class of security settings the content carries."
  value = {
    for k, m in module.agreements : k => {
      protocol   = m.agreement_type
      host       = m.host_partner_name
      guest      = m.guest_partner_name
    }
  }
}

💡 agreement_type is emitted precisely so this table can be built without opening any content document. An AS2 row is one whose signing and encryption settings warrant a look; an X12 or Edifact row is about envelopes and acknowledgements.

13 · What the workflow does with the agreement
module "decode_action" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-action-custom.git?ref=v1.0.0"

  name         = "Decode_X12_message"
  logic_app_id = module.b2b_workflow.id

  body = jsonencode({
    type   = "Xml"
    inputs = { agreementName = module.contoso_x12.name }
  })
}

ℹ️ The workflow's decode/encode actions reference the agreement by name, which is why name is emitted. The workflow's integration account must be the one holding this agreement — a workflow linked to a different account will not find it.

14 · Retiring a relationship
# Removing the module destroys the agreement. Inbound documents from that
# partner are then rejected as unmatched, immediately.

⚠️ Destroy is not a soft delete and there is no grace period. Confirm with the counterparty that the relationship is genuinely over before removing the agreement — and consider retiring the partner artifact separately, since other agreements may still reference it.

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

# 1 · The resource group.
module "b2b_rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-b2b-eastus2"
  location = "eastus2"
}

# 2 · The integration account. Standard, because the agreement ceiling is 10 on Free
#     and only 1 on Basic — Basic is the smallest of the three tiers here, not the middle.
module "b2b_account" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account.git?ref=v1.0.0"

  name                = "ia-b2b-eastus2"
  resource_group_name = module.b2b_rg.name
  location            = module.b2b_rg.location
  sku_name            = "Standard"
}

# 3 · Our side of every relationship, declaring the identities agreements name.
module "our_partner" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-partner.git?ref=v1.0.0"

  name                     = "our-company"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  business_identity = {
    primary = { qualifier = "ZZ", value = "OURCO" }
    as2     = { qualifier = "AS2Identity", value = "OURCO-AS2" }
  }
}

# 4 · The counterparty.
module "contoso_partner" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-partner.git?ref=v1.0.0"

  name                     = "contoso"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  business_identity = {
    primary = { qualifier = "ZZ", value = "CONTOSO-PROD" }
    as2     = { qualifier = "AS2Identity", value = "CONTOSO-AS2" }
  }
}

# 5 · The AS2 certificates. The private key never leaves Key Vault.
module "contoso_signing_cert" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-certificate.git?ref=v1.0.0"

  name                     = "contoso-signing"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  key_vault_key = {
    key_name     = "contoso-as2-signing"
    key_vault_id = var.b2b_vault_id
  }
}

# 6 · The agreement — this module. Identities WIRED from steps 3 and 4, and the
#     certificate from step 5 referenced BY NAME inside the content document.
module "contoso_as2" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-integration-account-agreement.git?ref=v1.0.0"

  name                     = "contoso-as2"
  resource_group_name      = module.b2b_rg.name
  integration_account_name = module.b2b_account.name

  agreement_type     = "AS2"
  host_partner_name  = module.our_partner.name
  guest_partner_name = module.contoso_partner.name

  host_identity  = module.our_partner.business_identities["as2"]
  guest_identity = module.contoso_partner.business_identities["as2"]

  content = file("${path.module}/agreements/contoso-as2.json")

  metadata = {
    onboarding_ticket = "B2B-4471"
    business_owner    = "supply-chain-ops"
  }

  depends_on = [module.contoso_signing_cert]
}

# 7 · The workflow whose decode/encode actions match documents against it.
module "b2b_workflow" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-logic-app-workflow.git?ref=v1.0.0"

  name                       = "logic-b2b-inbound"
  resource_group_name        = module.b2b_rg.name
  location                   = module.b2b_rg.location
  logic_app_integration_account_id = module.b2b_account.id
}

💡 The wiring is the design. host_identity and guest_identity come from steps 3 and 4 rather than being retyped, because the service will not tell you when they disagree with the partners. The certificate in step 5 is referenced by name inside the content document, so no key material passes through this module. And depends_on is explicit because a name reference inside an opaque JSON string creates no dependency Terraform can see. Output names on sibling modules are illustrative; match them to the versions you pin.


📥 Inputs

Required addressing (all force-new): name, resource_group_name, integration_account_name.

The relationship: agreement_type, host_partner_name, guest_partner_name, host_identity, guest_identity, content.

Optional: metadata.

Universal tail: timeouts. This resource type does not support tags.

Full object() schemas
variable "name"                     { type = string } # force-new; a rename is a matching gap on a live relationship
variable "resource_group_name"      { type = string } # force-new
variable "integration_account_name" { type = string } # force-new; the NAME, not the Resource ID

variable "agreement_type" {
  # AS2 | X12 | Edifact (validated). REQUIRED, not defaulted: the content's required
  # shape depends entirely on this, and a mismatch is rejected at APPLY, not at plan.
  type = string
}

variable "host_partner_name"  { type = string } # must already exist in the same account
variable "guest_partner_name" { type = string } # must already exist in the same account

variable "host_identity" {
  # ⚠️ Must match a business identity actually DECLARED on the host partner. The service
  #    does NOT cross-check, so a mismatch produces an agreement that never matches a
  #    document. Wire from the partner module's business_identities output.
  type = object({
    qualifier = string # e.g. "ZZ" (mutually defined), "01" (DUNS), "AS2Identity"
    value     = string
  })
}

variable "guest_identity" {
  # ⚠️ Same caveat as host_identity.
  type = object({ qualifier = string, value = string })
}

variable "content" {
  # The agreement definition as JSON. Terraform validates NOTHING inside it.
  # 🔒 For AS2 this decides signing, encryption, and algorithms — review it as a
  #    SECURITY ARTIFACT. Certificates are referenced BY NAME; never embed key material.
  # Prefer jsonencode({...}) or file("$${path.module}/agreements/<name>.json").
  type = string
}

variable "metadata" {
  # A map(string) HERE — but a plain string on the certificate, partner, and schema
  # artifacts in this same family.
  type    = map(string)
  default = {}
}

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

🧾 Outputs

Output Description Notes
id The Azure Resource ID of the agreement. Emitted first.
name The agreement name. The workflow's AS2/EDI actions reference it by name.
integration_account_name The parent account.
resource_group_name The account's resource group.
agreement_type AS2, X12, or Edifact. Emitted so a review sees the protocol — and therefore the class of security settings — without opening the content.
host_partner_name Your side of the relationship.
guest_partner_name The counterparty.
host_identity The { qualifier, value } claimed for the host partner. Derived from the input, so it resolves at plan time. Compare it against the host partner module's business_identities — nothing else will.
guest_identity The { qualifier, value } claimed for the guest partner. Same, for the guest side.
content_declares_section_for_agreement_type true when the content document has a top-level section whose name matches agreement_type, compared case-insensitively. Advisory, not a provider check. false means the agreement declares one protocol and carries the settings for another, or none — a combination Terraform will still plan and apply. Assert on it in a composition.
basic_tier_permits_fewer_agreements_than_free Constant true. Free 10, Basic 1, Standard 1,000. The tier names imply the opposite ordering.
delete_fails_if_the_account_is_already_gone Constant true. The delete accepts only 200/204 — no not-found tolerance, unlike the read. Destroy order matters.
create_collision_check_is_disabled_by_a_caller_feature Constant true. A caller-side features {} switch turns a create into a silent overwrite of an existing agreement, content document included.

content is deliberately not emitted. It carries the exchange's security settings, is already readable from state, and re-emitting it would spread a security-relevant artifact across every consumer.

🧠 Architecture Notes

  • Two claims in this resource are unverified by anything, and that shapes the whole module. The service does not cross-check host_identity / guest_identity against the partners, and Terraform checks only that content parses as JSON — and through a module boundary that check is the module's own, at variable evaluation, because a provider schema check reaches only a literal written in a resource block — and nothing about its meaning is validated anywhere. Neither is checkable here — the partner is a separate resource whose identities are not inputs, and the content's correctness depends on the protocol, the partners, and the certificate and schema artifacts in the account. So the module wires the identities from the partner module rather than letting them be retyped, and states plainly which claims nothing validates.
  • content is a security artifact, not configuration. For AS2 it carries whether messages are signed, whether they are encrypted, and which algorithms are used. An agreement that disables signing is a valid agreement and a weakened channel — which is why the guidance is to keep it in a real file that a reviewer will diff, and why agreement_type is emitted so a review knows which documents deserve that scrutiny.
  • No key material enters this module. Certificates are referenced by name inside content and owned by the certificate module, which keeps the private key in Key Vault. That also means a name reference creates no Terraform dependency, so ordering against the certificate needs explicit depends_on.
  • agreement_type is required rather than defaulted. There is no safe default: AS2, X12, and Edifact describe genuinely different relationships, and a guessed value produces a content mismatch that fails only at apply.
  • A rename is an outage on a live relationship. All three addressing fields are force-new, and replacement means a window in which inbound documents have no agreement to match — they are rejected, not queued.
  • The parent is addressed by name, not by Resource ID. This is the pattern across every artifact in this family, and it means the dependency on the account is a string, not a reference — worth remembering when reasoning about apply order.
  • metadata is a map(string) here and a plain string on three of its siblings. A genuine inconsistency in the provider's schema for this family, and the most likely thing to trip over when copying a block between artifacts.
  • content is not emitted, following this suite's rule that a module does not re-publish a security-relevant artifact a consumer can already reach.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Key material none accepted — certificates referenced by name — (structural; use the certificate module)
Identity drift documentation and wiring steer to the partner module's output retype the values by hand (do not)
Protocol agreement_type required and validated — (no default to opt out of)
Content review documented as a security artifact; file() / jsonencode() encouraged inline a heredoc
Protocol visibility agreement_type emitted — (no opt-out)
Secret re-publication content not emitted — (no opt-out)

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 — never a branch.
  • This library is plan-only during authoring; a human runs terraform plan / apply from CI against real credentials.
  • Before applying: confirm both partners exist and declare the exact business identities this agreement names, and that any certificate or schema the content references is already in the account.
  • After apply, send a test document in both directions. A green apply proves the agreement exists, not that it matches anything.

🧪 Testing

  • terraform validate proves the configuration is internally consistent and type-correct against the pinned provider schema. It does not exercise this module's validation {} blocks — there are seventeen of them, and run from inside the module's own directory validate evaluates no variables and therefore fires none. Run it against a calling configuration to fire them, or drive them offline with terraform console (an immediate end-of-input on stdin), which evaluates all of them. jsonencode() in content additionally makes malformed JSON fail while the variables are evaluated.
  • terraform fmt -check enforces canonical formatting.
  • Neither command calls Azure. Only terraform plan (run by a human, from CI) exercises the ARM API — the module ships without any cloud apply.
  • What only apply exercises: whether content matches agreement_type, whether both partner names resolve in the account, and the account SKU's agreement ceiling (10 Free / 1 Basic / 1,000 Standard), which no module in this family can see.
  • What nothing exercises: whether the business identities match the partners' declared identities, and whether the content's referenced certificates and schemas exist. The first is not validated by Terraform or the service; both surface as unmatched documents at run time. This is why the composition example wires identities rather than retyping them.

💬 Example Output

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

Outputs:

id                       = "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-b2b-eastus2/providers/Microsoft.Logic/integrationAccounts/ia-b2b-eastus2/agreements/contoso-as2"
name                     = "contoso-as2"
integration_account_name = "ia-b2b-eastus2"
resource_group_name      = "rg-b2b-eastus2"
agreement_type           = "AS2"
host_partner_name        = "our-company"
guest_partner_name       = "contoso"
host_identity            = { "qualifier" = "AS2Identity", "value" = "OURCO-AS2" }
guest_identity           = { "qualifier" = "AS2Identity", "value" = "CONTOSO-AS2" }
content_declares_section_for_agreement_type            = true
basic_tier_permits_fewer_agreements_than_free          = true
delete_fails_if_the_account_is_already_gone            = true
create_collision_check_is_disabled_by_a_caller_feature = true

ℹ️ content_declares_section_for_agreement_type printing false is the one line worth stopping on: it means the declared protocol and the document disagree, which applies cleanly and matches nothing.

🔍 Troubleshooting

Symptom Cause Fix
Provider configuration not present / features error No caller-side provider "azurerm" { features {} }. Add the provider block with features {} in the root module.
agreement_type must be exactly one of: AS2, X12, Edifact Wrong casing or an invented value. The provider's membership test is case-sensitive, and the API model's fourth value, NotSpecified, is deliberately not accepted. Use AS2, X12, or Edifact — note Edifact, not EDIFACT.
Apply fails: content does not match the agreement type The JSON body is for a different protocol. Match the body to agreement_type; name files after the protocol so it is visible.
Apply fails: partner not found host_partner_name or guest_partner_name does not resolve in the account. Create the partner first, in the same integration account.
Apply fails: agreement limit reached The account's SKU ceiling: 1 on Basic, 10 on Free, 1,000 on Standard. Nothing in the plan shows it. Move to Standard. Do not move Free to Basic to fix this — that cuts the agreement limit from 10 to 1 and makes it worse.
Apply succeeds, inbound documents are rejected as unmatched A business identity does not match one declared on the partner. Nothing validates this. Wire host_identity / guest_identity from the partner module's business_identities output.
Run-time failure referencing a certificate The certificate named inside content is not in this account, or was created after the agreement. Create it in the same account and add depends_on — a name inside JSON is not a Terraform dependency.
AS2 messages are unsigned or unencrypted The content document says so. It is a valid agreement. Review the content's security settings; treat that file as a security artifact.
Type error on metadata It is a map(string) here but a plain string on the certificate, partner, and schema artifacts. Use a map on this resource.
Plan wants to replace the agreement name, resource_group_name, and integration_account_name are force-new. Expected — but on a live relationship schedule it, or run old and new side by side across the cutover.

🔗 Related Docs


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