Skip to content

Repository files navigation

Terraform AzAPI Logic App Workflow

Consumption Logic App workflows deployed WHOLE through azapi: one PUT carries the definition (the portal code-view export, rendered by templatefile), the typed parameter values, the generated $connections parameter and the identity, so the authored artefact IS the deployed artefact.

CI Release Terraform Registry License


Overview

This module is the full-definition way to run Consumption Logic Apps as code, built for the portal-first workflow: develop in the designer, export the code view into a .json.tftpl template, ctrl+F the values Terraform owns into ${tokens}, and deploy the whole thing with templatefile(...). It sits beside terraform-azurerm-logic-app-workflow (the shell + per-resource authoring path, per the Libre DevOps Logic App standard) and deliberately trades that path's granular resources for properties the per-resource split cannot give you:

  • One execution graph. runAfter is the only ordering; there is no second depends_on graph across azurerm_logic_app_trigger_* / azurerm_logic_app_action_* resources to keep in lockstep, and no four-resources-per-playbook scatter.
  • The artefact is the portal export. The definition deploys verbatim (decoded, never reshaped), so a portal export diffs cleanly against the rendered template: refactors are proven by byte-identical renders, and drift review is a JSON diff, not archaeology. All three shapes Azure hands you paste straight in (see Authoring).
  • Whole-workflow lifecycle. Create, update and destroy PUT/DELETE the workflow itself. A concurrency-singleton trigger can never hit CannotDisableTriggerConcurrency (proven live: that trap only fires when a trigger is PATCHed out of a live definition individually).
  • Consumption connection facts, encoded. Consumption accepts ONLY V1 Microsoft.Web/connections (which also reject access policies); managed identity auth is the connection's parameterValueType = "Alternative" PLUS the connectionProperties.authentication block inside $connections, which the module generates from a typed map. Parameter DECLARATIONS (including $connections) stay in the definition, exactly where the portal exports them; the module supplies only the VALUES and validates the two halves against each other at plan time (undeclared values, missing $connections declarations and unknown callback triggers all fail the plan, not the run).
  • Sibling dispatch is ordered. ARM validates a native Workflow dispatch action's TARGET at PUT time (NestedWorkflowNotFound, proven live), and sibling references must be CONSTRUCTED ARM ids (a module-output reference from a definition would be a dependency cycle). Mark a workflow that dispatches to a sibling with deploy_tier = 1, and one whose target is itself a dispatcher (a router invoking a hooked handler, proven needed live) with deploy_tier = 2; each tier deploys after the ones below it, in the same module call.
  • SAS can be turned off. trigger.sas_authentication_enabled = false emits sasAuthenticationPolicy: Disabled: the callback URL stops authenticating and the AAD policies become the only HTTP door (native Workflow dispatch and connector triggers never used SAS). The property is accepted by the resource provider on 2019-05-01 (proven against the ARM validate endpoint) though absent from that version's published OpenAPI spec; a check flags SAS-off with no policy, since that trigger accepts no HTTP caller at all.
  • Plans stay quiet. The default response_export_values is a trimmed stable set: ["*"] echoes the whole GET response including volatile fields (changedTime and friends), which makes every plan a no-op update-in-place on every workflow (proven live). Widen it per workflow when you need more of the response.
  • Secrets stay out of state. SecureString / SecureObject parameter values ride the provider's write-only sensitive_body, so they never appear in the plan output or the state's body. Rotation is detected by the provider's private-state hash; sensitive_body_version is deliberately not exposed, because its omit-unchanged-paths semantics would strip secure values from a full-resource PUT on unrelated updates.

The split between the two guard rails is evidence-based, not taste: anything the platform REJECTS is a plan-time validation (a valueless parameter, a policy with no aud claim, connections with no $connections declaration, a callback trigger that does not exist), each one confirmed against the ARM validate endpoint rather than assumed. checks only warn, and only about things that deploy cleanly and then bite later: an empty call, a definition with no trigger, a connection the definition never references, a $connections declaration with nothing wired to it, and a trigger with SAS off and no policy.

Authoring: paste it straight out of Azure

definition takes the JSON in any of the three shapes Azure gives you, so there is nothing to reshape by hand and no way to get the wrapping wrong:

Copied from Shape
Designer, Logic app code view {"definition": {...}, "parameters": {...}}
az rest, az logic workflow show, Export template {"properties": {"definition": {...}, ...}, "id": ..., "name": ...}
Another template in this shape the bare definition, {"$schema": ..., "triggers": ..., "actions": ...}

The module unwraps the outer two and deploys the definition inside; a workflow definition has no top-level definition or properties key of its own, so the unwrap is unambiguous.

A wrapper's own parameters block is ADOPTED (4.4.0). It holds the SOURCE environment's VALUES, and a portal export carries a value for every parameter its definition declares, so a paste deploys untouched rather than failing on values the module could see and chose to discard. That had been the one thing standing between "copy out of the portal" and "apply".

Precedence, lowest to highest: a defaultValue inside the definition, then whatever the wrapper carried, then the parameters input, then the generated $connections. So naming a parameter in parameters always overrides what arrived with the paste.

Two things are never adopted, each for cause:

  • $connections, because the wrapper's copy names the source environment's connection resources. This module generates the whole value from the connections input instead.
  • SecureString and SecureObject, because a secret someone typed into the designer would otherwise land in a template file, the plan output and the state's body. Secure values ride sensitive_body, so they stay an explicit input and a secure declaration carrying only a wrapper value is still rejected at plan.

A bare definition contributes nothing here: its top-level parameters is its DECLARATIONS, not values, and reading those as values would make the check below vacuous.

Anything still without a value fails the PLAN rather than the apply, because the engine rejects a valueless parameter outright (InvalidTemplate, "the value for the workflow parameter ... is not provided"), and $connections left unwired trips a check.

The portal's read-back fields survive the round trip untouched: evaluatedRecurrence on a Recurrence trigger deploys as pasted (accepted by the resource provider, proven against the ARM validate endpoint), so there is nothing to hand-strip before committing the template.

So the round trip is: build it in the designer, open the code view, paste the whole thing into templates/<name>.json.tftpl, ctrl+F the values Terraform owns into ${tokens}, plan.

Usage

module "playbooks" {
  source  = "libre-devops/logic-app-workflow/azapi"
  version = "~> 4.0"

  resource_group_id = module.rg.ids["rg-ldo-uks-prd-001"]
  location          = "uksouth"
  tags              = module.tags.tags

  diagnostics = { log_analytics_workspace_id = module.law.workspace_ids["log-ldo-uks-prd-001"] }

  shared_connections = {
    "azuresentinel" = {
      connection_id         = module.api_connection.api_connection_ids["azuresentinel"]
      managed_api_id        = module.api_connection.api_connections["azuresentinel"].managed_api_id
      managed_identity_auth = true
    }
  }

  workflows = {
    "logic-ldo-uks-prd-001" = {
      title = "HTTP - Acknowledge a dispatch and comment on the Sentinel incident"

      definition = templatefile("${path.module}/templates/incident-ack.json.tftpl", {
        comment_prefix = var.comment_prefix
      })

      parameters = {
        comment_suffix     = { type = "String", value = "(automated acknowledgement)" }
        notify_webhook_url = { type = "SecureString", value = var.notify_webhook_url }
      }

      callback_trigger_name = "manual"
    }
  }
}

Examples

  • examples/minimal - one workflow from a portal-shaped template with a single scalar token: a daily Recurrence trigger and a Compose action.
  • examples/complete - the estate shape: a real V1 Sentinel connection with managed identity auth, typed parameters including a SecureString, diagnostics, an AAD open authentication policy on the trigger, and the callback URL output. Its second workflow is the paste-in path proven end to end: an unedited portal code view export, wrapper and all, with one ctrl+F token.

Consumption workflows are free at rest, so unlike this module's Security Copilot sibling the examples are safe to apply and CI DOES live self-test them (apply then always destroy) on manual dispatch.

Developing

Local work needs PowerShell 7+ and just, because the recipes wrap the LibreDevOpsHelpers PowerShell module (the same engine the libre-devops/terraform-azure action runs in CI). Install just with brew install just, or uv tool add rust-just then uv run just <recipe>.

Run just to list recipes: just update-ldo-pwsh, just validate, just scan, just pwsh-analyze, just test, and just docs. The plan/apply/destroy recipes run an example against the remote state, and just e2e [minimal|complete] applies then always destroys one. Releasing is also just: just increment-release [patch|minor|major] bumps, tags, and publishes a GitHub release, and the Terraform Registry picks up the tag.

Security scan exceptions

This module is scanned with Trivy; HIGH and CRITICAL findings fail the build. Any waiver is a deliberate, reviewed decision, never a way to quiet a finding that should be fixed. Waivers live in .trivyignore.yaml (the machine-applied source of truth, passed to Trivy with --ignorefile) and are mirrored in the table below so the reason is auditable.

Trivy ID Resource Finding Justification
None

To add an exception: add an entry to .trivyignore.yaml (id, optional paths to scope it, and a statement recording why), then add a matching row here. Both the file and this table are reviewed in the pull request.

Reference

The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.

Requirements

Name Version
terraform >= 1.11.0, < 2.0.0
azapi >= 2.5.0, < 3.0.0

Providers

Name Version
azapi >= 2.5.0, < 3.0.0

Modules

No modules.

Resources

Name Type
azapi_resource.diagnostics resource
azapi_resource.last resource
azapi_resource.late resource
azapi_resource.this resource
azapi_resource_action.callback_url data source

Inputs

Name Description Type Default Required
api_version API version for Microsoft.Logic/workflows, without the '@' prefix. 2019-05-01 is the GA Consumption version; variablised so it is never pinned to a stale value. string "2019-05-01" no
diagnostics Module-level default diagnostics: when set, every workflow gets an allLogs diagnostic setting to this workspace (named diag-) unless it sets its own diagnostics or opts out with diagnostics_enabled = false. An object rather than a bare string so its presence stays plan-known when the workspace is created in the same apply (for_each keys must never depend on unknown values).
object({
log_analytics_workspace_id = string
})
null no
diagnostics_api_version API version for the Microsoft.Insights/diagnosticSettings extension resource, without the '@' prefix. string "2021-05-01-preview" no
location Azure region for the workflows. string n/a yes
resource_group_id Resource id of the resource group the workflows deploy into (the azapi parent_id). string n/a yes
schema_validation_enabled azapi schema validation for the resource body. Off by default: the workflow definition is free-form Workflow Definition Language that the embedded schema cannot usefully validate, and a lagging schema must never block a valid PUT. bool false no
shared_connections API connections shared by every workflow in the call (the usual estate shape: the same Sentinel/Monitor/ITSM connections on every playbook), keyed by managed API name. Merged under each workflow's own connections (same-key workflow entries win); a workflow opts out with use_shared_connections = false.
map(object({
connection_id = string
connection_name = optional(string)
managed_api_id = string
managed_identity_auth = optional(bool, false)
}))
{} no
tags Tags applied to the workflows (merged with per-workflow tags; the workflow's title always lands as the hidden-title tag). map(string) {} no
workflows Consumption Logic App workflows (Microsoft.Logic/workflows) keyed by name
(logic-ldo-uks-prd-001), each deployed WHOLE: one PUT carries the definition, the parameter
values, the generated $connections parameter and the identity. Fields:
title REQUIRED human-readable description, becomes the hidden-title tag the portal
renders as the resource subtitle ("{Trigger type} - {what it does}").
definition REQUIRED, the complete workflow definition as a JSON STRING: the portal
code-view export, normally the output of templatefile(...) with scalar tokens
for the values Terraform owns. The module deploys it verbatim (jsondecode, no
reshaping), so the authored artefact IS the deployed artefact and a portal
export diffs cleanly against the template render. Parameter DECLARATIONS
(including $connections) live in the definition itself, exactly where the
portal exports them.
THREE SHAPES ARE ACCEPTED, so anything you copy out of Azure pastes straight in:
the bare definition ({"$schema", "contentVersion", "triggers", "actions", ...}),
the portal code view, which wraps it as {"definition": {...}, "parameters": {...}},
and an ARM resource GET (az rest / az logic workflow show), which wraps it as
{"properties": {"definition": {...}, ...}, "id": ..., "name": ...}. The module
unwraps the outer two and deploys the definition inside.
A WRAPPER'S OWN parameters BLOCK IS IGNORED, deliberately: it holds the SOURCE
environment's VALUES (live connection ids, a resolved $connections block, maybe a
secret someone typed into the designer), and this module takes values from the
parameters and connections inputs so they stay environment-specific and secrets
stay out of the template. Re-supply anything you actually need: a declared
parameter left with no value and no defaultValue FAILS THE PLAN (the engine
rejects that deploy), and a $connections declaration with nothing wired to it
warns through a check (it deploys, then fails at run time).
parameters Deployment VALUES for parameters the definition declares. Values are strings
(Terraform coerces numbers and bools, so value = 25 works; Object and Array
values pass jsonencode(...), the standard's rule for all workflow JSON); the
module converts them to their real JSON types. SecureString and SecureObject
values ride the provider's sensitive_body, so they never appear in the plan
output or the state's body.
identity SystemAssigned by default; UserAssigned supported.
connections API connections for the workflow's managed connectors, keyed by the managed API
name the action bodies reference. Generates the entire $connections parameter
VALUE (connectionId, connectionName, id, and the ManagedServiceIdentity
authentication block when managed_identity_auth is true), so bodies keep saying
@parameters('$connections')['']['connectionId'] with zero hand-rolled
JSON. Consumption accepts ONLY V1 Microsoft.Web/connections; managed identity
auth needs parameterValueType "Alternative" on the connection plus this block.
connections_identity_id Names the user assigned identity (by resource id) inside every
managed identity authenticated connection's connectionProperties. REQUIRED when
the workflow runs as a user assigned identity: a bare authentication block means
SystemAssigned, and the platform rejects it
(InvalidWorkflowManagedIdentitySpecified) when no system identity exists.
callback_trigger_name When set, the module lists that trigger's callback URL (the SAS
invoke URL an action group receiver or external caller posts to) into the
callback_urls output. Must name a trigger the definition declares.
diagnostics Optional per-workflow diagnostic setting (allLogs) to a Log Analytics workspace,
named diag- unless overridden.
access_control IP restrictions for action/content/workflow_management, and for the trigger
additionally AAD open authentication policies (claims like iss/aud/appid), the
right way to let an action group or app call an HTTP trigger without shared
SAS exposure. Every policy MUST pin an aud claim (the platform rejects a policy
without one; validated here at plan time). Policies ADMIT token callers; SAS
stays valid alongside them unless trigger.sas_authentication_enabled = false
turns it off, at which point the policies are the only HTTP door.
DELIBERATE OMISSIONS from the full 2019-05-01 surface, each for cause:
integrationServiceEnvironment (the ISE is a retired Azure offering; none can
exist), openAuthenticationPolicies on the actions/contents/workflowManagement
sections (the type is shared in the spec but the platform applies OAuth policies
to trigger invocations only), and endpointsConfiguration (platform-populated
regional IP lists; read them from the workflows output).
enabled, integration_account_id Pass-throughs (enabled maps to properties.state).
deploy_tier 0 (default), 1 or 2; each tier deploys after the ones below it. ARM validates a
native Workflow dispatch action's TARGET at PUT time (NestedWorkflowNotFound,
proven live), so a workflow that invokes a sibling by id deploys a tier above
that sibling: leaves are 0, their dispatchers are 1, and a dispatcher whose
target is itself a dispatcher (a router invoking a hooked handler, proven
needed live) is 2. Sibling references are CONSTRUCTED ARM ids, never this
module's outputs (that would be a dependency cycle).
response_export_values, ignore_missing_property, ignore_null_property, ignore_casing
azapi passthroughs, defaulted for a stable diff (see deploy_tier and the
trimmed response_export_values default).
map(object({
title = string
enabled = optional(bool, true)
tags = optional(map(string))

definition = string

identity = optional(object({
type = optional(string, "SystemAssigned")
identity_ids = optional(set(string))
}), {})

parameters = optional(map(object({
type = string
value = string
})), {})

connections_identity_id = optional(string)

connections = optional(map(object({
connection_id = string
connection_name = optional(string)
managed_api_id = string
managed_identity_auth = optional(bool, false)
})), {})
use_shared_connections = optional(bool, true)

callback_trigger_name = optional(string)

diagnostics = optional(object({
log_analytics_workspace_id = string
name = optional(string)
}))
diagnostics_enabled = optional(bool, true)

access_control = optional(object({
action_allowed_ips = optional(list(string))
content_allowed_ips = optional(list(string))
workflow_management_allowed_ips = optional(list(string))
trigger = optional(object({
allowed_ips = optional(list(string), [])
open_authentication_policies = optional(map(object({
claims = map(string)
})), {})
# SAS (shared access signature) authentication on the trigger's callback URL. Default true,
# the platform default. false emits accessControl.triggers.sasAuthenticationPolicy
# {state: Disabled}: the SAS URL stops authenticating and open authentication policies
# carry the only HTTP door (native Workflow dispatch and managed-connector triggers do not
# ride SAS). The property is ACCEPTED by the resource provider on api-version 2019-05-01
# (proven against the ARM validate endpoint) although the published OpenAPI spec for that
# version does not document it.
sas_authentication_enabled = optional(bool, true)
}))
}))

integration_account_id = optional(string)

deploy_tier = optional(number, 0)

# Trimmed by default, deliberately: ["*"] echoes the whole GET response including volatile
# fields (changedTime and friends), which makes every plan show a no-op update-in-place on
# every workflow (proven live). The default exports only the stable fields the module's
# outputs read; widen it per workflow if you need more.
response_export_values = optional(list(string), ["identity", "properties.accessEndpoint", "properties.state", "properties.endpointsConfiguration"])
ignore_missing_property = optional(bool, true)
ignore_null_property = optional(bool, true)
ignore_casing = optional(bool, true)
}))
{} no

Outputs

Name Description
access_endpoints Map of workflow name to its access endpoint (from the PUT response).
callback_urls Map of workflow name to its callback_trigger_name trigger's invoke URL (the SAS URL an action group receiver or external caller posts to). Sensitive: the SAS grants invoke.
diagnostic_setting_ids Map of workflow name to its diagnostic setting resource id (only workflows with diagnostics).
identities Map of workflow name to its identity { principal_id, tenant_id } (principal_id is populated for system-assigned identities), for role assignments.
ids Map of workflow name to its resource id.
ids_zipmap Map of workflow name to a { name, id } object, for passing where both are needed together.
names The workflow names.
tags The base tags applied to the workflows.
workflows Map of workflow name to { id, name, location, state, and the raw exported properties } for anything the typed outputs do not surface (outbound IPs, endpoints configuration).

About

Consumption Logic App workflows deployed whole through azapi: the portal code-view export is the artefact, rendered by templatefile

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages