Terraform apply fails: resource already exists - to be imported into the State (application_permissions)¶
One-sentence summary: the app role, permission scope, pre-authorized app, API access, or delegated permission grant already exists in Entra ID; check the plan first, because a spurious replacement of a resource Terraform already tracks needs a module fix while a genuinely untracked object needs an import.
🚨 Symptom¶
terraform apply fails with one of these errors:
Error: A resource with the ID "/applications/<app-object-id>/appRoles/<role-id>" already exists - to be imported into the State. Please see the resource documentation for "azuread_application_app_role" for more information.
Error: A resource with the ID "/applications/<app-object-id>/permissionScopes/<scope-id>" already exists - to be imported into the State. Please see the resource documentation for "azuread_application_permission_scope" for more information.
The same pattern appears for the pre-authorization, API access, and consent resource types, in any combination, whenever a workspace starts managing objects that already exist in Entra:
Error: A resource with the ID "<app-object-id>/preAuthorizedApplication/<authorized-app-id>" already exists - to be imported into the State. Please see the resource documentation for "azuread_application_pre_authorized" for more information.
Error: A resource with the ID "/applications/<app-object-id>/apiAccess/<resource-app-id>" already exists - to be imported into the State. Please see the resource documentation for "azuread_application_api_access" for more information.
Error: A resource with the ID "/oauth2PermissionGrants/<grant-id>" already exists - to be imported into the State. Please see the resource documentation for "azuread_service_principal_delegated_permission_grant" for more information.
This appears in Terraform Cloud apply logs when application_permissions tries to create a role, scope, pre-authorization, API access grant, or delegated permission grant that already exists in Entra ID.
📌 Applies to¶
| Aspect | Value |
|---|---|
| Component | saif-application-permissions Terraform module, and the permissions submodule inside the platform application module |
| Forge versions | Any version managing generated app_roles or scopes |
| Related versions | Generated auth.generated.tf older than the Forge 3.8.5 template still carries the depends_on that triggers cause 1. The platform half is fixed in iac-azure-modules 5.1.1 |
🧠 Cause¶
Two different problems produce this identical error, and their fixes are opposite. One needs the object left alone and a module change; the other needs an import. Run the Diagnose step before acting.
Cause 1: Terraform is replacing a resource it already tracks¶
The object is in state, the plan shows it being replaced, and the replacement traces back to a deferred data source read. Terraform recreates it, the "new" resource resolves to the same Entra object, and the create call collides with the object that is still there.
The replacement itself is spurious. It comes from a coarse module-level depends_on sitting above the data sources the module reads:
- Terraform cannot read a data source at plan time if a managed resource in its dependency closure has a pending change in the same plan. It defers the read to apply time and reports
read_because_dependency_pending. A changed local or variable does not trigger this, and unknown values in the data source's own configuration produce a different reason,read_because_config_unknown. - Every attribute derived from that deferred read becomes unknown at plan time. Which attribute that is depends on the module. In
application_permissionsit is the application object ID fromdata.azuread_application. In the platformapplicationmodule the application ID arrives as a direct resource reference and is never unknown; the unknowns there are the pre-authorized client lookups,data.azuread_application.pre_authorizedanddata.azuread_service_principal.pre_authorized_sp. - Those unknowns land on attributes that cannot change in place. The provider reports the change as impossible without replacement, which is what
"action_reason": "replace_because_cannot_update"records, and Terraform plans a replacement. - At apply time the data sources resolve to the same objects they always did, so the replacement recreates something identical to what already exists.
Two separate depends_on blocks caused this in practice:
| Location | Resources affected | Observed action order | Fix |
|---|---|---|---|
module "application_permissions" in generated infra/api/auth.generated.tf |
azuread_application_app_role, azuread_application_permission_scope |
["delete", "create"], destroy-then-create, destructive if the run fails |
Regenerate the file from the current template (Forge 3.8.5 and later) |
module "permissions" inside the platform application module |
azuread_application_pre_authorized, azuread_application_api_access, azuread_service_principal_delegated_permission_grant |
["create", "delete"], create-before-destroy, safe for the address that errors |
Move to a module version built on iac-azure-modules >= 5.1.1, which contains iac-azure-modules#76 |
The trigger is "something upstream has a pending change," so this is intermittent rather than constant. A workspace can apply cleanly for months and then fail the first time an unrelated upstream resource changes, such as a new random_string introduced by a platform module bump. That intermittency is the tell: nothing about the app registration itself changed.
Read the action order as a diagnostic signal, not just a caveat. It tells you which depends_on you are looking at and whether anything is at risk:
["delete", "create"]on an app role or scope points at the generatedauth.generated.tf. Terraform destroys first, so a run that errors before the create leaves the object deleted from the registration. In the run behind this article,App.Read,App.Write, and theUser.Readscope were planned this way and all three disappeared from state between serial 4 and serial 5. The destroy ran, the run errored, the create never happened.["create", "delete"]on a pre-authorization, API access, or delegated grant resource points at the platformapplicationmodule. The create runs first and collides with the object that is still there, so the run errors without touching the existing object. These are the addresses that reportalready exists, and they are safe.
action_reason was replace_because_cannot_update on all nine replacements in that run, which is the ordinary "the provider cannot change this in place" reason rather than anything exotic.
Do not treat the error list as the blast radius. That run planned six replacements in the platform module and surfaced only three errors, because the apply stopped early. Check the app registration after any failed apply, not just the plan, and check the app roles and scopes first since those are the destroy-then-create half.
Cause 2: state drift, the object is not tracked at all¶
Standard Terraform state drift: the role, scope, pre-authorization, or grant exists in Entra ID, but Terraform state has no record of the matching resource address. That can happen after a partial apply, manual creation, state loss, or an out-of-band delete and recreate. It can occur on its own, or immediately after resolving the deadlock in Terraform apply fails: Provider produced inconsistent final plan if you re-ran an apply while the object was mid-transition.
The recurring case is a workspace starting to manage an Entra object that already exists, and there is no single version boundary for it. The pre-authorization, API access, and consent resources arrived in application_permissions across four commits in late 2025, not in 3.0.12:
| Resource | Introduced |
|---|---|
azuread_application_api_access.api_permissions, azuread_application_pre_authorized.pre_authorized |
b66ce20, 2025-10-22, with the module itself |
azuread_application_api_access.pre_authorized_api_access |
f5786c1, 2025-11-03 |
azuread_service_principal_delegated_permission_grant.pre_authorized_consent |
e19e64d, 2025-11-03 |
azuread_service_principal_delegated_permission_grant.scope_permissions |
b13526a, 2025-11-07 |
3.0.12 (cc89c65, 2026-01-20) added no resource types at all. It added the configure_api_access and grant_admin_consent opt-out flags, both defaulting to true, plus documentation comments. An app that kept the defaults saw no change in what the module manages.
So the trigger is narrower than "upgrading past a release." Expect this error when a workspace manages one of these objects for the first time: an app onboarded to a module version that already declares the resource, a pre_authorized_applications entry added for a client someone had already pre-authorized by hand, or configure_api_access or grant_admin_consent flipped from false back to true. Only the addresses newly brought under management collide, so the blast radius is those addresses rather than the whole module.
🔍 Diagnose¶
Check whether Terraform already tracks the failing address:
terraform state list | Select-String "azuread_application_app_role|azuread_application_permission_scope|azuread_application_pre_authorized|azuread_application_api_access|azuread_service_principal_delegated_permission_grant"
Then route on what you find. Do not route on must be replaced alone. A tainted resource, an explicit terraform apply -replace=..., and a genuine change to an attribute that forces replacement all replace an address that is present in state, and none of them are fixed by this guide.
- Address is not in state. Cause 2. Go to Fix cause 2.
- Address is in state, the plan shows
must be replaced, and the deferred-read signature below is present. Cause 1. Go to Fix cause 1. - Address is in state and replaced, but the deferred-read signature is absent. Neither cause applies, and this guide is the wrong one. Find the real replacement trigger instead: look for
is tainted, so must be replacedin the plan output, check the run's plan options for-replace, and diff your config for an actual change to a replacement-forcing attribute. Removing thedepends_onwill not help.
Confirm the cause 1 signature from the plan rather than the apply log. In a Terraform Cloud structured plan (GET /api/v2/plans/{plan-id}/json-output), look for all of:
"action_reason": "read_because_dependency_pending"on a nearbydata.azuread_*resource.- A replacement on the failing address, meaning
"actions": ["create", "delete"]or["delete", "create"], normally with"action_reason": "replace_because_cannot_update". - No
"action_reason"ofreplace_because_taintedorreplace_by_requeston that address, either of which points at step 3 instead.
List every multi-action change in the plan, not just the addresses that errored, since the action order per address tells you which depends_on is responsible and which objects a failed run can delete.
In human-readable plan output, look for will be read during apply on a data source whose configuration is entirely static. That strongly suggests an inherited depends_on, but it is not proof. A depends_on on the data block itself, an unknown count or for_each, a precondition that depends on a pending resource, or unknown values in the provider configuration all defer a read as well. Confirm a module-level depends_on is actually present before acting on it.
✅ Fix¶
Fix cause 1: remove the coarse depends_on¶
Do not import. The object is already tracked correctly; the plan is wrong.
-
Compare your generated
infra/api/auth.generated.tfagainst the current template atsrc/templates/saif-feature-api/infra/api/auth.generated.tf. If your copy containsdepends_on = [module.saif-appservices], it predates the fix. Regenerate the file, or apply the change by hand: drop thedepends_on, raise the version constraint to>= 3.8.5, < 4.0.0, and pass the identity in 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 }Passing these as inputs creates the same ordering edges the
depends_onwas there to provide, without deferring the module's data reads.The 3.8.5 release notes are written around a different symptom of the same
depends_on,Error: Cycleon apply. For the app roles and scopes, spurious replacement and the cycle both come from the coarse module boundary, and this change clears both, so do not skip 3.8.5 because your error text does not mention a cycle. It does not cover the platform module half; see step 2. -
For the pre-authorization, API access, and delegated grant resources, the fix is iac-azure-modules#76, released in
iac-azure-modules5.1.1. That is a platform module you consume indirectly:saif-appservices(SAIFCorp/saif-apiservice/azure) sits several modules above where the change lands, so bumpingsaif-appservicesalone does not pick it up. You get the fix when the Forge-published module you call is itself built oniac-azure-modules >= 5.1.1, so check the release notes for the Forge version carrying that bump and move to it.Until your workspace is on a version that carries the fix, the interim path for this half is to re-run the apply after the upstream change has settled. The deferred read is no longer pending on the second run, so the replacement disappears and the apply succeeds. That is a workaround, not a fix; it recurs the next time something upstream changes. Once you are on a version containing #76, this step should stop being necessary, and a recurrence means the deferred read is coming from somewhere else.
-
Re-plan. The app role and scope replacements should disappear and
data.azuread_applicationshould resolve at plan time. Replacements inside the platformapplicationmodule persist until the module you call is built oniac-azure-modules >= 5.1.1. -
If a previous failed apply already destroyed app roles or scopes, the clean apply after this fix recreates them. Verify against the registration rather than assuming.
Fix cause 2: import or remove the untracked object¶
-
Import the exact ID from the error message:
terraform import 'module.application_permissions.module.permissions.azuread_application_app_role.app_roles["<value>"]' "/applications/<app-object-id>/appRoles/<role-id>" terraform import 'module.application_permissions.module.permissions.azuread_application_permission_scope.scopes["<value>"]' "/applications/<app-object-id>/permissionScopes/<scope-id>"For the pre-authorization, API access, and consent resources there are five distinct addresses, not three. The module declares two
azuread_application_api_accessresources and twoazuread_service_principal_delegated_permission_grantresources under different names, and each pair means a different thing:Resource What it represents Whose object ID the import ID carries azuread_application_api_access.api_permissionsThis API's own permissions on an upstream API such as Microsoft Graph This API azuread_service_principal_delegated_permission_grant.scope_permissionsAdmin consent for those upstream permissions Grant ID only azuread_application_pre_authorized.pre_authorizedA client app pre-authorized against this API This API azuread_application_api_access.pre_authorized_api_accessThe client app's own API access entry pointing back at this API The pre-authorized client app azuread_service_principal_delegated_permission_grant.pre_authorized_consentAdmin consent for the client app against this API Grant ID only Include the resource name in the address.
azuread_application_api_access["Microsoft Graph"]without a name is not a valid Terraform address and fails immediately withError: Invalid address. Use the ID exactly as it appears in the error, prefixed as shown:terraform import 'module.saif-appservices.module.identity.module.application.module.permissions.azuread_application_api_access.api_permissions["Microsoft Graph"]' "/applications/<this-api-app-object-id>/apiAccess/<upstream-api-client-id>" terraform import 'module.saif-appservices.module.identity.module.application.module.permissions.azuread_service_principal_delegated_permission_grant.scope_permissions["Microsoft Graph"]' "/oauth2PermissionGrants/<grant-id>" terraform import 'module.saif-appservices.module.identity.module.application.module.permissions.azuread_application_pre_authorized.pre_authorized["_saif_cli"]' "<this-api-app-object-id>/preAuthorizedApplication/<client-app-client-id>" terraform import 'module.saif-appservices.module.identity.module.application.module.permissions.azuread_application_api_access.pre_authorized_api_access["_saif_cli"]' "/applications/<client-app-object-id>/apiAccess/<this-api-client-id>" terraform import 'module.saif-appservices.module.identity.module.application.module.permissions.azuread_service_principal_delegated_permission_grant.pre_authorized_consent["_saif_cli"]' "/oauth2PermissionGrants/<grant-id>"The two object-ID placeholders are not the same app.
<this-api-app-object-id>is the registration this module manages.<client-app-object-id>is the pre-authorized client's object ID, whichpre_authorized_api_accesstakes instead. They coincide only for the_selfkey, where the client is this API itself._saif_cliis an example key; substitute your ownpre_authorized_applicationskeys.The delegated permission grant import must be prefixed with
/oauth2PermissionGrants/. The bare grant ID fails atterraform plan, harmlessly, since no state is touched. Resolve the grant IDs with:$apiSpId = az ad sp list --filter "appId eq '<this-api-client-id>'" --query "[0].id" -o tsv $msGraphSpId = az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv $clientSpId = az ad sp list --filter "appId eq '<pre-authorized-app-client-id>'" --query "[0].id" -o tsv # scope_permissions: this API consuming Microsoft Graph az rest --method GET --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$apiSpId' and resourceId eq '$msGraphSpId'" --query "[?consentType=='AllPrincipals'].id | [0]" -o tsv # pre_authorized_consent: the client app consuming this API, client and resource inverted az rest --method GET --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$clientSpId' and resourceId eq '$apiSpId'" --query "[?consentType=='AllPrincipals'].id | [0]" -o tsvThe two queries are not interchangeable.
pre_authorized_consentinverts the pair, so the first query returns nothing for it, and loosening the filter until it returns something imports the wrong grant.00000003-0000-0000-c000-000000000000is the well-known Microsoft Graph application ID, resolved to the tenant's Microsoft Graph service principal object ID. A client/resource pair can have both a per-user (Principal) grant and a tenant-wide admin-consent (AllPrincipals) grant;application_permissionsmanages theAllPrincipalsgrant, so filter onconsentTypeinstead of takingvalue[0]. Confirm which grant type matches what Terraform actually declares before importing.These five are not the whole module.
application_permissionsdeclares ten resources; the other five areazuread_application_app_role.app_roles,azuread_application_permission_scope.scopes, and threeazuread_app_role_assignmentresources (permissions,role_assignments,pre_authorized_app_roles), which drift the same way. Import whichever addresses your errors actually name instead of working from a fixed count.Import the addresses one at a time. Each
terraform importis independent and touches only the address you name, so run one command per address the error reports, in any order. There is no bulk path for these resources, and the commands above are the whole procedure. -
If the object is orphaned, remove it through the Entra admin center,
az rest, or Microsoft Graph instead of importing it.
Do not force a recreate to clear this error
Terraform apply fails: Provider produced inconsistent final plan offers a targeted destroy of the tracked app roles and scopes. That step is gated there on confirming the deferred-read signature first and on targeting exact instance keys, and it is not a remedy for this error. If Diagnose confirms cause 1, the objects are fine and only the plan is wrong, and a targeted destroy deletes working app roles and scopes to work around it. The already exists error itself comes from a create-before-destroy replacement that leaves the object intact; a targeted destroy removes it for real, so callers lose the authorization that role or scope carries until the recreate lands, and a failed apply can leave it deleted. The IDs survive that: they are deterministic uuidv5 values seeded on the application ID and the role or scope value, so a recreate against an unchanged seed recomputes the identical GUID. GUIDs churn only when the seed changes, such as when the app registration itself is recreated. Fix the depends_on instead.
🔬 Verify¶
For cause 1, the plan no longer shows must be replaced on the permission resources, and no data.azuread_* source reports will be read during apply. Confirm the app registration still has every expected app role and scope, since a prior failed apply may have destroyed some.
For cause 2, terraform state list now includes the imported address.
📚 Related¶
- Terraform apply fails: Provider produced inconsistent final plan
- saif-application-permissions README
- 3.8.5 release notes
- 3.0.12 release notes, which added the
configure_api_accessandgrant_admin_consentopt-out flags - Application permissions configuration guide