ADLDSMF.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\ADLDSMF.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName ADLDSMF.Import.DoDotSource -Fallback $false if ($ADLDSMF_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 ADLDSMF.Import.IndividualFiles -Fallback $false if ($ADLDSMF_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 foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # 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 foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # 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 'ADLDSMF' -Language 'en-US' function New-Change { <# .SYNOPSIS Create a new change object. .DESCRIPTION Create a new change object. Helper command that unifies result generation. .PARAMETER Identity The identity the change applies to. .PARAMETER Property What property is being modified. .PARAMETER OldValue The old value that is being updated. .PARAMETER NewValue The new value that will be set instead. .PARAMETER DisplayStyle How the change will display in text form. Defaults to: NewValue .PARAMETER Data Additional data to include in the change. .EXAMPLE PS C:\> New-Change -Identity "CN=max,OU=Users,DC=Fabrikam,DC=org" Property LuckyNumber -OldValue 1 -NewValue 42 Creates a new change. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Identity, [Parameter(Mandatory = $true)] [string] $Property, [AllowEmptyCollection()] [AllowNull()] $OldValue, [AllowEmptyCollection()] [AllowNull()] $NewValue, [ValidateSet('NewValue', 'RemoveValue')] [string] $DisplayStyle = 'NewValue', [AllowEmptyCollection()] [AllowNull()] $Data ) $dsStyles = @{ 'NewValue' = { '{0} -> {1}' -f $this.Property, $this.New } 'RemoveValue' = { '{0} Remove {1}' -f $this.Property, $this.Old } } $object = [PSCustomObject]@{ PSTypeName = 'AdLds.Change' Identity = $Identity Property = $Property Old = $OldValue New = $NewValue Data = $Data } Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value $dsStyles[$DisplayStyle] -Force $object } function New-Password { <# .SYNOPSIS Generate a new, complex password. .DESCRIPTION Generate a new, complex password. .PARAMETER Length The length of the password calculated. Defaults to 32 .PARAMETER AsSecureString Returns the password as secure string. .EXAMPLE PS C:\> New-Password Generates a new 32v character password. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] [CmdletBinding()] Param ( [int] $Length = 32, [switch] $AsSecureString ) begin { $characters = @{ 0 = @('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z') 1 = @('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z') 2 = @(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9) 3 = @('#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@') 4 = @('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z') 5 = @('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z') 6 = @(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9) 7 = @('#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@') } } process { $letters = foreach ($number in (5..$Length)) { $characters[(($number % 4) + (0..4 | Get-Random))] | Get-Random } 0, 1, 2, 3 | ForEach-Object { $letters += $characters[$_] | Get-Random } $letters = $letters | Sort-Object { Get-Random } if ($AsSecureString) { $letters -join "" | ConvertTo-SecureString -AsPlainText -Force } else { $letters -join "" } } } function New-TestResult { <# .SYNOPSIS A new test result, as produced by any of the test commands. .DESCRIPTION A new test result, as produced by any of the test commands. This helper function ensures that all test results look the same. .PARAMETER Type What kind object is being tested. Should receive the objectclass being affected. .PARAMETER Action What we do with the object in question. .PARAMETER Identity The specific object being changed. .PARAMETER Change Any specific change data that will be applied to the object. See New-Change for more details on that structure. .PARAMETER ADObject The actual AD LDS Object being modified. Will usually be $null when creating something new. .PARAMETER Configuration The configuration object based on which the change will be applied. .EXAMPLE PS C:\> New-TestResult -Type User -Action Create -Identity $userName -Configuration $configSet Test result heralding the creation of a new user. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [string] $Type, [ValidateSet('Create', 'Update', 'Delete', 'Add', 'Remove', 'Rename')] [string] $Action, [string] $Identity, $Change, $ADObject, $Configuration ) [PSCustomObject]@{ PSTypeName = 'AdLds.Testresult' Type = $Type Action = $Action Identity = $Identity Change = $Change ADObject = $ADObject Configuration = $Configuration } } function Resolve-SchemaGuid { <# .SYNOPSIS Resolves the name of an attribute or objectclass to its GUID form. .DESCRIPTION Resolves the name of an attribute or objectclass to its GUID form. Used to enable user-friendly names in configuration. + Supports caching requests to optimize performance + Will return guids unmodified .PARAMETER Name The name or guid of the attribute or object class. Guids will be returned unverified. .PARAMETER Server The LDS Server to connect to. .PARAMETER Credential The credentials - if any - to use to the specified server. .PARAMETER Cache A hashtable used for caching requests. .EXAMPLE PS C:\> Resolve-SchemaGuid -Name contact -Server lds1.contoso.com -Cache $cache Returns the GUID form of the "contact" object class if present. #> [OutputType([string])] [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $Name, [Parameter(Mandatory = $true)] [string] $Server, [PSCredential] $Credential, [hashtable] $Cache = @{ } ) begin { $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { if ($Name -as [Guid]) { return $Name } if ($Cache[$Name]) { return $Cache[$Name] } $rootDSE = [adsi]"LDAP://$Server/rootDSE" $targetObjectClassObj = Get-ADObject @ldsParam -SearchBase ($rootdse.schemaNamingContext.value) -LDAPFilter "CN=$Name" -Properties 'schemaIDGUID' if (-not $targetObjectClassObj) { throw "Unknown attribute or object class: $Name" } $bytes = [byte[]]$targetObjectClassObj.schemaIDGUID $guid = [guid]::new($bytes) $Cache[$Name] = "$guid" "$guid" } } function Unprotect-OrganizationalUnit { <# .SYNOPSIS Removes deny rules on OrganizationalUnits. .DESCRIPTION Removes deny rules on OrganizationalUnits. Necessary whenever we want to delete an OU. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Identity The OU to unprotect. Specify the full distinguishedname. .EXAMPLE PS C:\> Unprotect-OrganizationalUnit @ldsParam -Identity $ouPath Removes the deletion protection from the OU specified in $ouPath #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [Parameter(Mandatory = $true)] [string] $Identity ) begin { Update-ADSec $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { $adObject = Get-ADObject @ldsParam -Identity $Identity -Partition $Partition -Properties DistinguishedName $acl = Get-AdsAcl @ldsParam -Path $adObject.DistinguishedName $denyRules = $acl.Access | Where-Object AccessControlType -eq Deny if (-not $denyRules) { return } foreach ($rule in $denyRules) { $null = $acl.RemoveAccessRule($rule) } $acl | Set-AdsAcl @ldsParam -Path $adObject.DistinguishedName } } function Update-ADSec { <# .SYNOPSIS Injects Get-LdsDomain into the ADSec module to overwrite its use of Get-ADDomain. .DESCRIPTION Injects Get-LdsDomain into the ADSec module to overwrite its use of Get-ADDomain. This enables us to override the AD domain connection verification performed by the module. .EXAMPLE PS C:\> Update-ADSec Injects Get-LdsDomain into the ADSec module to overwrite its use of Get-ADDomain. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param () & (Get-Module ADSec) { Set-Alias -Name Get-ADDomain -Value Get-LdsDomain -Scope Script } } function Update-LdsConfiguration { <# .SYNOPSIS Updates the reference to the currently "connected to" LDS instance. .DESCRIPTION Updates the reference to the currently "connected to" LDS instance. This is used by Get-LdsDomain, which is injected into the ADSec module to avoid issues with domain resolution. .PARAMETER LdsServer The server hosting the LDS instance. .PARAMETER LdsPartition The partition of the LDS instance. .EXAMPLE PS C:\> Update-LdsConfiguration -LdsServer lds1.contoso.com -LdsPartition 'DC=Fabrikam,DC=org' Registers lds1.contoso.com as the current server and 'DC=Fabrikam,DC=org' as the current partition. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $LdsServer, [Parameter(Mandatory = $true)] [string] $LdsPartition ) $script:_ldsServer = $LdsServer $script:_ldsPartition = $LdsPartition } function Get-LdsDomain { <# .SYNOPSIS Returns a pseudo-domain object from an LDS instance. .DESCRIPTION Returns a pseudo-domain object from an LDS instance. Use to transparently redirect Get-ADDomain calls. .PARAMETER LdsServer LDS Server instance to use. Reads from cache if provided. .PARAMETER LdsPartition LDS partition to use. Reads from cache if provided. .EXAMPLE PS C:\> Get-LdsDomain Returns the default domain #> param ( [string] $LdsServer = $script:_ldsServer, [string] $LdsPartition = $script:_ldsPartition ) $object = Get-ADObject -LdapFilter '(objectClass=domainDns)' -Server $LdsServer -SearchBase $LdsPartition -Properties * Add-Member -InputObject $object -MemberType NoteProperty -Name NetbiosName -Value $object.Name -Force Add-Member -InputObject $object -MemberType NoteProperty -Name DnsRoot -Value ($object.DistinguishedName -replace "DC=" -replace ",", ".") -Force $groupSid = Get-ADObject -LdapFilter '(&(objectClass=group)(isCriticalSystemObject=TRUE))' -Server $LdsServer -SearchBase $LdsPartition -Properties ObjectSID -ResultSetSize 1 | ForEach-Object ObjectSID Add-Member -InputObject $object -MemberType NoteProperty -Name DomainSID -Value (($groupSid.Value -replace '-\d+$') -as [System.Security.Principal.SecurityIdentifier]) -Force $object } function Import-LdsConfiguration { <# .SYNOPSIS Import a set of configuration files. .DESCRIPTION Import a set of configuration files. Each configuration file must be a psd1, json or (at PS7+) jsonc file. They can be stored any levels of nested folder deep, but they cannot be hidden. Each file shall contain an array of entries and each entry shall have an objectclass plus all the attributes it should have. Note to include everything an object of the given type must have. For each entry, specifying an objectclass is optional: If none is specified, the name of the parent folder is chosen instead. Thus, creating a folder named "user" will have all settings directly within default to the objectclass "user". Supported Object Classes: - AccessRule - Group - GroupMembership - OrganizationalUnit - SchemaAttribute - User Note: Group Memberships and access rules are not really object entities in AD LDS, but are treated the same for configuration purposes. Example Content: > user.psd1 @{ Name = 'Thomas' Path = 'OU=Admin,%DomainDN%' Enabled = $true } .PARAMETER Path Path to a folder containing all configuration sets. .EXAMPLE PS C:\> Import-LdsConfiguration -Path C:\scripts\lds\config Imports all the configuration files under the specified path, no matter how deeply nested. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Path ) $objectClasses = 'AccessRule', 'Group', 'GroupMembership', 'OrganizationalUnit', 'SchemaAttribute', 'User' $extensions = '.json', '.psd1' if ($PSVersionTable.PSVersion.Major -ge 7) {$extensions = '.json', '.jsonc', '.psd1'} foreach ($file in Get-ChildItem -Path $Path -Recurse -File | Where-Object Extension -In $extensions) { $datasets = Import-PSFPowerShellDataFile -LiteralPath $file.FullName -Psd1Mode Unsafe $defaultObjectClass = $file.Directory.Name.ToLower() foreach ($dataset in $datasets) { if (-not $dataset.ObjectClass) { $dataset.ObjectClass = $defaultObjectClass } switch ($dataset.ObjectClass) { 'groupmembership' { $identity = "$($dataset.Group)|$($dataset.Member)|$($dataset.Type)" $script:content.groupmembership.$identity = $dataset } 'accessrule' { $identity = "$($dataset.Path)|$($dataset.Identity)|$($dataset.IdentityType)|$($dataset.Rights)|$($dataset.ObjectType)" $script:content.accessrule.$identity = $dataset } 'SchemaAttribute' { $script:content.SchemaAttribute[$dataSet.AttributeID] = $dataSet } default { if ($dataset.ObjectClass -notin $objectClasses) { Write-PSFMessage -Level Warning -Message 'Invalid Object Class: {0} Importing file "{1}". Legal Values: {2}' -StringValues $dataset.ObjectClass, $file.FullName, ($objectClasses -join ', ') -Tag 'badClass' -Target $dataset } $identity = "$($dataset.Name),$($dataset.Path)" if (-not $script:content.$($dataset.ObjectClass)) { $script:content.$($dataset.ObjectClass) = @{ } } $script:content.$($dataset.ObjectClass)[$identity] = $dataset } } } } } function Invoke-LdsConfiguration { <# .SYNOPSIS Applies all currently configured settings to the target AD LDS server. .DESCRIPTION Applies all currently configured settings to the target AD LDS server. Use Import-LdsConfiguration first to load one or more configuration sets. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Options Which part of the configuration to deploy. Defaults to all of them ('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute') .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Invoke-LdsConfiguration -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Applies all currently configured settings to the target AD LDS server. .EXAMPLE PS C:\> Invoke-LdsConfiguration -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' -Options User, Group, OrganizationalUnit Applies all currently configured users, groups and OUs to the target AD LDS server. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [ValidateSet('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute')] [string[]] $Options = @('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute'), [switch] $Delete ) begin { $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential } process { if ($Options -contains 'SchemaAttribute') { Invoke-LdsSchemaAttribute @ldsParam } if ($Options -contains 'OrganizationalUnit') { Invoke-LdsOrganizationalUnit @ldsParam -Delete:$Delete } if ($Options -contains 'Group') { Invoke-LdsGroup @ldsParam -Delete:$Delete } if ($Options -contains 'User') { Invoke-LdsUser @ldsParam -Delete:$Delete } if ($Options -contains 'GroupMembership') { Invoke-LdsGroupMembership @ldsParam -Delete:$Delete } if ($Options -contains 'AccessRule') { Invoke-LdsAccessRule @ldsParam -Delete:$Delete } } } function Reset-LdsAccountPassword { <# .SYNOPSIS Reset the password of any given user account. .DESCRIPTION Reset the password of any given user account. The new password will be pasted to clipboard. .PARAMETER UserName Name of the user to reset. .PARAMETER Server LDS Server to contact. .PARAMETER Partition Partition of the LDS Server to search. .PARAMETER NewPassword The new password to assign. Autogenerates a random password if not specified. .PARAMETER Credential Credential to use for the request .EXAMPLE PS C:\> Reset-LdsAccountPassword -Name svc_whatever -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Resets the password of account 'svc_whatever' #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $UserName, [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [SecureString] $NewPassword = (New-Password -AsSecureString), [PSCredential] $Credential ) $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $userObject = Get-ADUser @ldsParamLight -LDAPFilter "(name=$UserName)" -SearchBase $Partition if (-not $userObject) { Stop-PSFFunction -Cmdlet $PSCmdlet -Message "Unable to find $UserName!" -EnableException $true } if (1 -lt @($userObject).Count) { Stop-PSFFunction -Cmdlet $PSCmdlet -Message "More than one account found for $UserName!`n$($userObject.DistinguishedName -join "`n")" -EnableException $true } Set-ADAccountPassword @ldsParam -NewPassword $NewPassword -Identity $userObject.ObjectGUID if (-not $userObject.Enabled) { Write-PSFMessage -Level Host -Message "Enabling account: $($userObject.Name)" Enable-ADAccount @ldsParam -Identity $userObject.ObjectGuid } Write-PSFMessage -Level Host -Message "Password reset for $($userObject.Name) executed." $null = Read-Host "Press enter to paste the new password to the clipboard." $cred = [PSCredential]::new("whatever", $NewPassword) $cred.GetNetworkCredential().Password | Set-Clipboard Write-PSFMessage -Level Host -Message "Password for $($userObject.Name) has been written to clipboard." } function Reset-LdsConfiguration { <# .SYNOPSIS Removes all registered configuration settings. .DESCRIPTION Removes all registered configuration settings. .EXAMPLE PS C:\> Reset-LdsConfiguration Removes all registered configuration settings. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param () $script:content = @{ user = @{ } group = @{ } organizationalUnit = @{ } groupmembership = @{ } accessrule = @{ } SchemaAttribute = @{ } } } function Test-LdsConfiguration { <# .SYNOPSIS Test all configured settings against the target LDS instance. .DESCRIPTION Test all configured settings against the target LDS instance. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Options Which part of the configuration to test for. Defaults to all of them ('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute') .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsConfiguration -Server lds1.contoso.com -Partition 'DC=Fabrikam,DC=org' Test all configured settings against the 'DC=Fabrikam,DC=org' LDS instance on server lds1.contoso.com. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [ValidateSet('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute')] [string[]] $Options = @('User', 'Group', 'OrganizationalUnit', 'GroupMembership', 'AccessRule', 'SchemaAttribute'), [switch] $Delete ) begin { $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential, Delete } process { if ($Options -contains 'SchemaAttribute' -and -not $Delete) { Test-LdsSchemaAttribute @ldsParam } if ($Options -contains 'OrganizationalUnit') { Test-LdsOrganizationalUnit @ldsParam } if ($Options -contains 'Group') { Test-LdsGroup @ldsParam } if ($Options -contains 'User') { Test-LdsUser @ldsParam } if ($Options -contains 'GroupMembership') { Test-LdsGroupMembership @ldsParam } if ($Options -contains 'AccessRule') { Test-LdsAccessRule @ldsParam } } } function Invoke-LdsAccessRule { <# .SYNOPSIS Applies all the configured access rules. .DESCRIPTION Applies all the configured access rules. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsAccessRule -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Apply all configured access rules to the 'DC=fabrikam,DC=org' partition on lds1.contoso.com #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-ADSec Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential } process { if (-not $TestResult) { $TestResult = Test-LdsAccessRule @ldsParam -Partition $Partition -Delete:$Delete } foreach ($testItem in $TestResult | Sort-Object Action -Descending) { switch ($testItem.Action) { 'Add' { $acl = Get-AdsAcl @ldsParam -Path $testItem.Identity $acl.AddAccessRule($testItem.Change.Rule) $acl | Set-AdsAcl @ldsParam -Path $testItem.Identity } 'Remove' { $acl = Get-AdsAcl @ldsParam -Path $testItem.Identity $null = $acl.RemoveAccessRule($testItem.Change.Rule) $acl | Set-AdsAcl @ldsParam -Path $testItem.Identity } } } } } function Test-LdsAccessRule { <# .SYNOPSIS Tests, whether the current access rules match the configured state. .DESCRIPTION Tests, whether the current access rules match the configured state. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsAccessRule -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Tests, whether the current access rules on lds1.contoso.com match the configured state. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete ) begin { #region Functions function Resolve-AccessRule { [OutputType([System.DirectoryServices.ActiveDirectoryAccessRule])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $RuleCfg, [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [hashtable] $SchemaCache = @{ }, [hashtable] $PrincipalCache = @{ }, [string] $DomainSID ) $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential $rights = $script:adrights[$RuleCfg.Rights] # resolve AccessRule settings $inheritanceType = 'None' if ($RuleCfg.Inheritance) { $inheritanceType = $RuleCfg.Inheritance } $objectType = [guid]::Empty $inheritedObjectType = [guid]::Empty if ($RuleCfg.ObjectType) { $objectType = $RuleCfg.ObjectType | Resolve-SchemaGuid @ldsParam -Cache $SchemaCache } if ($RuleCfg.InheritedObjectType) { $inheritedObjectType = $RuleCfg.InheritedObjectType | Resolve-SchemaGuid @ldsParam -Cache $SchemaCache } $type = 'Allow' if ($RuleCfg.Type) { $type = $RuleCfg.Type } $principal = $PrincipalCache["$($RuleCfg.IdentityType):$($RuleCfg.Identity)"] if (-not $principal -and 'SID' -eq $RuleCfg.IdentityType){ $ruleIdentity = $RuleCfg.Identity -replace '%DomainSID%', $DomainSID $sid = $ruleIdentity -as [System.Security.Principal.SecurityIdentifier] if (-not $sid) { throw "Principal is not a legal SID: $($RuleCfg.Identity)!" } $PrincipalCache["$($RuleCfg.IdentityType):$($RuleCfg.Identity)"] = $sid $principal = $PrincipalCache["$($RuleCfg.IdentityType):$($RuleCfg.Identity)"] } elseif (-not $principal) { $principalObject = Get-ADObject @ldsParam -SearchBase $Partition -LDAPFilter "(&(objectClass=$($RuleCfg.IdentityType))(name=$($RuleCfg.Identity)))" -Properties ObjectSID -ErrorAction Stop if (-not $principalObject) { throw "Principal not found: $($RuleCfg.IdentityType) - $($RuleCfg.Identity)" } $PrincipalCache["$($RuleCfg.IdentityType):$($RuleCfg.Identity)"] = $principalObject.ObjectSID $principal = $PrincipalCache["$($RuleCfg.IdentityType):$($RuleCfg.Identity)"] } [System.DirectoryServices.ActiveDirectoryAccessRule]::new( $principal, $rights, $type, $objectType, $inheritanceType, $inheritedObjectType ) } function Compare-AccessRule { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.DirectoryServices.ActiveDirectoryAccessRule[]] $Reference, [Parameter(Mandatory = $true, ValueFromPipeline = $true)] $InputObject, [switch] $NoMatch ) process { if (-not $InputObject) { return } $isMatched = $false foreach ($referenceObject in $Reference) { if ($referenceObject.ActiveDirectoryRights -bxor $InputObject.ActiveDirectoryRights) { continue } if ($referenceObject.InheritanceType -ne $InputObject.InheritanceType) { continue } if ($referenceObject.ObjectType -ne $InputObject.ObjectType) { continue } if ($referenceObject.InheritedObjectType -ne $InputObject.InheritedObjectType) { continue } if ($referenceObject.AccessControlType -ne $InputObject.AccessControlType) { continue } if ("$($referenceObject.IdentityReference)" -ne "$($InputObject.IdentityReference)") { continue } $isMatched = $true break } if ($isMatched -eq -not $NoMatch) { $InputObject } } } function Get-ObjectDefaultRule { [CmdletBinding()] param ( [string] $Path, [hashtable] $LdsParam, [hashtable] $LdsParamLight, $RootDSE, [hashtable] $DefaultPermissions ) $adObject = Get-ADObject @LdsParam -Identity $Path -Properties ObjectClass if ($DefaultPermissions.ContainsKey($adObject.ObjectClass)) { return $DefaultPermissions[$adObject.ObjectClass] } $class = Get-ADObject @ldsParamLight -SearchBase $RootDSE.schemaNamingContext -LDAPFilter "(&(objectClass=classSchema)(ldapDisplayName=$($adObject.ObjectClass)))" -Properties defaultSecurityDescriptor $acl = [System.DirectoryServices.ActiveDirectorySecurity]::new() $acl.SetSecurityDescriptorSddlForm($class.defaultSecurityDescriptor) $DefaultPermissions[$adObject.ObjectClass] = $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) $DefaultPermissions[$adObject.ObjectClass] } #endregion Functions Update-ADSec Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $rootDSE = Get-ADRootDSE @ldsParamLight $domainSID = (Get-ADObject @ldsParamLight -LDAPFilter '(&(objectCategory=group)(name=Administrators))' -SearchBase $ldsParam.Partition -Properties objectSID).ObjectSID.Value -replace '-512$' $principals = @{ } $schemaCache = @{ } $pathCache = @{ } } process { #region Adding foreach ($ruleCfg in $script:content.accessrule.Values) { $resolvedPath = $ruleCfg.Path -replace '%DomainDN%', $Partition try { $rule = Resolve-AccessRule @ldsParam -RuleCfg $ruleCfg -SchemaCache $schemaCache -PrincipalCache $principals -DomainSID $domainSID } catch { Write-PSFMessage -Level Warning -Message "Failed to process rule for $resolvedPath, granting $($ruleCfg.Rights) to $($ruleCfg.Identity)" -ErrorRecord $_ continue } if (-not $pathCache[$resolvedPath]) { $pathCache[$resolvedPath] = @($rule) } else { $pathCache[$resolvedPath] = @($pathCache[$resolvedPath]) + @($rule) } $acl = Get-AdsAcl @ldsParamLight -Path $resolvedPath $currentRules = $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) $matching = $currentRules | Compare-AccessRule -Reference $rule $change = [PSCustomObject]@{ Path = $resolvedPath Name = $ruleCfg.Identity Right = $ruleCfg.Rights Type = $rule.AccessControlType Rule = $rule } Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Force -Value { if ('Allow' -eq $this.Type) { '{0} -> {1}' -f $this.Name, $this.Right } else { '{0} != {1}' -f $this.Name, $this.Right } } if ($matching) { if ($Delete) { $change.Rule = $matching New-TestResult -Type AccessRule -Action Remove -Identity $resolvedPath -Configuration $ruleCfg -ADObject $acl -Change $change } continue } if ($Delete) { continue } New-TestResult -Type AccessRule -Action Add -Identity $resolvedPath -Configuration $ruleCfg -ADObject $acl -Change $change } #endregion Adding #region Removing $schemaDefaultPermissions = @{ } $sidToName = @{ } foreach ($adPath in $pathCache.Keys) { $defaultRules = Get-ObjectDefaultRule -Path $adPath -LdsParam $ldsParam -LdsParamLight $ldsParamLight -RootDSE $rootDSE -DefaultPermissions $schemaDefaultPermissions $intendedRules = @($defaultRules) + @($pathCache[$adPath]) | Remove-PSFNull $acl = Get-AdsAcl @ldsParamLight -Path $adPath $currentRules = $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) $surplusRules = $currentRules | Compare-AccessRule -Reference $intendedRules -NoMatch foreach ($surplusRule in $surplusRules) { # Skip OU deletion protection if ('S-1-1-0' -eq $surplusRule.IdentityReference -and 'Deny' -eq $surplusRule.AccessControlType) { continue } if (-not $sidToName[$surplusRule.IdentityReference]) { try { $sidToName[$surplusRule.IdentityReference] = Get-ADObject @ldsParamLight -SearchBase $Partition -LDAPFilter "(objectSID=$($surplusRule.IdentityReference))" -Properties Name } catch { $sidToName[$surplusRule.IdentityReference] = @{ Name = $surplusRule.IdentityReference }} if (-not $sidToName[$surplusRule.IdentityReference]) { $sidToName[$surplusRule.IdentityReference] = @{ Name = $surplusRule.IdentityReference } } } $change = [PSCustomObject]@{ Path = $adPath Name = $sidToName[$surplusRule.IdentityReference].Name Right = $surplusRule.ActiveDirectoryRights Type = $surplusRule.AccessControlType Rule = $surplusRule } Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Force -Value { if ('Allow' -eq $this.Type) { '{0} -> {1}' -f $this.Name, $this.Right } else { '{0} != {1}' -f $this.Name, $this.Right } } New-TestResult -Type AccessRule -Action Remove -Identity $adPath -ADObject $acl -Change $change } } #endregion Removing } } function Invoke-LdsGroup { <# .SYNOPSIS Applies all configured groups. .DESCRIPTION Applies all configured groups, creating or updating their settings as needed. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsGroup -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Applies all configured groups to 'DC=fabrikam,DC=org' on the server 'lds1.contoso.com'. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $systemProperties = 'ObjectClass', 'Path', 'Name', 'GroupScope' } process { if (-not $TestResult) { $TestResult = Test-LdsGroup @ldsParam -Delete:$Delete } foreach ($testItem in $TestResult) { switch ($testItem.Action) { 'Create' { $attributes = $testItem.Configuration | ConvertTo-PSFHashtable -Exclude $systemProperties $newParam = @{ Name = $testItem.Configuration.Name GroupScope = $testItem.Configuration.GroupScope Path = ($testItem.Identity -replace '^.+?,') } if (0 -lt $attributes.Count) { $newParam.OtherAttributes = $attributes } if (-not $newParam.GroupScope) { $newParam.GroupScope = 'DomainLocal' } New-ADGroup @ldsParamLight @newParam } 'Delete' { Remove-ADGroup @ldsParam -Identity $testItem.ADObject.ObjectGUID -Recursive -Confirm:$false } 'Update' { $update = @{ } foreach ($change in $testItem.Change) { $update[$change.Property] = $change.New } Set-ADObject @ldsParam -Identity $testItem.ADObject.ObjectGUID -Replace $update } } } } } function Test-LdsGroup { <# .SYNOPSIS Tests, whether the targeted ad lds server conforms to the group configuration. .DESCRIPTION Tests, whether the targeted ad lds server conforms to the group configuration. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsGroup -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Tests whether the groups in 'DC=fabrikam,DC=org' on lds1.contoso.com are in their desired state. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $systemProperties = 'ObjectClass', 'Path', 'Name' } process { foreach ($configurationItem in $script:content.group.Values) { $path = 'CN={0},{1}' -f $configurationItem.Name, ($configurationItem.Path -replace '%DomainDN%',$Partition) if ($path -notmatch ',DC=') { $path = $path, $Partition -join ',' } $resultDefaults = @{ Type = 'Group' Identity = $path Configuration = $configurationItem } $failed = $null $adObject = $null try { $adObject = Get-ADGroup @ldsParam -Identity $path -Properties * -ErrorAction SilentlyContinue -ErrorVariable failed } catch { $failed = $_ } if ($failed -and $failed.CategoryInfo.Category -ne 'ObjectNotFound') { foreach ($failure in $failed) { Write-Error $failure } continue } #region Cases # Case: Does not Exist if (-not $adObject) { if ($Delete) { continue } New-TestResult @resultDefaults -Action Create continue } # Case: Exists $resultDefaults.ADObject = $adObject if ($Delete) { New-TestResult @resultDefaults -Action Delete continue } $changes = foreach ($pair in $configurationItem.GetEnumerator()) { if ($pair.Key -in $systemProperties) { continue } if ($pair.Value -ne $adObject.$($pair.Key)) { New-Change -Identity $path -Property $pair.Key -OldValue $adObject.$($pair.Key) -NewValue $pair.Value } } if ($changes) { New-TestResult @resultDefaults -Action Update -Change $changes } #endregion Cases } } } function Invoke-LdsGroupMembership { <# .SYNOPSIS Applies the configuration-defined group memberships. .DESCRIPTION Applies the configuration-defined group memberships. It is generally good idea to apply groups and users first. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsGroupMembership -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Applies the configuration-defined group memberships against 'DC=fabrikam,DC=org' on lds1.contoso.com. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential } process { if (-not $TestResult) { $TestResult = Test-LdsGroupMembership @ldsParam -Delete:$Delete } foreach ($testItem in $TestResult) { switch ($testItem.Action) { 'Update' { foreach ($change in $testItem.Change) { switch ($change.Action) { 'Add' { Add-ADGroupMember @ldsParam -Identity $testItem.ADObject -Members $change.DN } 'Remove' { Remove-ADGroupMember @ldsParam -Identity $testItem.ADObject -Members $change.DN } } } } } } } } function Test-LdsGroupMembership { <# .SYNOPSIS Test whether the group memberships are in their desired state. .DESCRIPTION Test whether the group memberships are in their desired state. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsGroupMembership -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Test whether the group memberships are in their desired state for 'DC=fabrikam,DC=org' on lds1.contoso.com #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Remap @{ Partition = 'SearchBase' } $members = @{ } $ldsObjects = @{ } } process { foreach ($configurationSets in $script:content.groupmembership.Values | Group-Object { $_.Group }) { Write-PSFMessage -Level Verbose -Message "Processing group memberships of {0}" -StringValues $configurationSets.Name $groupObject = Get-ADGroup @ldsParamLight -LDAPFilter "(name=$($configurationSets.Name))" -Properties * if (-not $groupObject) { Write-PSFMessage -Level Warning -Message "Group not found: {0}! Cannot process members" -StringValues $configurationSets.Name continue } $ldsObjects[$groupObject.DistinguishedName] = $groupObject #region Determine intended members $intendedMembers = foreach ($entry in $configurationSets.Group) { # Read from Cache if ($members["$($entry.Type):$($entry.Member)"]) { $members["$($entry.Type):$($entry.Member)"] continue } # Read from LDS Instance $ldsObject = Get-ADObject @ldsParamLight -LDAPFilter "(&(objectClass=$($entry.Type))(name=$($entry.Member)))" -Properties * # Not Yet Created if (-not $ldsObject) { Write-PSFMessage -Level Warning -Message 'Unable to find {0} {1}, will be unable to add it to group {2}' -StringValues $entry.Type, $entry.Member, $entry.Group continue } $members["$($entry.Type):$($entry.Member)"] = $ldsObject $ldsObjects[$ldsObject.DistinguishedName] = $ldsObject $ldsObject } #endregion Determine intended members #region Determine actual members $actualMembers = foreach ($member in $groupObject.Members) { if ($ldsObjects[$member]) { $ldsObjects[$member] continue } try { $ldsObject = Get-ADObject @ldsParam -Identity $member -Properties * -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -Message "Error resolving member of {0}: {1}" -StringValues $configurationSets.Name, $member -ErrorRecord $_ continue } $ldsObjects[$ldsObject.DistinguishedName] = $ldsObject $ldsObject } #endregion Determine actual members #region Compare and generate changes $toAdd = $intendedMembers | Where-Object DistinguishedName -NotIn $actualMembers.DistinguishedName | ForEach-Object { [PSCustomObject]@{ PSTypename = 'AdLdsTools.Change.GroupMembership' Action = 'Add' Member = $_.Name Type = $_.ObjectClass DN = $_.DistinguishedName Group = $configurationSets.Name } } if ($Delete) { $toAdd = @() } $toRemove = $actualMembers | Where-Object { (-not $Delete -and $_.DistinguishedName -NotIn $intendedMembers.DistinguishedName) -or ($Delete -and $_.DistinguishedName -in $intendedMembers.DistinguishedName) } | ForEach-Object { [PSCustomObject]@{ PSTypename = 'AdLdsTools.Change.GroupMembership' Action = 'Remove' Member = $_.Name Type = $_.ObjectClass DN = $_.DistinguishedName Group = $configurationSets.Name } } $changes = @($toAdd) + @($toRemove) | Remove-PSFNull | Add-Member -MemberType ScriptMethod -Name ToString -Value { '{0} -> {1}' -f $this.Action, $this.Member } -Force -PassThru #endregion Compare and generate changes if ($changes) { New-TestResult -Type GroupMemberShip -Action Update -Identity $groupObject.Name -ADObject $groupObject -Configuration $configurationSets -Change $changes } } } } function Invoke-LdsOrganizationalUnit { <# .SYNOPSIS Creates the desired organizational units. .DESCRIPTION Creates the desired organizational units. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsOrganizationalUnit -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Creates the desired organizational units in 'DC=fabrikam,DC=org' on lds1.contoso.com #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $systemProperties = 'ObjectClass', 'Path', 'Name' $filter = { # Delete actions should go from innermost to top-level # Create actions should go from top-level to most nested if ($_.Action -eq 'Delete') { $_.Identity.Length * -1 } else { $_.Identity.Length } } } process { if (-not $TestResult) { $TestResult = Test-LdsOrganizationalUnit @ldsParam -Delete:$Delete } foreach ($testItem in $TestResult | Sort-Object Action, $filter) { switch ($testItem.Action) { 'Create' { $attributes = $testItem.Configuration | ConvertTo-PSFHashtable -Exclude $systemProperties $newParam = @{ Name = $testItem.Configuration.Name Path = ($testItem.Identity -replace '^.+?,') } if (0 -lt $attributes.Count) { $newParam.OtherAttributes = $attributes } New-ADOrganizationalUnit @ldsParamLight @newParam } 'Delete' { Unprotect-OrganizationalUnit @ldsParam -Identity $testItem.ADObject.ObjectGUID Remove-ADOrganizationalUnit @ldsParam -Identity $testItem.ADObject.ObjectGUID -Recursive -Confirm:$false } 'Update' { $update = @{ } foreach ($change in $testItem.Change) { $update[$change.Property] = $change.New } Set-ADObject @ldsParam -Identity $testItem.ADObject.ObjectGUID -Replace $update } } } } } function Test-LdsOrganizationalUnit { <# .SYNOPSIS Tests, whether the desired organizational units exist. .DESCRIPTION Tests, whether the desired organizational units exist. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsOrganizationalUnit -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Tests, whether the desired organizational units exist in 'DC=fabrikam,DC=org' on lds1.contoso.com #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $systemProperties = 'ObjectClass', 'Path', 'Name' } process { foreach ($configurationItem in $script:content.organizationalUnit.Values) { $path = 'OU={0},{1}' -f $configurationItem.Name, ($configurationItem.Path -replace '%DomainDN%',$Partition) if ($path -notmatch ',DC=') { $path = $path, $Partition -join ',' } $resultDefaults = @{ Type = 'OrganizationalUnit' Identity = $path Configuration = $configurationItem } $failed = $null $adObject = $null try { $adObject = Get-ADOrganizationalUnit @ldsParam -Identity $path -Properties * -ErrorAction SilentlyContinue -ErrorVariable failed } catch { $failed = $_ } if ($failed -and $failed.CategoryInfo.Category -ne 'ObjectNotFound') { foreach ($failure in $failed) { Write-Error $failure } continue } #region Cases # Case: Does not Exist if (-not $adObject) { if ($Delete) { continue } New-TestResult @resultDefaults -Action Create continue } # Case: Exists $resultDefaults.ADObject = $adObject if ($Delete) { New-TestResult @resultDefaults -Action Delete continue } $changes = foreach ($pair in $configurationItem.GetEnumerator()) { if ($pair.Key -in $systemProperties) { continue } if ($pair.Value -ne $adObject.$($pair.Key)) { New-Change -Identity $path -Property $pair.Key -OldValue $adObject.$($pair.Key) -NewValue $pair.Value } } if ($changes) { New-TestResult @resultDefaults -Action Update -Change $changes } #endregion Cases } } } function Invoke-LdsSchemaAttribute { <# .SYNOPSIS Applies the intended schema attributes. .DESCRIPTION Applies the intended schema attributes. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsSchemaAttribute -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Applies the intended schema attributes to 'DC=fabrikam,DC=org' on lds1.contoso.com. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $systemProperties = 'ObjectClass', 'AttributeID', 'IsDeleted', 'Optional', 'MayContain' $rootDSE = Get-ADRootDSE @ldsParamLight } process { if (-not $TestResult) { $TestResult = Test-LdsSchemaAttribute @ldsParam } $testResultsSorted = $TestResult | Sort-Object { switch ($_.Action) { Create { 1 } Delete { 2 } Update { 3 } Add { 4 } Remove { 5 } Default { 6 } } } foreach ($testItem in $testResultsSorted) { switch ($testItem.Action) { 'Create' { $attributes = $testItem.Configuration | ConvertTo-PSFHashtable -Exclude $systemProperties $attributes.AttributeID = $testItem.Configuration.AttributeID $name = $testItem.Configuration.Name if (-not $name) { $name = $testItem.Configuration.AdminDisplayName } New-ADObject @ldsParamLight -Type attributeSchema -Name $name -Path $rootDSE.schemaNamingContext -OtherAttributes $attributes } 'Delete' { $testItem.ADObject | Set-ADObject @ldsParamLight -Replace @{ IsDeleted = $true } } 'Update' { $replacements = @{ } foreach ($change in $testItem.Change) { $replacements[$change.Property] = $change.New } $testItem.ADObject | Set-ADObject @ldsParamLight -Replace $replacements } 'Add' { $testItem.Change.Data | Set-ADObject @ldsParamLight -Add @{ mayContain = $testItem.ADObject.lDAPDisplayName } } 'Remove' { $testItem.Change.Data | Set-ADObject @ldsParamLight -Remove @{ mayContain = $testItem.Identity } } 'Rename' { $testItem.ADObject | Rename-ADObject @ldsParamLight -NewName @($testItem.Change.New)[0] } } } } } function Test-LdsSchemaAttribute { <# .SYNOPSIS Tests, whether the intended schema attributes have been applied. .DESCRIPTION Tests, whether the intended schema attributes have been applied. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .EXAMPLE PS C:\> Test-LdsSchemaAttribute -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Tests, whether the intended schema attributes have been applied to 'DC=fabrikam,DC=org' on lds1.contoso.com #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $systemProperties = 'ObjectClass', 'AttributeID', 'IsDeleted', 'Optional', 'MayContain' $rootDSE = Get-ADRootDSE @ldsParamLight $classes = Get-ADObject @ldsParamLight -SearchBase $rootDSE.schemaNamingContext -LDAPFilter '(objectClass=classSchema)' -Properties mayContain, adminDisplayName } process { foreach ($schemaSetting in $script:content.SchemaAttribute.Values) { $schemaObject = $null $schemaObject = Get-ADObject @ldsParamLight -LDAPFilter "(attributeID=$($schemaSetting.AttributeID))" -SearchBase $rootDSE.schemaNamingContext -ErrorAction Ignore -Properties * $resultDefaults = @{ Type = 'SchemaAttribute' Identity = $schemaSetting.AdminDisplayName Configuration = $schemaSetting } if (-not $schemaObject) { # If we already want to disable the attribute, no need to create it if ($schemaSetting.IsDeleted) { continue } if ($schemaSetting.Optional) { continue } New-TestResult @resultDefaults -Action Create foreach ($entry in $schemaSetting.MayContain) { if ($classes.AdminDisplayName -notcontains $entry) { continue } New-TestResult @resultDefaults -Action Add -Change @( New-Change -Identity $schemaSetting.AdminDisplayName -Property MayContain -NewValue $entry -Data ($classes | Where-Object AdminDisplayName -EQ $entry) ) } continue } $resultDefaults.ADObject = $schemaObject if ($schemaSetting.IsDeleted -and -not $schemaObject.isDeleted) { New-TestResult @resultDefaults -Action Delete -Change @( New-Change -Identity $schemaSetting.AdminDisplayName -Property IsDeleted -OldValue $false -NewValue $true ) } if ($schemaSetting.Name -and $schemaSetting.Name -cne $schemaObject.Name) { New-TestResult @resultDefaults -Action Rename -Change @( New-Change -Identity $schemaSetting.AdminDisplayName -Property Name -OldValue $schemaObject.Name -NewValue $schemaSetting.Name ) } $changes = foreach ($pair in $schemaSetting.GetEnumerator()) { if ($pair.Key -in $systemProperties) { continue } if ($pair.Value -cne $schemaObject.$($pair.Key)) { New-Change -Identity $schemaSetting.AdminDisplayName -Property $pair.Key -OldValue $schemaObject.$($pair.Key) -NewValue $pair.Value } } if ($changes) { New-TestResult @resultDefaults -Action Update -Change $changes } $mayBeContainedIn = $schemaSetting.MayContain if ($schemaSetting.IsDeleted) { $mayBeContainedIn = @() } $classesMatch = $classes | Where-Object mayContain -Contains $schemaObject.LdapDisplayName foreach ($matchingclass in $classesMatch) { if ($matchingclass.AdminDisplayName -in $mayBeContainedIn) { continue } New-TestResult @resultDefaults -Action Remove -Change @( New-Change -Identity $schemaSetting.AdminDisplayName -Property MayContain -OldValue $matchingclass.AdminDisplayName -DisplayStyle RemoveValue -Data $matchingClass ) } foreach ($allowedClass in $mayBeContainedIn) { if ($classesMatch.AdminDisplayName -contains $allowedClass) { continue } New-TestResult @resultDefaults -Action Add -Change @( New-Change -Identity $schemaSetting.AdminDisplayName -Property MayContain -NewValue $allowedClass -Data ($classes | Where-Object AdminDisplayName -EQ $allowedClass) ) } } } } function Invoke-LdsUser { <# .SYNOPSIS Creates the intended user objects. .DESCRIPTION Creates the intended user objects. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .PARAMETER TestResult Result objects of the associated Test-Command. Allows cherry-picking which change to apply. If not specified, it will a test and apply all test results instead. .EXAMPLE PS C:\> Invoke-LdsUser -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Creates the intended user objects for 'DC=fabrikam,DC=org' on lds1.contoso.com. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete, [Parameter(ValueFromPipeline = $true)] $TestResult ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $ldsParamLight = $ldsParam | ConvertTo-PSFHashtable -Exclude Partition $systemProperties = 'ObjectClass', 'Path', 'Name', 'Enabled' } process { if (-not $TestResult) { $TestResult = Test-LdsUser @ldsParam -Delete:$Delete } foreach ($testItem in $TestResult) { switch ($testItem.Action) { 'Create' { $attributes = $testItem.Configuration | ConvertTo-PSFHashtable -Exclude $systemProperties $newParam = @{ Name = $testItem.Configuration.Name Path = ($testItem.Identity -replace '^.+?,') OtherAttributes = $attributes } if ($testItem.Configuration.Enabled) { $newParam += @{ Enabled = $true AccountPassword = New-Password -AsSecureString } } if (0 -eq $newParam.OtherAttributes.Count) { $newParam.Remove('OtherAttributes') } New-ADUser @ldsParamLight @newParam } 'Delete' { Remove-ADUser @ldsParam -Identity $testItem.ADObject.ObjectGUID -Recursive -Confirm:$false } 'Update' { $update = @{ } foreach ($change in $testItem.Change) { $update[$change.Property] = $change.New } Set-ADObject @ldsParam -Identity $testItem.ADObject.ObjectGUID -Replace $update } } } } } function Test-LdsUser { <# .SYNOPSIS Tests, whether the desired users have already been created. .DESCRIPTION Tests, whether the desired users have already been created. .PARAMETER Server The LDS Server to target. .PARAMETER Partition The Partition on the LDS Server to target. .PARAMETER Credential Credentials to use for the operation. .PARAMETER Delete Undo everything defined in configuration. Allows rolling back after deployment. .EXAMPLE PS C:\> Test-LdsUser -Server lds1.contoso.com -Partition 'DC=fabrikam,DC=org' Tests, whether the desired users have already been created for 'DC=fabrikam,DC=org' on lds1.contoso.com #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $Server, [Parameter(Mandatory = $true)] [string] $Partition, [PSCredential] $Credential, [switch] $Delete ) begin { Update-LdsConfiguration -LdsServer $Server -LdsPartition $Partition $ldsParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Partition, Credential $systemProperties = 'ObjectClass', 'Path', 'Name', 'Enabled' } process { foreach ($configurationItem in $script:content.user.Values) { if ($configurationItem.SamAccountName -and -not $configurationItem.Name) { $configurationItem.Name = $configurationItem.SamAccountName } $path = 'CN={0},{1}' -f $configurationItem.Name, ($configurationItem.Path -replace '%DomainDN%',$Partition) if ($path -notmatch ',DC=') { $path = $path, $Partition -join ',' } $resultDefaults = @{ Type = 'User' Identity = $path Configuration = $configurationItem } $failed = $null $adObject = $null try { $adObject = Get-ADUser @ldsParam -Identity $path -Properties * -ErrorAction SilentlyContinue -ErrorVariable failed } catch { $failed = $_ } if ($failed -and $failed.CategoryInfo.Category -ne 'ObjectNotFound') { foreach ($failure in $failed) { Write-Error $failure } continue } #region Cases # Case: Does not Exist if (-not $adObject) { if ($Delete) { continue } New-TestResult @resultDefaults -Action Create continue } # Case: Exists $resultDefaults.ADObject = $adObject if ($Delete) { New-TestResult @resultDefaults -Action Delete continue } $changes = foreach ($pair in $configurationItem.GetEnumerator()) { if ($pair.Key -in $systemProperties) { continue } if ($pair.Value -ne $adObject.$($pair.Key)) { New-Change -Identity $path -Property $pair.Key -OldValue $adObject.$($pair.Key) -NewValue $pair.Value } } if ($changes) { New-TestResult @resultDefaults -Action Update -Change $changes } #endregion Cases } } } <# 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 'ADLDSMF' -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 'ADLDSMF' -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 'ADLDSMF' -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." <# 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 'ADLDSMF.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "ADLDSMF.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name ADLDSMF.alcohol #> $script:content = @{ user = @{ } group = @{ } organizationalUnit = @{ } groupmembership = @{ } accessrule = @{ } SchemaAttribute = @{ } } $script:adrights = @{ 'FullControl' = @( [System.DirectoryServices.ActiveDirectoryRights]::GenericAll ) 'Enumerate' = @( [System.DirectoryServices.ActiveDirectoryRights]::ListChildren [System.DirectoryServices.ActiveDirectoryRights]::ListObject ) 'Read' = @( [System.DirectoryServices.ActiveDirectoryRights]::GenericRead ) 'EditObject' = @( [System.DirectoryServices.ActiveDirectoryRights]::Delete [System.DirectoryServices.ActiveDirectoryRights]::ReadProperty [System.DirectoryServices.ActiveDirectoryRights]::WriteProperty ) 'ManageChildren' = @( [System.DirectoryServices.ActiveDirectoryRights]::CreateChild [System.DirectoryServices.ActiveDirectoryRights]::DeleteChild ) 'Extended' = @( [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight ) } # Make ACL work again $null = Get-Acl -Path . -ErrorAction Ignore # Disable AD Connection Check Set-PSFConfig -FullName 'ADSec.Connect.NoAssertion' -Value $true # Load config if present if (Test-Path -Path "$script:ModuleRoot\Config") { Import-LdsConfiguration -Path "$script:ModuleRoot\Config" } New-PSFLicense -Product 'ADLDSMF' -Manufacturer 'frweinma' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2023-12-11") -Text @" Copyright (c) 2023 frweinma 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. "@ #endregion Load compiled code |