Private/ConvertTo-InforcerDocModel.ps1
|
function Get-InforcerCategoryKey { <# .SYNOPSIS Builds a category key from primaryGroup and secondaryGroup, deduplicating when appropriate. .DESCRIPTION Returns primaryGroup alone when secondaryGroup is null, empty, equal to primaryGroup, or equal to "All". Otherwise returns "primaryGroup / secondaryGroup". Used by ConvertTo-InforcerDocModel for deterministic category naming (per D-11). #> [CmdletBinding()] param( [Parameter()][string]$PrimaryGroup, [Parameter()][string]$SecondaryGroup ) if ([string]::IsNullOrWhiteSpace($SecondaryGroup) -or $SecondaryGroup -eq $PrimaryGroup -or $SecondaryGroup -eq 'All') { return $PrimaryGroup } return "$PrimaryGroup / $SecondaryGroup" } function Get-InforcerPolicyName { <# .SYNOPSIS Resolves a policy display name using the D-13 fallback chain. .DESCRIPTION Applies: displayName -> friendlyName -> name -> policyData.name -> policyData.displayName -> "Policy {id}". Ensures no policy appears with a null name. #> [CmdletBinding()] param([Parameter(Mandatory)]$Policy) $name = $Policy.displayName -as [string] if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.friendlyName -as [string] } if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.name -as [string] } if ([string]::IsNullOrWhiteSpace($name) -and $Policy.policyData) { $name = $Policy.policyData.name -as [string] if ([string]::IsNullOrWhiteSpace($name)) { $name = $Policy.policyData.displayName -as [string] } } if ([string]::IsNullOrWhiteSpace($name)) { $idVal = $Policy.id $name = "Policy $(if ($null -ne $idVal) { $idVal } else { 'Unknown' })" } return $name } function ConvertTo-InforcerDocModel { <# .SYNOPSIS Normalizes raw Inforcer API data into a format-agnostic DocModel. .DESCRIPTION Takes a DocData bundle (from Get-InforcerDocData) and transforms it into the hierarchical DocModel structure: Products -> Categories -> Policies, each with Basics, Settings, and Assignments sections. Settings Catalog policies (policyTypeId 10) have their settingDefinitionIDs resolved to friendly names via ConvertTo-InforcerSettingRows. All other policy types have their policyData properties enumerated as flat Name/Value rows via ConvertTo-FlatSettingRows. The DocModel is format-agnostic: it contains only data, no rendering logic, and makes no API calls (per NORM-06). .PARAMETER DocData Hashtable from Get-InforcerDocData containing: Tenant, Baselines, Policies, TenantId, CollectedAt. .OUTPUTS Hashtable representing the DocModel tree (per D-10). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$DocData, [Parameter()] [hashtable]$GroupNameMap, [Parameter()] [hashtable]$FilterMap, [Parameter()] [hashtable]$RoleNameMap, [Parameter()] [hashtable]$LocationNameMap, [Parameter()] [hashtable]$AppNameMap, [Parameter()] [hashtable]$ScopeTagMap, [Parameter()] [switch]$ComparisonMode ) # Friendly labels for Conditional Access camelCase property names $caFriendlyNames = @{ 'includeUsers' = 'Include Users' 'excludeUsers' = 'Exclude Users' 'includeGroups' = 'Include Groups' 'excludeGroups' = 'Exclude Groups' 'includeRoles' = 'Include Roles' 'excludeRoles' = 'Exclude Roles' 'includeGuestsOrExternalUsers' = 'Include Guests or External Users' 'excludeGuestsOrExternalUsers' = 'Exclude Guests or External Users' 'includeApplications' = 'Include Applications' 'excludeApplications' = 'Exclude Applications' 'includeUserActions' = 'Include User Actions' 'includeAuthenticationContextClassReferences' = 'Include Authentication Context' 'includeLocations' = 'Include Locations' 'excludeLocations' = 'Exclude Locations' 'includePlatforms' = 'Include Platforms' 'excludePlatforms' = 'Exclude Platforms' 'includeServicePrincipals' = 'Include Service Principals' 'excludeServicePrincipals' = 'Exclude Service Principals' 'clientAppTypes' = 'Client App Types' 'userRiskLevels' = 'User Risk Levels' 'signInRiskLevels' = 'Sign-in Risk Levels' 'servicePrincipalRiskLevels' = 'Service Principal Risk Levels' 'grantControls' = 'Grant Controls' 'builtInControls' = 'Built-in Controls' 'customAuthenticationFactors' = 'Custom Authentication Factors' 'termsOfUse' = 'Terms of Use' 'authenticationStrength' = 'Authentication Strength' 'operator' = 'Operator' 'allowedCombinations' = 'Allowed Combinations' 'requirementsSatisfied' = 'Requirements Satisfied' 'policyType' = 'Policy Type' 'combinationConfigurations' = 'Combination Configurations' 'sessionControls' = 'Session Controls' 'applicationEnforcedRestrictions' = 'Application Enforced Restrictions' 'cloudAppSecurity' = 'Cloud App Security' 'persistentBrowser' = 'Persistent Browser' 'signInFrequency' = 'Sign-in Frequency' 'disableResilienceDefaults' = 'Disable Resilience Defaults' 'continuousAccessEvaluation' = 'Continuous Access Evaluation' 'secureSignInSession' = 'Secure Sign-in Session' 'transferMethods' = 'Transfer Methods' 'modifiedDateTime' = 'Modified' 'conditions' = 'Conditions' 'users' = 'Users' 'applications' = 'Applications' 'locations' = 'Locations' 'platforms' = 'Platforms' 'devices' = 'Devices' 'clientApplications' = 'Client Applications' } # Friendly labels for camelCase auth combination values $caFriendlyValues = @{ 'windowsHelloForBusiness' = 'Windows Hello for Business' 'fido2' = 'FIDO2 Security Key' 'deviceBasedPush' = 'Device-based Push' 'temporaryAccessPassOneTime' = 'Temporary Access Pass (One-time)' 'temporaryAccessPassMultiUse' = 'Temporary Access Pass (Multi-use)' 'password,microsoftAuthenticatorPush' = 'Password + Microsoft Authenticator' 'password,softwareOath' = 'Password + Software OATH Token' 'password,hardwareOath' = 'Password + Hardware OATH Token' 'password,sms' = 'Password + SMS' 'password,voice' = 'Password + Voice' 'federatedMultiFactor' = 'Federated Multi-factor' 'federatedSingleFactor' = 'Federated Single-factor' 'microsoftAuthenticatorPush' = 'Microsoft Authenticator Push' 'softwareOath' = 'Software OATH Token' 'hardwareOath' = 'Hardware OATH Token' 'sms' = 'SMS' 'voice' = 'Voice' 'x509CertificateMultiFactor' = 'X.509 Certificate (Multi-factor)' 'x509CertificateSingleFactor' = 'X.509 Certificate (Single-factor)' 'authenticationTransfer' = 'Authentication Transfer' 'deviceCodeFlow' = 'Device Code Flow' } $tenant = $DocData.Tenant $policies = $DocData.Policies $baselines = $DocData.Baselines # Tenant metadata $tenantName = '' if ($null -ne $tenant) { $tenantName = $tenant.tenantFriendlyName if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = $tenant.tenantDnsName } if ([string]::IsNullOrWhiteSpace($tenantName)) { $tenantName = "Tenant $($DocData.TenantId)" } } # Collect all baseline names for this tenant $baselineNames = @() if ($null -ne $baselines -and $baselines.Count -gt 0) { foreach ($bl in @($baselines)) { $blName = $bl.name if ([string]::IsNullOrWhiteSpace($blName)) { $blName = $bl.baselineGroupName } if (-not [string]::IsNullOrWhiteSpace($blName)) { $baselineNames += $blName } } } # Group policies by product -> category (per NORM-01, NORM-02, D-04, D-05) $products = [ordered]@{} foreach ($policy in @($policies)) { if ($null -eq $policy) { continue } $prod = $policy.product if ([string]::IsNullOrWhiteSpace($prod)) { $prod = 'Other' } # Normalize policy name (per NORM-05, D-13) $policyName = Get-InforcerPolicyName -Policy $policy # Map to friendly display name and Microsoft admin portal category $displayInfo = Get-InforcerPolicyDisplayInfo -PolicyName $policyName ` -Product $prod -PrimaryGroup $policy.primaryGroup ` -SecondaryGroup $policy.secondaryGroup -PolicyTypeId $policy.policyTypeId if ($displayInfo.FriendlyName) { $policyName = $displayInfo.FriendlyName } $catKey = if ($displayInfo.Category) { $displayInfo.Category } else { Get-InforcerCategoryKey -PrimaryGroup $policy.primaryGroup -SecondaryGroup $policy.secondaryGroup } if ([string]::IsNullOrWhiteSpace($catKey)) { $catKey = 'General' } # ComparisonMode filtering: only Intune-relevant products, skip non-comparable categories if ($ComparisonMode) { $prodLower = $prod.ToLowerInvariant() $intuneProducts = @('intune', 'windows', 'macos', 'ios', 'android', 'defender') $isIntuneRelevant = $false foreach ($ip in $intuneProducts) { if ($prodLower -match [regex]::Escape($ip)) { $isIntuneRelevant = $true; break } } if (-not $isIntuneRelevant) { continue } # Skip exchange categories (Defender for Office 365, not Intune) $catLower = $catKey.ToLowerInvariant() if ($catLower -match '^exchange') { continue } } # Ensure product and category exist (per NORM-02) if (-not $products.Contains($prod)) { $products[$prod] = @{ Categories = [ordered]@{} } } if (-not $products[$prod].Categories.Contains($catKey)) { $products[$prod].Categories[$catKey] = [System.Collections.Generic.List[object]]::new() } # Extract policy tags (baseline membership indicators) $policyTags = @() if ($null -ne $policy.tags -and @($policy.tags).Count -gt 0) { $policyTags = @($policy.tags | ForEach-Object { if ($_ -is [PSObject] -and $_.PSObject.Properties['name']) { $_.name } else { $_.ToString() } } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } # Basics section (per NORM-04) $basics = @{ Name = $policyName Description = if ($policy.policyData -and $policy.policyData.description) { $policy.policyData.description } else { '' } ProfileType = if ($policy.inforcerPolicyTypeName) { $policy.inforcerPolicyTypeName } else { '' } Platform = if ($policy.platform) { $policy.platform } else { '' } # null ~96% per D-14 Created = if ($policy.policyData -and $policy.policyData.createdDateTime) { $policy.policyData.createdDateTime } else { '' } Modified = if ($policy.policyData -and $policy.policyData.lastModifiedDateTime) { $policy.policyData.lastModifiedDateTime } else { '' } ScopeTags = '' Tags = if ($policyTags.Count -gt 0) { $policyTags -join ', ' } else { '' } } # Scope tags normalization (per D-15) -- resolve IDs to names when ScopeTagMap available $scopeTags = $policy.policyData.roleScopeTagIds if ($null -ne $scopeTags -and $scopeTags.Count -gt 0) { if ($ScopeTagMap -and $ScopeTagMap.Count -gt 0) { $resolvedTags = @($scopeTags | ForEach-Object { $tagId = $_.ToString() if ($ScopeTagMap.ContainsKey($tagId)) { $ScopeTagMap[$tagId] } else { $tagId } }) $basics.ScopeTags = ($resolvedTags -join ', ') } else { $basics.ScopeTags = ($scopeTags -join ', ') } } # Settings section: route by policyTypeId (per D-06, D-07, SCAT-01..06) $settings = [System.Collections.Generic.List[object]]::new() $policyTypeId = $policy.policyTypeId if ($policyTypeId -eq 10 -and $policy.policyData -and $policy.policyData.settings) { # Settings Catalog -- use ConvertTo-InforcerSettingRows (per SCAT-01..06) foreach ($settingGroup in @($policy.policyData.settings)) { if ($null -ne $settingGroup -and $settingGroup.settingInstance) { foreach ($row in (ConvertTo-InforcerSettingRows -SettingInstance $settingGroup.settingInstance)) { [void]$settings.Add($row) } } } } elseif ($null -ne $policy.policyData) { # Non-catalog -- use ConvertTo-FlatSettingRows (per D-07) foreach ($row in (ConvertTo-FlatSettingRows -PolicyData $policy.policyData)) { [void]$settings.Add($row) } } # Resolve GUIDs in CA policy settings (groups, roles, named locations, apps) # Builds a single ordered list of resolution maps to try for each GUID $guidPattern = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' $resolutionMaps = @($GroupNameMap, $RoleNameMap, $LocationNameMap, $AppNameMap) | Where-Object { $_ } if ($settings.Count -gt 0 -and $resolutionMaps.Count -gt 0) { $resolveGuid = { param([string]$Id) foreach ($map in $resolutionMaps) { if ($map.ContainsKey($Id)) { return $map[$Id] } } return $null } foreach ($row in $settings) { $val = $row.Value if ([string]::IsNullOrWhiteSpace($val)) { continue } if ($val -match $guidPattern) { $resolved = & $resolveGuid $val if ($resolved) { $row.Value = $resolved } } elseif ($val -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { $parts = $val -split ',\s*' $changed = $false $newParts = foreach ($part in $parts) { $p = $part.Trim() if ($p -match $guidPattern) { $r = & $resolveGuid $p if ($r) { $changed = $true; $r } else { $p } } else { $p } } if ($changed) { $row.Value = $newParts -join ', ' } } } } # Friendly CA property names and value labels if ($settings.Count -gt 0) { foreach ($row in @($settings)) { $name = $row.Name $val = $row.Value # Resolve camelCase values (e.g. allowedCombinations like "password,softwareOath") $val = $row.Value if (-not [string]::IsNullOrWhiteSpace($val) -and $val -is [string]) { # Try full value first (handles combo keys like "password,microsoftAuthenticatorPush") $trimmedVal = $val.Trim() $normalizedVal = (($trimmedVal -split ',') | ForEach-Object { $_.Trim() }) -join ',' if ($caFriendlyValues.ContainsKey($trimmedVal)) { $row.Value = $caFriendlyValues[$trimmedVal] } elseif ($caFriendlyValues.ContainsKey($normalizedVal)) { $row.Value = $caFriendlyValues[$normalizedVal] } elseif ($trimmedVal -match ',') { # Fall back to per-part resolution $parts = $trimmedVal -split ',\s*' $changed = $false $newParts = foreach ($p in $parts) { $pt = $p.Trim() if ($caFriendlyValues.ContainsKey($pt)) { $changed = $true; $caFriendlyValues[$pt] } else { $pt } } if ($changed) { $row.Value = $newParts -join ', ' } } } # Rename camelCase CA property names to friendly labels if ($caFriendlyNames.ContainsKey($name)) { $row.Name = $caFriendlyNames[$name] } } } # Convert ISO 8601 durations (e.g. PT0S, P30D, PT24H) to friendly text foreach ($row in @($settings)) { $v = $row.Value if (-not [string]::IsNullOrWhiteSpace($v) -and $v -match '^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$') { $days = if ($Matches[1]) { [int]$Matches[1] } else { 0 } $hours = if ($Matches[2]) { [int]$Matches[2] } else { 0 } $mins = if ($Matches[3]) { [int]$Matches[3] } else { 0 } $secs = if ($Matches[4]) { [int]$Matches[4] } else { 0 } $parts = @() if ($days -gt 0) { $parts += "$days day$(if ($days -ne 1) { 's' })" } if ($hours -gt 0) { $parts += "$hours hour$(if ($hours -ne 1) { 's' })" } if ($mins -gt 0) { $parts += "$mins minute$(if ($mins -ne 1) { 's' })" } if ($secs -gt 0) { $parts += "$secs second$(if ($secs -ne 1) { 's' })" } if ($parts.Count -eq 0) { $row.Value = '0 (immediate)' } else { $row.Value = $parts -join ', ' } } } # Assignments section (per NORM-03) -- uses Resolve-InforcerAssignments $rawAssignments = $policy.policyData.assignments if ($null -eq $rawAssignments) { $rawAssignments = $policy.assignments } $resolveParams = @{ RawAssignments = $rawAssignments } if ($null -ne $GroupNameMap) { $resolveParams['GroupNameMap'] = $GroupNameMap } if ($null -ne $FilterMap) { $resolveParams['FilterMap'] = $FilterMap } $assignments = @(Resolve-InforcerAssignments @resolveParams) # Assemble normalized policy (per NORM-03) $normalizedPolicy = @{ Basics = $basics Settings = $settings.ToArray() Assignments = $assignments PolicyTypeId = $policyTypeId } [void]$products[$prod].Categories[$catKey].Add($normalizedPolicy) } # Return the DocModel (per D-10, NORM-06) @{ TenantName = $tenantName TenantId = $DocData.TenantId GeneratedAt = $DocData.CollectedAt BaselineName = if ($baselineNames.Count -gt 0) { $baselineNames[0] } else { '' } Baselines = $baselineNames Products = $products } } |