Private/Invoke-NSPConditionalAccessRunStage.ps1
|
function Invoke-NSPConditionalAccessRunStage { <# .SYNOPSIS Performs one stage of one run item. Returns a short message; throws on failure. Mutates the item. .DESCRIPTION Safety properties: - VerifyTarget re-reads the tenant immediately before writing. A policy changed since the plan (canonical hash differs from what the plan saw) stops the run. Nothing is written over a change the operator did not review. - Create stages look the target up again themselves before creating, and reuse an object an earlier interrupted attempt already made, so resuming a run never creates duplicates. - PATCH bodies state every managed field explicitly (empty lists, nulls for absent sections, clientAppTypes 'all'), so an approved removal really removes. State is never sent. - ConfirmApplied re-reads what Graph stored. The ownership record holds that hash. If it differs from the plan's desired hash, the item is marked NeedsReview with the fields. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)]$Run, [Parameter(Mandatory)]$Item, [Parameter(Mandatory)][string]$Stage, [string]$ConfigRoot ) function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key } function Add-ItemValue { param([string]$Name, $Value) $Item | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } function Test-NotFound { param($ErrorRecord) "$ErrorRecord" -match 'Request_ResourceNotFound|ResourceNotFound|NotFound|404' } function Get-LivePolicy { param([string]$Id) try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/policies/$Id" } catch { if (Test-NotFound $_) { $null } else { throw } } } function Get-PolicyHash { param($Policy) Get-NSPConditionalAccessPolicyHash -Canonical (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Policy) } function Get-Filtered { param([string]$Path, [string]$Filter, [string]$Select) @(Invoke-NSPGraphCollection -Uri "$Path`?`$filter=$([uri]::EscapeDataString($Filter))&`$select=$Select") } function ConvertTo-Hashtable { param($Value) if ($null -eq $Value) { return $null } if ($Value -is [System.Collections.IDictionary]) { $copy = [ordered]@{} foreach ($key in $Value.Keys) { $copy[[string]$key] = ConvertTo-Hashtable $Value[$key] } return $copy } if ($Value -is [System.Management.Automation.PSCustomObject]) { $copy = [ordered]@{} foreach ($p in $Value.PSObject.Properties) { $copy[$p.Name] = ConvertTo-Hashtable $p.Value } return $copy } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return , @($Value | ForEach-Object { ConvertTo-Hashtable $_ }) } $Value } function Get-WriteBody { # Every managed field explicit; for PATCH, sections the definition does not use are nulled. # List fields are assigned directly, never through `= if {...}`: a statement's output is # unrolled, so @('All') would be sent as "All" and @() as null. Graph rejects both (1007). param([switch]$ForPatch) $desired = ConvertTo-Hashtable $Item.DesiredBody $conditions = if ($desired.Contains('conditions')) { $desired['conditions'] } else { [ordered]@{} } $shape = [ordered]@{ users = @('includeUsers', 'excludeUsers', 'includeGroups', 'excludeGroups', 'includeRoles', 'excludeRoles') applications = @('includeApplications', 'excludeApplications', 'includeUserActions') platforms = @('includePlatforms', 'excludePlatforms') locations = @('includeLocations', 'excludeLocations') } $out = [ordered]@{} foreach ($section in $shape.Keys) { if ($conditions.Contains($section) -and $conditions[$section]) { $block = [ordered]@{} foreach ($key in $shape[$section]) { $block[$key] = @() if ($conditions[$section].Contains($key)) { $block[$key] = @($conditions[$section][$key]) } } $out[$section] = $block } elseif ($section -in 'users', 'applications') { $block = [ordered]@{} foreach ($key in $shape[$section]) { $block[$key] = @() } $out[$section] = $block } elseif ($ForPatch) { $out[$section] = $null } } # Guest and external user types are objects, sent as planned; a PATCH clears an unused one. $users = if ($conditions.Contains('users')) { $conditions['users'] } else { $null } foreach ($key in 'includeGuestsOrExternalUsers', 'excludeGuestsOrExternalUsers') { if ($users -and $users.Contains($key)) { $out['users'][$key] = $users[$key] } elseif ($ForPatch) { $out['users'][$key] = $null } } $out['clientAppTypes'] = @('all') if ($conditions.Contains('clientAppTypes')) { $out['clientAppTypes'] = @($conditions['clientAppTypes']) } $out['signInRiskLevels'] = @() if ($conditions.Contains('signInRiskLevels')) { $out['signInRiskLevels'] = @($conditions['signInRiskLevels']) } $out['userRiskLevels'] = @() if ($conditions.Contains('userRiskLevels')) { $out['userRiskLevels'] = @($conditions['userRiskLevels']) } # Authentication flows (device code, authentication transfer): a PATCH clears them when unused. if ($conditions.Contains('authenticationFlows')) { $out['authenticationFlows'] = $conditions['authenticationFlows'] } elseif ($ForPatch) { $out['authenticationFlows'] = $null } if ($ForPatch) { # Definitions have no device filter, so a PATCH clears it, unless the body carries one # (an undo restoring a policy's earlier state). $out['devices'] = $null if ($conditions.Contains('devices')) { $out['devices'] = $conditions['devices'] } } $body = [ordered]@{ displayName = $desired['displayName'] } if (-not $ForPatch) { $body['state'] = $desired['state'] } $body['conditions'] = $out $body['grantControls'] = if ($desired.Contains('grantControls')) { $desired['grantControls'] } else { $null } $body['sessionControls'] = if ($desired.Contains('sessionControls')) { $desired['sessionControls'] } elseif ($ForPatch) { $null } else { $null } if (-not $ForPatch -and $null -eq $body['sessionControls']) { $body.Remove('sessionControls') } if (-not $ForPatch -and $null -eq $body['grantControls']) { $body.Remove('grantControls') } $body } function Find-PolicyByName { param([string]$Name) @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value $_ 'displayName') -ceq $Name }) } $desiredName = Get-Value $Item.DesiredBody 'displayName' $desiredState = Get-Value $Item.DesiredBody 'state' $isPolicy = $Item.Kind -eq 'Policy' switch ($Stage) { 'VerifyTarget' { if ($isPolicy -and $Item.Action -eq 'Create') { if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." } $same = @(Find-PolicyByName $desiredName) if (-not $same.Count) { return 'No policy with this name exists.' } if ($same.Count -eq 1 -and (Get-PolicyHash $same[0]) -eq $Item.DesiredHash) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') return "Already present with the desired content (an earlier attempt): $($Item.ObjectId). It will be recorded, not recreated." } $detail = if ($same.Count -gt 1) { "$($same.Count) policies have that name" } else { $fields = (Compare-NSPConditionalAccessCanonicalPolicy -Live (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $same[0]) -Desired (ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Item.DesiredBody)).Differences | ForEach-Object Field "it differs in: $($fields -join ', ')" } throw "A policy named '$desiredName' already exists ($detail). Re-plan." } if ($isPolicy) { $live = Get-LivePolicy $Item.LiveObjectId if (-not $live) { throw "Policy $($Item.LiveObjectId) no longer exists. Re-plan." } $hash = Get-PolicyHash $live Add-ItemValue LiveDisplayName (Get-Value $live 'displayName') # The policy as it was before this run touched it, for New-NSPConditionalAccessUndoPlan. if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $live } if ($hash -eq $Item.LiveHash) { return 'Live policy is unchanged since the plan.' } if ($hash -eq $Item.DesiredHash -and (Get-Value $live 'displayName') -ceq $desiredName) { Add-ItemValue AlreadyApplied $true return 'Live policy already matches the definition (an earlier attempt); no update needed.' } throw "Policy '$(Get-Value $live 'displayName')' changed since the plan was made. Nothing was written. Re-plan and review the new differences." } if ($Item.Action -eq 'CreateGroup') { if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." } $same = @(Get-Filtered 'groups' "displayName eq '$($desiredName -replace "'", "''")'" 'id,displayName,onPremisesSyncEnabled') $cloud = @($same | Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') }) if ($same.Count -gt 1 -or ($same.Count -eq 1 -and -not $cloud.Count)) { throw "Group name '$desiredName' is already used ($($same.Count) match(es)). Resolve it, then re-plan." } if ($cloud.Count -eq 1) { Add-ItemValue ObjectId (Get-Value $cloud[0] 'id') return "Cloud group '$desiredName' already exists ($($Item.ObjectId)); it will be bound, not recreated." } return 'No group with this name exists.' } if ($Item.Action -eq 'CreateAuthenticationStrength') { if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." } $wanted = (@(Get-Value $Item.DesiredBody 'allowedCombinations') | Sort-Object) -join ',' $custom = @(Invoke-NSPGraphCollection -Uri 'policies/authenticationStrengthPolicies' | Where-Object { (Get-Value $_ 'policyType') -eq 'custom' }) $same = @($custom | Where-Object { ((@(Get-Value $_ 'allowedCombinations') | Sort-Object) -join ',') -eq $wanted }) if ($same.Count) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') Add-ItemValue Reused $true return "Strength '$(Get-Value $same[0] 'displayName')' already allows exactly these combinations ($($Item.ObjectId)); it will be bound, not created." } if (@($custom | Where-Object { (Get-Value $_ 'displayName') -ceq $desiredName }).Count) { throw "A different authentication strength is already named '$desiredName'. Rename or remove it, then re-plan." } return 'No matching authentication strength exists.' } if ($Item.Action -eq 'UndoDeleteNamedLocation') { # Restoring a deleted duplicate recreates a duplicate by design; only a resumed run is checked. if ($Item.ObjectId) { return "Recreated earlier in this run: $($Item.ObjectId)." } return "Will recreate '$desiredName'." } if ($Item.Action -eq 'CreateNamedLocation') { if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." } $countries = @(Get-Value $Item.DesiredBody 'countriesAndRegions') $same = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations' | Where-Object { (Get-Value $_ 'displayName') -ceq $desiredName -and ((@(Get-Value $_ 'countriesAndRegions') | Sort-Object) -join ',') -eq (($countries | Sort-Object) -join ',') }) if ($same.Count -eq 1) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') return "Named location '$desiredName' already exists ($($Item.ObjectId)); it will be bound, not recreated." } if ($same.Count -gt 1) { throw "Several named locations are called '$desiredName'. Resolve them, then re-plan." } return 'No matching named location exists.' } } 'CreatePolicy' { if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." } # Re-check here too: after an interruption, VerifyTarget may already be marked done. $same = @(Find-PolicyByName $desiredName) if ($same.Count) { if ($same.Count -eq 1 -and (Get-PolicyHash $same[0]) -eq $Item.DesiredHash) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') return "Found the policy an interrupted attempt created: $($Item.ObjectId)." } throw "A policy named '$desiredName' appeared since verification. Nothing was created. Re-plan." } $created = Invoke-NSPGraphRequest -Method POST -Uri 'identity/conditionalAccess/policies' -Body (Get-WriteBody) Add-ItemValue ObjectId (Get-Value $created 'id') return "Created $($Item.ObjectId) in state $(Get-Value $Item.DesiredBody 'state')." } 'PatchPolicy' { Add-ItemValue ObjectId $Item.LiveObjectId if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' } Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" -Body (Get-WriteBody -ForPatch) | Out-Null return "Updated $($Item.LiveObjectId) and named it '$desiredName'. State unchanged." } 'ConfirmApplied' { # Conditional Access writes take seconds to become visible: a GET right after a PATCH can # still return the old policy. Re-read with growing waits before drawing any conclusion. $delays = @(2, 4, 8, 15, 30) if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) } $attempt = 0 while ($true) { $live = Get-LivePolicy $Item.ObjectId $canonical = if ($live) { ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $live } else { $null } $hash = if ($live) { Get-NSPConditionalAccessPolicyHash -Canonical $canonical } else { $null } $settled = $live -and $hash -eq $Item.DesiredHash -and (Get-Value $live 'displayName') -ceq $desiredName if ($settled -or $attempt -ge $delays.Count) { break } Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))" Start-Sleep -Seconds $delays[$attempt] $attempt++ } if (-not $live) { throw "Policy $($Item.ObjectId) could not be read back." } Add-ItemValue AppliedHash $hash $notes = [System.Collections.Generic.List[string]]::new() if ($hash -ne $Item.DesiredHash) { $desired = ConvertTo-NSPConditionalAccessCanonicalPolicy -Policy $Item.DesiredBody $fields = (Compare-NSPConditionalAccessCanonicalPolicy -Live $canonical -Desired $desired).Differences | ForEach-Object Field $notes.Add("Graph stored different content than planned in: $($fields -join ', ').") } if ((Get-Value $live 'displayName') -cne $desiredName) { $notes.Add("Display name is '$(Get-Value $live 'displayName')'.") } if ($Item.Action -eq 'Create' -and (Get-Value $live 'state') -ne (Get-Value $Item.DesiredBody 'state')) { $notes.Add("State is $(Get-Value $live 'state'), expected $(Get-Value $Item.DesiredBody 'state').") } $waited = if ($attempt) { " after $attempt re-read(s)" } else { '' } if ($notes.Count) { if ($Item.Action -ne 'Create' -and $hash -eq $Item.LiveHash -and $Item.PSObject.Properties['LiveDisplayName'] -and (Get-Value $live 'displayName') -ceq $Item.LiveDisplayName) { $notes.Insert(0, 'The policy still reads exactly as before the update: the change may not have propagated yet, or was not applied. Re-inventory and re-plan to check.') } Add-ItemValue Result 'NeedsReview' Add-ItemValue Notes @(@($Item.Notes) + $notes) return "Applied, with differences to review$($waited): $($notes -join ' ')" } return "Read back$($waited): matches the definition." } 'VerifyState' { $live = Get-LivePolicy $Item.LiveObjectId if (-not $live) { throw "Policy $($Item.LiveObjectId) no longer exists. Re-plan." } if ((Get-PolicyHash $live) -ne $Item.LiveHash) { throw "Policy '$(Get-Value $live 'displayName')' changed since the promotion plan. Nothing was written. Re-inventory and plan again, so the new content soaks first." } $state = Get-Value $live 'state' if ($state -eq $desiredState) { Add-ItemValue AlreadyApplied $true return "Already $desiredState; no update needed." } if ($state -ne $Item.LiveState) { throw "Policy state is $state; the plan expected $($Item.LiveState). Re-plan." } return "Live policy is unchanged and $state." } 'PatchState' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' } Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" -Body @{ state = $desiredState } | Out-Null return "Set $($Item.LiveObjectId) to $desiredState." } 'ConfirmState' { # Same propagation lag as ConfirmApplied: re-read before concluding. $delays = @(2, 4, 8, 15, 30) if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) } $attempt = 0 while ($true) { $live = Get-LivePolicy $Item.LiveObjectId if (($live -and (Get-Value $live 'state') -eq $desiredState) -or $attempt -ge $delays.Count) { break } Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))" Start-Sleep -Seconds $delays[$attempt] $attempt++ } if (-not $live) { throw "Policy $($Item.LiveObjectId) could not be read back." } $notes = [System.Collections.Generic.List[string]]::new() if ((Get-Value $live 'state') -ne $desiredState) { $notes.Add("State reads $(Get-Value $live 'state'), expected $desiredState. It may not have propagated yet; re-inventory to check.") } if ((Get-PolicyHash $live) -ne $Item.LiveHash) { $notes.Add('The policy content changed during promotion. Re-plan and review.') } if ($notes.Count) { Add-ItemValue Result 'NeedsReview' Add-ItemValue Notes @(@($Item.Notes) + $notes) return "Promoted, with differences to review: $($notes -join ' ')" } return "Read back: $desiredState$(if ($attempt) { " after $attempt re-read(s)" })." } 'VerifyTrustedLocation' { if ($Item.Action -eq 'CreateTrustedLocation') { if ($Item.ObjectId) { return "Created earlier in this run: $($Item.ObjectId)." } $same = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations' | Where-Object { (Get-Value $_ 'displayName') -ceq $desiredName }) if (-not $same.Count) { return 'No named location has this name.' } if ($same.Count -eq 1 -and (Get-NSPConditionalAccessIpLocationKey -Location $same[0]) -eq $Item.DesiredHash) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') return "Already present with these ranges (an earlier attempt): $($Item.ObjectId). It will not be recreated." } throw "A named location called '$desiredName' now exists. Nothing was written. Take a new inventory and plan again." } $location = try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $location) { throw "Named location $($Item.LiveObjectId) no longer exists. Re-plan." } if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $location } $key = Get-NSPConditionalAccessIpLocationKey -Location $location if ($key -eq $Item.LiveHash) { return 'Named location is unchanged since the plan.' } if ($key -eq $Item.DesiredHash) { Add-ItemValue AlreadyApplied $true; return 'Named location already has the planned ranges; no update needed.' } throw "Named location '$(Get-Value $location 'displayName')' changed since the plan. Nothing was written. Take a new inventory and plan again." } 'PatchNamedLocation' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' } # Graph needs the type on a named-location PATCH. Invoke-NSPGraphRequest -Method PATCH -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)" -Body (ConvertTo-Hashtable $Item.DesiredBody) | Out-Null Add-ItemValue ObjectId $Item.LiveObjectId return "Updated named location '$desiredName'." } 'ConfirmTrustedLocation' { $delays = @(2, 4, 8, 15) if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) } $attempt = 0 while ($true) { $location = Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$($Item.LiveObjectId)" $key = Get-NSPConditionalAccessIpLocationKey -Location $location if ($key -eq $Item.DesiredHash -or $attempt -ge $delays.Count) { break } Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))" Start-Sleep -Seconds $delays[$attempt] $attempt++ } Add-ItemValue AppliedHash $key if ($key -ne $Item.DesiredHash) { Add-ItemValue Result 'NeedsReview' Add-ItemValue Notes @(@($Item.Notes) + "Read back as $key, expected $($Item.DesiredHash).") return 'Updated, but the read-back differs; review it in the Entra admin center.' } return "Read back: matches the plan$(if ($attempt) { " after $attempt re-read(s)" })." } 'VerifySecurityDefaults' { $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled') $current = [bool](Get-Value (Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled') if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody ([ordered]@{ isEnabled = $current }) } if ($current -eq $wanted) { Add-ItemValue AlreadyApplied $true return "Security Defaults is already $(if ($wanted) { 'on' } else { 'off' }); no update needed." } if ($wanted) { # Graph refuses to turn Security Defaults on while any policy is On; say which. $on = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value $_ 'state') -eq 'enabled' } | ForEach-Object { Get-Value $_ 'displayName' }) if ($on.Count) { throw "Security Defaults cannot be turned on while $($on.Count) Conditional Access policies are On ($($on -join ', ')). Roll them back to report-only first. Nothing was written." } } return "Security Defaults is $(if ($current) { 'on' } else { 'off' })." } 'PatchSecurityDefaults' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped: already applied.' } $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled') Invoke-NSPGraphRequest -Method PATCH -Uri 'policies/identitySecurityDefaultsEnforcementPolicy' -Body @{ isEnabled = $wanted } | Out-Null return "Turned Security Defaults $(if ($wanted) { 'on' } else { 'off' })." } 'ConfirmSecurityDefaults' { $wanted = [bool](Get-Value $Item.DesiredBody 'isEnabled') $delays = @(2, 4, 8, 15) if ($null -ne $script:ConfirmAppliedDelaySeconds) { $delays = @($script:ConfirmAppliedDelaySeconds) } $attempt = 0 while ($true) { $current = [bool](Get-Value (Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled') if ($current -eq $wanted -or $attempt -ge $delays.Count) { break } Write-NSPConditionalAccessProgress -Activity Run -Status "$($Item.ItemId): waiting $($delays[$attempt]) s for Graph to show the change (re-read $($attempt + 1) of $($delays.Count))" Start-Sleep -Seconds $delays[$attempt] $attempt++ } if ($current -ne $wanted) { # The promotions after this would be refused; stop the run here. throw "Security Defaults still reads $(if ($current) { 'on' } else { 'off' }) after the update. Check it in the Entra admin center, then resume the run." } return "Read back: Security Defaults is $(if ($wanted) { 'on' } else { 'off' })." } 'VerifySyncedGroup' { $groupId = Get-Value $Item.DesiredBody 'groupId' $group = try { Invoke-NSPGraphRequest -Uri "groups/$groupId`?`$select=id,displayName,onPremisesSyncEnabled" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $group) { throw "Group $groupId no longer exists. Re-plan." } if (-not (Get-Value $group 'onPremisesSyncEnabled')) { throw "Group '$(Get-Value $group 'displayName')' is not synced from on-premises. Re-plan." } Add-ItemValue ObjectId $groupId return "Synced group '$(Get-Value $group 'displayName')' is present." } 'VerifyUnreferenced' { $locationId = Get-Value $Item.DesiredBody 'id' $location = try { Invoke-NSPGraphRequest -Uri "identity/conditionalAccess/namedLocations/$locationId" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $location) { Add-ItemValue AlreadyApplied $true; return 'The named location is already gone.' } $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { $locations = Get-Value (Get-Value $_ 'conditions') 'locations' $locationId -in @(@(Get-Value $locations 'includeLocations') + @(Get-Value $locations 'excludeLocations')) } | ForEach-Object { Get-Value $_ 'displayName' }) if ($users.Count) { throw "Named location '$(Get-Value $location 'displayName')' is used by: $($users -join ', '). Nothing was deleted." } if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $location } return 'No policy references it.' } 'DeleteNamedLocation' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' } $locationId = Get-Value $Item.DesiredBody 'id' try { Invoke-NSPGraphRequest -Method DELETE -Uri "identity/conditionalAccess/namedLocations/$locationId" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } } return "Deleted named location $locationId." } 'VerifyRetire' { # The safety order, re-checked at run time: no other policy may still exclude the # travel group, or deleting its travel policy would leave travelers unrestricted. $travelKey = Get-Value $Item.DesiredBody 'travelKey' $entry = @(Get-NSPConditionalAccessTravelEntry -TenantProfile (Get-Content -LiteralPath $Run.ProfilePath -Raw | ConvertFrom-Json) | Where-Object Key -eq $travelKey)[0] $groupIds = if ($entry) { @($entry.Ids) } else { @() } $blocking = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value $_ 'id') -ne $Item.LiveObjectId -and @(@(Get-Value (Get-Value (Get-Value $_ 'conditions') 'users') 'excludeGroups') | Where-Object { $_ -in $groupIds }).Count } | ForEach-Object { Get-Value $_ 'displayName' }) if ($blocking.Count) { throw "The travel group is still excluded by: $($blocking -join ', '). Nothing was deleted. Update that policy first, then re-plan." } if (-not $Item.LiveObjectId) { return 'No live travel policy to delete.' } $live = Get-LivePolicy $Item.LiveObjectId if (-not $live) { Add-ItemValue AlreadyApplied $true; return 'The travel policy is already gone.' } if ((Get-PolicyHash $live) -ne $Item.LiveHash) { throw "Policy '$(Get-Value $live 'displayName')' changed since the plan. Nothing was deleted. Re-plan." } if (-not $Item.PSObject.Properties['PreviousBody']) { Add-ItemValue PreviousBody $live } return 'No other policy excludes the travel group; the travel policy is unchanged.' } 'DeletePolicy' { if (-not $Item.LiveObjectId -or ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied)) { return 'Nothing to delete.' } try { Invoke-NSPGraphRequest -Method DELETE -Uri "identity/conditionalAccess/policies/$($Item.LiveObjectId)" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } } return "Deleted $($Item.LiveObjectId)." } 'ForgetTravel' { $travelKey = Get-Value $Item.DesiredBody 'travelKey' Remove-NSPConditionalAccessTravelRecord -TenantId $Run.TenantId -ProfilePath $Run.ProfilePath -DefinitionId $Item.DefinitionId -TravelKey $travelKey -ConfigRoot $ConfigRoot -Confirm:$false if (-not $travelKey) { return "Removed the ownership record for $($Item.DefinitionId)." } Add-ItemValue Notes @(@($Item.Notes) + "Delete the travel group for '$travelKey' when it is no longer needed (on-premises too, in a hybrid tenant).") return "Removed travel entry '$travelKey' and its ownership record." } 'ForgetOwnership' { Remove-NSPConditionalAccessTravelRecord -TenantId $Run.TenantId -ProfilePath $Run.ProfilePath -DefinitionId $Item.DefinitionId -ConfigRoot $ConfigRoot -Confirm:$false return "$($Item.DefinitionId) no longer owns a policy; the next plan matches it afresh." } 'VerifyUnusedGroup' { $groupId = Get-Value $Item.DesiredBody 'id' $group = try { Invoke-NSPGraphRequest -Uri "groups/$groupId`?`$select=id,displayName" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $group) { Add-ItemValue AlreadyApplied $true; return 'The group is already gone.' } $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { $userCondition = Get-Value (Get-Value $_ 'conditions') 'users' $groupId -in @(@(Get-Value $userCondition 'includeGroups') + @(Get-Value $userCondition 'excludeGroups')) } | ForEach-Object { Get-Value $_ 'displayName' }) if ($users.Count) { throw "Group '$(Get-Value $group 'displayName')' is used by: $($users -join ', '). Undo or re-plan those first. Nothing was deleted." } return 'No policy references the group.' } 'DeleteGroup' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' } $groupId = Get-Value $Item.DesiredBody 'id' try { Invoke-NSPGraphRequest -Method DELETE -Uri "groups/$groupId" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } } return "Deleted group $groupId (it stays restorable from deleted items for 30 days)." } 'UnbindProfile' { Remove-NSPConditionalAccessProfileBinding -ProfilePath $Run.ProfilePath -Kind (Get-Value $Item.DesiredBody 'bindKind') -Key (Get-Value $Item.DesiredBody 'key') -ObjectId (Get-Value $Item.DesiredBody 'id') -Confirm:$false return "Removed $(Get-Value $Item.DesiredBody 'id') from $(Get-Value $Item.DesiredBody 'bindKind') $(Get-Value $Item.DesiredBody 'key') in the tenant profile." } 'RemoveMember' { $groupId = Get-Value $Item.DesiredBody 'groupId' $userId = Get-Value $Item.DesiredBody 'userId' try { Invoke-NSPGraphRequest -Method DELETE -Uri "groups/$groupId/members/$userId/`$ref" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } } return "Removed from $(Get-Value $Item.DesiredBody 'groupName')." } 'RemoveOnPremMember' { $groupIdentity = Get-Value $Item.DesiredBody 'groupIdentity' $userIdentity = Get-Value $Item.DesiredBody 'objectGuid' if (-not $userIdentity) { $userIdentity = Get-Value $Item.DesiredBody 'samAccountName' } if (Test-NSPConditionalAccessAdModule) { Remove-NSPConditionalAccessAdGroupMember -GroupIdentity $groupIdentity -UserIdentity $userIdentity -Confirm:$false | Out-Null return "Removed from $(Get-Value $Item.DesiredBody 'groupName') in Active Directory. The cloud follows at the next sync." } $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem" $script = Join-Path $folder "Remove-$((Get-Value $Item.DesiredBody 'groupName') -replace '[^A-Za-z0-9_-]', '_')-Members.ps1" if (-not (Test-Path -LiteralPath $script)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null @('# Generated by NSP.M365.ConditionalAccess (undo). Run on a domain controller or a host with the ActiveDirectory module.', 'Import-Module ActiveDirectory -ErrorAction Stop') | Set-Content -LiteralPath $script -Encoding UTF8 } "Remove-ADGroupMember -Identity '$($groupIdentity -replace "'", "''")' -Members '$($userIdentity -replace "'", "''")' -Confirm:`$false # $(Get-Value $Item.DesiredBody 'samAccountName')" | Add-Content -LiteralPath $script -Encoding UTF8 Add-ItemValue Result 'OperatorActionRequired' Add-ItemValue Notes @(@($Item.Notes) + "Run $script on-premises.") return "No ActiveDirectory module on this host. Added to $script for the operator to run." } 'VerifyMember' { # Re-check just before writing: the user may have been disabled or covered since the plan. $userId = Get-Value $Item.DesiredBody 'userId' $user = try { Invoke-NSPGraphRequest -Uri "users/$userId`?`$select=id,accountEnabled" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $user -or -not (Get-Value $user 'accountEnabled')) { Add-ItemValue AlreadyApplied $true; Add-ItemValue Result 'Skipped' return 'The user no longer exists or is disabled; not added.' } $groupIds = @(Get-Value $Item.DesiredBody 'coverageGroupIds') if ($groupIds.Count) { $memberOf = @(Get-Value (Invoke-NSPGraphRequest -Method POST -Uri "users/$userId/checkMemberGroups" -Body @{ groupIds = $groupIds }) 'value') if ($memberOf.Count) { Add-ItemValue AlreadyApplied $true; Add-ItemValue Result 'Skipped' return 'The user is in a coverage group now; not added.' } } return 'User is enabled and still uncovered.' } 'AddMember' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Skipped.' } $groupId = Get-Value $Item.DesiredBody 'groupId' $userId = Get-Value $Item.DesiredBody 'userId' try { Invoke-NSPGraphRequest -Method POST -Uri "groups/$groupId/members/`$ref" -Body @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$userId" } | Out-Null } catch { if ("$_" -notmatch 'already exist') { throw } return "Already a member of $(Get-Value $Item.DesiredBody 'groupName')." } Add-ItemValue Added $true return "Added to $(Get-Value $Item.DesiredBody 'groupName')." } 'AddOnPremMember' { $groupIdentity = Get-Value $Item.DesiredBody 'groupIdentity' $userIdentity = Get-Value $Item.DesiredBody 'objectGuid' if (-not $userIdentity) { $userIdentity = Get-Value $Item.DesiredBody 'samAccountName' } if (Test-NSPConditionalAccessAdModule) { $outcome = Add-NSPConditionalAccessAdGroupMember -GroupIdentity $groupIdentity -UserIdentity $userIdentity -Confirm:$false if ($outcome -eq 'Added') { Add-ItemValue Added $true } return "$(if ($outcome -eq 'AlreadyMember') { 'Already a member of' } else { 'Added to' }) $(Get-Value $Item.DesiredBody 'groupName') in Active Directory. It reaches the cloud at the next sync." } # No ActiveDirectory module here: one script per run collects every on-premises add. $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem" $script = Join-Path $folder "Add-$((Get-Value $Item.DesiredBody 'groupName') -replace '[^A-Za-z0-9_-]', '_')-Members.ps1" if (-not (Test-Path -LiteralPath $script)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null @( '# Generated by NSP.M365.ConditionalAccess. Run on a domain controller or a host with the ActiveDirectory module.' "# Each line adds one reviewed, approved user to $(Get-Value $Item.DesiredBody 'groupName'). It restricts their access once synced." 'Import-Module ActiveDirectory -ErrorAction Stop' ) | Set-Content -LiteralPath $script -Encoding UTF8 } "Add-ADGroupMember -Identity '$($groupIdentity -replace "'", "''")' -Members '$($userIdentity -replace "'", "''")' # $(Get-Value $Item.DesiredBody 'samAccountName')" | Add-Content -LiteralPath $script -Encoding UTF8 Add-ItemValue Result 'OperatorActionRequired' Add-ItemValue Notes @(@($Item.Notes) + "Run $script on-premises.") return "No ActiveDirectory module on this host. Added to $script for the operator to run." } 'RecordOwnership' { Write-NSPConditionalAccessOwnership -TenantId $Run.TenantId -DefinitionId $Item.DefinitionId -ObjectId $Item.ObjectId ` -AppliedHash $Item.AppliedHash -AppliedVersion $Item.DefinitionVersion -RunId $Run.RunId -ConfigRoot $ConfigRoot return "Recorded: $($Item.DefinitionId) owns $($Item.ObjectId)." } 'CreateGroup' { if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." } $same = @(Get-Filtered 'groups' "displayName eq '$($desiredName -replace "'", "''")'" 'id,displayName,onPremisesSyncEnabled' | Where-Object { -not (Get-Value $_ 'onPremisesSyncEnabled') }) if ($same.Count -eq 1) { Add-ItemValue ObjectId (Get-Value $same[0] 'id') return "Found the group an interrupted attempt created: $($Item.ObjectId)." } $created = Invoke-NSPGraphRequest -Method POST -Uri 'groups' -Body (ConvertTo-Hashtable $Item.DesiredBody) Add-ItemValue ObjectId (Get-Value $created 'id') return "Created cloud group '$desiredName' ($($Item.ObjectId))." } 'CreateNamedLocation' { if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." } $created = Invoke-NSPGraphRequest -Method POST -Uri 'identity/conditionalAccess/namedLocations' -Body (ConvertTo-Hashtable $Item.DesiredBody) Add-ItemValue ObjectId (Get-Value $created 'id') return "Created named location '$desiredName' ($($Item.ObjectId))." } 'CreateAuthenticationStrength' { if ($Item.ObjectId) { return "Not created again; using $($Item.ObjectId)." } $created = Invoke-NSPGraphRequest -Method POST -Uri 'policies/authenticationStrengthPolicies' -Body ([ordered]@{ displayName = $desiredName description = Get-Value $Item.DesiredBody 'description' allowedCombinations = @(Get-Value $Item.DesiredBody 'allowedCombinations') }) Add-ItemValue ObjectId (Get-Value $created 'id') return "Created authentication strength '$desiredName' ($($Item.ObjectId))." } 'VerifyStrength' { $strengthId = Get-Value $Item.DesiredBody 'strengthId' $strength = try { Invoke-NSPGraphRequest -Uri "policies/authenticationStrengthPolicies/$strengthId" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $strength) { throw "Authentication strength $strengthId no longer exists. Re-plan." } $wanted = (@(Get-Value $Item.DesiredBody 'allowedCombinations') | Sort-Object) -join ',' if (((@(Get-Value $strength 'allowedCombinations') | Sort-Object) -join ',') -ne $wanted) { throw "Authentication strength '$(Get-Value $strength 'displayName')' no longer allows exactly $wanted. Nothing was bound. Re-plan." } Add-ItemValue ObjectId $strengthId return "Authentication strength '$(Get-Value $strength 'displayName')' is present and unchanged." } 'VerifyUnusedStrength' { $strengthId = Get-Value $Item.DesiredBody 'id' $strength = try { Invoke-NSPGraphRequest -Uri "policies/authenticationStrengthPolicies/$strengthId" } catch { if (Test-NotFound $_) { $null } else { throw } } if (-not $strength) { Add-ItemValue AlreadyApplied $true; return 'The authentication strength is already gone.' } $users = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies' | Where-Object { (Get-Value (Get-Value (Get-Value $_ 'grantControls') 'authenticationStrength') 'id') -eq $strengthId } | ForEach-Object { Get-Value $_ 'displayName' }) if ($users.Count) { throw "Authentication strength '$(Get-Value $strength 'displayName')' is used by: $($users -join ', '). Undo or re-plan those first. Nothing was deleted." } return 'No policy uses the authentication strength.' } 'DeleteAuthenticationStrength' { if ($Item.PSObject.Properties['AlreadyApplied'] -and $Item.AlreadyApplied) { return 'Nothing to delete.' } $strengthId = Get-Value $Item.DesiredBody 'id' try { Invoke-NSPGraphRequest -Method DELETE -Uri "policies/authenticationStrengthPolicies/$strengthId" | Out-Null } catch { if (-not (Test-NotFound $_)) { throw } } return "Deleted authentication strength $strengthId." } 'BindProfile' { $kind, $key = $Item.ItemId -split ':', 2 $bindKind = switch ($kind) { 'group' { 'Group' } 'travelgroup' { 'TravelGroup' } 'strength' { 'Strength' } default { 'Country' } } if (Get-Value $Item.DesiredBody 'bindKind') { $bindKind = Get-Value $Item.DesiredBody 'bindKind'; $key = Get-Value $Item.DesiredBody 'key' } $label = if ($desiredName) { $desiredName } else { Get-Value $Item.DesiredBody 'label' } Write-NSPConditionalAccessProfileBinding -ProfilePath $Run.ProfilePath -Kind $bindKind -Key $key -ObjectId $Item.ObjectId -Label $label return "Bound $($Item.ObjectId) to $bindKind $key in the tenant profile. Re-plan to use it." } 'WriteOnPremScript' { $name = Get-Value $Item.DesiredBody 'name' $folder = Join-Path (Split-Path -Parent $Run.Path) "$($Run.RunId)-onprem" New-Item -ItemType Directory -Path $folder -Force | Out-Null $script = Join-Path $folder "New-$($name -replace '[^A-Za-z0-9-]', '').ps1" Get-NSPConditionalAccessOnPremGroupScript -Name $name | Set-Content -LiteralPath $script -Encoding UTF8 Add-ItemValue Result 'OperatorActionRequired' Add-ItemValue Notes @(@($Item.Notes) + "Run $script on a domain controller (right-click > Run with PowerShell; it asks for the OU), then sync and re-plan.") return "Wrote $script. Operator action required." } default { throw "Unknown stage '$Stage'." } } } |