Public/activedirectory/Get-ADNestedGroupMembership.ps1

#Requires -Version 5.1
function Get-ADNestedGroupMembership {
    <#
    .SYNOPSIS
        Retrieves all nested Active Directory group memberships for a principal
 
    .DESCRIPTION
        Resolves all direct and nested group memberships for one or more AD principals
        using a single LDAP query with the LDAP_MATCHING_RULE_IN_CHAIN OID (1.2.840.113556.1.4.1941).
        This approach is significantly faster than recursive Get-ADPrincipalGroupMembership calls
        because the domain controller performs the recursion server-side in a single round-trip.
        Each returned object indicates whether the membership is direct or inherited (nested).
 
    .PARAMETER Identity
        The SamAccountName, DistinguishedName, or GUID of one or more AD principals
        (user, group, or computer) to query. Accepts pipeline input.
 
    .PARAMETER Server
        The target domain controller to query. If omitted, the default DC discovery is used.
 
    .PARAMETER Credential
        Alternate PSCredential to authenticate against Active Directory.
 
    .EXAMPLE
        Get-ADNestedGroupMembership -Identity 'jdoe'
 
        Retrieves all direct and nested group memberships for user jdoe using the default DC.
 
    .EXAMPLE
        Get-ADNestedGroupMembership -Identity 'jdoe' -Server 'DC01.contoso.com'
 
        Retrieves all group memberships for user jdoe targeting a specific domain controller.
 
    .EXAMPLE
        'jdoe', 'svc-app01' | Get-ADNestedGroupMembership
 
        Retrieves nested group memberships for multiple principals via pipeline input.
 
    .OUTPUTS
        PSWinOps.ADNestedGroupMembership
        Returns one object per group membership with Identity, GroupName, GroupDN,
        GroupCategory, GroupScope, Description, IsDirect, and Timestamp properties.
 
    .NOTES
        Author: Franck SALLET
        Version: 1.0.0
        Last Modified: 2026-04-03
        Requires: PowerShell 5.1+ / Windows only
        Requires: ActiveDirectory RSAT module
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/4e638665-f466-4597-93c4-12f2ebfde571
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Identity,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]$Credential
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        # Validate ActiveDirectory module availability
        try {
            Import-Module -Name 'ActiveDirectory' -ErrorAction Stop
        }
        catch {
            $errorMessage = "[$($MyInvocation.MyCommand)] ActiveDirectory module not available: $_"
            Write-Error -Message $errorMessage
            throw
        }

        # Build common splatting hash for AD cmdlets
        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }
    }

    process {
        foreach ($identityValue in $Identity) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing identity: $identityValue"

            try {
                # Step 1: Resolve the principal to its DN and direct MemberOf
                $adObject = Get-ADObject -Filter "SamAccountName -eq '$identityValue'" `
                    -Properties 'ObjectClass', 'MemberOf', 'SamAccountName' `
                    @adParams -ErrorAction Stop

                if (-not $adObject) {
                    Write-Error -Message "[$($MyInvocation.MyCommand)] Identity not found in AD: $identityValue"
                    continue
                }

                $resolvedSam = $adObject.SamAccountName
                $distinguishedName = $adObject.DistinguishedName

                # Build a hashset of direct group DNs for O(1) lookup
                $directGroupDNs = [System.Collections.Generic.HashSet[string]]::new(
                    [StringComparer]::OrdinalIgnoreCase
                )
                if ($adObject.MemberOf) {
                    foreach ($memberDN in $adObject.MemberOf) {
                        $null = $directGroupDNs.Add($memberDN)
                    }
                }

                Write-Verbose -Message "[$($MyInvocation.MyCommand)] Found $($directGroupDNs.Count) direct group(s) for $identityValue"

                # Step 2: Single LDAP query for ALL nested groups via LDAP_MATCHING_RULE_IN_CHAIN
                $ldapFilter = "(member:1.2.840.113556.1.4.1941:=$distinguishedName)"
                $nestedGroups = Get-ADGroup -LDAPFilter $ldapFilter `
                    -Properties 'GroupCategory', 'GroupScope', 'Description' `
                    @adParams -ErrorAction Stop

                if (-not $nestedGroups) {
                    Write-Verbose -Message "[$($MyInvocation.MyCommand)] No group memberships found for $identityValue"
                    continue
                }

                # Step 3: Build results with IsDirect flag, sorted by GroupName
                $sortedGroups = $nestedGroups | Sort-Object -Property 'Name'

                foreach ($group in $sortedGroups) {
                    $isDirect = $directGroupDNs.Contains($group.DistinguishedName)

                    [PSCustomObject]@{
                        PSTypeName    = 'PSWinOps.ADNestedGroupMembership'
                        Identity      = $resolvedSam
                        GroupName     = $group.Name
                        GroupDN       = $group.DistinguishedName
                        GroupCategory = [string]$group.GroupCategory
                        GroupScope    = [string]$group.GroupScope
                        Description   = $group.Description
                        IsDirect      = $isDirect
                        Timestamp     = Get-Date -Format 'o'
                    }
                }
            }
            catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Error processing identity '$identityValue': $_"
                continue
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}