From 840f59414bb36a47a869b12fc30091ce501861bc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Apr 2026 22:06:20 +0200 Subject: [PATCH 1/2] fix(security/azure): switch Logic App scheduled tasks to user-assigned identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #74 introduced system-assigned managed identities on the three Logic App workflows (recommendations, ri_exchange, cleanup) and used `azurerm_logic_app_workflow.[0].identity[0].principal_id` as the principal in the Key Vault role assignments. Every Azure deploy since then fails terraform plan with: Error: Missing required argument with module.compute_container_apps[0].azurerm_role_assignment.recommendations_kv_secrets_user[0], ... 379: principal_id = azurerm_logic_app_workflow.recommendations[0].identity[0].principal_id The argument "principal_id" is required, but no definition was found. In azurerm 3.117.1, `azurerm_logic_app_workflow..identity[0].principal_id` evaluates to null at plan time when the resource is being created from scratch, and `azurerm_role_assignment.principal_id` is a required string the validator can't accept null for. Result: the deploy is permanently broken on first apply with an SA-identity-based config. Fix: replace the system-assigned identity on each Logic App with a user-assigned managed identity. UA identities are first-class resources whose `principal_id` is populated at plan time, so the role assignment resolves cleanly. The same module already uses an UA identity for the Container App itself (azurerm_user_assigned_identity.container_app), so the pattern is consistent. Changes in scheduled-tasks.tf: - Added 3 azurerm_user_assigned_identity resources, one per workflow, gated on the same enable_scheduled_tasks / enable_ri_exchange_schedule flags as their workflows. - Each azurerm_logic_app_workflow's identity block changed from `type = "SystemAssigned"` to `type = "UserAssigned"; identity_ids = [...]` pointing at its UA identity. - Each azurerm_role_assignment's principal_id reads `azurerm_user_assigned_identity.[0].principal_id` directly — no more identity[0] chain. - Each get-secret action's body JSON authentication block gains an `identity = ` field. With UA identities the Logic Apps runtime needs to know which identity to use for the KV call (with SA there was exactly one, picked by default). Verified: `terraform validate` clean against terraform/environments/azure. The same flags + secret name plumbing in compute.tf and variables.tf are unchanged. --- .../azure/container-apps/scheduled-tasks.tf | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf b/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf index cd295e59..7a4ed39f 100644 --- a/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf +++ b/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf @@ -57,6 +57,53 @@ resource "terraform_data" "scheduled_task_secret_preconditions" { } } +# ============================================== +# User-assigned managed identities for the Logic App workflows +# ============================================== +# +# We use user-assigned identities (one per workflow) instead of the +# system-assigned identity originally introduced in #74 because azurerm's +# `azurerm_logic_app_workflow` exposes the system-assigned `identity[0]. +# principal_id` attribute as null at plan time when the resource is being +# created from scratch — Terraform's role-assignment validator then fails +# with "principal_id is required, but no definition was found", blocking +# every Azure deploy. UA identities are first-class resources whose +# `principal_id` is populated at plan time, so the role assignment can +# resolve cleanly. The same module already uses an UA identity for the +# Container App itself (`azurerm_user_assigned_identity.container_app`), +# so the pattern is consistent. +# +# Each workflow gets its own UA identity rather than sharing one so the +# count gating on enable_scheduled_tasks vs enable_ri_exchange_schedule +# stays per-workflow. + +resource "azurerm_user_assigned_identity" "recommendations" { + count = var.enable_scheduled_tasks ? 1 : 0 + + name = "${var.app_name}-recommendations-uami" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags +} + +resource "azurerm_user_assigned_identity" "ri_exchange" { + count = var.enable_ri_exchange_schedule ? 1 : 0 + + name = "${var.app_name}-ri-exchange-uami" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags +} + +resource "azurerm_user_assigned_identity" "cleanup" { + count = var.enable_scheduled_tasks ? 1 : 0 + + name = "${var.app_name}-cleanup-uami" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags +} + # ============================================== # Logic App workflow for recommendations refresh # ============================================== @@ -68,10 +115,11 @@ resource "azurerm_logic_app_workflow" "recommendations" { location = var.location resource_group_name = var.resource_group_name - # System-assigned managed identity used to read the shared secret from - # Key Vault at workflow runtime. See header comment. + # User-assigned managed identity used to read the shared secret from + # Key Vault at workflow runtime. See the UA-identity rationale block above. identity { - type = "SystemAssigned" + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.recommendations[0].id] } tags = var.tags @@ -106,6 +154,12 @@ resource "azurerm_logic_app_action_custom" "recommendations_get_secret" { authentication = { type = "ManagedServiceIdentity" audience = "https://vault.azure.net" + # Pin to the UA identity attached above. With multiple UA identities + # (we attach exactly one per workflow today, but the Logic Apps + # runtime can in principle accept multiple), the action MUST specify + # which to use; with system-assigned the runtime would default to + # the SA identity, but we don't have one anymore. + identity = azurerm_user_assigned_identity.recommendations[0].id } } runAfter = {} @@ -185,7 +239,8 @@ resource "azurerm_logic_app_workflow" "ri_exchange" { resource_group_name = var.resource_group_name identity { - type = "SystemAssigned" + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.ri_exchange[0].id] } tags = var.tags @@ -216,6 +271,7 @@ resource "azurerm_logic_app_action_custom" "ri_exchange_get_secret" { authentication = { type = "ManagedServiceIdentity" audience = "https://vault.azure.net" + identity = azurerm_user_assigned_identity.ri_exchange[0].id } } runAfter = {} @@ -277,7 +333,8 @@ resource "azurerm_logic_app_workflow" "cleanup" { resource_group_name = var.resource_group_name identity { - type = "SystemAssigned" + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.cleanup[0].id] } tags = var.tags @@ -309,6 +366,7 @@ resource "azurerm_logic_app_action_custom" "cleanup_get_secret" { authentication = { type = "ManagedServiceIdentity" audience = "https://vault.azure.net" + identity = azurerm_user_assigned_identity.cleanup[0].id } } runAfter = {} @@ -376,7 +434,7 @@ resource "azurerm_role_assignment" "recommendations_kv_secrets_user" { scope = var.key_vault_id role_definition_name = "Key Vault Secrets User" - principal_id = azurerm_logic_app_workflow.recommendations[0].identity[0].principal_id + principal_id = azurerm_user_assigned_identity.recommendations[0].principal_id } resource "azurerm_role_assignment" "ri_exchange_kv_secrets_user" { @@ -384,7 +442,7 @@ resource "azurerm_role_assignment" "ri_exchange_kv_secrets_user" { scope = var.key_vault_id role_definition_name = "Key Vault Secrets User" - principal_id = azurerm_logic_app_workflow.ri_exchange[0].identity[0].principal_id + principal_id = azurerm_user_assigned_identity.ri_exchange[0].principal_id } resource "azurerm_role_assignment" "cleanup_kv_secrets_user" { @@ -392,5 +450,5 @@ resource "azurerm_role_assignment" "cleanup_kv_secrets_user" { scope = var.key_vault_id role_definition_name = "Key Vault Secrets User" - principal_id = azurerm_logic_app_workflow.cleanup[0].identity[0].principal_id + principal_id = azurerm_user_assigned_identity.cleanup[0].principal_id } From 4389faf876a0cfb848b7c537acece2e0c40b5aed Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 27 Apr 2026 23:10:13 +0200 Subject: [PATCH 2/2] docs(azure): refresh scheduled-tasks.tf comments to user-assigned identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit nits: two security/architectural overview comments still described the original system-assigned identity approach from #74: - File header comment at the top of scheduled-tasks.tf - Step 1 comment above `azurerm_logic_app_action_custom.recommendations_get_secret` Both now describe the user-assigned identity wiring this PR introduces. The other remaining system-assigned references in the file are deliberate — they explain *why* the migration was needed (the SA `principal_id` plan-time-null bug from azurerm 3.117.1) and should stay. Comment-only change. No terraform plan output difference. --- .../compute/azure/container-apps/scheduled-tasks.tf | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf b/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf index 7a4ed39f..c54b819a 100644 --- a/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf +++ b/terraform/modules/compute/azure/container-apps/scheduled-tasks.tf @@ -2,11 +2,12 @@ # This is the Azure equivalent of AWS EventBridge + Lambda or GCP Cloud Scheduler + Cloud Run # # SECURITY: The shared scheduled-task secret is NEVER interpolated into the -# workflow definition or Terraform state. Each Logic App workflow has a -# system-assigned managed identity that holds "Key Vault Secrets User" on the -# vault that stores `scheduled-task-secret`. At workflow runtime the first -# action (`get-secret`) calls the Key Vault data-plane REST API authenticated -# by the workflow's managed identity, and the call-endpoint action references +# workflow definition or Terraform state. Each Logic App workflow is attached +# to a per-workflow user-assigned managed identity (see the UA-identity +# resources defined just below) that holds "Key Vault Secrets User" on the +# vault storing `scheduled-task-secret`. At workflow runtime the first action +# (`get-secret`) calls the Key Vault data-plane REST API authenticated by that +# user-assigned identity, and the call-endpoint action references # `@body('get-secret')['value']` in the outgoing Authorization header. # # Effect: `terraform show` / `az logicapp show` only ever reveal the Key Vault @@ -138,7 +139,7 @@ resource "azurerm_logic_app_trigger_recurrence" "daily" { } # Step 1: Fetch the shared secret from Key Vault using the workflow's -# system-assigned managed identity. The secret value lives in the workflow +# user-assigned managed identity. The secret value lives in the workflow # run's transient state only — never in the workflow definition or TF state. resource "azurerm_logic_app_action_custom" "recommendations_get_secret" { count = var.enable_scheduled_tasks ? 1 : 0