Private/Test-EGILeafExpression.ps1
|
# Compiled-leaf cache: leaf expressions are identical for every user in a run, # so each unique expression is parsed once and the compiled form is reused. $script:EGILeafCache = @{} function Get-EGIUserProperty { <# .SYNOPSIS Case-insensitive property lookup on a user object ([pscustomobject] or hashtable). #> [CmdletBinding()] param( [AllowNull()] $Obj, [Parameter(Mandatory)] [string] $Name ) if ($null -eq $Obj) { return $null } if ($Obj -is [System.Collections.IDictionary]) { foreach ($k in $Obj.Keys) { if ($k -ieq $Name) { return $Obj[$k] } } return $null } $p = $Obj.PSObject.Properties | Where-Object { $_.Name -ieq $Name } | Select-Object -First 1 if ($p) { return $p.Value } else { return $null } } function ConvertFrom-EGILiteral { <# .SYNOPSIS Converts a rule literal ('"Sales"', 'true', '["A", "B"]', '42') to a .NET value. #> [CmdletBinding()] param([Parameter(Mandatory)] [string] $Text) $t = $Text.Trim() if ($t -ieq 'null') { return $null } if ($t -ieq 'true') { return $true } if ($t -ieq 'false') { return $false } if ($t -match '^\[(.*)\]$') { $inner = $Matches[1] # Split on commas, but keep quoted values intact so "R, D" stays one item. $values = foreach ($m in [regex]::Matches($inner, '"([^"]*)"|''([^'']*)''|([^,\s"''][^,]*)')) { if ($m.Groups[1].Success) { $m.Groups[1].Value -replace '`"', '"' } elseif ($m.Groups[2].Success) { $m.Groups[2].Value } else { $m.Groups[3].Value.Trim() } } return @($values) } if ($t -match '^"(.*)"$' -or $t -match "^'(.*)'$") { return ($Matches[1] -replace '`"', '"') } if ($t -match '^-?\d+(\.\d+)?$') { return [double]$t } return $t } function Invoke-EGIOperator { <# .SYNOPSIS Applies one comparison operator. Wildcard metacharacters in the value (* ? [ ]) are escaped so -startsWith/-endsWith/-contains stay literal substring comparisons, matching Entra semantics. #> [CmdletBinding()] param($Left, [Parameter(Mandatory)] [string] $Op, $Right, [string] $Expression) $opName = $Op.TrimStart('-').ToLowerInvariant() $escaped = [System.Management.Automation.WildcardPattern]::Escape("$Right") switch ($opName) { 'eq' { return $Left -eq $Right } 'ne' { return $Left -ne $Right } 'startswith' { return ($null -ne $Left) -and ("$Left" -like "$escaped*") } 'notstartswith' { return -not (($null -ne $Left) -and ("$Left" -like "$escaped*")) } 'endswith' { return ($null -ne $Left) -and ("$Left" -like "*$escaped") } 'notendswith' { return -not (($null -ne $Left) -and ("$Left" -like "*$escaped")) } 'contains' { return ($null -ne $Left) -and ("$Left" -like "*$escaped*") } 'notcontains' { return -not (($null -ne $Left) -and ("$Left" -like "*$escaped*")) } 'match' { return ($null -ne $Left) -and ("$Left" -match "$Right") } 'notmatch' { return -not (($null -ne $Left) -and ("$Left" -match "$Right")) } 'in' { return $Right -contains $Left } 'notin' { return -not ($Right -contains $Left) } default { throw "Unsupported operator '$Op' in leaf '$Expression'." } } } function ConvertTo-EGICompiledLeaf { <# .SYNOPSIS Parses a leaf expression once into a reusable form; results are cached per expression string so bulk simulation doesn't re-parse per user. #> [CmdletBinding()] param([Parameter(Mandatory)] [string] $Expression) if ($script:EGILeafCache.ContainsKey($Expression)) { return $script:EGILeafCache[$Expression] } $supportedOps = @('eq', 'ne', 'startswith', 'notstartswith', 'endswith', 'notendswith', 'contains', 'notcontains', 'match', 'notmatch', 'in', 'notin') $leaf = if ($Expression -match '(?i)^Direct Reports for') { @{ Kind = 'Unsupported'; Message = "Leaf '$Expression' uses the direct-reports rule, which this prototype does not simulate." } } elseif ($Expression -match '(?i)\bmemberOf\b') { @{ Kind = 'Unsupported'; Message = "Leaf '$Expression' uses the (preview) memberOf operator, which this prototype does not simulate." } } elseif ($Expression -match '(?i)system\.now') { @{ Kind = 'Unsupported'; Message = "Leaf '$Expression' uses date-math against system.now, which this prototype does not simulate." } } # '-any (_ -op value)' over a collection property. elseif ($Expression -match '(?i)^user\.([A-Za-z0-9_]+)\s+-any\s*\(\s*_\s+(-\S+)\s+(.+)\)\s*$') { @{ Kind = 'Any'; Property = $Matches[1]; Operator = $Matches[2]; Value = (ConvertFrom-EGILiteral -Text $Matches[3]) } } # Simple single-property comparison: user.<prop> <op> <value> elseif ($Expression -match '(?i)^user\.([A-Za-z0-9_]+)\s+(-\S+)\s+(.+)$') { @{ Kind = 'Simple'; Property = $Matches[1]; Operator = $Matches[2]; Value = (ConvertFrom-EGILiteral -Text $Matches[3]) } } else { @{ Kind = 'Unsupported'; Message = "Could not parse leaf expression: '$Expression'." } } if ($leaf.Kind -ne 'Unsupported' -and $supportedOps -notcontains $leaf.Operator.TrimStart('-').ToLowerInvariant()) { $leaf = @{ Kind = 'Unsupported'; Message = "Unsupported operator '$($leaf.Operator)' in leaf '$Expression'." } } $script:EGILeafCache[$Expression] = $leaf return $leaf } function Test-EGILeafExpression { <# .SYNOPSIS Evaluates one leaf expression (e.g. 'user.department -eq "Sales"') against a user object. .DESCRIPTION Supports the common single-property comparison operators (-eq, -ne, -startsWith, -notStartsWith, -endsWith, -notEndsWith, -contains, -notContains, -match, -notMatch, -in, -notIn) plus a best-effort handling of '-any (_ -op value)' over collection properties such as otherMails / proxyAddresses. Extension attribute properties (user.extension_<appId>_<name>) are supported like any other string property. NOT SUPPORTED in this v0.1 prototype (throws a descriptive error so callers can skip/flag the rule instead of silently returning a wrong result): - 'Direct Reports for "<objectId>"' rules - the memberOf (preview) operator - employeeHireDate date-math expressions (system.now -plus/-minus) .PARAMETER Expression The raw leaf text, e.g. 'user.department -eq "Sales"'. .PARAMETER User A [pscustomobject]/hashtable representing the user, with properties named after the Graph user attributes (department, jobTitle, otherMails, ...). #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string] $Expression, [Parameter(Mandatory)] $User ) $leaf = ConvertTo-EGICompiledLeaf -Expression $Expression switch ($leaf.Kind) { 'Unsupported' { throw $leaf.Message } 'Any' { $collection = @(Get-EGIUserProperty -Obj $User -Name $leaf.Property) foreach ($item in $collection) { if (Invoke-EGIOperator -Left $item -Op $leaf.Operator -Right $leaf.Value -Expression $Expression) { return $true } } return $false } 'Simple' { $left = Get-EGIUserProperty -Obj $User -Name $leaf.Property return (Invoke-EGIOperator -Left $left -Op $leaf.Operator -Right $leaf.Value -Expression $Expression) } } } |