modules/shared/Policy/AlzMatcher.ps1
|
# AlzMatcher.ps1 # Track C scaffold (#431). Stub only. See docs/design/alz-scoring-algorithm.md. # Pure function: deterministic fuzzy-match of a tenant MG hierarchy against the # ALZ canonical reference. Returns score plus four component scores for auditability. Set-StrictMode -Version Latest $script:AlzWeights = @{ exactName = 0.40 structural = 0.30 renames = 0.20 levenshtein = 0.10 } $script:AlzCanonicalNodes = @( [pscustomobject]@{ Name = 'Root'; Depth = 0; ChildCount = 4 } [pscustomobject]@{ Name = 'Platform'; Depth = 1; ChildCount = 3 } [pscustomobject]@{ Name = 'Management'; Depth = 2; ChildCount = 0 } [pscustomobject]@{ Name = 'Connectivity'; Depth = 2; ChildCount = 0 } [pscustomobject]@{ Name = 'Identity'; Depth = 2; ChildCount = 0 } [pscustomobject]@{ Name = 'Landing Zones'; Depth = 1; ChildCount = 2 } [pscustomobject]@{ Name = 'Corp'; Depth = 2; ChildCount = 0 } [pscustomobject]@{ Name = 'Online'; Depth = 2; ChildCount = 0 } [pscustomobject]@{ Name = 'Decommissioned';Depth = 1; ChildCount = 0 } [pscustomobject]@{ Name = 'Sandbox'; Depth = 1; ChildCount = 0 } ) $script:AlzRenameTable = @{ 'Platform' = @('Core', 'Shared Services', 'SharedServices', 'Shared', 'Hub') 'Landing Zones' = @('Workloads', 'Application', 'Applications', 'LZ', 'LandingZones') 'Corp' = @('Internal', 'Private', 'Enterprise') 'Online' = @('External', 'Public', 'Internet') 'Decommissioned'= @('Decom', 'Retired', 'Archive') 'Sandbox' = @('Dev', 'Development', 'NonProd', 'Playground') 'Connectivity' = @('Network', 'Networking', 'Hub-Network', 'HubNetwork') 'Identity' = @('IAM', 'AAD', 'Entra') 'Management' = @('Mgmt', 'Operations', 'Ops', 'Monitoring') } function ConvertTo-AlzToken { param([string] $Name) if ([string]::IsNullOrWhiteSpace($Name)) { return '' } return (($Name.Trim().ToLowerInvariant() -replace '\s+', '') -replace '[-_]', '') } function Get-AlzTenantNodes { param( [Parameter(Mandatory)] [object] $TenantHierarchy, [int] $Depth = 0 ) $nodes = [System.Collections.Generic.List[object]]::new() foreach ($item in @($TenantHierarchy)) { if ($null -eq $item) { continue } $name = $null foreach ($nameProp in 'Name', 'name', 'DisplayName', 'displayName') { if ($item.PSObject.Properties[$nameProp]) { $name = [string]$item.$nameProp break } } $children = @() foreach ($childProp in 'Children', 'children', 'ManagementGroups', 'managementGroups', 'Nodes', 'nodes') { if ($item.PSObject.Properties[$childProp] -and $null -ne $item.$childProp) { $children = @($item.$childProp) break } } if (-not [string]::IsNullOrWhiteSpace($name)) { $nodes.Add([pscustomobject]@{ Name = $name Token = ConvertTo-AlzToken $name Depth = $Depth ChildCount = @($children).Count }) | Out-Null } if (@($children).Count -gt 0) { foreach ($child in @(Get-AlzTenantNodes -TenantHierarchy $children -Depth ($Depth + 1))) { $nodes.Add($child) | Out-Null } } } return @($nodes) } function Get-LevenshteinDistance { param( [string] $Left, [string] $Right ) if ($Left -eq $Right) { return 0 } if ([string]::IsNullOrEmpty($Left)) { return $Right.Length } if ([string]::IsNullOrEmpty($Right)) { return $Left.Length } $n = $Left.Length $m = $Right.Length $distance = New-Object 'int[,]' ($n + 1), ($m + 1) for ($i = 0; $i -le $n; $i++) { $distance[$i, 0] = $i } for ($j = 0; $j -le $m; $j++) { $distance[0, $j] = $j } for ($i = 1; $i -le $n; $i++) { for ($j = 1; $j -le $m; $j++) { $cost = if ($Left[($i - 1)] -ceq $Right[($j - 1)]) { 0 } else { 1 } $distance[$i, $j] = [Math]::Min( [Math]::Min($distance[($i - 1), $j] + 1, $distance[$i, ($j - 1)] + 1), $distance[($i - 1), ($j - 1)] + $cost ) } } return $distance[$n, $m] } function Get-AlzMatchBreakdown { param([Parameter(Mandatory)] [object] $TenantHierarchy) $tenantNodes = @(Get-AlzTenantNodes -TenantHierarchy $TenantHierarchy) $canonicalCount = @($script:AlzCanonicalNodes).Count if ($canonicalCount -eq 0) { throw 'ALZ canonical hierarchy is empty.' } $exactMatches = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $renameMatches = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $levMatches = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) $matchedHierarchy = [System.Collections.Generic.List[object]]::new() $rootAliases = @('TenantRoot', 'Tenant Root Group') foreach ($canonical in $script:AlzCanonicalNodes) { $canonicalToken = ConvertTo-AlzToken $canonical.Name $match = $tenantNodes | Where-Object { $_.Depth -eq $canonical.Depth -and ( $_.Token -eq $canonicalToken -or ($canonical.Name -eq 'Root' -and (ConvertTo-AlzToken $_.Name) -in @($rootAliases | ForEach-Object { ConvertTo-AlzToken $_ })) ) } | Select-Object -First 1 if ($match) { $exactMatches[$canonical.Name] = $match $matchedHierarchy.Add([pscustomobject]@{ tenantNode = $match.Name canonical = $canonical.Name matchType = 'exact' }) | Out-Null } } foreach ($canonical in $script:AlzCanonicalNodes) { if ($exactMatches.ContainsKey($canonical.Name)) { continue } $candidateRenames = @($script:AlzRenameTable[$canonical.Name] | ForEach-Object { ConvertTo-AlzToken $_ }) if (@($candidateRenames).Count -eq 0) { continue } $match = $tenantNodes | Where-Object { $_.Depth -eq $canonical.Depth -and $_.Token -in $candidateRenames } | Select-Object -First 1 if ($match) { $renameMatches[$canonical.Name] = $match $matchedHierarchy.Add([pscustomobject]@{ tenantNode = $match.Name canonical = $canonical.Name matchType = 'rename' }) | Out-Null } } foreach ($canonical in $script:AlzCanonicalNodes) { if ($exactMatches.ContainsKey($canonical.Name) -or $renameMatches.ContainsKey($canonical.Name)) { continue } $canonicalToken = ConvertTo-AlzToken $canonical.Name $match = $tenantNodes | Where-Object { $_.Depth -eq $canonical.Depth -and (Get-LevenshteinDistance -Left $_.Token -Right $canonicalToken) -le 2 } | Sort-Object { Get-LevenshteinDistance -Left $_.Token -Right $canonicalToken }, Name | Select-Object -First 1 if ($match) { $levMatches[$canonical.Name] = $match $matchedHierarchy.Add([pscustomobject]@{ tenantNode = $match.Name canonical = $canonical.Name matchType = 'levenshtein' }) | Out-Null } } $exactScore = [double]$exactMatches.Count / [double]$canonicalCount $renameCandidates = @($script:AlzCanonicalNodes | Where-Object { -not $exactMatches.ContainsKey($_.Name) }).Count $renamesScore = if ($renameCandidates -eq 0) { 1.0 } else { [double]$renameMatches.Count / [double]$renameCandidates } $remainingAfterRename = @($script:AlzCanonicalNodes | Where-Object { -not $exactMatches.ContainsKey($_.Name) -and -not $renameMatches.ContainsKey($_.Name) }).Count $levenshteinScore = if ($remainingAfterRename -eq 0) { 1.0 } else { [double]$levMatches.Count / [double]$remainingAfterRename } $depthCorrectCount = 0 foreach ($canonical in $script:AlzCanonicalNodes) { $matchedNode = $null if ($exactMatches.ContainsKey($canonical.Name)) { $matchedNode = $exactMatches[$canonical.Name] } if (-not $matchedNode -and $renameMatches.ContainsKey($canonical.Name)) { $matchedNode = $renameMatches[$canonical.Name] } if (-not $matchedNode -and $levMatches.ContainsKey($canonical.Name)) { $matchedNode = $levMatches[$canonical.Name] } if ($matchedNode -and $matchedNode.Depth -eq $canonical.Depth) { $depthCorrectCount++ } } $depthCorrectFraction = [double]$depthCorrectCount / [double]$canonicalCount $nonLeaf = @($script:AlzCanonicalNodes | Where-Object { $_.ChildCount -gt 0 }) $childMatches = 0 foreach ($canonical in $nonLeaf) { $matchedNode = $null if ($exactMatches.ContainsKey($canonical.Name)) { $matchedNode = $exactMatches[$canonical.Name] } if (-not $matchedNode -and $renameMatches.ContainsKey($canonical.Name)) { $matchedNode = $renameMatches[$canonical.Name] } if (-not $matchedNode -and $levMatches.ContainsKey($canonical.Name)) { $matchedNode = $levMatches[$canonical.Name] } if ($matchedNode -and [Math]::Abs([int]$matchedNode.ChildCount - [int]$canonical.ChildCount) -le 1) { $childMatches++ } } $childCountFraction = if (@($nonLeaf).Count -eq 0) { 1.0 } else { [double]$childMatches / [double]@($nonLeaf).Count } $structuralScore = ($depthCorrectFraction + $childCountFraction) / 2.0 return [pscustomobject]@{ ExactScore = [Math]::Round($exactScore, 4) StructuralScore = [Math]::Round($structuralScore, 4) RenamesScore = [Math]::Round($renamesScore, 4) LevenshteinScore = [Math]::Round($levenshteinScore, 4) MatchedHierarchy = @($matchedHierarchy) } } function Get-AlzMatchScore { [CmdletBinding()] param( [Parameter(Mandatory)] [double] $ExactName, [Parameter(Mandatory)] [double] $Structural, [Parameter(Mandatory)] [double] $Renames, [Parameter(Mandatory)] [double] $Levenshtein ) $score = ($script:AlzWeights.exactName * $ExactName) + ($script:AlzWeights.structural * $Structural) + ($script:AlzWeights.renames * $Renames) + ($script:AlzWeights.levenshtein * $Levenshtein) return [Math]::Round($score, 4) } function Invoke-AlzHierarchyMatch { <# .SYNOPSIS Score how closely a tenant MG hierarchy matches the ALZ canonical reference. .DESCRIPTION Implements the weighted formula: score = 0.40 * exactName + 0.30 * structural + 0.20 * renames + 0.10 * levenshtein See docs/design/alz-scoring-algorithm.md for the full specification. .PARAMETER TenantHierarchy Tenant MG hierarchy as a tree of nodes (Name, Depth, Children). .PARAMETER Mode Auto | Force | Off. Default Auto. .OUTPUTS PSCustomObject with Score, Components, MatchedHierarchy, Mode. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $TenantHierarchy, [ValidateSet('Auto','Force','Off')] [string] $Mode = 'Auto' ) if ($Mode -eq 'Off') { return [pscustomobject]@{ Mode = 'Off' Score = $null Components = $null Weighted = $null MatchedHierarchy= @() Decision = 'Off' } } $breakdown = Get-AlzMatchBreakdown -TenantHierarchy $TenantHierarchy $score = Get-AlzMatchScore ` -ExactName $breakdown.ExactScore ` -Structural $breakdown.StructuralScore ` -Renames $breakdown.RenamesScore ` -Levenshtein $breakdown.LevenshteinScore $decision = Get-AlzActivationDecision -Score $score -Mode $Mode return [pscustomobject]@{ Mode = $Mode Score = $score Components = [pscustomobject]@{ exactName = $breakdown.ExactScore structural = $breakdown.StructuralScore renames = $breakdown.RenamesScore levenshtein = $breakdown.LevenshteinScore } Weighted = [pscustomobject]@{ exactName = [Math]::Round($script:AlzWeights.exactName * $breakdown.ExactScore, 4) structural = [Math]::Round($script:AlzWeights.structural * $breakdown.StructuralScore, 4) renames = [Math]::Round($script:AlzWeights.renames * $breakdown.RenamesScore, 4) levenshtein = [Math]::Round($script:AlzWeights.levenshtein * $breakdown.LevenshteinScore, 4) } MatchedHierarchy = @($breakdown.MatchedHierarchy) Decision = $decision ForceOverridden = ($Mode -eq 'Force' -and $score -lt 0.80) } } function Get-AlzExactNameComponent { [CmdletBinding()] param([Parameter(Mandatory)] [object] $TenantHierarchy) return (Get-AlzMatchBreakdown -TenantHierarchy $TenantHierarchy).ExactScore } function Get-AlzStructuralComponent { [CmdletBinding()] param([Parameter(Mandatory)] [object] $TenantHierarchy) return (Get-AlzMatchBreakdown -TenantHierarchy $TenantHierarchy).StructuralScore } function Get-AlzRenamesComponent { [CmdletBinding()] param([Parameter(Mandatory)] [object] $TenantHierarchy) return (Get-AlzMatchBreakdown -TenantHierarchy $TenantHierarchy).RenamesScore } function Get-AlzLevenshteinComponent { [CmdletBinding()] param([Parameter(Mandatory)] [object] $TenantHierarchy) return (Get-AlzMatchBreakdown -TenantHierarchy $TenantHierarchy).LevenshteinScore } function Get-AlzActivationDecision { <# .SYNOPSIS Apply threshold semantics to a score: Full | Partial | Fallback. #> [CmdletBinding()] param( [Parameter(Mandatory)] [double] $Score, [ValidateSet('Auto','Force','Off')] [string] $Mode = 'Auto' ) if ($Mode -eq 'Off') { return 'Off' } if ($Mode -eq 'Force') { return 'Full' } if ($Score -ge 0.80) { return 'Full' } if ($Score -ge 0.50) { return 'Partial' } return 'Fallback' } if ($MyInvocation.MyCommand.Module) { Export-ModuleMember -Function ` Invoke-AlzHierarchyMatch, ` Get-AlzMatchScore, ` Get-AlzExactNameComponent, ` Get-AlzStructuralComponent, ` Get-AlzRenamesComponent, ` Get-AlzLevenshteinComponent, ` Get-AlzActivationDecision } |