Private/Get-IntuneAccessScopedReadiness.ps1
|
function Import-IntuneAccessPermissionAssessment { <# Reads the Permissions Assessment Report export from Tenant administration > Roles > Settings. Accepts .csv or .xlsx. The .xlsx reader uses System.IO.Compression only, so no extra module is needed. Expected columns: Group, Roles, Scope Tag, Resource, Old Permissions, New Permissions. #> [CmdletBinding()] param([Parameter(Mandatory)] [string] $Path) $resolved = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath $extension = [IO.Path]::GetExtension($resolved).ToLowerInvariant() $table = [System.Collections.Generic.List[string[]]]::new() if ($extension -eq '.csv') { $records = @(Import-Csv -LiteralPath $resolved) if ($records.Count -gt 0) { $headers = @($records[0].PSObject.Properties.Name) $table.Add([string[]] $headers) foreach ($record in $records) { $table.Add([string[]] @($headers | ForEach-Object { [string] $record.$_ })) } } } elseif ($extension -eq '.xlsx') { Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($resolved) try { function Read-ZipXml([string] $Name) { $entry = $zip.GetEntry($Name) if ($null -eq $entry) { return $null } $reader = [IO.StreamReader]::new($entry.Open()) try { [xml] $reader.ReadToEnd() } finally { $reader.Dispose() } } $shared = [System.Collections.Generic.List[string]]::new() $sharedXml = Read-ZipXml 'xl/sharedStrings.xml' if ($null -ne $sharedXml) { foreach ($item in $sharedXml.GetElementsByTagName('si')) { $shared.Add(($item.GetElementsByTagName('t') | ForEach-Object { $_.InnerText }) -join '') } } $sheetEntry = @($zip.Entries | Where-Object { $_.FullName -like 'xl/worksheets/sheet*.xml' } | Sort-Object FullName | Select-Object -First 1) if ($sheetEntry.Count -eq 0) { throw 'The workbook contains no worksheet.' } $sheet = Read-ZipXml $sheetEntry[0].FullName foreach ($row in $sheet.GetElementsByTagName('row')) { $cells = @{} $maxIndex = -1 foreach ($cell in $row.GetElementsByTagName('c')) { $letters = ([regex]::Match($cell.GetAttribute('r'), '^[A-Z]+')).Value $index = 0 foreach ($char in $letters.ToCharArray()) { $index = ($index * 26) + ([int] $char - 64) } $index-- $valueNode = @($cell.GetElementsByTagName('v')) $value = if ($cell.GetAttribute('t') -eq 's' -and $valueNode.Count) { $shared[[int] $valueNode[0].InnerText] } elseif ($cell.GetAttribute('t') -eq 'inlineStr') { ($cell.GetElementsByTagName('t') | ForEach-Object { $_.InnerText }) -join '' } elseif ($valueNode.Count) { $valueNode[0].InnerText } else { '' } $cells[$index] = $value if ($index -gt $maxIndex) { $maxIndex = $index } } if ($maxIndex -lt 0) { continue } $table.Add([string[]] @(0..$maxIndex | ForEach-Object { if ($cells.ContainsKey($_)) { [string] $cells[$_] } else { '' } })) } } finally { $zip.Dispose() } } else { throw 'The Permissions Assessment Report must be a .csv or .xlsx export.' } if ($table.Count -eq 0) { return [PSCustomObject] @{ PSTypeName = 'IntuneAccess.PermissionAssessment'; Path = $resolved; Rows = @(); State = 'Empty'; Warnings = @('The export contains no rows. Microsoft omits groups that are not affected by permission merging.') } } $wanted = [ordered] @{ Group = 'group'; Roles = 'roles'; ScopeTag = 'scopetag'; Resource = 'resource'; OldPermissions = 'oldpermissions'; NewPermissions = 'newpermissions' } $headerRow = -1 for ($i = 0; $i -lt [Math]::Min(10, $table.Count); $i++) { $normalised = @($table[$i] | ForEach-Object { ($_ -replace '[^A-Za-z]', '').ToLowerInvariant() }) if (@($wanted.Values | Where-Object { $_ -in $normalised }).Count -ge 4) { $headerRow = $i; break } } if ($headerRow -lt 0) { throw 'The file does not look like a Permissions Assessment Report export. Expected the columns Group, Roles, Scope Tag, Resource, Old Permissions and New Permissions.' } $headers = @($table[$headerRow] | ForEach-Object { ($_ -replace '[^A-Za-z]', '').ToLowerInvariant() }) $columnIndex = @{} foreach ($key in $wanted.Keys) { $columnIndex[$key] = [array]::IndexOf($headers, $wanted[$key]) } $missing = @($wanted.Keys | Where-Object { $columnIndex[$_] -lt 0 }) function Split-Permission([string] $Value) { @(($Value -split '[,;\r\n]+') | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique) } $rows = [System.Collections.Generic.List[object]]::new() for ($i = $headerRow + 1; $i -lt $table.Count; $i++) { $line = $table[$i] $get = { param($k) if ($columnIndex[$k] -ge 0 -and $columnIndex[$k] -lt $line.Count) { [string] $line[$columnIndex[$k]] } else { '' } } $group = (& $get 'Group').Trim() if (-not $group) { continue } $rows.Add([PSCustomObject] @{ PSTypeName = 'IntuneAccess.PermissionAssessmentRow' Group = $group Roles = @((& $get 'Roles') -split '[,;\r\n]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) ScopeTag = (& $get 'ScopeTag').Trim() Resource = (& $get 'Resource').Trim() OldPermissions = Split-Permission (& $get 'OldPermissions') NewPermissions = Split-Permission (& $get 'NewPermissions') }) } [PSCustomObject] @{ PSTypeName = 'IntuneAccess.PermissionAssessment' Path = $resolved Rows = $rows.ToArray() State = if ($rows.Count) { 'Imported' } else { 'Empty' } Warnings = @($missing | ForEach-Object { "Column '$_' was not found; it is treated as empty." }) } } function Get-IntuneAccessScopedReadiness { <# Models the Scoped permissions change for every Admin Group, mirroring the shape of Microsoft's Permissions Assessment Report (group, scope tag, resource, old and new permissions), and reconciles the model with an imported report when one is supplied. The tenant setting is never read or changed. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Collection, [AllowNull()] [object] $Assessment ) function ConvertTo-ResourceKey([string] $Value) { ($Value -replace '[^A-Za-z0-9]', '').ToLowerInvariant() } function ConvertTo-OperationKey([string] $Value) { ($Value -replace '[^A-Za-z0-9]', '').ToLowerInvariant() } $roleAssignments = @(Get-IntuneAccessProperty $Collection 'RoleAssignments' @()) $memberships = @(Get-IntuneAccessProperty $Collection 'Memberships' @()) $adminGroups = @(Get-IntuneAccessProperty $Collection 'AdminGroups' @()) $modelRows = [System.Collections.Generic.List[object]]::new() foreach ($group in $adminGroups) { $groupId = [string] $group.Id $memberCount = @($memberships | Where-Object { [string] $_.GroupId -eq $groupId } | ForEach-Object { $_.User.Id } | Select-Object -Unique).Count $groupAssignments = @($roleAssignments | Where-Object { $groupId -in @($_.RawIds.AdminGroupIds | ForEach-Object { [string] $_ }) } | ForEach-Object { $view = $_ | Select-Object * $view | Add-Member -NotePropertyName Applicability -NotePropertyValue 'Confirmed' -Force $view }) if ($groupAssignments.Count -lt 2) { continue } $impact = @(Resolve-IntuneScopedPermissionImpact -RoleAssignment $groupAssignments) foreach ($context in @($impact | Group-Object Resource, ScopeTagId)) { $contextRows = @($context.Group) $reductions = @($contextRows | Where-Object Change -EQ 'PermissionReduction') $unknown = @($contextRows | Where-Object Change -EQ 'NotEvaluated') if ($reductions.Count -eq 0 -and $unknown.Count -eq 0) { continue } $legacyRoleNames = @($contextRows | ForEach-Object { $_.LegacyGrantedBy } | ForEach-Object { [string] (Get-IntuneAccessProperty (Get-IntuneAccessProperty $_ 'RoleDefinition') 'DisplayName' '') } | Where-Object { $_ } | Sort-Object -Unique) $modelRows.Add([PSCustomObject] @{ PSTypeName = 'IntuneAccess.ScopedReadinessRow' GroupId = $groupId Group = [string] $group.DisplayName MemberCount = $memberCount Roles = $legacyRoleNames ScopeTagId = [string] $contextRows[0].ScopeTagId ScopeTag = [string] $contextRows[0].ScopeTagName Resource = [string] $contextRows[0].Resource OldPermissions = @($contextRows | Where-Object LegacyState -EQ 'Allowed' | ForEach-Object Operation | Sort-Object -Unique) NewPermissions = @($contextRows | Where-Object ScopedState -EQ 'Allowed' | ForEach-Object Operation | Sort-Object -Unique) LostPermissions = @($reductions | ForEach-Object Operation | Sort-Object -Unique) ModelState = if ($unknown.Count) { 'NotEvaluated' } else { 'PermissionReduction' } }) } } $reconciled = [System.Collections.Generic.List[object]]::new() $assessmentRows = @(Get-IntuneAccessProperty $Assessment 'Rows' @()) $matchedAssessment = [System.Collections.Generic.HashSet[int]]::new() foreach ($row in $modelRows) { $state = 'ModelOnly' $microsoftRow = $null if ($null -ne $Assessment) { for ($i = 0; $i -lt $assessmentRows.Count; $i++) { $candidate = $assessmentRows[$i] if ($candidate.Group -ieq $row.Group -and $candidate.ScopeTag -ieq $row.ScopeTag -and (ConvertTo-ResourceKey $candidate.Resource) -eq (ConvertTo-ResourceKey $row.Resource)) { $microsoftRow = $candidate; $null = $matchedAssessment.Add($i); break } } if ($null -ne $microsoftRow) { $modelNew = @($row.NewPermissions | ForEach-Object { ConvertTo-OperationKey $_ } | Sort-Object -Unique) -join ',' $reportNew = @($microsoftRow.NewPermissions | ForEach-Object { ConvertTo-OperationKey $_ } | Sort-Object -Unique) -join ',' $state = if ($modelNew -eq $reportNew) { 'Agreed' } else { 'DifferentPermissions' } } elseif ($row.MemberCount -eq 0) { $state = 'ModelOnlyEmptyGroup' } } $row | Add-Member -NotePropertyName ReconciliationState -NotePropertyValue $state -Force $row | Add-Member -NotePropertyName MicrosoftReportRow -NotePropertyValue $microsoftRow -Force $reconciled.Add($row) } if ($null -ne $Assessment) { for ($i = 0; $i -lt $assessmentRows.Count; $i++) { if ($matchedAssessment.Contains($i)) { continue } $candidate = $assessmentRows[$i] $reconciled.Add([PSCustomObject] @{ PSTypeName = 'IntuneAccess.ScopedReadinessRow'; GroupId = ''; Group = $candidate.Group; MemberCount = $null; Roles = $candidate.Roles ScopeTagId = ''; ScopeTag = $candidate.ScopeTag; Resource = $candidate.Resource OldPermissions = $candidate.OldPermissions; NewPermissions = $candidate.NewPermissions LostPermissions = @($candidate.OldPermissions | Where-Object { $_ -notin $candidate.NewPermissions }) ModelState = 'NotModelled'; ReconciliationState = 'MicrosoftOnly'; MicrosoftReportRow = $candidate }) } } $explanations = @{ Agreed = 'IntuneAccess and Microsoft agree on the permissions this group keeps for this scope tag.' DifferentPermissions = 'Both expect a reduction, but the remaining permissions differ. Check the role definitions and scope tags on each assignment.' ModelOnly = 'IntuneAccess predicts a reduction that the imported Microsoft report does not list.' ModelOnlyEmptyGroup = 'IntuneAccess predicts a reduction, but the group has no members, and Microsoft excludes empty groups from its report.' MicrosoftOnly = 'Microsoft lists a reduction that IntuneAccess did not model. Nested groups, hidden membership or assignments not collected can cause this.' } foreach ($row in $reconciled) { $key = [string] $row.ReconciliationState $text = if ($null -eq $Assessment) { 'Predicted by IntuneAccess. Import the Permissions Assessment Report export to reconcile with Microsoft.' } elseif ($explanations.ContainsKey($key)) { $explanations[$key] } else { '' } $row | Add-Member -NotePropertyName Explanation -NotePropertyValue $text -Force } [PSCustomObject] @{ PSTypeName = 'IntuneAccess.ScopedReadiness' Rows = @($reconciled | Sort-Object Group, ScopeTag, Resource) AffectedGroups = @($reconciled | ForEach-Object Group | Sort-Object -Unique).Count AssessmentState = if ($null -eq $Assessment) { 'NotImported' } else { [string] $Assessment.State } AssessmentPath = if ($null -eq $Assessment) { '' } else { [string] $Assessment.Path } Agreed = @($reconciled | Where-Object ReconciliationState -EQ 'Agreed').Count Disagreements = @($reconciled | Where-Object ReconciliationState -In @('DifferentPermissions', 'ModelOnly', 'MicrosoftOnly')).Count EvidenceBoundary = 'Microsoft states that enabling Scoped permissions cannot be reversed. IntuneAccess models the change from collected role assignments and never reads or changes the tenant setting. Use the Permissions Assessment Report in Tenant administration > Roles > Settings as the authoritative preview.' } } |