Date: 2026-10-10 Issue:
New-EntraOpsServicePIMPolicy.ps1fails intermittently on newly created groups due to Graph API eventual consistency Existing Solution:Wait-EntraOpsServiceEMCondition(already used throughout ServiceEM cmdlets)
Wait-EntraOpsServiceEMCondition is a private helper (EntraOps/Private/Wait-EntraOpsServiceEMCondition.ps1) purpose-built for exactly this scenario. It polls a scriptblock with capped exponential backoff (0, 1, 3, 7, 15 ... up to MaxIntervalSeconds, default 30s) until the condition returns $true or MaxWaitSeconds (default 300s) elapses.
| Cmdlet | What It Waits For |
|---|---|
New-EntraOpsServiceEMCatalogResource.ps1 |
Group to be indexed by Entitlement Management after creation (ResourceNotFoundInOriginSystem retries) |
New-EntraOpsServiceEMCatalogResourceRole.ps1 |
Catalog role assignments to be queryable after POST |
New-EntraOpsServiceEMCatalog.ps1 |
Catalog object to be queryable after creation |
New-EntraOpsServiceEMAssignmentPolicy.ps1 |
Assignment policies to be queryable after POST |
New-EntraOpsServiceAZContainer.ps1 |
Azure resource group to be queryable after creation |
New-EntraOpsServiceEMAssignment.ps1 |
Assignment request state to reach delivered |
The PIM policy cmdlet is the only ServiceEM cmdlet that does not use this helper, and it is the only one that fails intermittently on newly created objects.
In New-EntraOpsServicePIMPolicy.ps1 (lines 197-199), there is an incorrect assumption:
# Update-MgPolicyRoleManagementPolicy updates policy rules synchronously — there is no
# eventual consistency to wait for. The PolicyId (assignment) never changes across the
# update, so a retry loop comparing PolicyIds is both unnecessary and fragile under
# transient Graph timeouts.This is wrong. The roleManagementPolicy object itself is not provisioned synchronously. When a new role-assignable group is created:
- ✅ Group object is created immediately
- ✅
roleManagementPolicyAssignmentobjects appear and are queryable - ❌ The underlying
roleManagementPolicies/{policyId}object is NOT immediately available for PATCH
The race condition manifests as:
- Slow Graph → GET returns empty → code skips PATCH with a warning → success
- Fast Graph → GET finds the assignment → PATCH hits
NotFoundon the policy object → fatal throw
This is exactly the same pattern that New-EntraOpsServiceEMCatalogResource.ps1 handles for ResourceNotFoundInOriginSystem.
File: EntraOps/Public/ServiceEM/New-EntraOpsServicePIMPolicy.ps1
Current code (lines 164-185):
try {
Write-Verbose "$logPrefix Looking up PIM Policies with Assignments"
$groupPolicyAssignment = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '$($group.Id)' and scopeType eq 'Group'" -OutputType PSObject
$groupPolicyAssignments += $groupPolicyAssignment
} catch {
Write-Warning "$logPrefix Failed to find PIM Policies with Assignments for group $($group.Id). Ensure the caller has RoleManagementPolicy.ReadWrite.Directory or RoleManagement.Read.Directory permission. Error: $_"
continue
}
$memberPolicy = $groupPolicyAssignment | Where-Object { $_.id -like "*member" }
if (-not $memberPolicy) {
Write-Warning "$logPrefix No member policy assignment found for group $($group.Id). Skipping policy update."
continue
}
try {
Write-Verbose "$logPrefix Updating PIM Policy ID: $($memberPolicy.policyId)"
Invoke-EntraOpsMsGraphQuery -Method PATCH -Uri "/v1.0/policies/roleManagementPolicies/$($memberPolicy.policyId)" -Body ($currentGroupPolicyParams | ConvertTo-Json -Depth 10) -ThrowOnFailure | Out-Null
} catch {
Write-Warning "$logPrefix Failed to update PIM Policy for group $($group.Id). Error: $_"
$policyUpdateFailures.Add("$($group.Id): $($_.Exception.Message)")
}Recommended replacement:
# Wait for the roleManagementPolicyAssignment to exist (with -DisableCache to avoid stale empty results)
$assignmentState = @{ Assignment = $null }
$assignmentReady = Wait-EntraOpsServiceEMCondition -Activity "PIM policy assignment for $($group.DisplayName)" -MaxWaitSeconds 60 -logPrefix $logPrefix -Condition {
try {
$assignmentState.Assignment = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '$($group.Id)' and scopeType eq 'Group'" -OutputType PSObject -DisableCache
$memberPol = $assignmentState.Assignment | Where-Object { $_.id -like "*member" }
$null -ne $memberPol
} catch {
$false
}
}
if (-not $assignmentReady) {
Write-Warning "$logPrefix PIM policy assignment for group $($group.Id) did not appear within 60 seconds. Skipping policy update."
continue
}
$groupPolicyAssignments += $assignmentState.Assignment
$memberPolicy = $assignmentState.Assignment | Where-Object { $_.id -like "*member" }
# Wait for the roleManagementPolicy object to be PATCHable (retry on NotFound)
$patchState = @{ Succeeded = $false }
$patchReady = Wait-EntraOpsServiceEMCondition -Activity "PIM policy update for $($group.DisplayName)" -MaxWaitSeconds 60 -logPrefix $logPrefix -Condition {
try {
Invoke-EntraOpsMsGraphQuery -Method PATCH -Uri "/v1.0/policies/roleManagementPolicies/$($memberPolicy.policyId)" -Body ($currentGroupPolicyParams | ConvertTo-Json -Depth 10) -ThrowOnFailure | Out-Null
$patchState.Succeeded = $true
$true
} catch {
if ($_.Exception.Message -like "*NotFound*") {
Write-Verbose "$logPrefix PIM policy object not yet provisioned, will retry"
$false
} else {
throw
}
}
}
if (-not $patchReady) {
Write-Warning "$logPrefix Failed to update PIM Policy for group $($group.Id) after retries."
$policyUpdateFailures.Add("$($group.Id): Policy object not provisioned or PATCHable within timeout")
}Why this works:
- The GET now uses
-DisableCache, preventing the stale-empty-result race that caused pr67.16 to "succeed" by accident - The PATCH is wrapped in
Wait-EntraOpsServiceEMCondition, which retries onNotFoundwith exponential backoff (0, 1, 3, 7, 15s) - Both waits are capped at 60 seconds, which is more than sufficient for Graph replication
- Non-
NotFounderrors still throw immediately (e.g., permission errors)
Current (lines 197-199):
# Update-MgPolicyRoleManagementPolicy updates policy rules synchronously — there is no
# eventual consistency to wait for. The PolicyId (assignment) never changes across the
# update, so a retry loop comparing PolicyIds is both unnecessary and fragile under
# transient Graph timeouts.Replace with:
# Policy assignments are queryable before the underlying policy object is provisioned.
# Wait-EntraOpsServiceEMCondition handles this eventual consistency during the process block.In New-EntraOpsServiceBootstrap.ps1, before processing PIM policies, verify the tenant has the required license:
After line 306 (where $pimForGroupsSwitches is computed):
if ($pimForGroupsSwitches.Count -gt 0) {
$hasPimLicense = $false
try {
$skus = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/subscribedSkus" -OutputType PSObject -DisableCache
$hasPimLicense = $skus.servicePlans | Where-Object {
$_.servicePlanName -eq "Entra_Identity_Governance" -and $_.provisioningStatus -eq "Success"
}
} catch {
Write-Verbose "$logPrefix Could not verify tenant licenses"
}
if (-not $hasPimLicense) {
Write-Warning "$logPrefix PIM for Groups requires Microsoft Entra ID Governance or Microsoft Entra Suite license. Microsoft Entra ID P2 alone is not sufficient."
}
}This makes the warning conditional and accurate, rather than a static string that appears even when the license exists.
Current (line 170):
Write-Warning "$logPrefix Eligible Member role not found for '$($group.DisplayName)' in the catalog (group not yet managed by PIM for Groups, or no Microsoft Entra ID Governance license). No resource role is added to the access package, run the deployment again later."Recommended:
$reason = if (-not $hasPimLicense) { "no Microsoft Entra ID Governance license" } else { "group not yet managed by PIM for Groups (eventual consistency)" }
Write-Warning "$logPrefix Eligible Member role not found for '$($group.DisplayName)' in the catalog ($reason). No resource role is added to the access package; run the deployment again later."Use these prompts when submitting review feedback or opening follow-up issues:
Issue:
New-EntraOpsServicePIMPolicy.ps1fails intermittently withResourceNotFoundwhen PATCHingroleManagementPolicies/{policyId}on newly created groups.Root cause: The cmdlet assumes policy objects are provisioned synchronously, but Graph creates the
roleManagementPolicyAssignmentbefore the underlyingroleManagementPolicyis PATCHable.Fix: Use the existing
Wait-EntraOpsServiceEMConditionhelper (already used in every other ServiceEM cmdlet) to retry the PATCH onNotFoundwith exponential backoff. Also add-DisableCacheto the GET query for assignments.Reference: See
New-EntraOpsServiceEMCatalogResource.ps1for the exact retry pattern used forResourceNotFoundInOriginSystem.
Issue: The comment in
New-EntraOpsServicePIMPolicy.ps1(lines 197-199) incorrectly states that "there is no eventual consistency to wait for."Fix: Remove or correct this comment. The policy object IS subject to eventual consistency, which is why the proposed retry mechanism is necessary.
Issue: The warning emitted when
-EnablePimForGroupsor-EnableWorkloadPlanePimForGroupsis used is a static string that appears regardless of actual tenant license status.Fix: Query
/v1.0/subscribedSkusat runtime to verify the presence ofEntra_Identity_Governancebefore emitting the warning. This prevents confusion when the license exists but the operation fails for other reasons (e.g., the race condition above).
Issue: The error message in
New-EntraOpsServiceEMAccessPackageResourceAssignment.ps1(line 170) conflates three distinct failure modes: eventual consistency, missing license, and missing catalog resource role.Fix: Disambiguate the error message so operators know whether to wait, purchase a license, or re-run the deployment.
- Run
pr67.17(-EnableWorkloadPlanePimForGroups) on a freshly created group 5+ times - Confirm zero
ResourceNotFoundfailures - Confirm the cmdlet completes within the 60-second wait window (typical Graph replication is <15s)
- Run
pr67.16(-EnablePimForGroups) to ensure the fix does not regress the admin-tier path - Verify that the GET query for assignments no longer relies on cache (use Fiddler or Graph Explorer to confirm no
If-None-Matchheaders)
Recommendations generated from direct tenant inspection, code review, and end-to-end testing on 2026-10-10.
Implemented (in
New-EntraOpsServicePIMPolicy)-DisableCacheand retried withWait-EntraOpsServiceEMCondition.Exception.Data['StatusCode'], because matching*NotFound*against the message text isn't reliable.endblock was removed, along with the fallback lookup it described. That lookup could return a policy assignment whose policy was never updated.Not implemented
/subscribedSkus): it needs an extra Graph permission. It also relies on a guessed service-plan name and adds a new way for the deployment to fail. The licensing claim needs separating as well: PIM for Groups itself versus eligible membership through access packages.Evidence
The gist doesn't include request traces or failure rates, so the specific race in Graph is still unconfirmed. The change is defensive: it is safe either way. Running
pr67.17with-Verboseon fresh groups would show whether the 404 retry is ever needed.