Private/Convert/Expand-GroupMembership.ps1
|
function Expand-GroupMembership { <# .SYNOPSIS Expands group memberships to return direct member SIDs. .DESCRIPTION Takes an array of SIDs and expands any that are groups to include their direct members. Non-group principals are returned as-is. This function performs non-recursive expansion, returning only direct members of groups. The function uses the PrincipalStore to determine if a principal is a group, then queries the 'member' attribute via LDAP to retrieve direct member DNs. Each member DN is then resolved to its SID and added to the result set. IMPORTANT: Returns both the original group SID AND its member SIDs. To get only members without the group itself, filter the output: $result | Where-Object { $_ -ne $groupSid } Results are cached to avoid redundant LDAP queries for the same groups. .PARAMETER SidList Array of SID strings to process. Groups will be expanded to their direct members, non-groups will be returned unchanged. .INPUTS System.String[] Accepts an array of SID strings via the pipeline. .OUTPUTS System.String[] Returns an array of SID strings including both original non-group principals and all direct members of any groups in the input. .EXAMPLE $sids = @('S-1-5-21-...-513', 'S-1-5-21-...-1104') $expanded = Expand-GroupMembership -SidList $sids Expands Domain Users group and custom group to include their direct members. .EXAMPLE $template.LowPrivilegeEnrollee | Expand-GroupMembership Expands any groups in the LowPrivilegeEnrollee array to show all direct members. .NOTES This function performs non-recursive expansion. Nested groups are returned as group objects, not expanded further. For recursive expansion, call this function multiple times or implement recursive logic. Requires that principals have already been resolved via Resolve-Principal so they exist in the PrincipalStore with objectClass information. #> [CmdletBinding()] [OutputType([string[]])] param ( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('SID', 'SecurityIdentifier')] [string[]]$SidList ) begin { # Initialize expanded group cache if it doesn't exist if (-not $script:ExpandedGroupCache) { $script:ExpandedGroupCache = @{} } $allMembers = [System.Collections.Generic.List[string]]::new() Write-Verbose "Starting group membership expansion for $($SidList.Count) principal(s)" } process { foreach ($sid in $SidList) { Write-Verbose "Processing SID: $sid" # Check if principal exists in PrincipalStore if (-not $script:PrincipalStore -or -not $script:PrincipalStore.ContainsKey($sid)) { Write-Verbose "SID '$sid' not in PrincipalStore, returning as-is" $allMembers.Add($sid) continue } $principal = $script:PrincipalStore[$sid] # Check if it's a group if ($principal.objectClass -notmatch '^group$') { Write-Verbose "SID '$sid' is not a group (objectClass: $($principal.objectClass)), returning as-is" $allMembers.Add($sid) continue } # It's a group - check cache first if ($script:ExpandedGroupCache.ContainsKey($sid)) { $cachedMembers = $script:ExpandedGroupCache[$sid] Write-Verbose "Cache HIT: Group '$sid' has $($cachedMembers.Count) cached member(s)" # Always add the group itself first $allMembers.Add($sid) # Then add all cached members (if any) foreach ($member in $cachedMembers) { $allMembers.Add($member) } continue } # Not in cache - query LDAP for group members Write-Verbose "Cache MISS: Querying LDAP for members of group '$sid' ($($principal.ntAccountName))" try { # Get the group's DN $groupDN = $principal.distinguishedName if (-not $groupDN) { Write-Warning "Group SID '$sid' has no distinguishedName, cannot expand" $allMembers.Add($sid) continue } # Query the group object for its member attribute $groupPath = "LDAP://$script:Server/$groupDN" $groupEntry = New-AuthenticatedDirectoryEntry -Path $groupPath if (-not $groupEntry) { Write-Warning "Could not create DirectoryEntry for group '$groupDN'" $allMembers.Add($sid) continue } # Get member attribute (contains DNs of direct members) $memberDNs = @($groupEntry.Properties['member']) if ($memberDNs.Count -eq 0) { Write-Verbose "Group '$($principal.ntAccountName)' has no members - keeping group itself in list" # Cache empty result (but keep the group in the output) $script:ExpandedGroupCache[$sid] = @() $allMembers.Add($sid) $groupEntry.Dispose() continue } Write-Verbose "Group '$($principal.ntAccountName)' has $($memberDNs.Count) direct member(s)" # Convert each member DN to SID $memberSids = [System.Collections.Generic.List[string]]::new() foreach ($memberDN in $memberDNs) { try { # Query for the member's objectSid $memberPath = "LDAP://$script:Server/$memberDN" $memberEntry = New-AuthenticatedDirectoryEntry -Path $memberPath if ($memberEntry -and $memberEntry.Properties['objectSid'].Count -gt 0) { $memberSid = (New-Object System.Security.Principal.SecurityIdentifier($memberEntry.Properties['objectSid'][0], 0)).Value $memberSids.Add($memberSid) Write-Verbose " Member: $memberDN -> $memberSid" # Ensure member is in PrincipalStore (triggers resolution if needed) $sidRef = [System.Security.Principal.SecurityIdentifier]::new($memberSid) $null = $sidRef | Resolve-Principal } else { Write-Warning "Could not retrieve objectSid for member '$memberDN'" } if ($memberEntry) { $memberEntry.Dispose() } } catch { Write-Warning "Failed to process member '$memberDN': $_" } } # Cache the expanded membership $script:ExpandedGroupCache[$sid] = $memberSids.ToArray() Write-Verbose "Cached $($memberSids.Count) member(s) for group '$sid'" # Add the group itself first $allMembers.Add($sid) # Then add all members foreach ($memberSid in $memberSids) { $allMembers.Add($memberSid) } $groupEntry.Dispose() } catch { Write-Warning "Failed to expand group '$sid': $_" # Return the group itself on error $allMembers.Add($sid) } } } end { # Return unique list $uniqueMembers = $allMembers | Sort-Object -Unique Write-Verbose "Group expansion complete: $($SidList.Count) input(s) -> $($uniqueMembers.Count) unique member(s)" return $uniqueMembers } } |