Skip to content

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

  1. Update infra/api/auth.generated.tf to use application-permissions module version >= 3.8.5, < 4.0.0.

    module "application_permissions" {
      source  = "app.terraform.io/SAIFCorp/application-permissions/saif"
      version = ">= 3.8.5, < 4.0.0"
    }
    
  2. Run terraform init -upgrade so Terraform loads the updated module before validating the new application and service_principal inputs.

  3. Remove depends_on = [module.saif-appservices] and pass the application identity directly:

    application = {
      id        = module.saif-appservices.application_id
      client_id = module.saif-appservices.application_client_id
    }
    service_principal = {
      object_id = module.saif-appservices.application_principal_object_id
      client_id = module.saif-appservices.application_principal_client_id
    }
    
  4. Re-plan. In most cases the replacement disappears once data.azuread_application.application resolves at plan time, and nothing needs to be destroyed. Stop here.

  5. If the replacement persists after steps 1 through 3, do not destroy anything yet. Persistence alone is not evidence that the depends_on is 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 the depends_on only helps the deferred-read case, which the plan reports as read_because_dependency_pending on a nearby data.azuread_* source together with replace_because_cannot_update on 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_each instance 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 value of each role and scope named in your own errors, and list only those. Run the same command as terraform 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_permissions derives both IDs as uuidv5("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, and azuread_service_principal_delegated_permission_grant.pre_authorized_consent all declare depends_on on azuread_application_permission_scope.scopes, and azuread_app_role_assignment.pre_authorized_app_roles on azuread_application_app_role.app_roles. azuread_app_role_assignment.role_assignments reads 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 State error. 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

Plan: 0 to add, 0 to change, 0 to destroy.

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.