GetUnlinkedGPOs.psm1

<#
.SYNOPSIS
 
Retrieves all GPOs in a domain. For each GPO, it determines whether the GPO is linked to any OU. All unlinked GPOs are returned.
 
.DESCRIPTION
 
For housekeeping reasons, you might want to get rid of GPOs that do not apply to anything. This includes unlinked GPOs as well as GPOs that have disabled links only or where both computer and user part are disabled.
 
The Get-UnlinkedGPOs function searches a domain for these GPOs and returns the resulting GPO objects. It also adds 3 boolean properties to each returned GPO:
$GPO.Unlinked: The GPO is selected because it is not linked at all
$GPO.AllLinksDisabled: The GPO is selected because it has links, but all of these links are disabled
$GPO.AllSettingsDisabled: The GPO is selected because all settings are disabled. Already contained in $GPO.GPOStatus, but for convenience.
 
DYNAMIC PARAMETERS
 
-Domain <String>
    The domain where the operation should be performed. This must the user's current domain or a trusting domain. Tab completion searches through the list of possible target domains.
 
    Required? true
    Position? 1
    Default value False
    Accept pipeline input? false
    Accept wildcard characters? false
 
.PARAMETER IncludeDisabledLinks
 
By default, only GPOs are returned that have no links at all. Use this switch to also return all GPOs that have no enabled links (all links are disabled).
 
.PARAMETER IncludeDisabledGPOs
 
By default, only GPOs are returned that have no links at all. Use this switch to also return all GPOs where all settings are disabled (both user and computer settings).
 
.INPUTS
 
This cmdlet does not take pipeline input
 
.OUTPUTS
 
[[Microsoft.GroupPolicy.GPMGPO]]
 
.EXAMPLE
 
Get-UnlinkedGPOs -Domain corp.contoso.com
 
Gets all unlinked GPOs in the corp.contoso.com domain.
 
.EXAMPLE
 
Get-UnlinkedGPOs -Domain corp.contoso.com -IncludeDisabledLinks | Remove-GPO -Confirm:$False
 
Gets all unlinked GPOs including those with disabled links and pipes them to Remove-GPO for instant deletion (not recommended to use in the first place).
 
.NOTES
 
There are a lot of samples how to find unlinked GPOs with Powershell. All of them use Get-GPReport against all GPOs and parse the report for <LinksTo>-Elements. Whilst this approach works well in small environments, it is a complete mess in large domains with thousands of GPOs.
 
This function thus takes a completely different approach. It retrieves all SOMs that have a populated GPLink attribute and creates a hash of all GPOs in all these GPLinks. Then it compares all GPO IDs to this hash.
 
Although prepraring the hash takes some time (roughly 2 seconds per 100 SOMs), the overall time is significantly lower compared to Get-GPReport.
 
#>

function Get-UnlinkedGPOs {

    [CmdletBinding()]
    [Alias()]
    Param(
        [Parameter()]
        [Switch] $IncludeDisabledLinks,
        [Parameter()]
        [Switch] $IncludeDisabledGPOs
    )

    DynamicParam {

        # To enable tab expansion for the target domain, create a hash of all trusting domains and add this
        # as a ValidateSet to both parameters. Also add the user's current domain.

        $DomainArray = New-Object System.Collections.ArrayList
        [void]$DomainArray.Add( $env:USERDNSDOMAIN )

        $Trusts = Get-ADTrust -LDAPFilter '(!(trustDirection=2))' -Properties Name -Server $env:USERDNSDOMAIN | Select-Object -Property 'Name'
        Foreach ( $Trust in $Trusts ) {
            [void]$DomainArray.Add( $Trust.Name )
        }

        # Create the DynamicParam Array. Each array member is a hashtable containing the parmeter definition.
        # ParameterAttributes is an embedded Array of hashtables containing the attributes for each ParameterSet.
        # The comments for the Domain parameter definition show some commonly used attributes.

        # Parameter references:
        # https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_functions_advanced_parameters
        # https://docs.microsoft.com/en-us/powershell/scripting/developer/cmdlet/validating-parameter-input
        # https://docs.microsoft.com/en-us/powershell/scripting/developer/cmdlet/parameter-attribute-declaration

        $DynamicParameters = @(
            @{
                Name = 'Domain'
                # ValidateCount = @( [int]Min, [int]Max )
                # ValidateLenght = @( [int]Min, [int]Max )
                # ValidateRange = @( [int]Min, [int]Max )
                # ValidateSet = @( 'a', 'b', 'c' )
                ValidateSet = $DomainArray
                ParameterAttributes = @(
                    @{
                        # ParameterSetName = 'a'
                        # Mandatory = $True
                        # ValueFromPipeline = $True
                        # ValueFromPipelineByPropertyName = $True
                        Mandatory = $True
                    }
                )
            }
        )
    
        # Create and populate the parameter dictionary
        $RuntimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
        Foreach( $DynamicParameter in $DynamicParameters ) {
            $RuntimeParameter = New-DynamicParameter @DynamicParameter
            $RuntimeParameterDictionary.Add( $DynamicParameter.Name, $RuntimeParameter )
        }

        Return $RuntimeParameterDictionary
    }

    Begin {
        Foreach ( $BoundParam in $PSBoundParameters.GetEnumerator() ) {
            New-Variable -Name $BoundParam.Key -Value $BoundParam.Value -ErrorAction 'SilentlyContinue' -Whatif:$False
        }
    }

    Process {

        $PDC = ( Get-ADDomain -Identity $Domain ).PDCEmulator
        Write-Verbose "Retrieving unlinked GPOs in domain $Domain ($PDC).`n"

        $UnlinkedGPOs = New-Object System.Collections.ArrayList
        $AllSoms = New-Object System.Collections.ArrayList
        $GPLinkHash = @{}

        Write-Verbose "Retrieving GPOs..."

        $ProcessingTime = ( Measure-Command {
            $AllGPOs = Get-GPO -All -Domain $Domain -Server $PDC
        } ).TotalSeconds

        Write-Verbose "Found $( $AllGPOs.Count ) GPOs in $ProcessingTime seconds.`n"

        Write-Verbose "Retrieving GPLink SOMs..."

        $ProcessingTime = ( Measure-Command { 
            $OUDomainSOMS = Get-ADObject -LDAPFilter '(&(|((objectClass=organizationalUnit)(objectClass=domain)))(gpLink=[LDAP*))' -Properties 'GPLink' -Server $PDC | Select-Object -Property 'GPLink'
            If ( $OUDomainSOMS ) {
                [void]$AllSOMs.AddRange( $OUDomainSOMS )
            }
            $SiteSOMS = Get-ADReplicationSite -Filter * -Properties 'GPLink' -Server $PDC | Where-Object { $_.GPLink -match '^\[LDAP' } | Select-Object -Property 'GPLink'
            If ( $SiteSOMs ) {
                [void]$AllSoms.AddRange( $SiteSoms )
            }
        } ).TotalSeconds

        Write-Verbose "Found $( $AllSOMs.Count ) SOMs in $ProcessingTime seconds.`n"

        Write-Verbose "Preparing linked GPO hashtable..."

        $ProcessingTime = ( Measure-Command {
            Foreach ( $SOM in $AllSOMs ) {

                # GPLink has a weird format - [GPO-DN;LinkFlags][GPO-DN;LinkFlags][...]
                # remove leading [ and trailing ], then split on ][

                $GPLinks = $SOM.GPLink.Substring( 1, $SOM.GPLink.Length - 2 ) -split '\]\['

                # we want an array that holds all GPO Ids that are linked somewhere. So extract all GPO IDs from the GPLinks

                Foreach ( $GPLink in $GPLinks ) {

                    # GUID is 11...47, Flag is last char.
                    $GpoGuid = $GPLink.Substring( 11, 36 )
                    $Flags = $GPLink.Substring( $GPLink.Length -1 )
                    $Enabled   = ( ( $Flags -band 1 ) -eq 0 )  # $Flags bit 0 unset means "Link enabled"

                    # If the GPO hash already contains 1, do NOT overwrite it. This ensures
                    # that if at least one link is enabled, the hash always contains 1.
                    If ( $GPLinkHash[ $GpoGuid ] -ne 1 ) {
                        $GPLinkHash[ $GpoGuid ] = $Enabled
                    }
               }
            }
        } ).TotalSeconds

        Write-Verbose "GPO hashtable containing $( $GPLinkHash.Count ) GUIDs prepared in $ProcessingTime seconds.`n"

        Write-Verbose "Processing $( $AllGPOs.Count ) GPOs..."

        $ProcessingTime = ( Measure-Command {
            Foreach ( $GPO in $AllGPOs ) {
                Add-Member -InputObject $GPO -MemberType NoteProperty -Name 'Unlinked' -Value $False
                Add-Member -InputObject $GPO -MemberType NoteProperty -Name 'AllLinksDisabled' -Value $False
                Add-Member -InputObject $GPO -MemberType NoteProperty -Name 'AllSettingsDisabled' -Value $False
                # GPO is not linked at all...
                If ( -not $GPLinkHash.Contains( $GPO.ID.Guid ) ) {
                    [void]$UnlinkedGPOs.Add( $GPO )
                    $GPO.Unlinked = $True
                } 
                # GPO is linked but all links are disabled - the GUID hash contains 1 if at least one link is enabled.
                ElseIf ( $IncludeDisabledLinks -and $GPLinkHash[ $GPO.ID.Guid ] -ne 1 ) {
                    [void]$UnlinkedGPOs.Add( $GPO )
                    $GPO.AllLinksDisabled = $True
                }
                # GpoStatus is 'AllSettingsDisabled'
                If ( $IncludeDisabledGPOs -and $GPO.GPOStatus.Value__ -eq 0 ) {
                    [void]$UnlinkedGPOs.Add( $GPO )
                    $GPO.AllSettingsDisabled = $True
                }

            }
        } ).TotalSeconds

        Write-Verbose "$( $AllGPOs.Count ) GPOs processed in $ProcessingTime seconds.`n"

        Write-Verbose "$( $UnlinkedGPOs.Count ) unlinked GPOs found.`n"

        $UnlinkedGPOs
    }

    End {}

}

Function New-DynamicParameter {
    # based on the work of adamtheautomator
    # https://github.com/adbertram/Random-PowerShell-Work/blob/master/PowerShell%20Internals/New-DynamicParam.ps1
    [CmdletBinding()]
    [OutputType('System.Management.Automation.RuntimeDefinedParameter')]
    param (
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()]
        [String] $Name,

        [Parameter()][ValidateNotNullOrEmpty()]
        [Type] $Type = [String],

        [Parameter()][ValidateNotNullOrEmpty()][ValidateCount( 2, 2 )]
        [Int[]] $ValidateCount,
        
        [Parameter()][ValidateNotNullOrEmpty()][ValidateCount( 2, 2 )]
        [Int[]] $ValidateLength,
        
        [Parameter()][ValidateNotNullOrEmpty()]
        [String] $ValidatePattern,

        [Parameter()][ValidateNotNullOrEmpty()][ValidateCount( 2, 2 )]
        [Int[]] $ValidateRange,

        [Parameter()][ValidateNotNullOrEmpty()]
        [Scriptblock] $ValidateScript,

        [Parameter()][ValidateNotNullOrEmpty()]
        [Array] $ValidateSet,

        [Parameter()][ValidateNotNullOrEmpty()]
        [Switch] $ValidateNotNullOrEmpty,
        
        [Parameter()][ValidateNotNullOrEmpty()]
        [Array] $ParameterAttributes
    )
    
    $AttribColl = New-Object System.Collections.ObjectModel.Collection[System.Attribute]

    Foreach ( $ParameterAttribute in $ParameterAttributes ) {
        $ParamAttrib = New-Object System.Management.Automation.ParameterAttribute
        # Get all settable properties of the $ParamAttrib object
        $AttribNames = ( Get-Member -InputObject $ParamAttrib -MemberType Property | Where-Object -FilterScript { $_.Definition -match '{.*set;.*}$' } ).Name
        # Loop through settable properties and assign value if present in $ParameterAttribute
        Foreach ( $AttribName in $AttribNames ){
            If ( $ParameterAttribute.$AttribName ) { $ParamAttrib.$AttribName = $ParameterAttribute.$AttribName }
        }
        $AttribColl.Add( $ParamAttrib )
    }

    $ValidationAttributes = @( 'Count', 'Length', 'Pattern', 'Range', 'Script', 'Set' )

    # create all validation attributes
    Foreach ( $ValidationAttribute in $ValidationAttributes ){
        If ( $PSBoundParameters.ContainsKey( "Validate$ValidationAttribute" )) {
            $TypeName = 'System.Management.Automation.Validate' + $ValidationAttribute + 'Attribute'
            $AttribColl.Add(( New-Object $TypeName -ArgumentList ( Get-Variable -Name "Validate$ValidationAttribute" ).Value ))
        }
    }

    # need to handle this one separately - it does not take parameters in its constructor
    If ( $ValidateNotNullOrEmpty.IsPresent ) {
        $AttribColl.Add(( New-Object System.Management.Automation.ValidateNotNullOrEmptyAttribute ))
    }
    
    $RuntimeParam = New-Object System.Management.Automation.RuntimeDefinedParameter( $Name, $Type, $AttribColl )
    Return $RuntimeParam
    
}