Terraform apply fails: Provider produced inconsistent final plan (application_permissions)¶
One-sentence summary: a coarse depends_on = [module.saif-appservices] in auth.generated.tf defers the application_permissions lookup to apply time, so deterministic app role and scope IDs become unknown at plan time and Terraform hard-errors when the provider later recomputes the same IDs.
🚨 Symptom¶
terraform apply fails with:
Error: Provider produced inconsistent final plan
When expanding the plan for
module.application_permissions.module.permissions.azuread_application_app_role.app_roles["<value>"]
to include new values learned so far during apply, provider
"registry.terraform.io/hashicorp/azuread" changed the planned action from
DeleteThenCreate to NoOp.
The plan usually also shows:
# module.application_permissions.data.azuread_application.application will be read during apply
# (depends on a resource or a module with changes pending)
This appears in Terraform Cloud plan and apply logs for apps whose config.yml defines non-empty app_roles or scopes.
📌 Applies to¶
| Aspect | Value |
|---|---|
| Component | saif-application-permissions Terraform module, infra/api/auth.generated.tf |
| Forge versions | 3.0.11 through 3.8.4 |
| Related versions | application-permissions/saif module < 3.8.5. The platform half is fixed in iac-azure-modules 5.1.1 |
The versions above cover the app roles and scopes half only. The same depends_on pattern in the platform application module versions independently of Forge, no Forge 3.8.x release fixes it, and it is fixed upstream in iac-azure-modules 5.1.1; see the second paragraph under Cause.
🧠 Cause¶
Forge 3.0.11 added a coarse depends_on = [module.saif-appservices] so application_permissions would wait for the app to exist first. That defers the module's data.azuread_application lookup to apply time whenever saif-appservices has any pending change, which leaves the application object ID, and therefore the deterministic uuidv5-derived app-role and scope IDs, unknown at plan time. Terraform plans a replace, then the AzureAD provider recomputes the same IDs during apply and flips the action from DeleteThenCreate to NoOp, which Terraform core rejects as an inconsistent final plan.
The same depends_on pattern exists a second time, inside the platform application module's permissions submodule, and affects the pre-authorization and delegated-grant resources instead of roles and scopes. Those surface as resource already exists - to be imported into the State rather than an inconsistent final plan, because they are planned create-before-destroy, so the provider errors on the colliding create call before Terraform core ever gets to compare plans. Same root cause, different error text. See Terraform apply fails: resource already exists - to be imported into the State.
The same depends_on produces a third symptom, Error: Cycle, when resources in both module trees are being replaced in the same apply and Terraform cannot linearize the graph across the coarse module boundary. That is the symptom the 3.8.5 release notes are written around.
All three symptoms share this root cause, but the fix below covers only half of it. It removes the depends_on in the generated auth.generated.tf, so it clears the inconsistent final plan on app roles and scopes, and it clears Error: Cycle, because the coarse module.saif-appservices edge is the one Terraform could not linearize across. Replacements can still be planned inside the platform application module afterwards, but they no longer cross that boundary.
The fix does not touch the second depends_on inside the platform application module, so resource already exists on the pre-authorization, API access, and delegated-grant resources survives it. That half is fixed separately by iac-azure-modules#76, released in iac-azure-modules 5.1.1. You consume it indirectly: saif-appservices sits several modules above where the change lands, so bumping saif-appservices alone does not pick it up. You get the fix when the Forge-published module you call is itself built on iac-azure-modules >= 5.1.1. Re-running the apply remains the only interim step for a workspace still on an older pin. Route that symptom through Diagnose in the sibling article first, since already exists on an app role or scope is usually plain state drift rather than this depends_on at all.
✅ Fix¶
-
Update
infra/api/auth.generated.tfto useapplication-permissionsmodule version>= 3.8.5, < 4.0.0. -
Run
terraform init -upgradeso Terraform loads the updated module before validating the newapplicationandservice_principalinputs. -
Remove
depends_on = [module.saif-appservices]and pass the application identity directly: -
Re-plan. In most cases the replacement disappears once
data.azuread_application.applicationresolves at plan time, and nothing needs to be destroyed. Stop here. -
If the replacement persists after steps 1 through 3, do not destroy anything yet. Persistence alone is not evidence that the
depends_onis still to blame. A tainted resource, an explicit-replace, and a genuine change to a replacement-forcing attribute all survive the module fix and all keep planning a replace. Identify the remaining trigger with the three-way check in Diagnose. Removing thedepends_ononly helps the deferred-read case, which the plan reports asread_because_dependency_pendingon a nearbydata.azuread_*source together withreplace_because_cannot_updateon the failing address.Only when that check confirms the deferred-read signature, the affected addresses are still in Terraform state, and the replacement still will not clear, destroy the confirmed-affected instances once, then apply again. Target exact instance keys. An address without a key selects every
for_eachinstance of that resource, because Terraform reads a resource-level address as all instances currently associated with the resource:terraform destroy -auto-approve '-target=module.application_permissions.module.permissions.azuread_application_app_role.app_roles["App.Read"]' '-target=module.application_permissions.module.permissions.azuread_application_permission_scope.scopes["User.Read"]'Substitute the
valueof each role and scope named in your own errors, and list only those. Run the same command asterraform plan -destroy -target=...first and read the full change list before approving, because targeting in destroy mode also pulls in the objects that depend on the target.This deletes live app roles and scopes
These addresses are planned destroy-then-create, so they are already the ones a failed run can leave deleted. The destroy takes the role or scope off the registration immediately, and callers that rely on it lose authorization until the follow-up apply puts it back. If that apply fails partway, they stay deleted.
The GUIDs do not churn.
application_permissionsderives both IDs asuuidv5("url", "https://graph.microsoft.com/${var.application_id}/${value}"), seeded on the application ID and the role or scope value, so recreating one against the same registration with the same value recomputes the identical GUID. The seed changes only if the value changes or the app registration itself is recreated. The GUID churn described in the 3.8.5 release notes came from an ID-format bug that would have changed that seed, not from recreation.Check what comes down with it.
azuread_application_pre_authorized.pre_authorized,azuread_application_api_access.pre_authorized_api_access, andazuread_service_principal_delegated_permission_grant.pre_authorized_consentall declaredepends_ononazuread_application_permission_scope.scopes, andazuread_app_role_assignment.pre_authorized_app_rolesonazuread_application_app_role.app_roles.azuread_app_role_assignment.role_assignmentsreads the role ID out of the module's local map rather than from the role resource, so Terraform leaves it in place, but it points at a role that is missing from the registration until the apply recreates it. After the apply, confirm the registration lists every expected role and scope and that pre-authorizations and role assignments are intact.Do not use this to clear a
resource already exists - to be imported into the Stateerror. There the plan is wrong and the objects are fine, so destroying them trades working configuration for a bad plan. See Terraform apply fails: resource already exists - to be imported into the State.If the objects exist in Azure but are not in state, follow Terraform apply fails: resource already exists - to be imported into the State instead of using
destroy -target.
🔬 Verify¶
Two consecutive applies succeed without app_roles or scopes planning a replace, and the plan no longer says data.azuread_application.application will be read during apply.