Private/ConvertTo-AACGroupMembership.ps1
|
function ConvertTo-AACGroupMembership { <# .SYNOPSIS Flattens Entra ID groups and their members - nested groups followed to the end - into one row per group and member, with a summary per group. .DESCRIPTION -Group are the groups asked for (Microsoft Graph group objects); -Member maps a group ID to its direct members (directory objects: users, groups, devices, service principals, contacts), for those groups and every group nested in them. Rows (AAC.EntraGroupMember), one per member of each group asked for: Membership Direct, Nested (through a nested group - Via names the path, Depth how deep), or Empty (a group with no members, so every group is in the report) a nested group is a row of its own (MemberType Group) and its members follow it; a group met again on the same path (a loop) is not followed twice Groups (AAC.EntraGroup): its type (Microsoft 365, Security, Mail-enabled security, Distribution; dynamic, role-assignable), where it comes from (Cloud, or synced from on-premises AD), its direct members, nested groups, and the unique users in it - all, guests and disabled accounts - counted through every nested group. Returns @{ Rows; Groups; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [AllowEmptyCollection()] [object[]] $Group = @(), # Group ID -> its direct members. [hashtable] $Member = @{}, # Group ID -> why its members couldn't be read. [hashtable] $MemberError = @{} ) $value = { param($Object, [string] $Key) if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Key)) { $Object[$Key] } } else { Get-AACPropertyValue -InputObject $Object -Name $Key } } $text = { param($Object, [string] $Key) $v = & $value $Object $Key; if ($null -eq $v) { '' } else { [string]$v } } $groupType = { param($G) $types = @(& $value $G 'groupTypes') $kind = if ($types -contains 'Unified') { 'Microsoft 365' } elseif ((& $value $G 'securityEnabled') -eq $true -and (& $value $G 'mailEnabled') -eq $true) { 'Mail-enabled security' } elseif ((& $value $G 'securityEnabled') -eq $true) { 'Security' } elseif ((& $value $G 'mailEnabled') -eq $true) { 'Distribution' } else { 'Other' } $extra = @( if ($types -contains 'DynamicMembership') { 'dynamic' } if ((& $value $G 'isAssignableToRole') -eq $true) { 'role-assignable' } ) if ($extra.Count) { "$kind ($($extra -join ', '))" } else { $kind } } $source = { param($G) if ((& $value $G 'onPremisesSyncEnabled') -eq $true) { 'Synced from on-premises AD' } else { 'Cloud' } } $kindOf = @{ '#microsoft.graph.user' = 'User' '#microsoft.graph.group' = 'Group' '#microsoft.graph.device' = 'Device' '#microsoft.graph.servicePrincipal' = 'Service principal' '#microsoft.graph.orgContact' = 'Contact' } $memberType = { param($M) $odata = & $text $M '@odata.type'; if ($kindOf.Contains($odata)) { $kindOf[$odata] } elseif ($odata) { $odata -replace '^#microsoft\.graph\.', '' } else { 'Unknown' } } $rows = [System.Collections.Generic.List[object]]::new() $groups = [System.Collections.Generic.List[object]]::new() foreach ($g in @($Group | Sort-Object -Property { & $text $_ 'displayName' })) { $gid = & $text $g 'id' $gName = & $text $g 'displayName' $gType = & $groupType $g $gSource = & $source $g $users = @{} $nestedGroups = [System.Collections.Generic.HashSet[string]]::new() $errors = [System.Collections.Generic.List[string]]::new() $row = { param($M, [string] $Membership, [string] $Via, [int] $Depth) $kind = if ($M) { & $memberType $M } else { '' } $enabled = if ($M) { & $value $M 'accountEnabled' } else { $null } $rows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.EntraGroupMember' GroupName = $gName GroupType = $gType GroupSource = $gSource MemberName = $(if ($M) { & $text $M 'displayName' } else { '' }) MemberType = $kind UserPrincipalName = $(if ($M) { & $text $M 'userPrincipalName' } else { '' }) Mail = $(if ($M) { & $text $M 'mail' } else { '' }) UserType = $(if ($kind -eq 'User') { & $text $M 'userType' } else { '' }) AccountEnabled = $(if ($kind -eq 'User' -and $null -ne $enabled) { [bool]$enabled } else { $null }) JobTitle = $(if ($M) { & $text $M 'jobTitle' } else { '' }) Department = $(if ($M) { & $text $M 'department' } else { '' }) Membership = $Membership Via = $Via Depth = $Depth GroupId = $gid MemberId = $(if ($M) { & $text $M 'id' } else { '' }) }) if ($kind -eq 'User') { $users[(& $text $M 'id')] = $M } } # Depth-first through nested groups; $path guards against loops. $walk = { param([string] $Id, [string] $Via, [int] $Depth, [string[]] $Path) if ($MemberError.Contains($Id)) { $errors.Add("$(if ($Via) { $Via } else { $gName }): $($MemberError[$Id])") } foreach ($m in @(if ($Member.Contains($Id)) { $Member[$Id] })) { if ($null -eq $m) { continue } & $row $m $(if ($Depth -eq 0) { 'Direct' } else { 'Nested' }) $Via $Depth if ((& $memberType $m) -eq 'Group') { $childId = & $text $m 'id' if ($childId -ne $gid) { [void]$nestedGroups.Add($childId) } if ($Path -notcontains $childId) { $childVia = if ($Via) { "$Via > $(& $text $m 'displayName')" } else { "$gName > $(& $text $m 'displayName')" } & $walk $childId $childVia ($Depth + 1) (@($Path) + $childId) } } } } $before = $rows.Count & $walk $gid '' 0 @($gid) $direct = @(if ($Member.Contains($gid)) { $Member[$gid] | Where-Object { $null -ne $_ } }).Count if ($rows.Count -eq $before) { & $row $null $(if ($MemberError.Contains($gid)) { 'Not read' } else { 'Empty' }) '' 0 } $userList = @($users.Values) $groups.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.EntraGroup' GroupName = $gName GroupType = $gType GroupSource = $gSource DirectMembers = $direct NestedGroups = $nestedGroups.Count Users = $userList.Count Guests = @($userList | Where-Object { (& $text $_ 'userType') -eq 'Guest' }).Count DisabledUsers = @($userList | Where-Object { (& $value $_ 'accountEnabled') -eq $false }).Count Description = & $text $g 'description' MembershipRule = & $text $g 'membershipRule' Mail = & $text $g 'mail' GroupId = $gid Error = $errors -join '; ' }) } $all = $rows.ToArray() $people = @($all | Where-Object { $_.MemberType -eq 'User' } | Group-Object -Property MemberId) @{ Rows = $all Groups = $groups.ToArray() Stats = @{ Groups = $groups.Count Rows = $all.Count Users = $people.Count Guests = @($people | Where-Object { $_.Group[0].UserType -eq 'Guest' }).Count Disabled = @($people | Where-Object { $_.Group[0].AccountEnabled -eq $false }).Count NestedGroups = @($all | Where-Object MemberType -EQ 'Group' | Select-Object -ExpandProperty MemberId -Unique).Count EmptyGroups = @($groups | Where-Object { $_.DirectMembers -eq 0 -and -not $_.Error }).Count Unreadable = @($groups | Where-Object Error).Count } } } |