Skip to content

Commit 32f6cac

Browse files
authored
Add files via upload
1 parent 46ae72d commit 32f6cac

1 file changed

Lines changed: 265 additions & 67 deletions

File tree

Lines changed: 265 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,280 @@
1+
#requires -Version 7.0
12
<#
23
.SYNOPSIS
3-
Lists all Conditional Access policies that apply to a given user.
4+
Evaluates the user-assignment scope of Microsoft Entra Conditional Access policies.
5+
6+
.DESCRIPTION
7+
Determines whether a specified user is included in or excluded from each Conditional
8+
Access policy through All users, direct user assignment, transitive group membership,
9+
or supported directory-role assignment. The script reports policy state and matching
10+
reasons, but it does not claim that a policy will trigger for every sign-in because
11+
application, platform, location, device, risk, authentication flow, and other runtime
12+
conditions are evaluated separately by Microsoft Entra.
413
514
.PARAMETER UserPrincipalName
6-
The UPN (email) of the user to evaluate.
15+
The exact Microsoft Entra user principal name to evaluate.
16+
17+
.PARAMETER IncludeNonTargeted
18+
Includes policies that do not target the user or explicitly exclude the user. By
19+
default, only policies whose user-assignment scope targets the user are returned.
20+
21+
.PARAMETER OutputCsv
22+
Optional path for a CSV export. No CSV is written when this parameter is omitted.
23+
24+
.EXAMPLE
25+
./Get-ConditionalAccessUserTargeting.ps1 -UserPrincipalName 'alice@contoso.com'
726
827
.EXAMPLE
9-
.\List-ConditionalAccessPoliciesForUser.ps1 -UserPrincipalName "alice@contoso.com"
28+
./Get-ConditionalAccessUserTargeting.ps1 'alice@contoso.com' -IncludeNonTargeted -OutputCsv ./ca-user-scope.csv
29+
30+
.NOTES
31+
Required delegated Microsoft Graph scopes:
32+
- Policy.Read.All
33+
- Directory.Read.All
34+
35+
Reading Conditional Access policies also requires an appropriate Microsoft Entra role,
36+
such as Security Reader, Global Reader, Security Administrator, or Conditional Access
37+
Administrator.
1038
#>
1139

1240
[CmdletBinding()]
1341
param(
14-
[Parameter(Mandatory)]
15-
[string]$UserPrincipalName
42+
[Parameter(Mandatory, Position = 0)]
43+
[ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')]
44+
[string]$UserPrincipalName,
45+
46+
[Parameter()]
47+
[switch]$IncludeNonTargeted,
48+
49+
[Parameter()]
50+
[string]$OutputCsv
1651
)
1752

18-
# 1. Ensure Graph module is present
19-
if (-not (Get-Module Microsoft.Graph)) {
20-
Install-Module Microsoft.Graph -Scope CurrentUser -Force
53+
Set-StrictMode -Version Latest
54+
$ErrorActionPreference = 'Stop'
55+
56+
$requiredCommands = @(
57+
'Connect-MgGraph',
58+
'Get-MgContext',
59+
'Get-MgUser',
60+
'Get-MgUserTransitiveMemberOf',
61+
'Get-MgIdentityConditionalAccessPolicy'
62+
)
63+
64+
foreach ($commandName in $requiredCommands) {
65+
if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) {
66+
throw "Required Microsoft Graph command '$commandName' was not found. Install Microsoft.Graph, Microsoft.Graph.Users, and Microsoft.Graph.Identity.SignIns."
67+
}
68+
}
69+
70+
$requiredScopes = @('Policy.Read.All', 'Directory.Read.All')
71+
$context = Get-MgContext
72+
$mustConnect = $null -eq $context
73+
74+
if (-not $mustConnect) {
75+
$currentScopes = @($context.Scopes)
76+
foreach ($scope in $requiredScopes) {
77+
if ($scope -notin $currentScopes) {
78+
$mustConnect = $true
79+
break
80+
}
81+
}
82+
}
83+
84+
if ($mustConnect) {
85+
Write-Host 'Connecting to Microsoft Graph...' -ForegroundColor Cyan
86+
Connect-MgGraph -Scopes $requiredScopes -NoWelcome | Out-Null
87+
}
88+
89+
Write-Host "Resolving user: $UserPrincipalName" -ForegroundColor Cyan
90+
$user = Get-MgUser -UserId $UserPrincipalName -Property Id,DisplayName,UserPrincipalName,UserType -ErrorAction Stop
91+
92+
# Transitive membership is required because Conditional Access group targeting also
93+
# applies through nested groups. The returned collection can include groups and
94+
# directory roles.
95+
$memberships = @(Get-MgUserTransitiveMemberOf -UserId $user.Id -All -Property Id,DisplayName,RoleTemplateId)
96+
97+
$groupIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
98+
$roleTemplateIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
99+
100+
foreach ($membership in $memberships) {
101+
$odataType = [string]$membership.AdditionalProperties['@odata.type']
102+
103+
if ($odataType -eq '#microsoft.graph.group') {
104+
[void]$groupIds.Add([string]$membership.Id)
105+
continue
106+
}
107+
108+
if ($odataType -eq '#microsoft.graph.directoryRole') {
109+
$roleTemplateId = $null
110+
111+
if ($membership.PSObject.Properties['RoleTemplateId']) {
112+
$roleTemplateId = [string]$membership.RoleTemplateId
113+
}
114+
115+
if ([string]::IsNullOrWhiteSpace($roleTemplateId)) {
116+
$roleTemplateId = [string]$membership.AdditionalProperties['roleTemplateId']
117+
}
118+
119+
if (-not [string]::IsNullOrWhiteSpace($roleTemplateId)) {
120+
[void]$roleTemplateIds.Add($roleTemplateId)
121+
}
122+
}
123+
}
124+
125+
Write-Host "Resolved $($groupIds.Count) transitive group membership(s) and $($roleTemplateIds.Count) active directory-role template ID(s)." -ForegroundColor DarkGray
126+
127+
$policies = @(Get-MgIdentityConditionalAccessPolicy -All)
128+
$results = [System.Collections.Generic.List[object]]::new()
129+
130+
function Find-FirstMatch {
131+
param(
132+
[AllowNull()][object[]]$PolicyValues,
133+
[Parameter(Mandatory)]
134+
[AllowEmptyCollection()]
135+
[System.Collections.Generic.HashSet[string]]$UserValues
136+
)
137+
138+
foreach ($value in @($PolicyValues)) {
139+
if (-not [string]::IsNullOrWhiteSpace([string]$value) -and $UserValues.Contains([string]$value)) {
140+
return [string]$value
141+
}
142+
}
143+
144+
return $null
21145
}
22-
Import-Module Microsoft.Graph
23-
24-
# 2. Connect to Microsoft Graph
25-
Write-Verbose "Connecting to Microsoft Graph..."
26-
Connect-MgGraph -Scopes "Policy.Read.All","Directory.Read.All"
27-
28-
# 3. Retrieve user object and memberships
29-
$user = Get-MgUser -UserId $UserPrincipalName
30-
$userId = $user.Id
31-
32-
# Fetch all group and role memberships
33-
$memberOf = Get-MgUserMemberOf -UserId $userId -All
34-
$groupIds = $memberOf |
35-
Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' } |
36-
Select-Object -ExpandProperty Id
37-
$roleIds = $memberOf |
38-
Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } |
39-
Select-Object -ExpandProperty Id
40-
41-
# 4. Pull down every CA policy
42-
$policies = Get-MgIdentityConditionalAccessPolicy -All
43-
44-
# 5. Evaluate each policy’s user filter
45-
$applied = foreach ($pol in $policies) {
46-
$u = $pol.Conditions.Users
47-
48-
# Determine inclusion
49-
$included = $false
50-
if ($u.IncludeUsers -contains 'All') { $included = $true }
51-
elseif ($u.IncludeUsers -contains $userId) { $included = $true }
52-
elseif ($u.IncludeGroups -contains 'All') { $included = $true }
53-
elseif ($groupIds | Where-Object { $u.IncludeGroups -contains $_ }) { $included = $true }
54-
elseif ($u.IncludeRoles -contains 'All') { $included = $true }
55-
elseif ($roleIds | Where-Object { $u.IncludeRoles -contains $_ }) { $included = $true }
56-
57-
if (-not $included) { continue }
58-
59-
# Determine exclusion
60-
$excluded = $false
61-
if ($u.ExcludeUsers -contains 'All') { $excluded = $true }
62-
elseif ($u.ExcludeUsers -contains $userId) { $excluded = $true }
63-
elseif ($u.ExcludeGroups -contains 'All') { $excluded = $true }
64-
elseif ($groupIds | Where-Object { $u.ExcludeGroups -contains $_ }) { $excluded = $true }
65-
elseif ($u.ExcludeRoles -contains 'All') { $excluded = $true }
66-
elseif ($roleIds | Where-Object { $u.ExcludeRoles -contains $_ }) { $excluded = $true }
67-
68-
if (-not $excluded) {
69-
[PSCustomObject]@{
70-
Name = $pol.DisplayName
71-
Id = $pol.Id
72-
State = $pol.State
73-
}
74-
}
146+
147+
foreach ($policy in $policies) {
148+
$users = $policy.Conditions.Users
149+
150+
if ($null -eq $users) {
151+
$results.Add([pscustomobject]@{
152+
PolicyName = $policy.DisplayName
153+
PolicyId = $policy.Id
154+
State = $policy.State
155+
UserScopeResult = 'NotTargeted'
156+
IncludedBy = $null
157+
ExcludedBy = $null
158+
MatchedGroupId = $null
159+
MatchedRoleTemplateId = $null
160+
RuntimeConditionsRemain = $true
161+
Notes = 'Policy has no user assignment condition.'
162+
})
163+
continue
164+
}
165+
166+
$includeUsers = @($users.IncludeUsers)
167+
$excludeUsers = @($users.ExcludeUsers)
168+
$includeGroups = @($users.IncludeGroups)
169+
$excludeGroups = @($users.ExcludeGroups)
170+
$includeRoles = @($users.IncludeRoles)
171+
$excludeRoles = @($users.ExcludeRoles)
172+
173+
$matchedIncludeGroup = Find-FirstMatch -PolicyValues $includeGroups -UserValues $groupIds
174+
$matchedExcludeGroup = Find-FirstMatch -PolicyValues $excludeGroups -UserValues $groupIds
175+
$matchedIncludeRole = Find-FirstMatch -PolicyValues $includeRoles -UserValues $roleTemplateIds
176+
$matchedExcludeRole = Find-FirstMatch -PolicyValues $excludeRoles -UserValues $roleTemplateIds
177+
178+
$includedBy = $null
179+
if ($includeUsers -contains 'All') {
180+
$includedBy = 'AllUsers'
181+
}
182+
elseif ($includeUsers -contains $user.Id) {
183+
$includedBy = 'DirectUser'
184+
}
185+
elseif (-not [string]::IsNullOrWhiteSpace($matchedIncludeGroup)) {
186+
$includedBy = 'TransitiveGroup'
187+
}
188+
elseif (-not [string]::IsNullOrWhiteSpace($matchedIncludeRole)) {
189+
$includedBy = 'DirectoryRole'
190+
}
191+
192+
# Guest/external-user targeting is more complex than a simple userType check
193+
# because policies can select specific external identity types and tenants.
194+
$guestTargetingPresent = $null -ne $users.IncludeGuestsOrExternalUsers
195+
196+
$excludedBy = $null
197+
if ($excludeUsers -contains 'All') {
198+
$excludedBy = 'AllUsers'
199+
}
200+
elseif ($excludeUsers -contains $user.Id) {
201+
$excludedBy = 'DirectUser'
202+
}
203+
elseif (-not [string]::IsNullOrWhiteSpace($matchedExcludeGroup)) {
204+
$excludedBy = 'TransitiveGroup'
205+
}
206+
elseif (-not [string]::IsNullOrWhiteSpace($matchedExcludeRole)) {
207+
$excludedBy = 'DirectoryRole'
208+
}
209+
210+
$userScopeResult = if (-not [string]::IsNullOrWhiteSpace($excludedBy)) {
211+
'Excluded'
212+
}
213+
elseif (-not [string]::IsNullOrWhiteSpace($includedBy)) {
214+
'Targeted'
215+
}
216+
elseif ($guestTargetingPresent -and [string]$user.UserType -eq 'Guest') {
217+
'ReviewGuestOrExternalUserTargeting'
218+
}
219+
else {
220+
'NotTargeted'
221+
}
222+
223+
$notes = switch ($userScopeResult) {
224+
'Targeted' { 'User is within the policy user-assignment scope. Other policy conditions still determine whether a sign-in triggers the policy.' }
225+
'Excluded' { 'User matches an explicit user, group, role, or All users exclusion.' }
226+
'ReviewGuestOrExternalUserTargeting' { 'Policy contains guest/external-user targeting that requires tenant and external-user-type evaluation.' }
227+
default { 'No matching direct user, transitive group, or active directory-role inclusion was found.' }
228+
}
229+
230+
$results.Add([pscustomobject]@{
231+
PolicyName = $policy.DisplayName
232+
PolicyId = $policy.Id
233+
State = $policy.State
234+
UserScopeResult = $userScopeResult
235+
IncludedBy = $includedBy
236+
ExcludedBy = $excludedBy
237+
MatchedGroupId = if ($includedBy -eq 'TransitiveGroup') { $matchedIncludeGroup } elseif ($excludedBy -eq 'TransitiveGroup') { $matchedExcludeGroup } else { $null }
238+
MatchedRoleTemplateId = if ($includedBy -eq 'DirectoryRole') { $matchedIncludeRole } elseif ($excludedBy -eq 'DirectoryRole') { $matchedExcludeRole } else { $null }
239+
RuntimeConditionsRemain = $true
240+
Notes = $notes
241+
})
242+
}
243+
244+
$output = if ($IncludeNonTargeted) {
245+
@($results)
246+
}
247+
else {
248+
@($results | Where-Object UserScopeResult -eq 'Targeted')
249+
}
250+
251+
$output = @($output | Sort-Object State, PolicyName)
252+
253+
Write-Host ''
254+
Write-Host "User: $($user.DisplayName) <$($user.UserPrincipalName)>" -ForegroundColor Green
255+
Write-Host "Policies evaluated: $($policies.Count)"
256+
Write-Host "User-scope targeted: $(@($results | Where-Object UserScopeResult -eq 'Targeted').Count)"
257+
Write-Host "Explicitly excluded: $(@($results | Where-Object UserScopeResult -eq 'Excluded').Count)"
258+
Write-Host ''
259+
260+
if ($output.Count -gt 0) {
261+
$output |
262+
Select-Object PolicyName, State, UserScopeResult, IncludedBy, ExcludedBy, RuntimeConditionsRemain |
263+
Format-Table -AutoSize -Wrap
264+
}
265+
else {
266+
Write-Host 'No Conditional Access policies target this user through the evaluated user-assignment methods.' -ForegroundColor Yellow
267+
}
268+
269+
if (-not [string]::IsNullOrWhiteSpace($OutputCsv)) {
270+
$parentDirectory = Split-Path -Parent $OutputCsv
271+
if (-not [string]::IsNullOrWhiteSpace($parentDirectory)) {
272+
New-Item -Path $parentDirectory -ItemType Directory -Force | Out-Null
273+
}
274+
275+
$output | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding utf8
276+
Write-Host "CSV report saved to: $OutputCsv" -ForegroundColor Cyan
75277
}
76278

77-
# 6. Output results
78-
if ($applied) {
79-
$applied | Sort-Object Name | Format-Table -AutoSize
80-
} else {
81-
Write-Host "No Conditional Access policies apply to $UserPrincipalName."
82-
}
279+
Write-Host ''
280+
Write-Host 'Important: This is a user-assignment scope analysis, not a simulation of a specific sign-in. Use the Conditional Access What If tool or sign-in logs to evaluate application, device, location, risk, client type, and other runtime conditions.' -ForegroundColor Yellow

0 commit comments

Comments
 (0)