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.
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.
runAfteris the only ordering; there is no seconddepends_ongraph acrossazurerm_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'sparameterValueType = "Alternative"PLUS theconnectionProperties.authenticationblock 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$connectionsdeclarations and unknown callback triggers all fail the plan, not the run). - Sibling dispatch is ordered. ARM validates a native
Workflowdispatch 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 withdeploy_tier = 1, and one whose target is itself a dispatcher (a router invoking a hooked handler, proven needed live) withdeploy_tier = 2; each tier deploys after the ones below it, in the same module call. - SAS can be turned off.
trigger.sas_authentication_enabled = falseemitssasAuthenticationPolicy: 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; acheckflags SAS-off with no policy, since that trigger accepts no HTTP caller at all. - Plans stay quiet. The default
response_export_valuesis a trimmed stable set:["*"]echoes the whole GET response including volatile fields (changedTimeand 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/SecureObjectparameter values ride the provider's write-onlysensitive_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_versionis 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.
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 theconnectionsinput instead.SecureStringandSecureObject, because a secret someone typed into the designer would otherwise land in a template file, the plan output and the state'sbody. Secure values ridesensitive_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.
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/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.
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.
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.
The Requirements, Providers, Inputs, Outputs, and Resources below are generated by terraform-docs.
| Name | Version |
|---|---|
| terraform | >= 1.11.0, < 2.0.0 |
| azapi | >= 2.5.0, < 3.0.0 |
| Name | Version |
|---|---|
| azapi | >= 2.5.0, < 3.0.0 |
No modules.
| 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 |
| 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({ |
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({ |
{} |
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({ |
{} |
no |
| 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). |