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 { foreach ($typeName in $type) { if ((Get-Variable -Name $typeName -Scope Script -ValueOnly -ErrorAction SilentlyContinue).Count -gt 0) { return } } Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues ($Type -join ", ") -FunctionName $Cmdlet.CommandRuntime $exception = New-Object System.Data.DataException("No configuration data provided for: $($Type -join ", ")") $errorID = 'NotConfigured' $category = [System.Management.Automation.ErrorCategory]::NotSpecified $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, ($Type -join ", ")) $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 { # Not as default value to avoid null-bind dilemma if (-not $ReferenceObject) { $ReferenceObject = @() } if (-not $DifferenceObject) { $DifferenceObject = @() } #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-ObjectProperty { <# .SYNOPSIS Compares whether the input item is contained in the list of reference items. .DESCRIPTION Compares whether the input item is contained in the list of reference items. For this comparison, we use the defined propertynames. The input object is only returned, if there is at least one object with the same values for the specified properties. .PARAMETER ReferenceObject The list of objects the input is compared to. .PARAMETER PropertyName The list of properties used to establish the equality comparison. .PARAMETER DifferenceObject The input objects that are compared to the list in -ReferenceObject and only returned if at least one match exists. .EXAMPLE PS C:\> $_ | Compare-ObjectProperty -ReferenceObject $ADRules -PropertyName Identity, Permission, Allow Compares the current item ($_) with the content of $ADRules whether a match exists that shares all of Identity, Permission and Allow. #> [CmdletBinding()] param ( [Parameter(Position = 0)] [PSObject[]] $ReferenceObject, [Parameter(Position = 1)] [PSFramework.Parameter.SelectParameter[]] $PropertyName, [Parameter(ValueFromPipeline = $true)] [PSObject[]] $DifferenceObject ) begin { $comparer = $ReferenceObject | Select-PSFObject $PropertyName $select = { Select-PSFObject $PropertyName }.GetSteppablePipeline() $select.Begin($true) $properties = $PropertyName | ForEach-Object { if ($_.Value -is [string]) { return $_.Value } else { $_.Value.Name } } | Remove-PSFNull } process { :dif foreach ($inputObject in $DifferenceObject) { $inputConverted = $select.Process($inputObject) :ref foreach ($reference in $comparer) { foreach ($property in $properties) { if ($reference.$property -ne $inputConverted.$property) { continue ref } } $inputObject continue dif } } } end { $select.End() } } 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. .PARAMETER Parameters AD Parameters to pass through for Resolve-String. .PARAMETER AsString Compare properties as string. Will convert all $null values to "". .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, [hashtable] $Parameters = @{ }, [switch] $AsString ) begin { if (-not $ADProperty) { $ADProperty = $Property } } process { $propValue = $Configuration.$Property if ($Resolve) { $propValue = $propValue | Resolve-String @parameters } if (($propValue -is [System.Collections.ICollection]) -and ($ADObject.$ADProperty -is [System.Collections.ICollection])) { if (Compare-Object $propValue $ADObject.$ADProperty) { $null = $Changes.Add($Property) } } elseif ($AsString) { if ("$propValue" -ne "$($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, ValueFromPipeline = $true)] [string] $Name, [ValidateSet('SID','NTAccount')] [string] $OutputType = 'SID', [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing' -StringValues $Name # 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 } #region Processing Input SID if ($Name -as [System.Security.Principal.SecurityIdentifier]) { Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.InputSID' -StringValues $Name 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 Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.InputNT' -StringValues $Name $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 Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.NTDetails' -StringValues $domainPart, $namePart $param = @{ Server = $domain.DNSRoot } $cred = Get-DMDomainCredential -Domain $domain.DNSRoot if ($cred) { $param['Credential'] = $cred } Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.NT.LdapFilter' -StringValues "(samAccountName=$namePart)" $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 # 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:DNStoDomainName[$DnsName]) { return $script:DNStoDomainName[$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:DNStoDomainName["$($domainObject.Name)"] = $domainObject $script:NetBiostoDomain["$($domainObject.NetBIOSName)"] = $domainObject } catch { } } $domainObject = $null if ($script:SIDtoDomain[$identity]) { return $script:SIDtoDomain[$identity] } if ($script:DNStoDomain[$identity]) { return $script:DNStoDomain[$identity] } if ($script:DNStoDomainName[$identity]) { return $script:DNStoDomainName[$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:DNStoDomainName["$($domainObject.Name)"] = $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] [Alias('ComputerName')] $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)(samAccountName=$Name)))" } try { $adObject = Get-ADObject @parameters -LDAPFilter $filter -ErrorAction Stop -Properties * | Select-Object -First 1 } catch { try { $adObject = Get-ADObject @parametersAD -LDAPFilter $filter -ErrorAction Stop -Properties * | Select-Object -First 1 } 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-ADObject { <# .SYNOPSIS Resolves AD Objects from wildcard-patterned DNs. .DESCRIPTION Resolves AD Objects from wildcard-patterned Distinguished Names. .PARAMETER Filter The wildcard-patterned DN .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER ObjectClass Only return objects of the specified object class. Default: * .EXAMPLE PS C:\> Resolve-ADObject -OUFilter '*,*,OU=Contoso,DC=contoso,DC=com' -ObjectClass user Resolves all user objects two steps under the Contoso OU. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true, Mandatory = $true)] [string] $Filter, [PSFComputer] $Server, [PSCredential] $Credential, [string] $ObjectClass = '*' ) begin { function Get-AdNextStep { [CmdletBinding()] param ( $Parameters, $Fragments, $BasePath ) $nameFilter = (@($Fragments)[0] -split "=",2)[-1] $adObjects = Get-ADObject @Parameters -SearchBase $BasePath -SearchScope OneLevel -LDAPFilter "(name=$nameFilter)" if (@($Fragments).Count -eq 1) { return $adObjects } foreach ($adObject in $adObjects) { Get-AdNextStep -Parameters $Parameters -BasePath $adObject.DistinguishedName -Fragments $Fragments[1..$Fragments.Length] } } $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { $filterSegments = ($Filter -replace ",DC=.+$" -split "(?<=[^\\],)").TrimEnd(",") $basePath = $Filter -replace '^.+?,DC=','DC=' [array]::Reverse($filterSegments) Get-AdNextStep -Parameters $parameters -Fragments $filterSegments -BasePath $basePath | Where-Object ObjectClass -Like $ObjectClass } } 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. .PARAMETER IgnoreMissingSearchbase Disables warnings if a defined searchbase is missing. For use in OU tests. .EXAMPLE PS C:\> Resolve-ContentSearchBase @parameters Resolves the configured filters into searchbases for the targeted domain. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [string] $Server, [pscredential] $Credential, [switch] $NoContainer, [switch] $IgnoreMissingSearchbase ) 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)) { if ($IgnoreMissingSearchbase) { continue } 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)) { if ($IgnoreMissingSearchbase) { continue } 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. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .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)] [AllowEmptyString()] [AllowNull()] [string[]] $Text, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $replacementScript = { param ( [string] $Match ) if ($Match -like "%!*%") { try { (Invoke-DMDomainData -Name $Match.Trim('%!') @parameters -EnableException).Data } catch { throw } } if ($script:nameReplacementTable[$Match]) { $script:nameReplacementTable[$Match] } else { $Match } } $pattern = $script:nameReplacementTable.Keys -join "|" if ($Server) { $pattern += '|{0}' -f ($script:domainDataScripts.Values.Placeholder -join "|") } } process { foreach ($textItem in $Text) { if (-not $textItem) { return $textItem } try { [regex]::Replace($textItem, $pattern, $replacementScript, 'IgnoreCase') } catch { throw } } } } 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 Test-DmKdsRootKey { <# .SYNOPSIS Tests whether the KDS Root Key has been set up. .DESCRIPTION Tests whether the KDS Root Key has been set up. Prompts the user whether to set it up if not done yet. A valid KDS Root Key is required for using group Managed Service Accounts. .PARAMETER ComputerName The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-DmKdsRootKey -ComputerName contoso.com Tests whether the contoso.com domain has been set up for gMSA. #> [OutputType([bool])] [CmdletBinding()] Param ( [PSFComputer] [Alias('Server')] $ComputerName, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential } process { if (Get-PSFConfigValue -FullName 'DomainManagement.ServiceAccount.SkipKdsCheck') { return $true } $domain = Get-Domain2 @parameters $rootKeys = Invoke-Command @parameters { Get-KdsRootKey } if ($rootKeys | Where-Object { $_.EffectiveTime -LT (Get-Date).AddHours(-10) -and $_.DomainController -match ",OU=[^,]+,$($domain.DistinguishedName)$" }) { return $true } $paramGetPSFUserChoice = @{ Caption = 'No active KDS Root Key Detected' Message = 'Do you want to create a KDS Rootkey backdated to be instantly applicable?' Options = 'Yes', 'No' DefaultChoice = 1 } $choice = Get-PSFUserChoice @paramGetPSFUserChoice if ($choice -eq 1) { return $false } try { Write-PSFMessage -Level Host -String 'Test-KdsRootKey.Adding' $null = Invoke-Command @parameters -ScriptBlock { Add-KdsRootKey -EffectiveTime (Get-Date).AddHours(-10) -ErrorAction Stop } -ErrorAction Stop return $true } catch { Write-PSFMessage -Level Warning -String 'Test-KdsRootKey.Failed' -ErrorRecord $_ } $false } } function Compare-Identity { <# .SYNOPSIS Compares two sets of identity references, similar to Compare-Object. .DESCRIPTION Compares two sets of identity references, similar to Compare-Object. Only real difference: Performs identity resolution and compares at the SID level. .PARAMETER ReferenceIdentity One set of identities to compare. .PARAMETER DifferenceIdentity The other set of identities to compare. .PARAMETER Parameters AD connection parameters. Offer a hashtable containing server or credentials in any combination. .PARAMETER IncludeEqual Return identities that occur in both sets. .PARAMETER ExcludeDifferent Do not return identities that only occur in one set .EXAMPLE PS C:\> $relevantADRule.IdentityReference | Compare-Identity -Parameters $parameters -ReferenceIdentity $ConfiguredRules.IdentityReference -IncludeEqual -ExcludeDifferent Compares all identities between the accessrule already existing on the AD object and the ones defined for it. Only returns existing Active Directory-existing rules if there also is at least one configured rule for its identity. #> [CmdletBinding()] param ( $ReferenceIdentity, [Parameter(ValueFromPipeline = $true)] $DifferenceIdentity, [Hashtable] $Parameters = @{ }, [switch] $IncludeEqual, [switch] $ExcludeDifferent ) begin { #region Utility Functions function ConvertTo-SID { [CmdletBinding()] param ( $IdentityReference, [Hashtable] $Parameters ) $resolved = Convert-BuiltInToSID -Identity $IdentityReference if ($resolved -is [System.Security.Principal.SecurityIdentifier]) { return $resolved } # NTAccount try { Convert-Principal -Name $resolved -OutputType SID @Parameters } catch { $resolved } } #endregion Utility Functions $referenceItems = foreach ($identity in $ReferenceIdentity) { $sid = ConvertTo-SID -IdentityReference $identity -Parameters $Parameters [PSCustomObject]@{ Type = "Reference" Original = $identity SID = $sid SIDString = "$sid" } } [System.Collections.ArrayList]$differenceItems = @() } process { foreach ($item in $DifferenceIdentity) { $sid = ConvertTo-SID -IdentityReference $item -Parameters $Parameters $result = [PSCustomObject]@{ Type = "Difference" Original = $identity SID = $sid SIDString = "$sid" } $null = $differenceItems.Add($result) } } end { if (-not $ExcludeDifferent) { foreach ($differenceItem in $differenceItems) { if ($differenceItem.SIDString -in $referenceItems.SIDString) { continue } [PSCustomObject]@{ Identity = $differenceItem.Original SID = $differenceItem.SID Direction = '<=' } } foreach ($referenceItem in $referenceItems) { if ($referenceItem.SIDString -in $differenceItems.SIDString) { continue } [PSCustomObject]@{ Identity = $referenceItem.Original SID = $referenceItem.SID Direction = '=>' } } } if ($IncludeEqual) { foreach ($differenceItem in $differenceItems) { if ($differenceItem.SIDString -notin $referenceItems.SIDString) { continue } [PSCustomObject]@{ Identity = $differenceItem.Original SID = $differenceItem.SID Direction = '==' } } } } } 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 $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("PSReviewUnusedParameter", "")] [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 .PARAMETER Parameters Hashtable containing server and credential informations. .EXAMPLE PS C:\> Test-AccessRuleEquality -Rule1 $rule -Rule2 $rule2 Compares $rule with $rule2 #> [OutputType([System.Boolean])] [CmdletBinding()] param ( $Rule1, $Rule2, $Parameters ) function Get-SID { [CmdletBinding()] param ( $Rule, $Parameters ) if ($Rule.SID) { return $Rule.SID } if ($Rule.IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { return $Rule.IdentityReference } # NTAccount Convert-Principal -Name $Rule.IdentityReference -OutputType SID @Parameters } 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)") { $oneSID = Get-SID -Rule $Rule1 -Parameters $Parameters $twoSID = Get-SID -Rule $Rule2 -Parameters $Parameters if ("$oneSID" -ne "$twoSID") { return $false } } return $true } function ConvertTo-FilterName { <# .SYNOPSIS Converts a GP permission filter string into a list of the names of conditions included in the filter. .DESCRIPTION Converts a GP permission filter string into a list of the names of conditions included in the filter. Deduplicates results. .PARAMETER Filter The filter to parse. .EXAMPLE C:\> ConvertTo-FilterName -Filter $Filter Converts the filter in $Filter into the deduplicated names of the conditions to apply. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Filter ) $tokens = $null $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseInput($Filter, [ref]$tokens, [ref]$errors) $tokens | Where-Object Kind -eq Identifier | Select-Object -ExpandProperty Text -Unique } 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 Add-Member -InputObject $linkObject -MemberType ScriptMethod -Name ToLink -Value { # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0] $status = '0' if ($this.Status -eq 'Disabled') { $status = '1' } if ($this.Status -eq 'Enforced') { $status = '2' } '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status } $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', 'VersionNumber' } 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 ADVersion = $adPolicyObject.VersionNumber ExportID = $null ImportTime = $null Version = -1 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("PSUseUsingScopeModifierInNewRunspaces", "")] [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 -Confirm:$false } catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.CopyingFiles.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ } #region Installing Group Policy Object 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 $_ } #endregion Installing Group Policy Object #region Applying Registry Settings $resolvedName = $Configuration.DisplayName | Resolve-String @parameters $applicableRegistrySettings = Get-DMGPRegistrySetting | Where-Object { $resolvedName -eq ($_.PolicyName | Resolve-String @parameters) } if ($applicableRegistrySettings) { $registryData = foreach ($applicableRegistrySetting in $applicableRegistrySettings) { if ($applicableRegistrySetting.PSObject.Properties.Name -contains 'Value') { [PSCustomObject]@{ GPO = $resolvedName Key = Resolve-String @parameters -Text $applicableRegistrySetting.Key ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName Type = $applicableRegistrySetting.Type Value = $applicableRegistrySetting.Value } } else { [PSCustomObject]@{ GPO = $resolvedName Key = Resolve-String @parameters -Text $applicableRegistrySetting.Key ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName Type = $applicableRegistrySetting.Type Value = ((Invoke-DMDomainData @parameters -Name $applicableRegistrySetting.DomainData).Data | Write-Output) } } } Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.Importing.RegistryValues' -StringValues $Configuration.DisplayName -Target $Configuration foreach ($registryDatum in $registryData) { try { Invoke-Command -Session $session -ArgumentList $registryDatum -ScriptBlock { param ($RegistryDatum) $domain = Get-ADDomain -Server localhost $null = Get-GPO -Server localhost -Domain $domain.DNSRoot -Name $RegistryDatum.GPO -ErrorAction Stop | Set-GPRegistryValue -Server localhost -Domain $domain.DNSRoot -Key $RegistryDatum.Key -ValueName $RegistryDatum.ValueName -Type $RegistryDatum.Type -Value $RegistryDatum.Value -ErrorAction Stop } -ErrorAction Stop } catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.Importing.RegistryValues.Failed' -StringValues $Configuration.DisplayName, $registryDatum.Key, $registryDatum.ValueName -ErrorRecord $_ } } } #endregion Applying Registry Settings 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, versionNumber -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 Version = $PolicyObject.VersionNumber } $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("PSUseUsingScopeModifierInNewRunspaces", "")] [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("PSUseUsingScopeModifierInNewRunspaces", "")] [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-GPFilterMapping { <# .SYNOPSIS Determines which filter conditions apply to which GPO .DESCRIPTION Determines which filter conditions apply to which GPO Used by components that apply rules based on GPOs, such as GP Permissions and GP Ownership. .PARAMETER Conditions The list of conditions that need to be evaluated. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Resolve-GPFilterMapping @parameters -Conditions ($ownerConfig.FilterConditions | Remove-PSFNull -Enumerate | Sort-Object -Unique) Returns a mapping of which of the conditions needed and what GPOs they apply to. #> [CmdletBinding()] param ( [AllowEmptyCollection()] [string[]] $Conditions, [PSFComputer] $Server, [PSCredential] $Credential ) process { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $result = [PSCustomObject]@{ Success = $true Mapping = @{ } Conditions = $Conditions AllGpos = @() MissingCondition = $null ErrorType = 'None' ErrorData = @() ErrorTarget = $null } $allFilters = @{ } foreach ($filterObject in Get-DMGPPermissionFilter) { $allFilters[$filterObject.Name] = $filterObject } $result.MissingCondition = $Conditions | Where-Object { $_ -notin $allFilters.Keys } if ($result.MissingCondition) { $result.ErrorType = 'MissingCondition' $result.Success = $false $result return } if ($Conditions) { $relevantFilters = $allFilters | ConvertTo-PSFHashtable -Include $Conditions } else { $relevantFilters = @() } $allGpos = Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName $result.AllGpos = $allGpos $filterToGPOMapping = @{ } $managedGPONames = (Get-DMGroupPolicy).DisplayName | Resolve-String #region Process individual filter conditions :conditions foreach ($condition in $relevantFilters.Values) { switch ($condition.Type) { #region Managed - Do we define the policy using the GroupPolicy Component? 'Managed' { if ($condition.Reverse -xor (-not $condition.Managed)) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotIn $managedGPONames } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -In $managedGPONames } } #endregion Managed - Do we define the policy using the GroupPolicy Component? #region Path - Resolve by where GPOs are linked 'Path' { $searchBase = Resolve-String -Text $condition.Path if (-not (Test-ADObject @parameters -Identity $searchBase)) { if ($condition.Optional) { Write-PSFMessage -String 'Resolve-GPFilterMapping.Filter.Path.DoesNotExist.SilentlyContinue' -StringValues $Condition.Name, $searchBase -Target $condition continue conditions } $result.Success = $false $result.ErrorType = 'PathNotFound' $result.ErrorData = $searchBase $result.ErrorTarget = $condition $result return } $objects = Get-ADObject @parameters -SearchBase $searchBase -SearchScope $condition.Scope -LDAPFilter '(|(objectCategory=OrganizationalUnit)(objectCategory=domainDNS))' -Properties gPLink $allLinkedGpoDNs = $objects | ConvertTo-GPLink | Select-Object -ExpandProperty DistinguishedName -Unique if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -NotIn $allLinkedGpoDNs } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -In $allLinkedGpoDNs } } #endregion Path - Resolve by where GPOs are linked #region GPName - Match by name, using either direct comparison, wildcard or regex 'GPName' { $resolvedGpoName = Resolve-String -Text $condition.GPName switch ($condition.Mode) { 'Explicit' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NE $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -EQ $resolvedGpoName } } 'Wildcard' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotLike $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -Like $resolvedGpoName } } 'Regex' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotMatch $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -Match $resolvedGpoName } } } } #endregion GPName - Match by name, using either direct comparison, wildcard or regex } } #endregion Process individual filter conditions $result.Mapping = $filterToGPOMapping $result } } 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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [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 Version = -1 Error = $null } return } if (-not (Test-Path $configPath)) { [pscustomobject]@{ Success = $true Exists = $false ExportID = $null Timestamp = $null Version = -1 Error = $null } return } try { $data = Import-Clixml -Path $configPath -ErrorAction Stop } catch { [pscustomobject]@{ Success = $false Exists = $true ExportID = $null Timestamp = $null Version = -1 Error = $_ } return } [pscustomobject]@{ Success = $true Exists = $true ExportID = $data.ExportID Timestamp = $data.Timestamp Version = $data.Version Error = $null } } #endregion Remote Call - Resolve GPO data => $result #region Process results $Policy.ExportID = $result.ExportID $Policy.ImportTime = $result.Timestamp $Policy.Version = $result.Version 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 Test-GPPermissionFilter { <# .SYNOPSIS Tests, whether a GP Permission Filter applies to a specific GPO. .DESCRIPTION Tests, whether a GP Permission Filter applies to a specific GPO. Used primarily by Test-DMGPPermission to resolve applicable permissions that have target selection through filters. .PARAMETER GpoName The name of the GPO that is tested against. .PARAMETER Filter The filter string the represents the condition on which it applies. .PARAMETER Conditions The list of filter conditions contained in the filter-string. These are processed/parsed out when registering the filter using Register-DMGPPermissionFilter. .PARAMETER FilterHash The hashtable mapping filter to list of GPOs that the filter applies to. .EXAMPLE PS C:\> Test-GPPermissionFilter -GpoName $permissionObject.Name -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterToGPOMapping Tests, whether a GP Permission Filter applies to the specified GPO. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')] [OutputType([System.Boolean])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $GpoName, [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string] $Filter, [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string[]] $Conditions, [Parameter(Mandatory = $true)] [hashtable] $FilterHash ) if (-not $Filter.Trim()) { return $false } $testResults = @{ } foreach ($condition in $Conditions) { $testResults[$condition] = $FilterHash[$condition].DisplayName -contains $GpoName } $predicate = { param ( $MatchInfo ) "`$testResults['$($MatchInfo.Value)']" } $pattern = $Conditions -join "|" $resolvedFilter = [regex]::Replace($Filter, $pattern, $predicate) <# This is actually a safe operation: - The filter condition is tokenized and parsed for a very limited set of legal tokens (logical operators, parenthesis and filter names) - The filter names are constrained so that only letters, numbers and underscores can be used, making them safe for regex and injection purposes. These safety measures have been implemented in the parameter validations of Register-DMGPPermission and Register-DMGPPermissionFilter #> Invoke-Expression $resolvedFilter } 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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 Set-DMDomainContext @parameters } process{ if (-not $InputObject) { $InputObject = Test-DMAccessRule @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.AccessRule.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMAccessRule', $testItem -Target $testItem -Continue -EnableException $EnableException } 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 $_ } $failedCount = 0 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, $changeEntry.DistinguishedName -Target $changeEntry if (-not $aclObject.RemoveAccessRuleSpecific($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 -Parameters $parameters -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, $changeEntry.DistinguishedName -Target $changeEntry -Debug:$false $failedCount = $failedCount + 1 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 { if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } $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 { $failedCount = $failedCount + 1 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 #region Restore Default Access Rules if ($changeEntry.Type -eq 'Restore') { Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Restore' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry try { if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } $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 { $failedCount = $failedCount + 1 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 Restore Default Access Rules } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAccessRule.Processing.Execute' -ActionStringValues ($testItem.Changed.Count - $failedCount), $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. .PARAMETER Present Whether the access rule should exist or not. By default, it should. Set this to $false in order to explicitly delete an existing access rule. Set this to 'Undefined' to neither create nor delete it, in which case it will simply be accepted if it exists. .PARAMETER NoFixConfig By default, Test-DMAccessRule will generate a "FixConfig" result for accessrules that have been explicitly defined but are also part of the Schema Default permissions. If this setting is enabled, this result object is suppressed. .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, [Parameter(ValueFromPipelineByPropertyName = $true)] [PSFramework.Utility.TypeTransformationAttribute([string])] [DomainManagement.TriBool] $Present = 'true', [bool] $NoFixConfig = $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 Present = $Present NoFixConfig = $NoFixConfig } } '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 Present = $Present NoFixConfig = $NoFixConfig } } } } } 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, [PSFComputer] $Server, [PSCredential] $Credential ) $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential # Resolve the mode under which it will be evaluated. Either 'Additive' or 'Constrained' $processingMode = Resolve-DMAccessRuleMode @parameters -ADObject $adObject function Write-Result { [CmdletBinding()] param ( [ValidateSet('Create', 'Delete', 'FixConfig', 'Restore')] [Parameter(Mandatory = $true)] $Type, $Identity, [AllowNull()] $ADObject, [AllowNull()] $Configuration, [string] $DistinguishedName ) $item = [PSCustomObject]@{ Type = $Type Identity = $Identity ADObject = $ADObject Configuration = $Configuration DistinguishedName = $DistinguishedName } Add-Member -InputObject $item -MemberType ScriptMethod ToString -Value { '{0}: {1}' -f $this.Type, $this.Identity } -Force -PassThru } $defaultRulesPresent = [System.Collections.ArrayList]::new() $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 -Parameters $parameters -Rule1 $adRule -Rule2 $defaultRule) { $null = $defaultRulesPresent.Add($defaultRule) continue outer } } $adRule } #region Foreach non-default AD Rule: Check whether configured and delete if not so :outer foreach ($relevantADRule in $relevantADRules) { foreach ($configuredRule in $ConfiguredRules) { if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $relevantADRule -Rule2 $configuredRule) { # If explicitly defined for deletion, do so if ('False' -eq $configuredRule.Present) { Write-Result -Type Delete -Identity $relevantADRule.IdentityReference -ADObject $relevantADRule -DistinguishedName $ADObject } continue outer } } # Don't generate delete changes if ($processingMode -eq 'Additive') { continue } # Don't generate delete changes, unless we have configured a permission level for the affected identity if ($processingMode -eq 'Defined') { if (-not ($relevantADRule.IdentityReference | Compare-Identity -Parameters $parameters -ReferenceIdentity $ConfiguredRules.IdentityReference -IncludeEqual -ExcludeDifferent)) { continue } } Write-Result -Type Delete -Identity $relevantADRule.IdentityReference -ADObject $relevantADRule -DistinguishedName $ADObject } #endregion Foreach non-default AD Rule: Check whether configured and delete if not so #region Foreach configured rule: Check whether it exists as defined or make it so :outer foreach ($configuredRule in $ConfiguredRules) { foreach ($defaultRule in $DefaultRules) { if ('True' -ne $configuredRule.Present) { break } if ($configuredRule.NoFixConfig) { break } if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $defaultRule -Rule2 $configuredRule) { Write-Result -Type FixConfig -Identity $defaultRule.IdentityReference -ADObject $defaultRule -Configuration $configuredRule -DistinguishedName $ADObject continue outer } } foreach ($relevantADRule in $relevantADRules) { if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $relevantADRule -Rule2 $configuredRule) { continue outer } } # Do not generate Create rules for any rule not configured for creation if ('True' -ne $configuredRule.Present) { continue } Write-Result -Type Create -Identity $configuredRule.IdentityReference -Configuration $configuredRule -DistinguishedName $ADObject } #endregion Foreach configured rule: Check whether it exists as defined or make it so #region Foreach non-existent default rule: Create unless configured otherwise $domainControllersOUFilter = '*{0}' -f ('OU=Domain Controllers,%DomainDN%' | Resolve-String) :outer foreach ($defaultRule in $DefaultRules | Where-Object { $_ -notin $defaultRulesPresent.ToArray()}) { # Do not apply restore to Domain Controllers OU, as it is already deployed intentionally diverging from the OU defaults if ($ADObject -like $domainControllersOUFilter) { break } foreach ($configuredRule in $ConfiguredRules) { if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $defaultRule -Rule2 $configuredRule) { # If we explicitly don't want the rule: Skip and do NOT create a restoration action if ('True' -ne $configuredRule.Present) { continue outer } } } Write-Result -Type Restore -Identity $defaultRule.IdentityReference -Configuration $defaultRule -DistinguishedName $ADObject } #endregion Foreach non-existent default rule: Create unless configured otherwise } 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 { if ('True' -ne $ruleObject.Present) { continue } 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 Present = $ruleObject.Present } } } 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 { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')] [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) { try { $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $domainObject.DNSRoot -OutputType NTAccount } catch { # Empty Catch is OK here, warning happens in command } } else { try { $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $accessRule.IdentityReference -OutputType NTAccount } catch { # Empty Catch is OK here, warning happens in command } } 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.Name, $parentObject.SamAccountName) } else { return [System.Security.Principal.NTAccount]('{0}\{1}' -f $domainObject.Name, $parentObject.Name) } } #endregion Parent Resolution #region Default Resolution $identity = Resolve-String -Text $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 $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 Present = $ruleObject.Present } } } } #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 @parameters -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 } $adAclObject = Get-AdsAcl @parameters -Path $foundADObject.DistinguishedName $compareParam = @{ ADRules = $adAclObject.Access | Convert-AccessRuleIdentity @parameters DefaultRules = Get-DMObjectDefaultPermission @parameters -ObjectClass $foundADObject.ObjectClass ConfiguredRules = Get-CategoryBasedRules -ADObject $foundADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid ADObject = $foundADObject } $compareParam += $parameters $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 (-not $script:accessRules[$ruleItem.Path]) { $script:accessRules.Remove($ruleItem.Path) } } if ($ruleItem.Category) { $script:accessCategoryRules[$ruleItem.Category] = $script:accessCategoryRules[$ruleItem.Category] | Where-Object { $_ -ne $ruleItem} if (-not $script:accessCategoryRules[$ruleItem.Category]) { $script:accessCategoryRules.Remove($ruleItem.Category) } } } } } function Get-DMAccessRuleMode { <# .SYNOPSIS Retrieve registered AccessRule processing modes. .DESCRIPTION Retrieve registered AccessRule processing modes. These are used to define, how AccessRules will be processed. .PARAMETER Path Filter by the path the AccessRule processing mode applies to. .PARAMETER ObjectCategory Filter by the object category the AccessRule processing mode applies to. .EXAMPLE PS C:\> Get-DMAccessRuleMode List all registered AccessRule processing modes. #> [CmdletBinding()] Param ( [string] $Path = '*', [string] $ObjectCategory = '*' ) process { $script:accessRuleMode.Values | Where-Object Path -like $Path | Where-Object ObjectCategory -like $ObjectCategory } } function Register-DMAccessRuleMode { <# .SYNOPSIS Register the processing mode for access rules on a specified object. .DESCRIPTION Register the processing mode for access rules on a specified object. This is used by the AccessRule Component exclusively. .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 PathMode Whether to only target a specific path or the target path and all items beneath it. .PARAMETER ObjectCategory Instead of a path, define a category to apply the processing mode to. Categories are defined using Register-DMObjectCategory. This allows you to apply processing mode to a category of objects, rather than a specific path. With this you could apply a processing mode to all domain controller objects, for example. .PARAMETER Mode Determines, how the AccessRules are applied on the target object: - Constrained: All non-defined AccessRules will be removed. - Defined: Only non-defined AccessRules with identities for which a configuration exists on the object will be deleted. - Additive: Non-defined AccessRules on the targeted object will be ignored. By default, with no AccessRuleMode defined, all objects are considered to be in Constrained mode. .EXAMPLE PS C:\> Register-DMAccessRuleMode -Path 'OU=Company,%DomainDN%' -PathMode SubTree -Mode Additive Configures the specified OU and all items beneath it to be in additive mode. Defined AccessRules will be applied if missing, but previously existing rules remain untouched. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] [string] $Path, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] [ValidateSet('SingleItem', 'SubTree')] [string] $PathMode = 'SingleItem', [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')] [string] $ObjectCategory, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ValidateSet('Constrained', 'Defined', 'Additive')] [string] $Mode ) process { $identity = 'Path:{0}:{1}' -f $PathMode,$Path if ($ObjectCategory) { $identity = 'Category:{0}' -f $ObjectCategory } $script:accessRuleMode[$identity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.AccessRuleMode' Identity = $identity Type = $PSCmdlet.ParameterSetName Path = $Path PathMode = $PathMode ObjectCategory = $ObjectCategory Mode = $Mode } } } function Resolve-DMAccessRuleMode { <# .SYNOPSIS Resolves the AccessRule processing mode that applies to the specified ADObject. .DESCRIPTION Resolves the AccessRule processing mode that applies to the specified ADObject. .PARAMETER ADObject The AD Object for which to resolve the AccessRule processing mode. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Resolve-DMAccessRuleMode @parameters -ADObject $adObject Resolves the AccessRule processing mode that applies to the specified ADObject. #> [OutputType([string])] [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] $ADObject, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { if ($script:accessRuleMode.Count -lt 1) { return 'Constrained' } $relevantCategories = @() if ($script:accessRuleMode.Values.ObjectCategory) { $relevantCategories = Resolve-DMObjectCategory -ADObject $ADObject @parameters } $applicableModes = :main foreach ($mode in $script:accessRuleMode.Values) { if ($mode.Path) { try { $resolvedPath = $mode.Path | Resolve-String @parameters } catch { Write-PSFMessage -Level Warning -String 'Resolve-DMAccessRuleMode.PathResolution.Failed' -StringValues $mode.Path -ErrorRecord $_ $resolvedPath = $mode.Path | Resolve-String } switch ($mode.PathMode) { 'SingleItem' { if ($ADObject.DistinguishedName -eq $resolvedPath) { $mode } continue main } 'SubTree' { if ($ADObject.DistinguishedName -like "*$resolvedPath") { $mode } continue main } } } if ($mode.ObjectCategory -and ($mode.ObjectCategory -in $relevantCategories.Name)) { $mode } } if ($primaryMode = $applicableModes | Where-Object { $_.Type -eq 'Path' -and $_.PathMode -eq 'SingleItem'}) { return $primaryMode.Mode } if ($secondaryMode = $applicableModes | Where-Object Type -eq 'Category' | Select-Object -First 1) { return $secondaryMode.Mode } if ($tertiaryMode = $applicableModes | Where-Object { $_.Type -eq 'Path' -and $_.PathMode -eq 'SubTree'} | Sort-Object { $_.Path.Length } -Descending | Select-Object -First 1) { return $tertiaryMode.Mode } return 'Constrained' } } function Unregister-DMAccessRuleMode { <# .SYNOPSIS Removes previously registered AccessRule processing modes. .DESCRIPTION Removes previously registered AccessRule processing modes. Prioritizes Identity over Path over ObjectCategory. .PARAMETER Identity The Identity of the AccessRule processing mode to remove. .PARAMETER Path The Path of the AccessRule processing mode to remove. .PARAMETER ObjectCagegory The ObjectCategory of the AccessRule processing mode to remove. .EXAMPLE PS C:\> Get-DMAccessRuleMode | Unregister-DMAccessRuleMode Clears all registered AccessRule processing modes. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [AllowNull()] [string] $Identity, [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [AllowNull()] [string] $Path, [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [AllowNull()] [string] $ObjectCagegory ) process { if ($Identity) { $script:accessRuleMode.Remove($Identity) } elseif ($Path) { $script:accessRuleMode.Remove("Path:$Path") } elseif ($ObjectCagegory) { $script:accessRuleMode.Remove("Category:$ObjectCategory") } } } 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 ($script:aclsByCategory.Values) | Where-Object Category -like $Path $script:aclDefaultOwner | 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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process{ if (-not $InputObject) { $InputObject = Test-DMAcl @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Acl.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMAcl', $testItem -Target $testItem -Continue -EnableException $EnableException } 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) { Disable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false } 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 ObjectCategory Assign ACL settings based on the ObjectCategory of an object. .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. .PARAMETER DefaultOwner Whether to make this the default owner for objects not specified under either a path or an object category. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [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] $Owner, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')] [bool] $NoInheritance = $false, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')] [bool] $Optional = $false, [Parameter(ParameterSetName = 'DefaultOwner')] [switch] $DefaultOwner, [string] $ContextName = '<Undefined>' ) process { switch ($PSCmdlet.ParameterSetName) { 'path' { $script:acls[$Path] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Acl' Path = $Path Owner = $Owner NoInheritance = $NoInheritance Optional = $Optional ContextName = $ContextName } } 'category' { $script:aclByCategory[$ObjectCategory] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Acl' Category = $ObjectCategory Owner = $Owner NoInheritance = $NoInheritance Optional = $Optional ContextName = $ContextName } } 'DefaultOwner' { # Array to appease Assert-Configuration $script:aclDefaultOwner = @([PSCustomObject]@{ PSTypeName = 'DomainManagement.Acl' Path = '<default>' Owner = $Owner NoInheritance = $false Optional = $null ContextName = $ContextName }) } } } } 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, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet Set-DMDomainContext @parameters #region Functions function Get-ChangeByCategory { [CmdletBinding()] param ( $ADObject, $Category, $ResultDefaults, $Parameters ) $aclObject = Get-AdsAcl @Parameters -Path $ADObject -EnableException # Ensure Owner Name is present - may not always resolve $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier]) $configuredSID = $Category.Owner | Resolve-String | Convert-Principal @parameters -OutputType SID [System.Collections.ArrayList]$changes = @() if ("$ownerSID" -ne "$configuredSID") { $null = $changes.Add('Owner') } Compare-Property -Property NoInheritance -Configuration $Category -ADObject $aclObject -Changes $changes -ADProperty AreAccessRulesProtected if ($changes.Count) { New-TestResult @resultDefaults -Identity $ADObject -Configuration $Category -Type Changed -Changed $changes.ToArray() -ADObject $aclObject } } #endregion Functions } 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]) $configuredSID = $aclDefinition.Owner | Resolve-String | Convert-Principal @parameters -OutputType SID [System.Collections.ArrayList]$changes = @() if ("$ownerSID" -ne "$configuredSID") { $null = $changes.Add('Owner') } 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 ADObjects 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=*)' -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 } if ($script:aclByCategory.Count -gt 0) { $category = Resolve-DMObjectCategory -ADObject $foundADObject @parameters if ($matchingCategory = $category | Where-Object Name -in $script:aclByCategory.Keys | Select-Object -First 1) { Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclByCategory[$matchingCategory.Name] -ResultDefaults $resultDefaults -Parameters $parameters continue } } if ($script:aclDefaultOwner) { Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclDefaultOwner[0] -ResultDefaults $resultDefaults -Parameters $parameters } else { New-TestResult @resultDefaults -Type ShouldManage -ADObject $foundADObject -Identity $foundADObject.DistinguishedName } } #endregion check if all ADObjects 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. .PARAMETER Category The object category the acl settings apply to .EXAMPLE PS C:\> Get-DMAcl | Unregister-DMAcl Clears all registered acls. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Path, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $Category ) process { foreach ($pathItem in $Path) { if ($pathItem -eq '<default>') { $script:aclDefaultOwner = $null } else { $script:acls.Remove($pathItem) } } foreach ($categoryItem in $Category) { $script:aclByCategory.Remove($categoryItem) } } } function Get-DMDomainData { <# .SYNOPSIS Returns registered domain data gathering scripts. .DESCRIPTION Returns registered domain data gathering scripts. .PARAMETER Name The name to filter by, accepts wildcards. Defaults to '*' .EXAMPLE PS C:\> Get-DomainData Returns all registered domain data gathering scripts #> [CmdletBinding()] Param ( [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)] [string] $Name = '*' ) process { $script:domainDataScripts.Values | Where-Object Name -like $Name } } function Invoke-DMDomainData { <# .SYNOPSIS Gathers domain specific data. .DESCRIPTION Gathers domain specific data. The gathering scripts are supplied using Register-DMDomainData. The data is currently consumed only by the extended group policy Component. .PARAMETER Name Name of the registered scriptblock to invoke. .PARAMETER Reset Disable retrieving data from cache. By default, all data is cached on a per-domain basis. .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:\> Invoke-DMDomainData @parameters -Name PKIServer Executes the scriptblock stored as PKIServer against the targeted domain. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true)] [PsfValidatePattern('^[\d\w_]+$', ErrorString = 'DomainManagement.Validate.DomainData.Pattern')] [string] $Name, [switch] $Reset, [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 Set-DMDomainContext @parameters } process { #region Script not found if (-not $script:domainDataScripts[$Name]) { $result = [PSCustomObject]@{ Name = $Name Data = $null Error = "Script not found, check configuration" Success = $false Type = "ScriptNotFound" Timestamp = Get-Date } Write-PSFMessage -Level Warning -String 'Invoke-DMDomainData.Script.NotFound' -StringValues $Name -Target $result if ($EnableException) { Stop-PSFFunction -String 'Invoke-DMDomainData.Script.NotFound.Error' -StringValues $Name -Target $result -EnableException $EnableException -Category ObjectNotFound } $result return } #endregion Script not found $domainObject = Get-Domain2 @parameters if (-not $script:cache_DomainData[$domainObject.DNSRoot]) { $script:cache_DomainData[$domainObject.DNSRoot] = @{ } } if ($script:cache_DomainData[$domainObject.DNSRoot][$Name] -and -not $Reset) { return $script:cache_DomainData[$domainObject.DNSRoot][$Name] } $scriptTask = $script:domainDataScripts[$Name] $result = [PSCustomObject]@{ Name = $Name Data = $null Error = $null Success = $false Type = $null Timestamp = Get-Date } try { $result.data = $scriptTask.Scriptblock.Invoke($parameters.Clone()) $result.Success = $true $result.Type = 'Success' $result.Timestamp = Get-Date $script:cache_DomainData[$domainObject.DNSRoot][$Name] = $result $result } catch { $result.Error = $_ $result.Timestamp = Get-Date $result.Type = $_.CategoryInfo.Category Write-PSFMessage -String 'Invoke-DMDomainData.Invocation.Error' -StringValues $Name -ErrorRecord $_ -Target $result if ($EnableException) { Stop-PSFFunction -String 'Invoke-DMDomainData.Invocation.Error.Terminate' -StringValues $Name -ErrorRecord $_ -Target $result -EnableException $EnableException } $result } } } function Register-DMDomainData { <# .SYNOPSIS Registers a domain data gathering script. .DESCRIPTION Registers a domain data gathering script. These can be used to provide domain specific data (in contrast to the usual context specific data, which might be applied to multiple domains). .PARAMETER Name Name under which to register the data gathering script. Can only contain letters, numbers and underscores. .PARAMETER Scriptblock The scriptblock performing the actual gathering. Receives a hashtable containing Server and - possibly - Credentials. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Import-PowerShellDataFile .\config.psd1 | ForEach-Object { Register-DMDomainData @_ } Registers all configuration settings stored in config.psd1 #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidatePattern('^[\d\w_]+$', ErrorString = 'DomainManagement.Validate.DomainData.Pattern')] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [scriptblock] $Scriptblock, [string] $ContextName = '<Undefined>' ) process { $script:domainDataScripts[$Name] = [PSCustomObject]@{ Name = $Name Placeholder = '%!{0}%' -f $Name Scriptblock = $Scriptblock ContextName = $ContextName } } } function Unregister-DMDomainData { <# .SYNOPSIS Removes registered domain data gathering scripts. .DESCRIPTION Removes registered domain data gathering scripts. Also deletes all associated cached data. .PARAMETER Name Name of the domain data gathering script to remove. .EXAMPLE PS C:\> Get-DMDomainData | Unregister-DMDomainData Clears all domain data gathering scripts. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($nameString in $Name) { $script:domainDataScripts.Remove($nameString) foreach ($domainDataHash in $script:cache_DomainData.Values) { $domainDataHash.Remove($nameString) } } } } function Get-DMDomainLevel { <# .SYNOPSIS Returns the defined desired state if configured. .DESCRIPTION Returns the defined desired state if configured. .EXAMPLE PS C:\> Get-DMDomainLevel Returns the defined desired state if configured. #> [CmdletBinding()] Param ( ) process { $script:domainLevel } } function Invoke-DMDomainLevel { <# .SYNOPSIS Applies the desired domain level if needed. .DESCRIPTION Applies the desired domain level if 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-DMDomainLevel -Server contoso.com Raises the domain "contoso.com" to the desired level if needed. #> [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 DomainLevel -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process { foreach ($testItem in Test-DMDomainLevel @parameters) { switch ($testItem.Type) { 'Raise' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMDomainLevel.Raise.Level' -ActionStringValues $testItem.Configuration.Level -Target $testItem.ADObject -ScriptBlock { Set-ADDomainMode @parameters -DomainMode $testItem.Configuration.DesiredLevel -Identity $testItem.ADObject -ErrorAction Stop -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } } } } function Register-DMDomainLevel { <# .SYNOPSIS Register a domain functional level as desired state. .DESCRIPTION Register a domain functional level as desired state. .PARAMETER Level The level to apply. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Register-DMDomainLevel -Level 2016 Apply the desired domain level of 2016 #> [CmdletBinding()] param ( [ValidateSet('2008R2', '2012', '2012R2', '2016')] [string] $Level, [string] $ContextName = '<Undefined>' ) process { $script:domainLevel = @([PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.DomainLevel' Level = $Level ContextName = $ContextName }) } } function Test-DMDomainLevel { <# .SYNOPSIS Tests whether the target domain has at least the desired functional level. .DESCRIPTION Tests whether the target domain has at least the desired functional level. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-DMDomainLevel -Server contoso.com Tests whether the domain contoso.com has at least the desired functional level. #> [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 DomainLevel -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process { $levelValues = @{ '2008R2' = 4 '2012' = 5 '2012R2' = 6 '2016' = 7 } $level = Get-DMDomainLevel $desiredLevel = $levelValues[$level.Level] $tempConfiguration = $level | ConvertTo-PSFHashtable $tempConfiguration['DesiredLevel'] = [Microsoft.ActiveDirectory.Management.ADDomainMode]$desiredLevel $domain = Get-ADDomain @parameters if ($domain.DomainMode -lt $desiredLevel) { New-TestResult -ObjectType DomainLevel -Type Raise -Identity $domain -Server $Server -Configuration ([pscustomobject]$tempConfiguration) -ADObject $domain } } } function Unregister-DMDomainLevel { <# .SYNOPSIS Removes the domain level configuration if present. .DESCRIPTION Removes the domain level configuration if present. .EXAMPLE PS C:\> Unregister-DMDomainLevel Removes the domain level configuration if present. #> [CmdletBinding()] Param ( ) process { $script:domainLevel = $null } } function Get-DMExchange { <# .SYNOPSIS Returns the defined Exchange domain configuration to apply. .DESCRIPTION Returns the defined Exchange domain configuration to apply. .EXAMPLE PS C:\> Get-DMExchange Returns the defined Exchange domain configuration to apply. #> [CmdletBinding()] param ( ) process { $script:exchangeVersion } } function Invoke-DMExchange { <# .SYNOPSIS Apply the desired exchange domain content update. .DESCRIPTION Apply the desired exchange domain content update. Use Register-DMExchange to define the exchange update. .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-DMExchange -Server dc1.emea.contoso.com Apply the desired exchange domain content update to the emea.contoso.com domain. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true)] [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 ExchangeVersion -Cmdlet $PSCmdlet $domainObject = Get-ADDomain @parameters #region Utility Functions function Test-ExchangeIsoPath { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession] $Session, [string] $Path ) Invoke-Command -Session $Session -ScriptBlock { Test-Path -Path $using:Path } } function Invoke-ExchangeDomainUpdate { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession] $Session, [string] $Path, [ValidateSet('Install', 'Update')] [string] $Mode ) $result = Invoke-Command -Session $Session -ScriptBlock { param ( $Parameters ) $exchangeIsoPath = Resolve-Path -Path $Parameters.Path # Mount Volume $diskImage = Mount-DiskImage -ImagePath $exchangeIsoPath -PassThru $volume = Get-Volume -DiskImage $diskImage $installPath = "$($volume.DriveLetter):\setup.exe" #region Execute $resultText = switch ($Parameters.Mode) { 'Install' { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } 'Update' { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } } $results = [pscustomobject]@{ Success = $LASTEXITCODE -lt 1 Message = $resultText -join "`n" } #endregion Execute # Dismount Volume try { Dismount-DiskImage -ImagePath $exchangeIsoPath } catch { } # Report result $results } -ArgumentList ($PSBoundParameters | ConvertTo-PSFHashtable -Exclude Session) Write-PSFMessage -Message ($result.Message -join "`n") -Tag exchange, result if (-not $result.Success) { throw "Error applying exchange update: $($result.Message)" } } #endregion Utility Functions } process { $testResult = Test-DMExchange @parameters if (-not $testResult) { return } #region PS Remoting $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential $psParameter.ComputerName = $Server try { $session = New-PSSession @psParameter -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-DMExchange.WinRM.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $Server return } #endregion PS Remoting #region Execute try { switch ($testResult.Type) { 'Install' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testResult.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-DMExchange.IsoPath.Missing' -StringValues $testResult.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMExchange.Installing' -ActionStringValues $testResult.Configuration -Target $domainObject -ScriptBlock { Invoke-ExchangeDomainUpdate -Session $session -Mode Install -Path $testResult.Configuration.LocalImagePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'Update' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testResult.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-DMExchange.IsoPath.Missing' -StringValues $testResult.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMExchange.Updating' -ActionStringValues $testResult.Configuration -Target $domainObject -ScriptBlock { Invoke-ExchangeDomainUpdate -Session $session -Mode Update -Path $testResult.Configuration.LocalImagePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } } #endregion Execute finally { if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -Confirm:$false -WhatIf:$false } } } } function Register-DMExchange { <# .SYNOPSIS Registers an exchange version to apply to the domain's exchange objects. .DESCRIPTION Registers an exchange version to apply to the domain's exchange objects. Updating this requires Enterprise Admin permissions. .PARAMETER LocalImagePath The path where to find the Exchange ISO file Must be local on the remote server connected to! Updating the Exchange AD settings is only supported when executed through the installer contained in that ISO file without exceptions. .PARAMETER ExchangeVersion The version of the Exchange server to apply. E.g. 2016CU6 We map Exchange versions to their respective identifier in AD: ObjectVersion in the domain's Microsoft Exchange System Objects container. This parameter is to help avoiding to have to look up that value. If your version is not supported by us yet, look up the version number and explicitly bind it to -ObjectVersion instead. .PARAMETER ObjectVersion The object version on the "Microsoft Exchange System Objects" container in the domain. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Register-DMExchange -LocalImagePath 'C:\ISO\exchange-2019-cu6.iso' -ExchangeVersion '2019CU6' Registers the Exchange 2019 CU6 exchange version as exchange domain settings to be applied. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $LocalImagePath, [Parameter(Mandatory = $true, ParameterSetName = 'Version')] [PsfValidateSet(TabCompletion = 'ADMF.Core.ExchangeVersion')] [PsfArgumentCompleter('ADMF.Core.ExchangeVersion')] [string] $ExchangeVersion, [Parameter(ParameterSetName = 'Details')] [int] $ObjectVersion, [string] $ContextName = '<Undefined>' ) process { $object = [pscustomobject]@{ PSTypeName = 'DomainManagement.Configuration.Exchange' ObjectVersion = $ObjectVersion LocalImagePath = $LocalImagePath ExchangeVersion = (Get-AdcExchangeVersion | Where-Object DomainVersion -eq $ObjectVersion | Sort-Object Name | Select-Object -Last 1).Name ContextName = $ContextName } if ($ExchangeVersion) { # Will always succeede, since the input validation prevents invalid exchange versions $exchangeVersionInfo = Get-AdcExchangeVersion -Binding $ExchangeVersion $object.ObjectVersion = $exchangeVersionInfo.DomainVersion $object.ExchangeVersion = $exchangeVersionInfo.Name } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { if ($this.ExchangeVersion) { $this.ExchangeVersion } else { $this.ObjectVersion } } -Force $script:exchangeVersion = @($object) } } function Test-DMExchange { <# .SYNOPSIS Check whether the targeted domain has the desired exchange object update version. .DESCRIPTION Check whether the targeted domain has the desired exchange object update version. Use Register-DMExchange to define the desired version. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-DMExchange Check whether the current domain has the desired exchange object update version. #> [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 ExchangeVersion -Cmdlet $PSCmdlet } process { $desiredState = Get-DMExchange $adObject = Get-ADObject @parameters -LDAPFilter '(objectClass=msExchSystemObjectsContainer)' -Properties objectVersion $resultDefaults = @{ ObjectType = 'ExchangeVersion' Server = $parameters.Server Configuration = $desiredState } if (-not $adObject) { New-TestResult @resultDefaults -Type Install -Identity 'Exchange Domain Objects' return } if (($adObject.objectVersion -as [int]) -lt $desiredState.ObjectVersion) { New-TestResult @resultDefaults -Type Update -Identity 'Exchange Domain Objects' -ADObject $adObject } } } function Unregister-DMExchange { <# .SYNOPSIS Clears the defined exchange domain configuration from the loaded configuration set. .DESCRIPTION Clears the defined exchange domain configuration from the loaded configuration set. .EXAMPLE PS C:\> Unregister-DMExchange Clears the defined exchange domain configuration from the loaded configuration set. #> [CmdletBinding()] param ( ) process { $script:exchangeVersion = $null } } 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 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [string] $PolicyName = '*', [string] $OrganizationalUnit = '*' ) process { ($script:groupPolicyLinks.Values.Values) | Where-Object { ($_.PolicyName -like $PolicyName) -and ($_.OrganizationalUnit -like $OrganizationalUnit) } | Remove-PSFNull ($script:groupPolicyLinksDynamic.Values.Values) | Where-Object { ($_.PolicyName -like $PolicyName) -and ($_.OrganizationalUnit -like $OrganizationalUnit) } | Remove-PSFNull } } 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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 -Confirm:$false } 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.Include | Sort-Object -Property @{ Expression = { $_.Tier }; Descending = $false }, 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 } $stateID = "0" if ($_.State -eq 'Enforced') { $stateID = "2" } if ($_.State -eq 'Disabled') { $stateID = "1" } "[LDAP://$gpoDN;$stateID]" }) -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 -Confirm:$false } 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.ExtendedInclude.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.ExtendedInclude | Sort-Object -Property @{ Expression = { $_.Tier }; Descending = $false }, Precedence -Descending | ForEach-Object { $_.ToLink() }) -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 -Confirm:$false } #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, GroupPolicyLinksDynamic -Cmdlet $PSCmdlet $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{ if (-not $InputObject) { $InputObject = Test-DMGPLink @parameters } #region Executing Test-Results foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GPLink.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGPLink', $testItem -Target $testItem -Continue -EnableException $EnableException } $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 } '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 } } } #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 OUFilter A filter string for an organizational unit. The filter must be a wildcard-pattern supporting distinguishedname. .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. .PARAMETER Tier The tier of a link is a priority ordering on top of Precedence. While precedence determines order within a given tier, each tier is processed separately. The higher the tier number, the higher the priority. In additive mode, already existing linked policies have a Tier 0 priority. If you want your own policies to be prepended, use Tier 1 or higher. If you want your own policies to have the least priority however, user Tier -1 or lower. Default: 1 .PARAMETER State The state the link should be in. Supported states: + Enabled: Link should be enabled + Disabled: Link should be disabled + Enforced: Link is being enforced + Undefined: The current state of the link is ignored Defaults to: Enabled .PARAMETER ProcessingMode In which way GPO links are being processed: - Additive: Add provided links, but do not modify the existing ones. - Constrained: Replace existing links that are undesired By default, constrained mode is being used. If any single link for a given Organizational Unit is in constrained mode, the entire OU is processed under constraind mode. .PARAMETER Present Whether the link should be present at all. Relevant in additive mode, to retain the capability to delete undesired links. .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, ParameterSetName = 'Path')] [Alias('OU')] [string] $OrganizationalUnit, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [string] $OUFilter, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [int] $Precedence, [Parameter(ValueFromPipelineByPropertyName = $true)] [int] $Tier = 1, [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('Enabled', 'Disabled', 'Enforced', 'Undefined')] [string] $State = 'Enabled', [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('Constrained', 'Additive')] [string] $ProcessingMode = 'Constrained', [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $Present = $true ) process { switch ($PSCmdlet.ParameterSetName) { 'Path' { if (-not $script:groupPolicyLinks[$OrganizationalUnit]) { $script:groupPolicyLinks[$OrganizationalUnit] = @{ } } $script:groupPolicyLinks[$OrganizationalUnit][$PolicyName] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.GPLink' PolicyName = $PolicyName OrganizationalUnit = $OrganizationalUnit Precedence = $Precedence Tier = $Tier State = $State ProcessingMode = $ProcessingMode Present = $Present } } 'Filter' { if (-not $script:groupPolicyLinksDynamic[$OUFilter]) { $script:groupPolicyLinksDynamic[$OUFilter] = @{ } } $script:groupPolicyLinksDynamic[$OUFilter][$PolicyName] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.GPLink' PolicyName = $PolicyName OUFilter = $OUFilter Precedence = $Precedence Tier = $Tier State = $State ProcessingMode = $ProcessingMode Present = $Present } } } } } 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, GroupPolicyLinksDynamic -Cmdlet $PSCmdlet Set-DMDomainContext @parameters #region Utility Functions function Get-OUData { [CmdletBinding()] param ( $Parameters ) $ous = @{ } #region Explicit OUs foreach ($organizationalUnit in $script:groupPolicyLinks.Keys) { $resolvedOU = Resolve-String -Text $organizationalUnit $ous[$resolvedOU] = [PSCustomObject]@{ OrganizationalUnit = $resolvedOU ProcessingMode = 'Additive' Include = @() Exclude = @() ExtendedInclude = @() } $ous[$resolvedOU].Include = $script:groupPolicyLinks[$organizationalUnit].Values | Where-Object Present $ous[$resolvedOU].Exclude = $script:groupPolicyLinks[$organizationalUnit].Values | Where-Object Present -EQ $false if ($ous[$resolvedOU].Include.ProcessingMode -contains 'Constrained') { $ous[$resolvedOU].ProcessingMode = 'Constrained' } } #region Explicit OUs #region Filter-Based OUs foreach ($filter in $script:groupPolicyLinksDynamic.Keys) { $adObjects = Resolve-ADObject @Parameters -Filter (Resolve-String -Text $filter) -ObjectClass organizationalUnit $values = $script:groupPolicyLinksDynamic[$filter].Values foreach ($adObject in $adObjects) { if (-not $ous[$adObject.DistinguishedName]) { $ous[$adObject.DistinguishedName] = [PSCustomObject]@{ OrganizationalUnit = $adObject.DistinguishedName ProcessingMode = 'Additive' Include = @() Exclude = @() ExtendedInclude = @() } } $container = $ous[$adObject.DistinguishedName] $container.Include = $container.Include, $values | Remove-PSFNull -Enumerate | Where-Object Present $container.Exclude = $container.Exclude, $values | Remove-PSFNull -Enumerate | Where-Object Present -EQ $false if ($container.Include.ProcessingMode -contains 'Constrained') { $container.ProcessingMode = 'Constrained' } } } #endregion Filter-Based OUs $ous.Values } function New-Update { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( $PolicyName, $Status, $Action ) [PSCustomObject]@{ PSTypeName = 'DomainManagement.GPLink.Update' Action = $Action Policy = $PolicyName Status = $Status } } function ConvertTo-LinkConfigWithState { <# .SYNOPSIS Convert config object to new object and match it with the state of the current item. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] $LinkObject, [AllowNull()] $CurrentLinks, [hashtable] $GpoDisplayToDN = @{ } ) process { foreach ($linkItem in $LinkObject) { if (-not $linkItem) { continue } $currentLink = $CurrentLinks | Where-Object DisplayName -eq $linkItem.PolicyName $itemHash = $linkItem | ConvertTo-PSFHashtable $itemHash.PSTypeName = 'DomainManagement.GPLink' $itemHash.StateValid = ($linkItem.State -eq $currentLink.Status) -or ($currentLink -and $linkItem.State -eq 'Undefined') $itemHash.CurrentState = $currentLink.Status $itemHash.PolicyName = $itemHash.PolicyName | Resolve-String $itemHash.DistinguishedName = $GpoDisplayToDN[$itemHash.PolicyName] $object = [PSCustomObject]$itemHash Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { switch ($this.State) { 'Enabled' { $this.PolicyName } 'Disabled' { '~|{0}' -f $this.PolicyName } 'Enforced' { '*|{0}' -f $this.PolicyName } } } -Force Add-Member -InputObject $object -MemberType ScriptMethod -Name ToLink -Value { # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0] $statusLabel = $this.State if ($statusLabel -eq 'Undefined' -and $this.CurrentState) { $statusLabel = $this.CurrentState } elseif ($statusLabel -eq 'Undefined') { $statusLabel = 'Enabled' } $status = switch ($statusLabel) { 'Enabled' { "0" } 'Disabled' { "1" } 'Enforced' { "2" } } '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status } $object } } } function ConvertFrom-ADLink { [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] $LinkObject ) process { foreach ($object in $LinkObject) { $objectHash = $object | ConvertTo-PSFHashtable $objectHash.Tier = 0 $objectHash.PolicyName = $objectHash.DisplayName $objectHash.StateValid = $true $objectHash.CurrentState = $objectHash.Status $objectHash.State = $objectHash.Status $item = [PSCustomObject]$objectHash Add-Member -InputObject $item -MemberType ScriptMethod -Name ToString -Value { switch ($this.Status) { 'Enabled' { $this.DisplayName } 'Disabled' { '~|{0}' -f $this.DisplayName } 'Enforced' { '*|{0}' -f $this.DisplayName } } } -Force Add-Member -InputObject $item -MemberType ScriptMethod -Name ToLink -Value { # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0] $status = '0' if ($this.Status -eq 'Disabled') { $status = '1' } if ($this.Status -eq 'Enforced') { $status = '2' } '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status } $item } } } function Get-LinkUpdate { [CmdletBinding()] param ( $Configuration, $ADObject, $GpoDisplayToDN ) $currentSorted = $ADObject.LinkedGroupPolicyObjects | Sort-Object Precedence $includeSorted = $Configuration.Include | Sort-Object @{ Expression = { $_.Tier }; Descending = $true }, Precedence | Where-Object PolicyName -NotIn $Configuration.Exclude.PolicyName | ConvertTo-LinkConfigWithState -CurrentLinks $currentSorted -GpoDisplayToDN $GpoDisplayToDN if ($Configuration.ProcessingMode -eq 'Additive') { $currentAdditive = $ADObject.LinkedGroupPolicyObjects | Where-Object DisplayName -NotIn $includeSorted.PolicyName | Where-Object DisplayName -NotIn $Configuration.Exclude.PolicyName | Sort-Object Precedence | ConvertFrom-ADLink $newDesiredState = @($currentAdditive) + @($includeSorted) | Write-Output | Remove-PSFNull | Sort-Object @{ Expression = { $_.Tier }; Descending = $true }, Precedence } else { $newDesiredState = $includeSorted } $Configuration.ExtendedInclude = $newDesiredState $orderCorrect = Compare-Array -ReferenceObject $newDesiredState.PolicyName -DifferenceObject $currentSorted.DisplayName -OrderSpecific -Quiet if ($orderCorrect -and $newDesiredState.StateValid -notcontains $false) { return } $index = 0 foreach ($desired in $newDesiredState) { if ($currentSorted.DisplayName -notcontains $desired.PolicyName) { New-Update -Action Add -PolicyName $desired.PolicyName -Status 'Enabled' $index = $index + 1 continue } if ($index -gt @($currentSorted).Count -or $desired.PolicyName -ne $currentSorted[$index].DisplayName) { New-Update -Action Reorder -PolicyName $desired.PolicyName -Status 'Enabled' $index = $index + 1 continue } if (-not $desired.StateValid) { New-Update -Action State -PolicyName $desired.PolicyName -Status $desired.State $index = $index + 1 continue } $index = $index + 1 } foreach ($current in $currentSorted) { if ($current.DisplayName -notin $newDesiredState.PolicyName) { New-Update -Action Delete -PolicyName $current.DisplayName -Status $current.Status } } } #endregion Utility Functions $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 Process Configuration $ouData = Get-OUData -Parameters $parameters foreach ($ouDatum in $ouData) { $resultDefaults = @{ Server = $Server ObjectType = 'GPLink' Identity = $ouDatum.OrganizationalUnit Configuration = $ouDatum } #region Handle AD Object doesn't exist try { $adObject = Get-ADObject @parameters -Identity $ouDatum.OrganizationalUnit -ErrorAction Stop -Properties gPLink, Name, DistinguishedName $resultDefaults['ADObject'] = $adObject } catch { Write-PSFMessage -String 'Test-DMGPLink.OUNotFound' -StringValues $ouDatum.OrganizationalUnit -ErrorRecord $_ -Tag 'panic', 'failed' New-TestResult @resultDefaults -Type 'MissingParent' Continue } #endregion Handle AD Object doesn't exist #region Handle AD Object does not contain any links $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 } #endregion Handle AD Object does not contain any links $updates = Get-LinkUpdate -Configuration $ouDatum -ADObject $adObject -GpoDisplayToDN $gpoDisplayToDN if ($updates) { New-TestResult @resultDefaults -Type 'Update' -Changed ($updates | Sort-Object { if ($_.Action -eq "Delete") { 0 } elseif ($_.Action -eq "Reorder") { 1 } else { 2 } }) } } #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' } $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 $ouData.OrganizationalUnit) { continue } if ([string]::IsNullOrWhiteSpace($adObject.GPLink)) { continue } $linkObjects = $adObject | ConvertTo-GPLink -PolicyMapping $gpoDNToDisplay Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value $linkObjects -Force $changes = foreach ($linkedObject in $linkObjects) { New-Update -PolicyName $linkedObject.DisplayName -Status $linkedObject.Status -Action Delete } New-TestResult -ObjectType GPLink -Type 'Delete' -Identity $adObject.DistinguishedName -Server $Server -ADObject $adObject -Changed $changes } #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. .PARAMETER OUFilter The filter of the filterbased policy link to remove .EXAMPLE PS C:\> Get-DMGPLink | Unregister-DMGPLink Clears all configured Group policy links. #> [CmdletBinding(DefaultParameterSetName = 'Path')] param ( [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $PolicyName, [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] [Alias('OU')] [string] $OrganizationalUnit, [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [string] $OUFilter ) process { switch ($PSCmdlet.ParameterSetName) { 'Path' { $script:groupPolicyLinks[$OrganizationalUnit].Remove($PolicyName) if ($script:groupPolicyLinks[$OrganizationalUnit].Keys.Count -lt 1) { $script:groupPolicyLinks.Remove($OrganizationalUnit) } } 'Filter' { $script:groupPolicyLinksDynamic[$OUFilter].Remove($PolicyName) if ($script:groupPolicyLinksDynamic[$OUFilter].Keys.Count -lt 1) { $script:groupPolicyLinksDynamic.Remove($OUFilter) } } } } } function Get-DMGPOwner { <# .SYNOPSIS Returns the list of defined group policy ownerships. .DESCRIPTION Returns the list of defined group policy ownerships. This represents the _desired_ state in your domain, not the one that actually pertains. .PARAMETER GpoName The name of the by which to filter. .PARAMETER Identity The identity reference to be made owner. .PARAMETER Filter The actual filter logic that determines, whether a policy should be affected by the given rule. .PARAMETER IsGlobal Only return the global / default owner setting .EXAMPLE PS C:\> Get-DMGPOwner Returns all configured GP ownerships #> [CmdletBinding()] Param ( [string] $GpoName, [string] $Identity, [string] $Filter, [switch] $IsGlobal ) process { $results = foreach ($rule in $script:groupPolicyOwners.Values) { if ((Test-PSFParameterBinding -ParameterName GpoName) -and ($rule.GpoName -notlike $GpoName)) { continue } if ((Test-PSFParameterBinding -ParameterName Identity) -and ($rule.Identity -notlike $Identity)) { continue } if ((Test-PSFParameterBinding -ParameterName Filter) -and ($rule.Filter -notlike $Filter)) { continue } if ($IsGlobal -and -not $rule.All) { continue } $rule } $results } } function Invoke-DMGPOwner { <# .SYNOPSIS Brings all group ownerships into the desired state. .DESCRIPTION Brings all group ownerships into the desired state. Use Register-DMGPOwner to define a desired state. Use Test-DMGPOwner to test/preview changes. .PARAMETER InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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-DMGPOwner -Server corp.contoso.com Bringsgs the domain corp.contoso.com into the desired state where group policy ownership is concerned #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 GroupPolicyOwners -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process { # Test All GPO Ownerships if no specific test result was specified if (-not $InputObject) { $InputObject = Test-DMGPOwner @parameters -EnableException:$EnableException } #region Process Test results foreach ($testResult in $InputObject) { # Catch invalid input - can only process test results if ($testResult.PSObject.TypeNames -notcontains 'DomainManagement.GPOwner.TestResult') { Stop-PSFFunction -String 'Invoke-DMGPOwner.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException } switch ($testResult.Type) { 'Update' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPOwner.Update.Owner' -ActionStringValues $testResult.Changed.Old, $testResult.Changed.New, $testResult.Identity -ScriptBlock { Set-AdsOwner @parameters -Path $testResult.ADObject -Identity $testResult.Changed.NewObject.ObjectSID -EnableException -Confirm:$false } -Target $testResult.Identity -PSCmdlet $PSCmdlet -EnableException $EnableException } } } #endregion Process Test results } } function Register-DMGPOwner { <# .SYNOPSIS Define the desired state for group policy ownership. .DESCRIPTION Define the desired state for group policy ownership. Afterwards use Test-DMGPOwner to determine, whether reality matches desire. Or Invoke-DMGPOwner to bring reality into the desired state. You can define ownership in three ways: - Explicitly to a specific group policy object - By filter, using the same filter syntax as used for GP Permissions - Global, a default setting for when the other two do not apply In Case multiple rules apply to a GPO, this precedence will be adhered to: Explicit > Filter > Global In case multiple filters apply, the one with the lowest Weight value applies. .PARAMETER GpoName The name of the GPO this rule applies to. This parameter uses name resolution. .PARAMETER Filter The filter by which to determine which GPO this rule applies to. Examples: - "IsManaged -and Tier0" - "-not (IsManaged) -or (Tier1 -and UserScope)" Each condition (e.g. "IsManaged" or "Tier0") needs to be defined as a condition separately. Conditions are documented here: - https://admf.one/documentation/components/domain/gppermissionfilters.html Examples on how to use them can be found in the "Filter" parameter description here: - https://admf.one/documentation/components/domain/gppermissions.html .PARAMETER Weight The precedence order when multiple filter conditions apply. The lower the number, the higher the priority. .PARAMETER All Define a global default rule. There can always only be one global default value. .PARAMETER Identity The identity that should be the owner of the affected GPO(s). Can be a sid or an NT identity reference. This parameter supports name resolution. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Get-Content .\gpoowners.json | ConvertFrom-Json | Write-Output | Register-DMGPOwner Reads all settings from the provided json file and registers them. .NOTES General notes #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [string] $GpoName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [PsfValidateScript('DomainManagement.Validate.GPPermissionFilter', ErrorString = 'DomainManagement.Validate.GPPermissionFilter')] [string] $Filter, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [int] $Weight = 50, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [switch] $All, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [PsfValidateScript('DomainManagement.Validate.Identity', ErrorString = 'DomainManagement.Validate.Identity')] [string] $Identity, [string] $ContextName = '<Undefined>' ) process { switch ($PSCmdlet.ParameterSetName) { 'Explicit' { $permIdentity = 'Explicit|{0}' -f $GpoName $script:groupPolicyOwners[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPOwner' EntryIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName GpoName = $GpoName Identity = $Identity ContextName = $ContextName } } 'Filter' { $permIdentity = 'Filter|{0}' -f $Filter $script:groupPolicyOwners[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPOwner' EntryIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName Filter = $Filter FilterConditions = ConvertTo-FilterName -Filter $Filter Weight = $Weight Identity = $Identity ContextName = $ContextName } } 'All' { $script:groupPolicyOwners['All'] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPOwner' EntryIdentity = 'All' Type = $PSCmdlet.ParameterSetName All = $true Identity = $Identity ContextName = $ContextName } } } } } function Test-DMGPOwner { <# .SYNOPSIS Tests, whether a domain's group policy ownerships are in the desired state. .DESCRIPTION Tests, whether a domain's group policy ownerships are in the desired state. Use Register-DMGPOwner to define the desired state. Use Invoke-DMGPOwner to bring the domain into the desired 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. .EXAMPLE PS C:\> Test-DMGPOwner -Server corp.contoso.com Tests whether the domain of corp.contoso.com has the desired GP Owner configuration. #> [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 GroupPolicyOwners -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process { $ownerConfig = Get-DMGPOwner #region Resolve which condition maps to which GPO $filterMapping = Resolve-GPFilterMapping @parameters -Conditions ($ownerConfig.FilterConditions | Remove-PSFNull -Enumerate | Sort-Object -Unique) if (-not $filterMapping.Success) { switch ($filterMapping.ErrorType) { MissingCondition { Stop-PSFFunction -String Test-DMGPOwner.Filter.Error.MissingCondition -StringValues ($filterMapping.MissingCondition -join ", ") -EnableException $EnableException -Category ObjectNotFound -Tag fail, panic -Target $filterMapping return } PathNotFound { Stop-PSFFunction -String Test-DMGPOwner.Filter.Error.PathNotFound -StringValues $filterMapping.ErrorData -EnableException $EnableException -Category ObjectNotFound -Tag fail, panic -Target $filterMapping return } } } #endregion Resolve which condition maps to which GPO foreach ($gpoADObject in $filterMapping.AllGpos) { $ownerCfg = $null #region Resolve applicable config item $ownerCfg = $ownerConfig | Where-Object { $_.Type -eq 'Explicit' -and $gpoADObject.DisplayName -eq (Resolve-String -Text $_.GpoName) } | Select-Object -First 1 if (-not $ownerCfg) { $ownerCfg = $ownerConfig | Where-Object { $_.Type -eq 'Filter' -and (Test-GPPermissionFilter -GpoName $gpoADObject.DisplayName -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterMapping.Mapping) } | Sort-Object Weight | Select-Object -First 1 } if (-not $ownerCfg) { $ownerCfg = $ownerConfig | Where-Object { $_.Type -eq 'All' } } # If nothing is configured, ignore GPO if (-not $ownerCfg) { continue } #endregion Resolve applicable config item try { $desiredOwner = Resolve-Principal @parameters -Name (Resolve-String -Text $ownerCfg.Identity) -OutputType ADObject } catch { Write-PSFMessage -Level Warning -String 'Test-DMGPOwner.Identity.NotFound' -StringValues $ownerCfg.Identity, $gpoADObject.DisplayName -Target $ownerCfg New-TestResult -ObjectType GPOwner -Type IdentityNotFound -Identity $gpoADObject.DisplayName -Server $Server -Configuration $ownerCfg -ADObject $gpoADObject continue } $actualAcl = Get-AdsAcl @parameters -Path $gpoADObject Add-Member -InputObject $gpoADObject -MemberType NoteProperty -Name Acl -Value $actualAcl -Force $actualOwner = $actualAcl.GetOwner([System.Security.Principal.SecurityIdentifier]) if ("$actualOwner" -eq "$($desiredOwner.ObjectSID)") { continue } try { $actualOwnerAD = Resolve-Principal @parameters -Name $actualOwner -OutputType ADObject } catch { $actualOwnerAD = $null } $change = [PSCustomObject]@{ Type = 'ChangeOwner' New = $desiredOwner.SamAccountName Old = $actualOwnerAD.SamAccountName NewObject = $desiredOwner } if (-not $change.Old) { $change.Old = $actualOwner } Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Value { '{0} -> {1}' -f $this.Old, $this.New } -Force New-TestResult -ObjectType GPOwner -Type Update -Identity $gpoADObject.DisplayName -Changed $change -Server $Server -Configuration $ownerCfg -ADObject $gpoADObject } } } function Unregister-DMGPOwner { <# .SYNOPSIS Removes entries from the desired state for group policy ownership. .DESCRIPTION Removes entries from the desired state for group policy ownership. .PARAMETER EntryIdentity The identity of the entry. .EXAMPLE PS C:\> Get-DMGPOwner | Unregister-DMGPOwner Clears all defines group policy ownerships #> [CmdletBinding()] Param ( [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)] [string[]] $EntryIdentity ) process { foreach ($identityString in $EntryIdentity) { $script:groupPolicyOwners.Remove($identityString) } } } function Get-DMGPPermissionFilter { <# .SYNOPSIS Lists the registered Group Policy permission filters. .DESCRIPTION Lists the registered Group Policy permission filters. .PARAMETER Name The name to filter by. Default: '*' .EXAMPLE PS C:\> Get-DMGPPermissionFilter Lists all registered Group Policy permission filters #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { ($script:groupPolicyPermissionFilters.Values) | Where-Object Name -like $Name } } function Register-DMGPPermissionFilter { <# .SYNOPSIS Registers a GP Permission filter rule. .DESCRIPTION Registers a GP Permission filter rule. These rules are used to apply GP Permissions not on any one specific object but on any number of GPOs that match the defined rule. For example it is possible to define rules that match GPOs by name, that apply to all GPOs defined in configuration or to GPOs linked under a specific OU structure. .PARAMETER Name Name of the filter rule. Must only contain letters, numbers and underscore. .PARAMETER Reverse Reverses the result of the rule. This combined with another condition allows reversing the result. For example, combined with a Path condition, this would make the filter match any GPO NOT linked to that path. .PARAMETER Managed Matches GPOs that are defined by ADMF ($true) or not so ($false). .PARAMETER Path Matches GPOs that have been linked to the specified organizational unit (or potentially OUs beneath it). Subject to name insertion. .PARAMETER PathScope Defines how the path rule is applied: - Base: Only the specified OU's linked GPOs are evaluated (default). - OneLevel: Only the OU's directly beneath the specified OU are evaluated for linked GPOs. - SubTree: All OUs under the specified path are avaluated for linked GPOs. .PARAMETER PathOptional Whether the path is optional. By default, when evaluating a path filter, processing of GP permission terminates if the designated path does not exist, as we cannot guarantee a consistent permission-set being applied. With this setting enabled, instead processing silently continues. (Even if this is enabled, a silent log entry will be added for tracking purposes!) .PARAMETER GPName Name of the GP to filter for. This can be a wildcard or regex match, depending on the -GPNameMode parameter, however by default an exact match is required. Subject to name insertion. .PARAMETER GPNameMode How exactly the GPName parameter is applied: - Explicit: An exact name equality is required (default) - Wildcard: Supports wildcard comparisons (using the -like operator) - Regex: Supports regex matching (using the -match operator) None of the three options is case sensitive. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Get-Content .\gppermissionfilter.json | ConvertFrom-Json | Write-Output | Register-DMGPPermissionFilter Reads all registered filters from the input file and registers them for use in testing Group Policy Permissionss. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidatePattern('^[\w\d_]+$', ErrorString = 'DomainManagement.Validate.PermissionFilterName')] [string] $Name, [Parameter(ValueFromPipelineByPropertyName = $true)] [switch] $Reverse, [Parameter(Mandatory = $true, ParameterSetName = 'Managed', ValueFromPipelineByPropertyName = $true)] [bool] $Managed, [Parameter(Mandatory = $true, ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)] [string] $Path, [Parameter(ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)] [ValidateSet('Base', 'OneLevel', 'SubTree')] [string] $PathScope = 'Base', [Parameter(ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)] [switch] $PathOptional, [Parameter(Mandatory = $true, ParameterSetName = 'GPName', ValueFromPipelineByPropertyName = $true)] [string] $GPName, [Parameter(ParameterSetName = 'GPName', ValueFromPipelineByPropertyName = $true)] [ValidateSet('Explicit', 'Wildcard', 'Regex')] [string] $GPNameMode = 'Explicit', [string] $ContextName = '<Undefined>' ) process { switch ($PSCmdlet.ParameterSetName) { 'Managed' { $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermissionFilter' Type = 'Managed' Name = $Name Reverse = $Reverse Managed = $Managed ContextName = $ContextName } } 'Path' { $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermissionFilter' Type = 'Path' Name = $Name Reverse = $Reverse Path = $Path Optional = $PathOptional Scope = $PathScope ContextName = $ContextName } } 'GPName' { $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermissionFilter' Type = 'GPName' Name = $Name Reverse = $Reverse GPName = $GPName Mode = $GPNameMode ContextName = $ContextName } } } } } function Unregister-DMGPPermissionFilter { <# .SYNOPSIS Removes a GP Permission Filter. .DESCRIPTION Removes a GP Permission Filter. .PARAMETER Name The name of the filter to remove. .EXAMPLE PS C:\> Get-DMGPPermissionFilter | Unregister-DMGPPermissionFilter Removes all registered GP Permission Filter. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($filterName in $Name) { $script:groupPolicyPermissionFilters.Remove($filterName) } } } function Get-DMGPPermission { <# .SYNOPSIS Lists registered GP permission rules. .DESCRIPTION Lists registered GP permission rules. These represent the desired state for how access to Group Policy Objects should be configured. .PARAMETER GpoName The name of the GPO the rule was assigned to. .PARAMETER Identity The name of trustee receiving permissions. .PARAMETER Filter The filter string assigned to the access rule to return. .PARAMETER IsGlobal Only return rules that apply to ALL GPOs globally. .EXAMPLE PS C:\> Get-DMGPPermmission Returns all registered permissions. #> [CmdletBinding()] param ( [string] $GpoName, [string] $Identity, [string] $Filter, [switch] $IsGlobal ) process { $results = foreach ($rule in $script:groupPolicyPermissions.Values) { if ((Test-PSFParameterBinding -ParameterName GpoName) -and ($rule.GpoName -notlike $GpoName)) { continue } if ((Test-PSFParameterBinding -ParameterName Identity) -and ($rule.Identity -notlike $Identity)) { continue } if ((Test-PSFParameterBinding -ParameterName Filter) -and ($rule.Filter -notlike $Filter)) { continue } if ($IsGlobal -and -not $rule.All) { continue } $rule } $results } } function Invoke-DMGPPermission { <# .SYNOPSIS Brings the current Group Policy Permissions into compliance with the desired state defined in configuration. .DESCRIPTION Brings the current Group Policy Permissions into compliance with the desired state defined in configuration. - Use Register-DMGPPermission and Register-DMGPPermissionFilter to define the desired state - Use Test-DMGPPermission to preview the changes it would apply This command accepts the output objects of Test-DMGPPermission as input, allowing you to precisely define, which changes to actually apply. If you do not do so, ALL deviations from the desired state will be corrected. .PARAMETER InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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-DMGPPermission -Server corp.contoso.com Brings the group policy object permissions of the domain corp.contoso.com into compliance with the desired state. #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 GroupPolicyPermissions -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 'Invoke-DMGPPermission.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } #region Utility Functions function ConvertTo-ADAccessRule { [OutputType([System.DirectoryServices.ActiveDirectoryAccessRule])] [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] $ChangeEntry ) begin { $guidEmpty = [System.Guid]::Empty $guidApplyGpoRight = [System.Guid]'edacfd8f-ffb3-11d1-b41d-00a0c968f939' $inheritanceType = 'All' $rightsMap = @{ 'GpoRead' = ([System.DirectoryServices.ActiveDirectoryRights]'GenericRead') 'GpoApply' = ([System.DirectoryServices.ActiveDirectoryRights]'GenericRead') 'GpoEdit' = ([System.DirectoryServices.ActiveDirectoryRights]' CreateChild, DeleteChild, ReadProperty, WriteProperty, GenericExecute') 'GpoEditDeleteModifySecurity' = ([System.DirectoryServices.ActiveDirectoryRights]'CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, GenericRead, WriteDacl, WriteOwner') 'GpoCustom' = ([System.DirectoryServices.ActiveDirectoryRights]'CreateChild, Self, WriteProperty, GenericRead, WriteDacl, WriteOwner') } <# System.Security.Principal.IdentityReference identity System.DirectoryServices.ActiveDirectoryRights adRights System.Security.AccessControl.AccessControlType type guid objectType System.DirectoryServices.ActiveDirectorySecurityInheritance inheritanceType guid inheritedObjectType #> } process { foreach ($change in $ChangeEntry) { # Identity property might be 'deserialized' $identityReference = $change.Identity -as [string] -as [System.Security.Principal.SecurityIdentifier] [System.Security.AccessControl.AccessControlType]$type = 'Allow' if (-not $change.Allow) { $type = 'Deny' } [System.DirectoryServices.ActiveDirectoryAccessRule]::new( $identityReference, $rightsMap[$change.Permission], $type, $guidEmpty, $inheritanceType, $guidEmpty ) if ($change.Permission -eq 'GpoApply') { [System.DirectoryServices.ActiveDirectoryAccessRule]::new( $identityReference, ([System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight), $type, $guidApplyGpoRight, $inheritanceType, $guidEmpty ) } } } } #endregion Utility Functions } process { try { # Test All GPO permissions if no specific test result was specified if (-not $InputObject) { $InputObject = Test-DMGPPermission @parameters -EnableException:$EnableException } #region Process Test results foreach ($testResult in $InputObject) { # Catch invalid input - can only process test results if ($testResult.PSObject.TypeNames -notcontains 'DomainManagement.GPPermission.TestResult') { Stop-PSFFunction -String 'Invoke-DMGPPermission.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException } if ($testResult.Type -eq 'AccessError') { Write-PSFMessage -Level Warning -String 'Invoke-DMGPPermission.Result.Access.Error' -StringValues $testResult.Identity -Target $testResult continue } try { $acl = Get-AdsAcl -Path $testResult.AdObject.DistinguishedName @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-DMGPPermission.AD.Access.Error' -StringValues $testResult, $testResult.ADObject.DistinguishedName -ErrorRecord $_ -Continue -EnableException $EnableException } [string[]]$applicableIdentities = $acl.Access.Identity | Remove-PSFNull | Resolve-String | Convert-Principal @parameters # Process Remove actions first, as they might interfere when processed last and replacing permissions. foreach ($change in ($testResult.Changed | Sort-Object Action -Descending)) { #region Remove if ($change.Action -eq 'Remove') { if (($change.Permission -eq 'GpoCustom') -or ($applicableIdentities -notcontains $change.Identity)) { $rulesToRemove = $acl.Access | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).ToString() -eq $change.Identity } } else { $accessRulesToRemove = ConvertTo-ADAccessRule -ChangeEntry $change $rulesToRemove = $acl.Access | Compare-ObjectProperty -ReferenceObject $accessRulesToRemove -PropertyName ActiveDirectoryRights, AccessControlType, 'IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) to String as IdentityReference' } foreach ($rule in $rulesToRemove) { $null = $acl.RemoveAccessRule($rule) } } #endregion Remove #region Add else { if ($change.Permission -eq 'GpoCustom') { $acl.Access | Where-Object { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).ToString() -eq $change.Identity } | ForEach-Object { $null = $acl.RemoveAccessRule($_) } } try { $accessRulesToAdd = ConvertTo-ADAccessRule -ChangeEntry $change } catch { Stop-PSFFunction -String 'Invoke-DMGPPermission.AccessRule.Error' -StringValues $change.Identity, $change.DisplayName -ErrorRecord $_ -Continue -EnableException $EnableException -Cmdlet $PSCmdlet } foreach ($rule in $accessRulesToAdd) { $null = $acl.AddAccessRule($rule) } } #endregion Add } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPPermission.AD.UpdatingPermission' -ActionStringValues $testResult.Changed.Count -ScriptBlock { $acl | Set-AdsAcl @parameters -Confirm:$false -EnableException } -Continue -EnableException $EnableException -PSCmdlet $PSCmdlet -Target $testResult Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPPermission.Gpo.SyncingPermission' -ActionStringValues $testResult.Changed.Count -ScriptBlock { $domainObject = Get-Domain2 @parameters Invoke-Command -Session $session -ScriptBlock { $gpoObject = Get-Gpo -Server localhost -DisplayName $using:testResult.Identity -Domain $using:domainObject.DNSRoot -ErrorAction Stop $gpoObject.MakeAclConsistent() } -ErrorAction Stop } -Continue -EnableException $EnableException -PSCmdlet $PSCmdlet -Target $testResult } #endregion Process Test results } finally { if ($session) { $session | Remove-PSSession -WhatIf:$false -Confirm:$false } } } } function Register-DMGPPermission { <# .SYNOPSIS Registers a GP permission as the desired state. .DESCRIPTION Registers a GP permission as the desired state. Permissions can be applied in three ways: - Explicitly to a specific GPO - To ALL GPOs - To GPOs that match a specific filter string. For defining filter conditions, see the help on Register-DMGPPermissionFilter. Another important concept is the "Managed" concept. By default, all GPOs are considered unmanaged, where GP Permissions are concerned. This means, any additional permissionss that have been applied are ok. By setting a GPO's permissions under management - by applying a permission rule that uses the -Managed parameter - any permissions not defined for it will be removed. .PARAMETER GpoName Name of the GPO this permission applies to. Subject to string insertion. .PARAMETER Filter The filter condition governing, what GPOs these permissions apply to. A filter string can consist of the following elements: - Names of filter conditions - Logical operators - Parenthesis Example filter strings: - 'IsManaged' - 'IsManaged -and -not (IsDomainDefault -or IsDomainControllerDefault)' - '-not (IsManaged) -and (IsTier1 -or IsSupport)' .PARAMETER All This access rule applies to ALL GPOs. .PARAMETER Identity The group or user to assign permissions to. Subject to string insertion. .PARAMETER ObjectClass What kind of object the assigned identity is. Can be any legal object class in AD. Only object classes that have a SID should be chosen though (otherwise, assigning permissions to it gets kind of difficult). .PARAMETER Permission What kind of permission to grant. .PARAMETER Deny Whether to create a Deny rule, rather than an Allow rule. .PARAMETER NoPermissionChange Disable application of a set of permissions. Setting this flag allows defining a rule that only applies the "Managed" state (see below). .PARAMETER Managed Whether the affected GPOs should be considered "Under Management". A GPO "Under Management" will have all non-defined permissions removed. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Get-Content .\gpopermissions.json | ConvertFrom-Json | Write-Output | Register-DMGPPermission Reads all settings from the provided json file and registers them. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ExplicitNoChange')] [string] $GpoName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FilterNoChange')] [PsfValidateScript('DomainManagement.Validate.GPPermissionFilter', ErrorString = 'DomainManagement.Validate.GPPermissionFilter')] [string] $Filter, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'AllNoChange')] [switch] $All, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [PsfValidateScript('DomainManagement.Validate.Identity', ErrorString = 'DomainManagement.Validate.Identity')] [string] $Identity, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [string] $ObjectClass, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [ValidateSet('GpoApply', 'GpoRead', 'GpoEdit', 'GpoEditDeleteModifySecurity', 'GpoCustom')] [string] $Permission, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')] [switch] $Deny, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ExplicitNoChange')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FilterNoChange')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'AllNoChange')] [switch] $NoPermissionChange, [Parameter(ValueFromPipelineByPropertyName = $true)] [switch] $Managed, [string] $ContextName = '<Undefined>' ) begin { $allowHash = @{ $false = "Allow" $true = "Deny" } } process { switch ($PSCmdlet.ParameterSetName) { 'Explicit' { $permIdentity = 'Explicit|{0}|{1}|{2}|{3}' -f $GpoName, $Identity, $Permission, $allowHash[$Deny.ToBool()] $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName GpoName = $GpoName Identity = $Identity ObjectClass = $ObjectClass Permission = $Permission Deny = $Deny.ToBool() Managed = $Managed.ToBool() ContextName = $ContextName } } 'Filter' { $permIdentity = 'Filter|{0}|{1}|{2}|{3}' -f $Filter, $Identity, $Permission, $allowHash[$Deny.ToBool()] $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName Filter = $Filter FilterConditions = (ConvertTo-FilterName -Filter $Filter) Identity = $Identity ObjectClass = $ObjectClass Permission = $Permission Deny = $Deny.ToBool() Managed = $Managed.ToBool() ContextName = $ContextName } } 'All' { $permIdentity = 'All|{0}|{1}|{2}' -f $Identity, $Permission, $allowHash[$Deny.ToBool()] $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName All = $true Identity = $Identity ObjectClass = $ObjectClass Permission = $Permission Deny = $Deny.ToBool() Managed = $Managed.ToBool() ContextName = $ContextName } } 'ExplicitNoChange' { $permIdentity = 'NoChange|Explicit|{0}' -f $GpoName $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName GpoName = $GpoName Managed = $Managed.ToBool() ContextName = $ContextName } } 'FilterNoChange' { $permIdentity = 'NoChange|Filter|{0}' -f $Filter $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = $permIdentity Type = $PSCmdlet.ParameterSetName Filter = $Filter FilterConditions = (ConvertTo-FilterName -Filter $Filter) Managed = $Managed.ToBool() ContextName = $ContextName } } 'AllNoChange' { $script:groupPolicyPermissions['NoChange|All'] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.GPPermission' PermissionIdentity = 'NoChange|All' Type = $PSCmdlet.ParameterSetName All = $true Managed = $Managed.ToBool() ContextName = $ContextName } } } } } function Test-DMGPPermission { <# .SYNOPSIS Tests whether the existing Group Policy permissions reflect the desired state. .DESCRIPTION Tests whether the existing Group Policy permissions reflect the desired state. Use Register-DMGPPermission and Register-DMGPPermissionFilter to define the desired 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. .EXAMPLE PS C:\> Test-DMGPPermission -Server corp.contoso.com Tests whether the domain of corp.contoso.com has the desired GP Permission configuration. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [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 GroupPolicyPermissions -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-DMGPPermission.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } #region Utility Functions function Compare-GPAccessRules { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] [CmdletBinding()] param ( [AllowNull()] [AllowEmptyCollection()] $ADRules, [AllowNull()] [AllowEmptyCollection()] $ConfiguredRules, [bool] $Managed ) if (-not $Managed -and -not $ConfiguredRules) { return } $configuredRules | Where-Object { $_ -and -not ($_ | Compare-ObjectProperty -ReferenceObject $ADRules -PropertyName 'Identity to String', Permission, Allow) } | ForEach-Object { $_.Action = 'Add' $_ } if (-not $Managed) { return } $ADRules | Where-Object { $_ -and -not ($_ | Compare-ObjectProperty -ReferenceObject $ConfiguredRules -PropertyName 'Identity to String', Permission, Allow) } | ForEach-Object { $_.Action = 'Remove' $_ } } function Convert-GPAccessRuleIdentity { [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] $InputObject, $ADObject, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false } process { foreach ($inputItem in $InputObject) { #region Case: Input from AD if ($inputItem.PSObject.TypeNames -like "*Microsoft.GroupPolicy.GPPermission") { [PSCustomObject]@{ PSTypeName = 'DomainManagement.Result.GPPermission.Action' Identity = $inputItem.Trustee.Sid DisplayName = '{0}\{1}' -f $inputItem.Trustee.Domain, $inputItem.Trustee.Name Permission = $inputItem.Permission -as [string] Allow = -not $inputItem.Denied Action = $null ADObject = $ADObject } } #endregion Case: Input from AD #region Case: Input from Configuration else { # Convert to SecurityIdentifier (preferred) or NT Account $identity = Resolve-Identity -IdentityReference $inputItem.Identity if ($identity -is [System.Security.Principal.NTAccount]) { $domainName, $identityName = $identity -replace '^(.+)@(.+)$','$2\$1' -split "\\" try { $principal = Get-Principal @parameters -Name $identityName -Domain $domainName -ObjectClass $inputItem.ObjectClass } catch { throw } $identity = $principal.ObjectSID } [PSCustomObject]@{ PSTypeName = 'DomainManagement.Result.GPPermission.Action' Identity = $identity DisplayName = $inputItem.Identity Permission = $inputItem.Permission Allow = -not $inputItem.Deny Action = $null ADObject = $ADObject } } #endregion Case: Input from Configuration } } } function Resolve-Identity { [CmdletBinding()] param ( [string] $IdentityReference ) #region Default Resolution $identity = Resolve-String -Text $IdentityReference if ($identity -as [System.Security.Principal.SecurityIdentifier]) { $identity = $identity -as [System.Security.Principal.SecurityIdentifier] } else { $identity = $identity -as [System.Security.Principal.NTAccount] try { $identity = $identity.Translate([System.Security.Principal.SecurityIdentifier]) } catch { $null = $null } # Do nothing intentionally, but shut up PSSA anyway } if ($null -eq $identity) { $identity = (Resolve-String -Text $IdentityReference) -as [System.Security.Principal.NTAccount] } $identity #endregion Default Resolution } #endregion Utility Functions } process { if (Test-PSFFunctionInterrupt) { return } try { #region Data Preparation $allFilters = @{ } foreach ($filterObject in (Get-DMGPPermissionFilter)) { $allFilters[$filterObject.Name] = $filterObject } $allPermissions = Get-DMGPPermission $allConditions = $allPermissions | Where-Object FilterConditions | Select-Object -ExpandProperty FilterConditions | Write-Output | Select-Object -Unique $missingConditions = $allConditions | Where-Object { $_ -notin $allFilters.Keys } if ($missingConditions) { Stop-PSFFunction -String 'Test-DMGPPermission.Validate.MissingFilterConditions' -StringValues ($missingConditions -join ", ") -EnableException $EnableException -Cmdlet $PSCmdlet -Tag Error, Panic return } if ($allConditions) { $relevantFilters = $allFilters | ConvertTo-PSFHashtable -Include $allConditions } else { $relevantFilters = @() } $allGpos = Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName #region Process relevant filters $filterToGPOMapping = @{ } $managedGPONames = (Get-DMGroupPolicy).DisplayName | Resolve-String :conditions foreach ($condition in $relevantFilters.Values) { switch ($condition.Type) { #region Managed - Do we define the policy using the GroupPolicy Component? 'Managed' { if ($condition.Reverse -xor (-not $condition.Managed)) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notin $managedGPONames } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -in $managedGPONames } } #endregion Managed - Do we define the policy using the GroupPolicy Component? #region Path - Resolve by where GPOs are linked 'Path' { $searchBase = Resolve-String -Text $condition.Path if (-not (Test-ADObject @parameters -Identity $searchBase)) { if ($condition.Optional) { Write-PSFMessage -String 'Test-DMGPPermission.Filter.Path.DoesNotExist.SilentlyContinue' -StringValues $Condition.Name, $searchBase -Target $condition continue conditions } Stop-PSFFunction -String 'Test-DMGPPermission.Filter.Path.DoesNotExist.Stop' -StringValues $searchBase -Target $Condition.Name, $condition -EnableException $EnableException -Tag Panic, Error return } $objects = Get-ADObject @parameters -SearchBase $searchBase -SearchScope $condition.Scope -LDAPFilter '(|(objectCategory=OrganizationalUnit)(objectCategory=domainDNS))' -Properties gPLink $allLinkedGpoDNs = $objects | ConvertTo-GPLink | Select-Object -ExpandProperty DistinguishedName -Unique if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -notin $allLinkedGpoDNs } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -in $allLinkedGpoDNs } } #endregion Path - Resolve by where GPOs are linked #region GPName - Match by name, using either direct comparison, wildcard or regex 'GPName' { $resolvedGpoName = Resolve-String -Text $condition.GPName switch ($condition.Mode) { 'Explicit' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -ne $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -eq $resolvedGpoName } } 'Wildcard' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notlike $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -like $resolvedGpoName } } 'Regex' { if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notmatch $resolvedGpoName } else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -match $resolvedGpoName } } } } #endregion GPName - Match by name, using either direct comparison, wildcard or regex } } foreach ($key in $filterToGPOMapping.Keys) { Write-PSFMessage -Level Debug -String 'Test-DMGPPermission.Filter.Result' -StringValues $key, ($filterToGPOMapping[$key].DisplayName -join ', ') } #endregion Process relevant filters #endregion Data Preparation #region Process GPO Permissions $domainObject = Get-Domain2 @parameters $permissionObjects = Invoke-Command -Session $session -ScriptBlock { Update-TypeData -TypeName Microsoft.GroupPolicy.GPPermission -SerializationDepth 4 foreach ($policyObject in $using:allGpos) { $resultObject = [PSCustomObject]@{ Name = $policyObject.DisplayName Permissions = @() Error = $null } try { $resultObject.Permissions = Get-GPPermission -All -Name $resultObject.Name -Server localhost -Domain $using:domainObject.DNSRoot -ErrorAction Stop } catch { $resultObject.Error = $_ } $resultObject } } $resultDefaults = @{ Server = $Server ObjectType = 'GPPermission' } foreach ($permissionObject in $permissionObjects) { $applicableSettings = $allPermissions | Where-Object { $_.All -or (Resolve-String -Text $_.GpoName) -eq $permissionObject.Name -or ($_.Filter -and (Test-GPPermissionFilter -GpoName $permissionObject.Name -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterToGPOMapping)) } $adObject = $allGpos | Where-Object DisplayName -eq $permissionObject.Name Add-Member -InputObject $permissionObject -MemberType ScriptMethod -Name ToString -Value { $this.Name } -Force if ($permissionObject.Error) { New-TestResult @resultDefaults -Type AccessError -Identity $permissionObject -Configuration $applicableSettings -ADObject $adObject -Changed $permissionObject continue } $shouldManage = $applicableSettings.Managed -contains $true try { $compareParameter = @{ ADRules = ($permissionObject.Permissions | Convert-GPAccessRuleIdentity @parameters -ADObject $adObject) ConfiguredRules = ($applicableSettings | Where-Object Identity | Convert-GPAccessRuleIdentity @parameters -ADObject $adObject) Managed = $shouldManage } } catch { Stop-PSFFunction -String 'Test-DMGPPermission.Identity.Resolution.Error' -StringValues $adObject.DisplayName -Target $permissionObject -Continue -EnableException $EnableException -Tag Panic, Error } $delta = Compare-GPAccessRules @compareParameter if ($delta) { New-TestResult @resultDefaults -Type Update -Identity $permissionObject -Changed $delta -Configuration $applicableSettings -ADObject $adObject continue } } #endregion Process GPO Permissions } finally { if ($session) { $session | Remove-PSSession -WhatIf:$false -Confirm:$false} } } } function Unregister-DMGPPermission { <# .SYNOPSIS Removes a registered GP Permission. .DESCRIPTION Removes a registered GP Permission. .PARAMETER PermissionIdentity The identity string of a GP permission. This is NOT the user/group assigned permission (Identity property) but instead the unique identifier of the permission setting (PermissionIdentity property). .EXAMPLE PS C:\> Get-DMGPPermission | Unregister-DMGPPermission Clear all defined configuration. #> [CmdletBinding()] param ( [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)] [string[]] $PermissionIdentity ) process { foreach ($identityString in $PermissionIdentity) { $script:groupPolicyPermissions.Remove($identityString) } } } function Get-DMGPRegistrySetting { <# .SYNOPSIS Returns the registered group policy registry settings. .DESCRIPTION Returns the registered group policy registry settings. .PARAMETER PolicyName The name of the policy to filter by. .PARAMETER Key Filter by the key affected. .PARAMETER ValueName Filter by the name of the value set. .EXAMPLE PS C:\> Get-DMGPRegistrySetting Returns all registered group policy registry settings. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string] $PolicyName = '*', [string] $Key = '*', [string] $ValueName = '*' ) process { $script:groupPolicyRegistrySettings.Values | Where-Object { $_.PolicyName -like $PolicyName -and $_.Key -like $Key -and $_.ValueName -like $ValueName } } } function Register-DMGPRegistrySetting { <# .SYNOPSIS Register a registry setting that should be applied to a group policy object. .DESCRIPTION Register a registry setting that should be applied to a group policy object. Note: These settings are only applied to group policy objects deployed through the GroupPolicy Component .PARAMETER PolicyName Name of the group policy object to attach this setting to. Subject to advanced string insertion. .PARAMETER Key The registry key affected. Subject to advanced string insertion. .PARAMETER ValueName The name of the value to modify. Subject to advanced string insertion. .PARAMETER Value The value to insert into the specified registry-key-value. .PARAMETER DomainData Instead of offering an explicit value, have the resulting value calculated by a scriptblock executed against the target domain. In opposite to ADMF Contexts, DomainData data gathering scriptblocks are executed on a per-domain basis. While a Context supports integrating logic, Contexts themselves are not re-run when switching to another domain with the same Context choice. DomainData gathering logic can be configured using Register-DMDomainData or defining appropriate configuration in ADMF Contexts. .PARAMETER Type What kind of registry value should be defined? Supported types: 'Binary', 'DWord', 'ExpandString', 'MultiString', 'QWord', 'String' .EXAMPLE PS C:\> Get-Content .\registrysettings.json | ConvertFrom-Json | Write-Output | Register-DMGPRegistrySetting Imports all the registry value definitions configured in the specified file. #> [CmdletBinding(DefaultParameterSetName = 'Value')] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $PolicyName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Key, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ValueName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Value')] $Value, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'DomainData')] [string] $DomainData, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ValidateSet('Binary', 'DWord', 'ExpandString', 'MultiString', 'QWord', 'String')] [string] $Type ) process { $identity = $PolicyName, $Key, $ValueName -join "þ" $data = @{ PSTypeName = 'DomainManagement.Configuration.GPRegistrySetting' Identity = $identity PolicyName = $PolicyName Key = $Key ValueName = $ValueName Type = $Type } switch ($PSCmdlet.ParameterSetName) { 'Value' { $data['Value'] = $Value } 'DomainData' { $data['DomainData'] = $DomainData } } $script:groupPolicyRegistrySettings[$identity] = [PSCustomObject]$data } } function Test-DMGPRegistrySetting { <# .SYNOPSIS Validates, whether a GPO's defined registry settings have been applied. .DESCRIPTION Validates, whether a GPO's defined registry settings have been applied. To define a GPO, use Register-DMGroupPolicy To define a GPO's associated registry settings, use Register-DMGPRegistrySetting Note: While it is theoretically possible to define a GPO registry setting without defining the GPO it is attached to, these settings will not be applied anyway, as processing is directly tied into the Group Policy invocation process. .PARAMETER PolicyName Name of the GPO to scan for compliance. Subject to advanced string insertion. .PARAMETER PassThru Returns result objects, rather than boolean values. Useful for better reporting and integration into the test-* workflow. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-DMGPRegistrySetting @parameters -PolicyName $policy Tests, whether the specified GPO has all the desired registry keys configured. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $PolicyName, [switch] $PassThru, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false #region Utility Functions function Write-Result { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')] [CmdletBinding()] param ( [bool] $Success, [string] $Status, [AllowEmptyCollection()] [object] $Changes, [bool] $PassThru ) if (-not $PassThru) { return $Success } [PSCustomObject]@{ Success = $Success Status = $Status Changes = $Changes } } #endregion Utility Functions #region WinRM Session Handling $reUseSession = $false if ($Server.Type -eq 'PSSession') { $session = $Server.InputObject $reUseSession = $true } elseif (($Server.Type -eq 'Container') -and ($Server.InputObject.Connections.PSSession)) { $session = $Server.InputObject.Connections.PSSession $reUseSession = $true } else { $pdcParameter = $parameters.Clone() $pdcParameter.ComputerName = (Get-Domain2 @parameters).PDCEmulator $pdcParameter.Remove('Server') try { $session = New-PSSession @pdcParameter -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-DMGPRegistrySetting.WinRM.Failed' -StringValues $parameters.Server -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $parameters.Server return } } #endregion WinRM Session Handling } process { if (Test-PSFFunctionInterrupt) { return } #region Processing the Configuration $resolvedName = $PolicyName | Resolve-String @parameters $applicableRegistrySettings = Get-DMGPRegistrySetting | Where-Object { $resolvedName -eq ($_.PolicyName | Resolve-String @parameters) } if (-not $applicableRegistrySettings) { Write-Result -Success $true -Status 'No Registry Settings Defined' -PassThru $PassThru return } $registryData = foreach ($applicableRegistrySetting in $applicableRegistrySettings) { if ($applicableRegistrySetting.PSObject.Properties.Name -contains 'Value') { [PSCustomObject]@{ GPO = $resolvedName Key = Resolve-String @parameters -Text $applicableRegistrySetting.Key ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName Type = $applicableRegistrySetting.Type Value = $applicableRegistrySetting.Value } } else { [PSCustomObject]@{ GPO = $resolvedName Key = Resolve-String @parameters -Text $applicableRegistrySetting.Key ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName Type = $applicableRegistrySetting.Type Value = ((Invoke-DMDomainData @parameters -Name $applicableRegistrySetting.DomainData).Data | Write-Output) } } } #endregion Processing the Configuration #region Executing the Query $regArgument = @{ GPO = $resolvedName RegistryData = $registryData } $result = Invoke-Command -Session $session -ArgumentList $regArgument -ScriptBlock { param ( $RegData ) $result = [PSCustomObject]@{ PolicyName = $RegData.GPO Success = $false Status = 'NotStarted' Changes = @() } try { if (-not ($gpo = Get-GPO -Server Localhost -Domain (Get-ADDomain -Server localhost).DNSRoot -Name $RegData.GPO -ErrorAction Stop)) { $result.Status = "PolicyNotFound" return $result } } catch { $result.Status = "Error: $_" return $result } $domain = Get-ADDomain -Server localhost $changes = foreach ($registryDatum in $RegData.RegistryData) { $data = $null $data = $gpo | Get-GPRegistryValue -Server localhost -Domain $domain.DNSRoot -Key $registryDatum.Key -ValueName $registryDatum.ValueName -ErrorAction Ignore if (-not $data) { [PSCustomObject]@{ PSTypeName = 'DomainManagement.Change.GPRegistry' PolicyName = $RegData.GPO Key = $registryDatum.Key ValueName = $registryDatum.ValueName ShouldValue = $registryDatum.Value IsValue = $null } continue } if ($data.Value -ne $registryDatum.Value) { [PSCustomObject]@{ PSTypeName = 'DomainManagement.Change.GPRegistry' PolicyName = $RegData.GPO Key = $registryDatum.Key ValueName = $registryDatum.ValueName ShouldValue = $registryDatum.Value IsValue = $data.Value } } } if ($changes) { foreach ($change in $changes) { $change.PSObject.TypeNames.Clear() $change.PSObject.TypeNames.Add("DomainManagement.Change.GPRegistry") $change.PSObject.TypeNames.Add("System.Management.Automation.PSCustomObject") $change.PSObject.TypeNames.Add("System.Object") } $result.Changes = $changes $result.Status = 'BadSettings' } else { $result.Success = $true } return $result } $level = 'Verbose' if ($result.Status -like 'Error:*') { $level = 'Warning' } Write-PSFMessage -Level $level -String 'Test-DMGPRegistrySetting.TestResult' -StringValues $resolvedName, $result.Success, $result.Status -Target $PolicyName #endregion Executing the Query # Result Write-Result -Success $result.Success -Status $result.Status -Changes $result.Changes -PassThru $PassThru } end { if (Test-PSFFunctionInterrupt) { return } if (-not $reUseSession) { $session | Remove-PSSession -Confirm:$false -WhatIf:$false } } } function Unregister-DMGPRegistrySetting { <# .SYNOPSIS Removes defined group policy registry settings. .DESCRIPTION Removes defined group policy registry settings. .PARAMETER PolicyName The name of the GPO the registry setting has been applied to. .PARAMETER Key The registry key affected. .PARAMETER ValueName The name of the value this applies to. .EXAMPLE PS C:\> Get-DMGPRegistrySetting | Unregister-DMGPRegistrySetting Clears all defined group policy registry settings. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $PolicyName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Key, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ValueName ) process { $identity = $PolicyName, $Key, $ValueName -join "þ" $script:groupPolicyRegistrySettings.Remove($identity) } } 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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 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>") try { $group.CommitChanges() } catch { if (-not $Credential) { throw } $group.Password = $Credential.GetNetworkCredential().Password $group.CommitChanges() } finally { $group.Close() } } function Remove-GroupMember { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [string] $GroupDN, [string] $SID, [string] $TargetDN, [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) } $group.member.Remove("<SID=$SID>") $group.member.Remove($TargetDN) try { $group.CommitChanges() } catch { $group.Close() if ($Credential) { $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password) } else { $group = New-Object DirectoryServices.DirectoryEntry($path) } $group.member.Remove($TargetDN) $group.CommitChanges() } finally { $group.Close() } } #endregion Utility Functions } process { if (-not $InputObject) { $InputObject = Test-DMGroupMembership @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GroupMembership.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroupMembership', $testItem -Target $testItem -Continue -EnableException $EnableException } 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 -TargetDN $testItem.Configuration.ADObject.DistinguishedName -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. This parameter also accepts SIDs instead of names. Note: %DomainSID% is the placeholder for the domain SID, %RootDomainSID% the one for the forest root domain. .PARAMETER Domain Domain the entity is from, that is being granted group membership. .PARAMETER ItemType The type of object being granted membership. .PARAMETER ObjectCategory Rather than specifying an explicit entity, assign all principals in a given Object Cagtegory as group member. Note: In order to be applicable, each category member must be a security principal (that has the ObjectSID property). .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. .PARAMETER Mode How the defined group membership will be processed: - Default: Member must exist and be member of the group. - MayBeMember: Principal must exist but may be a member. No add action will be generated if not a member, but also no remove action if it already is a member. - MemberIfExists: If Principal exists, make it a member. - MayBeMemberIfExists: Both existence and membership are optional for this principal. .PARAMETER GroupProcessingMode Governs how ALL group memberships on the targeted group will be processed. Supported modes: - Constrained: Existing Group Memberships not defined will be removed - Additive: Group Memberships defined will be applied, but non-configured memberships will be ignored. If no setting is defined, it will default to 'Constrained' .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .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', 'Computer', 'msDS-GroupManagedServiceAccount')] [string] $ItemType, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')] [string] $ObjectCategory, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')] [string] $Group, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')] [bool] $Empty, [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('Default', 'MayBeMember', 'MemberIfExists', 'MayBeMemberIfExists')] [string] $Mode = 'Default', [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('Constrained', 'Additive')] [string] $GroupProcessingMode, [string] $ContextName = '<Undefined>' ) 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 Mode = $Mode ContextName = $ContextName } } elseif ($ObjectCategory) { $script:groupMemberShips[$Group]["ObjectCategory:$($ObjectCategory)"] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.GroupMembership' Category = $ObjectCategory Group = $Group Mode = $Mode ContextName = $ContextName } } elseif ($Empty) { $script:groupMemberShips[$Group] = @{ } } if ($GroupProcessingMode) { $script:groupMemberShips[$Group]['__Configuration'] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.GroupMembership.Configuration' ProcessingMode = $GroupProcessingMode } } } } 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 ($groupMembershipName in $script:groupMemberShips.Keys) { $resolvedGroupName = Resolve-String -Text $groupMembershipName $processingMode = 'Constrained' if ($script:groupMemberShips[$groupMembershipName].__Configuration.ProcessingMode) { $processingMode = $script:groupMemberShips[$groupMembershipName].__Configuration.ProcessingMode } $resultDefaults = @{ Server = $Server ObjectType = 'GroupMembership' } #region Resolve Assignments $failedResolveAssignment = $false $assignments = foreach ($assignment in $script:groupMemberShips[$groupMembershipName].Values) { if ($assignment.PSObject.TypeNames -contains 'DomainManagement.GroupMembership.Configuration') { continue } #region Explicit Entity if ($assignment.Name) { $param = @{ Domain = Resolve-String -Text $assignment.Domain } + $parameters if ((Resolve-String -Text $assignment.Name) -as [System.Security.Principal.SecurityIdentifier]) { $param['Sid'] = Resolve-String -Text $assignment.Name } else { $param['Name'] = Resolve-String -Text $assignment.Name $param['ObjectClass'] = $assignment.ItemType } try { $adResult = Get-Principal @param } catch { # If it's a member that is allowed to NOT exist, simply skip the entry if ($assignment.Mode -in 'MemberIfExists', 'MayBeMemberIfExists') { continue } 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 Type = 'Explicit' } continue } if (-not $adResult) { # If it's a member that is allowed to NOT exist, simply skip the entry if ($assignment.Mode -in 'MemberIfExists', 'MayBeMemberIfExists') { continue } 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 Type = 'Explicit' } continue } [PSCustomObject]@{ Assignment = $assignment ADObject = $adResult Type = 'Explicit' } } #endregion Explicit Entity #region Object Category elseif ($assignment.Category) { try { $adObjects = Find-DMObjectCategoryItem @parameters -Name $assignment.Category -Property ObjectSID, SamAccountName -EnableException } catch { Stop-PSFFunction -String 'Test-DMGroupMembership.Category.Error' -StringValues $assignment.Category, $assignment.Group -ErrorRecord $_ -Continue -ContinueLabel main -EnableException $EnableException -Target $assignment } foreach ($adObject in $adObjects) { [PSCustomObject]@{ Assignment = $assignment ADObject = $adObject Type = 'Category' } } } #endregion Object Category } #endregion Resolve Assignments #region Check Current AD State 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 } #endregion Check Current AD State #region Compare Assignments to existing state 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 } # Skip if membership is optional if ($assignment.Assignment.Mode -in 'MayBeMember', 'MayBeMemberIfExists') { continue } if ($adMembers | Where-Object ObjectSID -EQ $assignment.ADObject.objectSID) { continue } New-TestResult @resultDefaults -Type Add -Identity "$(Resolve-String -Text $assignment.Assignment.Group)þ$($assignment.ADObject.ObjectClass)þ$(Resolve-String -Text $assignment.ADObject.SamAccountName)" -Configuration $assignment -ADObject $adObject } #endregion Compare Assignments to existing state if ($processingMode -eq 'Additive') { continue } #region Compare existing state to assignments foreach ($adMember in $adMembers) { if ("$($adMember.ObjectSID)" -in ($assignments.ADObject.ObjectSID | ForEach-Object { "$_" })) { 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 } } #endregion Compare existing state to assignments } } } 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', 'foreignSecurityPrincipal', 'Computer', '<Empty>')] [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].Count) { $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. Use the ADMF module's Export-AdmfGpo command to generate the gpo definition from an existing deployment. .PARAMETER InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 } Set-DMDomainContext @parameters try { $gpoRemotePath = New-GpoWorkingDirectory -Session $session -ErrorAction Stop } catch { Remove-PSSession -Session $session -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue Stop-PSFFunction -String 'Invoke-DMGroupPolicy.Remote.WorkingDirectory.Failed' -StringValues $computerName -Target $computerName -ErrorRecord $_ -EnableException $EnableException return } } process { if (Test-PSFFunctionInterrupt) { return } if (-not $InputObject) { $InputObject = Test-DMGroupPolicy @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GroupPolicy.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroupPolicy', $testItem -Target $testItem -Continue -EnableException $EnableException } switch ($testItem.Type) { 'Delete' { if (-not $Delete) { continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Delete' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Remove-GroupPolicy -Session $session -ADObject $testItem.ADObject -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'ConfigError' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnConfigError' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -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' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'Modified' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnModify' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'Manage' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnManage' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'BadRegistryData' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnBadRegistry' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnNew' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock { Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop } -EnableException $EnableException -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 } } if ($session) { Remove-PSSession -Session $session -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue } } } 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. .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 # DomainData retrieval $domainDataNames = ((Get-DMGroupPolicy).DisplayName | Get-DMGPRegistrySetting | Where-Object DomainData).DomainData | Select-Object -Unique try { $null = $domainDataNames | Invoke-DMDomainData @parameters -EnableException } catch { Stop-PSFFunction -String 'Test-DMGroupPolicy.DomainData.Failed' -StringValues ($domainDataNames -join ",") -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } # PS Remoting $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 } } 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.Version -ne $policyObject.ADVersion) { New-TestResult @resultDefaults -Type 'Modified' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $policyObject continue } $registryTest = Test-DMGPRegistrySetting -Server $session -PolicyName $desiredPolicy.DisplayName -PassThru if (-not $registryTest.Success) { New-TestResult @resultDefaults -Type 'BadRegistryData' -Identity $desiredPolicy.DisplayName -Configuration $desiredPolicy -ADObject $policyObject -Changed $registryTest.Changes 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 -Confirm:$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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 Set-DMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-DMGroup @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Group.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroup', $testItem -Target $testItem -Continue -EnableException $EnableException } 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 -PSCmdlet $PSCmdlet -Continue } 'Create' { $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 Confirm = $false } New-ADGroup @newParameters } -EnableException $EnableException -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 { Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -SamAccountName (Resolve-String -Text $testItem.Configuration.SamAccountName) -ErrorAction Stop -Confirm:$false if ((Resolve-String -Text $testItem.Configuration.Name) -cne $testItem.ADObject.Name) { Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop -Confirm:$false } } -EnableException $EnableException -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 -Confirm:$false } -EnableException $EnableException -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 ($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 -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } if ($testItem.Changed -contains 'Scope') { $targetScope = Resolve-String -Text $testItem.Configuration.Scope if ($targetScope -notin ([Enum]::GetNames([Microsoft.ActiveDirectory.Management.ADGroupScope]))) { Stop-PSFFunction -String 'Invoke-DMGroup.Group.InvalidScope' -StringValues $testItem, $targetScope -Continue -EnableException $EnableException -Target $testItem } Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update.Scope' -ActionStringValues $testItem, $testItem.ADObject.GroupScope, $targetScope -Target $testItem -ScriptBlock { $null = Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -GroupScope Universal -ErrorAction Stop -Confirm:$false $null = Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -GroupScope $targetScope -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } if ($testItem.Changed -contains 'Name') { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update.Name' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock { $testItem.ADObject | Rename-ADObject @parameters -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop -Confirm:$false } -EnableException $EnableException -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 SamAccountName The SamAccountName of the group. Defaults to the Name if not otherwise specified. .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. .PARAMETER Optional Group is tolerated if it exists, but will not be created if not. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .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(ValueFromPipelineByPropertyName = $true)] [string] $SamAccountName, [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, [bool] $Optional, [string] $ContextName = '<Undefined>' ) process { $script:groups[$Name] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Group' Name = $Name SamAccountName = $(if ($SamAccountName) { $SamAccountName } else { $Name }) Path = $Path Description = $Description Scope = $Scope Category = $Category OldNames = $OldNames Present = $Present Optional = $Optional ContextName = $ContextName } } } 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 { $oldNamesFound = @() :main foreach ($groupDefinition in $script:groups.Values) { $resolvedName = Resolve-String -Text $groupDefinition.SamAccountName $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 -ErrorAction Stop } catch { } } switch (($oldGroups | Measure-Object).Count) { #region Case: No old version present 0 { if (-not $groupDefinition.Optional) { New-TestResult @resultDefaults -Type Create } continue main } #endregion Case: No old version present #region Case: One old version present 1 { New-TestResult @resultDefaults -Type Rename -ADObject $oldGroups $oldNamesFound += $oldGroups.Name continue main } #endregion Case: One old version present #region Case: Too many old versions present default { New-TestResult @resultDefaults -Type MultipleOldGroups -ADObject $oldGroups $oldNamesFound += $oldGroups.Name 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 Compare-Property -Property Name -Configuration $groupDefinition -ADObject $adObject -Changes $changes -Resolve $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 $oldNamesFound) { continue } 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 Register-StringMapping -Name $Name -Value $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) Unregister-StringMapping -Name $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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 Set-DMDomainContext @parameters } process{ if (-not $InputObject) { $InputObject = Test-DMObject @parameters } foreach ($testItem in ($InputObject | Sort-Object { $_.Identity.Length })) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Object.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMObject', $testItem -Target $testItem -Continue -EnableException $EnableException } 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 -Confirm:$false } -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 -Confirm:$false } -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(ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ObjectClass, [Parameter(ValueFromPipelineByPropertyName = $true)] $Attributes, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $AttributesToResolve ) process { $identity = "CN=$Name,$Path" if (-not $Name) { $identity = $Path } $script:objects[$identity] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Object' Identity = $identity 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 Find-DMObjectCategoryItem { <# .SYNOPSIS Searches for items that are part of an object category. .DESCRIPTION Searches for items that are part of an object category. Caution: A combination of inefficient filters and large scope can lead to significant performance delays in large environments! .PARAMETER Name The name of the object category to search items for. .PARAMETER Property Properties to include when retrieving matching items. Ensure the property is legal for all potential matches. .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:\> Find-DMObjectCategoryItem -Name 'CAServer' Find all objects that are part of the CAServer category. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Name, [string[]] $Property, [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false } process { $category = $script:objectCategories[$Name] if (-not $category) { Stop-PSFFunction -String 'Find-DMObjectCategoryItem.Category.NotFound' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet return } $searchBase = Resolve-String -Text $category.SearchBase @parameters if ($category.LdapFilter) { $filter = '(&(objectClass={0})({1}))' -f $category.ObjectClass, (Resolve-String -Text $category.LdapFilter @parameters) if ($Property) { $parameters.Properties = $Property } try { Get-ADObject @parameters -LDAPFilter $filter -SearchBase $searchBase -SearchScope $category.SearchScope -ErrorAction Stop } catch { Stop-PSFFunction -String 'Find-DMObjectCategoryItem.ADError' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet return } } else { if ($Property) { $parameters.Properties = $Property } try { Get-ADObject @parameters -Filter $category.Filter -SearchBase $searchBase -SearchScope $category.SearchScope -ErrorAction Stop } catch { Stop-PSFFunction -String 'Find-DMObjectCategoryItem.ADError' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet return } } } } 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. .PARAMETER SearchBase The path under which to look for objects of this category. Defaults to domain wide. Supports string resolution. .PARAMETER SearchScope How deep to search for objects of this category under the chosen searchbase. Supported Values: - Subtree: All items under the searchbase. (default) - OneLevel: All items directly under the searchbase. - Base: Only the searchbase itself is inspected. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .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, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $SearchBase = '%DomainDN%', [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('Subtree', 'OneLevel', 'Base')] [string] $SearchScope = 'Subtree', [string] $ContextName = '<Undefined>' ) process { $script:objectCategories[$Name] = [PSCustomObject]@{ Name = $Name ObjectClass = $ObjectClass Property = $Property TestScript = $TestScript Filter = $Filter LdapFilter = $LdapFilter SearchBase = $SearchBase SearchScope = $SearchScope ContextName = $ContextName } } } 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 :main foreach ($filteredObjectCategory in $filteredObjectCategories) { #region Consider Searchbase $resolvedBase = Resolve-String -Text $filteredObjectCategory.SearchBase @parameters switch ($filteredObjectCategory.SearchScope) { 'Base' { if ($adObjectReloaded.DistinguishedName -ne $resolvedBase) { continue main } } 'OneLevel' { if ($adObjectReloaded.DistinguishedName -notlike "*,$resolvedBase") { continue main } if (($adObjectReloaded.DistinguishedName -split ",").Count -ne (($resolvedBase -split ",").Count + 1)) { continue main } } 'Subtree' { if ($adObjectReloaded.DistinguishedName -notlike "*,$resolvedBase") { continue main } } } #endregion Consider Searchbase 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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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]) Set-DMDomainContext @parameters } process { #region Sort Script $sortScript = { if ($_.Type -eq 'ShouldDelete') { $_.ADObject.DistinguishedName.Split(",").Count } else { 1000 - $_.Identity.Split(",").Count } } #endregion Sort Script if (-not $InputObject) { $InputObject = Test-DMOrganizationalUnit @parameters | Sort-Object $sortScript -Descending } :main foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.OrganizationalUnit.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMOrganizationalUnit', $testItem -Target $testItem -Continue -EnableException $EnableException } switch ($testItem.Type) { 'Delete' { 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 -Confirm:$false } Remove-ADOrganizationalUnit @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } 'Create' { $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 Confirm = $false } 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 -Confirm:$false } -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 -Confirm:$false } -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 Optional By default, organizational units must exist if defined. Setting this to true makes them optional instead - they will not be created but are tolerated if they exist. .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, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $Optional, [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 Optional = $Optional 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 Delete -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 { if (-not $ouDefinition.Optional) { New-TestResult @resultDefaults -Type Create } 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 -IgnoreMissingSearchbase)) { 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 Delete -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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')] [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'))) } #> $access = foreach ($accessRule in $acl.Access) { try { Add-Member -InputObject $accessRule -MemberType NoteProperty -Name SID -Value $accessRule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) } catch { # Do nothing, don't want the property if no SID is to be had } $accessRule } [PSCustomObject]@{ Class = $class.lDAPDisplayName Access = $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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 Set-DMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-DMPasswordPolicy @parameters } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.PSO.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMPasswordPolicy', $testItem -Target $testItem -Continue -EnableException $EnableException } 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 -Confirm:$false } -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 -Confirm:$false } $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 Get-DMServiceAccount { <# .SYNOPSIS List the configured service accounts. .DESCRIPTION List the configured service accounts. .PARAMETER Name Name to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-DMServiceAccount List all configured service accounts. #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { $($script:serviceAccounts.Values | Where-Object Name -like $Name) } } function Invoke-DMServiceAccount { <# .SYNOPSIS Applies the desired state of Service Accounts to the target domain. .DESCRIPTION Applies the desired state of Service Accounts to the target domain. Use Register-DMServiceAccount to define the desired state. .PARAMETER InputObject Individual test results to apply. Use Test-DMServiceAccount to generate these test result objects. If none are specified, it will instead execute its own test and apply all test results. .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-DMServiceAccount -Server fabrikam.org Brings the fabrikam.org domain into compliance with the defined service account configuration. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { #region Utility Functions function Get-ObjectCategoryContent { [CmdletBinding()] param ( [System.Collections.Hashtable] $Categories, [string] $Name, [System.Collections.Hashtable] $Parameters ) if (-not $Categories.ContainsKey($Name)) { $Categories[$Name] = (Find-DMObjectCategoryItem -Name $Name @parameters -Property SamAccountName).SamAccountName } $Categories[$Name] } function New-ServiceAccount { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $TestItem, [System.Collections.Hashtable] $Parameters, [System.Collections.Hashtable] $Categories ) $resolvedPath = $TestItem.Configuration.Path | Resolve-String @parameters $newParam = $Parameters.Clone() $newParam += @{ Name = $TestItem.Configuration.Name | Resolve-String @parameters | Set-String -OldValue '\$$' SamAccountName = $TestItem.Configuration.Name | Resolve-String @parameters | Set-String -OldValue '\$$' | Add-String '' '$' DNSHostName = $TestItem.Configuration.DNSHostName | Resolve-String @parameters Description = $TestItem.Configuration.Description | Resolve-String @parameters KerberosEncryptionType = $TestItem.Configuration.KerberosEncryptionType } if ($TestItem.Configuration.ServicePrincipalName) { $newParam.ServicePrincipalNames = $TestItem.Configuration.ServicePrincipalName | Resolve-String @parameters } if ($TestItem.Configuration.DisplayName) { $newParam.DisplayName = $TestItem.Configuration.DisplayName | Resolve-String @parameters } if ($TestItem.Configuration.Attributes) { $newParam.OtherAttributes = $TestItem.Configuration.Attributes | ConvertTo-PSFHashtable } #region Calculate desired principals $desiredPrincipals = @() foreach ($category in $TestItem.Configuration.ObjectCategory) { Get-ObjectCategoryContent -Categories $Categories -Name $category -Parameters $Parameters | ForEach-Object { $desiredPrincipals += $_ } } # Direct Assignment foreach ($name in $TestItem.Configuration.ComputerName | Resolve-String @Parameters) { if ($name -notlike '*$') { $name = "$($name)$" } try { $null = Get-ADComputer @Parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.Computer.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer continue } } # Optional Direct Assignment foreach ($name in $TestItem.Configuration.ComputerNameOptional | Resolve-String @Parameters) { if ($name -notlike '*$') { $name = "$($name)$" } try { $null = Get-ADComputer @Parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Verbose -String 'Invoke-DMServiceAccount.Computer.Optional.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer continue } } # Direct Assignment foreach ($name in $TestItem.Configuration.GroupName | Resolve-String @Parameters) { try { $null = Get-ADGroup @Parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.Group.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer continue } } if ($desiredPrincipals) { $newParam.PrincipalsAllowedToRetrieveManagedPassword = $desiredPrincipals } #endregion Calculate desired principals New-ADServiceAccount @newParam -ErrorAction Stop -Confirm:$false -Path $resolvedPath } function Set-ServiceAccount { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $TestItem, [System.Collections.Hashtable] $Parameters ) $setParam = $Parameters.Clone() $properties = @{ } $clear = @() foreach ($change in $testItem.Changed) { if (-not $change.NewValue -and 0 -ne $change.NewValue) { $clear += $change.Property } else { $properties[$change.Property] = $change.NewValue } } if ($properties.Count -gt 0) { $setParam.Replace = $properties } if ($clear) { $setParam.Clear = $clear } Set-ADServiceAccount @setParam -Identity $testItem.ADObject.ObjectGuid -Confirm:$false -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 ServiceAccounts -Cmdlet $PSCmdlet Set-DMDomainContext @parameters $categories = @{ } } process { if (-not $InputObject) { $InputObject = Test-DMServiceAccount @parameters } if (-not (Test-DmKdsRootKey @parameters)) { Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.NoKdsRootKey' return } :main foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.ServiceAccount.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMServiceAccount', $testItem -Target $testItem -Continue -EnableException $EnableException } switch ($testItem.Type) { 'Delete' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Deleting' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { Remove-ADServiceAccount @parameters -Identity $testItem.ADObject.SamAccountName -ErrorAction Stop -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Creating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { New-ServiceAccount -TestItem $testItem -Parameters $parameters -Categories $categories } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Update' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Updating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { Set-ServiceAccount -TestItem $testItem -Parameters $parameters } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'PrincipalUpdate' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.UpdatingPrincipal' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { $principals = ($testItem.Changed | Where-Object Type -EQ Update).NewValue Set-ADServiceAccount @parameters -Identity $testItem.ADObject.ObjectGuid -PrincipalsAllowedToRetrieveManagedPassword $principals } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Move' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Moving' -ActionStringValues $testItem.Identity, $testItem.Changed.NewValue -Target $testItem.Identity -ScriptBlock { Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -TargetPath $testItem.Changed.NewValue -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Rename' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Renaming' -ActionStringValues $testItem.Identity, $testItem.Changed.NewValue -Target $testItem.Identity -ScriptBlock { Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -NewName $testItem.Changed.NewValue -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'RenameSam' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.RenamingSam' -ActionStringValues $testItem.Identity, $testItem.ADObject.SamAccountName -Target $testItem.Identity -ScriptBlock { Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -Replace @{ samAccountName = $testItem.Identity } -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Enable' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Enabling' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { Enable-ADAccount @parameters -Identity $testItem.ADObject.ObjectGuid -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } 'Disable' { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Disabling' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock { Disable-ADAccount @parameters -Identity $testItem.ADObject.ObjectGuid -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet } } } } } function Register-DMServiceAccount { <# .SYNOPSIS Register a Group Managed Service Account as a desired state object. .DESCRIPTION Register a Group Managed Service Account as a desired state object. This will then be tested for during Test-DMServiceAccount and ensured during Invoke-DMServiceAccount. .PARAMETER Name Name of the Service Account. This must be a legal name, 15 characters or less (no trailing $ needed). The SamAccountName will be automatically calculated based off this setting (by appending a $). Supports string resolution. .PARAMETER DNSHostName The DNSHostName of the gMSA. Supports string resolution. .PARAMETER Description Describe what the gMSA is supposed to be used for. Supports string resolution. .PARAMETER Path The path where to place the gMSA. Supports string resolution. .PARAMETER ServicePrincipalName Any service principal names to add to the gMSA. Supports string resolution. .PARAMETER DisplayName A custom DisplayName for the gMSA. Note, this setting will be ignored in the default dsa.msc console! It only affects other applications that might be gMSA aware and support it. Supports string resolution. .PARAMETER ObjectCategory Only thus designated principals are allowed to retrieve the password to the gMSA. Using this you can grant access to any members of given Object Categories. .PARAMETER ComputerName Only thus designated principals are allowed to retrieve the password to the gMSA. Using this you can grant access to an explicit list of computer accounts. A missing computer will cause a warning, but not otherwise fail the process. Supports string resolution. .PARAMETER ComputerNameOptional Only thus designated principals are allowed to retrieve the password to the gMSA. Using this you can grant access to an explicit list of computer accounts. A missing computer will be logged but not otherwise noted. Supports string resolution. .PARAMETER GroupName Only thus designated principals are allowed to retrieve the password to the gMSA. Using this you can grant access to an explicit list of ActiveDirectory groups. Supports string resolution. .PARAMETER KerberosEncryptionType The supported Kerberos encryption types. Can be any combination of 'AES128', 'AES256', 'DES', 'RC4' Default: 'AES128','AES256' .PARAMETER Enabled Whether the account should be enabled or disabled. By default, this is 'Undefined', causing the workflow to ignore its enablement state. .PARAMETER Present Whether the account should exist or not. By default, it should. Set this to $false in order to explicitly delete an existing gMSA. Set this to 'Undefined' to neither create nor delete it, in which case it will only modify properties if the service account exists. .PARAMETER Attributes Offer additional attributes to define. This can be either a hashtable or an object and can contain any writeable properties a gMSA can have in your organization. .PARAMETER OldNames A list of previous names the gMSA held. This causes the ADMF to trigger rename actions. .PARAMETER ContextName The name of the context defining the setting. This allows determining the configuration set that provided this setting. Used by the ADMF, available to any other configuration management solution. .EXAMPLE PS C:\> Get-Content .\serviceaccounts.json | ConvertFrom-Json | Write-Output | Register-DMServiceAccount Load up all settings defined in serviceaccounts.json #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $DNSHostName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Description, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Path, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $ServicePrincipalName = @(), [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $DisplayName, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $ObjectCategory, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $ComputerName, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $ComputerNameOptional, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $GroupName, [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateSet('AES128', 'AES256', 'DES', 'RC4')] [string[]] $KerberosEncryptionType = @('AES128', 'AES256'), [Parameter(ValueFromPipelineByPropertyName = $true)] [PSFramework.Utility.TypeTransformationAttribute([string])] [DomainManagement.TriBool] $Enabled = 'Undefined', [Parameter(ValueFromPipelineByPropertyName = $true)] [PSFramework.Utility.TypeTransformationAttribute([string])] [DomainManagement.TriBool] $Present = 'true', [Parameter(ValueFromPipelineByPropertyName = $true)] $Attributes, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $OldNames = @(), [string] $ContextName = '<Undefined>' ) process { $script:serviceAccounts[$Name] = [PSCustomObject]@{ PSTypeName = 'DomainManagement.Configuration.ServiceAccount' Name = $Name SamAccountName = $Name DNSHostName = $DNSHostName Description = $Description Path = $Path ServicePrincipalName = $ServicePrincipalName DisplayName = $DisplayName ObjectCategory = $ObjectCategory ComputerName = $ComputerName ComputerNameOptional = $ComputerNameOptional GroupName = $GroupName KerberosEncryptionType = $KerberosEncryptionType Enabled = $Enabled Present = $Present Attributes = $Attributes | ConvertTo-PSFHashtable OldNames = $OldNames ContextName = $ContextName } } } function Test-DMServiceAccount { <# .SYNOPSIS Tests whether the currently deployed service accoaunts match the configured desired state. .DESCRIPTION Tests whether the currently deployed service accoaunts match the configured desired state. Use Register-DMServiceAccount to define the desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-DMServiceAccount -Server contoso.com Tests whether the service accounts in the contoso.com domain are compliant with the desired state. #> [CmdletBinding()] param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { #region Utility Functions function New-Change { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $Identity, $Type, $Property, $Previous, $NewValue ) [pscustomobject]@{ PSTypeName = 'DomainManagement.Change.ServiceAccount' Identity = $Identity Type = $Type Property = $Property Previous = $Previous NewValue = $NewValue } } #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 serviceAccounts -Cmdlet $PSCmdlet Set-DMDomainContext @parameters } process { #region Prepare Object Categories $rawCategories = $script:serviceAccounts.Values.ObjectCategory | Remove-PSFNull -Enumerate | Sort-Object -Unique $categories = @{ } foreach ($rawCategory in $rawCategories) { $categories[$rawCategory] = Find-DMObjectCategoryItem -Name $rawCategory @parameters -Property SamAccountName } $renameCurrentSAM = @() #endregion Prepare Object Categories #region Process Configured Objects foreach ($serviceAccountDefinition in $script:serviceAccounts.Values) { $resolvedName = (Resolve-String -Text $serviceAccountDefinition.SamAccountName @parameters) -replace '\$$' $resolvedPath = Resolve-String -Text $serviceAccountDefinition.Path @parameters $resultDefaults = @{ Server = $Server ObjectType = 'ServiceAccount' Identity = $resolvedName Configuration = $serviceAccountDefinition } $adObject = $null try { $adObject = Get-ADServiceAccount @parameters -Identity $resolvedName -ErrorAction Stop -Properties * } catch { foreach ($oldName in $serviceAccountDefinition.OldNames) { try { $adObject = Get-ADServiceAccount @parameters -Identity ($oldName | Resolve-String @parameters) -ErrorAction Stop -Properties * } catch { continue } # No Need to rename when deleting it anyway if (-not $serviceAccountDefinition.Present) { break } New-TestResult -Type RenameSam @resultDefaults -ADObject $adObject $renameCurrentSAM += $adObject.SamAccountName break } } if (-not $adObject) { # .Present is of type TriBool, so itself would be $true for both 'true' and 'undefined' cases, # and we do not want to create if undefined if ($serviceAccountDefinition.Present -eq 'true') { New-TestResult -Type Create @resultDefaults (New-Change -Identity $resolvedName -Type Create) } continue } $resultDefaults.ADObject = $adObject if (-not $serviceAccountDefinition.Present) { New-TestResult -Type Delete @resultDefaults -Changed (New-Change -Identity $adObject.SamAccountName -Type Delete) continue } #region Compare Common Properties $parentPath = $adObject.DistinguishedName -split ",", 2 | Select-Object -Last 1 if ($parentPath -ne $resolvedPath) { New-TestResult -Type Move @resultDefaults -Changed (New-Change -Type Move -Property 'Path' -Previous $parentPath -NewValue $resolvedPath -Identity $resolvedName) } if ($adObject.Name -ne $resolvedName) { New-TestResult -Type Rename @resultDefaults -Changed (New-Change -Type Rename -Property 'Name' -Previous $adObject.Name -NewValue $resolvedName -Identity $resolvedName) } [System.Collections.ArrayList]$changes = @() Compare-Property -Property DNSHostName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters Compare-Property -Property Description -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters -AsString Compare-Property -Property DisplayName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters -AsString if ($adObject.ServicePrincipalName -or $serviceAccountDefinition.ServicePrincipalName) { Compare-Property -Property ServicePrincipalName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters } if ($adObject.KerberosEncryptionType[0] -ne $serviceAccountDefinition.KerberosEncryptionType) { $null = $changes.Add('KerberosEncryptionType') } if ($serviceAccountDefinition.Attributes.Count -gt 0) { $attributesObject = [PSCustomObject]$serviceAccountDefinition.Attributes foreach ($key in $serviceAccountDefinition.Attributes.Keys) { Compare-Property -Property $key -Configuration $attributesObject -ADObject $adObject -Changes $changes } } $defaultProperties = 'DNSHostName', 'Description', 'ServicePrincipalName', 'DisplayName' $changeObjects = foreach ($change in $changes) { if ($change -in $defaultProperties) { New-Change -Type Update -Property $change -Previous $adObject.$change -NewValue ($serviceAccountDefinition.$change | Resolve-String @parameters) -Identity $resolvedName } else { New-Change -Type Update -Property $change -Previous $adObject.$change -NewValue $attributesObject.$change -Identity $resolvedName } } if ($changes) { New-TestResult -Type Update @resultDefaults -Changed $changeObjects } #endregion Compare Common Properties #region Enabled if ($serviceAccountDefinition.Enabled -ne 'Undefined') { if ($adObject.Enabled -and -not $serviceAccountDefinition.Enabled) { New-TestResult -Type Disable @resultDefaults -Changed (New-Change -Type Disable -Property Enabled -Previous $true -NewValue $false -Identity $resolvedName) } if (-not $adObject.Enabled -and $serviceAccountDefinition.Enabled) { New-TestResult -Type Enable @resultDefaults -Changed (New-Change -Type Enable -Property Enabled -Previous $false -NewValue $true -Identity $resolvedName) } } #endregion Enabled #region PrincipalsAllowedToRetrieveManagedPassword # Use SamAccountName rather than DistinguishedName as accounts may not yet have been moved to their correct container so DN might fail $currentPrincipals = ($adObject.PrincipalsAllowedToRetrieveManagedPassword | Get-ADObject @parameters -Properties SamAccountName).SamAccountName # Object Category $desiredPrincipals = @() foreach ($category in $serviceAccountDefinition.ObjectCategory) { $categories[$category].SamAccountName | ForEach-Object { $desiredPrincipals += $_ } } # Direct Assignment foreach ($name in $serviceAccountDefinition.ComputerName | Resolve-String @parameters) { if ($name -notlike '*$') { $name = "$($name)$" } try { $null = Get-ADComputer @parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Warning -String 'Test-DMServiceAccount.Computer.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, computer continue } } # Optional Direct Assignment foreach ($name in $serviceAccountDefinition.ComputerNameOptional | Resolve-String @parameters) { if ($name -notlike '*$') { $name = "$($name)$" } try { $null = Get-ADComputer @parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Verbose -String 'Test-DMServiceAccount.Computer.Optional.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, computer continue } } # Direct Group Assignment foreach ($name in $serviceAccountDefinition.GroupName | Resolve-String @parameters) { try { $null = Get-ADGroup @parameters -Identity $name -ErrorAction Stop $desiredPrincipals += $name } catch { Write-PSFMessage -Level Warning -String 'Test-DMServiceAccount.Group.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, group continue } } $principalChanges = @() foreach ($principal in $currentPrincipals) { if ($principal -in $desiredPrincipals) { continue } $principalChanges += New-Change -Type Remove -Property Principal -Previous $principal -Identity $resolvedName } foreach ($principal in $desiredPrincipals) { if ($principal -in $currentPrincipals) { continue } $principalChanges += New-Change -Type Add -Property Principal -NewValue $principal -Identity $resolvedName } if (-not $principalChanges) { continue } $principalChanges += New-Change -Type Update -Property Principal -Previous $currentPrincipals -NewValue $desiredPrincipals -Identity $resolvedName New-TestResult -Type PrincipalUpdate @resultDefaults -Changed $principalChanges #endregion PrincipalsAllowedToRetrieveManagedPassword } #endregion Process Configured Objects #region Process Non-Configuted AD-Objects $foundServiceAccounts = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) { Get-ADServiceAccount @parameters -LDAPFilter '(!(isCriticalSystemObject=TRUE))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope } $configuredNames = $script:serviceAccounts.Values.SamAccountName | Resolve-String @parameters | ForEach-Object { if ($_ -like '*$') { $_ } else { "$($_)$" } } $resultDefaults = @{ Server = $Server ObjectType = 'ServiceAccount' } foreach ($foundServiceAccount in $foundServiceAccounts) { if ($foundServiceAccount.SamAccountName -in $configuredNames) { continue } if ($foundServiceAccount.SamAccountName -in $renameCurrentSAM) { continue } New-TestResult @resultDefaults -Type Delete -Identity $foundServiceAccount.SamAccountName -ADObject $foundServiceAccount -Changed (New-Change -Identity $foundServiceAccount.SamAccountName -Type Delete) } #endregion Process Non-Configuted AD-Objects } } function Unregister-DMServiceAccount { <# .SYNOPSIS Removes a service account from the list of registered service accounts. .DESCRIPTION Removes a service account from the list of registered service accounts. .PARAMETER Name The account to remove. .EXAMPLE PS C:\> Get-DMServiceAccount | Unregister-DMServiceAccount Clear all configured service accounts. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($nameItem in $Name) { $script:serviceAccounts.Remove($nameItem) } } } 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 Reset-DMDomainCredential { <# .SYNOPSIS Resets cached credentials for contacting domains. .DESCRIPTION Resets cached credentials for contacting domains. Use this command when invalidating credentials you used. For example in ADMF the credential provider: If you create one that uses a temporary account, then delete it when done, you need to reset the cache when connecting with your default credentials. .PARAMETER Credential Clear all cache entries using this credential object. .PARAMETER Domain Clear the cached credentials for the target domain. .PARAMETER UserName Clear all cached credentials using this username. .PARAMETER All Clear ALL cached credentials .EXAMPLE PS C:\> Reset-DMDomainCredential -All Clear all cached credentials #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Credential')] [PSCredential] $Credential, [Parameter(Mandatory = $true, ParameterSetName = 'Domain')] [string] $Domain, [Parameter(Mandatory = $true, ParameterSetName = 'Name')] [string] $UserName, [Parameter(Mandatory = $true, ParameterSetName = 'All')] [switch] $All ) process { switch ($PSCmdlet.ParameterSetName) { 'Credential' { [string[]]$keys = $script:domainCredentialCache.Keys foreach ($key in $keys) { if ($script:domainCredentialCache[$key] -eq $Credential) { $script:domainCredentialCache.Remove($key) } } } 'Domain' { $script:domainCredentialCache.Remove($Domain) } 'Name' { [string[]]$keys = $script:domainCredentialCache.Keys foreach ($key in $keys) { if ($script:domainCredentialCache[$key].UserName -eq $UserName) { $script:domainCredentialCache.Remove($key) } } } 'All' { $script:domainCredentialCache = @{ } } } } } 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, [AllowEmptyCollection()] [string[]] $Include, [AllowEmptyCollection()] [string[]] $Exclude, [AllowEmptyCollection()] [string[]] $UserExcludePattern ) process { if ($Mode) { $script:contentMode.Mode = $Mode } if (Test-PSFParameterBinding -ParameterName Include) { $script:contentMode.Include = $Include } if (Test-PSFParameterBinding -ParameterName Exclude) { $script:contentMode.Exclude = $Exclude } if (Test-PSFParameterBinding -ParameterName 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 $forestRootSID = $forestRootDomain.DomainSID.Value } else { try { $cred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential $forestRootDomain = Get-ADDomain @cred -Server $forestObject.RootDomain -ErrorAction Stop $forestRootSID = $forestRootDomain.DomainSID.Value } catch { $forestRootDomain = [PSCustomObject]@{ Name = $forestObject.RootDomain.Split(".",2)[0] DNSRoot = $forestObject.RootDomain DistinguishedName = 'DC={0}' -f ($forestObject.RootDomain.Split(".") -join ",DC=") } $forestRootSID = (Get-ADObject @parameters -SearchBase "CN=System,$($domainObject.DistinguishedName)" -SearchScope OneLevel -LDAPFilter "(&(objectClass=trustedDomain)(trustPartner=$($forestObject.RootDomain)))" -Properties securityIdentifier).securityIdentifier.Value } } $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 '%DomainNetBIOSName%' -Value $domainObject.NetbiosName Register-DMNameMapping -Name '%DomainFqdn%' -Value $domainObject.DNSRoot Register-DMNameMapping -Name '%DomainDN%' -Value $domainObject.DistinguishedName Register-DMNameMapping -Name '%DomainSID%' -Value $domainObject.DomainSID.Value Register-DMNameMapping -Name '%RootDomainName%' -Value $forestRootDomain.Name Register-DMNameMapping -Name '%RootDomainFqdn%' -Value $forestRootDomain.DNSRoot Register-DMNameMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName Register-DMNameMapping -Name '%RootDomainSID%' -Value $forestRootSID 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 InputObject Test results provided by the associated test command. Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. .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 ( [Parameter(ValueFromPipeline = $true)] $InputObject, [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 Set-DMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-DMUser @parameters } :main foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.User.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMUser', $testItem -Target $testItem -Continue -EnableException $EnableException } switch ($testItem.Type) { 'Delete' { 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 } 'Create' { $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 = $testItem.Configuration.Enabled # Both True and Undefined will result in $true Confirm = $false } 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 { Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -SamAccountName $testItem.Configuration.SamAccountName -ErrorAction Stop if ($testItem.ADObject.Name -ne (Resolve-String -Text $testItem.Configuration.Name)) { 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-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 -Confirm:$false } -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['sn'] = (Resolve-String -Text $testItem.Configuration.Surname) } if ($testItem.Changed -contains 'Description') { $changes['Description'] = (Resolve-String -Text $testItem.Configuration.Description) } 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 -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } if ($testItem.Changed -contains 'Enabled') { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.EnableDisable' -ActionStringValues $testItem.Configuration.Enabled -Target $testItem -ScriptBlock { $null = Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Enabled $testItem.Configuration.Enabled -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } if ($testItem.Changed -contains 'Name') { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.Name' -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 -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } if ($testItem.Changed -contains 'PasswordNeverExpires') { Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.PasswordNeverExpires' -ActionStringValues $testItem.Configuration.PasswordNeverExpires -Target $testItem -ScriptBlock { $null = Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -PasswordNeverExpires $testItem.Configuration.PasswordNeverExpires -Confirm:$false } -EnableException $EnableException -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 Name Name 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 Enabled Whether the user object should be enabled or disabled. Defaults to: Undefined .PARAMETER Optional By default, all defined user accounts must exist. By setting a user account optional, it will be tolerated if it exists, but not created if it does not. .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. When set to 'Undefined', this will act exactly as if -Optional were set to $true .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] $Name, [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)] [PSFramework.Utility.TypeTransformationAttribute([string])] [DomainManagement.TriBool] $Enabled = 'Undefined', [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $Optional, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $OldNames = @(), [Parameter(ValueFromPipelineByPropertyName = $true)] [PSFramework.Utility.TypeTransformationAttribute([string])] [DomainManagement.TriBool] $Present = 'true' ) process { $userHash = @{ PSTypeName = 'DomainManagement.User' SamAccountName = $SamAccountName Name = $Name GivenName = $GivenName Surname = $Surname Description = $null PasswordNeverExpires = $PasswordNeverExpires.ToBool() UserPrincipalName = $UserPrincipalName Path = $Path Enabled = $Enabled Optional = $Optional OldNames = $OldNames Present = $Present } if ($Description) { $userHash['Description'] = $Description } if (-not $Name) { $userHash['Name'] = $SamAccountName } $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) { $resolvedSamAccName = Resolve-String -Text $userDefinition.SamAccountName $resultDefaults = @{ Server = $Server ObjectType = 'User' Identity = $resolvedSamAccName Configuration = $userDefinition } #region User that needs to be removed if (-not $userDefinition.Present) { try { $adObject = Get-ADUser @parameters -Identity $resolvedSamAccName -Properties Description, PasswordNeverExpires -ErrorAction Stop } catch { continue } # Only errors when user not present = All is well New-TestResult @resultDefaults -Type Delete -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 $resolvedSamAccName -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 { if (-not ($userDefinition.Optional -or ($userDefinition.Present -eq 'Undefined'))) { New-TestResult @resultDefaults -Type Create } 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 Compare-Property -Property Name -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 ($userDefinition.Enabled -ne "Undefined") { if ($adObject.Enabled -ne $userDefinition.Enabled) { $null = $changes.Add('Enabled') } } 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.SamAccountName -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 Delete -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-PSFConfig -Module 'DomainManagement' -Name 'ServiceAccount.SkipKdsCheck' -Value $false -Initialize -Validation bool -Description 'Whether the check for a KDS Root Key should be skipped. By default, Invoke-DMServiceAccount will validate the necessary key exists before creating gMSA. However, reading the key requires Domain Admin privileges, which may not always be available. Skipping the check will cause gMSA creation to fail with an error, if the KDSRootKey does not yet exist.' Set-PSFScriptblock -Name 'DomainManagement.Validate.GPPermissionFilter' -Scriptblock { $tokens = $null $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseInput($_, [ref]$tokens, [ref]$errors) if ($errors) { Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.SyntaxError' -StringValues $_ -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter' return $false } $validTokenTypes = 'Identifier', 'Parameter', 'LParen', 'RParen', 'EndOfInput', 'And', 'Not', 'Or', 'Xor' $invalidTokenTypes = $tokens | Where-Object Kind -notin $validTokenTypes | Select-Object -ExpandProperty Kind -Unique if ($invalidTokenTypes) { Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidTokenType' -StringValues $_, ($invalidTokenTypes -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter' return $false } $validParameters = '-and', '-or', '-not', '-xor' $invalidParameters = $tokens | Where-Object Kind -eq Parameter | Where-Object Text -notin $validParameters | Select-Object -ExpandProperty Text -Unique if ($invalidParameters) { Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidParameters' -StringValues $_, ($invalidParameters -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter' return $false } $validIdentifierPattern = '^[\w\d_]+$' $invalidIdentifiers = $tokens | Where-Object Kind -eq Identifier | Where-Object Text -notmatch $validIdentifierPattern | Select-Object -ExpandProperty Text -Unique if ($invalidIdentifiers) { Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidIdentifiers' -StringValues $_, ($invalidIdentifiers -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter' return $false } return $true } Set-PSFScriptblock -Name 'DomainManagement.Validate.Identity' -Scriptblock { if ($_ -as [System.Security.Principal.SecurityIdentifier]) { return $true } if (($_ -replace '%[\d\w_]+%','S-1-0-00-0000000000-0000000000-0000000000') -as [System.Security.Principal.SecurityIdentifier]) { return $true } if ($_ -like "*@*") { return $true } if ($_ -like "*\*") { return $true } $false } Set-PSFScriptblock -Name 'DomainManagement.Validate.TypeName.AccessRule' -Scriptblock { ($_.PSObject.TypeNames -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 { } #> Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.GpoName' -ScriptBlock { (Get-DMGPPermission).GpoName } Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.Identity' -ScriptBlock { (Get-DMGPPermission).Identity } Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.Filter' -ScriptBlock { (Get-DMGPPermission).Filter } Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter GpoName -Name 'DomainManagement.GPPermission.GpoName' Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter Identity -Name 'DomainManagement.GPPermission.Identity' Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter Filter -Name 'DomainManagement.GPPermission.Filter' Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermissionFilter.Name' -ScriptBlock { (Get-DMGPPermissionFilter).Name } Register-PSFTeppArgumentCompleter -Command Get-DMGPPermissionFilter -Parameter Name -Name 'DomainManagement.GPPermissionFilter.Name' Register-PSFTeppArgumentCompleter -Command Unregister-DMGPPermissionFilter -Parameter Name -Name 'DomainManagement.GPPermissionFilter.Name' <# # 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. "@ $PSDefaultParameterValues['Resolve-String:ModuleName'] = 'ADMF.Core' $PSDefaultParameterValues['Register-StringMapping:ModuleName'] = 'ADMF.Core' $PSDefaultParameterValues['Clear-StringMapping:ModuleName'] = 'ADMF.Core' $PSDefaultParameterValues['Unregister-StringMapping:ModuleName'] = 'ADMF.Core' Register-PSFCallback -Name DomainManagement.ConfigurationReset -ModuleName ADMF.Core -CommandName Clear-AdcConfiguration -ScriptBlock { Clear-DMConfiguration } # 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 registry settings $script:groupPolicyRegistrySettings = @{ } # Configured group policy links $script:groupPolicyLinks = @{ } $script:groupPolicyLinksDynamic = @{ } # Configured group policy permission filters $script:groupPolicyPermissionFilters = @{ } # Configured group policy permissions $script:groupPolicyPermissions = @{ } # Configured owners of group policy objects $script:groupPolicyOwners = @{ } # Configured ACLs $script:acls = @{ } $script:aclByCategory = @{ } $script:aclDefaultOwner = $null # Configured Access Rules - Based on OU / Path $script:accessRules = @{ } # Configured Access Rule processing Modes $script:accessRuleMode = @{ } # Configured Access Rules - Based on Object Category $script:accessCategoryRules = @{ } # Configured Object Categories $script:objectCategories = @{ } # Configured generic objects $script:objects = @{ } # Configured data gathering scripts $script:domainDataScripts = @{ } # Configured domain functional level $script:domainLevel = $null # Configured Exchange Domain Setting Versions $script:exchangeVersion = $null # Configured Group Managed Service Accounts $script:serviceAccounts = @{ } #----------------------------------------------------------------------------# # 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 = @{ } # Cached domain data, used by Invoke-DMDomainData. Can be any script logic result $script:cache_DomainData = @{ } # Domain mapping cache, used by Get-Domain $script:SIDtoDomain = @{ } $script:DNStoDomain = @{ } $script:DNStoDomainName = @{ } $script:NetBiostoDomain = @{ } #----------------------------------------------------------------------------# # 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' # Deutsch 'BUILTIN\Konten-Operatoren' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-548' 'BUILTIN\Server-Operatoren' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-549' 'BUILTIN\Druck-Operatoren' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-550' 'BUILTIN\Prä-Windows 2000 kompatibler Zugriff' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-554' 'BUILTIN\Erstellungen eingehender Gesamtstrukturvertrauensstellung' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-557' 'BUILTIN\Windows-Autorisierungszugriffsgruppe' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-560' 'BUILTIN\Terminalserver-Lizenzserver' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-561' 'BUILTIN\Zertifikatdienst-DCOM-Zugriff' = [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 |