Providers/Entra/Public/New-EntraConditionalAccessPolicy.ps1
|
function New-EntraConditionalAccessPolicy { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates the seeded Conditional Access policies, always in report-only state #> [CmdletBinding(SupportsShouldProcess)] [OutputType('EntraConditionalAccessPolicy')] param( [Parameter()] [string[]]$PolicyKey, [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection $definitions = @(Get-EntraSeedData -Name 'EntraConditionalAccessPolicies') if ($PolicyKey) { $definitions = @($definitions | Where-Object { $PolicyKey -contains $_.Key }) $missing = @($PolicyKey | Where-Object { $definitions.Key -notcontains $_ }) if ($missing) { Write-Error "No seed definition for policy key(s): $($missing -join ', ')" -ErrorAction Stop return } } $cache = @{} # Named locations are resolved once. They are referenced by key in the CSV and a policy # that silently loses its location condition is a policy that quietly means something # else, so a missing one is a warning that costs the condition rather than a silent drop. $locationDefinitions = @(Get-EntraSeedData -Name 'EntraNamedLocations') # Only the locations some policy actually names need to resolve. Requiring all of them # would make an unreferenced location's absence block policies that do not use it. $referencedLocationKeys = @( foreach ($definition in $definitions) { ($definition.IncludeLocations -split ';' | Where-Object { $_ }) ($definition.ExcludeLocations -split ';' | Where-Object { $_ }) } ) | ForEach-Object { $_.Trim() } | Sort-Object -Unique $locationsByKey = @{} $buildLocationMap = { $locationsByKey = @{} foreach ($locationDefinition in (@(Get-EntraSeededObject -Type NamedLocations -Connection $connection))) { foreach ($candidate in $locationDefinitions) { if ($locationDefinition.displayName -eq ('{0}{1}' -f $marker.Prefix, $candidate.DisplayName)) { $locationsByKey[$candidate.Key] = $locationDefinition.id } } } return $locationsByKey } $locationsByKey = & $buildLocationMap # Applications are addressed by appId, not by directory object id - Conditional Access names # the client identifier, and passing the object id yields a policy that silently matches # nothing at all. Built once here for the same reason the location map is. $applicationDefinitions = @(Get-EntraSeedData -Name 'EntraApplications') $applicationsByKey = @{} foreach ($seededApp in (@(Get-EntraSeededObject -Type Applications -Connection $connection))) { foreach ($candidate in $applicationDefinitions) { if ($seededApp.displayName -eq ('{0}{1}' -f $marker.Prefix, $candidate.DisplayName)) { $applicationsByKey[$candidate.Key] = $seededApp.appId } } } # The named locations step runs immediately before this one, and Graph does not list a # location the instant it is created. Without this retry a policy silently loses its # location condition on a full seed and keeps it on a re-run, which is the most # confusing kind of intermittent. $missingLocationKeys = @($referencedLocationKeys | Where-Object { -not $locationsByKey.ContainsKey($_) }) if ($missingLocationKeys -and -not $WhatIfPreference) { Write-Verbose ("Named location(s) $($missingLocationKeys -join ', ') are not listed yet; " + 'waiting for replication before giving up on them.') foreach ($delay in 5, 10, 15) { Start-Sleep -Seconds $delay $locationsByKey = & $buildLocationMap $missingLocationKeys = @($referencedLocationKeys | Where-Object { -not $locationsByKey.ContainsKey($_) }) if (-not $missingLocationKeys) { break } } } $strengthsByName = @{} try { foreach ($strength in (Invoke-EntraRequest -Method GET -Path '/identity/conditionalAccess/authenticationStrength/policies' -Connection $connection).value) { $strengthsByName[$strength.displayName] = $strength.id } } catch { Write-Warning "Could not read the tenant's authentication strengths: $($_.Exception.Message)" } # Entra places no uniqueness constraint on a policy's displayName, so without this a re-run # creates a second copy of every policy - and a duplicated Conditional Access policy is a # worse thing to leave behind than a duplicated group. $existingByName = @{} foreach ($policy in (Get-EntraSeededObject -Type ConditionalAccessPolicies -Connection $connection)) { $existingByName[$policy.displayName] = $policy } $created = [System.Collections.Generic.List[object]]::new() $index = 0 foreach ($definition in $definitions) { $index++ $displayName = '{0}{1}' -f $marker.Prefix, $definition.DisplayName Write-TestProgress -Activity 'Seeding Conditional Access policies' -Status $displayName ` -PercentComplete ([int](100 * $index / [Math]::Max(1, $definitions.Count))) -ShowProgress:$ShowProgress if ($existingByName.ContainsKey($displayName)) { Write-Verbose "Conditional Access policy '$displayName' already exists; reusing it" $created.Add([PSCustomObject]@{ PSTypeName = 'EntraConditionalAccessPolicy' Key = $definition.Key Id = $existingByName[$displayName].id DisplayName = $displayName State = $existingByName[$displayName].state IncludeGroups = @($definition.IncludeGroups -split ';' | Where-Object { $_ }) GrantControls = @($definition.GrantControls -split ';' | Where-Object { $_ }) Operator = $definition.GrantOperator Purpose = $definition.Purpose }) continue } $includeGroupIds = @(foreach ($key in ($definition.IncludeGroups -split ';' | Where-Object { $_ })) { $id = Resolve-EntraSeededId -Key $key.Trim() -Kind Group -Cache $cache -Connection $connection if ($id) { $id } else { Write-Warning "Policy '$($definition.Key)' includes group '$($key.Trim())', which does not exist." } }) if (-not $includeGroupIds) { # An unscoped Conditional Access policy applies to everyone in the tenant. Even # report-only, that would put a lab object into every real person's sign-in # evaluation, so a policy that resolves no groups is refused rather than created. Write-Error ("Policy '$($definition.Key)' resolved no groups to scope to, and an unscoped " + "Conditional Access policy applies tenant-wide. Refusing to create it.") -ErrorAction Continue continue } $excludeGroupIds = @(foreach ($key in ($definition.ExcludeGroups -split ';' | Where-Object { $_ })) { $id = Resolve-EntraSeededId -Key $key.Trim() -Kind Group -Cache $cache -Connection $connection if ($id) { $id } else { Write-Warning "Policy '$($definition.Key)' excludes group '$($key.Trim())', which does not exist." } }) # Named users rather than a group, because that is how a break-glass exclusion is written # in practice and it is the shape most likely to be got wrong by a script reading policy. $excludeUserIds = @(foreach ($key in ($definition.ExcludeUsers -split ';' | Where-Object { $_ })) { $id = Resolve-EntraSeededId -Key $key.Trim() -Kind User -Cache $cache -Connection $connection if ($id) { $id } else { Write-Warning "Policy '$($definition.Key)' excludes user '$($key.Trim())', which does not exist." } }) # 'All' passes through; anything else is a seed application key resolved to its appId. # Every policy used to target All, which left the eight seeded applications referenced by # nothing and made "which policies affect this app" unanswerable in the lab. $includeApplicationIds = @(foreach ($key in ($definition.IncludeApplications -split ';' | Where-Object { $_ })) { $trimmed = $key.Trim() if ($trimmed -eq 'All') { 'All' } elseif ($applicationsByKey.ContainsKey($trimmed)) { $applicationsByKey[$trimmed] } else { Write-Warning "Policy '$($definition.Key)' targets application '$trimmed', which does not exist. Skipping the policy rather than widening it to All." $null } }) if (@($includeApplicationIds | Where-Object { $_ }).Count -eq 0) { # Falling back to All here would turn a policy meant for one application into one # that touches every application in the tenant. Refused, exactly as an unscoped # policy is refused above. Write-Error ("Policy '$($definition.Key)' resolved no applications to target. Refusing to create it, " + 'because defaulting to All would widen it to the whole tenant.') -ErrorAction Continue continue } $conditions = @{ users = @{ includeGroups = $includeGroupIds excludeGroups = $excludeGroupIds excludeUsers = $excludeUserIds } applications = @{ includeApplications = @($includeApplicationIds | Where-Object { $_ }) } clientAppTypes = @($definition.ClientAppTypes -split ';' | Where-Object { $_ }) } $platforms = @($definition.IncludePlatforms -split ';' | Where-Object { $_ }) if ($platforms) { $conditions.platforms = @{ includePlatforms = $platforms; excludePlatforms = @() } } $includeLocationIds = @(foreach ($key in ($definition.IncludeLocations -split ';' | Where-Object { $_ })) { if ($locationsByKey.ContainsKey($key.Trim())) { $locationsByKey[$key.Trim()] } else { Write-Warning "Policy '$($definition.Key)' includes location '$($key.Trim())', which does not exist. The condition is weaker than intended." } }) $excludeLocationIds = @(foreach ($key in ($definition.ExcludeLocations -split ';' | Where-Object { $_ })) { if ($locationsByKey.ContainsKey($key.Trim())) { $locationsByKey[$key.Trim()] } else { Write-Warning "Policy '$($definition.Key)' excludes location '$($key.Trim())', which does not exist. The condition is weaker than intended." } }) if ($includeLocationIds -or $excludeLocationIds) { # 'All' rather than the include list when only exclusions are named: the inverted # form - everywhere except these - is the one the CSV describes, and writing the # exclusions without it would produce a policy that applies nowhere. # # The outer @() is load-bearing and cannot be dropped. Assigning the result of an # if expression unrolls a single-element array to a scalar, so this produced # "includeLocations":"All" rather than ["All"]. Graph rejects that with error 1007, # "does not match the schema of ConditionalAccessPolicy type", naming no field - # which reads exactly like a transient failure and is not one. $conditions.locations = @{ includeLocations = @(if ($includeLocationIds) { $includeLocationIds } else { 'All' }) excludeLocations = @($excludeLocationIds) } } $riskLevels = @($definition.SignInRiskLevels -split ';' | Where-Object { $_ }) if ($riskLevels) { $conditions.signInRiskLevels = $riskLevels } # Sign-in risk and user risk are different conditions and are routinely confused by # scripts that read policy, which is reason enough for the data to carry both. $userRiskLevels = @($definition.UserRiskLevels -split ';' | Where-Object { $_ }) if ($userRiskLevels) { $conditions.userRiskLevels = $userRiskLevels } # Report-only unless the row asks for disabled, and never anything else. The rule this # protects is "this module cannot create an ENFORCING policy" - report-only logs without # acting, disabled does not even log, and both are safe in a tenant somebody uses. A # disabled policy is there because real tenants have them and an inventory script must # not count one as active. Anything other than these two values is refused rather than # trusted, so a typo in the CSV cannot produce an enabled policy. $requestedState = if ($definition.PSObject.Properties.Name -contains 'State' -and $definition.State) { $definition.State.Trim() } else { 'enabledForReportingButNotEnforced' } if ($requestedState -notin @('enabledForReportingButNotEnforced', 'disabled')) { Write-Error ("Policy '$($definition.Key)' asks for state '$requestedState'. Only " + "'enabledForReportingButNotEnforced' and 'disabled' are permitted, because this module " + 'must never create an enforcing Conditional Access policy. Refusing to create it.') -ErrorAction Continue continue } $body = @{ displayName = $displayName state = $requestedState conditions = $conditions } $controls = @($definition.GrantControls -split ';' | Where-Object { $_ }) if ($controls) { $builtIn = @($controls | Where-Object { $_ -ne 'authenticationStrength' }) $grant = @{ operator = $definition.GrantOperator builtInControls = $builtIn } if ($controls -contains 'authenticationStrength') { # The seeded custom strength first, then the built-in of the same intent. The # custom one is preferred because it is editable, which is the whole point: # widening it weakens every policy referencing it with no policy changing, and # a baseline can only catch that if the policy points at an object this module # controls. The built-in is the fallback so the policy is still meaningful when # the strengths step was skipped. $strengthName = @( '{0}Phishing Resistant Lab' -f $marker.Prefix 'Phishing-resistant MFA' ) | Where-Object { $strengthsByName.ContainsKey($_) } | Select-Object -First 1 if ($strengthName) { $grant.authenticationStrength = @{ id = $strengthsByName[$strengthName] } Write-Verbose "Policy '$($definition.Key)' uses authentication strength '$strengthName'" } else { Write-Warning ("Policy '$($definition.Key)' wants an authentication strength, and neither the " + "seeded one nor the built-in exists. Falling back to a plain MFA grant.") $grant.builtInControls = @($builtIn + 'mfa') } } $body.grantControls = $grant } $sessionSpecs = @($definition.SessionControls -split ';' | Where-Object { $_ }) if ($sessionSpecs) { $session = @{} foreach ($spec in $sessionSpecs) { $parts = $spec -split ':' switch ($parts[0]) { 'signInFrequency' { $session.signInFrequency = @{ isEnabled = $true type = $parts[2] value = [int]$parts[1] } } 'persistentBrowser' { $session.persistentBrowser = @{ isEnabled = $true; mode = $parts[1] } } default { Write-Warning "Policy '$($definition.Key)' names unknown session control '$($parts[0])'." } } } if ($session.Count -gt 0) { $body.sessionControls = $session } } # Conditional Access rejects a body it dislikes with a single generic schema error # that names no field, so the body is worth having in a transcript when that happens. Write-Verbose "Policy '$($definition.Key)' body: $($body | ConvertTo-Json -Depth 20 -Compress)" if (-not $PSCmdlet.ShouldProcess($displayName, 'Create report-only Conditional Access policy')) { continue } try { # Deliberately not retried. Conditional Access reports every body it dislikes as # error 1007 - "object is null or does not match the schema" - naming no field, # which is easy to mistake for a transient. It is not: the one time this fired in # development the body was genuinely wrong, and retrying only delayed the message # by half a minute. The verbose body dump above is what actually diagnoses it. $policy = Invoke-EntraRequest -Method POST -Path '/identity/conditionalAccess/policies' -Body $body } catch { Write-Error "Failed to create Conditional Access policy '${displayName}': $($_.Exception.Message)" continue } $created.Add([PSCustomObject]@{ PSTypeName = 'EntraConditionalAccessPolicy' Key = $definition.Key Id = $policy.id DisplayName = $displayName State = $policy.state IncludeGroups = @($definition.IncludeGroups -split ';' | Where-Object { $_ }) GrantControls = $controls Operator = $definition.GrantOperator Purpose = $definition.Purpose }) Write-Verbose "Created report-only policy '$displayName' ($($policy.id))" } Write-TestProgress -Activity 'Seeding Conditional Access policies' -Completed -ShowProgress:$ShowProgress if ($PassThru) { return $created.ToArray() } } |