Public/Add-AdGroupNesting.ps1

function Add-AdGroupNesting {

    <#
        .SYNOPSIS
            Adds members to an Active Directory group with enhanced error handling and logging.

        .DESCRIPTION
            This function extends Add-ADGroupMember with:
            - Comprehensive error handling
            - Event logging
            - Duplicate membership checks
            - Progress tracking
            - Validation of group objects
            - Support for batch operations

            Function will check for valid AD group representing the identity parameter. It will check existing
            group membership and verify each member. If the current member exist, it will remain as member
            of the group. If user does not exist, or if it can't be found in the current AD, it will be removed.
            Last, each new member will checked on the current AD and will only be added to the group if is not
            already member of it.

        .PARAMETER Identity
            The group to modify. Can be specified as:
            - Distinguished Name (DN)
            - GUID (objectGUID)
            - Security Identifier (objectSid)
            - SAM Account Name (sAMAccountName)

        .PARAMETER Members
            One or more members to add to the group. Can be:
            - Users
            - Groups
            - Computers
            - Group managed service accounts
            Accepts single string or array of identifiers.

        .PARAMETER Server
            Specifies the Active Directory Domain Services instance to connect to.
            If not specified, the function uses the default domain controller for the current domain.

        .EXAMPLE
            Add-AdGroupNesting -Identity "Domain Admins" -Members "TheUser"
            Adds a single user to the Domain Admins group.

        .EXAMPLE
            $members = @("User1", "User2", "Group1")
            Add-AdGroupNesting -Identity "ITSupport" -Members $members -Verbose
            Adds multiple members to the ITSupport group with verbose output.

        .EXAMPLE
            "ServiceAccounts" | Add-AdGroupNesting -Members "svc_backup" -WhatIf
            Shows what would happen when adding a service account to a group. .INPUTS
            System.String
            You can pipe the Identity parameter to this function.
            The Identity parameter accepts string values representing AD group identifiers.

        .OUTPUTS
            System.Management.Automation.PSCustomObject
            Returns a summary object containing:
            - GroupName: The name of the modified group
            - MembersAdded: Array of successfully added members
            - MembersFailed: Array of members that failed to be added
            - TotalProcessed: Total count of members processed

        .NOTES
            Used Functions:
                Name ║ Module/Namespace
                ═══════════════════════════════════════╬══════════════════════════════
                Add-ADGroupMember ║ ActiveDirectory
                Get-ADGroupMember ║ ActiveDirectory
                Get-ADObject ║ ActiveDirectory
                Get-AdObjectType ║ EguibarIT
                Write-CustomLog ║ EguibarIT
                Write-CustomError ║ EguibarIT
                Get-FunctionDisplay ║ EguibarIT
                Import-MyModule ║ EguibarIT
                Write-Verbose ║ Microsoft.PowerShell.Utility
                Write-Debug ║ Microsoft.PowerShell.Utility
                Write-Error ║ Microsoft.PowerShell.Utility
                Write-Warning ║ Microsoft.PowerShell.Utility
                Write-Progress ║ Microsoft.PowerShell.Utility

        .NOTES
            Version: 2.2
            DateModified: 23/May/2025
            LastModifiedBy: Vicente Rodriguez Eguibar
                            vicente@eguibar.com
                            Eguibar IT
                            http://www.eguibarit.com

        .LINK
            https://github.com/vreguibar/EguibarIT/blob/main/Public/Add-AdGroupNesting.ps1 .LINK
            https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/
            implementing-least-privilege-administrative-models

        .LINK
            http://blogs.technet.com/b/lrobins/archive/2011/06/23/quot-admin-free-quot-active-directory-and-
            windows-part-1-understanding-privileged-groups-in-ad.aspx

        .LINK
            http://blogs.msmvps.com/acefekay/2012/01/06/using-group-nesting-strategy-ad-best-practices-for-group-strategy/

        .COMPONENT
            Active Directory

        .ROLE
            Identity Management

        .FUNCTIONALITY
            Group Management
    #>


    [CmdletBinding(
        SupportsShouldProcess = $true,
        ConfirmImpact = 'Medium'
    )]
    [OutputType([PSCustomObject])]

    Param (
        # Param1 Group which membership is to be changed
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $False,
            HelpMessage = 'Group which membership is to be changed',
            Position = 0)]
        [ValidateNotNullOrEmpty()]
        [Alias('Group', 'GroupName')]
        $Identity,

        # Param2 ID of New Member of the group
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $False,
            HelpMessage = 'ID of New Member of the group. Can be a single string or array.',
            Position = 1)]
        [ValidateNotNullOrEmpty()]
        [Alias('Member', 'Add')]
        $Members,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [Alias('DomainController', 'DC')]
        [string]
        $Server
    )

    Begin {
        Set-StrictMode -Version Latest

        # Initialize logging
        if ($null -ne $Variables -and
            $null -ne $Variables.Header) {

            $txt = ($Variables.Header -f
                (Get-Date).ToString('dd/MMM/yyyy'),
                $MyInvocation.Mycommand,
                (Get-FunctionDisplay -HashTable $PsBoundParameters -Verbose:$False)
            )
            Write-Verbose -Message $txt
        } #end If

        # Build the message of the event
        $sb = [System.Text.StringBuilder]::new()
        $sb.AppendLine('Function "{0}" was called successfully.' -f $MyInvocation.Mycommand) | Out-Null
        $sb.AppendLine('Parameters used by the function: ') | Out-Null
        $sb.AppendLine((Get-FunctionDisplay -HashTable $PsBoundParameters -Verbose:$False)) | Out-Null

        $Splat = @{
            CustomEventId  = ([EventID]::FunctionCalled)
            EventName      = ('Call to {0}' -f $MyInvocation.Mycommand)
            EventCategory  = [EventCategory]::Initialization
            Message        = $sb.ToString()
            CustomSeverity = [EventSeverity]::Information
            Verbose        = $PSBoundParameters['Verbose']
        }
        Write-CustomLog @Splat

        ##############################
        # Module imports

        Import-MyModule -Name 'ActiveDirectory' -Verbose:$false

        ##############################
        # Variables Definition

        [hashtable]$Splat = [hashtable]::New([StringComparer]::OrdinalIgnoreCase)
        [hashtable]$CommonParams = [hashtable]::New([StringComparer]::OrdinalIgnoreCase)

        # Define array lists
        $CurrentMembers = [System.Collections.ArrayList]::new()
        $processedMembers = [System.Collections.ArrayList]::new()
        $failedMembers = [System.Collections.ArrayList]::new()


        # Check if Identity is a group. Retrieve the object if not Microsoft.ActiveDirectory.Management.AdGroup.
        $Identity = Get-AdObjectType -Identity $Identity

        [hashtable]$CommonParams = @{
            ErrorAction = 'Stop'
        }

        if ($PSBoundParameters.ContainsKey('Server')) {
            $CommonParams['Server'] = $Server
        } #end IF

    } #end Begin

    Process {

        # Get group members
        Try {

            Write-Debug -Message ('Getting members of group {0}' -f $Identity)
            $CurrentMembers = Get-ADGroupMember -Identity $Identity -Recursive @CommonParams

            If ($null -eq $CurrentMembers) {
                Write-Debug -Message ('Group {0} has no members' -f $Identity)

            } Else {
                Write-CustomLog -EventInfo ([EventIDs]::GetGroupMembership) -Message ('Got members from group {0}' -f $Identity)
            } #end If-Else

        } Catch {

            $Splat = @{
                CreateWindowsEvent = $true
                EventInfo          = ([EventIDs]::FailedGetGroupMembership)
                Message            = 'Failed to retrieve members of the group "{0}". {1}' -f $Identity, $_
                EventName          = 'GetGroupMembersError'
            }
            Write-CustomError @Splat
        } #end Try-Catch


        try {
            Write-Debug -Message ('Adding members to group..: {0}' -f $Identity.SamAccountName)

            # Iterate members
            Foreach ($item in $Members) {
                $item = Get-AdObjectType -Identity $item

                Write-Debug -Message ('Validated member object: {0}' -f $Item.DistinguishedName)

                # Check if member is already in the group
                # FIX: Changed from -notcontains to -contains for correct logical check
                If (($null -ne $CurrentMembers) -and
                    ($CurrentMembers.DistinguishedName -contains $item.DistinguishedName)) {

                    Write-Debug -Message ('
                        {0} is already a member
                        of {1} group'
 -f
                        $item.Name, $Identity.Name
                    )
                    continue
                } #end If

                try {
                    Write-Debug -Message ('Adding: {0}' -f $Item)

                    If ($PSCmdlet.ShouldProcess($Identity.DistinguishedName, "Add member $item")) {
                        $Splat = @{
                            Identity = $Identity
                            Members  = $item
                        }

                        Add-ADGroupMember @Splat @CommonParams
                        [void]$processedMembers.Add($item.Name)

                        Write-CustomLog -EventInfo ([EventIDs]::SetGroupMembership) -Message ('Added member {0} to group {1}' -f
                            $item.Name, $Identity.Name)

                    } #end If
                } catch {

                    [void]$failedMembers.Add($item)

                    Write-CustomError -CreateWindowsEvent -EventInfo ([EventIDs]::FailedSetGroupMembership) -Message ('
                        Failed to add member "{0}"
                        to group "{1}".
                        {2}'
 -f
                        $item, $Identity, $_.Exception.Message
                    )

                    continue

                } #end try-catch

            } #end Foreach

            Write-Verbose -Message ('Members were added correctly to group {0}' -f $Identity.sAMAccountName)

        } catch {

            Write-Error -Message 'Error when adding group member'
            throw

        } #end Try-Catch

    } #end Process

    End {
        # Report results
        if ($processedMembers.Count -gt 0) {

            Write-Verbose -Message ('Successfully added {0} members to {1}' -f
                $processedMembers.Count, $Identity.Name)

        } #end If

        if ($failedMembers.Count -gt 0) {

            Write-Warning -Message ('Failed to add {0} members to {1}' -f
                $failedMembers.Count, $Identity.Name)

        } #end If

        if ($null -ne $Variables -and
            $null -ne $Variables.Footer) {

            $txt = ($Variables.Footer -f $MyInvocation.InvocationName,
                'adding members to the group.'
            )
            Write-Verbose -Message $txt
        } #end If
    } #end End

} #end Function Add-AdGroupNesting