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 membership type, when it was created, its owners (and how many), its direct members, nested groups, and the unique users in it - all, guests and disabled accounts - counted through every nested group. Summary (AAC.EntraGroupMembershipSummary), one per group, in Export-EntraGroupMemberShip.ps1's CSV layout and labels: GroupName, GroupSource (Cloud, Windows Server AD), GroupType ('Security', 'Microsoft 365 / Dynamic', 'Mail-Enabled Security / Role-Assignable' ...), Members (everyone in the group, flattened: direct members and the members of every nested group at any depth, each once, nested groups themselves left out; '(No members)' when there are none), NestedGroupMembers (every nested group at any depth, by its path, with its own direct members: 'Group: a, b; Group > Inner: c'), then Owners ('Name (UPN); ...', '(No owners)'), CreatedDateTime (UTC) and MembershipType (Assigned, Dynamic, Dynamic (paused)). -Owner maps a group ID to its owners, -OwnerError to why they couldn't be read; every row and group carries the same three. A group whose members couldn't be read has no summary row; Unread lists them (@{ GroupName; Error }). Sections maps each group's ID to its own members and then each nested group's, in the order met: @{ Path ('' for the group, else 'nested > deeper'); Depth; Error; Members (its rows); Loops (IDs of groups already above on that path, not followed again) }. People (AAC.EntraGroupPerson): everyone in each group once - not nested groups - with Membership (Direct, Nested, or Direct and nested) and Through (the nested groups they come through). Returns @{ Rows; Groups; Sections; People; Summary; Unread; 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 = @{}, # Group ID -> its owners (users, service principals). [hashtable] $Owner = @{}, # Group ID -> why its owners couldn't be read. [hashtable] $OwnerError = @{} ) $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' } } # Assigned (members added by hand) or Dynamic (by a rule); a paused rule is said. $membershipType = { param($G) if (@(& $value $G 'groupTypes') -contains 'DynamicMembership') { if ((& $text $G 'membershipRuleProcessingState') -eq 'Paused') { 'Dynamic (paused)' } else { 'Dynamic' } } else { 'Assigned' } } $created = { param($G) $v = & $value $G 'createdDateTime' if ($v -is [datetime]) { $v.ToUniversalTime() } elseif ($v) { try { [datetime]::Parse([string]$v, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]'AdjustToUniversal, AssumeUniversal') } catch { $null } } } # 'Name (user principal name)' per owner; '(No owners)' or why they couldn't be read. $ownersOf = { param([string] $Id) if ($OwnerError.Contains($Id)) { return "(Not read: $([string]$OwnerError[$Id]))" } $list = @(if ($Owner.Contains($Id)) { $Owner[$Id] | Where-Object { $null -ne $_ } }) if (-not $list.Count) { return '(No owners)' } @(foreach ($o in $list) { $name = foreach ($key in 'displayName', 'userPrincipalName', 'id') { $v = & $text $o $key; if ($v) { $v; break } } $upn = & $text $o 'userPrincipalName' if ((& $memberType $o) -eq 'Service principal') { "$name (Service principal)" } elseif ($upn -and $upn -ne $name) { "$name ($upn)" } else { [string]$name } }) -join '; ' } $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' } } # Export-EntraGroupMemberShip.ps1's labels, for the summary rows. $summaryType = { param($G) $types = @(& $value $G 'groupTypes') @( 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 { 'Unknown' } if ($types -contains 'DynamicMembership') { 'Dynamic' } if ((& $value $G 'isAssignableToRole') -eq $true) { 'Role-Assignable' } ) -join ' / ' } $summaryName = { param($M) $name = foreach ($key in 'displayName', 'userPrincipalName', 'id') { $v = & $text $M $key; if ($v) { $v; break } } if ((& $memberType $M) -eq 'Group') { "$name (Group)" } else { [string]$name } } # Every group nested under -Id, at any depth, as 'Path: its own members' # - each group once, however many paths lead to it; $Seen guards loops. $nestedParts = { param([string] $Id, [string] $Label, [System.Collections.Generic.HashSet[string]] $Seen) foreach ($child in @(if ($Member.Contains($Id) -and -not $MemberError.Contains($Id)) { $Member[$Id] | Where-Object { $null -ne $_ -and (& $memberType $_) -eq 'Group' } })) { $childId = & $text $child 'id' if (-not $Seen.Add($childId)) { continue } $name = foreach ($key in 'displayName', 'userPrincipalName', 'id') { $v = & $text $child $key; if ($v) { $v; break } } $path = if ($Label) { "$Label > $name" } else { [string]$name } $own = @(if ($Member.Contains($childId) -and -not $MemberError.Contains($childId)) { $Member[$childId] | Where-Object { $null -ne $_ } }) "$($path): $(if ($own.Count) { @($own | ForEach-Object { & $summaryName $_ }) -join ', ' } else { '(No members)' })" & $nestedParts $childId $path $Seen } } $rows = [System.Collections.Generic.List[object]]::new() $groups = [System.Collections.Generic.List[object]]::new() $summary = [System.Collections.Generic.List[object]]::new() $unread = [System.Collections.Generic.List[object]]::new() $sectionsOf = @{} $people = [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 $gMembershipType = & $membershipType $g $gCreated = & $created $g $gOwners = & $ownersOf $gid $gOwnerCount = @(if ($Owner.Contains($gid) -and -not $OwnerError.Contains($gid)) { $Owner[$gid] | Where-Object { $null -ne $_ } }).Count $users = @{} $everyone = [ordered]@{} # every member that isn't a group, at any depth, once $nestedGroups = [System.Collections.Generic.HashSet[string]]::new() $errors = [System.Collections.Generic.List[string]]::new() # The group's own members, then each nested group's, in the order met. $sections = [System.Collections.Generic.List[object]]::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 MembershipType = $gMembershipType GroupCreated = $gCreated GroupOwners = $gOwners 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 } if ($M -and $kind -ne 'Group') { $mid = & $text $M 'id'; if (-not $everyone.Contains($mid)) { $everyone[$mid] = & $summaryName $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])") } $section = [pscustomobject]@{ Path = $(if ($Via) { $Via.Substring($gName.Length + 3) } else { '' }) # 'nested > deeper', '' for the group itself Depth = $Depth Error = $(if ($MemberError.Contains($Id)) { [string]$MemberError[$Id] } else { '' }) Members = [System.Collections.Generic.List[object]]::new() Loops = [System.Collections.Generic.HashSet[string]]::new() # groups already above on this path } $sections.Add($section) 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 $section.Members.Add($rows[$rows.Count - 1]) if ((& $memberType $m) -eq 'Group') { $childId = & $text $m 'id' if ($childId -ne $gid) { [void]$nestedGroups.Add($childId) } if ($Path -contains $childId) { [void]$section.Loops.Add($childId) } else { $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 } $sectionsOf[$gid] = $sections.ToArray() # Everyone in the group once - users, service principals, devices, # contacts - with how: Direct, Nested (through which groups) or both. $mine = @(for ($i = $before; $i -lt $rows.Count; $i++) { $rows[$i] }) foreach ($person in @($mine | Where-Object { $_.MemberType -and $_.MemberType -ne 'Group' } | Group-Object -Property MemberId | Sort-Object -Property { $_.Group[0].MemberName })) { $first = $person.Group[0] $isDirect = @($person.Group | Where-Object Membership -EQ 'Direct').Count -gt 0 $through = @($person.Group | Where-Object Membership -EQ 'Nested' | ForEach-Object { $_.Via.Substring($gName.Length + 3) } | Select-Object -Unique) $people.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.EntraGroupPerson' GroupName = $gName MemberName = $(if ($first.MemberName) { $first.MemberName } elseif ($first.UserPrincipalName) { $first.UserPrincipalName } else { $first.MemberId }) MemberType = $first.MemberType UserPrincipalName = $first.UserPrincipalName Mail = $first.Mail UserType = $first.UserType AccountEnabled = $first.AccountEnabled JobTitle = $first.JobTitle Department = $first.Department Membership = $(if ($isDirect -and $through.Count) { 'Direct and nested' } elseif ($isDirect) { 'Direct' } else { 'Nested' }) Through = $through -join '; ' GroupId = $gid MemberId = $first.MemberId }) } if ($MemberError.Contains($gid)) { $unread.Add(@{ GroupName = $gName; Error = [string]$MemberError[$gid] }) } else { $summary.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.EntraGroupMembershipSummary' GroupName = $gName GroupSource = $(if ((& $value $g 'onPremisesSyncEnabled') -eq $true) { 'Windows Server AD' } else { 'Cloud' }) GroupType = & $summaryType $g Members = $(if ($everyone.Count) { @($everyone.Values) -join ', ' } else { '(No members)' }) NestedGroupMembers = @(& $nestedParts $gid '' ([System.Collections.Generic.HashSet[string]]::new([string[]]@($gid)))) -join '; ' Owners = $gOwners CreatedDateTime = $(if ($gCreated) { $gCreated.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture) + ' UTC' } else { '' }) MembershipType = $gMembershipType }) } $userList = @($users.Values) $groups.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.EntraGroup' GroupName = $gName GroupType = $gType GroupSource = $gSource MembershipType = $gMembershipType Created = $gCreated Owners = $gOwners OwnerCount = $gOwnerCount 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() $users = @($all | Where-Object { $_.MemberType -eq 'User' } | Group-Object -Property MemberId) @{ Rows = $all Groups = $groups.ToArray() Sections = $sectionsOf People = $people.ToArray() Summary = $summary.ToArray() Unread = $unread.ToArray() Stats = @{ Groups = $groups.Count Rows = $all.Count Users = $users.Count Guests = @($users | Where-Object { $_.Group[0].UserType -eq 'Guest' }).Count Disabled = @($users | 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 } } } |