DomainManagement.psm1

$script:ModuleRoot = $PSScriptRoot
$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\DomainManagement.psd1").ModuleVersion

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName DomainManagement.Import.DoDotSource -Fallback $false
if ($DomainManagement_dotsourcemodule) { $script:doDotSource = $true }

<#
Note on Resolve-Path:
All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator.
This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS.
Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist.
This is important when testing for paths.
#>


# Detect whether at some level loading individual module files, rather than the compiled module was enforced
$importIndividualFiles = Get-PSFConfigValue -FullName DomainManagement.Import.IndividualFiles -Fallback $false
if ($DomainManagement_importIndividualFiles) { $importIndividualFiles = $true }
if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true }
if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true }
    
function Import-ModuleFile
{
    <#
        .SYNOPSIS
            Loads files into the module on module import.
         
        .DESCRIPTION
            This helper function is used during module initialization.
            It should always be dotsourced itself, in order to proper function.
             
            This provides a central location to react to files being imported, if later desired
         
        .PARAMETER Path
            The path to the file to load
         
        .EXAMPLE
            PS C:\> . Import-ModuleFile -File $function.FullName
     
            Imports the file stored in $function according to import policy
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path
    )
    
    $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath
    if ($doDotSource) { . $resolvedPath }
    else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) }
}

#region Load individual files
if ($importIndividualFiles)
{
    # Execute Preimport actions
    . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\preimport.ps1"
    
    # Import all internal functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Import all public functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Execute Postimport actions
    . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\postimport.ps1"
    
    # End it here, do not load compiled code below
    return
}
#endregion Load individual files

#region Load compiled code
<#
This file loads the strings documents from the respective language folders.
This allows localizing messages and errors.
Load psd1 language files for each language you wish to support.
Partial translations are acceptable - when missing a current language message,
it will fallback to English or another available language.
#>

Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'DomainManagement' -Language 'en-US'

function Assert-ADConnection
{
    <#
    .SYNOPSIS
        Ensures connection to AD is possible before performing actions.
     
    .DESCRIPTION
        Ensures connection to AD is possible before performing actions.
        Should be the first things all commands connecting to AD should call.
        Do this before invoking callbacks, as the configuration change becomes pointless if the forest is unavailable to begin with,
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to safely terminate the calling command in case of failure.
     
    .EXAMPLE
        PS C:\> Assert-ADConnection @parameters -Cmdlet $PSCmdlet
 
        Kills the calling command if AD is not available.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process
    {
        # A domain being unable to retrieve its own object can really only happen if the service is down
        try { $null = Get-ADDomain @parameters -ErrorAction Stop }
        catch {
            Write-PSFMessage -Level Warning -String 'Assert-ADConnection.Failed' -StringValues $Server -Tag 'failed' -ErrorRecord $_
            $Cmdlet.ThrowTerminatingError($_)
        }
    }
}


function Assert-Configuration
{
    <#
    .SYNOPSIS
        Ensures a set of configuration settings has been provided for the specified setting type.
     
    .DESCRIPTION
        Ensures a set of configuration settings has been provided for the specified setting type.
        This maps to the configuration variables defined in variables.ps1
        Note: Not ALL variables defined in that file should be mapped, only those storing individual configuration settings!
     
    .PARAMETER Type
        The setting type to assert.
 
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to safely terminate the calling command in case of failure.
     
    .EXAMPLE
        PS C:\> Assert-Configuration -Type Users
 
        Asserts, that users have already been specified.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Type,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    process
    {
        if ((Get-Variable -Name $Type -Scope Script -ValueOnly).Count -gt 0) { return }
        
        Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues $Type -FunctionName $Cmdlet.CommandRuntime

        $exception = New-Object System.Data.DataException("No configuration data provided for: $Type")
        $errorID = 'NotConfigured'
        $category = [System.Management.Automation.ErrorCategory]::NotSpecified
        $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, $Type)
        $cmdlet.ThrowTerminatingError($recordObject)
    }
}


function Compare-Array
{
    <#
    .SYNOPSIS
        Compares two arrays.
     
    .DESCRIPTION
        Compares two arrays.
     
    .PARAMETER ReferenceObject
        The first array to compare with the second array.
     
    .PARAMETER DifferenceObject
        The second array to compare with the first array.
     
    .PARAMETER OrderSpecific
        Makes the comparison order specific.
        By default, the command does not care for the order the objects are stored in.
     
    .PARAMETER Quiet
        Rather than returning a delta report object, return a single truth statement:
        - $true if the two arrays are equal
        - $false if the two arrays are NOT equal.
     
    .EXAMPLE
        PS C:\> Compare-Array -ReferenceObject $currentStateSorted.DisplayName -DifferenceObject $desiredStateSorted.PolicyName -Quiet -OrderSpecific
 
        Compares the two sets of names, and returns ...
        - $true if both sets contains the same names in the same order
        - $false if they do not
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [CmdletBinding()]
    Param (
        [object[]]
        $ReferenceObject,

        [object[]]
        $DifferenceObject,

        [switch]
        $OrderSpecific,

        [switch]
        $Quiet
    )
    
    process
    {
        #region Not Order Specific
        if (-not $OrderSpecific) {
            $delta = Compare-Object -ReferenceObject $ReferenceObject -DifferenceObject $DifferenceObject
            if ($delta) {
                if ($Quiet) { return $false }
                [PSCustomObject]@{
                    ReferenceObject = $ReferenceObject
                    DifferenceObject = $DifferenceObject
                    Delta = $delta
                    IsEqual = $false
                }
                return
            }
            else {
                if ($Quiet) { return $true }
                [PSCustomObject]@{
                    ReferenceObject = $ReferenceObject
                    DifferenceObject = $DifferenceObject
                    Delta = $delta
                    IsEqual = $true
                }
                return
            }
        }
        #endregion Not Order Specific

        #region Order Specific
        else {
            if ($Quiet -and ($ReferenceObject.Count -ne $DifferenceObject.Count)) { return $false }
            $result = [PSCustomObject]@{
                ReferenceObject = $ReferenceObject
                DifferenceObject = $DifferenceObject
                Delta = @()
                IsEqual = $true
            }
            
            $maxCount = [math]::Max($ReferenceObject.Count, $DifferenceObject.Count)
            [System.Collections.ArrayList]$indexes = @()

            foreach ($number in (0..($maxCount - 1))) {
                if ($number -ge $ReferenceObject.Count) {
                    $null = $indexes.Add($number)
                    continue
                }
                if ($number -ge $DifferenceObject.Count) {
                    $null = $indexes.Add($number)
                    continue
                }
                if ($ReferenceObject[$number] -ne $DifferenceObject[$number]) {
                    if ($Quiet) { return $false }
                    $null = $indexes.Add($number)
                    continue
                }
            }

            if ($indexes.Count -gt 0) {
                $result.IsEqual = $false
                $result.Delta = $indexes.ToArray()
            }

            $result
        }
        #endregion Order Specific
    }
}


function Compare-Property
{
    <#
    .SYNOPSIS
        Helper function simplifying the changes processing.
     
    .DESCRIPTION
        Helper function simplifying the changes processing.
     
    .PARAMETER Property
        The property to use for comparison.
     
    .PARAMETER Configuration
        The object that was used to define the desired state.
     
    .PARAMETER ADObject
        The AD Object containing the actual state.
     
    .PARAMETER Changes
        An arraylist where changes get added to.
        The content of -Property will be added if the comparison fails.
     
    .PARAMETER Resolve
        Whether the value on the configured object's property should be string-resolved.
     
    .PARAMETER ADProperty
        The property on the ad object to use for the comparison.
        If this parameter is not specified, it uses the value from -Property.
     
    .EXAMPLE
        PS C:\> Compare-Property -Property Description -Configuration $ouDefinition -ADObject $adObject -Changes $changes -Resolve
 
        Compares the description on the configuration object (after resolving it) with the one on the ADObject and adds to $changes if they are inequal.
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Property,

        [Parameter(Mandatory = $true)]
        [object]
        $Configuration,

        [Parameter(Mandatory = $true)]
        [object]
        $ADObject,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.ArrayList]
        $Changes,

        [switch]
        $Resolve,

        [string]
        $ADProperty
    )
    
    begin
    {
        if (-not $ADProperty) { $ADProperty = $Property }
    }
    process
    {
        $propValue = $Configuration.$Property
        if ($Resolve) { $propValue = $propValue | Resolve-String }

        if (($propValue -is [System.Collections.ICollection]) -and ($ADObject.$ADProperty -is [System.Collections.ICollection])) {
            if (Compare-Object $propValue $ADObject.$ADProperty) {
                $null = $Changes.Add($Property)
            }
        }
        elseif ($propValue -ne $ADObject.$ADProperty) {
            $null = $Changes.Add($Property)
        }
    }
}


function Convert-Principal {
    <#
    .SYNOPSIS
        Converts a principal to either SID or NTAccount format.
     
    .DESCRIPTION
        Converts a principal to either SID or NTAccount format.
        It caches all resolutions, uses Convert-BuiltInToSID to resolve default builtin account names,
        uses Get-Domain to resolve foreign domain SIDs and names.
 
        Basically, it is a best effort attempt to resolve principals in a useful manner.
     
    .PARAMETER Name
        The name of the entity to convert.
     
    .PARAMETER OutputType
        Whether to return an NTAccount or SID.
        Defaults to SID
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Convert-Principal @parameters -Name contoso\administrator
 
        Tries to convert the user contoso\administrator into a SID
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [ValidateSet('SID','NTAccount')]
        [string]
        $OutputType = 'SID',

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    process {
        # Terminate if already cached
        if ($OutputType -eq 'SID' -and $script:cache_PrincipalToSID[$Name]) { return $script:cache_PrincipalToSID[$Name] }
        if ($OutputType -eq 'NTAccount' -and $script:cache_PrincipalToNT[$Name]) { return $script:cache_PrincipalToNT[$Name] }

        $builtInIdentity = Convert-BuiltInToSID -Identity $Name
        if ($builtInIdentity -ne $Name) { return $builtInIdentity }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        #region Processing Input SID
        if ($Name -as [System.Security.Principal.SecurityIdentifier]) {
            if ($OutputType -eq 'SID') {
                $script:cache_PrincipalToSID[$Name] = $Name -as [System.Security.Principal.SecurityIdentifier]
                return $script:cache_PrincipalToSID[$Name]
            }

            $script:cache_PrincipalToNT[$Name] = Get-Principal @parameters -Sid $Name -Domain $Name -OutputType NTAccount
            return $script:cache_PrincipalToNT[$Name]
        }
        #endregion Processing Input SID

        $ntAccount = $Name -as [System.Security.Principal.NTAccount]
        if ($OutputType -eq 'NTAccount') {
            $script:cache_PrincipalToNT[$Name] = $ntAccount
            return $script:cache_PrincipalToNT[$Name]
        }

        try {
            $script:cache_PrincipalToSID[$Name] = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
            return $script:cache_PrincipalToSID[$Name]
        }
        catch {
            $domainPart, $namePart = $ntAccount.Value.Split("\", 2)
            $domain = Get-Domain @parameters -DnsName $domainPart

            $param = @{
                Server = $domain.DNSRoot
            }
            $cred = Get-DMDomainCredential -Domain $domain.DNSRoot
            if ($cred) { $param['Credential'] = $cred }
            $adObject = Get-ADObject @param -LDAPFilter "(samAccountName=$namePart)" -Properties ObjectSID
            $script:cache_PrincipalToSID[$Name] = $adObject.ObjectSID
            $adObject.ObjectSID
        }
    }
}

function Get-Domain
{
    <#
    .SYNOPSIS
        Returns the domain object associated with a SID or fqdn.
     
    .DESCRIPTION
        Returns the domain object associated with a SID or fqdn.
 
        This command uses caching to avoid redundant and expensive lookups & searches.
     
    .PARAMETER Sid
        The domain SID to search by.
     
    .PARAMETER DnsName
        The domain FQDN / full dns name.
        May _also_ be just the Netbios name, but DNS name will take precedence!
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Domain @parameters -Sid $sid
 
        Returns the domain object associated with the $sid
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Sid')]
        [string]
        $Sid,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $DnsName,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        if (-not $script:SIDtoDomain) { $script:SIDtoDomain = @{ } }
        if (-not $script:DNStoDomain) { $script:DNStoDomain = @{ } }
        if (-not $script:NetBiostoDomain) { $script:NetBiostoDomain = @{ } }
        
        # Define variable to prevent superscope lookup
        $internalSid = $null
        $domainObject = $null
    }
    process
    {
        if ($Sid) {
            $internalSid = ([System.Security.Principal.SecurityIdentifier]$Sid).AccountDomainSid.Value
        }
        if ($internalSid -and $script:SIDtoDomain[$internalSid]) { return $script:SIDtoDomain[$internalSid] }
        if ($DnsName -and $script:DNStoDomain[$DnsName]) { return $script:DNStoDomain[$DnsName] }
        if ($DnsName -and $script:NetBiostoDomain[$DnsName]) { return $script:NetBiostoDomain[$DnsName] }

        $identity = $internalSid
        if ($DnsName) { $identity = $DnsName }

        $credsToUse = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        $forestObject = Get-ADForest @parameters
        foreach ($domainName in $forestObject.Domains) {
            if ($script:DNSToDomain.Keys -contains $domainName) { continue }
            try {
                $domainObject = Get-ADDomain -Server $domainName @credsToUse -ErrorAction Stop
                $script:SIDtoDomain["$($domainObject.DomainSID)"] = $domainObject
                $script:DNStoDomain["$($domainObject.DNSRoot)"] = $domainObject
                $script:NetBiostoDomain["$($domainObject.NetBIOSName)"] = $domainObject
            }
            catch { }
        }

        if ($script:SIDtoDomain[$identity]) { return $script:SIDtoDomain[$identity] }
        if ($script:DNStoDomain[$identity]) { return $script:DNStoDomain[$identity] }
        if ($script:NetBiostoDomain[$identity]) { return $script:NetBiostoDomain[$identity] }

        try { $domainObject = Get-ADDomain @parameters -Identity $identity -ErrorAction Stop }
        catch {
            if (-not $domainObject) {
                try { $domainObject = Get-ADDomain -Identity $identity -ErrorAction Stop }
                catch { }
            }
            if (-not $domainObject) { throw }
        }

        if ($domainObject) {
            $script:SIDtoDomain["$($domainObject.DomainSID)"] = $domainObject
            $script:DNStoDomain["$($domainObject.DNSRoot)"] = $domainObject
            $script:NetBiostoDomain["$($domainObject.NetBIOSName)"] = $domainObject
            if ($DnsName) { $script:DNStoDomain[$DnsName] = $domainObject }
            $domainObject
        }
    }
}

function Get-Domain2
{
    <#
    .SYNOPSIS
        Returns the direct domain object accessible via the server/credential parameter connection.
     
    .DESCRIPTION
        Returns the direct domain object accessible via the server/credential parameter connection.
        Caches data for subsequent calls.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Domain2 @parameters
 
        Returns the domain associated with the specified connection information
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server = '<Default>',

        [PSCredential]
        $Credential
    )
    
    begin
    {
        # Note: Module Scope variable solely maintained in this file
        # Scriptscope for data persistence only
        if (-not ($script:directDomainObjectCache)) {
            $script:directDomainObjectCache = @{ }
        }
    }
    process
    {
        if ($script:directDomainObjectCache["$Server"]) {
            return $script:directDomainObjectCache["$Server"]
        }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $adObject = Get-ADDomain @parameters
        $script:directDomainObjectCache["$Server"] = $adObject
        $adObject
    }
}


function Get-Principal
{
    <#
    .SYNOPSIS
        Returns a principal's resolved AD object if able to.
     
    .DESCRIPTION
        Returns a principal's resolved AD object if able to.
        Will throw an exception if the AD connection fails.
        Will return nothing if the target domain does not contain the specified principal.
        Uses the credentials provided by Set-DMDomainCredential if available.
 
        Results will be cached automatically, subsequent callls returning the cached results.
     
    .PARAMETER Sid
        The SID of the principal to search.
 
    .PARAMETER Name
        The name of the principal to search for.
 
    .PARAMETER ObjectClass
        The objectClass of the principal to search for.
     
    .PARAMETER Domain
        The domain in which to look for the principal.
 
    .PARAMETER OutputType
        The format in which the output is being returned.
        - ADObject: Returns the full AD object with full information from AD
        - NTAccount: Returns a simple NT Account notation.
 
    .PARAMETER Refresh
        Do not use cached data, reload fresh data.
 
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Principal -Sid $adObject.ObjectSID -Domain $redForestDomainFQDN
 
        Tries to return the principal from the specified domain based on the SID offered.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName = 'SID')]
        [string]
        $Sid,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $ObjectClass,

        [Parameter(Mandatory = $true)]
        [string]
        $Domain,

        [ValidateSet('ADObject','NTAccount')]
        [string]
        $OutputType = 'ADObject',

        [switch]
        $Refresh,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parametersAD = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process
    {
        $identity = $Sid
        if (-not $Sid) { $identity = "$($Domain)þ$($objectClass)þ$($Name)" }

        if ($script:resolvedPrincipals[$identity] -and -not $Refresh) {
            switch ($OutputType) {
                'ADObject' { return $script:resolvedPrincipals[$identity] }
                'NTAccount'
                {
                    if ($script:resolvedPrincipals[$identity].objectSID.AccountDomainSid) { return [System.Security.Principal.NTAccount]"$((Get-Domain @parametersAD -Sid $script:resolvedPrincipals[$identity].objectSID.AccountDomainSid).Name)\$($script:resolvedPrincipals[$identity].SamAccountName)" }
                    else { return [System.Security.Principal.NTAccount]"BUILTIN\$($script:resolvedPrincipals[$identity].SamAccountName)" }
                }
            }
        }

        try {
            if ($Domain -as [System.Security.Principal.SecurityIdentifier]) {
                $domainObject = Get-Domain @parametersAD -Sid $Domain
            }
            else {
                $domainObject = Get-Domain @parametersAD -DnsName $Domain
            }
            $parameters = @{
                Server = $domainObject.DNSRoot
            }
            $domainName = $domainObject.DNSRoot
        }
        catch {
            $parameters = @{
                Server = $Domain
            }
            $domainName = $Domain
        }
        if ($credentials = Get-DMDomainCredential -Domain $domainName) { $parameters['Credential'] = $credentials }

        $filter = "(objectSID=$Sid)"
        if (-not $Sid) {  $filter = "(&(objectClass=$ObjectClass)(name=$Name))" }

        try { $adObject = Get-ADObject @parameters -LDAPFilter $filter -ErrorAction Stop -Properties * }
        catch {
            try { $adObject = Get-ADObject @parametersAD -LDAPFilter $filter -ErrorAction Stop -Properties * }
            catch { }
            if (-not $adObject) {
                Write-PSFMessage -Level Warning -String 'Get-Principal.Resolution.Failed' -StringValues $Sid, $Name, $ObjectClass, $Domain -Target $PSBoundParameters
                throw
            }
        }
        if ($adObject) {
            $script:resolvedPrincipals[$identity] = $adObject
            switch ($OutputType) {
                'ADObject' { return $adObject }
                'NTAccount'
                {
                    if ($adObject.objectSID.AccountDomainSid) { return [System.Security.Principal.NTAccount]"$((Get-Domain @parametersAD -Sid $adObject.objectSID.AccountDomainSid).Name)\$($adObject.SamAccountName)" }
                    else { [System.Security.Principal.NTAccount]"BUILTIN\$($adObject.SamAccountName)" }
                }
            }
        }
    }
}

function Invoke-Callback
{
    <#
    .SYNOPSIS
        Invokes registered callbacks.
     
    .DESCRIPTION
        Invokes registered callbacks.
        Should be placed inside the begin block of every single Test-* and Invoke-* command.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command
     
    .EXAMPLE
        PS C:\> Invoke-Callback @parameters -Cmdlet $PSCmdlet
 
        Executes all callbacks against the specified server using the specified credentials.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [string]
        $Server,

        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    begin
    {
        if (-not $script:callbacks) { return }

        if (-not $script:callbackDomains) { $script:callbackDomains = @{ } }
        if (-not $script:callbackForests) { $script:callbackForests = @{ } }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $serverName = '<Default Domain>'
        if ($Server) { $serverName = $Server }
    }
    process
    {
        if (-not $script:callbacks) { return }

        if (-not $script:callbackDomains[$serverName]) {
            try { $script:callbackDomains[$serverName] = Get-ADDomain @parameters -ErrorAction Stop }
            catch { } # Ignore errors, might not work yet
        }
        if (-not $script:callbackForests[$serverName]) {
            try { $script:callbackForests[$serverName] = Get-ADForest @parameters -ErrorAction Stop }
            catch { } # Ignore errors, might not work yet
        }

        foreach ($callback in $script:callbacks.Values) {
            Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking' -StringValues $callback.Name
            try {
                $param = @($serverName, $Credential, $script:callbackDomains[$serverName], $script:callbackForests[$serverName])
                $callback.Scriptblock.Invoke($param)
                Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Success' -StringValues $callback.Name
            }
            catch {
                Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Failed' -StringValues $callback.Name -ErrorRecord $_
                $Cmdlet.ThrowTerminatingError($_)
            }
        }
    }
}


function New-Password
{
    <#
        .SYNOPSIS
            Generate a new, complex password.
         
        .DESCRIPTION
            Generate a new, complex password.
         
        .PARAMETER Length
            The length of the password calculated.
            Defaults to 32
 
        .PARAMETER AsSecureString
            Returns the password as secure string.
         
        .EXAMPLE
            PS C:\> New-Password
 
            Generates a new 32v character password.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [CmdletBinding()]
    Param (
        [int]
        $Length = 32,

        [switch]
        $AsSecureString
    )
    
    begin
    {
        $characters = @{
            0 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z')
            1 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z')
            2 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9)
            3 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@')
            4 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z')
            5 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z')
            6 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9)
            7 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@')
        }
    }
    process
    {
        $letters = foreach ($number in (1..$Length)) {
            $characters[(($number % 4) + (1..4 | Get-Random))] | Get-Random
        }
        if ($AsSecureString) { $letters -join "" | ConvertTo-SecureString -AsPlainText -Force }
        else { $letters -join "" }
    }
}


function New-TestResult
{
    <#
    .SYNOPSIS
        Generates a new test result object.
     
    .DESCRIPTION
        Generates a new test result object.
        Helper function that slims down the Test- commands.
     
    .PARAMETER ObjectType
        What kind of object is being processed (e.g.: User, OrganizationalUnit, Group, ...)
     
    .PARAMETER Type
        What kind of change needs to be performed
     
    .PARAMETER Identity
        Identity of the change item
     
    .PARAMETER Changed
        What properties - if any - need to be changed
     
    .PARAMETER Server
        The server the test was performed against
     
    .PARAMETER Configuration
        The configuration object containing the desired state.
     
    .PARAMETER ADObject
        The AD Object(s) containing the actual state.
     
    .EXAMPLE
        PS C:\> New-TestResult -ObjectType User -Type Changed -Identity $resolvedDN -Changed Description -Server $Server -Configuration $userDefinition -ADObject $adObject
 
        Creates a new test result object using the specified information.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ObjectType,

        [Parameter(Mandatory = $true)]
        [string]
        $Type,

        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [object[]]
        $Changed,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [PSFComputer]
        $Server,

        $Configuration,

        $ADObject
    )
    
    process
    {
        $object = [PSCustomObject]@{
            PSTypeName = "DomainManagement.$ObjectType.TestResult"
            Type = $Type
            ObjectType = $ObjectType
            Identity = $Identity
            Changed = $Changed
            Server = $Server
            Configuration = $Configuration
            ADObject = $ADObject
        }
        Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { $this.Identity } -Force
        $object
    }
}


function Resolve-ContentSearchBase
{
    <#
        .SYNOPSIS
            Resolves the ruleset for content enforcement into actionable search data.
         
        .DESCRIPTION
            Resolves the ruleset for content enforcement into actionable search data.
            This ensures that both Include and Exclude rules are properly translated into AD search queries.
            This command is designed to be called by all Test- commands across the entire module.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER NoContainer
            By defaults, containers are returned as well.
            Using this parameter prevents container processing.
         
        .EXAMPLE
            PS C:\> Resolve-ContentSearchBase @parameters
 
            Resolves the configured filters into searchbases for the targeted domain.
    #>

    
    [CmdletBinding()]
    Param (
        [string]
        $Server,

        [pscredential]
        $Credential,
        
        [switch]
        $NoContainer
    )
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        #region Utility Functions
        function Convert-DistinguishedName {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string[]]
                $Name,

                [switch]
                $Exclude
            )
            process {
                foreach ($nameItem in $Name) {
                    [PSCustomObject]@{
                        Name = $nameItem
                        Depth = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" }).Count
                        Elements = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" })
                        Exclude = $Exclude.ToBool()
                    }
                }
            }
        }

        function Get-ChildRelationship {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                $Parent,

                [Parameter(Mandatory = $true)]
                $Items
            )

            foreach ($item in $Items) {
                if ($item.Name -notlike "*,$($Parent.Name)") { continue }

                [PSCustomObject]@{
                    Child = $item
                    Parent = $Parent
                    Delta = $item.Depth - $Parent.Depth
                }
            }
        }

        function New-SearchBase {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [string]
                $Name,

                [ValidateSet('OneLevel', 'Subtree')]
                [string]
                $Scope = 'Subtree'
            )

            [PSCustomObject]@{
                SearchBase = $Name
                SearchScope = $Scope
            }
        }

        function Resolve-SearchBase {
            [CmdletBinding()]
            Param (
                [Parameter(Mandatory = $true)]
                $Parent,

                [Parameter(Mandatory = $true)]
                $Children,

                [string]
                $Server,

                [pscredential]
                $Credential
            )
            New-SearchBase -Name $Parent.Name -Scope OneLevel

            $childPaths = @{
                $Parent.Name = @{}
            }
            foreach ($childItem in $Children) {
                $subPath = $childItem.Name.Replace($Parent.Name, '').Trim(",")
                $subPathSegments = $subPath.Split(",")
                [System.Array]::Reverse($subPathSegments)

                $basePath = $Parent.Name
                foreach ($pathSegment in $subPathSegments) {
                    $newDN = $pathSegment, $basePath -join ","
                    $childPaths[$basePath][$newDN] = $newDN
                    if (-not $childPaths[$newDN]) { $childPaths[$newDN] = @{ } }
                    $basePath = $newDN
                }
            }

            $currentPath = ''
            [System.Collections.ArrayList]$pathsToProcess = @($Parent.Name)
            while ($pathsToProcess.Count -gt 0) {
                $currentPath = $pathsToProcess[0]
                $nextContainerObjects = Get-ADObject @parameters -SearchBase $currentPath -SearchScope OneLevel -LDAPFilter '(|(objectCategory=container)(objectCategory=organizationalUnit))'
                foreach ($containerObject in $nextContainerObjects) {
                    # Skip the actual children, as those (and their children) have already been processed
                    if ($containerObject.DistinguishedName -in $Children.Name) { continue }
                    if ($childPaths.ContainsKey($containerObject.DistinguishedName)) {
                        New-SearchBase -Name $containerObject.DistinguishedName -Scope OneLevel
                        $null = $pathsToProcess.Add($containerObject.DistinguishedName)
                    }
                    else {
                        New-SearchBase -Name $containerObject.DistinguishedName
                    }
                }
                $pathsToProcess.Remove($currentPath)
            }
        }
        #endregion Utility Functions

        Set-DMDomainContext @parameters
        $warningLevel = 'Warning'
        if (@(Get-ADOrganizationalUnit @parameters -ErrorAction Ignore -ResultSetSize 2 -Filter *).Count -eq 1) { $warningLevel = 'Verbose' }
    }
    process
    {
        #region preprocessing and early termination
        # Don't process any OUs if in Additive Mode
        if ($script:contentMode.Mode -eq 'Additive') { return }

        # If already processed, return previous results
        if (($Server -eq $script:contentSearchBases.Server) -and (-not (Compare-Object $script:contentMode.Include $script:contentSearchBases.Include)) -and (-not (Compare-Object $script:contentMode.Exclude $script:contentSearchBases.Exclude))) {
            if ($NoContainer) { $script:contentSearchBases.Bases | Where-Object SearchBase -notlike "CN=*" }
            else { $script:contentSearchBases.Bases }
            return
        }

        # Parse Includes and excludes
        $include = $script:contentMode.Include | Resolve-String | Convert-DistinguishedName
        $exclude = $script:contentMode.Exclude | Resolve-String | Convert-DistinguishedName -Exclude
        
        # If no todo: Terminate
        if (-not ($include -or $exclude)) { return }

        # Implicitly include domain when no custom include rules
        if ($exclude -and -not $include) {
            $include = $script:domainContext.DN | Convert-DistinguishedName
        }
        $allItems = @{}
        foreach ($item in $include) {
            if (-not (Test-ADObject @parameters -Identity $item.Name)) {
                Write-PSFMessage -Level $warningLevel -String 'Resolve-ContentSearchBase.Include.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server
                continue
            }
            $allItems[$item.Name] = $item
        }
        foreach ($item in $exclude) {
            if (-not (Test-ADObject @parameters -Identity $item.Name)) {
                Write-PSFMessage -Level $warningLevel -String 'Resolve-ContentSearchBase.Exclude.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server
                continue
            }
            $allItems[$item.Name] = $item
        }
        $relationship_All = foreach ($item in $allItems.Values) {
            Get-ChildRelationship -Parent $item -Items $allItems.Values
        }
        # Remove multiple include/exclude nestings producing reddundant inheritance detection
        $relationship_Relevant = $relationship_All | Group-Object { $_.Child.Name } | ForEach-Object {
            $_.Group | Sort-Object Delta | Select-Object -First 1
        }
        #endregion preprocessing and early termination

        [System.Collections.ArrayList]$itemsProcessed = @()
        [System.Collections.ArrayList]$targetOUsFound = @()

        foreach ($item in ($allItems.Values | Sort-Object Depth -Descending)) {
            $children = $relationship_Relevant | Where-Object { $_.Parent.Name -eq $item.Name }
            $allChildren = $relationship_All | Where-Object { $_.Parent.Name -eq $item.Name }

            # Case: Exclude Rule - will not be scanned
            if ($item.Exclude) {
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Casse: No Children - Just add a plain searchbase
            if (-not $children) {
                $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name))
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Case: No recursive Children that would exclude something - Add plain searchbase and remove all entries from all children as not needed
            if (-not ($allChildren.Child | Where-Object Exclude)) {
                $redundantFindings = $targetOUsFound | Where-Object SearchBase -in $allChildren.Child.Name
                foreach ($finding in $redundantFindings) { $targetOUsFound.Remove($finding) }
                $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name))
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Case: Children that require processing
            foreach ($searchbase in (Resolve-SearchBase @parameters -Parent $item -Children $children.Child)) {
                $null = $targetOUsFound.Add($searchbase)
            }
            $null = $itemsProcessed.Add($item)
        }

        $script:contentSearchBases.Include = $script:contentMode.Include
        $script:contentSearchBases.Exclude = $script:contentMode.Exclude
        $script:contentSearchBases.Server = $Server
        $script:contentSearchBases.Bases = $targetOUsFound.ToArray()

        foreach ($searchBase in $script:contentSearchBases.Bases) {
            if ($NoContainer -and ($searchBase.SearchBase -like 'CN=*')) { continue }
            Write-PSFMessage -String 'Resolve-ContentSearchBase.Searchbase.Found' -StringValues $searchBase.SearchScope, $searchBase.SearchBase, $script:domainContext.Fqdn
            $searchBase
        }
    }
}

function Resolve-String
{
    <#
        .SYNOPSIS
            Resolves a string, inserting all registered placeholders as appropriate.
         
        .DESCRIPTION
            Resolves a string, inserting all registered placeholders as appropriate.
            Use Register-DMNameMapping to configure your own replacements.
         
        .PARAMETER Text
            The string on which to perform the replacements.
 
        .EXAMPLE
            PS C:\> Resolve-String -Text $_.GroupName
 
            Returns the resolved name of the input string (probably the finalized name of a new group to add).
    #>

    [OutputType([string])]
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [string[]]
        $Text
    )
    
    begin
    {
        $replacementScript = {
            param (
                [string]
                $Match
            )

            if ($script:nameReplacementTable[$Match]) { $script:nameReplacementTable[$Match] }
            else { $Match }
        }

        $pattern = $script:nameReplacementTable.Keys -join "|"
    }
    process
    {
        foreach ($textItem in $Text) {
            [regex]::Replace($textItem, $pattern, $replacementScript)
        }
    }
}


function Test-ADObject
{
    <#
    .SYNOPSIS
        Tests, whether a given AD object already exists.
     
    .DESCRIPTION
        Tests, whether a given AD object already exists.
     
    .PARAMETER Identity
        Identity of the object to test.
        Must be a unique identifier accepted by Get-ADObject.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-ADObject -Identity $distinguishedName
 
        Tests whether the object referenced in $distinguishedName exists in the current domain.
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [string]
        $Server,

        [pscredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        try {
            $null = Get-ADObject -Identity $Identity @parameters -ErrorAction Stop
            return $true
        }
        catch {
            return $false
        }
    }
}


function Convert-BuiltInToSID
{
    <#
    .SYNOPSIS
        Converts pre-configured built in accounts into SID form.
     
    .DESCRIPTION
        Converts pre-configured built in accounts into SID form.
        These must be registered using Register-DMBuiltInSID.
        Returns all identity references that are not a BuiltIn account that was registered.
     
    .PARAMETER Identity
        The identity reference to translate.
     
    .EXAMPLE
        Convert-BuiltInToSID -Identity $Rule1.IdentityReference
         
        Converts to IdentityReference of $Rule1 if necessary
    #>

    [CmdletBinding()]
    Param (
        $Identity
    )
    
    process
    {
        if ($Identity -as [System.Security.Principal.SecurityIdentifier]) { return ($Identity -as [System.Security.Principal.SecurityIdentifier]) }
        if ($script:builtInSidMapping["$Identity"]) { return $script:builtInSidMapping["$Identity"] }
        $Identity
    }
}

function Get-PermissionGuidMapping
{
    <#
    .SYNOPSIS
        Retrieve a hashtable mapping permission guids to their respective name.
     
    .DESCRIPTION
        Retrieve a hashtable mapping permission guids to their respective name.
        This is retrieved from the target forest on first request, then cached for subsequent calls.
        The cache is specific to the targeted server and maintained as long as the process runs.
     
    .PARAMETER NameToGuid
        Rather than returning a hashtable mapping guid to name, return a hashtable mapping name to guid.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-PermissionGuidMapping -Server contoso.com
 
        Returns a hashtable mapping guids to rights from the contoso.com forest.
    #>

    [CmdletBinding()]
    Param (
        [switch]
        $NameToGuid,

        [PSFComputer]
        $Server = 'default',
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        # Script scope variables declared and maintained in this file only
        if (-not $script:schemaGuidToRightMapping) {
            $script:schemaGuidToRightMapping = @{ }
        }
        if (-not $script:schemaRightToGuidMapping) {
            $script:schemaRightToGuidMapping = @{ }
        }
    }
    process
    {
        [string]$identity = $Server
        if ($script:schemaGuidToRightMapping[$identity]) {
            if ($NameToGuid) { return $script:schemaRightToGuidMapping[$identity] }
            else { return $script:schemaGuidToRightMapping[$identity] }
        }
        Write-PSFMessage -Level Host -String 'Get-PermissionGuidMapping.Processing' -StringValues $identity
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        Get-ADObject -SearchBase "CN=Extended-Rights,$((Get-ADRootDSE).configurationNamingContext)" -LDAPFilter '(objectClass=controlAccessRight)' -Properties name, rightsGUID

        $configurationNC = (Get-ADRootDSE @parameters).configurationNamingContext
        $objects = Get-ADObject @parameters -SearchBase "CN=Extended-Rights,$configurationNC" -Properties Name,rightsGUID -LDAPFilter '(objectCategory=controlAccessRight)' # Exclude the schema object itself
        $processed = $objects | Select-PSFObject Name, 'rightsGUID to Guid as ID' | Select-PSFObject Name, 'ID to string'

        if (-not $processed) { return }
        $script:schemaGuidToRightMapping[$identity] = @{ "$([guid]::Empty)" = '<All>' }
        $script:schemaRightToGuidMapping[$identity] = @{ '<All>' = "$([guid]::Empty)" }
        
        foreach ($processedItem in $processed) {
            $script:schemaGuidToRightMapping[$identity][$processedItem.ID] = $processedItem.Name
            $script:schemaRightToGuidMapping[$identity][$processedItem.Name] = $processedItem.ID
        }
        if ($NameToGuid) { return $script:schemaRightToGuidMapping[$identity] }
        else { return $script:schemaGuidToRightMapping[$identity] }
    }
}


function Get-SchemaGuidMapping
{
    <#
    .SYNOPSIS
        Returns a hashtable mapping schema guids to the name of an attribute / class.
     
    .DESCRIPTION
        Returns a hashtable mapping schema guids to the name of an attribute / class.
        This hashtable is being generated (and cached) on a per-Server basis.
     
    .PARAMETER NameToGuid
        Return a hashtable mapping name to guid, rather than one mapping guid to name.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-SchemaGuidMapping @parameters
 
        Returns a hashtable mapping Guid of attributes or classes to their humanly readable name.
    #>

    [CmdletBinding()]
    Param (
        [switch]
        $NameToGuid,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    process
    {
        [string]$identity = '<default>'
        if ($Server) { $identity = $Server }

        if (Test-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity") {
            if ($NameToGuid) { return (Get-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity").NameToGuid }
            else { return (Get-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity").GuidToName }
        }

        Write-PSFMessage -Level Host -String 'Get-SchemaGuidMapping.Processing' -StringValues $identity
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $schemaNC = (Get-ADRootDSE @parameters).schemaNamingContext
        $objects = Get-ADObject @parameters -SearchBase $schemaNC -Properties Name,SchemaIDGuid -LDAPFilter '(schemaIDGUID=*)' # Exclude the schema object itself
        $processed = $objects | Select-PSFObject Name, 'SchemaIDGuid to Guid as ID' | Select-PSFObject Name, 'ID to string'

        if (-not $processed) { return }
        $data = [PSCustomObject]@{
            NameToGuid = @{ '<All>' = "$([guid]::Empty)" }
            GuidToName = @{ "$([guid]::Empty)" = '<All>' }
        }
        foreach ($processedItem in $processed) {
            $data.GuidToName[$processedItem.ID] = $processedItem.Name
            $data.NameToGuid[$processedItem.Name] = $processedItem.ID
        }
        Set-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity" -Value $data
        if ($NameToGuid) { return $data.NameToGuid }
        else { return $data.GuidToName }
    }
}


function Remove-RedundantAce {
    <#
    .SYNOPSIS
        Removes redundant Access Rule entries.
     
    .DESCRIPTION
        Removes redundant Access Rule entries.
        This only considers explicit rules for the specified identity reference.
        It compares the highest privileged access rule with other rules only.
 
        This is designed to help prevent an explicit "GenericAll" privilege making redundant other entries.
        This function is explicitly called in Invoke-DMAccessRule, in case of a planned ACE removal failing (and only for the failing identity).
        That will only lead to trouble if a conflicting ACE is in the desired state (and who would desire something like that??)
     
    .PARAMETER AccessControlList
        The access control list to remove redundant ACE from.
     
    .PARAMETER IdentityReference
        The identity for which to do the removing.
     
    .EXAMPLE
        PS C:\> Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $identity
 
        Removes all redundant access rules on $aclobject that apply to $identity.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [System.DirectoryServices.ActiveDirectorySecurity]
        $AccessControlList,

        $IdentityReference
    )

    $relevantRules = $AccessControlList.Access | Where-Object {
        ($_.IsInherited -eq $false) -and ($_.IdentityReference -eq $IdentityReference)
    } | Sort-Object ActiveDirectoryRights -Descending
    if (-not $relevantRules) { return }

    $master = $null
    $results = foreach ($rule in $relevantRules) {
        if ($null -eq $master) {
            $master = $rule
            $rule
            continue
        }

        # If rights are not a subset of master: It's not redundant
        if (($master.ActiveDirectoryRights -band $rule.ActiveDirectoryRights) -ne $rule.ActiveDirectoryRights) {
            $rule
            continue
        }

        if ($master.InheritanceType -ne $rule.InheritanceType) {
            $rule
            continue
        }
        if ($master.AccessControlType -ne $rule.AccessControlType) {
            $rule
            continue
        }
        if (($master.ObjectType -ne $rule.ObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.ObjectType)) {
            $rule
            continue
        }
        if (($master.InheritedObjectType -ne $rule.InheritedObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.InheritedObjectType)) {
            $rule
            continue
        }
    }

    # If none were filtered out: Don't do anything
    if ($results.Count -eq $relevantRules.Count) { return }

    foreach ($rule in $relevantRules) { $null = $AccessControlList.RemoveAccessRule($rule) }
    foreach ($rule in $results) { $AccessControlList.AddAccessRule($rule) }
}

function Test-AccessRuleEquality {
    <#
    .SYNOPSIS
        Compares two access rules with each other.
     
    .DESCRIPTION
        Compares two access rules with each other.
     
    .PARAMETER Rule1
        The first rule to compare
     
    .PARAMETER Rule2
        The second rule to compare
     
    .EXAMPLE
        PS C:\> Test-AccessRuleEquality -Rule1 $rule -Rule2 $rule2
 
        Compares $rule with $rule2
    #>

    [OutputType([System.Boolean])]
    [CmdletBinding()]
    param (
        $Rule1,
        $Rule2
    )

    if ($Rule1.ActiveDirectoryRights -ne $Rule2.ActiveDirectoryRights) { return $false }
    if ($Rule1.InheritanceType -ne $Rule2.InheritanceType) { return $false }
    if ($Rule1.ObjectType -ne $Rule2.ObjectType) { return $false }
    if ($Rule1.InheritedObjectType -ne $Rule2.InheritedObjectType) { return $false }
    if ($Rule1.AccessControlType -ne $Rule2.AccessControlType) { return $false }
    if ((Convert-BuiltInToSID -Identity $Rule1.IdentityReference) -ne (Convert-BuiltInToSID -Identity $Rule2.IdentityReference)) { return $false }
    return $true
}

function ConvertTo-GPLink {
    <#
    .SYNOPSIS
        Parses the gPLink property on ad objects.
     
    .DESCRIPTION
        Parses the gPLink property on ad objects.
        This allows analyzing gPLinkOrder without consulting the GPO API.
     
    .PARAMETER ADObject
        The adobject from which to take the gPLink property.
     
    .PARAMETER PolicyMapping
        Hashtable mapping distinguished names of group policies to their respective displayname.
     
    .EXAMPLE
        PS C:\> $adObjects | ConvertTo-GPLink
 
        Converts all objects in $adObjects to GPLink metadata.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $ADObject,

        [Hashtable]
        $PolicyMapping = @{ }
    )

    begin {
        $statusMapping = @{
            "0" = 'Enabled'
            "1" = 'Disabled'
            "2" = 'Enforced'
        }
    }
    process {
        foreach ($adItem in $ADObject) {
            if (-not $adItem.gPLink) { continue }
            if ([string]::IsNullOrWhiteSpace($adItem.gPLink)) { continue }

            $pieces = $adItem.gPLink -Split "\[" | Remove-PSFNull
            $index = ($pieces | Measure-Object).Count

            foreach ($gpLink in $pieces) {
                $linkObject = [PSCustomObject]@{
                    ADObject = $adItem
                    DistinguishedName = ($gpLink -replace '^LDAP://|;\d\]$')
                    Status = $statusMapping[($gpLink -replace '^.+;|\]$')]
                    DisplayName = $PolicyMapping[($gpLink -replace '^LDAP://|;\d\]$')]
                    Precedence = $index
                }
                Add-Member -InputObject $linkObject -MemberType ScriptMethod -Name ToString -Value {
                    switch ($this.Status) {
                        'Enabled' { $this.DisplayName }
                        'Disabled' { '~|{0}' -f $this.DisplayName }
                        'Enforced' { '*|{0}' -f $this.DisplayName }
                    }
                } -Force
                $linkObject
                $index--
            }
        }
    }
}

function Get-LinkedPolicy
{
    <#
    .SYNOPSIS
        Scans all managed OUs and returns linked GPOs.
     
    .DESCRIPTION
        Scans all managed OUs and returns linked GPOs.
        Use Set-DMContentMode to define what OUs are considered "managed".
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-LinkedPolicy @parameters
 
        Returns all group policy objects that are linked to OUs under management.
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        # OneLevel needs to be converted to base, as searching for OUs with "OneLevel" would return unmanaged OUs.
        # This search however is targeted at GPOs linked to managed OUs only.
        $translateScope = @{
            'Subtree' = 'Subtree'
            'OneLevel' = 'Base'
            'Base' = 'Base'
        }

        $gpoProperties = 'DisplayName','Description','DistinguishedName','CN','Created','Modified','gPCFileSysPath','ObjectGUID','isCriticalSystemObject'
    }
    process
    {
        $adObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADObject @parameters -LDAPFilter '(gPLink=*)' -SearchBase $searchBase.SearchBase -SearchScope $translateScope[$searchBase.SearchScope] -Properties gPLink
        }
        foreach ($adObject in $adObjects) {
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value ($adObject.gPLink | Split-GPLink) -Force
        }
        foreach ($adPolicyObject in ($adObjects.LinkedGroupPolicyObjects | Select-Object -Unique | Get-ADObject @parameters -Properties $gpoProperties)) {
            [PSCustomObject]@{
                PSTypeName = 'DomainManagement.GroupPolicy.Linked'
                DisplayName = $adPolicyObject.DisplayName
                Description = $adPolicyObject.Description
                DistinguishedName = $adPolicyObject.DistinguishedName
                LinkedTo = ($adObjects | Where-Object LinkedGroupPolicyObjects -Contains $adPolicyObject.DistinguishedName)
                CN = $adPolicyObject.CN
                Created = $adPolicyObject.Created
                Modified = $adPolicyObject.Modified
                Path = $adPolicyObject.gPCFileSysPath
                ObjectGUID = $adPolicyObject.ObjectGUID
                IsCritical = $adPolicyObject.isCriticalSystemObject
                ExportID = $null
                ImportTime = $null
                State = "Unknown"
            }
        }
    }
}


function Install-GroupPolicy
{
    <#
    .SYNOPSIS
        Uses PowerShell remoting to install a GPO into the target domain.
     
    .DESCRIPTION
        Uses PowerShell remoting to install a GPO into the target domain.
        Installation does not support using a Migration Table.
        Overwrites an existing GPO, if one with the same name exists.
        Also includes a tracking file to detect drift and when an update becomes necessary.
     
    .PARAMETER Session
        The PowerShell remoting session to the domain controller on which to import the GPO.
     
    .PARAMETER Configuration
        The configuration object representing the desired state for the GPO
     
    .PARAMETER WorkingDirectory
        The folder on the target machine where GPO-related working files are stored.
        Everything inside this folder is subject to deletion.
     
    .EXAMPLE
        PS C:\> Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
 
        Installs the specified group policy on the remote system connected to via $session.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session,

        [PSObject]
        $Configuration,

        [string]
        $WorkingDirectory
    )
    
    begin
    {
        $timestamp = (Get-Date).AddMinutes(-5)

        $stopDefault = @{
            Target = $Configuration
            Cmdlet = $PSCmdlet
            EnableException = $true
        }
    }
    process
    {
        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.CopyingFiles' -StringValues $Configuration.DisplayName -Target $Configuration
        try { Copy-Item -Path $Configuration.Path -Destination $WorkingDirectory -Recurse -ToSession $Session -ErrorAction Stop -Force }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.CopyingFiles.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }

        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.ImportingConfiguration' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            Invoke-Command -Session $session -ArgumentList $Configuration, $WorkingDirectory -ScriptBlock {
                param (
                    $Configuration,
                    $WorkingDirectory
                )
                try {
                    $domain = Get-ADDomain -Server localhost
                    $paramImportGPO = @{
                        Domain           = $domain.DNSRoot
                        Server           = $env:COMPUTERNAME
                        BackupGpoName  = $Configuration.DisplayName
                        TargetName     = $Configuration.DisplayName
                        Path           = $WorkingDirectory
                        CreateIfNeeded = $true
                        ErrorAction    = 'Stop'
                    }
                    $null = Import-GPO @paramImportGPO
                }
                catch { throw }
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ImportingConfiguration.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }

        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.ReadingADObject' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            $policyObject = Invoke-Command -Session $session -ArgumentList $Configuration -ScriptBlock {
                param ($Configuration)
                Get-ADObject -Server localhost -LDAPFilter "(&(objectCategory=groupPolicyContainer)(DisplayName=$($Configuration.DisplayName)))" -Properties Modified, gPCFileSysPath -ErrorAction Stop
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.Error' -StringValues $Configuration.DisplayName -ErrorRecord $_ }
        if (-not $policyObject) { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.NoObject' -StringValues $Configuration.DisplayName }
        if ($policyObject.Modified -lt $timestamp) { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.Timestamp' -StringValues $Configuration.DisplayName, $policyObject.Modified, $timestamp }

        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.UpdatingConfigurationFile' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            Invoke-Command -Session $session -ArgumentList $Configuration, $policyObject -ScriptBlock {
                param (
                    $Configuration,
                    $PolicyObject
                )
                $object = [PSCustomObject]@{
                    ExportID = $Configuration.ExportID
                    Timestamp = $PolicyObject.Modified
                }
                $object | Export-Clixml -Path "$($PolicyObject.gPCFileSysPath)\dm_config.xml" -Force -ErrorAction Stop
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.UpdatingConfigurationFile.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }

        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.DeletingImportFiles' -StringValues $Configuration.DisplayName -Target $Configuration
        Invoke-Command -Session $session -ArgumentList $WorkingDirectory -ScriptBlock {
            param ($WorkingDirectory)
            Remove-Item -Path "$WorkingDirectory\*" -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

function New-GpoWorkingDirectory
{
    <#
    .SYNOPSIS
        Creates a new temporary folder for GPO import.
     
    .DESCRIPTION
        Creates a new temporary folder for GPO import.
        Used during Invoke-DMGroupPolicy to ennsure a local working directory.
     
    .PARAMETER Session
        The powershell session to the target server operations are performed on.
     
    .EXAMPLE
        PS C:\> $workingFolder = New-GpoWorkingDirectory -Session $session
 
        Ensures the working folder exists and stores the session-local path in the $workingFolder variable.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [OutputType([string])]
    [CmdletBinding()]
    Param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session
    )
    
    process
    {
        try
        {
            Invoke-Command -Session $Session -ScriptBlock {
                if ($env:temp) {
                    try {
                        $item = New-Item -Path $env:temp -Name DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in %temp%: $_" }
                }
                elseif (Test-Path C:\temp) {
                    try {
                        $item = New-Item -Path C:\temp -Name DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in C:\temp: $_" }
                }
                else {
                    try {
                        $item = New-Item -Path C:\ -Name temp_DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in C:\: $_" }
                }
            } -ErrorAction Stop
        }
        catch { throw }
    }
}


function Remove-GroupPolicy
{
    <#
    .SYNOPSIS
        Removes the specified group policy object.
     
    .DESCRIPTION
        Removes the specified group policy object.
     
    .PARAMETER Session
        PowerShell remoting session to the server on which to perform the operation.
     
    .PARAMETER ADObject
        AD object data retrieved when scanning the domain using Get-LinkedPolicy.
     
    .EXAMPLE
        PS C:\> Remove-GroupPolicy -Session $session -ADObject $testItem.ADObject -ErrorAction Stop
 
        Removes the specified group policy object.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session,

        [PSObject]
        $ADObject
    )
    
    process
    {
        Write-PSFMessage -Level Debug -String 'Remove-GroupPolicy.Deleting' -StringValues $ADObject.DisplayName -Target $ADobject
        try {
            Invoke-Command -Session $Session -ArgumentList $ADObject -ScriptBlock {
                param (
                    $ADObject
                )
                $domainObject = Get-ADDomain -Server localhost

                Remove-GPO -Name $ADObject.DisplayName -ErrorAction Stop -Confirm:$false -Server $domainObject.PDCEmulator -Domain $domainObject.DNSRoot
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction -String 'Remove-GroupPolicy.Deleting.Failed' -StringValues $ADObject.DisplayName -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet }
    }
}


function Resolve-PolicyRevision
{
    <#
    .SYNOPSIS
        Checks the management state information of the specified policy object.
     
    .DESCRIPTION
        Checks the management state information of the specified policy object.
        It uses PowerShell remoting to read the configuration file with the associated group policy.
        This configuration file is stored when deploying a group policy using Invoke-DMGroupPolicy.
 
        This process is required to ensure only policies that need updating are thus updated.
     
    .PARAMETER Policy
        The policy object to validate and add the state information to.
     
    .PARAMETER Session
        The PowerShell Session to the PDCEmulator of the domain the GPO is part of.
     
    .EXAMPLE
        PS C:\> Resolve-PolicyRevision -Policy $managedPolicy -Session $session
 
        Checks the management state information of the specified policy object.
    #>

    [CmdletBinding()]
    Param (
        [psobject]
        $Policy,

        [System.Management.Automation.Runspaces.PSSession]
        $Session
    )
    
    process
    {
        #region Remote Call - Resolve GPO data => $result
        $result = Invoke-Command -Session $Session -ArgumentList $Policy.Path -ScriptBlock {
            param (
                $Path
            )

            $testPath = Join-Path -Path $Path -ChildPath gpt.ini
            $configPath = Join-Path -Path $Path -ChildPath dm_config.xml

            if (-not (Test-Path $testPath)) {
                [pscustomobject]@{
                    Success = $false
                    Exists = $null
                    ExportID = $null
                    Timestamp = $null
                    Error = $null
                }
                return
            }
            if (-not (Test-Path $configPath)) {
                [pscustomobject]@{
                    Success = $true
                    Exists = $false
                    ExportID = $null
                    Timestamp = $null
                    Error = $null
                }
                return
            }
            try { $data = Import-Clixml -Path $configPath -ErrorAction Stop }
            catch {
                [pscustomobject]@{
                    Success = $false
                    Exists = $true
                    ExportID = $null
                    Timestamp = $null
                    Error = $_
                }
                return
            }
            [pscustomobject]@{
                Success = $true
                Exists = $true
                ExportID = $data.ExportID
                Timestamp = $data.Timestamp
                Error = $null
            }
        }
        #endregion Remote Call - Resolve GPO data => $result

        #region Process results
        $Policy.ExportID = $result.ExportID
        $Policy.ImportTime = $result.Timestamp

        if (-not $result.Success) {
            if ($result.Exists) {
                $Policy.State = 'ConfigError'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.ErrorOnConfigImport' -StringValues $Policy.DisplayName, $result.Error.Exception.Message -Target $Policy }
                throw $result.Error
            else {
                $Policy.State = 'CriticalError'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.PolicyError' -StringValues $Policy.DisplayName -Target $Policy
                throw "Policy object not found in filesystem. Check existence and permissions!"
            }
        }
        else {
            if ($result.Exists) {
                $Policy.State  = 'Healthy'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.Success' -StringValues $Policy.DisplayName, $result.ExportID, $result.Timestamp -Target $Policy }
            else {
                $Policy.State = 'Unmanaged'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.Result.SuccessNotYetManaged' -StringValues $Policy.DisplayName -Target $Policy
            }
        }
        #endregion Process results
    }
}


function Split-GPLink {
    <#
    .SYNOPSIS
        Splits up the gPLink string on an AD object.
     
    .DESCRIPTION
        Splits up the gPLink string on an AD object.
        Returns the distinguishedname of the linked policies in the order they are linked.
     
    .PARAMETER LinkText
        The text from the gPLink property
     
    .EXAMPLE
        PS C:\> $adObject.gPLink | Split-GPLink
 
        Returns the distinguishednames of all linked group policies.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $LinkText
    )
    process
    {
        foreach ($line in $LinkText) {
            $lines = $line -split "\]\[" -replace '\]|\[' -replace '^LDAP://|;\d$'
            foreach ($lineItem in $lines) {
                if ([string]::IsNullOrWhiteSpace($lineItem)) { continue }
                $lineItem
            }
        }
    }
}

function Get-DMAccessRule
{
    <#
    .SYNOPSIS
        Returns the list of configured access rules.
     
    .DESCRIPTION
        Returns the list of configured access rules.
        These access rules define the desired state where delegation in a domain is concerned.
        This is consumed by Test-DMAccessRule, see the help on that command for more details.
     
    .PARAMETER Identity
        The Identity to filter by.
        This allows swiftly filtering by who is being granted permission.
     
    .EXAMPLE
        PS C:\> Get-DMAccessRule
 
        Returns a list of all registered accessrules
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Identity = '*'
    )
    
    process
    {
        ($script:accessRules.Values | Write-Output | Where-Object IdentityReference -like $Identity)
        ($script:accessCategoryRules.Values | Write-Output | Where-Object IdentityReference -like $Identity)
    }
}


function Invoke-DMAccessRule
{
    <#
    .SYNOPSIS
        Applies the desired state of accessrule configuration.
     
    .DESCRIPTION
        Applies the desired state of accessrule configuration.
        Define the desired state with Register-DMAccessRule.
        Test the desired state with Test-DMAccessRule.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-DMAccessRule -Server contoso.com
 
        Applies the desired access rule configuration to the contoso.com domain.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type accessRules -Cmdlet $PSCmdlet
        $testResult = Test-DMAccessRule @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'Update'
                {
                    Write-PSFMessage -Level Debug -String 'Invoke-DMAccessRule.Processing.Rules' -StringValues $testItem.Identity, $testItem.Changed.Count -Target $testItem

                    try { $aclObject = Get-AdsAcl @parameters -Path $testItem.Identity -EnableException }
                    catch { Stop-PSFFunction -String 'Invoke-DMAccessRule.Access.Failed' -StringValues $testItem.Identity -EnableException $EnableException -Target $testItem -Continue -ErrorRecord $_ }
                    foreach ($changeEntry in $testItem.Changed) {
                        #region Remove Access Rules
                        if ($changeEntry.Type -eq 'Delete') {
                            Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Remove' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType -Target $changeEntry
                            if (-not $aclObject.RemoveAccessRule($changeEntry.ADObject.OriginalRule)) {
                                Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $changeEntry.ADObject.OriginalRule.IdentityReference
                                foreach ($rule in $aclObject.GetAccessRules($true, $false, [System.Security.Principal.NTAccount])) {
                                    if (Test-AccessRuleEquality -Rule1 $rule -Rule2 $changeEntry.ADObject.OriginalRule) {
                                        Write-PSFMessage -Level Warning -String 'Invoke-DMAccessRule.AccessRule.Remove.Failed' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType -Target $changeEntry -Debug:$false
                                        break
                                    }
                                }
                            }
                            continue
                        }
                        #endregion Remove Access Rules

                        #region Add Access Rules
                        if ($changeEntry.Type -eq 'Create') {
                            Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Create' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry
                            try { $accessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new((Convert-Principal @parameters -Name $changeEntry.Configuration.IdentityReference), $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType, $changeEntry.Configuration.ObjectType, $changeEntry.Configuration.InheritanceType, $changeEntry.Configuration.InheritedObjectType) }
                            catch {
                                Stop-PSFFunction -String 'Invoke-DMAccessRule.AccessRule.Creation.Failed' -StringValues $testItem.Identity, $changeEntry.Configuration.IdentityReference -EnableException $EnableException -Target $changeEntry -Continue -ErrorRecord $_
                            }
                            $null = $aclObject.AddAccessRule($accessRule)
                            #TODO: Validation and remediation of success. Adding can succeed but not do anything, when accessrules are redundant. Potentially flag it for full replacement?
                            continue
                        }
                        #endregion Add Access Rules
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAccessRule.Processing.Execute' -ActionStringValues $testItem.Changed.Count -Target $testItem -ScriptBlock {
                        Set-AdsAcl @parameters -Path $testItem.Identity -AclObject $aclObject -EnableException -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'MissingADObject'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAccessRule.ADObject.Missing' -StringValues $testItem.Identity -Target $testItem -Debug:$false
                }
            }
        }
    }
}


function Register-DMAccessRule
{
    <#
    .SYNOPSIS
        Registers a new access rule as a desired state.
     
    .DESCRIPTION
        Registers a new access rule as a desired state.
        These are then compared with a domain's configuration when executing Test-DMAccessRule.
        See that command for more details on this procedure.
     
    .PARAMETER Path
        The path to the AD object to govern.
        This should be a distinguishedname.
        This path uses name resolution.
        For example %DomainDN% will be replaced with the DN of the target domain itself (and should probably be part of everyy single path).
     
    .PARAMETER ObjectCategory
        Instead of a path, define a category to apply the rule to.
        Categories are defined using Register-DMObjectCategory.
        This allows you to apply rules to a category of objects, rather than a specific path.
        With this you could apply a rule to all domain controller objects, for example.
     
    .PARAMETER Identity
        The identity to apply the rule to.
        Use the string '<Parent>' to apply the rule to the parent object of the object affected by this rule.
     
    .PARAMETER AccessControlType
        Whether this is an Allow or Deny rule.
     
    .PARAMETER ActiveDirectoryRights
        The actual rights to grant.
        This is a [string] type to allow some invalid values that happen in the field and are still applied by AD.
     
    .PARAMETER InheritanceType
        How the Access Rule is being inherited.
     
    .PARAMETER InheritedObjectType
        Name or Guid of property or right affected by this rule.
        Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...),
        what properties to affect (e.g.: User-Account-Control) or what extended rights to grant.
        Which in what combination applies depends on the ActiveDirectoryRights set.
     
    .PARAMETER ObjectType
        Name or Guid of property or right affected by this rule.
        Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...),
        what properties to affect (e.g.: User-Account-Control) or what extended rights to grant.
        Which in what combination applies depends on the ActiveDirectoryRights set.
 
    .PARAMETER Optional
        The path this access rule object is assigned to is optional and need not exist.
        This makes the rule apply only if the object exists, without triggering errors if it doesn't.
        It will also ignore access errors on the object.
        Note: Only if all access rules assigned to an object are set to $true, will the object be considered optional.
     
    .EXAMPLE
        PS C:\> Register-DMAccessRule -ObjectCategory DomainControllers -Identity '%DomainName%\Domain Admins' -ActiveDirectoryRights GenericAll
 
        Grants the domain admins of the target domain FullControl over all domain controllers, without any inheritance.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')]
        [string]
        $ObjectCategory,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Identity,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ActiveDirectoryRights,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Security.AccessControl.AccessControlType]
        $AccessControlType = 'Allow',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.DirectoryServices.ActiveDirectorySecurityInheritance]
        $InheritanceType = 'None',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectType = '<All>',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $InheritedObjectType = '<All>',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Optional = $false
    )
    
    process
    {
        switch ($PSCmdlet.ParameterSetName)
        {
            'Path' {
                if (-not $script:accessRules[$Path]) { $script:accessRules[$Path] = @() }
                $script:accessRules[$Path] += [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.AccessRule'
                    Path = $Path
                    IdentityReference = $Identity
                    AccessControlType = $AccessControlType
                    ActiveDirectoryRights = $ActiveDirectoryRights
                    InheritanceType = $InheritanceType
                    InheritedObjectType = $InheritedObjectType
                    ObjectType = $ObjectType
                    Optional = $Optional
                }
            }
            'Category' {
                if (-not $script:accessCategoryRules[$ObjectCategory]) { $script:accessCategoryRules[$ObjectCategory] = @() }
                $script:accessCategoryRules[$ObjectCategory] += [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.AccessRule'
                    Category = $ObjectCategory
                    IdentityReference = $Identity
                    AccessControlType = $AccessControlType
                    ActiveDirectoryRights = $ActiveDirectoryRights
                    InheritanceType = $InheritanceType
                    InheritedObjectType = $InheritedObjectType
                    ObjectType = $ObjectType
                    Optional = $Optional
                }
            }
        }
    }
}


function Test-DMAccessRule
{
    <#
    .SYNOPSIS
        Validates the targeted domain's Access Rule configuration.
     
    .DESCRIPTION
        Validates the targeted domain's Access Rule configuration.
        This is done by comparing each relevant object's non-inherited permissions with the Schema-given default permissions for its object type.
        Then the remaining explicit permissions that are not part of the schema default are compared with the configured desired state.
 
        The desired state can be defined using Register-DMAccessRule.
        Basically, two kinds of rules are supported:
        - Path based access rules - point at a DN and tell the system what permissions should be applied.
        - Rule based access rules - All objects matching defined conditions will be affected by the defined rules.
        To define rules - also known as Object Categories - use Register-DMObjectCategory.
        Example rules could be "All Domain Controllers" or "All Service Connection Points with the name 'Virtual Machine'"
 
        This command will test all objects that ...
        - Have at least one path based rule.
        - Are considered as "under management", as defined using Set-DMContentMode
        It uses a definitive approach - any access rule not defined will be flagged for deletion!
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMAccessRule -Server fabrikam.com
 
        Tests, whether the fabrikam.com domain conforms to the configured, desired state.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        #region Utility Functions
        function Compare-AccessRules {
            [CmdletBinding()]
            param (
                $ADRules,
                $ConfiguredRules,
                $DefaultRules,
                $ADObject
            )

            function Write-Result {
                [CmdletBinding()]
                param (
                    [ValidateSet('Create', 'Delete', 'FixConfig')]
                    [Parameter(Mandatory = $true)]
                    $Type,

                    $Identity,

                    [AllowNull()]
                    $ADObject,

                    [AllowNull()]
                    $Configuration
                )

                $item = [PSCustomObject]@{
                    Type = $Type
                    Identity = $Identity
                    ADObject = $ADObject
                    Configuration = $Configuration
                }
                Add-Member -InputObject $item -MemberType ScriptMethod ToString -Value { '{0}: {1}' -f $this.Type, $this.Identity } -Force -PassThru
            }

            $relevantADRules = :outer foreach ($adRule in $ADRules) {
                if ($adRule.OriginalRule.IsInherited) { continue }
                #region Skip OUs' "Protect from Accidential Deletion" ACE
                if (($adRule.AccessControlType -eq 'Deny') -and ($ADObject.ObjectClass -eq 'organizationalUnit')) {
                    if ($adRule.IdentityReference -eq 'everyone') { continue }
                    $eSid = [System.Security.Principal.SecurityIdentifier]'S-1-1-0'
                    $eName = $eSid.Translate([System.Security.Principal.NTAccount])
                    if ($adRule.IdentityReference -eq $eName) { continue }
                    if ($adRule.IdentityReference -eq $eSid) { continue }
                }
                #endregion Skip OUs' "Protect from Accidential Deletion" ACE

                foreach ($defaultRule in $DefaultRules) {
                    if (Test-AccessRuleEquality -Rule1 $adRule -Rule2 $defaultRule) { continue outer }
                }
                $adRule
            }

            :outer foreach ($relevantADRule in $relevantADRules) {
                foreach ($configuredRule in $ConfiguredRules) {
                    if (Test-AccessRuleEquality -Rule1 $relevantADRule -Rule2 $configuredRule) { continue outer }
                }
                Write-Result -Type Delete -Identity $relevantADRule.IdentityReference -ADObject $relevantADRule
            }

            :outer foreach ($configuredRule in $ConfiguredRules) {
                foreach ($defaultRules in $DefaultRules) {
                    if (Test-AccessRuleEquality -Rule1 $defaultRules -Rule2 $configuredRule) {
                        Write-Result -Type FixConfig -Identity $defaultRule.IdentityReference -ADObject $defaultRule -Configuration $configuredRule
                        continue outer
                    }
                }
                foreach ($relevantADRule in $relevantADRules) {
                    if (Test-AccessRuleEquality -Rule1 $relevantADRule -Rule2 $configuredRule) { continue outer }
                }
                Write-Result -Type Create -Identity $configuredRule.IdentityReference -Configuration $configuredRule
            }
        }

        function Convert-AccessRule {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $Rule,

                [Parameter(Mandatory = $true)]
                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )
            begin {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $convertCmdName = { Convert-DMSchemaGuid @parameters -OutType Name }.GetSteppablePipeline()
                $convertCmdName.Begin($true)
                $convertCmdGuid = { Convert-DMSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline()
                $convertCmdGuid.Begin($true)
            }
            process {
                foreach ($ruleObject in $Rule) {
                    $objectTypeGuid = $convertCmdGuid.Process($ruleObject.ObjectType)[0]
                    $objectTypeName = $convertCmdName.Process($ruleObject.ObjectType)[0]
                    $inheritedObjectTypeGuid = $convertCmdGuid.Process($ruleObject.InheritedObjectType)[0]
                    $inheritedObjectTypeName = $convertCmdName.Process($ruleObject.InheritedObjectType)[0]

                    try { $identity = Resolve-Identity @parameters -IdentityReference $ruleObject.IdentityReference -ADObject $ADObject }
                    catch { Stop-PSFFunction -String 'Convert-AccessRule.Identity.ResolutionError' -Target $ruleObject -ErrorRecord $_ -Continue }

                    [PSCustomObject]@{
                        PSTypeName = 'DomainManagement.AccessRule.Converted'
                        IdentityReference = $identity
                        AccessControlType = $ruleObject.AccessControlType
                        ActiveDirectoryRights = $ruleObject.ActiveDirectoryRights
                        InheritanceFlags = $ruleObject.InheritanceFlags
                        InheritanceType = $ruleObject.InheritanceType
                        InheritedObjectType = $inheritedObjectTypeGuid
                        InheritedObjectTypeName = $inheritedObjectTypeName
                        ObjectFlags = $ruleObject.ObjectFlags
                        ObjectType = $objectTypeGuid
                        ObjectTypeName = $objectTypeName
                        PropagationFlags = $ruleObject.PropagationFlags
                    }
                }
            }
            end {
                #region Inject Category-Based rules
                Get-CategoryBasedRules -ADObject $ADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid
                #endregion Inject Category-Based rules

                $convertCmdName.End()
                $convertCmdGuid.End()
            }
        }

        function Convert-AccessRuleIdentity {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [System.DirectoryServices.ActiveDirectoryAccessRule[]]
                $InputObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )
            begin {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $domainObject = Get-Domain2 @parameters
            }
            process {
                :main foreach ($accessRule in $InputObject) {
                    if ($accessRule.IdentityReference -is [System.Security.Principal.NTAccount]) {
                        Add-Member -InputObject $accessRule -MemberType NoteProperty -Name OriginalRule -Value $accessRule -PassThru
                        continue main
                    }
                    
                    if (-not $accessRule.IdentityReference.AccountDomainSid) {
                        $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $domainObject.DNSRoot -OutputType NTAccount
                    }
                    else {
                        $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $accessRule.IdentityReference -OutputType NTAccount
                    }
                    if (-not $identity) {
                        $identity = $accessRule.IdentityReference
                    }

                    $newRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new($identity, $accessRule.ActiveDirectoryRights, $accessRule.AccessControlType, $accessRule.ObjectType, $accessRule.InheritanceType, $accessRule.InheritedObjectType)
                    # Include original object as property in order to facilitate removal if needed.
                    Add-Member -InputObject $newRule -MemberType NoteProperty -Name OriginalRule -Value $accessRule -PassThru
                }
            }
        }

        function Resolve-Identity {
            [CmdletBinding()]
            param (
                [string]
                $IdentityReference,

                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )

            #region Parent Resolution
            if ($IdentityReference -eq '<Parent>') {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $domainObject = Get-Domain2 @parameters
                $parentPath = ($ADObject.DistinguishedName -split ",",2)[1]
                $parentObject = Get-ADObject @parameters -Identity $parentPath -Properties SamAccountName, Name, ObjectSID
                if (-not $parentObject.ObjectSID) {
                    Stop-PSFFunction -String 'Resolve-Identity.ParentObject.NoSecurityPrincipal' -StringValues $ADObject, $parentObject.Name, $parentObject.ObjectClass -EnableException $true -Cmdlet $PSCmdlet
                }
                if ($parentObject.SamAccountName) { return [System.Security.Principal.NTAccount]('{0}\{1}' -f $domainObject.NetBIOSName, $parentObject.SamAccountName) }
                else { return [System.Security.Principal.NTAccount]('{0}\{1}' -f $domainObject.NetBIOSName, $parentObject.Name) }
            }
            #endregion Parent Resolution

            #region Default Resolution
            $identity = Resolve-String -Text $ruleObject.IdentityReference
            if ($identity -as [System.Security.Principal.SecurityIdentifier]) {
                $identity = $identity -as [System.Security.Principal.SecurityIdentifier]
            }
            else {
                $identity = $identity -as [System.Security.Principal.NTAccount]
            }
            if ($null -eq $identity) { $identity = (Resolve-String -Text $ruleObject.IdentityReference) -as [System.Security.Principal.NTAccount] }

            $identity
            #endregion Default Resolution
        }

        function Get-CategoryBasedRules {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential,

                $ConvertNameCommand,

                $ConvertGuidCommand
            )

            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include ADObject, Server, Credential

            $resolvedCategories = Resolve-DMObjectCategory @parameters
            foreach ($resolvedCategory in $resolvedCategories) {
                foreach ($ruleObject in $script:accessCategoryRules[$resolvedCategory.Name]) {
                    $objectTypeGuid = $ConvertGuidCommand.Process($ruleObject.ObjectType)[0]
                    $objectTypeName = $ConvertNameCommand.Process($ruleObject.ObjectType)[0]
                    $inheritedObjectTypeGuid = $ConvertGuidCommand.Process($ruleObject.InheritedObjectType)[0]
                    $inheritedObjectTypeName = $ConvertNameCommand.Process($ruleObject.InheritedObjectType)[0]

                    try { $identity = Resolve-Identity @parameters -IdentityReference $ruleObject.IdentityReference }
                    catch { Stop-PSFFunction -String 'Convert-AccessRule.Identity.ResolutionError' -Target $ruleObject -ErrorRecord $_ -Continue }

                    [PSCustomObject]@{
                        PSTypeName = 'DomainManagement.AccessRule.Converted'
                        IdentityReference = $identity
                        AccessControlType = $ruleObject.AccessControlType
                        ActiveDirectoryRights = $ruleObject.ActiveDirectoryRights
                        InheritanceFlags = $ruleObject.InheritanceFlags
                        InheritanceType = $ruleObject.InheritanceType
                        InheritedObjectType = $inheritedObjectTypeGuid
                        InheritedObjectTypeName = $inheritedObjectTypeName
                        ObjectFlags = $ruleObject.ObjectFlags
                        ObjectType = $objectTypeGuid
                        ObjectTypeName = $objectTypeName
                        PropagationFlags = $ruleObject.PropagationFlags
                    }
                }
            }
        }
        #endregion Utility Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type accessRules -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        try { $null = Get-DMObjectDefaultPermission -ObjectClass top @parameters }
        catch {
            Stop-PSFFunction -String 'Test-DMAccessRule.DefaultPermission.Failed' -StringValues $Server -Target $Server -EnableException $false -ErrorRecord $_
            return
        }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        #region Process Configured Objects
        foreach ($key in $script:accessRules.Keys) {
            $resolvedPath = Resolve-String -Text $key

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'AccessRule'
                Identity = $resolvedPath
                Configuration = $script:accessRules[$key]
            }

            if (-not (Test-ADObject @parameters -Identity $resolvedPath)) {
                if ($script:accessRules[$key].Optional -notcontains $false) { continue }
                New-TestResult @resultDefaults -Type 'MissingADObject'
                continue
            }
            try { $adAclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException }
            catch {
                if ($script:accessRules[$key].Optional -notcontains $false) { continue }
                Write-PSFMessage -String 'Test-DMAccessRule.NoAccess' -StringValues $resolvedPath -Tag 'panic','failed' -Target $script:accessRules[$key] -ErrorRecord $_
                New-TestResult @resultDefaults -Type 'NoAccess'
                Continue
            }

            $adObject = Get-ADObject @parameters -Identity $resolvedPath
            
            $defaultPermissions = Get-DMObjectDefaultPermission @parameters -ObjectClass $adObject.ObjectClass
            $delta = Compare-AccessRules -ADRules ($adAclObject.Access | Convert-AccessRuleIdentity @parameters) -ConfiguredRules ($script:accessRules[$key] | Convert-AccessRule @parameters -ADObject $adObject) -DefaultRules $defaultPermissions -ADObject $adObject

            if ($delta) {
                New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject
                continue
            }
        }
        #endregion Process Configured Objects

        #region Process Non-Configured AD Objects
        $resolvedConfiguredObjects = $script:accessRules.Keys | Resolve-String

        $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -NoContainer)) {
            Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'AccessRule'
        }

        $convertCmdName = { Convert-DMSchemaGuid @parameters -OutType Name }.GetSteppablePipeline()
        $convertCmdName.Begin($true)
        $convertCmdGuid = { Convert-DMSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline()
        $convertCmdGuid.Begin($true)

        foreach ($foundADObject in $foundADObjects) {
            # Skip items that were defined in configuration, they were already processed
            if ($foundADObject.DistinguishedName -in $resolvedConfiguredObjects) { continue }

            $compareParam = @{
                ADRules = ((Get-AdsAcl @parameters -Path $foundADObject.DistinguishedName).Access | Convert-AccessRuleIdentity @parameters)
                DefaultRules = Get-DMObjectDefaultPermission @parameters -ObjectClass $foundADObject.ObjectClass
                ConfiguredRules = Get-CategoryBasedRules -ADObject $foundADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid
                ADObject = $foundADObject
            }
            $delta = Compare-AccessRules @compareParam

            if ($delta) {
                New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject -Identity $foundADObject.DistinguishedName
                continue
            }
        }

        $convertCmdName.End()
        $convertCmdGuid.End()
        #endregion Process Non-Configured AD Objects
    }
}


function Unregister-DMAccessRule
{
    <#
    .SYNOPSIS
        Removes a registered accessrule from the list of desired rules.
     
    .DESCRIPTION
        Removes a registered accessrule from the list of desired rules.
     
    .PARAMETER RuleObject
        The rule object to remove.
        Must be returned by Get-DMAccessRule
     
    .EXAMPLE
        PS C:\> Get-DMAccessRule | Unregister-DMAccessRule
 
        Removes all registered Access Rules, clearing the desired state of rules.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        [PsfValidateScript('DomainManagement.Validate.TypeName.AccessRule', ErrorString = 'DomainManagement.Validate.TypeName.AccessRule.Failed')]
        $RuleObject
    )
    
    process
    {
        foreach ($ruleItem in $RuleObject) {
            if ($ruleItem.Path) {
                $script:accessRules[$ruleItem.Path] = $script:accessRules[$ruleItem.Path] | Where-Object { $_ -ne $ruleItem}
            }
            if ($ruleItem.Category) {
                $script:accessCategoryRules[$ruleItem.Category] = $script:accessCategoryRules[$ruleItem.Category] | Where-Object { $_ -ne $ruleItem}
            }
        }
    }
}


function Get-DMAcl
{
    <#
        .SYNOPSIS
            Lists registered acls.
         
        .DESCRIPTION
            Lists registered acls.
         
        .PARAMETER Path
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMAcls
 
            Lists all registered acls.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Path = '*'
    )
    
    process
    {
        ($script:acls.Values | Where-Object Path -like $Path)
    }
}


function Invoke-DMAcl
{
    <#
    .SYNOPSIS
        Applies the desired ACL configuration.
     
    .DESCRIPTION
        Applies the desired ACL configuration.
        To define the desired acl state, use Register-DMAcl.
         
        Note: The ACL suite of commands only manages the ACL itself, not the rules assigned to it!
        Explicitly, this makes this suite the tool to manage inheritance and ownership over an object.
        To manage AccessRules, look at the *-DMAccessRule commands.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMAcl -Server contoso.com
 
        Applies the configured, desired state of object Acl to all managed objects in contoso.com
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Acls -Cmdlet $PSCmdlet
        $testResult = Test-DMAcl @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'MissingADObject'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.MissingADObject' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
                'NoAccess'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.NoAccess' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
                'OwnerNotResolved'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.OwnerNotResolved' -StringValues $testItem.Identity, $testItem.ADObject.GetOwner([System.Security.Principal.SecurityIdentifier]) -Target $testItem
                    continue
                }
                'Changed'
                {
                    if ($testItem.Changed -contains 'Owner') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAcl.UpdatingOwner' -ActionStringValues ($testItem.Configuration.Owner | Resolve-String) -Target $testItem -ScriptBlock {
                            Set-AdsOwner @parameters -Path $testItem.Identity -Identity (Convert-Principal @parameters -Name ($testItem.Configuration.Owner | Resolve-String)) -EnableException -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($testItem.Changed -contains 'NoInheritance') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAcl.UpdatingInheritance' -ActionStringValues $testItem.Configuration.NoInheritance -Target $testItem -ScriptBlock {
                            if ($testItem.Configuration.NoInheritance) {
                                try { throw (New-Object System.NotImplementedException("This functionality is not yet available!")) }
                                catch {
                                    Stop-PSFFunction -Message "Error" -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet -Target $testItem -Tag 'error','failed','panic'
                                }
                            } #TODO: Implement Disable-AdsInheritance
                            else { Enable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false }
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
                'ShouldManage'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.ShouldManage' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
            }
        }
    }
}

function Register-DMAcl
{
    <#
    .SYNOPSIS
        Registers an active directory acl.
     
    .DESCRIPTION
        Registers an active directory acl.
        This acl will be maintained as configured during Invoke-DMAcl.
     
    .PARAMETER Path
        Path (distinguishedName) of the ADObject the acl is assigned to.
        Subject to string insertion.
     
    .PARAMETER Owner
        Owner of the ADObject.
        Subject to string insertion.
     
    .PARAMETER NoInheritance
        Whether inheritance should be disabled on the ADObject.
        Defaults to $false
 
    .PARAMETER Optional
        The path this acl object is assigned to is optional and need not exist.
        This makes the rule apply only if the object exists, without triggering errors if it doesn't.
        It will also ignore access errors on the object.
     
    .EXAMPLE
        PS C:\> Get-Content .\groups.json | ConvertFrom-Json | Write-Output | Register-DMAcl
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as acl configuration.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Owner,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $NoInheritance = $false,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Optional = $false
    )
    process
    {
        $script:acls[$Path] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.Acl'
            Path = $Path
            Owner = $Owner
            NoInheritance = $NoInheritance
            Optional = $Optional
        }
    }
}

function Test-DMAcl
{
    <#
        .SYNOPSIS
            Tests whether the configured groups match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured groups match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMGroup
 
            Tests whether the configured groups' state matches the current domain group setup.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Acls -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        #region processing configuration
        foreach ($aclDefinition in $script:acls.Values) {
            $resolvedPath = Resolve-String -Text $aclDefinition.Path

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'Acl'
                Identity = $resolvedPath
                Configuration = $aclDefinition
            }

            
            if (-not (Test-ADObject @parameters -Identity $resolvedPath))  {
                if ($aclDefinition.Optional) { continue }
                Write-PSFMessage -String 'Test-DMAcl.ADObjectNotFound' -StringValues $resolvedPath -Tag 'panic','failed' -Target $aclDefinition
                New-TestResult @resultDefaults -Type 'MissingADObject'
                Continue
            }

            try { $aclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException }
            catch {
                if ($aclDefinition.Optional) { continue }
                Write-PSFMessage -String 'Test-DMAcl.NoAccess' -StringValues $resolvedPath -Tag 'panic','failed' -Target $aclDefinition -ErrorRecord $_
                New-TestResult @resultDefaults -Type 'NoAccess'
                Continue
            }
            # Ensure Owner Name is present - may not always resolve
            $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier])
            if ($aclObject.Owner -and -not $ownerSID.AccountDomainSid) { Add-Member -InputObject $aclObject -MemberType NoteProperty -Name Owner2 -Value $aclObject.Owner }
            else {
                try { $domain = (Get-Domain @parameters -Sid $ownerSID.AccountDomainSid).DNSRoot }
                catch {
                    Write-PSFMessage -String 'Test-DMAcl.OwnerDomainNotResolved' -StringValues $resolvedPath -Tag 'panic','failed' -Target $aclDefinition -ErrorRecord $_
                    New-TestResult @resultDefaults -Type 'OwnerNotResolved'
                    Continue
                }
                try { $ntaccount = Get-Principal @parameters -Sid $ownerSID -Domain $domain -OutputType NTAccount }
                catch {
                    Write-PSFMessage -String 'Test-DMAcl.OwnerPrincipalNotResolved' -StringValues $resolvedPath -Tag 'panic','failed' -Target $aclDefinition -ErrorRecord $_
                    New-TestResult @resultDefaults -Type 'OwnerNotResolved'
                    Continue
                }
                Add-Member -InputObject $aclObject -MemberType NoteProperty -Name Owner2 -Value $ntaccount
            }

            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property Owner -Configuration $aclDefinition -ADObject $aclObject -Changes $changes -Resolve -ADProperty Owner2
            Compare-Property -Property NoInheritance -Configuration $aclDefinition -ADObject $aclObject -Changes $changes -ADProperty AreAccessRulesProtected

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $aclObject
            }
        }
        #endregion processing configuration

        #region check if all ADObejcts are managed
        <#
        Object Types ignored:
        - Service Connection Point
        - RID Set
        - DFSR Settings objects
        - Computer objects
        Pre-defining domain controllers or other T0 servers and their meta-information objects would be an act of futility and probably harmful.
        #>

        $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -NoContainer)) {
            Get-ADObject @parameters -LDAPFilter '(&(objectCategory=*)(!(|(objectCategory=serviceConnectionPoint)(objectCategory=rIDSet)(objectCategory=msDFSR-LocalSettings)(objectCategory=msDFSR-Subscriber)(objectCategory=msDFSR-Subscription)(objectCategory=computer))))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }
        
        $resolvedConfiguredPaths = $script:acls.Values.Path | Resolve-String
        $resultDefaults = @{
            Server = $Server
            ObjectType = 'Acl'
        }

        foreach ($foundADObject in $foundADObjects) {
            if ($foundADObject.DistinguishedName -in $resolvedConfiguredPaths) { continue }
            
            New-TestResult @resultDefaults -Type ShouldManage -ADObject $foundADObject -Identity $foundADObject.DistinguishedName
        }
        #endregion check if all ADObejcts are managed
    }
}


function Unregister-DMAcl
{
    <#
    .SYNOPSIS
        Removes a acl that had previously been registered.
     
    .DESCRIPTION
        Removes a acl that had previously been registered.
     
    .PARAMETER Path
        The path (distinguishedName) of the acl to remove.
     
    .EXAMPLE
        PS C:\> Get-DMAcl | Unregister-DMAcl
 
        Clears all registered acls.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Path
    )
    
    process
    {
        foreach ($pathItem in $Path) {
            $script:acls.Remove($pathItem)
        }
    }
}


function Get-DMGPLink
{
    <#
    .SYNOPSIS
        Returns the list of registered group policy links.
     
    .DESCRIPTION
        Returns the list of registered group policy links.
        Use Register-DMGPLink to register new group policy links.
     
    .PARAMETER PolicyName
        The name of the GPO to filter by.
     
    .PARAMETER OrganizationalUnit
        The name of the OU the GPO is assigned to.
     
    .EXAMPLE
        PS C:\> Get-DMGPLink
 
        Returns all registered GPLinks
    #>

    [CmdletBinding()]
    param (
        [string]
        $PolicyName = '*',
        
        [string]
        $OrganizationalUnit = '*'
    )
    
    process
    {
        ($script:groupPolicyLinks.Values.Values | Where-Object {
            ($_.PolicyName -like $PolicyName) -and ($_.OrganizationalUnit -like $OrganizationalUnit)
        })
    }
}


function Invoke-DMGPLink
{
    <#
    .SYNOPSIS
        Applies the desired group policy linking configuration.
     
    .DESCRIPTION
        Applies the desired group policy linking configuration.
        Use Register-DMGPLink to define the desired state.
         
        Note: Invoke-DMGroupPolicy uses links to safely determine GPOs it can delete!
        It will look for GPOs that have been linked to managed folders in order to avoid fragile name lookups.
        Removing the old links before cleaning up the associated GPOs might leave orphaned GPOs in your domain.
        To avoid deleting old links, use the -Disable parameter.
 
        Recommended execution order:
        - Invoke GPOs (without deletion)
        - Invoke GPLinks (with -Disable)
        - Invoke GPOs (with deletion)
        - Invoke GPLinks (without -Disable)
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Disable
        By default, undesired links are removed.
        With this parameter set it will instead disable undesired links.
        Use this in order to not lose track of previously linked GPOs.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGPLink
 
        Configures the current domain's group policy links as desired.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $Disable,

        [switch]
        $EnableException
    )
    
    begin
    {
        #region Utility Functions
        function Clear-Link {
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                [bool]
                $Disable
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            if (-not $Disable) {
                Set-ADObject @parameters -Identity $ADObject -Clear gPLink -ErrorAction Stop
                return
            }
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = ($ADObject.gPLink -replace ";\d\]",";1]") } -ErrorAction Stop
        }

        function New-Link {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                $Configuration,

                [Hashtable]
                $GpoNameMapping
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            $gpLinkString = ($Configuration | Sort-Object -Property Precedence -Descending | ForEach-Object {
                $gpoDN = $GpoNameMapping[(Resolve-String -Text $_.PolicyName)]
                if (-not $gpoDN) {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGPLink.New.GpoNotFound' -StringValues (Resolve-String -Text $_.PolicyName) -Target $ADObject -FunctionName Invoke-DMGPLink
                    return
                }
                "[LDAP://$gpoDN;0]"
            }) -Join ""
            Write-PSFMessage -Level Debug -String 'Invoke-DMGPLink.New.NewGPLinkString' -StringValues $ADObject.DistinguishedName, $gpLinkString -Target $ADObject -FunctionName Invoke-DMGPLink
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = $gpLinkString } -ErrorAction Stop
        }

        function Update-Link {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                $Configuration,

                [bool]
                $Disable,

                [Hashtable]
                $GpoNameMapping
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            $gpLinkString = ''
            if ($Disable) {
                $desiredDNs = $Configuration.PolicyName | Resolve-String | ForEach-Object { $GpoNameMapping[$_] }
                $gpLinkString += ($ADobject.LinkedGroupPolicyObjects | Where-Object DistinguishedName -NotIn $desiredDNs | Sort-Object -Property Precedence -Descending | ForEach-Object {
                    "[LDAP://$($_.DistinguishedName);1]"
                }) -join ""
            }
            
            $gpLinkString += ($Configuration | Sort-Object -Property Precedence -Descending | ForEach-Object {
                $gpoDN = $GpoNameMapping[(Resolve-String -Text $_.PolicyName)]
                if (-not $gpoDN) {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGPLink.Update.GpoNotFound' -StringValues (Resolve-String -Text $_.PolicyName) -Target $ADObject -FunctionName Invoke-DMGPLink
                    return
                }
                "[LDAP://$gpoDN;0]"
            }) -Join ""
            Write-PSFMessage -Level Debug -String 'Invoke-DMGPLink.Update.NewGPLinkString' -StringValues $ADObject.DistinguishedName, $gpLinkString -Target $ADObject -FunctionName Invoke-DMGPLink
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = $gpLinkString } -ErrorAction Stop
        }
        #endregion Utility Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyLinks -Cmdlet $PSCmdlet
        $testResult = Test-DMGPLink @parameters

        $gpoDisplayToDN = @{ }
        $gpoDNToDisplay = @{ }
        foreach ($adPolicyObject in (Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName, DistinguishedName)) {
            $gpoDisplayToDN[$adPolicyObject.DisplayName] = $adPolicyObject.DistinguishedName
            $gpoDNToDisplay[$adPolicyObject.DistinguishedName] = $adPolicyObject.DisplayName
        }
    }
    process
    {
        #region Executing Test-Results
        foreach ($testItem in $testResult) {
            $countConfigured = ($testItem.Configuration | Measure-Object).Count
            $countActual = ($testItem.ADObject.LinkedGroupPolicyObjects | Measure-Object).Count
            $countNotInConfig = ($testItem.ADObject.LinkedGroupPolicyObjects | Where-Object DistinguishedName -notin ($testItem.Configuration.PolicyName | Remove-PSFNull| Resolve-String | ForEach-Object { $gpoDisplayToDN[$_] }) | Measure-Object).Count

            switch ($testItem.Type) {
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Delete.AllEnabled' -ActionStringValues $countActual -Target $testItem -ScriptBlock {
                        Clear-Link @parameters -ADObject $testItem.ADObject -Disable $Disable -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'DeleteDisabledOnly' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Delete.AllDisabled' -ActionStringValues $countActual -Target $testItem -ScriptBlock {
                        Clear-Link @parameters -ADObject $testItem.ADObject -Disable $Disable -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'DeleteSomeDisabled' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Delete.SomeDisabled' -ActionStringValues $countActual -Target $testItem -ScriptBlock {
                        Clear-Link @parameters -ADObject $testItem.ADObject -Disable $Disable -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'New' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.New' -ActionStringValues $countConfigured -Target $testItem -ScriptBlock {
                        New-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -GpoNameMapping $gpoDisplayToDN -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Update' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Update.AllEnabled' -ActionStringValues $countConfigured, $countActual, $countNotInConfig -Target $testItem -ScriptBlock {
                        Update-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -Disable $Disable -GpoNameMapping $gpoDisplayToDN -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'UpdateDisabledOnly' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Update.AllDisabled' -ActionStringValues $countConfigured, $countActual, $countNotInConfig -Target $testItem -ScriptBlock {
                        Update-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -Disable $Disable -GpoNameMapping $gpoDisplayToDN -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'UpdateSomeDisabled' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Update.SomeDisabled' -ActionStringValues $countConfigured, $countActual, $countNotInConfig -Target $testItem -ScriptBlock {
                        Update-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -Disable $Disable -GpoNameMapping $gpoDisplayToDN -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
        #endregion Executing Test-Results
    }
}


function Register-DMGPLink
{
    <#
    .SYNOPSIS
        Registers a group policy link as a desired state.
     
    .DESCRIPTION
        Registers a group policy link as a desired state.
     
    .PARAMETER PolicyName
        The name of the group policy being linked.
        Supports string expansion.
     
    .PARAMETER OrganizationalUnit
        The organizational unit (or domain root) being linked to.
        Supports string expansion.
     
    .PARAMETER Precedence
        Numeric value representing the order it is linked in.
        The lower the number, the higher on the list, the more relevant the setting.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMGPLink
 
        Import all GPLinks stored in the json file located at $configPath.
    #>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('OU')]
        [string]
        $OrganizationalUnit,

        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $Precedence
    )
    
    process
    {
        if (-not $script:groupPolicyLinks[$OrganizationalUnit]) {
            $script:groupPolicyLinks[$OrganizationalUnit] = @{ }
        }
        $script:groupPolicyLinks[$OrganizationalUnit][$PolicyName] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.GPLink'
            PolicyName = $PolicyName
            OrganizationalUnit = $OrganizationalUnit
            Precedence = $Precedence
        }
    }
}


function Test-DMGPLink
{
    <#
    .SYNOPSIS
        Tests, whether the configured group policy linking matches the desired state.
     
    .DESCRIPTION
        Tests, whether the configured group policy linking matches the desired state.
        Define the desired state using the Register-DMGPLink command.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMGPLink -Server contoso.com
 
        Tests, whether the group policy links of contoso.com match the configured state
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyLinks -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        $gpoDisplayToDN = @{ }
        $gpoDNToDisplay = @{ }
        foreach ($adPolicyObject in (Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName, DistinguishedName)) {
            $gpoDisplayToDN[$adPolicyObject.DisplayName] = $adPolicyObject.DistinguishedName
            $gpoDNToDisplay[$adPolicyObject.DistinguishedName] = $adPolicyObject.DisplayName
        }

        #region Process Configuration
        foreach ($organizationalUnit in $script:groupPolicyLinks.Keys) {
            $resolvedName = Resolve-String -Text $organizationalUnit
            $desiredState = $script:groupPolicyLinks[$organizationalUnit].Values

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'GPLink'
                Identity = $resolvedName
                Configuration = $desiredState
            }
            
            try {
                $adObject = Get-ADObject @parameters -Identity $resolvedName -ErrorAction Stop -Properties gPLink, Name, DistinguishedName
                $resultDefaults['ADObject'] = $adObject
            }
            catch {
                Write-PSFMessage -String 'Test-DMGPLink.OUNotFound' -StringValues $resolvedName -ErrorRecord $_ -Tag 'panic','failed'
                New-TestResult @resultDefaults -Type 'MissingParent'
                Continue
            }

            $currentState = $adObject | ConvertTo-GPLink -PolicyMapping $gpoDNToDisplay
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value $currentState -Force
            if (-not $currentState) {
                New-TestResult @resultDefaults -Type 'New'
                    continue
            }

            $currentStateFilteredSorted = $currentState | Where-Object Status -ne 'Disabled' | Sort-Object Precedence
            $currentStateSorted = $currentState | Sort-Object Precedence
            $desiredStateSorted = $desiredState | Sort-Object Precedence

            if (Compare-Array -ReferenceObject $currentStateFilteredSorted.DisplayName -DifferenceObject ($desiredStateSorted.PolicyName | Resolve-String) -Quiet -OrderSpecific) {
                if (Compare-Array -ReferenceObject $currentStateSorted.DisplayName -DifferenceObject ($desiredStateSorted.PolicyName | Resolve-String) -Quiet -OrderSpecific) { continue }
                else {
                    New-TestResult @resultDefaults -Type 'UpdateDisabledOnly' -Changed ($currentStateSorted | Where-Object DisplayName -notin $desiredStateSorted.PolicyName)
                    continue
                }
            }

            if ($currentStateSorted | Where-Object Status -eq 'Disabled') {
                New-TestResult @resultDefaults -Type 'UpdateSomeDisabled' -Changed ($currentStateSorted | Where-Object DisplayName -notin $desiredStateSorted.PolicyName)
                continue
            }

            New-TestResult @resultDefaults -Type 'Update' -Changed ($currentStateSorted | Where-Object DisplayName -notin $desiredStateSorted.PolicyName)
        }
        #endregion Process Configuration

        #region Process Managed Estate
        # OneLevel needs to be converted to base, as searching for OUs with "OneLevel" would return unmanaged OUs.
        # This search however is targeted at GPOs linked to managed OUs only.
        $translateScope = @{
            'Subtree' = 'Subtree'
            'OneLevel' = 'Base'
            'Base' = 'Base'
        }
        $configuredContainers = $script:groupPolicyLinks.Keys | Resolve-String
        $adObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADObject @parameters -LDAPFilter '(gPLink=*)' -SearchBase $searchBase.SearchBase -SearchScope $translateScope[$searchBase.SearchScope] -Properties gPLink, Name, DistinguishedName
        }

        foreach ($adObject in $adObjects) {
            # If we have a configuration on it, it has already been processed
            if ($adObject.DistinguishedName -in $configuredContainers) { continue }
            if ([string]::IsNullOrWhiteSpace($adObject.GPLink)) { continue }

            $linkObjects = $adObject | ConvertTo-GPLink -PolicyMapping $gpoDNToDisplay
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value $linkObjects -Force
            if (-not ($linkObjects | Where-Object Status -eq Enabled)) {
                New-TestResult -ObjectType GPLink -Type 'DeleteDisabledOnly' -Identity $adObject.DistinguishedName -Server $Server -ADObject $adObject
                continue
            }
            elseif (-not ($linkObjects | Where-Object Status -eq Disabled)) {
                New-TestResult -ObjectType GPLink -Type 'Delete' -Identity $adObject.DistinguishedName -Server $Server -ADObject $adObject
                continue
            }
            New-TestResult -ObjectType GPLink -Type 'DeleteSomeDisabled' -Identity $adObject.DistinguishedName -Server $Server -ADObject $adObject
        }
        #endregion Process Managed Estate
    }
}


function Unregister-DMGPLink
{
    <#
    .SYNOPSIS
        Removes a group policy link from the configured desired state.
     
    .DESCRIPTION
        Removes a group policy link from the configured desired state.
     
    .PARAMETER PolicyName
        The name of the policy to unregister.
     
    .PARAMETER OrganizationalUnit
        The name of the organizational unit the policy should be unregistered from.
     
    .EXAMPLE
        PS C:\> Get-DMGPLink | Unregister-DMGPLink
 
        Clears all configured Group policy links.
    #>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('OU')]
        [string]
        $OrganizationalUnit
    )
    
    process
    {
        $script:groupPolicyLinks[$OrganizationalUnit].Remove($PolicyName)
    }
}

function Get-DMGroupMembership
{
    <#
    .SYNOPSIS
        Returns the list of configured group memberships.
     
    .DESCRIPTION
        Returns the list of configured group memberships.
     
    .PARAMETER Group
        Name of the group to filter by.
     
    .PARAMETER Name
        Name of the entity being granted groupmembership to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMGroupMembership
 
        List all configured group memberships.
    #>

    
    [CmdletBinding()]
    param (
        [string]
        $Group = '*',

        [string]
        $Name = '*'
    )
    
    process
    {
        $results = foreach ($key in $script:groupMemberShips.Keys) {
            if ($key -notlike $Group) { continue }

            if ($script:groupMemberShips[$key].Count -gt 0) {
                foreach ($innerKey in $script:groupMemberShips[$key].Keys) {
                    $script:groupMemberShips[$key][$innerKey]
                }
            }
            else {
                [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.GroupMembership'
                    Name = '<Empty>'
                    Domain = '<Empty>'
                    ItemType = '<Empty>'
                    Group = $key
                }
            }
        }
        $results | Sort-Object Group
    }
}


function Invoke-DMGroupMembership
{
    <#
    .SYNOPSIS
        Applies the desired group memberships to the target domain.
     
    .DESCRIPTION
        Applies the desired group memberships to the target domain.
        Use Register-DMGroupMembership to configure just what is considered desired.
        Use Set-DMDomainCredential to prepare authentication as needed for remote domains, when principals from that domain must be resolved.
     
    .PARAMETER RemoveUnidentified
        By default, existing permissions for foreign security principals that cannot be resolved will only be deleted, if every single configured membership was resolveable.
        In cases where that is not possible, these memberships are flagged as "Unidentified"
        Using this parameter you can enforce deleting them anyway.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGroupMembership -Server contoso.com
 
        Applies the desired group membership configuration to the contoso.com domain
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [switch]
        $RemoveUnidentified,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupMemberShips -Cmdlet $PSCmdlet
        $testResult = Test-DMGroupMembership @parameters
        Set-DMDomainContext @parameters

        #region Utility Functions
        function Add-GroupMember {
            [CmdletBinding()]
            param (
                [string]
                $GroupDN,
                [string]
                $SID,
                [string]
                $Server,
                [PSCredential]
                $Credential
            )

            if ($Server) { $path = "LDAP://$Server/$GroupDN" }
            else { $path = "LDAP://$GroupDN" }
            if ($Credential) {
                $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
            }
            else {
                $group = New-Object DirectoryServices.DirectoryEntry($path)
            }
            [void]$group.member.Add("<SID=$SID>")
            $group.CommitChanges()
            $group.Close()
        }

        function Remove-GroupMember {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [string]
                $GroupDN,
                [string]
                $SID,
                [string]
                $Server,
                [PSCredential]
                $Credential
            )

            if ($Server) { $path = "LDAP://$Server/$GroupDN" }
            else { $path = "LDAP://$GroupDN" }
            if ($Credential) {
                $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
            }
            else {
                $group = New-Object DirectoryServices.DirectoryEntry($path)
            }
            [void]$group.member.Remove("<SID=$SID>")
            $group.CommitChanges()
            $group.Close()
        }
        #endregion Utility Functions
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'Add' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.Add' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                        Add-GroupMember @parameters -SID $testItem.Configuration.ADObject.ObjectSID -GroupDN $testItem.ADObject.DistinguishedName
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Remove' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.Remove' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                        Remove-GroupMember @parameters -SID $testItem.Configuration.ADObject.ObjectSID -GroupDN $testItem.ADObject.DistinguishedName
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Unresolved' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGroupMembership.Unresolved' -StringValues $testItem.Identity -Target $testItem
                }
                'Unidentified' {
                    if ($RemoveUnidentified) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.RemoveUnidentified' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                            Remove-GroupMember @parameters -SID $testItem.Configuration.ADObject.ObjectSID -GroupDN $testItem.ADObject.DistinguishedName
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    else {
                        Write-PSFMessage -Level Warning -String 'Invoke-DMGroupMembership.Unidentified' -StringValues $testItem.Identity -Target $testItem
                    }
                }
            }
        }
    }
}


function Register-DMGroupMembership
{
    <#
    .SYNOPSIS
        Registers a group membership assignment as desired state.
     
    .DESCRIPTION
        Registers a group membership assignment as desired state.
        Any group with configured membership will be considered "managed" where memberships are concerned.
        This will causse all non-registered memberships to be configured for purging.
     
    .PARAMETER Name
        The name of the user or group to grant membership in the target group.
 
    .PARAMETER Domain
        Domain the entity is from, that is being granted group membership.
     
    .PARAMETER ItemType
        The type of object being granted membership.
     
    .PARAMETER Group
        The group to define members for.
 
    .PARAMETER Empty
        Whether the specified group should be empty.
        By default, groups are only considered when at least one member has been defined.
        Flagging a group for being empty will clear all members from it.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMGroupMembership
 
        Imports all defined groupmemberships from the targeted json configuration file.
    #>

    
    [CmdletBinding(DefaultParameterSetName = 'Entry')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [string]
        $Domain,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [ValidateSet('User', 'Group', 'foreignSecurityPrincipal')]
        [string]
        $ItemType,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')]
        [string]
        $Group,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')]
        [bool]
        $Empty
    )
    
    process
    {
        if (-not $script:groupMemberShips[$Group]) {
            $script:groupMemberShips[$Group] = @{ }
        }
        if ($Name) {
            $script:groupMemberShips[$Group]["$($ItemType):$($Name)"] = [PSCustomObject]@{
                PSTypeName = 'DomainManagement.GroupMembership'
                Name = $Name
                Domain = $Domain
                ItemType = $ItemType
                Group = $Group
            }
        }
        else {
            $script:groupMemberShips[$Group] = @{ }
        }
    }
}


function Test-DMGroupMembership
{
    <#
    .SYNOPSIS
        Tests, whether the target domain is compliant with the desired group membership assignments.
     
    .DESCRIPTION
        Tests, whether the target domain is compliant with the desired group membership assignments.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DMGroupMembership -Server contoso.com
 
        Tests, whether the "contoso.com" domain is in compliance with the desired group membership assignments.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupMemberShips -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($groupMembershipNames in $script:groupMemberShips.Keys) {
            $resolvedGroupName = Resolve-String -Text $groupMembershipNames

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'GroupMembership'
            }

            #region Resolve Assignments
            $failedResolveAssignment = $false
            $assignments = foreach ($assignment in $script:groupMemberShips[$groupMembershipNames].Values) {
                try { $adResult = Get-Principal @parameters -Domain (Resolve-String -Text $assignment.Domain) -Name (Resolve-String -Text $assignment.Name) -ObjectClass $assignment.ItemType }
                catch {
                    Write-PSFMessage -Level Warning -String 'Test-DMGroupMembership.Assignment.Resolve.Connect' -StringValues (Resolve-String -Text $assignment.Domain), (Resolve-String -Text $assignment.Name), $assignment.ItemType -ErrorRecord $_ -Target $assignment
                    $failedResolveAssignment = $true
                    [PSCustomObject]@{
                        Assignment = $assignment
                        ADObject = $null
                    }
                    continue
                }
                if (-not $adResult) {
                    Write-PSFMessage -Level Warning -String 'Test-DMGroupMembership.Assignment.Resolve.NotFound' -StringValues (Resolve-String -Text $assignment.Domain), (Resolve-String -Text $assignment.Name), $assignment.ItemType -Target $assignment
                    $failedResolveAssignment = $true
                    [PSCustomObject]@{
                        Assignment = $assignment
                        ADObject = $null
                    }
                    continue
                }
                [PSCustomObject]@{
                    Assignment = $assignment
                    ADObject = $adResult
                }
            }
            #endregion Resolve Assignments

            try {
                $adObject = Get-ADGroup @parameters -Identity $resolvedGroupName -Properties Members -ErrorAction Stop
                $adMembers = $adObject.Members | ForEach-Object {
                    $distinguishedName = $_
                    try { Get-ADObject @parameters -Identity $_ -ErrorAction Stop -Properties SamAccountName, objectSid }
                    catch {
                        $objectDomainName = $distinguishedName.Split(",").Where{$_ -like "DC=*"} -replace '^DC=' -join "."
                        $cred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
                        Get-ADObject -Server $objectDomainName @cred -Identity $distinguishedName -ErrorAction Stop -Properties SamAccountName, objectSid
                    }
                }
            }
            catch { Stop-PSFFunction -String 'Test-DMGroupMembership.Group.Access.Failed' -StringValues $resolvedGroupName -ErrorRecord $_ -EnableException $EnableException -Continue }

            foreach ($assignment in $assignments) {
                if (-not $assignment.ADObject) {
                    # Principal that should be member could not be found
                    New-TestResult @resultDefaults -Type Unresolved -Identity "$(Resolve-String -Text $assignment.Assignment.Group)þ$($assignment.Assignment.ItemType)þ$(Resolve-String -Text $assignment.Assignment.Name)" -Configuration $assignment -ADObject $adObject
                    continue
                }
                if ($adMembers | Where-Object ObjectSID -eq $assignment.ADObject.objectSID) {
                    continue
                }
                New-TestResult @resultDefaults -Type Add -Identity "$(Resolve-String -Text $assignment.Assignment.Group)þ$($assignment.Assignment.ItemType)þ$(Resolve-String -Text $assignment.Assignment.Name)" -Configuration $assignment -ADObject $adObject
            }

            foreach ($adMember in $adMembers) {
                if ($adMember.ObjectSID -in $assignments.ADObject.ObjectSID) {
                    continue
                }
                $configObject = [PSCustomObject]@{
                    Assignment = $null
                    ADObject = $adMember
                }

                if ($failedResolveAssignment -and ($adMember.ObjectClass -eq 'foreignSecurityPrincipal')) {
                    # Currently a member, is foreignSecurityPrincipal and we cannot be sure we resolved everything that should be member
                    New-TestResult @resultDefaults -Type Unidentified -Identity "$($adObject.Name)þ$($adMember.ObjectClass)þ$($adMember.SamAccountName)" -Configuration $configObject -ADObject $adObject
                }
                else {
                    New-TestResult @resultDefaults -Type Remove -Identity "$($adObject.Name)þ$($adMember.ObjectClass)þ$($adMember.SamAccountName)" -Configuration $configObject -ADObject $adObject
                }
            }
        }
    }
}

function Unregister-DMGroupMembership
{
    <#
    .SYNOPSIS
        Removes entries from the list of desired group memberships.
     
    .DESCRIPTION
        Removes entries from the list of desired group memberships.
     
    .PARAMETER Name
        Name of the identity being granted group membership
     
    .PARAMETER ItemType
        The type of object the identity being granted group membership is.
     
    .PARAMETER Group
        The group being granted membership in.
     
    .EXAMPLE
        PS C:\> Get-DMGroupMembership | Unregister-DMGroupMembership
 
        Removes all configured desired group memberships.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('User', 'Group')]
        [string]
        $ItemType,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Group
    )
    
    process
    {
        if (-not $script:groupMemberShips[$Group]) { return }
        if ($Name -eq '<empty>') {
            $null = $script:groupMemberShips.Remove($Group)
            return
        }
        if (-not $script:groupMemberShips[$Group]["$($ItemType):$($Name)"]) { return }
        $null = $script:groupMemberShips[$Group].Remove("$($ItemType):$($Name)")
        if (-not $script:groupMemberShips[$Group].Keys) {
            $null = $script:groupMemberShips.Remove($Group)
        }
    }
}


function Get-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Returns all registered GPO objects.
     
    .DESCRIPTION
        Returns all registered GPO objects.
        Thsi represents the _desired_ state, not any actual state.
     
    .PARAMETER Name
        The name to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMGroupPolicy
 
        Returns all registered GPOs
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:groupPolicyObjects.Values | Where-Object DisplayName -like $name)
    }
}


function Invoke-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Brings the group policy settings into compliance with the desired state.
     
    .DESCRIPTION
        Brings the group policy settings into compliance with the desired state.
        Define the desired state by using Register-DMGroupPolicy.
        Note: The original export will need to be carefully crafted to fit this system.
        TODO: Add definition on how to provide the GPO export,
     
    .PARAMETER Delete
        By default, this command will NOT delete group policies, in order to avoid accidentally locking yourself out of the system.
        Use this parameter to delete group policies that are no longer needed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGroupPolicy -Server fabrikam.com
 
        Brings the group policy settings from the domain fabrikam.com into compliance with the desired state.
 
    .EXAMPLE
        PS C:\> Invoke-DMGroupPolicy -Server fabrikam.com -Delete
 
        Brings the group policy settings from the domain fabrikam.com into compliance with the desired state.
        Will also delete all deprecated policies linked to the managed infrastructure.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [switch]
        $Delete,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyObjects -Cmdlet $PSCmdlet
        $computerName = (Get-ADDomain @parameters).PDCEmulator
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-DMGroupPolicy.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        $PSDefaultParameterValues['Invoke-Command:Session'] = $session
        $testResult = Test-DMGroupPolicy @parameters
        Set-DMDomainContext @parameters

        if (-not $testResult) { return }

        try { $gpoRemotePath = New-GpoWorkingDirectory -Session $session -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-DMGroupPolicy.Remote.WorkingDirectory.Failed' -StringValues $computerName -Target $computerName -ErrorRecord $_ -EnableException $EnableException
            return
        }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'Delete' {
                    if (-not $Delete) { continue }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Delete' -Target $testItem -ScriptBlock {
                        Remove-GroupPolicy -Session $session -ADObject $testItem.ADObject -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'ConfigError' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnConfigError' -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'CriticalError' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGroupPolicy.Skipping.InCriticalState' -StringValues $testItem.Identity -Target $testItem
                }
                'Update' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnUpdate' -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Modified' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnModify' -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Manage' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnManage' -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnNew' -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
    end
    {
        if ($gpoRemotePath) {
            Invoke-Command -Session $session -ArgumentList $gpoRemotePath -ScriptBlock {
                param ($GpoRemotePath)
                Remove-Item -Path $GpoRemotePath -Recurse -Force -Confirm:$false -ErrorAction SilentlyContinue -WhatIf:$false
            }
        }
    }
}


function Register-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Adds a group policy object to the list of desired GPOs.
     
    .DESCRIPTION
        Adds a group policy object to the list of desired GPOs.
        These are then tested for using Test-DMGroupPolicy and applied by using Invoke-DMGroupPolicy.
     
    .PARAMETER DisplayName
        Name of the GPO to add.
         
    .PARAMETER Description
        Description of the GPO in question,.
     
    .PARAMETER ID
        The GPO Id GUID.
     
    .PARAMETER Path
        Path to where the GPO export can be found.
     
    .PARAMETER ExportID
        The tracking ID assigned to the GPO in order to detect its revision.
        #TODO: Migrate export command and add documentation.
     
    .EXAMPLE
        PS C:\> Get-Content gpos.json | ConvertFrom-Json | Write-Output | Register-DMGroupPolicy
 
        Reads all gpos defined in gpos.json and registers each as a GPO object.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DisplayName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ID,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ExportID
    )
    
    process
    {
        $script:groupPolicyObjects[$DisplayName] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.GroupPolicyObject'
            DisplayName = $DisplayName
            Description = $Description
            ID = $ID
            Path = $Path
            ExportID = $ExportID
        }
    }
}


function Test-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Tests whether the current domain has the desired group policy setup.
     
    .DESCRIPTION
        Tests whether the current domain has the desired group policy setup.
        Based on timestamps and IDs it will detect for existing OUs, whether the currently deployed version:
        - Is based on the latest GPO version
        - has been changed since being last deployed (In which case it is configured to restore itself to its intended state)
        Ignores GPOs not linked to managed OUs.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DMGroupPolicy -Server contoso.com
 
        Validates that the contoso domain's group policies are in the desired state
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyObjects -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
        $computerName = (Get-ADDomain @parameters).PDCEmulator
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Test-DMGroupPolicy.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        $PSDefaultParameterValues['Invoke-Command:Session'] = $session
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'GroupPolicy'
        }

        #region Gather data
        $desiredPolicies = Get-DMGroupPolicy
        $managedPolicies = Get-LinkedPolicy @parameters
        foreach ($managedPolicy in $managedPolicies) {
            if (-not $managedPolicy.DisplayName) {
                Write-PSFMessage -Level Warning -String 'Test-DMGroupPolicy.ADObjectAccess.Failed' -StringValues $managedPolicy.DistinguishedName -Target $managedPolicy
                New-TestResult @resultDefaults -Type 'ADAccessFailed' -Identity $managedPolicy.DistinguishedName -ADObject $managedPolicy
                continue
            }
            # Resolve-PolicyRevision updates the content of $managedPolicy without producing output
            try { Resolve-PolicyRevision -Policy $managedPolicy -Session $session }
            catch { Write-PSFMessage -Level Warning -String 'Test-DMGroupPolicy.PolicyRevision.Lookup.Failed' -StringValues $managedPolicies.DisplayName -ErrorRecord $_ -EnableException $EnableException.ToBool() }
        }
        $desiredHash = @{ }
        $managedHash = @{ }
        foreach ($desiredPolicy in $desiredPolicies) { $desiredHash[$desiredPolicy.DisplayName] = $desiredPolicy }
        foreach ($managedPolicy in $managedPolicies) {
            if (-not $managedPolicy.DisplayName) { continue }
            $managedHash[$managedPolicy.DisplayName] = $managedPolicy
        }
        #endregion Gather data

        #region Compare configuration to actual state
        foreach ($desiredPolicy in $desiredHash.Values) {
            if (-not $managedHash[$desiredPolicy.DisplayName]) {
                New-TestResult @resultDefaults -Type 'Create' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy
                continue
            }

            switch ($managedHash[$desiredPolicy.DisplayName].State) {
                'ConfigError' { New-TestResult @resultDefaults -Type 'ConfigError' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $managedHash[$desiredPolicy.DisplayName] }
                'CriticalError' { New-TestResult @resultDefaults -Type 'CriticalError' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $managedHash[$desiredPolicy.DisplayName] }
                'Healthy'
                {
                    $policyObject = $managedHash[$desiredPolicy.DisplayName]
                    if ($desiredPolicy.ExportID -ne $policyObject.ExportID) {
                        New-TestResult @resultDefaults -Type 'Update' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $policyObject
                        continue
                    }
                    if ($policyObject.Modified -ne $policyObject.ImportTime) {
                        New-TestResult @resultDefaults -Type 'Modified' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $policyObject
                        continue
                    }
                }
                'Unmanaged'
                {
                    New-TestResult @resultDefaults -Type 'Manage' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $managedHash[$desiredPolicy.DisplayName]
                }
            }
        }
        #endregion Compare configuration to actual state

        #region Compare actual state to configuration
        foreach ($managedPolicy in $managedHash.Values) {
            if ($desiredHash[$managedPolicy.DisplayName]) { continue }
            if ($managedPolicy.IsCritical) { continue }
            New-TestResult @resultDefaults -Type 'Delete' -Identity $managedPolicy.DisplayName -ADObject $managedPolicy
        }
        #endregion Compare actual state to configuration
    }
    end
    {
        if ($session) { Remove-PSSession $session -WhatIf:$false }
    }
}


function Unregister-DMGroupPolicy
{
    <#
        .SYNOPSIS
            Removes a group policy object from the list of desired gpos.
         
        .DESCRIPTION
            Removes a group policy object from the list of desired gpos.
         
        .PARAMETER Name
            The name of the GPO to remove from the list of ddesired gpos
         
        .EXAMPLE
            PS C:\> Get-DMGroupPolicy | Unregister-DMGroupPolicy
 
            Clears all configured GPOs
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true)]
        [Alias('DisplayName')]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:groupPolicyObjects.Remove($nameItem)
        }
    }
}


function Get-DMGroup
{
    <#
        .SYNOPSIS
            Lists registered ad groups.
         
        .DESCRIPTION
            Lists registered ad groups.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMGroup
 
            Lists all registered ad groups.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:groups.Values | Where-Object Name -like $Name)
    }
}


function Invoke-DMGroup
{
    <#
        .SYNOPSIS
            Updates the group configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the group configuration of a domain to conform to the configured state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Innvoke-DMGroup -Server contoso.com
 
            Updates the groups in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Groups -Cmdlet $PSCmdlet
        $testResult = Test-DMGroup @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'ShouldDelete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'ConfigurationOnly' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMGroup.Group.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name = (Resolve-String -Text $testItem.Configuration.Name)
                            Description = (Resolve-String -Text $testItem.Configuration.Description)
                            Path = $targetOU
                            GroupCategory = $testItem.Configuration.Category
                            GroupScope = $testItem.Configuration.Scope
                        }
                        New-ADGroup @newParameters
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldGroups' {
                    Stop-PSFFunction -String 'Invoke-DMGroup.Group.MultipleOldGroups' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'group','critical','panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock {
                        Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Changed' {
                    if ($testItem.Changed -contains 'Path') {
                        $targetOU = Resolve-String -Text $testItem.Configuration.Path
                        try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                        catch { Stop-PSFFunction -String 'Invoke-DMGroup.Group.Update.OUExistsNot' -StringValues $testItem.Identity, $targetOU -Target $testItem -EnableException $EnableException -Continue }

                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Move' -ActionStringValues $targetOU -Target $testItem -ScriptBlock {
                            $null = Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -TargetPath $targetOU -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    $changes = @{ }
                    if ($testItem.Changed -contains 'Description') { $changes['Description'] = (Resolve-String -Text $testItem.Configuration.Description) }
                    if ($testItem.Changed -contains 'Category') { $changes['GroupCategory'] = (Resolve-String -Text $testItem.Configuration.Category) }
                    if ($testItem.Changed -contains 'Scope') { $changes['GroupScope'] = (Resolve-String -Text $testItem.Configuration.Scope) }
                    
                    if ($changes.Keys.Count -gt 0)
                    {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
}


function Register-DMGroup
{
    <#
    .SYNOPSIS
        Registers an active directory group.
     
    .DESCRIPTION
        Registers an active directory group.
        This group will be maintained as configured during Invoke-DMGroup.
     
    .PARAMETER Name
        The name of the group.
        Subject to string insertion.
     
    .PARAMETER Path
        Path (distinguishedName) of the OU to place the group in.
        Subject to string insertion.
     
    .PARAMETER Description
        Description of the group.
        Subject to string insertion.
     
    .PARAMETER Scope
        The scope of the group.
        Use DomainLocal for groups that grrant direct permissions and Global for role groups.
 
    .PARAMETER Category
        Whether the group should be a security group or a distribution group.
        Defaults to security.
 
    .PARAMETER OldNames
        Previous names the group used to have.
        By specifying this name, groups will be renamed if still using an old name.
        Conflicts may require resolving.
     
    .PARAMETER Present
        Whether the group should exist.
        Defaults to $true
        Set to $false for explicitly deleting groups, rather than creating them.
     
    .EXAMPLE
        PS C:\> Get-Content .\groups.json | ConvertFrom-Json | Write-Output | Register-DMGroup
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as group configuration.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('DomainLocal', 'Global', 'Universal')]
        [string]
        $Scope,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Security', 'Distribution')]
        [string]
        $Category = 'Security',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames = @(),

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Present = $true
    )
    
    process
    {
        $script:groups[$Name] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.Group'
            Name = $Name
            Path = $Path
            Description = $Description
            Scope = $Scope
            Category = $Category
            OldNames = $OldNames
            Present = $Present
        }
    }
}


function Test-DMGroup
{
    <#
        .SYNOPSIS
            Tests whether the configured groups match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured groups match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMGroup
 
            Tests whether the configured groups' state matches the current domain group setup.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Groups -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($groupDefinition in $script:groups.Values) {
            $resolvedName = Resolve-String -Text $groupDefinition.Name

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'Group'
                Identity = $resolvedName
                Configuration = $groupDefinition
            }

            #region Group that needs to be removed
            if (-not $groupDefinition.Present) {
                try { $adObject = Get-ADGroup @parameters -Identity $resolvedName -ErrorAction Stop }
                catch { continue } # Only errors when group not present = All is well
                
                New-TestResult @resultDefaults -Type ShouldDelete -ADObject $adObject
                continue
            }
            #endregion Group that needs to be removed

            #region Groups that don't exist but should | Groups that need to be renamed
            try { $adObject = Get-ADGroup @parameters -Identity $resolvedName -Properties Description -ErrorAction Stop }
            catch
            {
                $oldGroups = foreach ($oldName in ($groupDefinition.OldNames | Resolve-String)) {
                    try { Get-ADGroup @parameters -Identity $oldName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
                    catch { }
                }

                switch (($oldGroups | Measure-Object).Count) {
                    #region Case: No old version present
                    0
                    {
                        New-TestResult @resultDefaults -Type ConfigurationOnly
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1
                    {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldGroups
                        continue main
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default
                    {
                        New-TestResult @resultDefaults -Type MultipleOldGroups -ADObject $oldGroups
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Groups that don't exist but should | Groups that need to be renamed

            #region Existing Groups, might need updates
            # $adObject contains the relevant object

            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property Description -Configuration $groupDefinition -ADObject $adObject -Changes $changes -Resolve
            Compare-Property -Property Category -Configuration $groupDefinition -ADObject $adObject -Changes $changes -ADProperty GroupCategory
            Compare-Property -Property Scope -Configuration $groupDefinition -ADObject $adObject -Changes $changes -ADProperty GroupScope
            $ouPath = ($adObject.DistinguishedName -split ",",2)[1]
            if ($ouPath -ne (Resolve-String -Text $groupDefinition.Path)) {
                $null = $changes.Add('Path')
            }
            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
            }
            #endregion Existing Groups, might need updates
        }

        $foundGroups = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADGroup @parameters -LDAPFilter '(!(isCriticalSystemObject=*))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }

        $resolvedConfiguredNames = $script:groups.Values.Name | Resolve-String
        $resultDefaults = @{
            Server = $Server
            ObjectType = 'Group'
        }

        foreach ($existingGroup in $foundGroups) {
            if ($existingGroup.Name -in $resolvedConfiguredNames) { continue }
            if (1000 -ge ($existingGroup.SID -split "-")[-1]) { continue } # Ignore BuiltIn default groups

            New-TestResult @resultDefaults -Type ShouldDelete -ADObject $existingGroup -Identity $existingGroup.Name
        }
    }
}


function Unregister-DMGroup
{
    <#
    .SYNOPSIS
        Removes a group that had previously been registered.
     
    .DESCRIPTION
        Removes a group that had previously been registered.
     
    .PARAMETER Name
        The name of the group to remove.
     
    .EXAMPLE
        PS C:\> Get-DMGroup | Unregister-DMGroup
 
        Clears all registered groups.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:groups.Remove($nameItem)
        }
    }
}


function Get-DMNameMapping
{
    <#
        .SYNOPSIS
            List the registered name mappings
         
        .DESCRIPTION
            List the registered name mappings
            Mapped names are used for stringr replacement when invoking domain configurations.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMNameMapping
 
            List all registered mappings
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        foreach ($key in $script:nameReplacementTable.Keys) {
            if ($key -notlike $Name) { continue }

            [PSCustomObject]@{
                PSTypeName = 'DomainManagement.Name.Mapping'
                Name = $key
                Value = $script:nameReplacementTable[$key]
            }
        }
    }
}


function Register-DMNameMapping
{
    <#
        .SYNOPSIS
            Register a new name mapping.
         
        .DESCRIPTION
            Register a new name mapping.
            Mapped names are used for stringr replacement when invoking domain configurations.
         
        .PARAMETER Name
            The name of the placeholder to register.
            This label will be replaced with the content specified in -Value.
            Be aware that all labels must be enclosed in % and only contain letters, underscore and numbers.
         
        .PARAMETER Value
            The value to insert in place of the label.
         
        .EXAMPLE
            PS C:\> Register-DMNameMapping -Name '%ManagementGroup%' -Value 'Mgmt-Team-1234'
 
            Registers the string 'Mgmt-Team-1234' under the label '%ManagementGroup%'
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidatePattern('^%[\d\w_]+%$', ErrorString = 'DomainManagement.Validate.Name.Pattern')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Value
    )
    
    process
    {
        $script:nameReplacementTable[$Name] = $Value
    }
}


function Unregister-DMNameMapping
{
    <#
    .SYNOPSIS
        Removes a registered name mapping.
     
    .DESCRIPTION
        Removes a registered name mapping.
        Mapped names are used for stringr replacement when invoking domain configurations.
     
    .PARAMETER Name
        The name(s) of the mapping to purge.
     
    .EXAMPLE
        PS C:\> Get-DMNameMapping | Unregister-DMNameMapping
 
        Removes all registered name mappings.
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:nameReplacementTable.Remove($nameItem)
        }
    }
}


function Get-DMObject
{
    <#
    .SYNOPSIS
        Returns configured active directory objects.
     
    .DESCRIPTION
        Returns configured active directory objects.
     
    .PARAMETER Path
        The path to filter by.
 
    .PARAMETER Name
        The name to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMObject
 
        Returns all registered objects
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path = '*',

        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:objects.Values | Where-Object Path -like $Path | Where-Object Name -like $Name)
    }
}

function Invoke-DMObject
{
    <#
        .SYNOPSIS
            Updates the generic ad object configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the generic ad object configuration of a domain to conform to the configured state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Invoke-DMObject -Server contoso.com
 
            Updates the generic objects in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Objects -Cmdlet $PSCmdlet
        $testResult = Test-DMObject @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($testItem in ($testResult | Sort-Object { $_.Identity.Length })) {
            switch ($testItem.Type) {
                'Create' {
                    $createParam = $parameters.Clone()
                    $createParam += @{
                        Path = Resolve-String -Text $testItem.Configuration.Path
                        Name = Resolve-String -Text $testItem.Configuration.Name
                        Type = Resolve-String -Text $testItem.Configuration.ObjectClass
                    }
                    if ($testItem.Configuration.Attributes.Count -gt 0) {
                        $hash = @{ }
                        foreach ($key in $testItem.Configuration.Attributes.Keys) {
                            if ($key -notin $testItem.Configuration.AttributesToResolve) { $hash[$key] = $testItem.Configuration.Attributes[$key] }
                            else { $hash[$key] = $testItem.Configuration.Attributes[$key] | Resolve-String }
                        }
                        $createParam['OtherAttributes'] = $hash
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMObject.Object.Create' -ActionStringValues $testItem.Configuration.ObjectClass, $testItem.Identity -Target $testItem -ScriptBlock {
                        New-ADObject @createParam -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Changed' {
                    $setParam = $parameters.Clone()
                    $setParam += @{
                        Identity = $testItem.Identity
                    }
                    $replaceHash = @{ }
                    foreach ($propertyName in $testItem.Changed) {
                        if ($propertyName -notin $testItem.Configuration.AttributesToResolve) { $replaceHash[$propertyName] = $testItem.Configuration.Attributes[$propertyName] }
                        else { $replaceHash[$propertyName] = $testItem.Configuration.Attributes[$propertyName] | Resolve-String }
                    }
                    $setParam['Replace'] = $replaceHash
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMObject.Object.Change' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem -ScriptBlock {
                        Set-ADObject @setParam -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
}

function Register-DMObject
{
    <#
    .SYNOPSIS
        Registers a generic object as the desired state for active directory.
     
    .DESCRIPTION
        Registers a generic object as the desired state for active directory.
        This allows defining custom objects not implemented as a commonly supported type.
     
    .PARAMETER Path
        The Path to the OU in which to place the object.
        Subject to string insertion.
     
    .PARAMETER Name
        Name of the object to define.
        Subject to string insertion.
     
    .PARAMETER ObjectClass
        The class of the object to define.
     
    .PARAMETER Attributes
        Attributes to include in the object.
        If you specify a hashtable, keys are mapped to attributes.
        If you specify another arbitrary object type, properties are mapped to attributes.
 
    .PARAMETER AttributesToResolve
        The names of all attributes in configuration, for which you want to perform string insertion, before comparing with the actual object in AD.
     
    .EXAMPLE
        PS C:\> Get-Content .\objects.json | ConvertFrom-Json | Write-Output | Register-DMObject
 
        Imports all objects defined in objects.json.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectClass,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        $Attributes,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $AttributesToResolve
    )
    
    process
    {
        $script:objects["CN=$Name,$Path"] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.Object'
            Identity = "CN=$Name,$Path"
            Path = $Path
            Name = $Name
            ObjectClass = $ObjectClass
            Attributes = ($Attributes | ConvertTo-PSFHashtable)
            AttributesToResolve = $AttributesToResolve
        }
    }
}


function Test-DMObject
{
    <#
        .SYNOPSIS
            Tests, whether the desired objects have been defined correctly in AD.
         
        .DESCRIPTION
            Tests, whether the desired objects have been defined correctly in AD.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-DMObject
 
            Tests whether the current domain has all the custom objects as defined.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Objects -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($objectDefinition in $script:objects.Values) {
            $resolvedPath = Resolve-String -Text $objectDefinition.Identity

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'Object'
                Identity = $resolvedPath
                Configuration = $objectDefinition
            }

            #region Does not exist
            if (-not (Test-ADObject @parameters -Identity $resolvedPath)) {
                New-TestResult @resultDefaults -Type Create
            }
            #endregion Does not exist

            #region Exists
            else {
                if ($objectDefinition.Attributes.Keys) {
                    try { $adObject = Get-ADObject @parameters -Identity $resolvedPath -Properties ($objectDefinition.Attributes.Keys | Write-Output) }
                    catch { Stop-PSFFunction -String 'Test-DMObject.ADObject.Access.Error' -StringValues $resolvedPath, ($objectDefinition.Attributes.Keys -join ",") -Continue -ErrorRecord $_ -Tag error, baddata }
                }
                else {
                    try { $adObject = Get-ADObject @parameters -Identity $resolvedPath }
                    catch { Stop-PSFFunction -String 'Test-DMObject.ADObject.Access.Error2' -StringValues $resolvedPath -Continue -ErrorRecord $_ -Tag error }
                }
                
                [System.Collections.ArrayList]$changes = @()
                foreach ($propertyName in $objectDefinition.Attributes.Keys) {
                    Compare-Property -Property $propertyName -Configuration $objectDefinition.Attributes -ADObject $adObject -Changes $changes -Resolve:$($objectDefinition.AttributesToResolve -contains $propertyName)
                }
                if ($changes.Count) {
                    New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
                }
            }
            #endregion Exists
        }
    }
}

function Unregister-DMObject
{
    <#
    .SYNOPSIS
        Unregisters a configured active directory objects.
     
    .DESCRIPTION
        Unregisters a configured active directory objects.
     
    .PARAMETER Identity
        The paths to the object to unregister.
        Requires the full, unresolved identity as dn (CN=<Name>,<Path>).
     
    .EXAMPLE
        PS C:\> Get-DMObject | Unregister-DMObject
 
        Clears all configured AD objects.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Identity
    )
    
    process
    {
        foreach ($pathString in $Identity) {
            $script:objects.Remove($pathString)
        }
    }
}

function Get-DMObjectCategory
{
    <#
    .SYNOPSIS
        Returns registered object category objects.
     
    .DESCRIPTION
        Returns registered object category objects.
        See description on Register-DMObjectCategory for details on object categories in general.
     
    .PARAMETER Name
        The name to filter by.4
     
    .EXAMPLE
        PS C:\> Get-DMObjectCategory
 
        Returns all registered object categories.
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:objectCategories.Values | Where-Object Name -like $Name)
    }
}


function Register-DMObjectCategory
{
    <#
    .SYNOPSIS
        Registers a new object category.
     
    .DESCRIPTION
        Registers a new object category.
        Object categories are a way to apply settings to a type of object based on a ruleset / filterset.
        For example, by registering an object category "Domain Controllers" (with appropriate filters / conditions),
        it becomes possible to define access rules that apply to all domain controllers, but not all computers.
 
        Note: Not all setting types support categories yet.
     
    .PARAMETER Name
        The name of the category. Must be unique.
        Will NOT be resolved.
     
    .PARAMETER ObjectClass
        The ObjectClass of the object.
        This is the AD attribute of the object.
        Each object category can only apply to one class of object, in order to protect system performance.
     
    .PARAMETER Property
        The properties needed for this category.
        This attribute is used to optimize object reetrieval in case of multiple categories applying to the same class of object.
     
    .PARAMETER TestScript
        Scriptblock used to determine, whether the input object is part of the category.
        Receives the AD object with the requested attributes as input object / argument.
     
    .PARAMETER Filter
        A filter used to find all objects in AD that match this category.
     
    .PARAMETER LdapFilter
        An LDAP filter used to find all objects in AD that match this category.
     
    .EXAMPLE
        PS C:\> Register-DMObjectCategory -Name DomainController -ObjectClass computer -Property PrimaryGroupID -TestScript { $args[0].PrimaryGroupID -eq 516 } -LDAPFilter '(&(objectCategory=computer)(primaryGroupID=516))'
 
        Registers an object category applying to all domain controller's computer object in AD.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Filter')]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectClass,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Property,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [scriptblock]
        $TestScript,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [string]
        $Filter,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'LdapFilter')]
        [string]
        $LdapFilter
    )
    
    process
    {
        $script:objectCategories[$Name] = [PSCustomObject]@{
            Name = $Name
            ObjectClass = $ObjectClass
            Property = $Property
            TestScript = $TestScript
            Filter = $Filter
            LdapFilter = $LdapFilter
        }
    }
}


function Resolve-DMObjectCategory
{
    <#
    .SYNOPSIS
        Resolves what object categories apply to a given AD Object.
     
    .DESCRIPTION
        Resolves what object categories apply to a given AD Object.
     
    .PARAMETER ADObject
        The AD Object for which to resolve the object categories.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Resolve-DMObjectCategory @parameters -ADObject $adobject
 
        Resolves the object categories that apply to $adobject
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        $ADObject,

        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        if ($script:objectCategories.Values.ObjectClass -notcontains $ADObject.ObjectClass) {
            return
        }

        $filteredObjectCategories = $script:objectCategories.Values | Where-Object ObjectClass -eq $ADobject.ObjectClass
        $propertyNames = $filteredObjectCategories.Property | Select-Object -Unique
        $adObjectReloaded = Get-Adobject @parameters -Identity $ADObject.DistinguishedName -Properties $propertyNames
        foreach ($filteredObjectCategory in $filteredObjectCategories) {
            if ($filteredObjectCategory.Testscript.Invoke($adobjectreloaded)) {
                $filteredObjectCategory
            }
        }
    }
}


function Unregister-DMObjectCategory
{
    <#
    .SYNOPSIS
        Removes an object category from the list of registered object categories.
     
    .DESCRIPTION
        Removes an object category from the list of registered object categories.
        See description on Register-DMObjectCategory for details on object categories in general.
     
    .PARAMETER Name
        The exact name of the object category to unregister.
     
    .EXAMPLE
        PS C:\> Get-DMObjectCategory | Unregister-DMObjectCategory
 
        Clears all registered object categories.
    #>

    [CmdletBinding()]
    Param (
        [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:objectCategories.Remove($nameItem)
        }
    }
}


function Get-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Returns the list of configured Organizational Units.
     
    .DESCRIPTION
        Returns the list of configured Organizational Units.
        Does not in any way retrieve data from a domain.
        The returned list of OUs represent the desired state for each domain of the current context.
     
    .PARAMETER Name
        Name of the OU to filter by.
     
    .PARAMETER Path
        Path of the OU to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMOrganizationalUnit
 
        Return all configured OUs.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*',

        [string]
        $Path = '*'
    )
    
    process
    {
        ($script:organizationalUnits.Values | Where-Object Name -like $Name | Where-Object Path -like $Path)
    }
}


function Invoke-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Updates the organizational units of a domain to be compliant with the desired state.
     
    .DESCRIPTION
        Updates the organizational units of a domain to be compliant with the desired state.
        Use Register-DMOrganizationalUnit to define a desired state before using this command.
        Use Test-DMorganizationalUnit to receive details about the changes it will perform.
     
    .PARAMETER Delete
        Implement deletion commands.
        By default, when updating an existing deployment you would need to creaate missing OUs first, then move other objects and only delete OUs as the final step.
        In order to prevent accidents, by default NO OUs will be deleted.
        To enable OU deletion, you must specify this parameter.
        This parameter allows you to call it twice in your workflow: Once to prepare it for other objects, and another time to do the cleanup.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMOrganizationalUnit -Server contoso.com
 
        Brings the domain contoso.com into OU compliance.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [switch]
        $Delete,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type OrganizationalUnits -Cmdlet $PSCmdlet
        $everyone = ([System.Security.Principal.SecurityIdentifier]'S-1-1-0').Translate([System.Security.Principal.NTAccount])
        $testResult = Test-DMOrganizationalUnit @parameters | Sort-Object { $_.Identity.Split(",").Count }
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'ShouldDelete' {
                    if (-not $Delete) {
                        Write-PSFMessage -String 'Invoke-DMOrganizationalUnit.OU.Delete.NoAction' -StringValues $testItem.Identity -Target $testItem
                        continue main
                    }
                    $childObjects = Get-ADObject @parameters -SearchBase $testItem.ADObject.DistinguishedName -LDAPFilter '(!(objectCategory=OrganizationalUnit))'
                    if ($childObjects) {
                        Write-PSFMessage -Level Warning -String 'Invoke-DMOrganizationalUnit.OU.Delete.HasChildren' -StringValues $testItem.ADObject.DistinguishedName, ($childObjects | Measure-Object).Count -Target $testItem -Tag 'ou','critical','panic'
                        continue main
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Delete' -Target $testItem -ScriptBlock {
                        # Remove "Protect from accidental deletion" if neccessary
                        if ($accidentProtectionRule = ($testItem.ADObject.nTSecurityDescriptor.Access | Where-Object { ($_.IdentityReference -eq $everyone) -and ($_.AccessControlType -eq 'Deny') }))
                        {
                            $null = $testItem.ADObject.nTSecurityDescriptor.RemoveAccessRule($accidentProtectionRule)
                            Set-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -Replace @{ nTSecurityDescriptor = $testItem.ADObject.nTSecurityDescriptor } -ErrorAction Stop
                        }
                        Remove-ADOrganizationalUnit @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'ConfigurationOnly' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMOrganizationalUnit.OU.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name = (Resolve-String -Text $testItem.Configuration.Name)
                            Description = (Resolve-String -Text $testItem.Configuration.Description)
                            Path = $targetOU
                        }
                        New-ADOrganizationalUnit @newParameters -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldOUs' {
                    Stop-PSFFunction -String 'Invoke-DMOrganizationalUnit.OU.MultipleOldOUs' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'ou','critical','panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock {
                        Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Changed' {
                    $changes = @{ }
                    if ($testItem.Changed -contains 'Description') { $changes['Description'] = (Resolve-String -Text $testItem.Configuration.Description) }
                    
                    if ($changes.Keys.Count -gt 0)
                    {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
    end
    {
        # Reset Content Searchbases
        $script:contentSearchBases = [PSCustomObject]@{
            Include = @()
            Exclude = @()
            Bases   = @()
            Server = ''
        }
    }
}


function Register-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Registers an organizational unit, defining it as a desired state.
     
    .DESCRIPTION
        Registers an organizational unit, defining it as a desired state.
     
    .PARAMETER Name
        Name of the OU to register.
        Subject to string insertion.
     
    .PARAMETER Description
        Description for the OU to register.
        Subject to string insertion.
     
    .PARAMETER Path
        The path to where the OU should be.
        Subject to string insertion.
     
    .PARAMETER OldNames
        Previous names the OU had.
        During invocation, if it is not found but an OU in the same path with a listed old name IS, it will be renamed.
        Subject to string insertion.
     
    .PARAMETER Present
        Whether the OU should be present.
        Defaults to $true
     
    .EXAMPLE
        PS C:\> Get-Content .\organizationalUnits.json | ConvertFrom-Json | Write-Output | Register-DMOrganizationalUnit
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as organizational unit configuration.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [string[]]
        $OldNames = @(),

        [bool]
        $Present = $true
    )
    
    process
    {
        $distinguishedName = 'OU={0},{1}' -f $Name, $Path
        $script:organizationalUnits[$distinguishedName] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.OrganizationalUnit'
            DistinguishedName = $distinguishedName
            Name = $Name
            Description = $Description
            Path = $Path
            OldNames = $OldNames
            Present = $Present
        }
    }
}


function Test-DMOrganizationalUnit
{
    <#
        .SYNOPSIS
            Tests whether the configured OrganizationalUnit match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured OrganizationalUnit match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMOrganizationalUnit
 
            Tests whether the configured OrganizationalUnits' state matches the current domain OrganizationalUnit setup.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type OrganizationalUnits -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        #region Process Configured OUs
        :main foreach ($ouDefinition in $script:organizationalUnits.Values) {
            $resolvedDN = Resolve-String -Text $ouDefinition.DistinguishedName

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'OrganizationalUnit'
                Identity = $resolvedDN
                Configuration = $ouDefinition
            }

            if (-not $ouDefinition.Present) {
                if ($adObject = Get-ADOrganizationalUnit @parameters -LDAPFilter "(distinguishedName=$resolvedDN)" -Properties Description, nTSecurityDescriptor) {
                    New-TestResult @resultDefaults -Type ShouldDelete -ADObject $adObject
                }
                continue main
            }
            
            #region Case: Does not exist
            if (-not (Test-ADObject @parameters -Identity $resolvedDN)) {
                $oldNamedOUs = foreach ($oldDN in ($ouDefinition.OldNames | Resolve-String)) {
                    foreach ($adOrgUnit in (Get-ADOrganizationalUnit @parameters -LDAPFilter "(distinguishedName=$oldDN)" -Properties Description, nTSecurityDescriptor)) {
                        $adOrgUnit
                    }
                }

                switch (($oldNamedOUs | Measure-Object).Count) {
                    #region Case: No old version present
                    0
                    {
                        New-TestResult @resultDefaults -Type ConfigurationOnly
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1
                    {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldNamedOUs
                        continue main
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default
                    {
                        New-TestResult @resultDefaults -Type MultipleOldOUs -ADObject $oldNamedOUs
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Case: Does not exist

            $adObject = Get-ADOrganizationalUnit @parameters -Identity $resolvedDN -Properties Description, nTSecurityDescriptor
            
            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property Description -Configuration $ouDefinition -ADObject $adObject -Changes $changes -Resolve

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
            }
        }
        #endregion Process Configured OUs

        #region Process Managed Containers
        $foundOUs = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADOrganizationalUnit @parameters -LDAPFilter '(!(isCriticalSystemObject=*))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties nTSecurityDescriptor | Where-Object DistinguishedName -Ne $searchBase.SearchBase
        }

        $resolvedConfiguredNames = $script:organizationalUnits.Values.DistinguishedName | Resolve-String

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'OrganizationalUnit'
        }

        foreach ($existingOU in $foundOUs) {
            if ($existingOU.DistinguishedName -in $resolvedConfiguredNames) { continue } # Ignore configured OUs - they were previously configured for moving them, if they should not be in these containers
            
            New-TestResult @resultDefaults -Type ShouldDelete -ADObject $existingOU -Identity $existingOU.Name
        }
        #endregion Process Managed Containers
    }
}


function Unregister-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Removes an organizational unit from the list of registered OUs.
     
    .DESCRIPTION
        Removes an organizational unit from the list of registered OUs.
        This effectively removes it from the definition of the desired OU state.
     
    .PARAMETER Name
        The name of the OU to unregister.
     
    .PARAMETER Path
        The path of the OU to unregister.
     
    .PARAMETER DistinguishedName
        The full Distinguished name of the OU to unregister.
     
    .EXAMPLE
        PS C:\> Get-DMOrganizationalUnit | Unregister-DMOrganizationalUnit
 
        Removes all registered organizational units from the configuration
    #>

    
    [CmdletBinding(DefaultParameterSetName = 'DN')]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'NamePath')]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'NamePath')]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'DN')]
        [string]
        $DistinguishedName
    )
    
    process
    {
        if ($DistinguishedName) {
            $script:organizationalUnits.Remove($DistinguishedName)
        }
        if ($Name) {
            $distName = 'OU={0},{1}' -f $Name, $Path
            $script:organizationalUnits.Remove($distName)
        }
    }
}


function Convert-DMSchemaGuid
{
    <#
    .SYNOPSIS
        Converts names to guid and guids to name as defined in the active directory schema.
     
    .DESCRIPTION
        Converts names to guid and guids to name as defined in the active directory schema.
        Can handle both attributes as well as rights.
        Uses mapping data generated from active directory.
     
    .PARAMETER Name
        The name to convert. Can be both string or guid.
     
    .PARAMETER OutType
        The data tape to emit:
        - Name: Humanly readable name
        - Guid: Guid object
        - GuidString: Guid as a string
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Convert-DMSchemaGuid -Name Public-Information -OutType GuidString
 
        Converts the right "Public-Information" into its guid representation (guid returned as a string type)
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        [Alias('Guid')]
        [string[]]
        $Name,

        [ValidateSet('Name', 'Guid', 'GuidString')]
        [string]
        $OutType = 'Guid',

        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $guidToName = Get-SchemaGuidMapping @parameters
        $nameToGuid = Get-SchemaGuidMapping @parameters -NameToGuid
        $guidToRight = Get-PermissionGuidMapping @parameters
        $rightToGuid = Get-PermissionGuidMapping @parameters -NameToGuid
    }
    process
    {
        :main foreach ($nameString in $Name) {
            switch ($OutType) {
                'Name'
                {
                    if ($nameString -as [Guid]) {
                        if ($guidToName[$nameString]) {
                            $guidToName[$nameString]
                            continue main
                        }
                        if ($guidToRight[$nameString]) {
                            $guidToRight[$nameString]
                            continue main
                        }
                    }
                    else { $nameString }
                }
                'Guid'
                {
                    if ($nameString -as [Guid]) {
                        $nameString -as [Guid]
                        continue main
                    }
                    if ($nameToGuid[$nameString]) {
                        $nameToGuid[$nameString] -as [guid]
                        continue main
                    }
                    if ($rightToGuid[$nameString]) {
                        $rightToGuid[$nameString] -as [guid]
                        continue main
                    }
                }
                'GuidString'
                {
                    if ($nameString -as [Guid]) {
                        $nameString
                        continue main
                    }
                    if ($nameToGuid[$nameString]) {
                        $nameToGuid[$nameString]
                        continue main
                    }
                    if ($rightToGuid[$nameString]) {
                        $rightToGuid[$nameString]
                        continue main
                    }
                }
            }
        }
    }
}


function Get-DMObjectDefaultPermission
{
    <#
    .SYNOPSIS
        Gathers the default object permissions in AD.
     
    .DESCRIPTION
        Gathers the default object permissions in AD.
        Uses PowerShell remoting against the SchemaMaster to determine the default permissions, as local identity resolution is not reliable.
     
    .PARAMETER ObjectClass
        The object class to look up.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-DMObjectDefaultPermission -ObjectClass user
 
        Returns the default permissions for a user.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ObjectClass,

        [PSFComputer]
        $Server = '<Default>',

        [PSCredential]
        $Credential
    )
    
    begin
    {
        if (-not $script:schemaObjectDefaultPermission) {
            $script:schemaObjectDefaultPermission = @{ }
        }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        #region Scriptblock that gathers information on default permission
        $gatherScript = {
            #$domain = Get-ADDomain -Server localhost
            #$forest = Get-ADForest -Server localhost
            #$rootDomain = Get-ADDomain -Server $forest.RootDomain
            $commonAce = @()
            <#
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '4c164200-20c0-11d0-a768-00aa006e0529', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', 'bc0ac240-79a9-11d0-9020-00c04fc2d4cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', 'bc0ac240-79a9-11d0-9020-00c04fc2d4cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '59ba2f42-79a2-11d0-9020-00c04fc2d3cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '59ba2f42-79a2-11d0-9020-00c04fc2d3cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '037088f8-0ae1-11d2-b422-00a0c968f939', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '037088f8-0ae1-11d2-b422-00a0c968f939', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '4c164200-20c0-11d0-a768-00aa006e0529', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '5f202010-79a5-11d0-9020-00c04fc2d4cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '5f202010-79a5-11d0-9020-00c04fc2d4cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', 'bf967a9c-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ListChildren', 'Allow', '00000000-0000-0000-0000-000000000000', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($domain.NetBIOSName)\Key Admins"), 'ReadProperty, WriteProperty', 'Allow', '5b47d60f-6090-40b2-9f37-2a4de88f3063', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($rootDomain.NetBIOSName)\Enterprise Key Admins"), 'ReadProperty, WriteProperty', 'Allow', '5b47d60f-6090-40b2-9f37-2a4de88f3063', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($rootDomain.NetBIOSName)\Enterprise Admins"), 'GenericAll', 'Allow', '00000000-0000-0000-0000-000000000000', 'All', '00000000-0000-0000-0000-000000000000')
            #>

            $parameters = @{ Server = $env:COMPUTERNAME }
            $rootDSE = Get-ADRootDSE @parameters
            $classes = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter '(objectCategory=classSchema)' -Properties defaultSecurityDescriptor, lDAPDisplayName
            foreach ($class in $classes) {
                $acl = [System.DirectoryServices.ActiveDirectorySecurity]::new()
                $acl.SetSecurityDescriptorSddlForm($class.defaultSecurityDescriptor)
                foreach ($rule in $commonAce) { $acl.AddAccessRule($rule) }
                
                <#
                if ($class.lDAPDisplayName -eq 'organizationalUnit') {
                    $acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'Everyone'), 'DeleteTree, Delete', 'Deny', '00000000-0000-0000-0000-000000000000', 'None', '00000000-0000-0000-0000-000000000000')))
                }
                #>

                [PSCustomObject]@{
                    Class = $class.lDAPDisplayName
                    Access = $acl.Access
                }
            }
        }
        #endregion Scriptblock that gathers information on default permission
    }
    process
    {
        if ($script:schemaObjectDefaultPermission["$Server"]) {
            return $script:schemaObjectDefaultPermission["$Server"].$ObjectClass
        }

        #region Process Gathering logic
        if ($Server -ne '<Default>') {
            $parameters['ComputerName'] = $parameters.Server
            $parameters.Remove("Server")
        }
        
        try { $data = Invoke-PSFCommand @parameters -ScriptBlock $gatherScript -ErrorAction Stop }
        catch { throw }
        $script:schemaObjectDefaultPermission["$Server"] = @{ }
        foreach ($datum in $data) {
            $script:schemaObjectDefaultPermission["$Server"][$datum.Class] = $datum.Access
        }
        $script:schemaObjectDefaultPermission["$Server"].$ObjectClass
        #endregion Process Gathering logic
    }
}

function Register-DMBuiltInSID
{
    <#
    .SYNOPSIS
        Register a name that points at a well-known SID.
     
    .DESCRIPTION
        Register a name that points at a well-known SID.
        This is used to reliably be able to compare access rules where built-in SIDs fail (e.g. for Sub-Domains).
        This functionality is exposed, in order to be able to resolve these identities, irrespective of name resolution and localization.
     
    .PARAMETER Name
        The name of the builtin entity to map.
     
    .PARAMETER SID
        The SID associated with the builtin entity.
     
    .EXAMPLE
        PS C:\> Register-DMBuiltInSID -Name 'BUILTIN\Incoming Forest Trust Builders' -SID 'S-1-5-32-557'
 
        Maps the group 'BUILTIN\Incoming Forest Trust Builders' to the SID 'S-1-5-32-557'
        Note: This mapping is pre-defined in the module and needs not be applied
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, Position =1, ValueFromPipelineByPropertyName = $true)]
        [System.Security.Principal.SecurityIdentifier]
        $SID
    )
    
    process
    {
        $script:builtInSidMapping[$Name] = $SID
    }
}


function Get-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Returns the list of configured Finegrained Password policies defined as the desired state.
     
    .DESCRIPTION
        Returns the list of configured Finegrained Password policies defined as the desired state.
     
    .PARAMETER Name
        The name of the password policy to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMPasswordPolicy
 
        Returns all defined PSO objects.
    #>

    
    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:passwordPolicies.Values | Where-Object Name -like $Name)
    }
}


function Invoke-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Applies the defined, desired state for finegrained password policies (PSOs)
     
    .DESCRIPTION
        Applies the defined, desired state for finegrained password policies (PSOs)
        Define the desired state using Register-DMPasswordPolicy.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-DMPasswordPolicy
 
        Applies the currently defined baseline for password policies to the current domain.
    #>

    
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type PasswordPolicies -Cmdlet $PSCmdlet
        $testResult = Test-DMPasswordPolicy @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                #region Delete
                'ShouldDelete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADFineGrainedPasswordPolicy @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Delete

                #region Create
                'ConfigurationOnly' {

                    $parametersNew = $parameters.Clone()
                    $parametersNew += @{
                        Name = (Resolve-String -Text $testItem.Configuration.Name)
                        Precedence = $testItem.Configuration.Precedence
                        ComplexityEnabled = $testItem.Configuration.ComplexityEnabled
                        LockoutDuration = $testItem.Configuration.LockoutDuration
                        LockoutObservationWindow = $testItem.Configuration.LockoutObservationWindow
                        LockoutThreshold = $testItem.Configuration.LockoutThreshold
                        MaxPasswordAge = $testItem.Configuration.MaxPasswordAge
                        MinPasswordAge = $testItem.Configuration.MinPasswordAge
                        MinPasswordLength = $testItem.Configuration.MinPasswordLength
                        DisplayName = (Resolve-String -Text $testItem.Configuration.DisplayName)
                        Description = (Resolve-String -Text $testItem.Configuration.Description)
                        PasswordHistoryCount = $testItem.Configuration.PasswordHistoryCount
                        ReversibleEncryptionEnabled = $testItem.Configuration.ReversibleEncryptionEnabled
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Create' -Target $testItem -ScriptBlock {
                        $adObject = New-ADFineGrainedPasswordPolicy @parametersNew -ErrorAction Stop -PassThru
                        Add-ADFineGrainedPasswordPolicySubject @parameters -Identity $adObject -Subjects (Resolve-String -Text $testItem.Configuration.SubjectGroup)
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Create

                #region Changed
                'Changed' {
                    $changes = @{ }
                    $updateAssignment = $false

                    switch ($testItem.Changed) {
                        'SubjectGroup' { $updateAssignment = $true; continue }
                        'DisplayName' { $changes['DisplayName'] = Resolve-String -Text $testItem.Configuration.DisplayName; continue }
                        'Description' { $changes['Description'] = Resolve-String -Text $testItem.Configuration.Description; continue }
                        default { $changes[$_] = $testItem.Configuration.$_; continue }
                    }
                    
                    if ($changes.Keys.Count -gt 0)
                    {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $parametersUpdate = $parameters.Clone()
                            $parametersUpdate += $changes
                            $null = Set-ADFineGrainedPasswordPolicy -Identity $testItem.ADObject.ObjectGUID @parametersUpdate -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }

                    if ($updateAssignment) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Update.GroupAssignment' -ActionStringValues (Resolve-String -Text $testItem.Configuration.SubjectGroup) -Target $testItem -ScriptBlock {
                            if ($testItem.ADObject.AppliesTo) {
                                Remove-ADFineGrainedPasswordPolicySubject @parameters -Identity $testItem.ADObject.ObjectGUID -Subjects $testItem.ADObject.AppliesTo -ErrorAction Stop
                            }
                            $null = Add-ADFineGrainedPasswordPolicySubject @parameters -Identity $testItem.ADObject.ObjectGUID -Subjects (Resolve-String -Text $testItem.Configuration.SubjectGroup) -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
                #endregion Changed
            }
        }
    }
}


function Register-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Register a new Finegrained Password Policy as the desired state.
     
    .DESCRIPTION
        Register a new Finegrained Password Policy as the desired state.
        These policies are then compared to the current state in a domain.
     
    .PARAMETER Name
        The name of the PSO.
     
    .PARAMETER DisplayName
        The display name of the PSO.
     
    .PARAMETER Description
        The description for the PSO.
     
    .PARAMETER Precedence
        The precedence rating of the PSO.
        The lower the precedence number, the higher the priority.
     
    .PARAMETER MinPasswordLength
        The minimum number of characters a password must have.
     
    .PARAMETER SubjectGroup
        The group that the PSO should be assigned to.
     
    .PARAMETER LockoutThreshold
        How many bad password entries will lead to account lockout?
     
    .PARAMETER MaxPasswordAge
        The maximum age a password may have before it must be changed.
     
    .PARAMETER ComplexityEnabled
        Whether complexity rules are applied to users affected by this policy.
        By default, complexity rules requires 3 out of: "Lowercase letter", "Uppercase letter", "number", "special character".
        However, custom password filters may lead to very validation rules.
     
    .PARAMETER LockoutDuration
        If the account is being locked out, how long will the lockout last.
     
    .PARAMETER LockoutObservationWindow
        What is the time window before the bad password count is being reset.
     
    .PARAMETER MinPasswordAge
        How soon may a password be changed again after updating the password.
     
    .PARAMETER PasswordHistoryCount
        How many passwords are kept in memory to prevent going back to a previous password.
     
    .PARAMETER ReversibleEncryptionEnabled
        Whether the password should be stored in a manner that allows it to be decrypted into cleartext.
        By default, only un-reversible hashes are being stored.
     
    .PARAMETER SubjectDomain
        The domain the group is part of.
        Defaults to the target domain.
     
    .PARAMETER Present
        Whether the PSO should exist.
        Defaults to $true.
        If this is set to $false, no PSO will be created, instead the PSO will be removed if it exists.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMPasswordPolicy
 
        Imports all the configured policies from the defined config json file.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DisplayName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $Precedence,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $MinPasswordLength,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $SubjectGroup,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $LockoutThreshold,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $MaxPasswordAge,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $ComplexityEnabled = $true,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $LockoutDuration = '1h',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $LockoutObservationWindow = '1h',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $MinPasswordAge = '30m',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $PasswordHistoryCount = 24,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $ReversibleEncryptionEnabled = $false,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $SubjectDomain = '%DomainFqdn%',

        [bool]
        $Present = $true
    )
    
    process
    {
        $script:passwordPolicies[$Name] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.PasswordPolicy'
            Name = $Name
            Precedence = $Precedence
            ComplexityEnabled = $ComplexityEnabled
            LockoutDuration = $LockoutDuration.Value
            LockoutObservationWindow = $LockoutObservationWindow.Value
            LockoutThreshold = $LockoutThreshold
            MaxPasswordAge = $MaxPasswordAge.Value
            MinPasswordAge = $MinPasswordAge.Value
            MinPasswordLength = $MinPasswordLength
            DisplayName = $DisplayName
            Description = $Description
            PasswordHistoryCount = $PasswordHistoryCount
            ReversibleEncryptionEnabled = $ReversibleEncryptionEnabled
            SubjectDomain = $SubjectDomain
            SubjectGroup = $SubjectGroup
            Present = $Present
        }
    }
}


function Test-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Tests, whether the deployed PSOs match the desired PSOs.
     
    .DESCRIPTION
        Tests, whether the deployed PSOs match the desired PSOs.
        Use Register-DMPasswordPolicy to define the desired PSOs.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMPasswordPolicy -Server contoso.com
 
        Checks, whether the contoso.com domain's password policies match the desired state.
    #>

    
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type PasswordPolicies -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($psoDefinition in $script:passwordPolicies.Values) {
            $resolvedName = Resolve-String -Text $psoDefinition.Name

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'PSO'
                Identity = $resolvedName
                Configuration = $psoDefinition
            }

            #region Password Policy that needs to be removed
            if (-not $psoDefinition.Present) {
                try { $adObject = Get-ADFineGrainedPasswordPolicy @parameters -Identity $resolvedName -Properties DisplayName, Description -ErrorAction Stop }
                catch { continue main } # Only errors when PSO not present = All is well
                
                New-TestResult @resultDefaults -Type ShouldDelete -ADObject $adObject
                continue
            }
            #endregion Password Policy that needs to be removed

            #region Password Policies that don't exist but should : $adObject
            try { $adObject = Get-ADFineGrainedPasswordPolicy @parameters -Identity $resolvedName -Properties Description, DisplayName -ErrorAction Stop }
            catch
            {
                New-TestResult @resultDefaults -Type ConfigurationOnly
                continue main
            }
            #endregion Password Policies that don't exist but should : $adObject

            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property ComplexityEnabled -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property Description -Configuration $psoDefinition -ADObject $adObject -Changes $changes -Resolve
            Compare-Property -Property DisplayName -Configuration $psoDefinition -ADObject $adObject -Changes $changes -Resolve
            Compare-Property -Property LockoutDuration -Configuration $psoDefinition -ADObject $adObject -Changes $changes -Resolve
            Compare-Property -Property LockoutObservationWindow -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property LockoutThreshold -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property MaxPasswordAge -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property MinPasswordAge -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property MinPasswordLength -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property PasswordHistoryCount -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property Precedence -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property ReversibleEncryptionEnabled -Configuration $psoDefinition -ADObject $adObject -Changes $changes
            $groupObjects = foreach ($groupName in $psoDefinition.SubjectGroup) {
                try { Get-ADGroup @parameters -Identity (Resolve-String -Text $groupName) }
                catch { Write-PSFMessage -Level Warning -String 'Test-DMPasswordPolicy.SubjectGroup.NotFound' -StringValues $groupName, $resolvedName }
            }
            if (-not $groupObjects -or -not $ADObject.AppliesTo -or (Compare-Object $groupObjects.DistinguishedName $ADObject.AppliesTo)) {
                $null = $changes.Add('SubjectGroup')
            }

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
            }
        }

        $passwordPolicies = Get-ADFineGrainedPasswordPolicy @parameters -Filter *
        $resolvedPolicies = $script:passwordPolicies.Values.Name | Resolve-String

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'PSO'
        }

        foreach ($passwordPolicy in $passwordPolicies) {
            if ($passwordPolicy.Name -in $resolvedPolicies) { continue }
            New-TestResult @resultDefaults -Type ShouldDelete -ADObject $passwordPolicy -Identity $passwordPolicy.Name
        }
    }
}


function Unregister-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Remove a PSO from the list of desired PSOs that are applied to a domain.
     
    .DESCRIPTION
        Remove a PSO from the list of desired PSOs that are applied to a domain.
     
    .PARAMETER Name
        The name of the PSO to remove.
     
    .EXAMPLE
        PS C:\> Unregister-DMPasswordPolicy -Name "T0 Admin Policy"
 
        Removes the "T0 Admin Policy" policy.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($entry in $Name) {
            $script:passwordPolicies.Remove($entry)
        }
    }
}


function Clear-DMConfiguration
{
    <#
        .SYNOPSIS
            Clears the configuration, removing all registered settings.
         
        .DESCRIPTION
            Clears the configuration, removing all registered settings.
            Use this to clean up, e.g. when switching to a new configuration set.
         
        .EXAMPLE
            PS C:\> Clear-DMConfiguration
 
            Clears the configuration, removing all registered settings.
    #>

    [CmdletBinding()]
    Param (
    
    )
    
    process
    {
        . "$script:ModuleRoot\internal\scripts\variables.ps1"
    }
}


function Get-DMCallback
{
    <#
    .SYNOPSIS
        Returns the list of registered callbacks.
     
    .DESCRIPTION
        Returns the list of registered callbacks.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback.
        Supports wildcard filtering.
     
    .EXAMPLE
        PS C:\> Get-DMCallback
 
        Returns a list of all registered callbacks
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        $script:callbacks.Values | Where-Object Name -like $Name
    }
}


function Get-DMContentMode
{
    <#
    .SYNOPSIS
        Returns the current domain content mode / content handling policy.
     
    .DESCRIPTION
        Returns the current domain content mode / content handling policy.
        For more details on the content mode and how it behaves, see the description on Set-DMContentMode
     
    .EXAMPLE
        PS C:\> Get-DMContentMode
 
        Returns the current domain content mode / content handling policy.
    #>

    [CmdletBinding()]
    Param ()
    
    process
    {
        $script:contentMode
    }
}


function Get-DMDomainCredential
{
    <#
    .SYNOPSIS
        Retrieve credentials stored for accessing the targeted domain.
     
    .DESCRIPTION
        Retrieve credentials stored for accessing the targeted domain.
        Returns nothing when no credentials were stored.
        This is NOT used by the main commands, but internally for retrieving data regarding foreign principals in one-way trusts.
        Generally, these credentials should never have more than reading access to the target domain.
     
    .PARAMETER Domain
        The domain to retrieve credentials for.
        Does NOT accept wildcards.
     
    .EXAMPLE
        PS C:\> Get-DMDomainCredential -Domain contoso.com
 
        Returns the credentials for accessing contoso.com, as long as those have previously been stored.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Domain
    )
    
    process
    {
        if (-not $script:domainCredentialCache) { return }
        $script:domainCredentialCache[$Domain]
    }
}


function Register-DMCallback
{
    <#
    .SYNOPSIS
        Registers a scriptblock to be called when invoking any Test- or Invoke- command.
     
    .DESCRIPTION
        Registers a scriptblock to be called when invoking any Test- or Invoke- command.
        This enables extending the module and ensuring correct configuration loading.
        The scriptblock will receive four arguments:
        - The Server targeted (if any)
        - The credentials used to do the targeting (if any)
        - The Forest the two earlier pieces of information map to (if any)
        - The Domain the two earlier pieces of information map to (if any)
        Any and all of these pieces of information may be empty.
        Any exception in a callback scriptblock will block further execution!
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback to register (multiple can be active at any given moment).
     
    .PARAMETER ScriptBlock
        The scriptblock containing the callback logic.
     
    .EXAMPLE
        PS C:\> Register-DMCallback -Name MyCompany -Scriptblock $scriptblock
 
        Registers the scriptblock stored in $scriptblock under the name 'MyCompany'
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ScriptBlock]
        $ScriptBlock
    )
    
    begin
    {
        if (-not $script:callbacks) {
            $script:callbacks = @{ }
        }
    }
    process
    {
        $script:callbacks[$Name] = [PSCustomObject]@{
            Name = $Name
            ScriptBlock = $ScriptBlock
        }
    }
}


function Set-DMContentMode
{
    <#
    .SYNOPSIS
        Configures the way the module handles domain level objects not defined in configuration.
     
    .DESCRIPTION
        Configures the way the module handles domain level objects not defined in configuration.
        Depending on the desired domain configuration, dealing with undesired objects may be desirable.
 
        This module handles the following configurations:
        Mode Additive: In this mode, all configured content is considered in addition to what is already there. Objects not in scope of the configuration are ignored.
        Mode Constrained: In this mode, objects not configured are handled based on OU rules:
        - Include: If Include OUs are configured, only objects in the specified OUs are under management. Only objects in these OUs will be considered for deletion if not configured.
        - Exclude: If Exclude OUs are configured, objects in the excluded OUs are ignored, all objects outside of these OUs will be considered for deletion if not configured.
        If both Include and Exclude OUs are configured, they are merged without applying the implied top-level Include of an Exclude-only configuration.
        In this scenario, if a top-level Include is desired, it needs to be explicitly set.
 
        When specifying Include and Exclude OUs, specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
     
    .PARAMETER Mode
        The mode to operate under.
        In Additive mode, objects not configured are being ignored.
        In Constrained mode, objects not configured may still be under maanagement, depending on Include and Exclude rules.
     
    .PARAMETER Include
        OUs in which to look for objects under management.
        Use this to explicitly list which OUs should be inspected for objects to delete.
        Only applied in Constrained mode.
        Specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
     
    .PARAMETER Exclude
        OUs in which to NOT look for objects under management.
        All other OUs are subject to management and having undesired objects deleted.
        Only applied in Constrained mode.
        Specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
 
    .PARAMETER UserExcludePattern
        Regex expressions that are applied to the name property of user objects found in AD.
        By default, in Constrained mode, all users found in paths resolved to be under management (through -Include and -Exclude specified in this command) that are not configured will be flagged for deletion.
        Using this parameter, it becomes possible to exempt specific accounts or accounts according to a specific pattern from this.
     
    .EXAMPLE
        PS C:\> Set-DMContentMode -Mode 'Constrained' -Include 'OU=Administration,%DomainDN%'
 
        Enables Constrained mode and configures the top-level OU "Administration" as an OU under management.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [ValidateSet('Additive', 'Constrained')]
        [string]
        $Mode,

        [string[]]
        $Include,

        [string[]]
        $Exclude,

        [string[]]
        $UserExcludePattern
    )
    
    process
    {
        if ($Mode) { $script:contentMode.Mode = $Mode }
        if ($Include) { $script:contentMode.Include = $Include }
        if ($Exclude) { $script:contentMode.Exclude = $Exclude }
        if ($UserExcludePattern) { $script:contentMode.UserExcludePattern = $UserExcludePattern }
    }
}


function Set-DMDomainContext
{
    <#
        .SYNOPSIS
            Updates the domain settings for string replacement.
         
        .DESCRIPTION
            Updates the domain settings for string replacement.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Set-DMDomainContext @parameters
 
            Updates the current domain context
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        $domainObject = Get-ADDomain @parameters
        $forestObject = Get-ADForest @parameters
        if ($forestObject.RootDomain -eq $domainObject.DNSRoot) { $forestRootDomain = $domainObject }
        else {
            try {
                $cred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
                $forestRootDomain = Get-ADDOmain @cred -Server $forestObject.RootDomain -ErrorAction Stop
            }
            catch {
                $forestRootDomain = [PSCustomObject]@{
                    Name = $forestObject.RootDomain.Split(".",2)[0]
                    DNSRoot = $forestObject.RootDomain
                    DistinguishedName = 'DC={0}' -f ($forestObject.RootDomain.Split(".") -join ",DC=")
                }
            }
        }

        $script:domainContext.Name = $domainObject.Name
        $script:domainContext.Fqdn = $domainObject.DNSRoot
        $script:domainContext.DN = $domainObject.DistinguishedName
        $script:domainContext.ForestFqdn = $forestObject.Name

        Register-DMNameMapping -Name '%DomainName%' -Value $domainObject.Name
        Register-DMNameMapping -Name '%DomainFqdn%' -Value $domainObject.DNSRoot
        Register-DMNameMapping -Name '%DomainDN%' -Value $domainObject.DistinguishedName
        Register-DMNameMapping -Name '%RootDomainName%' -Value $forestRootDomain.Name
        Register-DMNameMapping -Name '%RootDomainFqdn%' -Value $forestRootDomain.DNSRoot
        Register-DMNameMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName
        Register-DMNameMapping -Name '%ForestFqdn%' -Value $forestObject.Name

        if ($Credential) {
            Set-DMDomainCredential -Domain $domainObject.DNSRoot -Credential $Credential
            Set-DMDomainCredential -Domain $domainObject.Name -Credential $Credential
            Set-DMDomainCredential -Domain $domainObject.DistinguishedName -Credential $Credential
        }
    }
}


function Set-DMDomainCredential
{
    <#
    .SYNOPSIS
        Stores credentials stored for accessing the targeted domain.
     
    .DESCRIPTION
        Stores credentials stored for accessing the targeted domain.
        This is NOT used by the main commands, but internally for retrieving data regarding foreign principals in one-way trusts.
        Generally, these credentials should never have more than reading access to the target domain.
     
    .PARAMETER Domain
        The domain to store credentials for.
        Does NOT accept wildcards.
 
    .PARAMETER Credential
        The credentials to store.
     
    .EXAMPLE
        PS C:\> Set-DMDomainCredential -Domain contoso.com -Credential $cred
 
        Stores the credentials for accessing contoso.com.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Domain,

        [Parameter(Mandatory = $true)]
        [PSCredential]
        $Credential
    )
    
    process
    {
        if (-not $script:domainCredentialCache) {
            $script:domainCredentialCache = @{ }
        }

        $script:domainCredentialCache[$Domain] = $Credential
    }
}


function Set-DMRedForestContext
{
    <#
    .SYNOPSIS
        Sets the basic information of the red forest.
     
    .DESCRIPTION
        Sets the basic information of the red forest.
        This is used to provide for replacement variables usable on all properties of all domain objects supporting string resolution.
 
        There are two ways to gather this information:
        - Collect it from a forest (default; Collects from the current user's forest by default)
        - Explicitly provide the values.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER FQDN
        FQDN of the forest.
     
    .PARAMETER Name
        Name of the forest (usually the same as the FQDN)
 
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Set-DMRedForestContext
 
        Configures the current forest as red forest.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Access')]
    Param (
        [Parameter(ParameterSetName = 'Access')]
        [string]
        $Server,

        [Parameter(ParameterSetName = 'Access')]
        [pscredential]
        $Credential,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $FQDN,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $Name,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        switch ($PSCmdlet.ParameterSetName) {
            'Access'
            {
                try { $forest = Get-ADForest @parameters -ErrorAction Stop }
                catch {
                    Stop-PSFFunction -String 'Set-DMRedForestContext.Connection.Failed' -StringValues $Server -Target $Server -EnableException $EnableException -ErrorRecord $_
                    return
                }
                $script:redForestContext.Name = $forest.Name
                $script:redForestContext.Fqdn = $forest.Name
                $script:redForestContext.RootDomainName = ($forest.RootDomain -split "\.")[0]
                $script:redForestContext.RootDomainFqdn = $forest.RootDomain

                Register-DMNameMapping -Name '%RedForestName%' -Value $forest.Name
                Register-DMNameMapping -Name '%RedForestFqdn%' -Value $forest.Name
                Register-DMNameMapping -Name '%RedForestRootDomainName%' -Value ($forest.RootDomain -split "\.")[0]
                Register-DMNameMapping -Name '%RedForestRootDomainFqdn%' -Value $forest.RootDomain
            }
            'Name'
            {
                $script:redForestContext.Name = $Name
                $script:redForestContext.Fqdn = $FQDN
                $script:redForestContext.RootDomainName = ($FQDN -split "\.")[0]
                $script:redForestContext.RootDomainFqdn = $FQDN

                Register-DMNameMapping -Name '%RedForestName%' -Value $Name
                Register-DMNameMapping -Name '%RedForestFqdn%' -Value $FQDN
                Register-DMNameMapping -Name '%RedForestRootDomainName%' -Value ($FQDN -split "\.")[0]
                Register-DMNameMapping -Name '%RedForestRootDomainFqdn%' -Value $FQDN
            }
        }
    }
}


function Unregister-DMCallback
{
    <#
    .SYNOPSIS
        Removes a callback from the list of registered callbacks.
     
    .DESCRIPTION
        Removes a callback from the list of registered callbacks.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback to remove.
     
    .EXAMPLE
        PS C:\> Get-DMCallback | Unregister-DMCallback
 
        Unregisters all callback scriptblocks that have been registered.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:callbacks.Remove($nameItem)
        }
    }
}


function Get-DMUser
{
    <#
        .SYNOPSIS
            Lists registered ad users.
         
        .DESCRIPTION
            Lists registered ad users.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMUser
 
            Lists all registered ad users.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:users.Values | Where-Object SamAccountName -like $Name)
    }
}


function Invoke-DMUser
{
    <#
        .SYNOPSIS
            Updates the user configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the user configuration of a domain to conform to the configured state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Innvoke-DMUser -Server contoso.com
 
            Updates the users in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Users -Cmdlet $PSCmdlet
        $testResult = Test-DMUser @parameters
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'ShouldDelete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'ConfigurationOnly' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMUser.User.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name = (Resolve-String -Text $testItem.Configuration.SamAccountName)
                            SamAccountName = (Resolve-String -Text $testItem.Configuration.SamAccountName)
                            UserPrincipalName = (Resolve-String -Text $testItem.Configuration.UserPrincipalName)
                            PasswordNeverExpires = $testItem.Configuration.PasswordNeverExpires
                            Path = $targetOU
                            AccountPassword = (New-Password -Length 128 -AsSecureString)
                            Enabled = $true
                        }
                        if ($testItem.Configuration.Description) { $newParameters['Description'] = Resolve-String -Text $testItem.Configuration.Description }
                        if ($testItem.Configuration.GivenName) { $newParameters['GivenName'] = Resolve-String -Text $testItem.Configuration.GivenName }
                        if ($testItem.Configuration.Surname) { $newParameters['Surname'] = Resolve-String -Text $testItem.Configuration.Surname }
                        New-ADUser @newParameters
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldUsers' {
                    Stop-PSFFunction -String 'Invoke-DMUser.User.MultipleOldUsers' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'user','critical','panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.SamAccountName) -Target $testItem -ScriptBlock {
                        Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.SamAccountName) -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Changed' {
                    if ($testItem.Changed -contains 'Path') {
                        $targetOU = Resolve-String -Text $testItem.Configuration.Path
                        try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                        catch { Stop-PSFFunction -String 'Invoke-DMUser.User.Update.OUExistsNot' -StringValues $testItem.Identity, $targetOU -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }

                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Move' -ActionStringValues $targetOU -Target $testItem -ScriptBlock {
                            $null = Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -TargetPath $targetOU -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    $changes = @{ }
                    if ($testItem.Changed -contains 'GivenName') { $changes['GivenName'] = (Resolve-String -Text $testItem.Configuration.GivenName) }
                    if ($testItem.Changed -contains 'Surname') { $changes['Surname'] = (Resolve-String -Text $testItem.Configuration.Surname) }
                    if ($testItem.Changed -contains 'Description') { $changes['Description'] = (Resolve-String -Text $testItem.Configuration.Description) }
                    if ($testItem.Changed -contains 'PasswordNeverExpires') { $changes['PasswordNeverExpires'] = $testItem.Configuration.PasswordNeverExpires }
                    if ($testItem.Changed -contains 'UserPrincipalName') { $changes['UserPrincipalName'] = (Resolve-String -Text $testItem.Configuration.UserPrincipalName) }
                    
                    if ($changes.Keys.Count -gt 0)
                    {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
}


function Register-DMUser
{
    <#
    .SYNOPSIS
        Registers a user definition into the configuration domains are compared to.
     
    .DESCRIPTION
        Registers a user definition into the configuration domains are compared to.
        This configuration is then compared to the configuration in AD when using Test-ADUser.
 
        Note: Many properties can be set up for string replacement at runtime.
        For example to insert the domain DN into the path, insert "%DomainDN%" (without the quotes) where the domain DN would be placed.
        Use Register-DMNameMapping to add additional values and the placeholder they will be inserted into.
        Use Get-DMNameMapping to retrieve a list of available mappings.
        This can be used to use the same content configuration across multiple environments, accounting for local naming differences.
     
    .PARAMETER SamAccountName
        SamAccountName of the user to manage.
        Subject to string insertion.
     
    .PARAMETER GivenName
        Given Name of the user to manage.
        Subject to string insertion.
     
    .PARAMETER Surname
        Surname (Family Name) of the user to manage.
        Subject to string insertion.
     
    .PARAMETER Description
        Description of the user account.
        This is required and should describe the purpose / use of the account.
        Subject to string insertion.
     
    .PARAMETER PasswordNeverExpires
        Whether the password should never expire.
        By default it WILL expire.
     
    .PARAMETER UserPrincipalName
        The user principal name the account should have.
        Subject to string insertion.
     
    .PARAMETER Path
        The organizational unit the user should be placed in.
        Subject to string insertion.
     
    .PARAMETER OldNames
        Previous names the user object had.
        Will trigger a rename if a user is found under one of the old names but not the current one.
        Subject to string insertion.
     
    .PARAMETER Present
        Whether the user should be present.
        This can be used to trigger deletion of a managed account.
     
    .EXAMPLE
        PS C:\> Get-Content .\users.json | ConvertFrom-Json | Write-Output | Register-DMUser
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as user configuration.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $SamAccountName,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $GivenName,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Surname,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]
        $PasswordNeverExpires,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $UserPrincipalName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames = @(),

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Present = $true
    )

    process
    {

        $userHash = @{
            PSTypeName = 'DomainManagement.User'
            SamAccountName = $SamAccountName
            GivenName = $GivenName
            Surname = $Surname
            Description = $null
            PasswordNeverExpires = $PasswordNeverExpires.ToBool()
            UserPrincipalName = $UserPrincipalName
            Path = $Path
            OldNames = $OldNames
            Present = $Present
        }
        if ($Description) {
            $userHash['Description'] = $Description
        }
        $script:users[$SamAccountName] = [PSCustomObject]$userHash
    }
}


function Test-DMUser
{
    <#
        .SYNOPSIS
            Tests whether the configured users match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured users match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMUser
 
            Tests whether the configured users' state matches the current domain user setup.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Users -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        #region Process Configured Users
        :main foreach ($userDefinition in $script:users.Values) {
            $resolvedName = Resolve-String -Text $userDefinition.SamAccountName

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'User'
                Identity = $resolvedName
                Configuration = $userDefinition
            }

            #region User that needs to be removed
            if (-not $userDefinition.Present) {
                try { $adObject = Get-ADUser @parameters -Identity $resolvedName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
                catch { continue } # Only errors when user not present = All is well
                
                New-TestResult @resultDefaults -Type ShouldDelete -ADObject $adObject
                continue
            }
            #endregion User that needs to be removed

            #region Users that don't exist but should | Users that need to be renamed
            try { $adObject = Get-ADUser @parameters -Identity $resolvedName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
            catch
            {
                $oldUsers = foreach ($oldName in ($userDefinition.OldNames | Resolve-String)) {
                    try { Get-ADUser @parameters -Identity $oldName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
                    catch { }
                }

                switch (($oldUsers | Measure-Object).Count) {
                    #region Case: No old version present
                    0
                    {
                        New-TestResult @resultDefaults -Type ConfigurationOnly
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1
                    {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldUsers
                        continue main
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default
                    {
                        New-TestResult @resultDefaults -Type MultipleOldUsers -ADObject $oldUsers
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Users that don't exist but should | Users that need to be renamed

            #region Existing Users, might need updates
            # $adObject contains the relevant object

            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property GivenName -Configuration $userDefinition -ADObject $adObject -Changes $changes -Resolve
            Compare-Property -Property Surname -Configuration $userDefinition -ADObject $adObject -Changes $changes -Resolve
            if ($null -ne $userDefinition.Description) { Compare-Property -Property Description -Configuration $userDefinition -ADObject $adObject -Changes $changes -Resolve }
            Compare-Property -Property PasswordNeverExpires -Configuration $userDefinition -ADObject $adObject -Changes $changes
            Compare-Property -Property UserPrincipalName -Configuration $userDefinition -ADObject $adObject -Changes $changes -Resolve
            $ouPath = ($adObject.DistinguishedName -split ",",2)[1]
            if ($ouPath -ne (Resolve-String -Text $userDefinition.Path)) {
                $null = $changes.Add('Path')
            }
            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
            }
            #endregion Existing Users, might need updates
        }
        #endregion Process Configured Users

        #region Process Managed Containers
        $foundUsers = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADUser @parameters -LDAPFilter '(!(isCriticalSystemObject=*))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }

        $resolvedConfiguredNames = $script:users.Values.SamAccountName | Resolve-String
        $exclusionPattern = $script:contentMode.UserExcludePattern -join "|"

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'User'
        }

        foreach ($existingUser in $foundUsers) {
            if ($existingUser.Name -in $resolvedConfiguredNames) { continue } # Ignore configured users - they were previously configured for moving them, if they should not be in these containers
            if (1000 -ge ($existingUser.SID -split "-")[-1]) { continue } # Ignore BuiltIn default users
            if ($exclusionPattern -and $existingUser.Name -match $exclusionPattern) { continue } # Skip whitelisted usernames

            New-TestResult @resultDefaults -Type ShouldDelete -ADObject $existingUser -Identity $existingUser.Name
        }
        #endregion Process Managed Containers
    }
}


function Unregister-DMUser
{
    <#
    .SYNOPSIS
        Removes a user that had previously been registered.
     
    .DESCRIPTION
        Removes a user that had previously been registered.
     
    .PARAMETER Name
        The name of the user to remove.
     
    .EXAMPLE
        PS C:\> Get-DMUser | Unregister-DMUser
 
        Clears all registered users.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('SamAccountName')]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:users.Remove($nameItem)
        }
    }
}


<#
This is an example configuration file
 
By default, it is enough to have a single one of them,
however if you have enough configuration settings to justify having multiple copies of it,
feel totally free to split them into multiple files.
#>


<#
# Example Configuration
Set-PSFConfig -Module 'DomainManagement' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'"
#>


Set-PSFConfig -Module 'DomainManagement' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging."
Set-PSFConfig -Module 'DomainManagement' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments."


Set-PSFScriptblock -Name 'DomainManagement.Validate.TypeName.AccessRule' -Scriptblock {
    ($_.PSObject.TypeName -contains 'DomainManagement.AccessRule')
}

<#
Stored scriptblocks are available in [PsfValidateScript()] attributes.
This makes it easier to centrally provide the same scriptblock multiple times,
without having to maintain it in separate locations.
 
It also prevents lengthy validation scriptblocks from making your parameter block
hard to read.
 
Set-PSFScriptblock -Name 'DomainManagement.ScriptBlockName' -Scriptblock {
     
}
#>


<#
# Example:
Register-PSFTeppScriptblock -Name "DomainManagement.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' }
#>


<#
# Example:
Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name DomainManagement.alcohol
#>


New-PSFLicense -Product 'DomainManagement' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-08-09") -Text @"
Copyright (c) 2019 Friedrich Weinmann
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@


# NOTE: All variables in this file will be cleared when using Clear-DMConfiguration
# That generally happens when switching between sets of configuration

 #----------------------------------------------------------------------------#
 # Configuration #
 #----------------------------------------------------------------------------#

# Mapping table of values to insert
$script:nameReplacementTable = @{ }

# Configured Organizational Units
$script:organizationalUnits = @{ }

# Configured groups
$script:groups = @{ }

# Configured users
$script:users = @{ }

# Configured Group Memberships
$script:groupMemberShips = @{ }

# Configured Finegrained Password Policies
$script:passwordPolicies = @{ }

# Configured group policy objects
$script:groupPolicyObjects = @{ }

# Configured group policy links
$script:groupPolicyLinks = @{ }

# Configured ACLs
$script:acls = @{ }

# Configured Access Rules - Based on OU / Path
$script:accessRules = @{ }

# Configured Access Rules - Based on Object Category
$script:accessCategoryRules = @{ }

# Configured Object Categories
$script:objectCategories = @{ }

# Configured generic objects
$script:objects = @{ }


 #----------------------------------------------------------------------------#
 # Cached Data #
 #----------------------------------------------------------------------------#

# Cached security principals, used by Get-Principal. Mapping to AD Objects
$script:resolvedPrincipals = @{ }

# More principal caching, used by Convert-Principal. Mapping to SID or NT Account
$script:cache_PrincipalToSID = @{ }
$script:cache_PrincipalToNT = @{ }


 #----------------------------------------------------------------------------#
 # Context Data #
 #----------------------------------------------------------------------------#

# Content Mode
$script:contentMode = [PSCustomObject]@{
    PSTypeName = 'DomainManagement.Content.Mode'
    Mode    = 'Additive'
    Include = @()
    Exclude = @()
    UserExcludePattern = @()
}
$script:contentSearchBases = [PSCustomObject]@{
    Include = @()
    Exclude = @()
    Bases   = @()
    Server = ''
}

# Domain Context
$script:domainContext = [PSCustomObject]@{
    Name = ''
    Fqdn = ''
    DN   = ''
    ForestFqdn = ''
}

# Red Forest Context
$script:redForestContext = [PSCustomObject]@{
    Name = ''
    Fqdn = ''
    RootDomainFqdn = ''
    RootDomainName = ''
}

# File for variables that should NOT be reset on context changes
$script:builtInSidMapping = @{
    # English
    'BUILTIN\Account Operators' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-548'
    'BUILTIN\Server Operators' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-549'
    'BUILTIN\Print Operators' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-550'
    'BUILTIN\Pre-Windows 2000 Compatible Access' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-554'
    'BUILTIN\Incoming Forest Trust Builders' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-557'
    'BUILTIN\Windows Authorization Access Group' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-560'
    'BUILTIN\Terminal Server License Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-561'
    'BUILTIN\Certificate Service DCOM Access' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-574'
    'BUILTIN\RDS Remote Access Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-575'
    'BUILTIN\RDS Endpoint Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-576'
    'BUILTIN\RDS Management Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-577'
    'BUILTIN\Storage Replica Administrators' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-582'
}
#endregion Load compiled code