Private/Conditions/Test-DeviceFilter.ps1
function Test-DeviceFilter { <# .SYNOPSIS Tests if a device matches a Conditional Access policy device filter rule. .DESCRIPTION This function evaluates if a device meets the criteria specified in a device filter rule. It supports all operators (equals, notEquals, contains, notContains, startsWith, notStartsWith) and both include and exclude modes. It handles missing device information gracefully based on mode: - For exclude mode: If no device info, the filter passes (device not excluded) - For include mode: If no device info, the filter fails (device not included) .PARAMETER Device The device context containing device properties. .PARAMETER FilterRule The device filter rule object from the policy. .EXAMPLE Test-DeviceFilter -Device $DeviceContext -FilterRule $Policy.Conditions.Devices.DeviceFilter #> [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $false)] [AllowNull()] [object]$Device, [Parameter(Mandatory = $true)] [AllowNull()] [object]$FilterRule ) # If no filter rule specified, device is in scope if (-not $FilterRule) { Write-Verbose "No device filter rule specified, device is in scope" return @{ Matches = $true Reason = "No device filter rule specified" } } # Extract filter rule components $mode = $FilterRule.mode $rules = $FilterRule.rule # Check if this is a KQL-style rule string rather than structured rules if ($rules -is [string]) { Write-Verbose "KQL-style filter rule detected: $rules" # For now, always return true for KQL rules until we implement a KQL parser return @{ Matches = $true Reason = "KQL-style filter rule not fully evaluated yet: $rules" } } Write-Verbose "Evaluating device filter: Mode=$mode, Rules count=$(if ($rules) { $rules.Count } else { 0 })" # Handle null device context based on mode if (-not $Device) { if ($mode -eq "exclude") { Write-Verbose "No device information available, exclude filter passes by default" return @{ Matches = $true Reason = "No device information available for exclude filter" } } else { # include mode Write-Verbose "No device information available, include filter fails by default" return @{ Matches = $false Reason = "No device information available for include filter" } } } # If no rules specified, device matches based on mode if (-not $rules -or (($rules -isnot [string]) -and (($rules -is [array] -and $rules.Count -eq 0) -or ($rules -isnot [array])))) { # If mode is null or empty and rules are empty, this is essentially a non-filter, so match everything if ([string]::IsNullOrEmpty($mode)) { Write-Verbose "Empty filter with no mode specified matches everything" return @{ Matches = $true Reason = "Empty filter with no mode specified matches everything" } } # Only use mode-specific behavior when mode is explicitly set $result = ($mode -eq "include") $reason = if ($result) { "Empty include filter matches by default" } else { "Empty exclude filter matches by default (changed from previous behavior)" } Write-Verbose $reason return @{ Matches = $true # Always return true for empty filters regardless of mode Reason = $reason } } # Evaluate each rule foreach ($rule in $rules) { # Check if rule is null or not an object with properties if ($null -eq $rule -or ($rule -isnot [PSCustomObject] -and $rule -isnot [Hashtable])) { Write-Warning "Invalid rule format encountered, skipping: $rule" continue } # Safely access rule properties with null checks $operator = if ($null -ne $rule.PSObject.Properties['operator']) { $rule.operator } else { $null } $operand = if ($null -ne $rule.PSObject.Properties['operand']) { $rule.operand } else { $null } $value = if ($null -ne $rule.PSObject.Properties['value']) { $rule.value } else { $null } # Handle KQL-style rules (present as a string in the rule property instead of structured format) if ($null -eq $operand -and $null -eq $operator -and $null -eq $value) { # This might be a KQL-style rule string if ($rule -is [string] -or ($rule.PSObject.Properties['rule'] -and $rule.rule -is [string])) { $ruleString = if ($rule -is [string]) { $rule } else { $rule.rule } Write-Verbose "KQL-style filter rule detected within rules array: $ruleString" # For KQL-style rules, we need to handle more complex evaluation # Just return a match for now - in future this should be enhanced to interpret KQL return @{ Matches = $true Reason = "KQL-style device filter rule is not fully evaluated yet: $ruleString" } } } # Guard against null operands if ($null -eq $operand) { Write-Warning "Null operand in device filter rule" # Continue to next rule if there is one, otherwise this will fail the match continue } # Get the device property value # Handle nested properties with dot notation (e.g., "deviceState.isCompliant") $deviceValue = $Device foreach ($propertyPart in $operand.Split('.')) { if ($null -eq $deviceValue) { break } $deviceValue = $deviceValue.$propertyPart } Write-Verbose "Rule: Operand=$operand, Operator=$operator, Value=$value, DeviceValue=$deviceValue" # Case-insensitive evaluation for string values $match = $false switch ($operator) { "equals" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -ieq $value) } else { $match = ($deviceValue -eq $value) } } "notEquals" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -ine $value) } else { $match = ($deviceValue -ne $value) } } "contains" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -like "*$value*") } else { $match = $false } } "notContains" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -notlike "*$value*") } else { $match = $true } } "startsWith" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -like "$value*") } else { $match = $false } } "notStartsWith" { if ($deviceValue -is [string] -and $value -is [string]) { $match = ($deviceValue -notlike "$value*") } else { $match = $true } } default { Write-Warning "Unsupported operator: $operator" $match = $false } } Write-Verbose "Rule match result: $match" # Early exit based on mode if (($mode -eq "include" -and -not $match) -or ($mode -eq "exclude" -and $match)) { $reason = if ($mode -eq "include") { "Device does not match include filter rule: $operand $operator $value" } else { "Device matches exclude filter rule: $operand $operator $value" } return @{ Matches = if ($mode -eq "exclude") { $false } else { $true } Reason = $reason } } } # If we reach here, all rules have been evaluated # For include mode: Device matches all rules # For exclude mode: Device doesn't match any rule $result = ($mode -eq "include") $reason = if ($result) { "Device matches all include filter rules" } else { "Device does not match any exclude filter rules" } Write-Verbose $reason return @{ Matches = $result Reason = $reason } } function Test-DeviceStateInScope { <# .SYNOPSIS Tests if a device state is in scope for a Conditional Access policy. .DESCRIPTION This function evaluates if a device state meets the criteria specified in a policy's device state conditions. It handles device filter rules, compliance state, and other device state requirements. .PARAMETER Policy The Conditional Access policy to evaluate. .PARAMETER DeviceContext The device context containing device state information. .EXAMPLE Test-DeviceStateInScope -Policy $policy -DeviceContext $DeviceContext #> [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [object]$Policy, [Parameter(Mandatory = $true)] [AllowNull()] [object]$DeviceContext ) # If no device conditions specified, all devices are in scope if (-not $Policy.Conditions.Devices) { Write-Verbose "No device state conditions specified in policy, all devices are in scope" return @{ InScope = $true Reason = "No device state conditions specified" } } # Check device filter rule if specified if ($Policy.Conditions.Devices.DeviceFilter) { # Check if this is an empty filter with null values $isEmptyFilter = $null -eq $Policy.Conditions.Devices.DeviceFilter.mode -and $null -eq $Policy.Conditions.Devices.DeviceFilter.rule if ($isEmptyFilter) { Write-Verbose "Device filter exists but is empty (null mode and rules), considering it a match" # Skip filter evaluation for empty filters } else { $filterResult = Test-DeviceFilter -Device $DeviceContext -FilterRule $Policy.Conditions.Devices.DeviceFilter if (-not $filterResult.Matches) { return @{ InScope = $false Reason = "Device filter not matched: $($filterResult.Reason)" } } } } # If specific device states are required, check them $requiredStates = $Policy.Conditions.Devices.DeviceStates if ($requiredStates -and $requiredStates.Count -gt 0) { # Handle missing device information if (-not $DeviceContext) { return @{ InScope = $false Reason = "Device information required but not available" } } # Parse each device state requirement $matchesAny = $false foreach ($state in $requiredStates) { switch ($state) { "Compliant" { if ($DeviceContext.IsCompliant) { $matchesAny = $true break } } "DomainJoined" { if ($DeviceContext.DomainJoined) { $matchesAny = $true break } } "All" { $matchesAny = $true break } default { # Unknown state, log and continue Write-Warning "Unknown device state requirement: $state" } } # Early exit if we found a match if ($matchesAny) { break } } if (-not $matchesAny) { return @{ InScope = $false Reason = "Device does not meet any required device state conditions" } } } # If we reach here, device is in scope return @{ InScope = $true Reason = "Device meets all device state conditions" } } function Test-DevicePlatformInScope { <# .SYNOPSIS Tests if a device platform is in scope for a Conditional Access policy. .DESCRIPTION This function evaluates if a device platform meets the criteria specified in a policy's platform conditions. It handles include and exclude platform lists. .PARAMETER Policy The Conditional Access policy to evaluate. .PARAMETER DeviceContext The device context containing platform information. .EXAMPLE Test-DevicePlatformInScope -Policy $policy -DeviceContext $DeviceContext #> [CmdletBinding()] [OutputType([System.Collections.Hashtable])] param ( [Parameter(Mandatory = $true)] [object]$Policy, [Parameter(Mandatory = $true)] [AllowNull()] [object]$DeviceContext ) # Extract platform information from DeviceContext $devicePlatform = if ($DeviceContext -and $DeviceContext.Platform) { $DeviceContext.Platform } else { $null } # Check if user explicitly specified a platform $isPlatformExplicitlySpecified = $null -ne $devicePlatform # If no platform conditions specified, all platforms are in scope if (-not $Policy.Conditions.Platforms -or (-not $Policy.Conditions.Platforms.IncludePlatforms -and -not $Policy.Conditions.Platforms.ExcludePlatforms)) { Write-Verbose "No platform conditions specified in policy, all platforms are in scope" return @{ InScope = $true Reason = "No platform conditions specified" } } $includePlatforms = $Policy.Conditions.Platforms.IncludePlatforms $excludePlatforms = $Policy.Conditions.Platforms.ExcludePlatforms Write-Verbose "Testing platform scope for policy: $($Policy.DisplayName)" Write-Verbose "Device platform: $devicePlatform" Write-Verbose "Include platforms: $($includePlatforms -join ', ')" Write-Verbose "Exclude platforms: $($excludePlatforms -join ', ')" Write-Verbose "Platform explicitly specified by user: $isPlatformExplicitlySpecified" # Check if platform is excluded if ($excludePlatforms -and $excludePlatforms.Count -gt 0) { # Handle special 'all' value for excludes if (Test-SpecialValue -Collection $excludePlatforms -ValueType "AllPlatforms") { # If all platforms are excluded, no platform can match return @{ InScope = $false Reason = "All platforms excluded" } } # If platform is explicitly specified, check if it's in the exclude list if ($isPlatformExplicitlySpecified -and $excludePlatforms -contains $devicePlatform) { return @{ InScope = $false Reason = "Platform explicitly excluded: $devicePlatform" } } } # Check if platform is included if ($includePlatforms -and $includePlatforms.Count -gt 0) { # Handle special 'all' value for includes if (Test-SpecialValue -Collection $includePlatforms -ValueType "AllPlatforms") { # If all platforms are included, any platform matches return @{ InScope = $true Reason = "All platforms included" } } # If platform is explicitly specified, check if it's in the include list if ($isPlatformExplicitlySpecified) { if ($includePlatforms -contains $devicePlatform) { return @{ InScope = $true Reason = "Platform explicitly included: $devicePlatform" } } else { return @{ InScope = $false Reason = "Platform not included: $devicePlatform" } } } else { # If platform is not specified but we have includes, match Microsoft's behavior: # When user only provides UserId with no platform, assume it matches the platform condition return @{ InScope = $true Reason = "Platform condition matches when platform not specified" } } } # If no includes specified, but excludes are and we got here, platform is in scope return @{ InScope = $true Reason = "Platform not in any exclusion lists" } } |