ForestManagement.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\ForestManagement.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName ForestManagement.Import.DoDotSource -Fallback $false if ($ForestManagement_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 ForestManagement.Import.IndividualFiles -Fallback $false if ($ForestManagement_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 'ForestManagement' -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 terminate said calling command if relevant settings are missing .EXAMPLE PS C:\> Assert-Configuration -Type Users Asserts, that users have already been specified. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Type, [Parameter(Mandatory = $true)] [System.Management.Automation.PSCmdlet] $Cmdlet ) process { if ((Get-Variable -Name $Type -Scope Script -ValueOnly).Count -gt 0) { return } Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues $Type -FunctionName $Cmdlet.CommandRuntime $exception = New-Object System.Data.DataException("No configuration data provided for: $Type") $errorID = 'NotConfigured' $category = [System.Management.Automation.ErrorCategory]::NotSpecified $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, $Type) $cmdlet.ThrowTerminatingError($recordObject) } } function Compare-SchemaProperty { <# .SYNOPSIS Compares configuration vs. adobject of schema attributes. .DESCRIPTION Compares configuration vs. adobject of schema attributes. Designed for use when comparing schema attributes, for example in Test-FMSchemaLdif. Returns $true when the values are INEQUAL. .PARAMETER Setting The settings object containing the desired state for an attribute. .PARAMETER ADObject The ADObject of the attribute to compare. .PARAMETER PropertyName The property to compare. .PARAMETER RootDSE The RootDSE object connected to. Used for objectCategory comparisons. .PARAMETER Add Is satisfied with the defined items being part of the AD object property, without requiring an exact match between configuration and ad. .EXAMPLE PS C:\> Compare-SchemaProperty -Setting $setting -ADObject $adObject -PropertyName attributeSecurityGUID -RootDSE $rootDSE Returns, whether the values found in $setting and $adObject are different from each other. #> [OutputType([System.Boolean])] [CmdletBinding()] param ( [Parameter(Mandatory=$true)] $Setting, [Parameter(Mandatory=$true)] $ADObject, [Parameter(Mandatory=$true)] $PropertyName, [Parameter(Mandatory=$true)] $RootDSE, [switch] $Add ) switch ($PropertyName) { 'schemaIDGUID' { return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|')) } 'attributeSecurityGUID' { return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|')) } 'objectCategory' { return (($Setting.$PropertyName -replace '<SchemaContainerDN>',$RootDSE.schemaNamingContext) -ne ($ADObject.$PropertyName -join '|')) } 'DistinguishedName' { # Don't compare identifiers! return $false } 'Description' { # Prevent encoding errors / issues from falsifying the results if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } return (($Setting.$PropertyName -replace "[^\d\w]","_") -ne ($ADObject.$PropertyName -replace "[^\d\w]","_")) } 'mayContain' { if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=') } default { if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false } if ($null -eq $Setting.$PropertyName) { return $true } if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true } if ($Add) { return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=') } return [bool](Compare-Object $Setting.$PropertyName $ADObject.$PropertyName) } } } function Compare-SiteLink { <# .SYNOPSIS Compares two sitelink objects. .DESCRIPTION Compares two sitelink objects. Returns the DifferenceSiteLink if it uses the same sites as the reference sitelink, no matter the order. .PARAMETER ReferenceSiteLink The sitelink to compare to input with. .PARAMETER DifferenceSiteLink The sitelink(s) to compare. .EXAMPLE $script:sitelinks.Values | Compare-SiteLink $refSiteLink Returns any registered sitelinks that span the same sites as $refSiteLink (Should never be more than 1!) #> [CmdletBinding()] Param ( [Parameter(Position = 0)] $ReferenceSiteLink, [Parameter(ValueFromPipeline = $true)] $DifferenceSiteLink ) process { foreach ($diffSiteLink in $DifferenceSiteLink) { if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site1) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site2)) { $diffSiteLink continue } if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site2) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site1)) { $diffSiteLink continue } } } } function ConvertTo-SchemaLdifPhase { <# .SYNOPSIS Converts ldif files into a phased state index. .DESCRIPTION Converts ldif files into a phased state index. For each phase/file for each object it calculates the resulting state after ALL commands in the file have been executed. This allows stepping through the individual ldif files in the order they are to be applied and figure out the last applied deployment state. .PARAMETER LdifData The set of Ldif file definitions as returned by Get-FMSchemaLdif .EXAMPLE PS C:\> $ldifPhases = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif) Returns the hashtable containing the different phases of all registered ldif files. #> [OutputType([Hashtable])] [CmdletBinding()] param ( $LdifData ) #region Utility Functions function Add-Node { [CmdletBinding()] param ( [string] $DistinguishedName, [string] $LdifName, [Hashtable] $MappingTable ) if (-not $MappingTable.ContainsKey($DistinguishedName)) { $MappingTable[$DistinguishedName] = @{ } } if (-not $MappingTable[$DistinguishedName][$LdifName]) { $MappingTable[$DistinguishedName][$LdifName] = @{ State = @{ } Add = @{ } Replace = @{ } } } } function Write-Change { [CmdletBinding()] param ( [string] $DistinguishedName, [string] $LdifName, $Change, [Hashtable] $MappingTable ) Add-Node -DistinguishedName $DistinguishedName -LdifName $LdifName -MappingTable $MappingTable $datasheet = $MappingTable[$DistinguishedName][$LdifName] switch -regex ($Change.changetype) { 'add' { $datasheet.State = @{ } foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'changeType', 'FM_OrderCount') { continue } $datasheet.State[$propertyName] = $Change.$propertyName } } 'modify' { #region We already have a defined state if ($datasheet.State.Count -gt 0) { if ($Change.add) { if ($datasheet.State.$($Change.add)) { $datasheet.State.$($Change.add) = @($datasheet.State.$($Change.add)) + @($Change.$($Change.add)) } else { $datasheet.State[$Change.add] = $Change.$($Change.add) } } elseif ($Change.replace) { $datasheet.State[$Change.replace] = $Change.$($Change.replace) } else { foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue } $datasheet.State[$propertyName] = $Change.$propertyName } } } #endregion We already have a defined state #region Undefined state else { if ($Change.add) { if ($datasheet.Add.$($Change.add)) { $datasheet.Add.$($Change.add) = @($datasheet.Add.$($Change.add)) + @($Change.$($Change.add)) } else { $datasheet.Add[$Change.add] = $Change.$($Change.add) } } elseif ($Change.replace) { $datasheet.Replace[$Change.replace] = $Change.$($Change.replace) } else { foreach ($propertyName in $Change.PSObject.Properties.Name) { if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue } $datasheet.Replace[$propertyName] = $Change.$propertyName } } } #endregion Undefined state } } } function Copy-State { [CmdletBinding()] param ( [Hashtable] $MappingTable, [string] $OldLdif, [string] $NewLdif ) foreach ($name in $MappingTable.Keys) { Add-Node -DistinguishedName $name -LdifName $NewLdif -MappingTable $MappingTable foreach ($key in $MappingTable[$name][$OldLdif].State.Keys) { $MappingTable[$name][$NewLdif].State[$key] = $MappingTable[$name][$OldLdif].State[$key] | Write-Output } foreach ($key in $MappingTable[$name][$OldLdif].Add.Keys) { $MappingTable[$name][$NewLdif].Add[$key] = $MappingTable[$name][$OldLdif].Add[$key] | Write-Output } foreach ($key in $MappingTable[$name][$OldLdif].Replace.Keys) { $MappingTable[$name][$NewLdif].Replace[$key] = $MappingTable[$name][$OldLdif].Replace[$key] | Write-Output } } } function Remove-NoOp { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( $LdifData, [Hashtable] $MappingTable ) $identities = $MappingTable.Keys | Write-Output foreach ($identity in $identities) { foreach ($ldifFile in $LdifData) { if (-not $MappingTable[$identity][$ldifFile.Name]) { continue } if ($ldifFile.Settings.DistinguishedName -contains $identity) { continue } $MappingTable[$identity].Remove($ldifFile.Name) } } } #endregion Utility Functions $mappingTable = @{ } $sortedLdif = $ldifData | Sort-Object Weight $previousLdif = '' foreach ($ldifItem in $sortedLdif) { if ($previousLdif) { Copy-State -MappingTable $mappingTable -OldLdif $previousLdif -NewLdif $ldifItem.Name } foreach ($setting in ($ldifItem.Settings | Sort-Object FM_OrderCount)) { Write-Change -DistinguishedName $setting.DistinguishedName -LdifName $ldifItem.Name -Change $setting -MappingTable $mappingTable } $previousLdif = $ldifItem.Name } Remove-NoOp -LdifData $sortedLdif -MappingTable $mappingTable $mappingTable } function Get-ADCertificate { <# .SYNOPSIS Returns forest certificates. .DESCRIPTION Returns forest certificates. .PARAMETER Parameters Hashtable containing AD connection values. May contain Server and Credential nodes but nothing else. .PARAMETER Type The kind of certificate to retrieve .EXAMPLE PS C:\> Get-ADCertificate -Parameters $parameters -Type NTAuthCA Returns all NTAuth certificates in the targeted forest. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [hashtable] $Parameters, [Parameter(Mandatory = $true)] [ValidateSet('NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA')] [string] $Type ) begin { #region Utility Functions function Get-CertificateInternal { [CmdletBinding()] param ( [string] $Object, [string] $Path, [string] $AltPath, [string] $NotInPath, [string] $AttributeName = 'cACertificate', [System.Collections.Hashtable] $Parameters ) #region Single Object Processing if ($Object -eq 'Single') { try { $adObject = Get-ADObject @Parameters -Identity $Path -ErrorAction Stop -Properties $AttributeName } catch { return } # Object doesn't exist foreach ($certData in $adObject.$AttributeName) { $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData) [pscustomobject]@{ Certificate = $certificate Subject = $certificate.Subject Thumbprint = $certificate.Thumbprint ADObject = $adObject AltADObject = $null AttributeName = $AttributeName } | Add-Member -MemberType ScriptMethod -Name ToString -Value { '{0} (<{1:yyyy-MM-dd})' -f $this.Subject, $this.Certificate.NotAfter } -Force -PassThru } return } #endregion Single Object Processing $adObjects = Get-ADObject @Parameters -SearchBase $Path -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName $existingCerts = foreach ($adObject in $adObjects) { foreach ($certData in $adObject.$AttributeName) { if (-not $certData) { continue } $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData) [pscustomobject]@{ Certificate = $certificate Subject = $certificate.Subject Thumbprint = $certificate.Thumbprint ADObject = $adObject AltADObject = $null AttributeName = $AttributeName } | Add-Member -MemberType ScriptMethod -Name ToString -Value { '{0} (<{1:yyyy-MM-dd})' -f $this.Subject, $this.Certificate.NotAfter } -Force -PassThru } } #region AltPath # Contained in the original container and ALSO in the alternative container (e.g. RootCA Certificate) if ($AltPath) { $altAdObjects = Get-ADObject @Parameters -SearchBase $AltPath -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName foreach ($adObject in $altAdObjects) { $certificates = foreach ($certData in $adObject.$AttributeName) { if (-not $certData) { continue } [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData) } foreach ($existingCert in $existingCerts) { if ($existingCert.Thumbprint -notin $certificates.Thumbprint) { continue } $existingCert.AltADObject = $adObject } } $existingCerts = $existingCerts | Where-Object AltADObject } #endregion AltPath #region NotInPath # Contained in the original container and NOT in the alternative container (e.g. SubCA Certificate) if ($NotInPath) { $notInAdObjects = Get-ADObject @Parameters -SearchBase $NotInPath -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName $certificates = foreach ($adObject in $notInAdObjects) { foreach ($certData in $adObject.$AttributeName) { if (-not $certData) { continue } [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData) } } $existingCerts = $existingCerts | Where-Object Thumbprint -NotIn $certificates.Thumbprint } #endregion NotInPath $existingCerts } #endregion Utility Functions $rootDSE = Get-ADRootDSE @parameters $mapping = @{ NTAuthCA = @{ Object = 'Single' Path = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" } RootCA = @{ Object = 'Multi' Path = "CN=Certification Authorities,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" AltPath = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" } SubCA = @{ Object = 'Multi' Path = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" NotInPath = "CN=Certification Authorities,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" } CrossCA = @{ Object = 'Multi' Path = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" AttributeName = 'crossCertificatePair' } KRA = @{ Object = 'Multi' Path = "CN=KRA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" AttributeName = 'userCertificate' } } } process { $table = $mapping[$type] Get-CertificateInternal -Parameters $parameters @table } } function Get-ExchangeVersion { <# .SYNOPSIS Return Exchange Version Information. .DESCRIPTION Return Exchange Version Information. .PARAMETER Binding The Binding to use. .PARAMETER Name The name to filter by. .EXAMPLE PS C:\> Get-ExchangeVersion Return a list of all Exchange Versions #> [CmdletBinding()] param ( [string] $Binding, [string] $Name = '*' ) begin { # Useful source: https://eightwone.com/references/schema-versions/ $versionMapping = @{ '2013RTM' = [PSCustomObject]@{ Name = 'Exchange 2013 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15449; RangeUpper = 15137; Binding = '2013RTM' } '2013CU1' = [PSCustomObject]@{ Name = 'Exchange 2013 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15614; RangeUpper = 15254; Binding = '2013CU1' } '2013CU2' = [PSCustomObject]@{ Name = 'Exchange 2013 CU2'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15688; RangeUpper = 15281; Binding = '2013CU2' } '2013CU3' = [PSCustomObject]@{ Name = 'Exchange 2013 CU3'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15763; RangeUpper = 15283; Binding = '2013CU3' } '2013SP1' = [PSCustomObject]@{ Name = 'Exchange 2013 SP1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15844; RangeUpper = 15292; Binding = '2013SP1' } '2013CU5' = [PSCustomObject]@{ Name = 'Exchange 2013 CU5'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15870; RangeUpper = 15300; Binding = '2013CU5' } '2013CU6' = [PSCustomObject]@{ Name = 'Exchange 2013 CU6'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15303; Binding = '2013CU6' } '2013CU7' = [PSCustomObject]@{ Name = 'Exchange 2013 CU7'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU7' } '2013CU8' = [PSCustomObject]@{ Name = 'Exchange 2013 CU8'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU8' } '2013CU9' = [PSCustomObject]@{ Name = 'Exchange 2013 CU9'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU9' } '2013CU10' = [PSCustomObject]@{ Name = 'Exchange 2013 CU10'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU10' } '2013CU11' = [PSCustomObject]@{ Name = 'Exchange 2013 CU11'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU11' } '2013CU12' = [PSCustomObject]@{ Name = 'Exchange 2013 CU12'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU12' } '2013CU13' = [PSCustomObject]@{ Name = 'Exchange 2013 CU13'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU13' } '2013CU14' = [PSCustomObject]@{ Name = 'Exchange 2013 CU14'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU14' } '2013CU15' = [PSCustomObject]@{ Name = 'Exchange 2013 CU15'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU15' } '2013CU16' = [PSCustomObject]@{ Name = 'Exchange 2013 CU16'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU16' } '2013CU17' = [PSCustomObject]@{ Name = 'Exchange 2013 CU17'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU17' } '2013CU18' = [PSCustomObject]@{ Name = 'Exchange 2013 CU18'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU18' } '2013CU19' = [PSCustomObject]@{ Name = 'Exchange 2013 CU19'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU19' } '2013CU20' = [PSCustomObject]@{ Name = 'Exchange 2013 CU20'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU20' } '2013CU21' = [PSCustomObject]@{ Name = 'Exchange 2013 CU21'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU21' } '2013CU22' = [PSCustomObject]@{ Name = 'Exchange 2013 CU22'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16131; RangeUpper = 15312; Binding = '2013CU22' } '2013CU23' = [PSCustomObject]@{ Name = 'Exchange 2013 CU23'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16133; RangeUpper = 15312; Binding = '2013CU23' } '2016Preview' = [PSCustomObject]@{ Name = 'Exchange 2016 Preview'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16041; RangeUpper = 15317; Binding = '2016Preview' } '2016RTM' = [PSCustomObject]@{ Name = 'Exchange 2016 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16210; RangeUpper = 15317; Binding = '2016RTM' } '2016CU1' = [PSCustomObject]@{ Name = 'Exchange 2016 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16211; RangeUpper = 15323; Binding = '2016CU1' } '2016CU2' = [PSCustomObject]@{ Name = 'Exchange 2016 CU2'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16212; RangeUpper = 15325; Binding = '2016CU2' } '2016CU3' = [PSCustomObject]@{ Name = 'Exchange 2016 CU3'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16212; RangeUpper = 15326; Binding = '2016CU3' } '2016CU4' = [PSCustomObject]@{ Name = 'Exchange 2016 CU4'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15326; Binding = '2016CU4' } '2016CU5' = [PSCustomObject]@{ Name = 'Exchange 2016 CU5'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15326; Binding = '2016CU5' } '2016CU6' = [PSCustomObject]@{ Name = 'Exchange 2016 CU6'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15330; Binding = '2016CU6' } '2016CU7' = [PSCustomObject]@{ Name = 'Exchange 2016 CU7'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU7' } '2016CU8' = [PSCustomObject]@{ Name = 'Exchange 2016 CU8'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU8' } '2016CU9' = [PSCustomObject]@{ Name = 'Exchange 2016 CU9'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU9' } '2016CU10' = [PSCustomObject]@{ Name = 'Exchange 2016 CU10'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU10' } '2016CU11' = [PSCustomObject]@{ Name = 'Exchange 2016 CU11'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16214; RangeUpper = 15332; Binding = '2016CU11' } '2016CU12' = [PSCustomObject]@{ Name = 'Exchange 2016 CU12'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16215; RangeUpper = 15332; Binding = '2016CU12' } '2016CU13' = [PSCustomObject]@{ Name = 'Exchange 2016 CU13'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU13' } '2016CU14' = [PSCustomObject]@{ Name = 'Exchange 2016 CU14'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU14' } '2016CU15' = [PSCustomObject]@{ Name = 'Exchange 2016 CU15'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU15' } '2016CU16' = [PSCustomObject]@{ Name = 'Exchange 2016 CU16'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU16' } '2016CU17' = [PSCustomObject]@{ Name = 'Exchange 2016 CU17'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU17' } '2019Preview' = [PSCustomObject]@{ Name = 'Exchange 2019 Preview'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2019Preview' } '2019RTM' = [PSCustomObject]@{ Name = 'Exchange 2019 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16751; RangeUpper = 17000; Binding = '2019RTM' } '2019CU1' = [PSCustomObject]@{ Name = 'Exchange 2019 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16752; RangeUpper = 17000; Binding = '2019CU1' } '2019CU2' = [PSCustomObject]@{ Name = 'Exchange 2019 CU2'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU2' } '2019CU3' = [PSCustomObject]@{ Name = 'Exchange 2019 CU3'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU3' } '2019CU4' = [PSCustomObject]@{ Name = 'Exchange 2019 CU4'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU4' } '2019CU5' = [PSCustomObject]@{ Name = 'Exchange 2019 CU5'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU5' } '2019CU6' = [PSCustomObject]@{ Name = 'Exchange 2019 CU6'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU6' } } } process { if ($Binding) { return $versionMapping[$Binding] } $versionMapping.Values | Where-Object Name -Like $Name } } function Get-SchemaAdminCredential { <# .SYNOPSIS Returns the credentials for the account to use for schema administration. .DESCRIPTION Returns the credentials for the account to use for schema administration. The behavior of this command is heavily controlled by the configuration system: ForestManagement.Schema.* .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Get-SchemaAdminCredential @parameters Returns the configured schema credentials #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] [OutputType([PSCredential])] [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $script:temporarySchemaUpdateUser = $null } process { #region Case: Explicit Credentials if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential') { Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential' return } #endregion Case: Explicit Credentials #region Case: Temporary Schema Admin Account if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.AutoCreate.TempAdmin') { do { $newName = "$(Get-Random -Minimum 100000 -Maximum 999999)_$($env:USERNAME)" } while (Get-ADUser @parameters -LDAPFilter "(name=$newName)") $password = New-Password -Length 128 -AsSecureString Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $newName -ScriptBlock { $newUser = New-ADUser @parameters -Name $newName -Description 'Temporary Admin account used to update the schema' -AccountPassword $password -PassThru -Enabled $true -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet if (-not $newUser) { return } $script:temporarySchemaUpdateUser = $newUser $domain = Get-ADDomain @parameters try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $newUser -ErrorAction Stop } catch { Remove-ADUser -Identity $userObject @parameters $script:temporarySchemaUpdateUser = $null Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Assignment.Failure' -StringValues $newName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($newName)", $password) return } #endregion Case: Temporary Schema Admin Account #region Case: Explicit Schema Admin Account if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name') { $accountName = Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name' if ($accountName -like "*\*") { $accountName = $account.Split("\")[1] } $domain = Get-ADDomain @parameters $accountObject = Get-ADUser @parameters -LDAPFilter "(name=$accountName)" $schemaAdmins = Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" -Properties Members #region Scenario: Account does not exist if (-not $accountObject) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoCreate')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.ExistsNot' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -Category ObjectNotFound } $password = New-Password -Length 128 -AsSecureString Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $accountName -ScriptBlock { $userObject = New-ADUser @parameters -Name $accountName -AccountPassword $password -Enabled $true -Description "Admin account for updating the schema. Created by $($env:USERDOMAIN)\$($env:USERNAME)" -PassThru -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet if (-not $userObject) { return } try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $userObject -ErrorAction Stop } catch { Remove-ADUser -Identity $userObject @parameters Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.GroupAssignment.Failure' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } #endregion Scenario: Account does not exist #region Fail Fast if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoGrant')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Unprivileged' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet } } if (-not $accountObject.Enabled) { if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoEnable')) { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Disabled' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet } } #endregion Fail Fast #region Prepare account for schema administration if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) { Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Group.Assignment' -Target $accountName -ScriptBlock { $null = $schemaAdmins | Add-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } if (-not $accountObject.Enabled) { Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Enable' -Target $accountName -ScriptBlock { $null = Enable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } #endregion Prepare account for schema administration #region Handle Password if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') { $password = New-Password -Length 128 -AsSecureString try { Write-PSFMessage -String 'Get-SchemaAdminCredential.Password.Reset' -StringValues $accountName $null = Set-ADAccountPassword @parameters -Identity $accountObject -NewPassword $password -ErrorAction Stop -Reset } catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.Reset.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } else { try { $password = Read-Host -Prompt "Specify password for schema admin $accountName" -AsSecureString -ErrorAction Stop } catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.InteractiveRead.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet } New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password) return } #endregion Handle Password } #endregion Case: Explicit Schema Admin Account # Case: Current User Credential $Credential } } function Import-LdifFile { <# .SYNOPSIS Parses an LDIF file and returns the changes it applies. .DESCRIPTION Parses an LDIF file and returns the changes it applies. Note: schemaupdatenow commands are skipped. .PARAMETER Path The path to the LDIF file to parse. .EXAMPLE PS C:\> Import-LdifFile -Path $ldifFile Parses the ldif file and returns changes it applies. #> [CmdletBinding()] param ( [string] $Path ) begin { #region Utility Functions function Resolve-AttributeName { [OutputType([string])] [CmdletBinding()] param ( [string] $Name ) switch ($Name) { 'dn' { 'DistinguishedName' } default { $Name } } } function Resolve-AttributeValue { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")] [CmdletBinding()] param ( [string] $Value, [bool] $IsBase64, [string] $AttributeName ) if ($IsBase64) { switch ($AttributeName) { 'schemaIDGUID' { [PSCustomObject]@{ Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value)) GuidData = [System.Convert]::FromBase64String($Value) } } 'attributeSecurityGUID' { [PSCustomObject]@{ Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value)) GuidData = [System.Convert]::FromBase64String($Value) } } 'omObjectClass' { [System.Convert]::FromBase64String($Value) } default { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) } } } else { if ($Value -eq "TRUE") { return $true } if ($Value -eq "FALSE") { return $false } if ($Value -eq "") { return '' } if ($null -ne ($Value -as [int])) { return ($Value -as [int]) } $Value } } #endregion Utility Functions $lines = Get-Content -Path $Path $currentObject = @{ } $lastKey = '' $orderCount = 0 } process { $isBase64 = $false foreach ($line in $lines) { if (-not $line) { continue } if ($line -like '#*') { continue } if ($line -like 'dn:*') { if (($currentObject.Keys.Count -gt 1) -and ($currentObject['replace'] -ne 'schemaupdatenow')) { [pscustomobject]$currentObject } $currentObject = @{ PSTypeName = 'ForestManagement.Schema.Ldif.Setting' DistinguishedName = ($line -replace '^dn:', '').Trim() -replace ',DC=X$' -replace ',CN=Schema,CN=Configuration$' FM_OrderCount = $orderCount } $orderCount++ $lastKey = 'DistinguishedName' continue } if ($line -match '^([^:]+):(?<colon>:*) (.*)$') { $isBase64 = $matches['colon'] -eq ':' $attributeName = Resolve-AttributeName -Name $matches[1] $attributeValue = Resolve-AttributeValue -Value $matches[2] -IsBase64 $isBase64 -AttributeName $attributeName # Prevent duplicate object classes - top is redundant and not listed in AD if (($attributeName -eq 'ObjectClass') -and ($attributeValue -eq 'Top')) { continue } if ($currentObject.ContainsKey($attributeName)) { $values = @($currentObject[$attributeName]) $values += $attributeValue $currentObject[$attributeName] = $values } else { $currentObject[$attributeName] = $attributeValue } $lastKey = $attributeName } # Handle value continuation on the next line # Values break line when exceeding a total width of 80 characters elseif ($line -match '^ (.+)$') { $currentObject[$lastKey] = $currentObject[$lastKey] + (Resolve-AttributeValue -Value $matches[1] -IsBase64 $isBase64 -AttributeName $lastKey) } } } end { # Process last item if ($currentObject.Keys.Count -gt 0) { if ($currentObject['replace'] -ne 'schemaupdatenow') { [pscustomobject]$currentObject } } } } 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_FM_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 Invoke-LdifFile { <# .SYNOPSIS Invokes a LDIF file against a target server / forest. .DESCRIPTION Invokes a LDIF file against a target server / forest. Note: This command assumes schema updates executed against the schema master (and will automatically switch to target that server). LDIF files are not technically constrained to performing schema updates however. Thus this function is not suitable to performing domain NC changes in a subdomain. .PARAMETER Path Path to the ldif file to import .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. .EXAMPLE PS C:\> Invoke-LdifFile -Path .\schema.ldif Imports the schema.ldif file into the current forest's schema. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] param ( [Parameter(Mandatory = $true)] [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')] [string] $Path, [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $parameters['Server'] = (Get-ADForest @parameters).SchemaMaster $domainObject = Get-ADDomain @parameters $arguments = @() if ($Credential) { $arguments += "-b" $networkCredential = $Credential.GetNetworkCredential() $userName = $networkCredential.UserName $domain = $networkCredential.Domain if (-not $domain -and $userName -match '@') { $userName, $domain = $userName -split '@',2 } $arguments += $userName if ($domain) { $arguments += $domain } $arguments += $networkCredential.Password } # Load target server $arguments += '-s' $arguments += "$Server" # Other settings $arguments += '-i' # Import $arguments += '-k' # Ignore errors for items that already exist $arguments += '-c' $arguments += 'DC=X' $arguments += $domainObject.DistinguishedName # Load File $arguments += '-f' $arguments += (Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem) } process { Invoke-PSFProtectedCommand -ActionString 'Invoke-LdifFile.Invoking.File' -ActionStringValues $Path -ScriptBlock { $procInfo = Start-Process -FilePath ldifde.exe -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop -WindowStyle Hidden if ($procInfo.ExitCode) { $winError = [System.ComponentModel.Win32Exception]::new($procInfo.ExitCode) switch ($procInfo.ExitCode) { 8224 { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file. Validate domain health, especially FSMO assignment and replication health. $($winError.Message)", $winError) } default { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file: $($winError.Message)", $winError) } } throw $outerError } } -EnableException $true -Target $Server -PSCmdlet $PSCmdlet } } 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("PSAvoidUsingConvertToSecureStringWithPlainText", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [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. .PARAMETER Properties Additional properties to include in the testresult object. .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, [hashtable] $Properties = @{ } ) process { $object = [PSCustomObject](@{ PSTypeName = "ForestManagement.$ObjectType.TestResult" Type = $Type ObjectType = $ObjectType Identity = $Identity Changed = $Changed Server = $Server Configuration = $Configuration ADObject = $ADObject } + $Properties) Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { $this.Identity } -Force $object } } function Remove-SchemaAdminCredential { <# .SYNOPSIS Implements the post processing of schema admin credentials. .DESCRIPTION Implements the post processing of schema admin credentials. This command is responsible for applying the schema admin credential configuration policies. For example, it will remove temporary admin accounts or perform the auto-reset auf admin credentials. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .PARAMETER SchemaAccountCredential The credential object of the schema admin that was returned by Get-SchemaAdminCredential. .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:\> Remove-SchemaAdminCredential @removeParameters Cleans up the credentials according to policy. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [PSFComputer] $Server, [PSCredential] $Credential, [PSCredential] $SchemaAccountCredential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false $domain = Get-ADDomain @parameters } process { if ($SchemaAccountCredential) { $userName = $SchemaAccountCredential.GetNetworkCredential().UserName try { Write-PSFMessage -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve' -StringValues $userName $accountObject = Get-ADUser @parameters -Identity $userName -ErrorAction Stop } catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve.Failed' -StringValues $userName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoRevoke') -and ($accountObject)) { Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.Group.Revoke' -Target $username -ScriptBlock { "$($domain.DomainSID)-518" | Remove-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDisable') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.Disable' -Target $username -ScriptBlock { Disable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.PasswordReset' -Target $username -ScriptBlock { $password = New-Password -Length 128 -AsSecureString Set-ADAccountPassword @parameters -Identity $accountObject -ErrorAction Stop -NewPassword $password -Reset -Confirm:$false } -EnableException $true -PSCmdlet $PSCmdlet } if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -and ($accountObject)) { $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.AutoDescription' -Target $username -ScriptBlock { Set-ADUser @parameters -Identity $accountObject -Description (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -ErrorAction Stop } -EnableException $true -PSCmdlet $PSCmdlet } if ($script:temporarySchemaUpdateUser) { try { Write-PSFMessage -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove' -StringValues $script:temporarySchemaUpdateUser.Name Remove-ADUser @parameters -Identity $script:temporarySchemaUpdateUser -ErrorAction Stop -Confirm:$false $script:temporarySchemaUpdateUser = $null } catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove.Failed' -StringValues $script:temporarySchemaUpdateUser.Name -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ } } } } function Resolve-SchemaAttribute { <# .SYNOPSIS Combines configuration and adobject into an attributes hashtable. .DESCRIPTION Combines configuration and adobject into an attributes hashtable. This is a helper function that allows to simplify the code used to create and update schema attributes. .PARAMETER Configuration The configuration object containing the desired schema attribute name. .PARAMETER ADObject The ADObject - if present - containing the current schema attribute configuration. Specifying this will cause it to return a delta hashtable useful for updating attributes. .PARAMETER Changes Changes to be applied to an existing attribute. .EXAMPLE PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration Returns the attributes hashtable for a new schema attribute. .EXAMPLE PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject Returns the attributes hashtable for attributes to update. #> [OutputType([hashtable])] [CmdletBinding()] param ( $Configuration, $ADObject, $Changes ) begin { function Convert-AttributeName { [OutputType([string])] [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] [AllowEmptyCollection()] [AllowEmptyString()] [AllowNull()] [string[]] $Name ) process { foreach ($entry in $Name) { if ($null -eq $entry) { continue } switch ($entry) { SingleValued { 'isSingleValued' } OID { 'attributeID' } PartialAttributeSet { 'isMemberOfPartialAttributeSet' } AdvancedView { 'showInAdvancedViewOnly' } default { $_ } } } } } } process { #region Build out basic attribute hashtable $attributes = @{ adminDisplayName = $Configuration.AdminDisplayName lDAPDisplayName = $Configuration.LdapDisplayName attributeId = $Configuration.OID oMSyntax = $Configuration.OMSyntax attributeSyntax = $Configuration.AttributeSyntax isSingleValued = ($Configuration.SingleValued -as [bool]) adminDescription = $Configuration.AdminDescription searchflags = $Configuration.SearchFlags isMemberOfPartialAttributeSet = $Configuration.PartialAttributeSet showInAdvancedViewOnly = $Configuration.AdvancedView } #endregion Build out basic attribute hashtable #region Case: New Attribute if (-not $ADObject) { $badProperties = foreach ($pair in $attributes.GetEnumerator()) { if ($null -eq $pair.Value) { $pair.Key } } if ($null -eq $Configuration.SingleValued) { $badProperties = @($badProperties) + @('SingleValued') } if ($badProperties) { throw "Cannot create new attribute $($Configuration.AdminDisplayName), missing attributes: $($badProperties -join ',')" } return $attributes } #endregion Case: New Attribute #region Case: Update Settings $updates = @{ } foreach ($change in $Changes) { if ($change.Property -in 'ObjectClass','MayContain','MustContain') { continue } $updates[($change.Property | Convert-AttributeName)] = $change.New } $systemOnly = @( 'isSingleValued' 'oMSyntax' 'attributeId' 'attributeSyntax' ) foreach ($attributeName in ($updates.Keys | Write-Output)) { if ($systemOnly -contains $attributeName) { Write-PSFMessage -Level Warning -String 'Resolve-SchemaAttribute.Update.SystemOnlyError' -StringValues $attributeName, $attributes.$attributeName, $ADObject $updates.Remove($attributeName) } } #endregion Case: Update Settings $updates } } function Set-FMDomainContext { <# .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-FMDomainContext @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 } } Register-StringMapping -Name '%DomainName%' -Value $domainObject.Name Register-StringMapping -Name '%DomainNetBIOSName%' -Value $domainObject.NetbiosName Register-StringMapping -Name '%DomainFqdn%' -Value $domainObject.DNSRoot Register-StringMapping -Name '%DomainDN%' -Value $domainObject.DistinguishedName Register-StringMapping -Name '%DomainSID%' -Value $domainObject.DomainSID.Value Register-StringMapping -Name '%RootDomainName%' -Value $forestRootDomain.Name Register-StringMapping -Name '%RootDomainFqdn%' -Value $forestRootDomain.DNSRoot Register-StringMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName Register-StringMapping -Name '%RootDomainSID%' -Value $forestRootSID Register-StringMapping -Name '%ForestFqdn%' -Value $forestObject.Name } } 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-SchemaAdminCredential { <# .SYNOPSIS Validates, whether the schema admin credential workflow should be executed. .DESCRIPTION Validates, whether the schema admin credential workflow should be executed. This is done using two checks: - Is the ForestManagement.Schema.Account.IgnoreOnCredentialProvider config setting set? - Is the command calling the caller of this command anything other than Invoke-AdmfForest with the CredentialProvider parameter set If the configuration is set and a crededntial provider was specified, it will return false. .EXAMPLE PS C:\> Test-SchemaAdminCredential Validates, whether the schema admin credential workflow should be executed. #> [OutputType([bool])] [CmdletBinding()] param () if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.IgnoreOnCredentialProvider') { return $true } $invocation = (Get-PSCallstack)[2] -not ($invocation.Command -eq 'Invoke-AdmfForest' -and $invocation.InvocationInfo.BoundParameters.Keys -contains 'CredentialProvider') } function Update-Schema { <# .SYNOPSIS Forces a schema update. .DESCRIPTION Forces a schema update. This allows immediately assigning new attributes in schema. Generally, it is recommended targeting the schema master dc. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Update-Schema -Server dc1.contoso.com Forces a schema update on dc1.contoso.com #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [string] $Server, [PSCredential] $Credential ) $path = "LDAP://RootDSE" if ($Server) { $path = "LDAP://$Server/RootDSE" } if ($Credential) { $rootDSE = [adsi]::new($path, $Credential.UserName, $Credential.GetNetworkCredential().Password) } else { $rootDSE = [adsi]::new($path) } $null = $rootDSE.put("schemaUpdateNow", 1) $null = $rootDSE.SetInfo() } function ConvertTo-SubnetMask { <# .SYNOPSIS Converts the size of a mask into the mask as IPAddress .DESCRIPTION Converts the size of a mask into the mask as IPAddress .PARAMETER MaskSize The size of the subnet. Valid between 1 and 32 .EXAMPLE PS C:\> ConvertTo-SubnetMask -MaskSize 30 Converts the size (30) into the mask as IPAddress #> [OutputType([IPAddress])] [CmdletBinding()] param ( [ValidateRange(1, 32)] [int] $MaskSize ) process { $binaryString = ("1") * $MaskSize + ("0") * (32 - $MaskSize) $bytes = foreach ($number in (0 .. 3)) { [convert]::ToByte($binaryString.SubString(($number * 8), 8), 2) } [IPAddress]::new($bytes) } } function Test-Subnet { <# .SYNOPSIS Tests whether a host fits into the specified subnet. .DESCRIPTION Tests whether a host fits into the specified subnet. .PARAMETER NetworkAddress The address of the subnet. .PARAMETER MaskAddress The subnet mask of the subnet. .PARAMETER MaskSize The size of the mask of the subnet. .PARAMETER HostAddress The address of the host to test .EXAMPLE PS C:\> Test-Subnet -NetworkAddress '192.168.2.0' -MaskSize 24 -HostAddress '192.168.20.255' Checks whether the address '192.168.20.255' is part of the subnet '192.168.2.0/24' #> [CmdletBinding()] Param ( [IPAddress] $NetworkAddress, [IPAddress] $MaskAddress, [int] $MaskSize, [IPAddress] $HostAddress ) process { if ($MaskSize) { $MaskAddress = ConvertTo-SubnetMask -MaskSize $MaskSize } $NetworkAddress.Address -eq ($MaskAddress.Address -band $HostAddress.Address) } } function Get-FMCertificate { <# .SYNOPSIS Returns registered Certificates. .DESCRIPTION Returns registered Certificates. .PARAMETER Thumbprint The thumbprint of the certificate to filter by. .PARAMETER Name The name of the certificate to filter by. .PARAMETER Type The type of certificate to look for .EXAMPLE PS C:\> Get-FMCertificate Returns all registered certificates intended for any of the forest certificate stores #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [string] $Thumbprint = '*', [string] $Name = '*', [string] $Type = '*' ) process { ($script:dsCertificates.Values) | Where-Object { $_.Certificate.Thumbprint -like $Thumbprint } | Where-Object { $_.Certificate.Subject -like $Name -or $_.Certificate.Subject -like "CN=$Name" -or $_.Certificate.FriendlyName -like $Name } | Where-Object Type -Like $Type } } function Invoke-FMCertificate { <# .SYNOPSIS Applies the desired certificates to the NTAuth store. .DESCRIPTION Applies the desired certificates to the NTAuth store. This allows distributing certificates that are trusted across the entire forest. .PARAMETER InputObject The test results to apply. Only specify objects returned by Test-FMCertificate. By default, if you do not specify this parameter it will run the test and apply all deltas found. .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-FMCertificate -Server contoso.com Applies the defined NTAuthStore configuration to the contoso.com 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 dsCertificates -Cmdlet $PSCmdlet Set-FMDomainContext @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-FMCertificate.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } #region Add Certificate Scriptblock $addCertificateScript = { param ( $Certificate ) $certPath = "$env:temp\cert_$(Get-Random -Minimum 10000 -Maximum 99999).cer" try { $Certificate.Certificate.GetRawCertData() | Set-Content $certPath -Encoding Byte -ErrorAction Stop } catch { [pscustomobject]@{ Success = $false Stage = 'Writing certificate file' Error = $_ } return } $res = certutil.exe -dspublish -f $certPath $Certificate.Type 2>&1 if ($LASTEXITCODE -gt 0) { [pscustomobject]@{ Success = $false Stage = 'Applying certificate using certutil' Output = $res } Remove-Item -Path $certPath -ErrorAction Ignore return } Remove-Item -Path $certPath -ErrorAction Ignore [pscustomobject]@{ Success = $true Stage = 'Done' Output = $null } } #endregion Add Certificate Scriptblock } process { if (Test-PSFFunctionInterrupt) { return } # Test All Certificates if no specific test result was specified if (-not $InputObject) { $InputObject = Test-FMCertificate @parameters } :main foreach ($testResult in $InputObject) { # Catch invalid input - can only process test results if ($testResult.PSObject.TypeNames -notcontains 'ForestManagement.Certificate.TestResult') { Stop-PSFFunction -String 'Invoke-FMCertificate.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException } switch ($testResult.Type) { 'Add' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMCertificate.Add' -ActionStringValues $testResult.Configuration.Certificate.Subject, $testResult.Configuration.Type -Target $testResult -ScriptBlock { $result = Invoke-Command -Session $session -ArgumentList $testResult.Configuration -ScriptBlock $addCertificateScript if (-not $result.Success) { throw "Error executing $($result.Stage) : $($result.Error)" } $certificates = Get-ADCertificate -Parameters $parameters -Type $testResult.Configuration.Type if ($testResult.Configuration.Certificate.Thumbprint -notin $certificates.Thumbprint) { throw "Certificate could not be applied successfully! Ensure you have the permissions needed for this operation. Certutil output:`n$($result.Output)" } } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main } 'Remove' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMCertificate.Remove' -ActionStringValues $testResult.ADObject.Subject, $testResult.ADObject.ADObject -Target $testResult -ScriptBlock { try { Set-ADObject @parameters -Identity $testResult.ADObject.ADObject -Remove @{ $testResult.ADObject.AttributeName = $testResult.ADObject.Certificate.GetRawCertData() } -ErrorAction Stop } catch { if ($_.Exception.ErrorCode -eq 8316) { Remove-ADObject @parameters -Identity $testResult.ADObject.ADObject -ErrorAction Stop -Confirm:$false } else { throw } } if ($testResult.ADObject.AltADObject) { try { Set-ADObject @parameters -Identity $testResult.ADObject.AltADObject -Remove @{ $testResult.ADObject.AttributeName = $testResult.ADObject.Certificate.GetRawCertData() } -ErrorAction Stop } catch { if ($_.Exception.ErrorCode -eq 8316) { Remove-ADObject @parameters -Identity $testResult.ADObject.AltADObject -ErrorAction Stop -Confirm:$false } else { throw } } } } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main } } } } end { if (Test-PSFFunctionInterrupt) { return } Remove-PSSession -Session $session -Confirm:$false -WhatIf:$false } } function Register-FMCertificate { <# .SYNOPSIS Register directory services certificates .DESCRIPTION Register directory services certificates .PARAMETER Certificate The certifcate to apply. .PARAMETER Type The kind of certificate this is. Can be: NTAuthCA, RootCA, SubCA, CrossCA or KRA. .PARAMETER Authorative Should the certificate configuration overwrite the existing configuration, rather than adding to it (default). .PARAMETER Remove Thumbprint of a certificate to remove rather than add. .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-FMCertificate -Certificate $certificate -Type RootCA Register a certiciate as RootCA certificate. .EXAMPLE PS C:\> Register-FMCertificate -Authorative -Type RootCA Sets our current configuration as authorative, removing all non-listed certificates from the store. .EXAMPLE PS C:\> Register-FMCertificate -Remove $cert.Thumbprint -Type SubCA Registers a certificate for removal from the SubCA list. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ValidateSet('NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA')] [string] $Type, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Certificate")] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Authorative")] [bool] $Authorative, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Remove')] [string] $Remove, [string] $ContextName = '<Undefined>' ) process { switch ($pscmdlet.ParameterSetName) { Certificate { $object = [pscustomobject]@{ Certificate = $Certificate Type = $Type Action = 'Add' ContextName = $ContextName } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { '+ {0} > {1}' -f $this.Type, $this.Certificate.Subject } -Force $script:dsCertificates[$Certificate.Thumbprint] = $object } Authorative { $script:dsCertificatesAuthorative[$Type] = $Authorative } Remove { $object = [pscustomobject]@{ Thumbprint = $Remove Type = $Type Action = 'Remove' ContextName = $ContextName } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { '- {0} > {1}' -f $this.Type, $this.Thumbprint } -Force $script:dsCertificates[$Remove] = $object } } } } function Test-FMCertificate { <# .SYNOPSIS Tests, whether the certificate stores are in the desired state. .DESCRIPTION Tests, whether the certificate stores are in the desired state, that is, all defined certificates are already in place. Use Register-FMCertificate to define desired the desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMCertificate -Server contoso.com Checks whether the contoso.com forest has all the certificates it should #> [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 dsCertificates -Cmdlet $PSCmdlet Set-FMDomainContext @parameters } process { $resDefault = @{ Server = $Server ObjectType = 'Certificate' } foreach ($type in 'NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA') { $certificates = Get-ADCertificate -Parameters $parameters -Type $type $desiredState = Get-FMCertificate -Type $type foreach ($desiredCert in $desiredState) { if ($desiredCert.Action -eq 'Add' -and $desiredCert.Certificate.Thumbprint -in $certificates.Thumbprint) { continue } if ($desiredCert.Action -eq 'Remove' -and $desiredCert.Thumbprint -notin $certificates.Thumbprint) { continue } $adObject = $null if ($desiredCert.Action -eq 'Remove') { $adObject = $certificates | Where-Object Thumbprint -EQ $desiredCert.Thumbprint } New-TestResult @resDefault -Type $desiredCert.Action -Identity $desiredCert -Configuration $desiredCert -ADObject $adObject } if (-not $script:dsCertificatesAuthorative[$type]) { continue } foreach ($certificate in $certificates) { if ($certificate.Thumbprint -in $desiredState.Certificate.Thumbprint) { continue } if ($certificate.Thumbprint -in $desiredState.Thumbprint) { continue } New-TestResult @resDefault -Type 'Remove' -Identity $certificate -ADObject $certificate } } } } function Unregister-FMCertificate { <# .SYNOPSIS Removes a certificate definition for the NTAuthStore. .DESCRIPTION Removes a certificate definition for the NTAuthStore. See Register-FMCertificate tfor details on defining a certificate. .PARAMETER Thumbprint The thumbprint of the certificate to remove. .PARAMETER Certificate The certificate to remove. .EXAMPLE PS C:\> Get-FMCertificate | Unregister-FMCertificate Clears all certificates from the list of defined NTAuth certificates #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Thumbprint, [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [System.Security.Cryptography.X509Certificates.X509Certificate2[]] $Certificate ) process { foreach ($thumbprintString in $Thumbprint) { $script:dsCertificates.Remove($thumbprintString) } foreach ($certificateObject in $Certificate) { $script:dsCertificates.Remove($certificateObject.Thumbprint) } } } function Get-FMExchangeSchema { <# .SYNOPSIS Returns the defined Exchange Forest configuration to apply. .DESCRIPTION Returns the defined Exchange Forest configuration to apply. .EXAMPLE PS C:\> Get-FMExchangeSchema Returns the defined Exchange Forest configuration to apply. #> [CmdletBinding()] Param ( ) process { $script:exchangeschema } } function Invoke-FMExchangeSchema { <# .SYNOPSIS Applies the desired Exchange version to the tareted Forest. .DESCRIPTION Applies the desired Exchange version to the tareted Forest. Requires Schema Admin & Enterprise Admin privileges. .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-FMExchangeSchema -Server contoso.com Applies the desired Exchange version to the contoso.com Forest. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] [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 ExchangeSchema -Cmdlet $PSCmdlet Set-FMDomainContext @parameters $forestObject = Get-ADForest @parameters $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential $psParameter.ComputerName = $Server try { $session = New-PSSession @psParameter -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } #region 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 Test-ExchangeSite { [CmdletBinding()] param ( [hashtable] $Parameters, $Forest ) $currentServer = Get-ADDomainController @parameters $schemaMaster = Get-ADDomainController @parameters -Identity $Forest.SchemaMaster $currentServer.Site -eq $schemaMaster.Site } function Invoke-ExchangeSchemaUpdate { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] param ( [System.Management.Automation.Runspaces.PSSession] $Session, [string] $Path, [string] $OrganizationName, [switch] $SchemaOnly, [ValidateSet('InstallSchema', 'UpdateSchema', 'Install', 'Update', 'EnableSplitP', 'DisableSplitP')] [string] $Mode, [bool] $SplitPermission, [bool] $AllDomains, [hashtable] $Parameters ) $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 $limit = (Get-Date).AddMinutes(1) while (-not $volume.DriveLetter) { $volume = Get-Volume -DiskImage $diskImage if ($volume.DriveLetter) { break } if ((Get-Date) -gt $limit) { try { Dismount-DiskImage -ImagePath $exchangeIsoPath } catch { } throw "Timeout waiting for volume drive letter!" } Start-Sleep -Milliseconds 250 } $installPath = "$($volume.DriveLetter):\setup.exe" #region Perform Installation $resultText = switch ($Parameters.Mode) { 'InstallSchema' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema 2>&1 } 'UpdateSchema' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema 2>&1 } 'Install' { if (-not $Parameters.SplitPermission) { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /OrganizationName:$($Parameters.OrganizationName) 2>&1 } else { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /ActiveDirectorySplitPermissions:true /OrganizationName:$($Parameters.OrganizationName) 2>&1 } } 'Update' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD 2>&1 } 'EnableSplitP' { & $installPath /PrepareAD /ActiveDirectorySplitPermissions:true /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 if (-not $Parameters.AllDomains) { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } else { & $installPath /PrepareAllDomains /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } } 'DisableSplitP' { & $installPath /PrepareAD /ActiveDirectorySplitPermissions:false /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 if (-not $Parameters.AllDomains) { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } else { & $installPath /PrepareAllDomains /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 } } } $results = [pscustomobject]@{ Success = $LASTEXITCODE -lt 1 Message = $resultText -join "`n" } #endregion Perform Installation # 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 -Target $Parameters.Server if (-not $result.Success) { throw "Error applying exchange update: $($result.Message)" } # Test Message validation (Text parsing is bad, but the method below is less reliable) if ($result.Message -match 'The Exchange Server setup operation completed successfully') { return } # Exchange's setup.exe is not always reliable in its exit codes, thus we need to retest # This is not guaranteed to work 100%, as replication delay may lead to false errors $testResult = Test-FMExchangeSchema @Parameters if (-not $testResult) { return } if ($testResult.Type -contains $Mode) { throw "Exchange Update probably failed! Success could not be verified, but replication delays might lead to a wrong alert here. This was the return from the exchange installer:`n$($result.Message)" } } #endregion Functions } process { if (Test-PSFFunctionInterrupt) { return } if (-not $InputObject) { $InputObject = Test-FMExchangeSchema @parameters } foreach ($testItem in $InputObject) { $commonParam = @{ Session = $session Path = $testItem.Configuration.LocalImagePath OrganizationName = $testItem.Configuration.OrganizationName Parameters = $parameters ErrorAction = 'Stop' } #region Apply Updates if needed switch ($testItem.Type) { #region Install Exchange Schema 'CreateSchema' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Installing' -ActionStringValues $testItem.Configuration -Target $forestObject -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode InstallSchema -SchemaOnly } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Install Exchange Schema #region Update Exchange Schema 'UpdateSchema' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Updating' -ActionStringValues $testItem.ADObject, $testItem.Configuration -Target $forestObject -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode UpdateSchema -SchemaOnly } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Update Exchange Schema #region Install Exchange Schema & AD Objects 'Create' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Installing' -ActionStringValues $testItem.Configuration -Target $forestObject -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode Install } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Install Exchange Schema & AD Objects #region Update Exchange Schema & AD Objects 'Update' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Updating' -ActionStringValues $testItem.ADObject, $testItem.Configuration -Target $forestObject -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode Update } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Update Exchange Schema & AD Objects 'DisableSplitP' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.DisablingSplitPermissions' -ActionStringValues $testItem.Configuration -Target $Server -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode DisableSplitP -AllDomains $testItem.Configuration.AllDomains } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } 'EnableSplitP' { if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } if (-not (Test-ExchangeSite -Parameters $parameters -Forest $forest)) { Stop-PSFFunction -String 'Invoke-FMExchangeSchema.SchemaMaster.WrongSite' -StringValues $parameters.Server, $forest.SchemaMaster -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.EnablingSplitPermissions' -ActionStringValues $testItem.Configuration -Target $Server -ScriptBlock { Invoke-ExchangeSchemaUpdate @commonParam -Mode EnableSplitP -AllDomains $testItem.Configuration.AllDomains } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } #endregion Apply Updates if needed } } end { if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -Confirm:$false -WhatIf:$false } } } function Register-FMExchangeSchema { <# .SYNOPSIS Registers an exchange version to apply to the forest's schema and configuration. .DESCRIPTION Registers an exchange version to apply to the forest's schema and configuration. Updating both requires both Schema Admin and Enterprise Admin permissions. Domain-Level changes to Exchange are handled by the DomainManagement module. .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 identifiers in AD: RangeUpper in schema and ObjectVersion in configuration. This parameter is to help avoiding to have to look up those values. If your version is not supported by us yet, look up those numbers and explicitly bind it to -RangeUpper and -ObjectVersion isntead. .PARAMETER RangeUpper The explicit RangeUpper schema attribute property, found on the ms-Exch-Schema-Version-Pt class in schema. .PARAMETER ObjectVersion The object version on the msExchOrganizationContainer type object in the configuration. Do NOT confuse that with the ObjectVersion of the exchange object in the default Naming Context (regular domain space). .PARAMETER OrganizationName The name of the Exchange Organization. Only used for CREATING a new Exchange deployment. Make sure to customize this if you are picky about names like that. .PARAMETER SchemaOnly Whether to only apply the schema updates. Enabling this will mean no configuration scope changes are applied and the root domain also will not be pre-configured for Exchange. .PARAMETER SplitPermission Whether the exchange installation should implement Active Directory Split Permissions. With Split Permissions, Exchange Administrators will be less able to affect Active Directory. This provides more security, but imposes more administrative effort. For more details on Split Permissions, see this documentation: https://docs.microsoft.com/en-us/exchange/permissions/split-permissions/configure-exchange-for-split-permissions?view=exchserver-2019 .PARAMETER AllDomains Whether the domain content changes to the root domain should be applied to ALL domains. Only applies to the SplitPermission change. .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-FMExchangeSchema -LocalImagePath 'C:\ISO\exchange-2019-cu6.iso' -ExchangeVersion '2019CU6' Registers the Exchange 2019 CU6 exchange version as exchange forest settings to be applied. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $LocalImagePath, [Parameter(Mandatory = $true, ParameterSetName = 'Version')] [PsfValidateSet(TabCompletion = 'ForestManagement.ExchangeVersion')] [PsfArgumentCompleter('ForestManagement.ExchangeVersion')] [string] $ExchangeVersion, [Parameter(Mandatory = $true, ParameterSetName = 'Details')] [int] $RangeUpper, [Parameter(ParameterSetName = 'Details')] [int] $ObjectVersion, [string] $OrganizationName = 'Exchange Organization', [switch] $SchemaOnly, [bool] $SplitPermission = $false, [switch] $AllDomains, [string] $ContextName = '<Undefined>' ) process { $object = [pscustomobject]@{ PSTypeName = 'ForestManagement.Configuration.ExchangeSchema' RangeUpper = $RangeUpper ObjectVersion = $ObjectVersion LocalImagePath = $LocalImagePath ExchangeVersion = (Get-AdcExchangeVersion | Where-Object RangeUpper -EQ $RangeUpper | Where-Object ObjectVersionConfig -EQ $ObjectVersion | Sort-Object Name | Select-Object -Last 1).Name OrganizationName = $OrganizationName SchemaOnly = $SchemaOnly.ToBool() SplitPermission = $SplitPermission AllDomains = $AllDomains ContextName = $ContextName } if ($ExchangeVersion) { # Will always succeede, since the input validation prevents invalid exchange versions $exchangeVersionInfo = Get-AdcExchangeVersion -Binding $ExchangeVersion $object.RangeUpper = $exchangeVersionInfo.RangeUpper $object.ObjectVersion = $exchangeVersionInfo.ObjectVersionConfig $object.ExchangeVersion = $exchangeVersionInfo.Name } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { if ($this.ExchangeVersion) { $this.ExchangeVersion } else { '{0} : {1}' -f $this.RangeUpper, $this.ObjectVersion } } -Force $script:exchangeschema = @($object) } } function Test-FMExchangeSchema { <# .SYNOPSIS Tests, whether the desired Exchange version has already been applied to the Forest. .DESCRIPTION Tests, whether the desired Exchange version has already been applied to the Forest. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMExchangeSchema -Server contoso.com Tests whether the desired Exchange version has already been applied to the contoso.com forest. #> [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 ExchangeSchema -Cmdlet $PSCmdlet Set-FMDomainContext @parameters #region Utility Functions function Get-ExchangeRangeUpper { [CmdletBinding()] param ( [hashtable] $Parameters ) $rootDSE = Get-ADRootDSE @parameters (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=ms-Exch-Schema-Version-Pt)" -Properties rangeUpper).rangeUpper } function Get-ExchangeObjectVersion { [CmdletBinding()] param ( [hashtable] $Parameters ) $rootDSE = Get-ADRootDSE @parameters (Get-ADObject @parameters -SearchBase $rootDSE.configurationNamingContext -LDAPFilter '(objectClass=msExchOrganizationContainer)' -Properties ObjectVersion).ObjectVersion } function Get-ExchangeOrganizationName { [CmdletBinding()] param ( [hashtable] $Parameters ) $rootDSE = Get-ADRootDSE @parameters (Get-ADObject @parameters -SearchBase $rootDSE.configurationNamingContext -LDAPFilter '(objectClass=msExchOrganizationContainer)').Name } #endregion Utility Functions } process { $forest = Get-ADForest @parameters $schemaVersion = Get-ExchangeRangeUpper -Parameters $parameters $objectVersion = Get-ExchangeObjectVersion -Parameters $parameters $displayName = (Get-AdcExchangeVersion | Where-Object RangeUpper -EQ $schemaVersion | Where-Object ObjectVersionConfig -EQ $objectVersion | Sort-Object Name | Select-Object -Last 1).Name $splitPermissionsEnabled = Test-ADObject @parameters -Identity ("OU=Microsoft Exchange Protected Groups,%DomainDN%" | Resolve-String) $adData = [pscustomobject]@{ SchemaVersion = $schemaVersion ObjectVersion = $objectVersion DisplayName = $displayName OrganizationName = Get-ExchangeOrganizationName -Parameters $parameters SplitPermissions = $splitPermissionsEnabled } Add-Member -InputObject $adData -MemberType ScriptMethod -Name ToString -Value { if ($this.DisplayName) { $this.DisplayName } else { '{0} : {1}' -f $this.SchemaVersion, $this.ObjectVersion } } -Force $configuredData = Get-FMExchangeSchema $common = @{ ObjectType = 'ExchangeSchema' Identity = $forest Server = $Server Configuration = $configuredData ADObject = $adData } if ($configuredData.SchemaOnly) { if (-not $schemaVersion) { New-TestResult @common -Type CreateSchema } elseif ($configuredData.RangeUpper -gt $schemaVersion) { New-TestResult @common -Type UpdateSchema } return } if (-not $schemaVersion -or -not $objectVersion) { New-TestResult @common -Type Create return } if (($configuredData.RangeUpper -gt $schemaVersion) -or ($configuredData.ObjectVersion -gt $objectVersion)) { New-TestResult @common -Type Update } if ($splitPermissionsEnabled -and -not $configuredData.SplitPermission) { New-TestResult @common -Type DisableSplitP } if (-not $splitPermissionsEnabled -and $configuredData.SplitPermission) { New-TestResult @common -Type EnableSplitP } } } function Unregister-FMExchangeSchema { <# .SYNOPSIS Clears the defined exchange forest configuration from the loaded configuration set. .DESCRIPTION Clears the defined exchange forest configuration from the loaded configuration set. .EXAMPLE PS C:\> Unregister-FMExchangeSchema Clears the defined exchange forest configuration from the loaded configuration set. #> [CmdletBinding()] Param ( ) process { $script:exchangeschema = $null } } function Get-FMForestLevel { <# .SYNOPSIS Returns the defined desired state if configured. .DESCRIPTION Returns the defined desired state if configured. .EXAMPLE PS C:\> Get-FMForestLevel Returns the defined desired state if configured. #> [CmdletBinding()] Param ( ) process { $script:forestLevel } } function Invoke-FMForestLevel { <# .SYNOPSIS Applies the desired forest level if needed. .DESCRIPTION Applies the desired forest level if needed. .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-FMForestLevel -Server contoso.com Raises the forest "contoso.com" to the desired level if needed. #> [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 ForestLevel -Cmdlet $PSCmdlet Set-FMDomainContext @parameters # Must be executed against the Domain Naming Master $forest = Get-ADForest @parameters $parameters.Server = $forest.DomainNamingMaster } process { if (-not $InputObject) { $InputObject = Test-FMForestLevel @parameters } foreach ($testItem in $InputObject) { switch ($testItem.Type) { 'Raise' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMForestLevel.Raise.Level' -ActionStringValues $testItem.Configuration.Level -Target $testItem.ADObject -ScriptBlock { Set-ADForestMode @parameters -ForestMode $testItem.Configuration.DesiredLevel -Identity $testItem.ADObject -ErrorAction Stop -Confirm:$false } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } } } } function Register-FMForestLevel { <# .SYNOPSIS Register a forest functional level as desired state. .DESCRIPTION Register a forest 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-FMForestLevel -Level 2016 Apply the desired forest level of 2016 #> [CmdletBinding()] param ( [ValidateSet('2008R2', '2012', '2012R2', '2016')] [string] $Level, [string] $ContextName = '<Undefined>' ) process { $script:forestlevel = @([PSCustomObject]@{ PSTypeName = 'ForestManagement.Configuration.ForestLevel' Level = $Level ContextName = $ContextName }) } } function Test-FMForestLevel { <# .SYNOPSIS Tests whether the target forest has at least the desired functional level. .DESCRIPTION Tests whether the target forest 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-FMForestLevel -Server contoso.com Tests whether the forest 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 ForestLevel -Cmdlet $PSCmdlet Set-FMDomainContext @parameters } process { $levelValues = @{ '2008R2' = 4 '2012' = 5 '2012R2' = 6 '2016' = 7 } $level = Get-FMForestLevel $desiredLevel = $levelValues[$level.Level] $tempConfiguration = $level | ConvertTo-PSFHashtable $tempConfiguration['DesiredLevel'] = [Microsoft.ActiveDirectory.Management.ADForestMode]$desiredLevel $forest = Get-ADForest @parameters if ($forest.ForestMode -lt $desiredLevel) { $change = New-AdcChange -Property ForestLevel -OldValue $forest.ForestMode -NewValue $level.Level -Type ForestLevel -Identity $forest -ToString { '{0}: {1} -> {2}' -f $this.Identity, $this.Old, $this.New } New-TestResult -ObjectType ForestLevel -Type Raise -Identity $forest -Server $Server -Configuration ([pscustomobject]$tempConfiguration) -ADObject $forest -Changed $change } } } function Unregister-FMForestLevel { <# .SYNOPSIS Removes the domain level configuration if present. .DESCRIPTION Removes the domain level configuration if present. .EXAMPLE PS C:\> Unregister-FMForestLevel Removes the domain level configuration if present. #> [CmdletBinding()] Param ( ) process { $script:forestlevel = $null } } function Get-FMNTAuthStore { <# .SYNOPSIS Returns registered NTAuthStore Certificates. .DESCRIPTION Returns registered NTAuthStore Certificates. .PARAMETER Thumbprint The thumbprint of the certificate to filter by. .PARAMETER Name The name of the certificate to filter by. .EXAMPLE PS C:\> Get-FMNTAuthStore Returns all registered certificates intended for the NTAuthStore #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [string] $Thumbprint = '*', [string] $Name = '*' ) process { ($script:ntAuthStoreCertificates.Values) | Where-Object Thumbprint -like $Thumbprint | Where-Object { $_.Subject -like $Name -or $_.Subject -like "CN=$Name" -or $_.FriendlyName -like $Name } } } function Invoke-FMNTAuthStore { <# .SYNOPSIS Applies the desired certificates to the NTAuth store. .DESCRIPTION Applies the desired certificates to the NTAuth store. This allows distributing certificates that are trusted across the entire forest. .PARAMETER InputObject The test results to apply. Only specify objects returned by Test-FMNTAuthStore. By default, if you do not specify this parameter it will run the test and apply all deltas found. .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-FMNTAuthStore -Server contoso.com Applies the defined NTAuthStore configuration to the contoso.com 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 ntAuthStoreCertificates -Cmdlet $PSCmdlet Set-FMDomainContext @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-FMNTAuthStore.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName return } #region Add Certificate Scriptblock $addCertificateScript = { param ( $Certificate ) $certPath = "$env:temp\cert_$(Get-Random -Minimum 10000 -Maximum 99999).cer" try { $Certificate.GetRawCertData() | Set-Content $certPath -Encoding Byte -ErrorAction Stop } catch { [pscustomobject]@{ Success = $false Stage = 'Writing certificate file' Error = $_ } return } $res = certutil.exe -dspublish -f $certPath NTAuthCA 2>&1 if ($LASTEXITCODE -gt 0) { [pscustomobject]@{ Success = $false Stage = 'Applying certificate using certutil' Error = $res } Remove-Item -Path $certPath -ErrorAction Ignore return } Remove-Item -Path $certPath -ErrorAction Ignore [pscustomobject]@{ Success = $true Stage = 'Done' Error = $null } } #endregion Add Certificate Scriptblock } process { if (Test-PSFFunctionInterrupt) { return } # Test All NTAuthStore Certificates if no specific test result was specified if (-not $InputObject) { $InputObject = Test-FMNTAuthStore @parameters } :main foreach ($testResult in $InputObject) { # Catch invalid input - can only process test results if ($testResult.PSObject.TypeNames -notcontains 'ForestManagement.NTAuthStore.TestResult') { Stop-PSFFunction -String 'Invoke-FMNTAuthStore.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException } switch ($testResult.Type) { 'Add' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMNTAuthStore.Add' -ActionStringValues $testResult.Configuration.Subject -Target $testResult -ScriptBlock { $result = Invoke-Command -Session $session -ArgumentList $testResult.Configuration -ScriptBlock $addCertificateScript if (-not $result.Success) { throw "Error executing $($result.Stage) : $($result.Error)" } $rootDSE = Get-ADRootDSE @parameters $storeObject = Get-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -ErrorAction Stop -Properties cACertificate $storedCertificates = $storeObject.cACertificate | ForEach-Object { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($_) } if ($testResult.Configuration.Thumbprint -notin $storedCertificates.Thumbprint) { throw "Certificate could not be applied successfully for unclarified reasons! Ensure you have the permissions needed for this operation." } } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main } 'Remove' { $rootDSE = Get-ADRootDSE @parameters Invoke-PSFProtectedCommand -ActionString 'Invoke-FMNTAuthStore.Remove' -ActionStringValues $testResult.ADObject.Subject -Target $testResult -ScriptBlock { Set-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -Remove @{ cACertificate = $testResult.ADObject.GetRawCertData() } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main } } } } end { if (Test-PSFFunctionInterrupt) { return } Remove-PSSession -Session $session -Confirm:$false -WhatIf:$false } } function Register-FMNTAuthStore { <# .SYNOPSIS Register NTAuthStore certificates .DESCRIPTION Register NTAuthStore certificates This is the ideal / desired state for the NTAuthStore certificate configuration. Forests will be brought into this state by using Invoke-FMNTAuthStore. .PARAMETER Certificate The certifcate to apply. .PARAMETER Authorative Should the NTAuthStore configuration overwrite the existing configuration, rather than adding to it (default). .EXAMPLE PS C:\> Register-FMNTAuthStore -Certificate $NTAuthStoreCertificate Register a certiciate. .EXAMPLE PS C:\> Register-FMNTAuthStore -Authorative Sets our current configuration as authorative, removing all non-listed certificates from the store. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Certificate")] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Authorative")] [switch] $Authorative ) process { switch ($PSCmdlet.ParameterSetName) { Certificate { $script:ntAuthStoreCertificates[$Certificate.Thumbprint] = $Certificate } Authorative { $script:ntAuthStoreAuthorative = $Authorative.ToBool() } } } } function Test-FMNTAuthStore { <# .SYNOPSIS Tests, whether the NTAuthStore is in the desired state. .DESCRIPTION Tests, whether the NTAuthStore is in the desired state, that is, all defined certificates are already in place. Use Register-FMNTAuthStore to define desired the desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMNTAuthStore -Server contoso.com Checks whether the contoso.com forest has all the NTAuth certificates it should #> [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 ntAuthStoreCertificates -Cmdlet $PSCmdlet Set-FMDomainContext @parameters #region Utility Functions function New-TestResult { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Type, [Parameter(Mandatory = $true)] [string] $Identity, [object[]] $Changed, [Parameter(Mandatory = $true)] [AllowNull()] [PSFComputer] $Server, $Configuration, $ADObject ) process { $object = [PSCustomObject]@{ PSTypeName = "ForestManagement.NTAuthStore.TestResult" Type = $Type ObjectType = "NTAuthStore" Identity = $Identity Changed = $Changed Server = $Server Configuration = $Configuration ADObject = $ADObject } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { $this.Identity } -Force $object } } #endregion Utility Functions $rootDSE = Get-ADRootDSE @parameters $storeObject = $null $storedCertificates = $null try { $storeObject = Get-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -ErrorAction Stop -Properties cACertificate $storedCertificates = $storeObject.cACertificate | ForEach-Object { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($_) } $hasStore = $storeObject -as [bool] } catch { $hasStore = $false } } process { $resDefault = @{ Server = $Server } $configuredCertificates = Get-FMNTAuthStore foreach ($configuredCertificate in $configuredCertificates) { if ($storeObject) { $resDefault.ADObject = $storeObject } if (-not $hasStore) { New-TestResult @resDefault -Type 'Add' -Identity $configuredCertificate.Thumbprint -Configuration $configuredCertificate continue } if ($configuredCertificate.Thumbprint -notin $storedCertificates.Thumbprint) { New-TestResult @resDefault -Type 'Add' -Identity $configuredCertificate.Thumbprint -Configuration $configuredCertificate continue } } if (-not $hasStore) { return } if (-not $script:ntAuthStoreAuthorative) { return } $resDefault = @{ Server = $Server } foreach ($storedCertificate in $storedCertificates) { if ($storedCertificate.Thumbprint -notin $configuredCertificates.Thumbprint) { New-TestResult @resDefault -Type 'Remove' -Identity $storedCertificate.Thumbprint -ADObject $storedCertificate } } } } function Unregister-FMNTAuthStore { <# .SYNOPSIS Removes a certificate definition for the NTAuthStore. .DESCRIPTION Removes a certificate definition for the NTAuthStore. See Register-FMNTAuthStore tfor details on defining a certificate. .PARAMETER Thumbprint The thumbprint of the certificate to remove. .EXAMPLE PS C:\> Get-FMNTAuthStore | Unregister-FMNTAuthStore Clears all certificates from the list of defined NTAuth certificates #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Thumbprint ) process { foreach ($thumbprintString in $Thumbprint) { $script:ntAuthStoreCertificates.Remove($thumbprintString) } } } function Get-FMSchema { <# .SYNOPSIS Returns the list of registered Schema Extensions. .DESCRIPTION Returns the list of registered Schema Extensions. .PARAMETER Name Name to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-FMSchema Returns a list of all schema extensions #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { ($script:schema.Values | Where-Object AdminDisplayName -Like $Name) } } function Invoke-FMSchema { <# .SYNOPSIS Updates the schema to conform to the desired state. .DESCRIPTION Updates the schema to conform to the desired state. Can add new attributes and update existing ones. Use Register-FMSchema to define the desired state. Use the module's configuration settings to govern schema admin credentials. The configuration can be read with Get-PSFConfig and updated with Set-PSFConfig. .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-FMSchema Updates the schema of the current forest according to the configured settings #> [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 Schema -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster $removeParameters = $parameters.Clone() #region Resolve Credentials $cred = $null if (Test-SchemaAdminCredential) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock { [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1 if ($cred) { $parameters['Credential'] = $cred } } -EnableException $EnableException -PSCmdlet $PSCmdlet if (Test-PSFFunctionInterrupt) { return } } $null = Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Credentials.Test' -Target $forest.SchemaMaster -ScriptBlock { $null = Get-ADDomain @parameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -RetryCount 5 -RetryWait 1 if (Test-PSFFunctionInterrupt) { return } #endregion Resolve Credentials # Prepare parameters to use for when discarding the schema credentials if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred } } process { if (Test-PSFFunctionInterrupt) { return } if (-not $InputObject) { $InputObject = Test-FMSchema @parameters } $testResultsSorted = $InputObject | Sort-Object { if ($_.Type -eq 'Decommission') { 0 } elseif ($_.Type -eq 'Rename') { 2 } elseif ($_.Type -eq 'ConfigurationOnly') { 3 } else { 1 } } :main foreach ($testItem in $testResultsSorted) { switch ($testItem.Type) { #region Create new Schema Attribute 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Creating.Attribute' -Target $testItem.Identity -ScriptBlock { New-ADObject @parameters -Type attributeSchema -Name $testItem.Configuration.AdminDisplayName -Path $rootDSE.schemaNamingContext -OtherAttributes (Resolve-SchemaAttribute -Configuration $testItem.Configuration) -ErrorAction Stop Update-Schema @parameters } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue foreach ($class in $testItem.Configuration.MayBeContainedIn) { try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class,$testItem.Identity -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.Configuration.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -RetryCount 10 } foreach ($class in $testItem.Configuration.MustBeContainedIn) { try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class,$testItem.Identity -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Add @{ mustContain = $testItem.Configuration.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -RetryCount 10 } } #endregion Create new Schema Attribute #region Decommission the unwanted Schema Attribute 'Decommission' { $values = @{ IsDefunct = $true # PartialAttributeSet = $false } foreach ($adObject in (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(mayContain=$($testItem.Configuration.OID))" -Properties ldapDisplayName)) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.MayContain' -ActionStringValues $testItem.ADObject.LdapDisplayName, $adObject.LdapDisplayName -Target $testItem -ScriptBlock { $adObject | Set-ADObject @parameters -Remove @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } foreach ($adObject in (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(mustContain=$($testItem.Configuration.OID))" -Properties ldapDisplayName)) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.MustContain' -ActionStringValues $testItem.ADObject.LdapDisplayName, $adObject.LdapDisplayName -Target $testItem -ScriptBlock { $adObject | Set-ADObject @parameters -Remove @{ mustContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.Attribute' -ActionStringValues $testItem.ADObject.LdapDisplayName, $testItem.ADObject.AttributeID -Target $testItem -ScriptBlock { $testItem.ADObject | Set-ADObject @parameters -Replace $values -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue $rootDSE = Get-ADRootDSE @parameters } #endregion Decommission the unwanted Schema Attribute #region Update Schema Attribute 'Update' { $resolvedAttributes = Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject -Changes $testItem.Changed if ($resolvedAttributes.Keys.Count -ge 1) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Updating.Attribute' -ActionStringValues ($resolvedAttributes.Keys -join ', ') -Target $testItem.Identity -ScriptBlock { $testItem.ADObject | Set-ADObject @parameters -Replace $resolvedAttributes -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } # Do not process MayContain or MustContain for defunct attributes if ($testItem.Configuration.IsDefunct) { continue } # Only proceed if any Object Class changes are intended $changes = $testItem.Changed | Where-Object Property -in 'MayContain','MustContain' if (-not $changes) { continue } foreach ($change in $changes) { $property = 'mayContain' if ($change.Property -eq 'MustContain') { $property = 'mustContain' } foreach ($class in $change.New | Where-Object { $_ -notin $change.Old }) { if (-not $class) { continue } try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop -Properties $property } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } if ($classObject.$property -notcontains $testItem.ADObject.LdapDisplayName) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class, $testItem.Identity -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Add @{ $property = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } foreach ($class in $change.Old | Where-Object { $_ -notin $change.New }) { if (-not $class) { continue } try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop -Properties $property } catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ } if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue } if ($classObject.$property -contains $testItem.ADObject.LdapDisplayName) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Removing.Attribute.FromObjectClass' -ActionStringValues $class, $testItem.Identity -Target $testItem.Identity -ScriptBlock { $classObject | Set-ADObject @parameters -Remove @{ $property = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } } } } #endregion Update Schema Attribute #region Rename Schema Attribute 'Rename' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Rename.Attribute' -ActionStringValues $testItem.ADObject.cn, $testItem.Configuration.Name -Target $testItem -ScriptBlock { $testItem.ADObject | Rename-ADObject @parameters -NewName $testItem.Configuration.Name -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue } #endregion Rename Schema Attribute } } } end { if (Test-PSFFunctionInterrupt) { return } if (Test-SchemaAdminCredential) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock { $null = Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet } } } function Register-FMSchema { <# .SYNOPSIS Registers a schema extension attribute. .DESCRIPTION Registers a schema extension attribute. These registered attributes will be applied / updated as needed when running Invoke-FMSchema. Use Test-FMSchema to verify, whether a forest is properly configured. .PARAMETER ObjectClass The class to assign the new attribute to. .PARAMETER MayBeContainedIn The classes which may contain this attribute. .PARAMETER MustBeContainedIn The classes which MUST contain this attribute. .PARAMETER OID The unique OID of the attribute. .PARAMETER AdminDisplayName The displayname of the attribute as admins see it. .PARAMETER LdapDisplayName The name of the attribute as LDAP sees it. .PARAMETER Name The name of the attribute. Defaults to the AdminDisplayName if not specified. .PARAMETER OMSyntax The OM Syntax of the attribute .PARAMETER AttributeSyntax The syntax rules of the attribute. .PARAMETER SingleValued Whether the attribute is singlevalued. .PARAMETER AdminDescription The human friendly description of the attribute. .PARAMETER SearchFlags The search flags for the attribute. .PARAMETER PartialAttributeSet Whether the attribute is part of a partial attribute set. .PARAMETER AdvancedView Whether this attribute is only shown in advanced view. Use this to hide it from the default display, used to simplify display by hiding information not needed for regulaar daily tasks. .PARAMETER IsDefunct Flag this attribute as defunct. It will be marked as such in AD, be delisted from the Global Catalog and removed from all its supposed memberships. .PARAMETER Optional By default, all defined schema attributes must exist. By setting a schema attribute optional, it will be tolerated if it exists, but not created if it does 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 .\schema.json | ConvertFrom-Json | Write-Output | Register-FMSchema Registers all extension attributes in the json file as schema settings to apply when running Invoke-FMSchema. #> [CmdletBinding(DefaultParameterSetName = 'Contained')] Param ( [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ObjectClass')] [AllowEmptyCollection()] [string[]] $ObjectClass, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Contained')] [AllowEmptyCollection()] [string[]] $MayBeContainedIn, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Contained')] [AllowEmptyCollection()] [string[]] $MustBeContainedIn, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $OID, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $AdminDisplayName, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $LdapDisplayName, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(ValueFromPipelineByPropertyName = $true)] [int] $OMSyntax, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $AttributeSyntax, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $SingleValued, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $AdminDescription, [Parameter(ValueFromPipelineByPropertyName = $true)] [int] $SearchFlags, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $PartialAttributeSet, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $AdvancedView, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $IsDefunct, [Parameter(ValueFromPipelineByPropertyName = $true)] [bool] $Optional, [string] $ContextName = '<Undefined>' ) process { $nameResult = $Name if (-not $Name) { $nameResult = $AdminDisplayName } $hashtable = $PSBoundParameters | ConvertTo-PSFHashtable $hashtable.ContextName = $ContextName $hashtable.PSTypeName = 'ForestManagement.Schema.Configuration' if ($nameResult) { $hashtable.Name = $nameResult } if ($PSBoundParameters.Keys -contains 'ObjectClass') { $hashtable.Remove('ObjectClass') $hashtable.MayBeContainedIn = $ObjectClass } $script:schema[$OID] = [PSCustomObject]$hashtable } } function Test-FMSchema { <# .SYNOPSIS Compare the current schema with the configured / desired configuration state. .DESCRIPTION Compare the current schema with the configured / desired configuration state. Only compares the custom configured settings, ignores any changes outside. (So it's not a delta comparison to the AD baseline) .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-FMSchema Tests the current domain's schema 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 Schema -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster #region Display Code $mayContainToString = { $updates = do { $this.New | Where-Object { $_ -notin @($this.Old) } | Format-String '+{0}' $this.Old | Where-Object { $_ -notin @($this.New) } | Format-String '-{0}' } until ($true) 'mayContain: {0}' -f ($updates -join ', ') } $mustContainToString = { $updates = do { $this.New | Where-Object { $_ -notin @($this.Old) } | Format-String '+{0}' $this.Old | Where-Object { $_ -notin @($this.New) } | Format-String '-{0}' } until ($true) 'mustContain: {0}' -f ($updates -join ', ') } #endregion Display Code } process { # Pick up termination flag from Stop-PSFFunction and interrupt if begin failed to connect if (Test-PSFFunctionInterrupt) { return } foreach ($schemaSetting in (Get-FMSchema)) { $schemaObject = $null $schemaObject = Get-ADObject @parameters -LDAPFilter "(attributeID=$($schemaSetting.OID))" -SearchBase $rootDSE.schemaNamingContext -ErrorAction Ignore -Properties * if (-not $schemaObject) { # If we already want to disable the attribute, no need to create it if ($schemaSetting.IsDefunct) { continue } if ($schemaSetting.Optional) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'Create' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = $null Server = $forest.SchemaMaster ADObject = $null Configuration = $schemaSetting } continue } if ($schemaSetting.IsDefunct -and -not $schemaObject.isDefunct) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'Decommission' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = @('IsDefunct') Server = $forest.SchemaMaster ADObject = $schemaObject Configuration = $schemaSetting } } if ($schemaSetting.Name -cne $schemaObject.cn) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'Rename' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = @('Name') Server = $forest.SchemaMaster ADObject = $schemaObject Configuration = $schemaSetting } } $changes = [System.Collections.ArrayList]@() $param = @{ Configuration = $schemaSetting ADObject = $schemaObject CaseSensitive = $true IfExists = $true AsUpdate = $true Changes = $changes Type = 'Schema' } Compare-AdcProperty @param -Property oMSyntax Compare-AdcProperty @param -Property attributeSyntax Compare-AdcProperty @param -Property SingleValued -ADProperty isSingleValued Compare-AdcProperty @param -Property adminDescription -AsString Compare-AdcProperty @param -Property adminDisplayName Compare-AdcProperty @param -Property ldapDisplayName Compare-AdcProperty @param -Property searchflags Compare-AdcProperty @param -Property PartialAttributeSet -ADProperty isMemberOfPartialAttributeSet Compare-AdcProperty @param -Property AdvancedView -ADProperty showInAdvancedViewOnly if (-not $schemaSetting.IsDefunct -and $schemaObject.isDefunct) { Compare-AdcProperty @param -Property isDefunct } if (-not $schemaSetting.IsDefunct -and $schemaSetting.PSObject.Properties.Name -contains 'MayBeContainedIn') { $mayContain = Get-ADObject @parameters -LDAPFilter "(mayContain=$($schemaSetting.LdapDisplayName))" -SearchBase $rootDSE.schemaNamingContext if (-not $mayContain -and $schemaSetting.MayBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MayContain -NewValue $schemaSetting.MayBeContainedIn -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mayContainToString)) } elseif ($mayContain.Name -and -not $schemaSetting.MayBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MayContain -OldValue $mayContain.Name -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mayContainToString)) } elseif (-not $mayContain.Name -and -not $schemaSetting.MayBeContainedIn) { # Nothing wrong here } elseif ($mayContain.Name | Compare-Object $schemaSetting.MayBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MayContain -OldValue $mayContain.Name -NewValue $schemaSetting.MayBeContainedIn -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mayContainToString)) } } if (-not $schemaSetting.IsDefunct -and $schemaSetting.PSObject.Properties.Name -contains 'MustBeContainedIn') { $mustContain = Get-ADObject @parameters -LDAPFilter "(mustContain=$($schemaSetting.LdapDisplayName))" -SearchBase $rootDSE.schemaNamingContext if (-not $mustContain -and $schemaSetting.MustBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MustContain -NewValue $schemaSetting.MustBeContainedIn -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mustContainToString)) } elseif ($mustContain.Name -and -not $schemaSetting.MustBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MustContain -OldValue $mustContain.Name -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mustContainToString)) } elseif (-not $mustContain.Name -and -not $schemaSetting.MustBeContainedIn) { # Nothing wrong here } elseif ($mustContain.Name | Compare-Object $schemaSetting.MustBeContainedIn) { $null = $changes.Add((New-AdcChange -Property MustContain -OldValue $mustContain.Name -NewValue $schemaSetting.MustBeContainedIn -Identity $schemaObject.DistinguishedName -Type Schema -ToString $mustContainToString)) } } if ($changes.Count -gt 0) { [PSCustomObject]@{ PSTypeName = 'ForestManagement.Schema.TestResult' Type = 'Update' ObjectType = 'Schema' Identity = $schemaSetting.AdminDisplayName Changed = $changes.ToArray() Server = $forest.SchemaMaster ADObject = $schemaObject Configuration = $schemaSetting } } } } } function Unregister-FMSchema { <# .SYNOPSIS Removes a configured schema extension. .DESCRIPTION Removes a configured schema extension. .PARAMETER Name Name(s) of the schema extensions to unregister. .EXAMPLE PS C:\> Unregister-FMSchema -Name $names Removes the list of names stored in $names from the registered schema extension configurations. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('AdminDisplayName')] [string[]] $Name ) process { foreach ($nameLabel in $Name) { $script:schema.Remove($nameLabel) } } } function Get-FMSchemaDefaultPermission { <# .SYNOPSIS Returns the list of registered default schema permissions. .DESCRIPTION Returns the list of registered default schema permissions. .PARAMETER ClassName The name of the affected objectclass to filter by. Defaults to '*'. .EXAMPLE PS C:\> Get-FMSchemaDefaultPermission Returns the list of all registered default schema permissions. #> [CmdletBinding()] param ( [string] $ClassName = '*' ) process { foreach ($key in $script:schemaDefaultPermissions.Keys) { if ($key -notlike $ClassName) { continue } $script:schemaDefaultPermissions[$key].Values } } } function Invoke-FMSchemaDefaultPermission { <# .SYNOPSIS Brings the target forest into compliance with the defined default permissions in its schema. .DESCRIPTION Brings the target forest into compliance with the defined default permissions in its schema. Use the module's configuration settings to govern schema admin credentials. The configuration can be read with Get-PSFConfig and updated with Set-PSFConfig. .PARAMETER InputObject Test results from Test-FMSchemaDefaultPermission to apply. .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-FMSchemaDefaultPermission -Server contoso.com Brings the contoso.com forest into compliance with the defined default permissions in its schema. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] Param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { #region Utility Functions function Add-AccessRule { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [CmdletBinding()] param ( $Change, $Session, [Hashtable] $Tracking ) Invoke-Command -Session $Session -ArgumentList $Change -ScriptBlock { param ($Change) $rule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule( [System.Security.Principal.SecurityIdentifier]$Change.Configuration.Principal, $Change.Configuration.ActiveDirectoryRights, $Change.Configuration.AccessControlType, $Change.Configuration.ObjectTypeGuid, $Change.Configuration.InheritanceType, $Change.Configuration.InheritedObjectTypeGuid ) $null = $acl.AddAccessRule($rule) } -ErrorAction Stop $Tracking[$Change] = $Change } function Remove-AccessRule { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $Change, $Session, [Hashtable] $Tracking ) Invoke-Command -Session $Session -ArgumentList $Change -ScriptBlock { param ($Change) $rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) foreach ($rule in $rules) { if ($rule.ActiveDirectoryRights -ne $Change.ADObject.ActiveDirectoryRights) { continue } if ($rule.InheritanceType -ne $Change.ADObject.InheritanceType) { continue } if ($rule.ObjectType -ne $Change.ADObject.ObjectType) { continue } if ($rule.InheritedObjectType -ne $Change.ADObject.InheritedObjectType) { continue } if ($rule.AccessControlType -ne $Change.ADObject.AccessControlType) { continue } if ("$($rule.IdentityReference)" -ne "$($Change.ADObject.IdentityReference)") { continue } $null = $acl.RemoveAccessRule($rule) } } -ErrorAction Stop $Tracking[$Change] = $Change } function Write-SchemaDefaultPermission { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")] [CmdletBinding()] param ( $Session, [hashtable] $SchemaParameters ) $newSddl, $schemaObjectDN = Invoke-Command -Session $Session -ScriptBlock { $acl.Sddl, $schemaObject.DistinguishedName } Set-ADObject @SchemaParameters -Identity $schemaObjectDN -Replace @{ defaultSecurityDescriptor = $newSddl } -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 SchemaDefaultPermissions -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchemaDefaultPermission.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster #region WinRM Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.WinRM.Connect' -Target $forest.SchemaMaster -ScriptBlock { $psParameters = $parameters.Clone() $psParameters.Remove('Server') $psParameters.ComputerName = $forest.SchemaMaster $session = New-PSSession @psParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -WhatIf:$false -Confirm:$false #endregion WinRM #region Resolve Credentials $cred = $null $schemaParameters = $parameters.Clone() Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock { [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1 if ($cred) { $schemaParameters['Credential'] = $cred } } -EnableException $EnableException -PSCmdlet $PSCmdlet if (Test-PSFFunctionInterrupt) { return } $null = Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Credentials.Test' -Target $forest.SchemaMaster -ScriptBlock { $null = Get-ADDomain @schemaParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -RetryCount 5 -RetryWait 1 if (Test-PSFFunctionInterrupt) { return } #endregion Resolve Credentials } process { if (Test-PSFFunctionInterrupt) { return } if (-not $InputObject) { $InputObject = Test-FMSchemaDefaultPermission @parameters -EnableException:$EnableException } foreach ($testItem in $InputObject) { # Catch invalid input - can only process test results if ($testItem.PSObject.TypeNames -notcontains 'ForestManagement.SchemaDefaultPermission.TestResult') { Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-FMSchemaDefaultPermission', $testItem -Target $testItem -Continue -EnableException $EnableException } switch ($testItem.Type) { 'Update' { #region Load Acl from SDDL Invoke-Command -Session $session -ArgumentList $rootDSE.schemaNamingContext, $testItem.Identity -ScriptBlock { param ($SchemaNC, $ClassName) $schemaObject = Get-ADObject -Server localhost -SearchBase $SchemaNC -LDAPFilter "(name=$ClassName)" -Properties defaultSecurityDescriptor $acl = New-Object System.DirectoryServices.ActiveDirectorySecurity $acl.SetSecurityDescriptorSddlForm($schemaObject.defaultSecurityDescriptor) } #endregion Load Acl from SDDL #region Apply individual changes to in-memory ACL $tracking = @{ } # Apply remove changes foreach ($change in $testItem.Changed) { if ($change.Type -ne 'Remove') { continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.AccessRule.Remove' -ActionStringValues $change.Identity, $change.Privilege, $change.Access -Target $testItem -ScriptBlock { Remove-AccessRule -Change $change -Session $session -Tracking $tracking -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke } # Apply add changes foreach ($change in $testItem.Changed) { if ($change.Type -ne 'Add') { continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.AccessRule.Add' -ActionStringValues $change.Identity, $change.Privilege, $change.Access -Target $testItem -ScriptBlock { Add-AccessRule -Change $change -Session $session -Tracking $tracking -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke } #endregion Apply individual changes to in-memory ACL # Write SDDL back to schema object Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Permissions.Update' -ActionStringValues $tracking.Count, $testItem.Changed.Count, $testItem.Identity -Target $testItem -ScriptBlock { Write-SchemaDefaultPermission -Session $session -SchemaParameters $schemaParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke } 'NotFound' { Write-PSFMessage -Level Warning -String 'Invoke-FMSchemaDefaultPermission.NotFound' -StringValues $testItem.Identity -Target $testItem } 'IdentityError' { Write-PSFMessage -Level Warning -String 'Invoke-FMSchemaDefaultPermission.IdentityError' -StringValues $testItem.Identity -Target $testItem } } } } end { if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -WhatIf:$false -Confirm:$false } if (Test-PSFFunctionInterrupt) { return } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock { $null = Remove-SchemaAdminCredential @parameters -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } function Register-FMSchemaDefaultPermission { <# .SYNOPSIS Registers a new desired schema default permission access rule. .DESCRIPTION Registers a new desired schema default permission access rule. These access rules are then used / applied when when creating a new object of the class affected. These settings apply only to new objects created of the affected class, not already existing ones. Using this you could for example add a group to have full control over all newly created group policy objects. .PARAMETER ClassName The name of the object class in schema this applies to. .PARAMETER Identity The principal to which the access rule applies. Supports limited string resolution. .PARAMETER ActiveDirectoryRights The rights granted. .PARAMETER AccessControlType Allow or Deny? Defaults to: Allow .PARAMETER InheritanceType How is this privilege inherited by child objects? .PARAMETER ObjectType What object types does this permission apply to? .PARAMETER InheritedObjectType What object types does this permission apply to? Used for extended properties. .PARAMETER Mode How access rules are actually applied: - Additive: Only add new access rules, but do not touch existing ones - Defined: Add new access rules, remove access rules not defined in configuration that apply to a principal that has access rules defined. - Constrained: Add new access rules, remove all access rules not defined in configuration All Modes of all settings for a given class are used when determining the effective Mode applied to that class. The most restrictive Mode applies. .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 .\sdp.json | ConvertFrom-Json | Write-Output | Register-FMSchemaDefaultPermission Loads all entries from the specified json file and registers them. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ClassName, [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)] [ValidateSet('Constrained', 'Defined', 'Additive')] [string] $Mode, [Parameter(ValueFromPipelineByPropertyName = $true)] [string] $ContextName = '<Undefined>' ) process { if (-not $script:schemaDefaultPermissions[$ClassName]) { $script:schemaDefaultPermissions[$ClassName] = @{ } } $script:schemaDefaultPermissions[$ClassName]["$($Identity)þ$($ActiveDirectoryRights)þ$($ObjectType)þ$($InheritedObjectType)þ$($InheritanceType)þ$($AccessControlType)"] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SchemaDefaultPermission.Configuration' ClassName = $ClassName Identity = $Identity ActiveDirectoryRights = $ActiveDirectoryRights AccessControlType = $AccessControlType InheritanceType = $InheritanceType ObjectType = $ObjectType InheritedObjectType = $InheritedObjectType Mode = $Mode ContextName = $ContextName } } } function Test-FMSchemaDefaultPermission { <# .SYNOPSIS Validates, whether the target forest has the defined default permissions applied in its schema. .DESCRIPTION Validates, whether the target forest has the defined default permissions applied in its schema. Returns a list of all actions that would be taken by the associated Invoke-* command. .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-FMSchemaDefaultPermission -Server contoso.com Validates, whether the contoso.com forest has the defined default permissions applied in its schema. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] [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 SchemaDefaultPermissions -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-FMSchemaDefaultPermission.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $forest = Get-ADForest @parameters $parameters["Server"] = $forest.SchemaMaster Invoke-PSFProtectedCommand -ActionString 'Test-FMSchemaDefaultPermission.WinRM.Connect' -Target $forest.SchemaMaster -ScriptBlock { $psParameters = $parameters.Clone() $psParameters.Remove('Server') $psParameters.ComputerName = $forest.SchemaMaster $session = New-PSSession @psParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet -WhatIf:$false -Confirm:$false #region Default Permissions Scriptblock $defaultPermissionScriptblock = { param ( $ClassName, $SchemaNC ) $object = Get-ADObject -LDAPFilter "(name=$ClassName)" -SearchBase $SchemaNC -Server localhost -Properties defaultSecurityDescriptor if (-not $object) { throw "Object class '$ClassName' not found!" } $acl = New-Object System.DirectoryServices.ActiveDirectorySecurity $acl.SetSecurityDescriptorSddlForm($object.defaultSecurityDescriptor) $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) } #endregion Default Permissions Scriptblock #region Utility Functions function Convert-ConfiguredAccessRule { [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] $AccessRule, [System.Collections.Hashtable] $Parameters ) process { $basicHash = $AccessRule | ConvertTo-PSFHashtable $basicHash.IdentityResolved = $true $basicHash.Error = $null $basicHash.ObjectTypeGuid = Convert-DMSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType GuidString $basicHash.ObjectTypeName = Convert-DMSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType Name $basicHash.InheritedObjectTypeGuid = Convert-DMSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType GuidString $basicHash.InheritedObjectTypeName = Convert-DMSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType Name # Namensauflösung $basicHash.ResolvedIdentity = $AccessRule.Identity | Resolve-String -Mode Lax -ArgumentList $Parameters # Principal Auflösung try { $basicHash.Principal = [string]($basicHash.ResolvedIdentity | Resolve-Principal -OutputType SID @Parameters -ErrorAction Stop) } catch { Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Principal.ResolutionError' -StringValues $AccessRule.Identity, $basicHash.ResolvedIdentity -Target $AccessRule $basicHash.IdentityResolved = $false $basicHash.Error = $_ } [pscustomobject]$basicHash } } function Compare-AccessRule { [CmdletBinding()] param ( $Configuration, $Applied, [PSFComputer] $Server, [Hashtable] $Parameters ) #region Utility Functions function New-Change { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateSet('Add', 'Remove')] [string] $Type, $Configuration, $ADObject, [string] $ClassName, [hashtable] $Parameters ) $object = [pscustomobject]@{ PSTypeName = 'ForestManagement.SchemaDefaultPermission.Change' Type = $Type Identity = $Configuration.ResolvedIdentity Privilege = $Configuration.ActiveDirectoryRights Access = $Configuration.AccessControlType -as [string] Configuration = $Configuration ADObject = $ADObject ClassName = $ClassName } if ($ADObject) { $object.Identity = $ADObject.IdentityReference if (($ADObject.IdentityReference -as [System.Security.Principal.SecurityIdentifier]).AccountDomainSid) { try { $object.Identity = $ADObject.IdentityReference | Resolve-Principal @Parameters -ErrorAction Stop -OutputType NTAccount } catch { } # No Action Needed } $object.Privilege = $ADObject.ActiveDirectoryRights $object.Access = $ADObject.AccessControlType -as [string] } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Force -Value { '{0}: {1}>{2}({3})' -f $this.Type, $this.Identity, $this.Privilege, $this.Access.SubString(0, 1) } -PassThru } #endregion Utility Functions #region Process ProcessingMode $processingMode = 'Additive' if ($Configuration.Mode -contains 'Defined') { $processingMode = 'Defined' } if ($Configuration.Mode -contains 'Constrained') { $processingMode = 'Constrained' } if ($processingMode -eq 'Constrained' -and ($Configuration | Where-Object IdentityResolved -EQ $false)) { Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Class.IdentityUncertain' -StringValues $Configuration[0].ClassName -Target $Configuration[0].ClassName -Data @{ Configured = $Configuration Applied = $Applied } New-TestResult -ObjectType SchemaDefaultPermission -Type IdentityError -Identity $Configuration[0].ClassName -Server $Server -Configuration $Configuration -ADObject $Applied return } #endregion Process ProcessingMode $changes = @() $matchedRules = @() #region Check configured rules against applied rules :outer foreach ($ruleDefinition in $Configuration) { if (-not $ruleDefinition.IdentityResolved) { continue } foreach ($appliedRule in $Applied) { if ($ruleDefinition.ActiveDirectoryRights -ne $appliedRule.ActiveDirectoryRights) { continue } if ($ruleDefinition.InheritanceType -ne $appliedRule.InheritanceType) { continue } if ($ruleDefinition.ObjectTypeGuid -ne $appliedRule.ObjectType) { continue } if ($ruleDefinition.InheritedObjectTypeGuid -ne $appliedRule.InheritedObjectType) { continue } if ($ruleDefinition.AccessControlType -ne $appliedRule.AccessControlType) { continue } if ($ruleDefinition.Principal -ne $appliedRule.IdentityReference) { continue } # Existing rule is a match $matchedRules += $appliedRule continue outer } $changes += New-Change -Type Add -Configuration $ruleDefinition -ClassName $Configuration[0].ClassName -Parameters $Parameters } #endregion Check configured rules against applied rules #region Check applied rules against configured rules foreach ($appliedRule in $Applied) { if ($processingMode -eq 'Additive') { break } if ($appliedRule -in $matchedRules) { continue } if ($processingMode -eq 'Defined' -and $Configuration.Principal -notcontains $Applied.Identity) { continue } $changes += New-Change -Type Remove -ADObject $appliedRule -ClassName $Configuration[0].ClassName -Parameters $Parameters } #endregion Check applied rules against configured rules if ($changes) { New-TestResult -ObjectType SchemaDefaultPermission -Type Update -Identity $Configuration[0].ClassName -Server $Server -Configuration $Configuration -ADObject $Applied -Changed $changes } } #endregion Utility Functions } process { if (Test-PSFFunctionInterrupt) { return } foreach ($className in $script:schemaDefaultPermissions.Keys) { $definedAccessRules = $script:schemaDefaultPermissions[$className].Values | Convert-ConfiguredAccessRule -Parameters $parameters try { $actualAccessRules = Invoke-Command -Session $session -ScriptBlock $defaultPermissionScriptblock -ArgumentList $className, $rootDSE.schemaNamingContext } catch { Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Class.NotFound' -StringValues $className -Target $className New-TestResult -ObjectType SchemaDefaultPermission -Type NotFound -Identity $className -Server $Server -Configuration $definedAccessRules continue } Compare-AccessRule -Configuration $definedAccessRules -Applied $actualAccessRules -Server $Server -Parameters $parameters } } end { if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -WhatIf:$false -Confirm:$false } } } function Unregister-FMSchemaDefaultPermission { <# .SYNOPSIS Removes schema default permissions from the list of registered configurationsets. .DESCRIPTION Removes schema default permissions from the list of registered configurationsets. .PARAMETER ClassName The name of the object class in schema this applies to. .PARAMETER Identity The principal to which the access rule applies. .PARAMETER ActiveDirectoryRights The rights granted. .PARAMETER AccessControlType Allow or Deny? .PARAMETER InheritanceType How is this privilege inherited by child objects? .PARAMETER ObjectType What object types does this permission apply to? .PARAMETER InheritedObjectType What object types does this permission apply to? Used for extended properties. .EXAMPLE PS C:\> Get-FMSchemaDefaultPermission | Unregister-FMSchemaDefaultPermission Clear all configured default schema permissions. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ClassName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Identity, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ActiveDirectoryRights, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [System.Security.AccessControl.AccessControlType] $AccessControlType, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [System.DirectoryServices.ActiveDirectorySecurityInheritance] $InheritanceType, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $ObjectType, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $InheritedObjectType ) process { if (-not $script:schemaDefaultPermissions[$ClassName]) { return } $script:schemaDefaultPermissions[$ClassName].Remove("$($Identity)þ$($ActiveDirectoryRights)þ$($ObjectType)þ$($InheritedObjectType)þ$($InheritanceType)þ$($AccessControlType)") if ($script:schemaDefaultPermissions[$ClassName].Count -lt 1) { $script:schemaDefaultPermissions.Remove($ClassName) } } } function Get-FMSchemaLdif { <# .SYNOPSIS Returns the registered schema ldif files. .DESCRIPTION Returns the registered schema ldif files. .PARAMETER Name The name to filter byy. .EXAMPLE PS C:\> Get-FMSchemaLdif List all registered ldif files. #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { ($script:schemaLdif.Values | Where-Object Name -Like $Name) } } function Invoke-FMSchemaLdif { <# .SYNOPSIS Applies missing LDIF files to a forest's schema. .DESCRIPTION Applies missing LDIF files to a forest's schema. .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-FMSchemaLdif Tests the configured LDIF schema files and applies all still missing updates. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { #region Resolve Schema Master $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SchemaLdif -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $forest = Get-ADForest @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Invoke-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $parameters["Server"] = $forest.SchemaMaster $removeParameters = $parameters.Clone() #endregion Resolve Schema Master #region Resolve Credentials $cred = $null if (Test-SchemaAdminCredential) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock { [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1 if ($cred) { $parameters['Credential'] = $cred } } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet if (Test-PSFFunctionInterrupt) { return } } #endregion Resolve Credentials # Prepare parameters to use for when discarding the schema credentials if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred } } process { if (Test-PSFFunctionInterrupt) { return } if (-not $InputObject) { $InputObject = Test-FMSchemaLdif @parameters -EnableException:$EnableException } foreach ($testItem in $InputObject) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Invoke.File' -ActionStringValues $testItem.Identity -Target $forest.SchemaMaster -ScriptBlock { Invoke-LdifFile @parameters -Path $testItem.Configuration.Path -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue } } end { if (Test-PSFFunctionInterrupt) { return } if (Test-SchemaAdminCredential) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock { Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop } -EnableException $EnableException -PSCmdlet $PSCmdlet } } } function Register-FMSchemaLdif { <# .SYNOPSIS Registers an ldif file for validation and application. .DESCRIPTION Registers an ldif file for validation and application. .PARAMETER Name The name to register the file under. .PARAMETER Path The path to the file to register. .PARAMETER Weight Ldif files will be applied in a certain order. The weight of an Ldif file determines, the order it is applied in. The lower the number, the earlier the file will be applied. Default: 50 .PARAMETER MissingObjectExemption Testing in a forest will cause it to complain about all objects the ldif file tries to modify, not create and doesn't exist. Using this parameter you can exempt individual classes from triggering this warning. .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-FMSchemaLdif -Name Skype -Path "$PSScriptRoot\skype.ldif" Registers the Skype for Business schema extensions. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Name, [Parameter(Mandatory = $true)] [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')] [string] $Path, [int] $Weight = 50, [string[]] $MissingObjectExemption, [string] $ContextName = '<Undefined>' ) begin { $resolvedPath = Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem } process { $script:schemaLdif[$Name] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SchemaLdif.Configuration' Name = $Name Path = $resolvedPath Settings = (Import-LdifFile -Path $Path) MissingObjectExemption = ($MissingObjectExemption | ForEach-Object { $_ -replace '(^CN=)|(^)','CN=' }) Weight = $Weight ContextName = $ContextName } } } function Test-FMSchemaLdif { <# .SYNOPSIS Tests whether the configured ldif-file-based schema extension has been applied. .DESCRIPTION Tests whether the configured ldif-file-based schema extension has been applied. .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-FMSchemaLdif Checks the current forest against all configured schema extension files #> [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 SchemaLdif -Cmdlet $PSCmdlet Set-FMDomainContext @parameters try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop $forest = Get-ADForest @parameters -ErrorAction Stop } catch { Stop-PSFFunction -String 'Test-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException() return } $parameters["Server"] = $forest.SchemaMaster } process { $ldifMapping = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif) $ldifSorted = Get-FMSchemaLdif | Sort-Object Weight $changes = @{ } $missingEntities = @() foreach ($ldifFile in $ldifSorted) { $changes[$ldifFile.Name] = @() } foreach ($distinguishedName in $ldifMapping.Keys) { $hasDefinedState = $ldifMapping[$distinguishedName].Values.State.Count -gt 0 $attributeName = '{0},{1}' -f $distinguishedName, $rootDSE.schemaNamingContext #region Retrieve AD Object ($adObject) try { $adObject = Get-ADObject @parameters -Identity $attributeName -ErrorAction Stop -Properties * } catch { if ($hasDefinedState) { foreach ($file in $ldifMapping[$distinguishedName].Keys) { $changes[$file] += [PSCustomObject]@{ DN = $distinguishedName Property = '<FailsToExist>' File = $file Setting = $ldifMapping[$distinguishedName][$file] ADObject = $null ValueS = $null ValueA = $null } } } else { if ($distinguishedName -notin ($ldifSorted.MissingObjectExemption | Write-Output)) { Write-PSFMessage -Level Warning -String 'Test-FMSchemaLdif.Missing.SchemaItem' -StringValues $attributeName -Tag 'panic' $missingEntities += $attributeName } } continue } #endregion Retrieve AD Object ($adObject) #region Compare configured with real state ($offStateLdifName) $offStateLdif = foreach ($ldifFile in $ldifSorted) { # Skip files that do not yet contain the taret object if (-not $ldifMapping[$distinguishedName][$ldifFile.Name]) { continue } $definedState = $ldifMapping[$distinguishedName][$ldifFile.Name] if ($definedState.State.Count -gt 0) { foreach ($propertyName in $definedState.State.Keys) { if (Compare-SchemaProperty -Setting $definedState.State -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.State.$propertyName ValueA = $adObject.$propertyName } } } } else { foreach ($propertyName in $definedState.Add.Keys) { if (Compare-SchemaProperty -Setting $definedState.Add -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE -Add) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.Add.$propertyName ValueA = $adObject.$propertyName } } } foreach ($propertyName in $definedState.Replace.Keys) { if (Compare-SchemaProperty -Setting $definedState.Replace -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) { [PSCustomObject]@{ DN = $distinguishedName Property = $propertyName File = $ldifFile.Name Setting = $definedState ADObject = $adObject ValueS = $definedState.Replace.$propertyName ValueA = $adObject.$propertyName } } } } } #endregion Compare configured with real state ($offStateLdifName) $applicableLdif = $ldifSorted | Where-Object Name -in $ldifMapping[$distinguishedName].Keys $lastAppliedItem = $applicableLdif | Where-Object Name -notin $offStateLdif.File | Sort-Object Weight -Descending | Select-Object -First 1 foreach ($ldifFile in $applicableLdif) { if ($ldifFile.Weight -lt $lastAppliedItem.Weight) { continue } if ($lastAppliedItem.Name -eq $ldifFile.Name) { continue } foreach ($entry in $offStateLdif) { if ($entry.File -ne $ldifFile.Name) { continue } $changes[$ldifFile.Name] += $entry } } } $ldifResult = foreach ($schemaName in $changes.Keys) { if (-not $changes[$schemaName]) { continue } [PSCustomObject]@{ PSTypeName = 'ForestManagement.SchemaLdif.TestResult' Type = 'InEqual' ObjectType = 'SchemaLdif' Identity = $schemaName Changed = $changes[$schemaName] Server = $forest.SchemaMaster DeltaCount = $changes[$schemaName].Count ADObject = $null Configuration = ($ldifSorted | Where-Object Name -eq $schemaName) } } $ldifResult | Sort-Object { $_.Configuration.Weight } } } function Unregister-FMSchemaLdif { <# .SYNOPSIS Removes a registered ldif file from the configured state. .DESCRIPTION Removes a registered ldif file from the configured state. .PARAMETER Name The name to select the ldif file by. .EXAMPLE PS C:\> Get-FMSchemaLdif | Unregister-FMSchemaLdif Unregisters all registered ldif files. #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Name ) process { foreach ($nameLabel in $Name) { $script:schemaLdif.Remove($nameLabel) } } } function Invoke-FMServer { <# .SYNOPSIS Ensures domain controllers are assigned to sites suitable for their IP addresses. .DESCRIPTION Ensures domain controllers are assigned to sites suitable for their IP addresses. .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-FMServer Ensures all domain controllers in the current forest are in the correct site. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] Param ( [Parameter(ValueFromPipeline = $true)] $InputObject, [PSFComputer] $Server, [PSCredential] $Credential, [switch] $EnableException ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential Assert-ADConnection @parameters -Cmdlet $PSCmdlet } process { if (-not $InputObject) { $InputObject = Test-FMServer @parameters } foreach ($testItem in $InputObject) { switch ($testItem.Type) { 'AddressNotFound' { if (-not $testItem.ADObject.DNSHostName) { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NotFound' -StringValues $testItem.Identity -Target $testItem.Identity } else { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.FailedToResolve' -StringValues $testItem.Identity -Target $testItem.Identity } } 'NoMatchingSubnet' { Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NoSubnet' -StringValues $testItem.Identity, $testItem.ADObject.IPAddress -Target $testItem.Identity } 'BadSite' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMServer.Server.Moving' -ActionStringValues $testItem.SupposedSite -Target $testItem.Identity -ScriptBlock { Move-ADDirectoryServer @parameters -Identity $testItem.ADobject.DistinguishedName -Site $testItem.SupposedSite -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Register-FMServer { <# .SYNOPSIS Configure the server-site assignment. .DESCRIPTION Configure the server-site assignment. .PARAMETER NoAutoAssignment Setting this to true will disable any automatically calculated site assignments. When enabled, only explicitly configured site assignments will be applied. .EXAMPLE PS C:\> Get-Content .\servers.json | ConvertFrom-Json | Write-Output | Register-FMServer Apply all configuration settings stored in servers.json #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Auto')] [bool] $NoAutoAssignment ) process { switch ($PSCmdlet.ParameterSetName) { #region Auto Assignment 'Auto' { $script:serverAutoAssignment = -not $NoAutoAssignment } #endregion Auto Assignment } } } function Test-FMServer { <# .SYNOPSIS Checks whether the Domain Controller in a forest are in the correct site. .DESCRIPTION Checks whether the Domain Controller in a forest are in the correct site. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMServer Tests, whethether all domain controllers in the current forest are up-to-date. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet $rootDSE = Get-ADRootDSE @parameters $searchBase = "CN=Sites,$($rootDSE.configurationNamingContext)" $domainControllers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $searchBase -Properties * | Select-Object *, IPAddress, @{ Name = 'SiteName' Expression = { $_.DistinguishedName -replace ".+,CN=(.+?),CN=Sites,CN=Configuration,DC=.+",'$1' } } foreach ($domainController in $domainControllers) { if ($domainController.DNSHostName) { $domainController.IPAddress = [IPAddress](Resolve-DnsName -Name $domainController.DNSHostName -ErrorAction Ignore -Debug:$false | Where-Object Type -eq A | Select-Object -First 1).IPAddress } } $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-PSFObject 'Name', @{ Name = "SiteName" Expression = { ($_.Site | Get-ADObject @parameters).Name } }, 'Name.Split("/")[0] AS IPBase TO IPAddress', 'Name.Split("/")[1].Split("´n")[0] AS MaskSize To Int', Mask, site | Where-Object Name -notlike "*CNF*" | Sort-Object MaskSize -Descending foreach ($subnet in $allSubnets) { $subnet.Mask = ConvertTo-SubnetMask -MaskSize $subnet.MaskSize } } process { :main foreach ($domainController in $domainControllers) { $resultDefaults = @{ ObjectType = 'Server' Identity = $domainController.Name Server = $Server ADObject = $domainController } if (-not $script:serverAutoAssignment) { continue } #region No IP Address if (-not $domainController.IPAddress) { New-TestResult @resultDefaults -Type AddressNotFound -Properties @{ CurrentSite = $domainController.SiteName } continue } #endregion No IP Address #region Resolving Subnet $foundSubnet = $null foreach ($subnet in $allSubnets) { if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) { $foundSubnet = $subnet break } } if (-not $foundSubnet) { New-TestResult @resultDefaults -Type NoMatchingSubnet -Properties @{ CurrentSite = $domainController.SiteName } continue } #endregion Resolving Subnet if ($domainController.SiteName -ne $foundSubnet.SiteName) { $currentSiteSubnets = $allSubnets | Where-Object SiteName -eq $domainController.SiteName foreach ($subnet in $currentSiteSubnets) { # Domain Controller is legally in his current site if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) { Write-PSFMessage -Level InternalComment -String 'Test-FMServer.SiteConflict' -StringValues $domainController.Name, $foundSubnet.SiteName, $domainController.SiteName, $foundSubnet.Name -Tag 'note' -Target $domainController.Name continue main } } New-TestResult @resultDefaults -Type BadSite -Changed $foundSubnet.SiteName -Properties @{ CurrentSite = $domainController.SiteName SupposedSite = $foundSubnet.SiteName FoundSubnet = $foundSubnet } } } } } function Get-FMSiteLink { <# .SYNOPSIS Returns the configured link between two sites. .DESCRIPTION Returns the configured link between two sites. .PARAMETER SiteName The site to filter by. Defaults to '*' .EXAMPLE PS C:\> Get-FMSiteLink Returns all configured sitelinks. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [string] $SiteName = "*" ) process { ($script:sitelinks.Values | Where-Object { ($_.Site1 -like $SiteName) -or ($_.Site2 -like $SiteName) }) } } function Invoke-FMSiteLink { <# .SYNOPSIS Update a forest's sitelink to conform to the defined configuration. .DESCRIPTION Update a forest's sitelink to conform to the defined configuration. Configuration is defined using Register-FMSiteLink. .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-FMSiteLink Updates the current forest's sitelinks to conform to the defined 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 Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet Set-FMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-FMSiteLink @parameters } foreach ($testItem in $InputObject) { switch ($testItem.Type) { #region Delete undesired Sitelink 'Delete' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Removing.SiteLink' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSiteLink @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Delete undesired Sitelink #region Create new Sitelink 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Creating.SiteLink' -Target $testItem.Name -ScriptBlock { $parametersCreate = $parameters.Clone() $parametersCreate += @{ ErrorAction = 'Stop' Name = $testItem.Name Description = $testItem.Description Cost = $testItem.Cost ReplicationFrequencyInMinutes = $testItem.ReplicationInterval SitesIncluded = $testItem.Site1, $testItem.Site2 } if ($testItem.Options) { $parametersCreate['OtherAttributes'] = @{ Options = $testItem.Options } } New-ADReplicationSiteLink @parametersCreate } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Create new Sitelink #region Update existing Sitelink 'Update' { if ($testItem.ADObject.Name -ne $testItem.IdealName) { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Renaming.SiteLink' -ActionStringValues $testItem.IdealName -Target $testItem.Name -ScriptBlock { Rename-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -NewName $testItem.IdealName -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } $parametersUpdate = $parameters.Clone() $parametersUpdate += @{ ErrorAction = 'Stop' Identity = $testItem.ADObject.ObjectGUID } foreach ($change in $testItem.Changed) { switch ($change.Property) { 'Cost' { $parametersUpdate['Cost'] = $change.NewValue } 'Description' { $parametersUpdate['Description'] = $change.NewValue } 'Options' { $parametersUpdate['Replace'] = @{ Options = $change.NewValue } } 'ReplicationInterval' { $parametersUpdate['ReplicationFrequencyInMinutes'] = $change.NewValue } } } # If the only change pending was the name, don't call a meaningles Set-ADReplicationSiteLink if ($parametersUpdate.Keys.Count -le (2 + $parameters.Keys.Count)) { continue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Updating.SiteLink' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSiteLink @parametersUpdate } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } #endregion Update existing Sitelink } } } } function Register-FMSiteLink { <# .SYNOPSIS Register a new sitelink configuration. .DESCRIPTION Register a new sitelink configuration. .PARAMETER Site1 The first sitename in the pair of sites to be linked. .PARAMETER Site2 The second sitename in the pair of sites to be linked. .PARAMETER Cost The cost of the connection between the two sites. .PARAMETER Interval The replication interval (in minutes) between two sites. Defaults to 15 minutes. Cannot be less than 15 minutes. .PARAMETER Description A description to add to the sitelink. For example, consider including a timestamp and the available bandwidth. .PARAMETER Option Any options for the sitelink. This is a bitmap with currently only one relevant setting: 00000001 : Change Notify (Changes replicate instantly, rather than the configured interval. Only use for high-bandwidth connections) .EXAMPLE PS C:\> Register-FMSiteLink -Site1 MySite -Site2 MyOtherSite -Cost 80 -Description '2019 | 1GB/s' -Option 1 Registers a new sitelink between MySite and MyOtherSite at a cost of 80, registering it as instant replication and adding docs on its bandwidth. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site1, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site2, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [ValidateRange(1,[int]::MaxValue)] [int] $Cost, [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateRange(15,[int]::MaxValue)] [int] $Interval = 15, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Description, [Parameter(ValueFromPipelineByPropertyName = $true)] [int] $Option ) process { $sitelinkName = "{0}-{1}" -f $Site1, $Site2 $script:sitelinks[$sitelinkName] = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.Configuration' Name = $sitelinkName Site1 = $Site1 Site2 = $Site2 Cost = $Cost Interval = $Interval Description = $Description Option = $Option } } } function Test-FMSiteLink { <# .SYNOPSIS Compares a live sitelink setup with the configured desired state. .DESCRIPTION Compares a live sitelink setup with the configured desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSiteLink Tests the current forest for compliance with the sitelink configuration #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { #region Functions function New-Update { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $Identity, $Property, $OldValue, $NewValue ) $datum = [PSCustomObject]@{ PSTypeName = 'ForestManagement.SiteLink.Update' Identity = $Identity Property = $Property OldValue = $OldValue NewValue = $NewValue } Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value { '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue } -Force $datum } #endregion Functions $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet Set-FMDomainContext @parameters $allSiteLinks = Get-ADReplicationSiteLink @parameters -Filter * -Properties Cost, Description, Options, Name, replInterval, siteList | Select-Object * $linksToExclude = @() $resultDefaults = @{ ObjectType = 'SiteLink' Server = $Server } foreach ($siteLink in $allSiteLinks) { $count = 1 foreach ($site in $siteLink.siteList) { try { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value (Get-ADObject @parameters -Identity $site -Properties Name).Name } catch { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value $site } $count++ } #region More than 2 sites in Sitelink if ($siteLink.siteList.Count -ge 3) { if (Get-PSFConfigValue -FullName 'ForestManagement.SiteLink.MultilateralLinks') { Write-PSFMessage -Level Verbose -String 'Test-FMSiteLink.Information.MultipleSites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, multiple_sites -Target $siteLink.DistinguishedName New-TestResult @resultDefaults -Type MultipleSites -Identity $siteLink.Name -ADObject $siteLink -Properties @{ Name = $siteLink.Name DistinguishedName = $siteLink.DistinguishedName } } else { Write-PSFMessage -Level Warning -String 'Test-FMSiteLink.Critical.TooManySites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, critical, panic -Target $siteLink.DistinguishedName New-TestResult @resultDefaults -Type TooManySites -Identity $siteLink.Name -ADObject $siteLink -Properties @{ Name = $siteLink.Name DistinguishedName = $siteLink.DistinguishedName } } $linksToExclude += $siteLink } #endregion More than 2 sites in Sitelink Add-Member -InputObject $siteLink -MemberType NoteProperty -Name IdealName -Value ('{0}-{1}' -f $siteLink.Site1, $siteLink.Site2) } $allSiteLinks = $allSiteLinks | Where-Object { $_ -notin $linksToExclude } } process { #region Test all sitelinks found in the forest foreach ($siteLink in $allSiteLinks) { if (-not (Get-FMSiteLink | Compare-SiteLink $siteLink)) { New-TestResult @resultDefaults -Type Delete -Identity $siteLink.Name -ADObject $siteLink -Properties @{ Name = $siteLink.Name Site1 = $siteLink.Site1 Site2 = $siteLink.Site2 IdealName = $siteLink.IdealName Cost = $siteLink.Cost Description = $siteLink.Description Options = $siteLink.Options ReplicationInterval = $siteLink.replInterval } continue } $configuredSitelink = Get-FMSiteLink | Compare-SiteLink $siteLink | Select-Object -First 1 $deltaProperties = @() #region Compare Properties if ($configuredSiteLink.Name -ne $siteLink.Name) { $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.Name -NewValue $configuredSitelink.Name -Property 'Name' } if ($configuredSiteLink.Cost -ne $siteLink.Cost) { $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.Cost -NewValue $configuredSitelink.Cost -Property 'Cost' } if ($configuredSiteLink.Description -ne ([string]($siteLink.Description))) { $deltaProperties += New-Update -Identity $siteLink.Name -OldValue ([string]($siteLink.Description)) -NewValue $configuredSitelink.Description -Property 'Description' } if ($configuredSiteLink.Option -ne ([int]($siteLink.Options))) { $deltaProperties += New-Update -Identity $siteLink.Name -OldValue ([int]($siteLink.Options)) -NewValue $configuredSitelink.Option -Property 'Options' } if ($configuredSiteLink.Interval -ne $siteLink.replInterval) { $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.replInterval -NewValue $configuredSitelink.Interval -Property 'ReplicationInterval' } #endregion Compare Properties if ($deltaProperties) { New-TestResult @resultDefaults -Type Update -Identity $siteLink.Name -ADObject $siteLink -Configuration $configuredSitelink -Properties @{ Name = $configuredSitelink.Name Site1 = $configuredSitelink.Site1 Site2 = $configuredSitelink.Site2 IdealName = $configuredSitelink.Name Cost = $configuredSitelink.Cost Description = $configuredSitelink.Description Options = $configuredSitelink.Option ReplicationInterval = $configuredSitelink.Interval } -Changed $deltaProperties } } #endregion Test all sitelinks found in the forest foreach ($configuredSitelink in (Get-FMSiteLink)) { if ($allSiteLinks | Compare-SiteLink $configuredSitelink) { continue } New-TestResult @resultDefaults -Type Create -Identity $configuredSitelink.Name -Configuration $configuredSitelink -Properties @{ Name = $configuredSitelink.Name Site1 = $configuredSitelink.Site1 Site2 = $configuredSitelink.Site2 IdealName = $configuredSitelink.Name Cost = $configuredSitelink.Cost Description = $configuredSitelink.Description Options = $configuredSitelink.Option ReplicationInterval = $configuredSitelink.Interval } } } } function Unregister-FMSiteLink { <# .SYNOPSIS Removes a link between two sites from configuration. .DESCRIPTION Removes a link between two sites from configuration. .PARAMETER Site1 The site1 of the link. .PARAMETER Site2 The site2 of the link. .EXAMPLE PS C:\> Unregister-FMSiteLink -Site1 MySite -Site2 MyOtherSite Removes a sitelink from configuration. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site1, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Site2 ) process { $sitelinkName = "{0}-{1}" -f $Site1, $Site2 $sitelinkName2 = "{1}-{0}" -f $Site1, $Site2 $script:sitelinks.Remove($sitelinkName) $script:sitelinks.Remove($sitelinkName2) } } function Get-FMSite { <# .SYNOPSIS Returns the list of configured sites. .DESCRIPTION Returns the list of configured sites. Sites can be configured using Register-FMSite. Those configurations represent the "Should be" state as defined for the entire organization. .PARAMETER Name Name to filter by. Defaults to "*" .EXAMPLE PS C:\> Get-FMSite Returns all configured sites. #> [CmdletBinding()] Param ( [string] $Name = "*" ) process { ($script:sites.Values | Where-Object Name -like $Name) } } function Invoke-FMSite { <# .SYNOPSIS Adjusts the targeted forest to comply with the site configuration. .DESCRIPTION Adjusts the targeted forest to comply with the site configuration. Use Register-FMSiteConfiguration to register configuration settings. .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-FMSite Scans the forest for discrepancies from the desired state Then attempts to rectify the state. #> [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 Sites -Cmdlet $PSCmdlet Set-FMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-FMSite @parameters } foreach ($testItem in $InputObject) { switch ($testItem.Type) { 'Delete' { $siteObject = Get-ADReplicationSite @parameters -Identity $testItem.Name $servers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $siteObject.DistinguishedName if ($servers) { Write-PSFMessage -Level Warning -String 'Invoke-FMSite.Removing.Site.ChildServers' -StringValues ($servers.Name -join ", ") -Tag 'failed','sites' } else { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Removing.Site' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSite @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Creating.Site' -Target $testItem.Name -ScriptBlock { New-ADReplicationSite @parameters -Name $testItem.Name -Description $testItem.Description -OtherAttributes @{ Location = $testItem.Location } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'Update' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Updating.Site' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSite @parameters -Identity $testItem.Name -Description $testItem.Description -Replace @{ Location = $testItem.Location } -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'Rename' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Renaming.Site' -ActionStringValues $testItem.NewName -Target $testItem.Name -ScriptBlock { Get-ADReplicationSite @parameters -Identity $testItem.Name | Rename-ADObject @parameters -NewName $testItem.NewName } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Register-FMSite { <# .SYNOPSIS Register a new site configuration. .DESCRIPTION Register a new site configuration. This is the ideal / desired state for the site setup. Forests will be brought into this state by using Invoke-FMSite. .PARAMETER Name Name of the site to apply. .PARAMETER Description Description the site should have. .PARAMETER Location Location the site should be part of. .PARAMETER OldNames Previous names for this site. Forests that have a site still using one of these names will have those sites renamed. .EXAMPLE PS C:\> Register-FMSite -Name ABCDE -Description "Some Site" -Location 'Atlantis' Registers a new desired site. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Description, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $Location, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]] $OldNames ) process { $hashtable = @{ PSTypeName = 'ForestManagement.Site.Configuration' Name = $Name Description = $Description Location = $Location } if ($OldNames) { $hashtable["OldNames"] = $OldNames } $script:sites[$Name] = [PSCustomObject]$hashtable } } function Test-FMSite { <# .SYNOPSIS Tests a target foret's site configuration with the desired state. .DESCRIPTION Tests a target foret's site configuration with the desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSite Checks whether the current forest is compliant with the desired site configuration. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { #region Functions function New-Update { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $Identity, $Property, $OldValue, $NewValue ) $datum = [PSCustomObject]@{ PSTypeName = 'ForestManagement.Site.Update' Identity = $Identity Property = $Property OldValue = $OldValue NewValue = $NewValue } Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value { '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue } -Force $datum } #endregion Functions $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Sites -Cmdlet $PSCmdlet Set-FMDomainContext @parameters $allSites = Get-ADReplicationSite @parameters -Filter * -Properties Location $renameMapping = @{} $script:sites.Values | Where-Object OldNames | ForEach-Object { foreach ($oldName in $_.OldNames) { $renameMapping[$oldName] = $_.Name } } } process { $foundSites = @{} $resultDefaults = @{ Server = $Server ObjectType = 'Site' } foreach ($site in $allSites) { if ($renameMapping.Keys -contains $site.Name) { New-TestResult @resultDefaults -Type Rename -Identity $site.Name -Properties @{ Name = $site.Name Description = $site.Description Location = $site.Location NewName = $renameMapping[$site.Name] } -ADObject $site -Changed (New-Update -Identity $site.Name -Property Name -OldValue $site.Name -NewValue $renameMapping[$site.Name]) continue } elseif ($script:sites.Keys -contains $site.Name) { $foundSites[$site.Name] = $site } else { New-TestResult @resultDefaults -Type Delete -Identity $site.Name -Properties @{ Name = $site.Name Description = $site.Description Location = $site.Location } -ADObject $site } } foreach ($site in $script:sites.Values) { if ($site.Name -in $allSites.Name) { continue } New-TestResult @resultDefaults -Type Create -Identity $site.Name -Properties @{ Name = $site.Name Description = $site.Description Location = $site.Location } -Configuration $site } foreach ($site in $foundSites.Values) { $deltaProperties = @() if ([string]($site.Location) -ne $script:sites[$site.Name].Location) { $deltaProperties += New-Update -Identity $site.Name -OldValue $site.Location -NewValue $script:sites[$site.Name].Location -Property 'Location' } if ([string]($site.Description) -ne $script:sites[$site.Name].Description) { $deltaProperties += New-Update -Identity $site.Name -OldValue $site.Description -NewValue $script:sites[$site.Name].Description -Property 'Description' } if (-not $deltaProperties) { continue } New-TestResult @resultDefaults -Type Update -Identity $site.Name -Properties @{ Name = $site.Name Description = $script:sites[$site.Name].Description Location = $script:sites[$site.Name].Location } -ADObject $site -Configuration $script:sites[$site.Name] -Changed $deltaProperties } } } function Unregister-FMSite { <# .SYNOPSIS Removes a site from the list of registered sites. .DESCRIPTION Removes a site from the list of registered sites. .PARAMETER Name Name of the site to unregister .EXAMPLE PS C:\> Unregister-FMSite -Name "MySite" Removes the site "MySite" from the list of registered sites #> [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)] [string[]] $Name ) process { foreach ($nameItem in $Name) { $script:sites.Remove($nameItem) } } } function Get-FMSubnet { <# .SYNOPSIS Returns the list of configured subnets. .DESCRIPTION Returns the list of configured subnets. Subnets can be configured using Register-FMSubnet. Those configurations represent the "Should be" state as defined for the entire organization. .PARAMETER Name Name of the subnet to filter by. Defaults to "*" .EXAMPLE PS C:\> Get-FMSubnet Returns all configured subnets. #> [CmdletBinding()] Param ( [string] $Name = "*" ) process { ($script:subnets.Values | Where-Object Name -like $Name) } } function Invoke-FMSubnet { <# .SYNOPSIS Corrects the subnet configuration of a forest. .DESCRIPTION Corrects the subnet configuration of a forest. .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-FMSubnet Corrects the subnet configuration of the current forest. #> [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 Subnets -Cmdlet $PSCmdlet Set-FMDomainContext @parameters } process { if (-not $InputObject) { $InputObject = Test-FMSubnet @parameters } $testResult = $InputObject | Sort-Object { switch ($_.Type) { 'ForestOnly' { 1 } 'InEqual' { 2 } default { 3 } } } foreach ($testItem in $testResult) { switch ($testItem.Type) { 'Delete' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Deleting.Subnet' -Target $testItem.Name -ScriptBlock { Remove-ADReplicationSubnet @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'Create' { Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Creating.Subnet' -Target $testItem.Name -ScriptBlock { New-ADReplicationSubnet @parameters -Name $testItem.Name -Site $testItem.SiteName -Description $testItem.Description -Location $testItem.Location -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } 'Update' { $parametersSetSplat = $parameters.Clone() $parametersSetSplat['Identity'] = $testItem.Identity foreach ($change in $testItem.Changed) { $parametersSetSplat[$change.Property] = $change.NewValue } Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Updating.Subnet' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock { Set-ADReplicationSubnet @parametersSetSplat -ErrorAction Stop } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet } } } } } function Register-FMSubnet { <# .SYNOPSIS Registers a new subnet assignment. .DESCRIPTION Registers a new subnet assignment. Subnets are assigned to sites. .PARAMETER SiteName Name of the site to which subnets are being assigned. .PARAMETER Name Subnet to assign. Must be a subnet in the following notation: <ipv4address>/<subnetsize> E.g.: 1.2.3.4/24 .PARAMETER Description Description to add to the subnet .PARAMETER Location Location, where the subnet is at. .EXAMPLE PS C:\> Register-FMSubnet -SiteName MySite -Name '1.2.3.4/32' Assigns the subnet '1.2.3.4/32' to the site 'MySite' #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [string] $SiteName, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [PsfValidateScript('ForestManagement.Validate.Subnet', ErrorString = 'ForestManagement.Validate.Subnet.Failed')] [string] $Name, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Description, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Location ) process { $hashtable = @{ PSTypeName = 'ForestManagement.Subnet.Configuration' SiteName = $SiteName Name = $Name Description = $Description Location = $Location } $script:subnets[$Name] = [PSCustomObject]$hashtable } } function Test-FMSubnet { <# .SYNOPSIS Compares a forest's Subnet configuration against its desired state. .DESCRIPTION Compares a forest's Subnet configuration against its desired state. .PARAMETER Server The server / domain to work with. .PARAMETER Credential The credentials to use for this operation. .EXAMPLE PS C:\> Test-FMSubnet Compares the current forest's Subnet configuration against its desired state. #> [CmdletBinding()] Param ( [PSFComputer] $Server, [PSCredential] $Credential ) begin { #region Functions function New-Update { [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( $Identity, $Property, $OldValue, $NewValue ) $datum = [PSCustomObject]@{ PSTypeName = 'ForestManagement.Subnet.Update' Identity = $Identity Property = $Property OldValue = $OldValue NewValue = $NewValue } Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value { '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue } -Force $datum } #endregion Functions $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $parameters['Debug'] = $false Assert-ADConnection @parameters -Cmdlet $PSCmdlet Invoke-Callback @parameters -Cmdlet $PSCmdlet Assert-Configuration -Type Subnets -Cmdlet $PSCmdlet Set-FMDomainContext @parameters $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-Object *, @{ Name = "SiteName" Expression = { ($_.Site | Get-ADObject @parameters).Name } } } process { $resultDefaults = @{ ObjectType = 'Subnet' Server = $Server } #region Test all Subnets found in the forest foreach ($subnetItem in $allSubnets) { if ($script:subnets.Keys -notcontains $subnetItem.Name) { New-TestResult @resultDefaults -Type Delete -Identity $subnetItem.Name -ADObject $subnetItem -Properties @{ SiteName = $subnetItem.SiteName Name = $subnetItem.Name Description = $subnetItem.Description Location = $subnetItem.Location } continue } $configuredSubnet = $script:subnets[$subnetItem.Name] $deltaProperties = @() if ($subnetItem.SiteName -ne $configuredSubnet.SiteName) { $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue $subnetItem.SiteName -NewValue $configuredSubnet.SiteName -Property 'Site' } if ([string]($subnetItem.Description) -ne $configuredSubnet.Description) { $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue ([string]$subnetItem.Description) -NewValue $configuredSubnet.Description -Property 'Description' } if ([string]($subnetItem.Location) -ne $configuredSubnet.Location) { $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue ([string]$subnetItem.Location) -NewValue $configuredSubnet.Location -Property 'Location' } if (-not $deltaProperties) { continue } New-TestResult @resultDefaults -Type Update -Identity $subnetItem.Name -ADObject $subnetItem -Configuration $configuredSubnet -Properties @{ SiteName = $configuredSubnet.SiteName Name = $configuredSubnet.Name Description = $configuredSubnet.Description Location = $configuredSubnet.Location } -Changed $deltaProperties } #endregion Test all Subnets found in the forest #region Catch subnets only in configuration but NOT in forest foreach ($configuredSubnet in $script:subnets.Values) { if ($allSubnets.Name -contains $configuredSubnet.Name) { continue } New-TestResult @resultDefaults -Type Create -Identity $configuredSubnet.Name -Configuration $configuredSubnet -Properties @{ SiteName = $configuredSubnet.SiteName Name = $configuredSubnet.Name Description = $configuredSubnet.Description Location = $configuredSubnet.Location } } #endregion Catch subnets only in configuration but NOT in forest } } function Unregister-FMSubnet { <# .SYNOPSIS Removes a subnet mapping. .DESCRIPTION Removes a subnet mapping. .PARAMETER Name Name of the subnets to unregister .EXAMPLE PS C:\> Unregister-FMSubnet -Name "1.2.3.4/32" Removes the subnet "1.2.3.4/32" #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] [CmdletBinding()] Param ( [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)] [string[]] $Name ) process { foreach ($nameItem in $Name) { $script:subnets.Remove($nameItem) } } } function Clear-FMConfiguration { <# .SYNOPSIS Clears the stored configuration data. .DESCRIPTION Clears the stored configuration data. .EXAMPLE PS C:\> Clear-FMConfiguration Clears the stored configuration data. #> [CmdletBinding()] Param ( ) process { # Site Configurations $script:sites = @{ } # Subnet Configurations $script:subnets = @{ } # Sitelink Configurations $script:sitelinks = @{ } # Schema Definition $script:schema = @{ } # Schema Definitions for external LDIF files $script:schemaLdif = @{ } } } function Get-FMCallback { <# .SYNOPSIS Returns the list of registered callbacks. .DESCRIPTION Returns the list of registered callbacks. For more details on this system, call: Get-Help about_FM_callbacks .PARAMETER Name The name of the callback. Supports wildcard filtering. .EXAMPLE PS C:\> Get-FMCallback Returns a list of all registered callbacks #> [CmdletBinding()] Param ( [string] $Name = '*' ) process { $script:callbacks.Values | Where-Object Name -like $Name } } function Register-FMCallback { <# .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_FM_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-FMCallback -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 Unregister-FMCallback { <# .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_FM_callbacks .PARAMETER Name The name of the callback to remove. .EXAMPLE PS C:\> Get-FMCallback | Unregister-FMCallback 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) } } } <# 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 'ForestManagement' -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 'ForestManagement' -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 'ForestManagement' -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." # Sitelinks Set-PSFConfig -Module 'ForestManagement' -Name 'SiteLink.MultilateralLinks' -Value $false -Initialize -Validation 'bool' -Description 'Whether sitelinks should be allowed to contain more than two sites. Enabling this will suppress all error messages when finding those.' # Schema Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.AutoCreate.TempAdmin' -Value $false -Initialize -Validation 'bool' -Description 'Schema Updates require special privileges not usually granted. Enabling this setting will have the task automatically create a temporary schema admin account with the permissions to execute the planned updates.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.IgnoreOnCredentialProvider' -Value $false -Initialize -Validation 'bool' -Description 'Whether the Schema Admin Credential workflow should be ignored when called from ADMF with the Credential Provider specified.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Credential' -Value $null -Initialize -Validation credential -Description 'Credentials to use for performing schema updates' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Name' -Value '' -Initialize -Validation string -Description 'The name of the account to use' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDescription' -Value '' -Initialize -Validation string -Description 'The description for the account used. If specified, this is what the description will be updated to after successfully using the account.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoCreate' -Value $false -Initialize -Validation bool -Description 'Whether the account should be created automatically if it isn''t present' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoEnable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be enabled for use if disabled.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDisable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be disabled after use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoGrant' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be added to the schema admins group before use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoRevoke' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be removed from the schema admins group after use.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Password.AutoReset' -Value $false -Initialize -Validation bool -Description 'Whether the password of the used account should be reset before & after use.' <# 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 'ForestManagement.ScriptBlockName' -Scriptblock { } #> Set-PSFScriptblock -Name 'ForestManagement.Validate.Path.SingleFile' -Scriptblock { try { Resolve-PSFPath -Path $_ -Provider FileSystem -SingleItem return $true } catch { return $false } } Set-PSFScriptblock -Name 'ForestManagement.Validate.Subnet' -Scriptblock { if (-not $_.Contains("/")) { return $false } if (($_ -split "/").Count -gt 2) { return $false } $base, $range = $_ -split "/" $ipv4Pattern = '^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$' if ($base -notmatch $ipv4Pattern) { return $false } $rangeNumber = $range -as [int] if (-not $rangeNumber) { return $false } if ($rangeNumber -lt 1) { return $false } if ($rangeNumber -gt 32) { return $false } $true } <# # Example: Register-PSFTeppScriptblock -Name "ForestManagement.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> Register-PSFTeppScriptblock -Name 'ForestManagement.ExchangeVersion' -ScriptBlock { (Get-AdcExchangeVersion).Binding } -Global Register-PSFTeppScriptblock -Name 'ForestManagement.ForestName' -ScriptBlock { (Get-ADTrust -Filter *).Target } Register-PSFTeppScriptblock -Name "ForestManagement.Sites" -ScriptBlock { $module = Get-Module ForestManagement & $module { $script:sites.Keys } } Register-PSFTeppScriptblock -Name "ForestManagement.Site2New" -ScriptBlock { $module = Get-Module ForestManagement $sites = & $module { $script:sites.Keys } $sitelinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site1) { return $sites | Sort-Object -Unique } $results = foreach ($site in $sites) { if ($site -eq $fakeBoundParameter.Site1) { continue } if ($siteLinks | Where-Object { ($_.Site1 -eq $fakeBoundParameter.Site1) -and ($_.Site2 -eq $site) }) { continue } if ($siteLinks | Where-Object { ($_.Site2 -eq $fakeBoundParameter.Site1) -and ($_.Site1 -eq $site) }) { continue } $site } $results | Sort-Object -Unique } Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site1" -ScriptBlock { $module = Get-Module ForestManagement $siteLinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site2) { return $siteLinks.Site1 | Sort-Object -Unique } ($siteLinks | Where-Object Site2 -eq $fakeBoundParameter.Site2).Site1 | Sort-Object -Unique } Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site2" -ScriptBlock { $module = Get-Module ForestManagement $siteLinks = & $module { $script:sitelinks.Values } if (-not $fakeBoundParameter.Site1) { return $siteLinks.Site2 | Sort-Object -Unique } ($siteLinks | Where-Object Site1 -eq $fakeBoundParameter.Site1).Site2 | Sort-Object -Unique } <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name ForestManagement.alcohol #> Register-PSFTeppArgumentCompleter -Command Get-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Unregister-FMSite -Parameter Name -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Get-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Get-FMSiteLink -Parameter SiteName -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site1 -Name 'ForestManagement.Sites' Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site2 -Name 'ForestManagement.Site2New' Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site1 -Name "ForestManagement.Linked.Site1" Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site2 -Name "ForestManagement.Linked.Site2" Register-PSFTeppArgumentCompleter -Command Invoke-FMSchema -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMServer -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSite -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Invoke-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSchema -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMServer -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSite -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName' Register-PSFTeppArgumentCompleter -Command Test-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName' New-PSFLicense -Product 'ForestManagement' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-08-05") -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. "@ # Directory Certificate Stores $script:dsCertificates = @{ } $script:dsCertificatesAuthorative = @{ } # Exchange Schema Version $script:exchangeschema = $null # Forest Level $script:forestlevel = $null # NT Auth Store Configuration $script:ntAuthStoreCertificates = @{ } $script:ntAuthStoreAuthorative = $false # Server Auto Assignment - whether domain controllers will be automatically moved to valid sites without any configuration needed $script:serverAutoAssignment = $true # Site Configurations $script:sites = @{ } # Subnet Configurations $script:subnets = @{ } # Sitelink Configurations $script:sitelinks = @{ } # Schema Definition $script:schema = @{ } # Schema Default Permission $script:schemaDefaultPermissions = @{ } # Schema Definitions for external LDIF files $script:schemaLdif = @{ } $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 ForestManagement.ConfigurationReset -ModuleName ADMF.Core -CommandName Clear-AdcConfiguration -ScriptBlock { Clear-FMConfiguration } #endregion Load compiled code |