diff --git a/ForestManagement/ForestManagement.psd1 b/ForestManagement/ForestManagement.psd1 index 85ac39e..f150395 100644 --- a/ForestManagement/ForestManagement.psd1 +++ b/ForestManagement/ForestManagement.psd1 @@ -3,7 +3,7 @@ RootModule = 'ForestManagement.psm1' # Version number of this module. - ModuleVersion = '1.5.82' + ModuleVersion = '1.6.85' # ID used to uniquely identify this module GUID = '7de4379d-17c8-48d3-bd6d-93279aef64bb' @@ -47,8 +47,11 @@ # Functions to export from this module FunctionsToExport = @( 'Clear-FMConfiguration' + 'Get-FMAccessRule' + 'Get-FMAcl' 'Get-FMCallback' 'Get-FMCertificate' + 'Get-FMContentMode' 'Get-FMExchangeSchema' 'Get-FMForestLevel' 'Get-FMNTAuthStore' @@ -58,6 +61,8 @@ 'Get-FMSite' 'Get-FMSiteLink' 'Get-FMSubnet' + 'Invoke-FMAccessRule' + 'Invoke-FMAcl' 'Invoke-FMCertificate' 'Invoke-FMExchangeSchema' 'Invoke-FMForestLevel' @@ -69,6 +74,8 @@ 'Invoke-FMSite' 'Invoke-FMSiteLink' 'Invoke-FMSubnet' + 'Register-FMAccessRule' + 'Register-FMAcl' 'Register-FMCallback' 'Register-FMCertificate' 'Register-FMExchangeSchema' @@ -81,6 +88,9 @@ 'Register-FMSite' 'Register-FMSiteLink' 'Register-FMSubnet' + 'Set-FMContentMode' + 'Test-FMAccessRule' + 'Test-FMAcl' 'Test-FMCertificate' 'Test-FMExchangeSchema' 'Test-FMForestLevel' @@ -92,6 +102,8 @@ 'Test-FMSite' 'Test-FMSiteLink' 'Test-FMSubnet' + 'Unregister-FMAccessRule' + 'Unregister-FMAcl' 'Unregister-FMCallback' 'Unregister-FMCertificate' 'Unregister-FMExchangeSchema' @@ -106,13 +118,13 @@ ) # Cmdlets to export from this module - # CmdletsToExport = '' + CmdletsToExport = @() # Variables to export from this module # VariablesToExport = '' # Aliases to export from this module - # AliasesToExport = '' + AliasesToExport = @() # List of all modules packaged with this module # ModuleList = @() diff --git a/ForestManagement/changelog.md b/ForestManagement/changelog.md index 9299679..afb0616 100644 --- a/ForestManagement/changelog.md +++ b/ForestManagement/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 1.6.85 (2026-06-19) + +- New: Component AccessRule - Managed access rules across Configuration and Schema naming context +- New: Component ACL - Manage owners and inheritance across Configuration and Schema naming context +- New: Component ContentMode - define management scope across Configuration and Schema naming context + ## 1.5.82 (2025-10-31) - Upd: General - Use the shared managed remoting feature, allowing configuring session options. diff --git a/ForestManagement/en-us/strings.psd1 b/ForestManagement/en-us/strings.psd1 index 2e558a7..647eeeb 100644 --- a/ForestManagement/en-us/strings.psd1 +++ b/ForestManagement/en-us/strings.psd1 @@ -22,7 +22,25 @@ 'Invoke-Callback.Invoking' = 'Executing callback: {0}' # $callback.Name 'Invoke-Callback.Invoking.Failed' = 'Error executing callback: {0}' # $callback.Name 'Invoke-Callback.Invoking.Success' = 'Successfully executed callback: {0}' # $callback.Name - + + 'Invoke-FMAcl.MissingADObject' = 'The target object could not be found: {0}' # $testItem.Identity + 'Invoke-FMAcl.NoAccess' = 'Failed to access Acl on {0}' # $testItem.Identity + 'Invoke-FMAcl.OwnerNotResolved' = 'Was unable to resolve the current owner ({1}) of {0}' # $testItem.Identity, $testItem.ADObject.GetOwner([System.Security.Principal.SecurityIdentifier]) + 'Invoke-FMAcl.ShouldManage' = 'The ADObject {0} has no defined ACL state and should either be configured or removed' # $testItem.Identity + 'Invoke-FMAcl.UpdatingInheritance' = 'Updating inheritance - Inheritance Disabled: {0}' # $testItem.Configuration.NoInheritance + 'Invoke-FMAcl.UpdatingOwner' = 'Granting ownership to {0}' # ($testItem.Configuration.Owner | Resolve-String) + + 'Invoke-FMAccessRule.Access.Failed' = 'Failed to access ACL on {0}' # $testItem.Identity + 'Invoke-FMAccessRule.AccessRule.Create' = 'Adding access rule for {0}, granting {1} ({2})' # $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType + 'Invoke-FMAccessRule.AccessRule.Creation.Failed' = 'Failed to create accessrule at {0} for {1}' # $testItem.Identity, $changeEntry.Configuration.IdentityReference + 'Invoke-FMAccessRule.AccessRule.Remove' = 'Removing access rule for {0}, granting {1} ({2}) from {3}' # $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName + 'Invoke-FMAccessRule.AccessRule.Remove.Error.Consistency' = 'Failed to remove access rule from {0}! This may be due to a consistency error. Investigate the object, it may be resolvable via the dsa GUI in the security tab' # $testItem.Identity + 'Invoke-FMAccessRule.AccessRule.Remove.Failed' = 'Failed to removing access rule for {0}, granting {1} ({2}) from {3} for unknown reasons (sorry). If this persists, consider enabling the alternative deletion mode through the "Domainmanagement.AccessRules.Remove.Option2" configuration setting.' # $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName + 'Invoke-FMAccessRule.AccessRule.Restore' = 'Restoring access rule from schema default for {0}, granting {1} ({2})' # $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType + 'Invoke-FMAccessRule.ADObject.Missing' = 'Cannot process access rules, due to missing AD object: {0}. Please ensure the domain object is created before trying to apply rules to it!' # $testItem.Identity + 'Invoke-FMAccessRule.Processing.Execute' = 'Applying {0} out of {1} intended access rule changes' # ($testItem.Changed.Count - $failedCount), $testItem.Changed.Count + 'Invoke-FMAccessRule.Processing.Rules' = 'Processing {1} access rule changes on {0}' # $testItem.Identity, $testItem.Changed.Count + 'Invoke-FMCertificate.Add' = 'Adding {1} certificate: {0}' # $testResult.Configuration.Certificate.Subject, $testResult.Configuration.Type 'Invoke-FMCertificate.Invalid.Input' = 'Invalid input - not a valid testresult object returned by Test-FMCertificate: {0}' # $testResult 'Invoke-FMCertificate.Remove' = 'Removing certificate {0} from {1}' # $testResult.ADObject.Subject, $testResult.ADObject.ADObject @@ -105,7 +123,22 @@ 'Remove-SchemaAdminCredential.TemporaryAccount.Remove' = 'Removing temporary schema admin account {0}' # $script:temporarySchemaUpdateUser.Name 'Remove-SchemaAdminCredential.TemporaryAccount.Remove.Failed' = 'Failed to remove temporary schema admin account {0}' # $script:temporarySchemaUpdateUser.Name + 'Resolve-ContentSearchBase.Exclude.NotFound' = 'Failed to find excluded ou/container: {0}' # $item.Name + 'Resolve-ContentSearchBase.Include.NotFound' = 'Failed to find included ou/container: {0}' # $item.Name + 'Resolve-ContentSearchBase.Searchbase.Found' = 'Resolved searchbase in {2}: {0} | {1}' # $searchBase.SearchScope, $searchBase.SearchBase, $script:domainContext.Fqdn + 'Resolve-SchemaAttribute.Update.SystemOnlyError' = 'Cannot update {0} to {1} on {2}. The attribute property is system protected and can only ever be defined when creating a new attribute! This cannot be undone and only replacing the attribute with a new attribute will allow you to resolve the issue.' # $attributeName, $attributes.$attributeName, $ADObject + + 'Set-FMContentMode.Error.UnknownExcludedComponent' = 'Error excluding a Component from the Forest Content Mode. Unexpected Component: {0}. Ensure the Component specified not only exists, but also supports being excluded from Forest Content Mode.' # $pair.Key + + 'Test-FMAccessRule.DefaultPermission.Failed' = 'Failed to retrieve default permissions from Schema when connecting to {0}' # $Server + 'Test-FMAccessRule.NoAccess' = 'Failed to access {0}' # $resolvedPath + 'Test-FMAccessRule.Parallel.Error' = 'Failed to process {0}' # $fail.ADObject + + 'Test-FMAcl.ADObjectNotFound' = 'The target object could not be found: {0}' # $resolvedPath + 'Test-FMAcl.NoAccess' = 'Failed to access Acl on {0}' # $resolvedPath + 'Test-FMAcl.ResolveOwner' = 'Resolving acceptable owners via Privileged Group Set {0} for object {1}' # $Category.Owner.Trim('_'),$ADObject.DistinguishedName + 'Test-FMSchema.Connect.Failed' = 'Failed to connect to {0}' # $Server 'Test-FMSchemaDefaultPermission.Class.IdentityUncertain' = 'Unable to resolve all identities for the default permissions to apply to {0}. This objectclass will be skipped instead.' # $Configuration[0].ClassName diff --git a/ForestManagement/functions/AccessRule/Get-FMAccessRule.ps1 b/ForestManagement/functions/AccessRule/Get-FMAccessRule.ps1 new file mode 100644 index 0000000..1eba2b8 --- /dev/null +++ b/ForestManagement/functions/AccessRule/Get-FMAccessRule.ps1 @@ -0,0 +1,32 @@ +function Get-FMAccessRule +{ + <# + .SYNOPSIS + Returns the list of configured access rules. + + .DESCRIPTION + Returns the list of configured access rules. + These access rules define the desired state where delegation in a forest's configuration & schema naming context is concerned. + This is consumed by Test-FMAccessRule, see the help on that command for more details. + + .PARAMETER Identity + The Identity to filter by. + This allows swiftly filtering by who is being granted permission. + + .EXAMPLE + PS C:\> Get-FMAccessRule + + Returns a list of all registered accessrules + #> + [CmdletBinding()] + Param ( + [string] + $Identity = '*' + ) + + process + { + $($script:accessRules.Values | Where-Object IdentityReference -like $Identity) + $($script:accessCategoryRules.Values | Where-Object IdentityReference -like $Identity) + } +} diff --git a/ForestManagement/functions/AccessRule/Invoke-FMAccessRule.ps1 b/ForestManagement/functions/AccessRule/Invoke-FMAccessRule.ps1 new file mode 100644 index 0000000..9ad5123 --- /dev/null +++ b/ForestManagement/functions/AccessRule/Invoke-FMAccessRule.ps1 @@ -0,0 +1,166 @@ +function Invoke-FMAccessRule { + <# + .SYNOPSIS + Applies the desired state of accessrule configuration. + + .DESCRIPTION + Applies the desired state of accessrule configuration. + Define the desired state with Register-FMAccessRule. + Test the desired state with Test-FMAccessRule. + + .PARAMETER InputObject + Test results provided by the associated test command. + Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. + + .PARAMETER Server + The server / domain to work with. + + .PARAMETER Credential + The credentials to use for this operation. + + .PARAMETER Confirm + If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + + .PARAMETER WhatIf + If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. + + .PARAMETER EnableException + This parameters disables user-friendly warnings and enables the throwing of exceptions. + This is less user friendly, but allows catching exceptions in calling scripts. + + .EXAMPLE + PS C:\> Invoke-FMAccessRule -Server contoso.com + + Applies the desired access rule configuration to the contoso.com forest. + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param ( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + + [PSFComputer] + $Server, + + [PSCredential] + $Credential, + + [switch] + $EnableException + ) + + begin { + $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential + $parameters['Debug'] = $false + Assert-ADConnection @parameters -Cmdlet $PSCmdlet + Invoke-Callback @parameters -Cmdlet $PSCmdlet + Assert-Configuration -Type accessRules -Cmdlet $PSCmdlet + Set-FMDomainContext @parameters + + $alternativeRemoval = Get-PSFConfigValue -FullName 'ForestManagement.AccessRules.Remove.Option2' -Fallback $false + } + process { + if (-not $InputObject) { + $InputObject = Test-FMAccessRule @parameters + } + + foreach ($testItem in $InputObject) { + # Catch invalid input - can only process test results + if ($testItem.PSObject.TypeNames -notcontains 'ForestManagement.AccessRule.TestResult') { + Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-FMAccessRule', $testItem -Target $testItem -Continue -EnableException $EnableException + } + + switch ($testItem.Type) { + 'Update' { + Write-PSFMessage -Level Debug -String 'Invoke-FMAccessRule.Processing.Rules' -StringValues $testItem.Identity, $testItem.Changed.Count -Target $testItem + + try { $aclObject = Get-AdsAcl @parameters -Path $testItem.Identity -EnableException } + catch { Stop-PSFFunction -String 'Invoke-FMAccessRule.Access.Failed' -StringValues $testItem.Identity -EnableException $EnableException -Target $testItem -Continue -ErrorRecord $_ } + $failedCount = 0 + foreach ($changeEntry in $testItem.Changed) { + #region Remove Access Rules + if ($changeEntry.Type -eq 'Delete') { + Write-PSFMessage -Level InternalComment -String 'Invoke-FMAccessRule.AccessRule.Remove' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName -Target $changeEntry + try { $aclObject.RemoveAccessRuleSpecific($changeEntry.ADObject.OriginalRule) } + catch { + Write-PSFMessage -Level Warning -String 'Invoke-FMAccessRule.AccessRule.Remove.Error.Consistency' -StringValues $testItem.Identity -Target $changeEntry + continue + } + Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $changeEntry.ADObject.OriginalRule.IdentityReference + + $stillThere = $false + foreach ($rule in $aclObject.GetAccessRules($true, $false, [System.Security.Principal.NTAccount])) { + if (Test-AdcAccessRuleEquality -Parameters $parameters -Rule1 $rule -Rule2 $changeEntry.ADObject.OriginalRule) { + $stillThere = $true + $failedCount = $failedCount + 1 + break + } + } + + if ($stillThere -and $alternativeRemoval) { + $null = $aclObject.RemoveAccessRule($changeEntry.ADObject.OriginalRule) + Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $changeEntry.ADObject.OriginalRule.IdentityReference + + $stillThere = $false + foreach ($rule in $aclObject.GetAccessRules($true, $false, [System.Security.Principal.NTAccount])) { + if (Test-AdcAccessRuleEquality -Parameters $parameters -Rule1 $rule -Rule2 $changeEntry.ADObject.OriginalRule) { + $stillThere = $true + $failedCount = $failedCount + 1 + break + } + } + } + + if ($stillThere) { + Write-PSFMessage -Level Warning -String 'Invoke-FMAccessRule.AccessRule.Remove.Failed' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName -Target $changeEntry -Debug:$false + } + continue + } + #endregion Remove Access Rules + + #region Add Access Rules + if ($changeEntry.Type -eq 'Create') { + Write-PSFMessage -Level InternalComment -String 'Invoke-FMAccessRule.AccessRule.Create' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry + try { + if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } + if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } + $accessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new((Convert-AdcPrincipal @parameters -Name $changeEntry.Configuration.IdentityReference), $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType, $changeEntry.Configuration.ObjectType, $changeEntry.Configuration.InheritanceType, $changeEntry.Configuration.InheritedObjectType) + } + catch { + $failedCount = $failedCount + 1 + Stop-PSFFunction -String 'Invoke-FMAccessRule.AccessRule.Creation.Failed' -StringValues $testItem.Identity, $changeEntry.Configuration.IdentityReference -EnableException $EnableException -Target $changeEntry -Continue -ErrorRecord $_ + } + $null = $aclObject.AddAccessRule($accessRule) + #TODO: Validation and remediation of success. Adding can succeed but not do anything, when accessrules are redundant. Potentially flag it for full replacement? + continue + } + #endregion Add Access Rules + + #region Restore Default Access Rules + if ($changeEntry.Type -eq 'Restore') { + Write-PSFMessage -Level InternalComment -String 'Invoke-FMAccessRule.AccessRule.Restore' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry + try { + if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } + if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." } + $accessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new((Convert-AdcPrincipal @parameters -Name $changeEntry.Configuration.IdentityReference), $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType, $changeEntry.Configuration.ObjectType, $changeEntry.Configuration.InheritanceType, $changeEntry.Configuration.InheritedObjectType) + } + catch { + $failedCount = $failedCount + 1 + Stop-PSFFunction -String 'Invoke-FMAccessRule.AccessRule.Creation.Failed' -StringValues $testItem.Identity, $changeEntry.Configuration.IdentityReference -EnableException $EnableException -Target $changeEntry -Continue -ErrorRecord $_ + } + $null = $aclObject.AddAccessRule($accessRule) + #TODO: Validation and remediation of success. Adding can succeed but not do anything, when accessrules are redundant. Potentially flag it for full replacement? + continue + } + #endregion Restore Default Access Rules + } + Invoke-PSFProtectedCommand -ActionString 'Invoke-FMAccessRule.Processing.Execute' -ActionStringValues ($testItem.Changed.Count - $failedCount), $testItem.Changed.Count -Target $testItem -ScriptBlock { + Set-AdsAcl @parameters -Path $testItem.Identity -AclObject $aclObject -EnableException -Confirm:$false + } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue + } + 'MissingADObject' { + Write-PSFMessage -Level Warning -String 'Invoke-FMAccessRule.ADObject.Missing' -StringValues $testItem.Identity -Target $testItem -Debug:$false + } + } + } + } +} diff --git a/ForestManagement/functions/AccessRule/Register-FMAccessRule.ps1 b/ForestManagement/functions/AccessRule/Register-FMAccessRule.ps1 new file mode 100644 index 0000000..e1370a3 --- /dev/null +++ b/ForestManagement/functions/AccessRule/Register-FMAccessRule.ps1 @@ -0,0 +1,167 @@ +function Register-FMAccessRule { + <# + .SYNOPSIS + Registers a new access rule as a desired state. + + .DESCRIPTION + Registers a new access rule as a desired state. + These are then compared with a domain's configuration when executing Test-FMAccessRule. + See that command for more details on this procedure. + + .PARAMETER Path + The path to the AD object to govern. + This should be a distinguishedname. + This path uses name resolution. + For example %DomainDN% will be replaced with the DN of the target domain itself (and should probably be part of everyy single path). + + .PARAMETER ObjectCategory + Instead of a path, define a category to apply the rule to. + Categories are defined using Register-AdcObjectCategory. + This allows you to apply rules to a category of objects, rather than a specific path. + With this you could apply a rule to all domain controller objects, for example. + + .PARAMETER Identity + The identity to apply the rule to. + + .PARAMETER AccessControlType + Whether this is an Allow or Deny rule. + + .PARAMETER ActiveDirectoryRights + The actual rights to grant. + This is a [string] type to allow some invalid values that happen in the field and are still applied by AD. + + .PARAMETER InheritanceType + How the Access Rule is being inherited. + None: Indicates no inheritance. The ACE information is only used on the object on which the ACE is set. ACE information is not inherited by any descendents of the object. + All: Indicates inheritance that includes the object to which the ACE is applied, the object's immediate children, and the descendents of the object's children. + Descendents: Indicates inheritance that includes the object's immediate children and the descendants of the object's children, but not the object itself. + SelfAndChildren: Indicates inheritance that includes the object itself and its immediate children. It does not include the descendents of its children. + Children: Indicates inheritance that includes the object's immediate children only, not the object itself or the descendents of its children. + + .PARAMETER InheritedObjectType + Name or Guid of property or right affected by this rule. + Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...), + what properties to affect (e.g.: User-Account-Control) or what extended rights to grant. + Which in what combination applies depends on the ActiveDirectoryRights set. + + .PARAMETER ObjectType + Name or Guid of property or right affected by this rule. + Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...), + what properties to affect (e.g.: User-Account-Control) or what extended rights to grant. + Which in what combination applies depends on the ActiveDirectoryRights set. + + .PARAMETER Optional + The path this access rule object is assigned to is optional and need not exist. + This makes the rule apply only if the object exists, without triggering errors if it doesn't. + It will also ignore access errors on the object. + Note: Only if all access rules assigned to an object are set to $true, will the object be considered optional. + + .PARAMETER Present + Whether the access rule should exist or not. + By default, it should. + Set this to $false in order to explicitly delete an existing access rule. + Set this to 'Undefined' to neither create nor delete it, in which case it will simply be accepted if it exists. + + .PARAMETER NoFixConfig + By default, Test-FMAccessRule will generate a "FixConfig" result for accessrules that have been explicitly defined but are also part of the Schema Default permissions. + If this setting is enabled, this result object is suppressed. + + .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-FMAccessRule -ObjectCategory DomainControllers -Identity '%DomainName%\Domain Admins' -ActiveDirectoryRights GenericAll + + Grants the domain admins of the target domain FullControl over all domain controllers, without any inheritance. + #> + [CmdletBinding(DefaultParameterSetName = 'Path')] + Param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')] + [string] + $Path, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')] + [string] + $ObjectCategory, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Identity, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $ActiveDirectoryRights, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [System.Security.AccessControl.AccessControlType] + $AccessControlType = 'Allow', + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [System.DirectoryServices.ActiveDirectorySecurityInheritance] + $InheritanceType = 'None', + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $ObjectType = '', + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $InheritedObjectType = '', + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [bool] + $Optional = $false, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [PSFramework.Utility.TypeTransformationAttribute([string])] + [DomainManagement.TriBool] + $Present = 'true', + + [bool] + $NoFixConfig = $false, + + [string] + $ContextName = '' + ) + + process { + switch ($PSCmdlet.ParameterSetName) { + 'Path' { + if (-not $script:accessRules[$Path]) { $script:accessRules[$Path] = @() } + $script:accessRules[$Path] += [PSCustomObject]@{ + PSTypeName = 'ForestManagement.AccessRule' + Path = $Path + IdentityReference = $Identity + AccessControlType = $AccessControlType + ActiveDirectoryRights = $ActiveDirectoryRights + InheritanceType = $InheritanceType + InheritedObjectType = $InheritedObjectType + ObjectType = $ObjectType + Optional = $Optional + Present = $Present + NoFixConfig = $NoFixConfig + ContextName = $ContextName + } + } + 'Category' { + if (-not $script:accessCategoryRules[$ObjectCategory]) { $script:accessCategoryRules[$ObjectCategory] = @() } + $script:accessCategoryRules[$ObjectCategory] += [PSCustomObject]@{ + PSTypeName = 'ForestManagement.AccessRule' + Category = $ObjectCategory + IdentityReference = $Identity + AccessControlType = $AccessControlType + ActiveDirectoryRights = $ActiveDirectoryRights + InheritanceType = $InheritanceType + InheritedObjectType = $InheritedObjectType + ObjectType = $ObjectType + Optional = $Optional + Present = $Present + NoFixConfig = $NoFixConfig + ContextName = $ContextName + } + } + } + } +} diff --git a/ForestManagement/functions/AccessRule/Test-FMAccessRule.ps1 b/ForestManagement/functions/AccessRule/Test-FMAccessRule.ps1 new file mode 100644 index 0000000..e8f4edb --- /dev/null +++ b/ForestManagement/functions/AccessRule/Test-FMAccessRule.ps1 @@ -0,0 +1,308 @@ +function Test-FMAccessRule { + <# + .SYNOPSIS + Validates the targeted forest's Access Rule configuration. + + .DESCRIPTION + Validates the targeted forest's Access Rule configuration. + This is done by comparing each relevant object's non-inherited permissions with the Schema-given default permissions for its object type. + Then the remaining explicit permissions that are not part of the schema default are compared with the configured desired state. + + The desired state can be defined using Register-FMAccessRule. + Basically, two kinds of rules are supported: + - Path based access rules - point at a DN and tell the system what permissions should be applied. + - Rule based access rules - All objects matching defined conditions will be affected by the defined rules. + To define rules - also known as Object Categories - use Register-AdcObjectCategory. + Example rules could be "All Domain Controllers" or "All Service Connection Points with the name 'Virtual Machine'" + + This command will test all objects that ... + - Have at least one path based rule. + - Are considered as "under management", as defined using Set-FMContentMode + It uses a definitive approach - any access rule not defined will be flagged for deletion! + + .PARAMETER Server + The server / domain to work with. + + .PARAMETER Credential + The credentials to use for this operation. + + .EXAMPLE + PS C:\> Test-FMAccessRule -Server fabrikam.com + + Tests, whether the fabrikam.com forest conforms to the configured, desired state. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "")] + [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 accessRules -Cmdlet $PSCmdlet + Set-FMDomainContext @parameters + + try { $null = Get-AdcObjectDefaultPermission -ObjectClass top @parameters } + catch { + Stop-PSFFunction -String 'Test-FMAccessRule.DefaultPermission.Failed' -StringValues $Server -Target $Server -EnableException $false -ErrorRecord $_ + return + } + } + process { + if (Test-PSFFunctionInterrupt) { return } + + #region Process Configured Objects + foreach ($key in $script:accessRules.Keys) { + $resolvedPath = Resolve-String -Text $key + + $resultDefaults = @{ + Server = $Server + ObjectType = 'AccessRule' + Identity = $resolvedPath + Configuration = $script:accessRules[$key] + } + + if (-not (Test-ADObject @parameters -Identity $resolvedPath)) { + if ($script:accessRules[$key].Optional -notcontains $false) { continue } + New-TestResult @resultDefaults -Type 'MissingADObject' + continue + } + try { $adAclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException } + catch { + if ($script:accessRules[$key].Optional -notcontains $false) { continue } + Write-PSFMessage -String 'Test-FMAccessRule.NoAccess' -StringValues $resolvedPath -Tag 'panic', 'failed' -Target $script:accessRules[$key] -ErrorRecord $_ + New-TestResult @resultDefaults -Type 'NoAccess' + continue + } + + $adObject = Get-ADObject @parameters -Identity $resolvedPath -Properties adminCount + + $defaultPermissions = Get-AdcObjectDefaultPermission @parameters -ObjectClass $adObject.ObjectClass + $desiredPermissions = $script:accessRules[$key] | ConvertFrom-AdcAccessRuleConfiguration @parameters -ADObject $adObject -IncludeCategory -CategoryRules $script:accessCategoryRules + + $delta = Compare-AdcAccessRules @parameters -ADRules ($adAclObject.Access | Convert-AdcAccessRuleIdentity @parameters -Target $adAclObject.DistinguishedName) -ConfiguredRules $desiredPermissions -DefaultRules $defaultPermissions -ADObject $adObject + + if ($delta) { + New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject + continue + } + } + #endregion Process Configured Objects + + if ($script:contentMode.ExcludeComponents.AccessRules) { return } + + $doParallelize = Get-PSFConfigValue -FullName 'ForestManagement.AccessRules.Parallelize' + #region Process Non-Configured AD Objects - Serial + if (-not $doParallelize) { + $resolvedConfiguredObjects = $script:accessRules.Keys | Resolve-String + + $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) { + Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties adminCount + } + + $resultDefaults = @{ + Server = $Server + ObjectType = 'AccessRule' + } + + $convertCmdName = { Convert-AdcSchemaGuid @parameters -OutType Name }.GetSteppablePipeline() + $convertCmdName.Begin($true) + $convertCmdGuid = { Convert-AdcSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline() + $convertCmdGuid.Begin($true) + + $processed = @{ } + foreach ($foundADObject in $foundADObjects) { + # Prevent duplicate processing + if ($processed[$foundADObject.DistinguishedName]) { continue } + $processed[$foundADObject.DistinguishedName] = $true + + # Skip GPOs, as those are handled within the GP Permissions Component + if ($foundADObject.DistinguishedName -match 'CN={[^,]+},CN=Policies,CN=System,') { continue } + + # Skip items that were defined in configuration, they were already processed + if ($foundADObject.DistinguishedName -in $resolvedConfiguredObjects) { continue } + + $adAclObject = Get-AdsAcl @parameters -Path $foundADObject.DistinguishedName + $compareParam = @{ + ADRules = $adAclObject.Access | Convert-AdcAccessRuleIdentity @parameters + DefaultRules = Get-AdcObjectDefaultPermission @parameters -ObjectClass $foundADObject.ObjectClass + ConfiguredRules = Get-AdcCategoryBasedRules -ADObject $foundADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid -CategoryRules $script:accessCategoryRules + ADObject = $foundADObject + } + + $compareParam += $parameters + $delta = Compare-AdcAccessRules @compareParam + + if ($delta) { + New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject -Identity $foundADObject.DistinguishedName + continue + } + } + + $convertCmdName.End() + $convertCmdGuid.End() + + return + } + #endregion Process Non-Configured AD Objects - Serial + + #region Process Non-Configured AD Objects - Parallel + #region Prepare Runspace Environment + $variables = @{ + resultDefaults = @{ + Server = $Server + ObjectType = 'AccessRule' + } + parameters = $parameters + schemaDefaultPermissions = $script:schemaObjectDefaultPermission["$Server"] + accessRuleConfiguration = @{ + accessRules = $script:accessRules + accessCategoryRules = $script:accessCategoryRules + } + objectCategorySettings = $script:objectCategories + stringTable = $script:nameReplacementTable + } + $modules = @( + (Get-Module ADMF.Core).ModuleBase + (Get-Module DomainManagement).ModuleBase + (Get-Module ADSec).ModuleBase + ) + $functions = @{ + 'New-TestResult' = [ScriptBlock]::Create((Get-Command -Name New-TestResult).Definition) + } + + $begin = { + $null = Get-Acl -Path . + & (Get-Module ForestManagement) { + $script:schemaObjectDefaultPermission["$($global:parameters.Server)"] = $global:schemaDefaultPermissions.Clone() + $script:accessRules = $global:accessRuleConfiguration.accessRules.Clone() + $script:accessCategoryRules = $global:accessRuleConfiguration.accessCategoryRules.Clone() + $script:nameReplacementTable = $global:stringTable.Clone() + $script:objectCategories = $global:objectCategorySettings.Clone() + foreach ($__category in $script:objectCategories.Values) { + if ($__category.TestScript -is [scriptblock]) { + $__category.TestScript = ([PsfScriptBlock]$__category.TestScript).ToGlobal() + } + } + } + + $global:convertCmdName = { Convert-AdcSchemaGuid @parameters -OutType Name }.GetSteppablePipeline() + $global:convertCmdName.Begin($true) + $global:convertCmdGuid = { Convert-AdcSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline() + $global:convertCmdGuid.Begin($true) + + $global:cmdCompareAccessRules = Get-Command Compare-AdcAccessRules + $global:cmdConvertAccessRuleIdentity = Get-Command Convert-AdcAccessRuleIdentity + $global:cmdGetCategoryBasedRules = Get-Command Get-AdcCategoryBasedRules + } + $process = { + $count = 0 + do { + try { + $foundADObject = $_ + $adAclObject = Get-AdsAcl @parameters -Path $foundADObject.DistinguishedName + $compareParam = @{ + ADRules = & $global:cmdConvertAccessRuleIdentity -InputObject $adAclObject.Access @parameters + DefaultRules = Get-AdcObjectDefaultPermission @parameters -ObjectClass $foundADObject.ObjectClass + ConfiguredRules = & $global:cmdGetCategoryBasedRules -ADObject $foundADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid + ADObject = $foundADObject + } + + $compareParam += $parameters + $delta = & $global:cmdCompareAccessRules @compareParam + } + catch { + $count++ + if ($count -lt 10) { continue } + + $fail = [PSCustomObject]@{ + ADObject = $foundADObject + Acl = $adAclObject + Error = $_ + } + Write-PSFRunspaceQueue -Name fails -Value $fail + break + } + + if ($delta) { + New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject -Identity $foundADObject.DistinguishedName + } + + break + } + while ($true) + } + $end = { + $global:convertCmdName.End() + $global:convertCmdGuid.End() + } + + $param = @{ + Name = 'AccessRuleProcessor' + Count = (Get-PSFConfigValue -FullName 'Forestanagement.AccessRules.Threads' -Fallback 4) + InQueue = 'input' + OutQueue = 'results' + Functions = $functions + Modules = $modules + Variables = $variables + + Begin = $begin + Process = $process + End = $end + + CloseOutQueue = $true + } + #endregion Prepare Runspace Environment + + $workflow = New-PSFRunspaceWorkflow -Name 'ForestManagement.AccessRules' -Force + $null = $workflow | Add-PSFRunspaceWorker @param + + $resolvedConfiguredObjects = $script:accessRules.Keys | Resolve-String + + $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -NoContainer)) { + Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties adminCount + } + + $processed = @{ } + foreach ($foundADObject in $foundADObjects) { + # Prevent duplicate processing + if ($processed[$foundADObject.DistinguishedName]) { continue } + $processed[$foundADObject.DistinguishedName] = $true + + # Skip items that were defined in configuration, they were already processed + if ($foundADObject.DistinguishedName -in $resolvedConfiguredObjects) { continue } + + Write-PSFRunspaceQueue -Name input -InputObject $workflow -Value $foundADObject + } + $workflow.Queues.input.Closed = $true + + try { + $workflow | Start-PSFRunspaceWorkflow + $workflow | Wait-PSFRunspaceWorkflow -WorkerName AccessRuleProcessor -Closed -PassThru | Stop-PSFRunspaceWorkflow + $fails = Read-PSFRunspaceQueue -InputObject $workflow -Name fails -All + foreach ($fail in $fails) { + Write-PSFMessage -Level Warning -String 'Test-FMAccessRule.Parallel.Error' -StringValues $fail.ADObject -ErrorRecord $fail.Error -Target $fail + } + + $results = Read-PSFRunspaceQueue -InputObject $workflow -Name results -All + # Fix String Presentation for objects from a background runspace + $results | Add-Member -MemberType ScriptMethod -Name ToString -Value { $this.Identity } -Force + $results.Changed | Add-Member -MemberType ScriptMethod ToString -Value { '{0}: {1}' -f $this.Type, $this.Identity } -Force + $results + } + finally { + $workflow | Remove-PSFRunspaceWorkflow + } + #endregion Process Non-Configured AD Objects - Parallel + } +} diff --git a/ForestManagement/functions/AccessRule/Unregister-FMAccessRule.ps1 b/ForestManagement/functions/AccessRule/Unregister-FMAccessRule.ps1 new file mode 100644 index 0000000..273c5b7 --- /dev/null +++ b/ForestManagement/functions/AccessRule/Unregister-FMAccessRule.ps1 @@ -0,0 +1,42 @@ +function Unregister-FMAccessRule +{ + <# + .SYNOPSIS + Removes a registered accessrule from the list of desired rules. + + .DESCRIPTION + Removes a registered accessrule from the list of desired rules. + + .PARAMETER RuleObject + The rule object to remove. + Must be returned by Get-DMAccessRule + + .EXAMPLE + PS C:\> Get-FMAccessRule | Unregister-FMAccessRule + + Removes all registered Access Rules, clearing the desired state of rules. + #> + [CmdletBinding()] + Param ( + [Parameter(ValueFromPipeline = $true)] + $RuleObject + ) + + process + { + foreach ($ruleItem in $RuleObject) { + if ($ruleItem.Path) { + $script:accessRules[$ruleItem.Path] = $script:accessRules[$ruleItem.Path] | Where-Object { $_ -ne $ruleItem} + if (-not $script:accessRules[$ruleItem.Path]) { + $script:accessRules.Remove($ruleItem.Path) + } + } + if ($ruleItem.Category) { + $script:accessCategoryRules[$ruleItem.Category] = $script:accessCategoryRules[$ruleItem.Category] | Where-Object { $_ -ne $ruleItem} + if (-not $script:accessCategoryRules[$ruleItem.Category]) { + $script:accessCategoryRules.Remove($ruleItem.Category) + } + } + } + } +} \ No newline at end of file diff --git a/ForestManagement/functions/acls/Get-FMAcl.ps1 b/ForestManagement/functions/acls/Get-FMAcl.ps1 new file mode 100644 index 0000000..f20fef7 --- /dev/null +++ b/ForestManagement/functions/acls/Get-FMAcl.ps1 @@ -0,0 +1,29 @@ +function Get-FMAcl { + <# + .SYNOPSIS + Lists registered acls. + + .DESCRIPTION + Lists registered acls. + + .PARAMETER Path + The name to filter by. + Defaults to '*' + + .EXAMPLE + PS C:\> Get-FMAcl + + Lists all registered acls. + #> + [CmdletBinding()] + param ( + [string] + $Path = '*' + ) + + process { + ($script:acls.Values) | Where-Object Path -Like $Path + ($script:aclsByCategory.Values) | Where-Object Category -Like $Path + $script:aclDefaultOwner | Where-Object Path -Like $Path + } +} diff --git a/ForestManagement/functions/acls/Invoke-FMAcl.ps1 b/ForestManagement/functions/acls/Invoke-FMAcl.ps1 new file mode 100644 index 0000000..2b3cc83 --- /dev/null +++ b/ForestManagement/functions/acls/Invoke-FMAcl.ps1 @@ -0,0 +1,108 @@ +function Invoke-FMAcl { + <# + .SYNOPSIS + Applies the desired ACL configuration. + + .DESCRIPTION + Applies the desired ACL configuration. + To define the desired acl state, use Register-FMAcl. + + Note: The ACL suite of commands only manages the ACL itself, not the rules assigned to it! + Explicitly, this makes this suite the tool to manage inheritance and ownership over an object. + To manage AccessRules, look at the *-FMAccessRule commands. + + .PARAMETER InputObject + Test results provided by the associated test command. + Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed. + + .PARAMETER Server + The server / domain to work with. + + .PARAMETER Credential + The credentials to use for this operation. + + .PARAMETER EnableException + This parameters disables user-friendly warnings and enables the throwing of exceptions. + This is less user friendly, but allows catching exceptions in calling scripts. + + .PARAMETER Confirm + If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + + .PARAMETER WhatIf + If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. + + .EXAMPLE + PS C:\> Invoke-FMAcl -Server contoso.com + + Applies the configured, desired state of object Acl to all managed objects in contoso.com + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param ( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + + [PSFComputer] + $Server, + + [PSCredential] + $Credential, + + [switch] + $EnableException + ) + + begin { + $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential + $parameters['Debug'] = $false + Assert-ADConnection @parameters -Cmdlet $PSCmdlet + Invoke-Callback @parameters -Cmdlet $PSCmdlet + Assert-Configuration -Type Acls, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet + Set-FMDomainContext @parameters + } + process { + if (-not $InputObject) { + $InputObject = Test-FMAcl @parameters + } + + foreach ($testItem in $InputObject) { + # Catch invalid input - can only process test results + if ($testItem.PSObject.TypeNames -notcontains 'ForestManagement.Acl.TestResult') { + Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMAcl', $testItem -Target $testItem -Continue -EnableException $EnableException + } + + switch ($testItem.Type) { + 'MissingADObject' { + Write-PSFMessage -Level Warning -String 'Invoke-FMAcl.MissingADObject' -StringValues $testItem.Identity -Target $testItem + continue + } + 'NoAccess' { + Write-PSFMessage -Level Warning -String 'Invoke-FMAcl.NoAccess' -StringValues $testItem.Identity -Target $testItem + continue + } + 'OwnerNotResolved' { + Write-PSFMessage -Level Warning -String 'Invoke-FMAcl.OwnerNotResolved' -StringValues $testItem.Identity, $testItem.ADObject.GetOwner([System.Security.Principal.SecurityIdentifier]) -Target $testItem + continue + } + 'Update' { + if ($testItem.Changed.Type -contains 'Owner') { + Invoke-PSFProtectedCommand -ActionString 'Invoke-FMAcl.UpdatingOwner' -ActionStringValues ($testItem.Configuration.Owner | Resolve-String) -Target $testItem -ScriptBlock { + Set-AdsOwner @parameters -Path $testItem.Identity -Identity (Convert-AdcPrincipal @parameters -Name ($testItem.Configuration.Owner | Resolve-String)) -EnableException -Confirm:$false + } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue + } + if ($testItem.Changed.Type -contains 'NoInheritance') { + Invoke-PSFProtectedCommand -ActionString 'Invoke-FMAcl.UpdatingInheritance' -ActionStringValues $testItem.Configuration.NoInheritance -Target $testItem -ScriptBlock { + if ($testItem.Configuration.NoInheritance) { + Disable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false + } + else { Enable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false } + } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue + } + } + 'ShouldManage' { + Write-PSFMessage -Level Warning -String 'Invoke-FMAcl.ShouldManage' -StringValues $testItem.Identity -Target $testItem + continue + } + } + } + } +} \ No newline at end of file diff --git a/ForestManagement/functions/acls/Register-FMAcl.ps1 b/ForestManagement/functions/acls/Register-FMAcl.ps1 new file mode 100644 index 0000000..3000cc9 --- /dev/null +++ b/ForestManagement/functions/acls/Register-FMAcl.ps1 @@ -0,0 +1,110 @@ +function Register-FMAcl { + <# + .SYNOPSIS + Registers an active directory acl. + + .DESCRIPTION + Registers an active directory acl. + This acl will be maintained as configured during Invoke-FMAcl. + + .PARAMETER Path + Path (distinguishedName) of the ADObject the acl is assigned to. + Subject to string insertion. + + .PARAMETER ObjectCategory + Assign ACL settings based on the ObjectCategory of an object. + + .PARAMETER Owner + Owner of the ADObject. + Subject to string insertion. + + .PARAMETER NoInheritance + Whether inheritance should be disabled on the ADObject. + Defaults to $false + + .PARAMETER Optional + The path this acl object is assigned to is optional and need not exist. + This makes the rule apply only if the object exists, without triggering errors if it doesn't. + It will also ignore access errors on the object. + + .PARAMETER DefaultOwner + Whether to make this the default owner for objects not specified under either a path or an object category. + + .PARAMETER ContextName + The name of the context defining the setting. + This allows determining the configuration set that provided this setting. + Used by the ADMF, available to any other configuration management solution. + + .EXAMPLE + PS C:\> Get-Content .\groups.json | ConvertFrom-Json | Write-Output | Register-FMAcl + + Reads a json configuration file containing a list of objects with appropriate properties to import them as acl configuration. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] + [CmdletBinding(DefaultParameterSetName = 'path')] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] + [string] + $Path, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')] + [string] + $ObjectCategory, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $Owner, + + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')] + [bool] + $NoInheritance = $false, + + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')] + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')] + [bool] + $Optional = $false, + + [Parameter(ParameterSetName = 'DefaultOwner')] + [switch] + $DefaultOwner, + + [string] + $ContextName = '' + ) + process { + switch ($PSCmdlet.ParameterSetName) { + 'path' { + $script:acls[$Path] = [PSCustomObject]@{ + PSTypeName = 'ForestManagement.Acl' + Path = $Path + Owner = $Owner + NoInheritance = $NoInheritance + Optional = $Optional + ContextName = $ContextName + } + } + 'category' { + $script:aclByCategory[$ObjectCategory] = [PSCustomObject]@{ + PSTypeName = 'ForestManagement.Acl' + Category = $ObjectCategory + Owner = $Owner + NoInheritance = $NoInheritance + Optional = $Optional + ContextName = $ContextName + } + } + 'DefaultOwner' { + # Array to appease Assert-Configuration + $script:aclDefaultOwner = @([PSCustomObject]@{ + PSTypeName = 'ForestManagement.Acl' + Path = '' + Owner = $Owner + NoInheritance = $false + Optional = $null + ContextName = $ContextName + }) + } + } + } +} \ No newline at end of file diff --git a/ForestManagement/functions/acls/Test-FMAcl.ps1 b/ForestManagement/functions/acls/Test-FMAcl.ps1 new file mode 100644 index 0000000..824fd8f --- /dev/null +++ b/ForestManagement/functions/acls/Test-FMAcl.ps1 @@ -0,0 +1,211 @@ +function Test-FMAcl { + <# + .SYNOPSIS + Tests whether the configured ACLs match a domain's configuration. + + .DESCRIPTION + Tests whether the configured ACLs match a domain's configuration. + + .PARAMETER Server + The server / domain to work with. + + .PARAMETER Credential + The credentials to use for this operation. + + .EXAMPLE + PS C:\> Test-FMAcl + + Tests whether the configured ACLs' state matches the current domain ACL setup. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] + [CmdletBinding()] + param ( + [PSFComputer] + $Server, + + [PSCredential] + $Credential + ) + + begin { + $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential + $parameters['Debug'] = $false + Assert-ADConnection @parameters -Cmdlet $PSCmdlet + Invoke-Callback @parameters -Cmdlet $PSCmdlet + Assert-Configuration -Type Acls, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet + Set-FMDomainContext @parameters + + #region Functions + function New-Change { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + $Type, + $OldValue, + $NewValue, + [string] + $Identity, + $OldSID, + $NewSID + ) + + $changeItem = [PSCustomObject]@{ + PSTypeName = 'ForestManagement.Acl.Change' + Type = $Type + Old = $OldValue + New = $NewValue + Identity = $Identity + OldSID = $OldSID + NewSID = $NewSID + } + Add-Member -InputObject $changeItem -MemberType ScriptMethod -Name ToString -Value { '{0}->{1}' -f $this.Type, $this.New } -Force -PassThru + } + function Get-ChangeByCategory { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] + [CmdletBinding()] + param ( + $ADObject, + + $Category, + + $ResultDefaults, + + $Parameters + ) + + $aclObject = Get-AdsAcl @Parameters -Path $ADObject -EnableException + + # Ensure Owner Name is present - may not always resolve + $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier]) + $simpleOwner = $true + if ($Category.Owner -match '^__.+__$') { + $simpleOwner = $false + Invoke-PSFProtectedCommand -ActionString 'Test-FMAcl.ResolveOwner' -ActionStringValues $Category.Owner.Trim('_'),$ADObject.DistinguishedName -Target $Category -ScriptBlock { + $ownerSet = Resolve-AdcPrivilegedGroupSet -Type $Category.Owner.Trim('_') @parameters + } -Continue -PSCmdlet $PSCmdlet + } + else { + $configuredSID = $Category.Owner | Resolve-String | Convert-AdcPrincipal @parameters -OutputType SID + } + + [System.Collections.ArrayList]$changes = @() + if ($simpleOwner) { + if ("$ownerSID" -ne "$configuredSID") { + $null = $changes.Add((New-Change -Identity $resolvedPath -Type Owner -OldValue $aclObject.Owner -NewValue ($Category.Owner | Resolve-String) -OldSID $ownerSID -NewSID $configuredSID)) + } + } + else { + if ("$ownerSID" -notin $ownerSet.SIDs) { + $null = $changes.Add((New-Change -Identity $resolvedPath -Type Owner -OldValue $aclObject.Owner -NewValue ($Category.Owner | Resolve-String) -OldSID $ownerSID -NewSID $ownerSet.DefaultSID)) + } + } + if ($Category.NoInheritance -ne $aclObject.AreAccessRulesProtected) { + # If AdminCount -eq 1, then inheritance should be disabled, no matter the configuration + if (-not ($aclObject.AreAccessRulesProtected -and $ADObject.AdminCount)) { + $null = $changes.Add((New-Change -Identity $ADObject -Type NoInheritance -OldValue $aclObject.AreAccessRulesProtected -NewValue $Category.NoInheritance)) + } + } + + if ($changes.Count) { + New-TestResult @resultDefaults -Identity $ADObject -Configuration $Category -Type Update -Changed $changes.ToArray() -ADObject $aclObject + } + } + #endregion Functions + } + process { + #region processing configuration + foreach ($aclDefinition in $script:acls.Values) { + $resolvedPath = Resolve-String -Text $aclDefinition.Path + + $resultDefaults = @{ + Server = $Server + ObjectType = 'Acl' + Identity = $resolvedPath + Configuration = $aclDefinition + } + + + if (-not (Test-ADObject @parameters -Identity $resolvedPath)) { + if ($aclDefinition.Optional) { continue } + Write-PSFMessage -String 'Test-FMAcl.ADObjectNotFound' -StringValues $resolvedPath -Tag 'panic', 'failed' -Target $aclDefinition + New-TestResult @resultDefaults -Type 'MissingADObject' + continue + } + + try { $aclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException } + catch { + if ($aclDefinition.Optional) { continue } + Write-PSFMessage -String 'Test-FMAcl.NoAccess' -StringValues $resolvedPath -Tag 'panic', 'failed' -Target $aclDefinition -ErrorRecord $_ + New-TestResult @resultDefaults -Type 'NoAccess' + continue + } + # Ensure Owner Name is present - may not always resolve + $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier]) + $simpleOwner = $true + if ($aclDefinition.Owner -match '^__.+__$') { + $simpleOwner = $false + Invoke-PSFProtectedCommand -ActionString 'Test-FMAcl.ResolveOwner' -ActionStringValues $aclDefinition.Owner.Trim('_'),$resolvedPath -Target $aclDefinition -ScriptBlock { + $ownerSet = Resolve-AdcPrivilegedGroupSet -Type $aclDefinition.Owner.Trim('_') @parameters + } -Continue -PSCmdlet $PSCmdlet + } + else { + $configuredSID = $aclDefinition.Owner | Resolve-String | Convert-AdcPrincipal @parameters -OutputType SID + } + + [System.Collections.ArrayList]$changes = @() + if ($simpleOwner) { + if ("$ownerSID" -ne "$configuredSID") { + $null = $changes.Add((New-Change -Identity $resolvedPath -Type Owner -OldValue $aclObject.Owner -NewValue ($aclDefinition.Owner | Resolve-String) -OldSID $ownerSID -NewSID $configuredSID)) + } + } + else { + if ("$ownerSID" -notin $ownerSet.SIDs) { + $null = $changes.Add((New-Change -Identity $resolvedPath -Type Owner -OldValue $aclObject.Owner -NewValue ($aclDefinition.Owner | Resolve-String) -OldSID $ownerSID -NewSID $ownerSet.DefaultSID)) + } + } + if ($aclDefinition.NoInheritance -ne $aclObject.AreAccessRulesProtected) { + $null = $changes.Add((New-Change -Identity $resolvedPath -Type NoInheritance -OldValue $aclObject.AreAccessRulesProtected -NewValue $aclDefinition.NoInheritance)) + } + + if ($changes.Count) { + New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $aclObject + } + } + #endregion processing configuration + + if ($script:contentMode.ExcludeComponents.ACLs) { return } + + #region check if all ADObjects are managed + $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) { + Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties AdminCount + } + + $resolvedConfiguredPaths = $script:acls.Values.Path | Resolve-String + $resultDefaults = @{ + Server = $Server + ObjectType = 'Acl' + } + + $processed = @{ } + foreach ($foundADObject in $foundADObjects) { + # Prevent duplicate processing + if ($processed[$foundADObject.DistinguishedName]) { continue } + $processed[$foundADObject.DistinguishedName] = $true + + # Skip items that were defined in configuration, they were already processed + if ($foundADObject.DistinguishedName -in $resolvedConfiguredPaths) { continue } + + if ($script:aclByCategory.Count -gt 0) { + $category = Resolve-AdcObjectCategory -ADObject $foundADObject @parameters + if ($matchingCategory = $category | Where-Object Name -In $script:aclByCategory.Keys | Select-Object -First 1) { + Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclByCategory[$matchingCategory.Name] -ResultDefaults $resultDefaults -Parameters $parameters + continue + } + } + + if ($script:aclDefaultOwner) { Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclDefaultOwner[0] -ResultDefaults $resultDefaults -Parameters $parameters } + else { New-TestResult @resultDefaults -Type ShouldManage -ADObject $foundADObject -Identity $foundADObject.DistinguishedName } + } + #endregion check if all ADObjects are managed + } +} \ No newline at end of file diff --git a/ForestManagement/functions/acls/Unregister-FMAcl.ps1 b/ForestManagement/functions/acls/Unregister-FMAcl.ps1 new file mode 100644 index 0000000..d3a353f --- /dev/null +++ b/ForestManagement/functions/acls/Unregister-FMAcl.ps1 @@ -0,0 +1,41 @@ +function Unregister-FMAcl { + <# + .SYNOPSIS + Removes a acl that had previously been registered. + + .DESCRIPTION + Removes a acl that had previously been registered. + + .PARAMETER Path + The path (distinguishedName) of the acl to remove. + + .PARAMETER Category + The object category the acl settings apply to + + .EXAMPLE + PS C:\> Get-FMAcl | Unregister-FMAcl + + Clears all registered acls. + #> + + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [string[]] + $Path, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string[]] + $Category + ) + + process { + foreach ($pathItem in $Path) { + if ($pathItem -eq '') { $script:aclDefaultOwner = $null } + else { $script:acls.Remove($pathItem) } + } + foreach ($categoryItem in $Category) { + $script:aclByCategory.Remove($categoryItem) + } + } +} diff --git a/ForestManagement/functions/schemadefaultpermissions/Test-FMSchemaDefaultPermission.ps1 b/ForestManagement/functions/schemadefaultpermissions/Test-FMSchemaDefaultPermission.ps1 index d67e369..6d635be 100644 --- a/ForestManagement/functions/schemadefaultpermissions/Test-FMSchemaDefaultPermission.ps1 +++ b/ForestManagement/functions/schemadefaultpermissions/Test-FMSchemaDefaultPermission.ps1 @@ -91,10 +91,10 @@ $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 + $basicHash.ObjectTypeGuid = Convert-AdcSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType GuidString + $basicHash.ObjectTypeName = Convert-AdcSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType Name + $basicHash.InheritedObjectTypeGuid = Convert-AdcSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType GuidString + $basicHash.InheritedObjectTypeName = Convert-AdcSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType Name # Namensauflösung $basicHash.ResolvedIdentity = $AccessRule.Identity | Resolve-String -Mode Lax -ArgumentList $Parameters diff --git a/ForestManagement/functions/system/Get-FMContentMode.ps1 b/ForestManagement/functions/system/Get-FMContentMode.ps1 new file mode 100644 index 0000000..879d640 --- /dev/null +++ b/ForestManagement/functions/system/Get-FMContentMode.ps1 @@ -0,0 +1,21 @@ +function Get-FMContentMode { + <# + .SYNOPSIS + Returns the current forest content mode / content handling policy. + + .DESCRIPTION + Returns the current forest content mode / content handling policy. + For more details on the content mode and how it behaves, see the description on Set-FMContentMode + + .EXAMPLE + PS C:\> Get-FMContentMode + + Returns the current domain content mode / content handling policy. + #> + [CmdletBinding()] + param () + + process { + $script:contentMode + } +} diff --git a/ForestManagement/functions/system/Set-FMContentMode.ps1 b/ForestManagement/functions/system/Set-FMContentMode.ps1 new file mode 100644 index 0000000..9a7ed7b --- /dev/null +++ b/ForestManagement/functions/system/Set-FMContentMode.ps1 @@ -0,0 +1,96 @@ +function Set-FMContentMode { + <# + .SYNOPSIS + Configures the way the module handles forest level objects not defined in configuration. + + .DESCRIPTION + Configures the way the module handles forest level objects not defined in configuration. + Depending on the desired forest configuration, dealing with undesired objects may be desirable. + + This module handles the following configurations: + Mode Additive: In this mode, all configured content is considered in addition to what is already there. Objects not in scope of the configuration are ignored. + Mode Constrained: In this mode, objects not configured are handled based on path rules: + - Include: If Include Paths are configured, only objects in the specified OUs are under management. Only objects in these OUs will be considered for deletion if not configured. + - Exclude: If Exclude Paths are configured, objects in the excluded OUs are ignored, all objects outside of these OUs will be considered for deletion if not configured. + If both Include and Exclude OUs are configured, they are merged without applying the implied top-level Include of an Exclude-only configuration. + In this scenario, if a top-level Include is desired, it needs to be explicitly set. + + When specifying Include and Exclude DNs, specify the full DN, inserting '%ConfigurationDN%' (without the quotes) for the configuration NC root and '%SchemaDN%' for the schema NC. + + .PARAMETER Mode + The mode to operate under. + In Additive mode, objects not configured are being ignored. + In Constrained mode, objects not configured may still be under maanagement, depending on Include and Exclude rules. + + .PARAMETER Include + Paths in which to look for objects under management. + Use this to explicitly list which Pathss should be inspected for objects to manage. + Only applied in Constrained mode. + Specify the full DN, inserting '%ConfigurationDN%' (without the quotes) for the configuration NC root and '%SchemaDN%' for the schema NC. + + .PARAMETER Exclude + Paths in which to NOT look for objects under management. + All other Paths are subject to management and having undesired objects deleted. + Only applied in Constrained mode. + Specify the full DN, inserting '%ConfigurationDN%' (without the quotes) for the configuration NC root and '%SchemaDN%' for the schema NC. + + .PARAMETER ExcludeComponents + Components to exclude from the Forest Content Mode. + By including them here, non-configured objects of that type will no longer get deleted. + (Details may vary, depending on the specific Component. See their respective documentation.) + + Each entry should use the Component name as Key and a boolean as Value in the hashtable. + If the value is considered $true, the Component is excluded. + Settings from multiple configuration sets will be merged, rather than fully replacing the old hashtable with a new one. + + Supported Components: + - AccessRules: Excluding them will test only configured paths for access control entries (ACE). + + .EXAMPLE + PS C:\> Set-DMContentMode -Mode 'Constrained' -Include 'OU=Administration,%DomainDN%' + + Enables Constrained mode and configures the top-level OU "Administration" as an OU under management. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + [ValidateSet('Additive', 'Constrained')] + [string] + $Mode, + + [AllowEmptyCollection()] + [string[]] + $Include, + + [AllowEmptyCollection()] + [string[]] + $Exclude, + + [AllowEmptyCollection()] + [string[]] + $UserExcludePattern, + + [bool] + $RemoveUnknownWmiFilter, + + [hashtable] + $ExcludeComponents + ) + + process { + if ($Mode) { $script:contentMode.Mode = $Mode } + if (Test-PSFParameterBinding -ParameterName Include) { $script:contentMode.Include = $Include } + if (Test-PSFParameterBinding -ParameterName Exclude) { $script:contentMode.Exclude = $Exclude } + if (Test-PSFParameterBinding -ParameterName UserExcludePattern) { $script:contentMode.UserExcludePattern = $UserExcludePattern } + if (Test-PSFParameterBinding -ParameterName RemoveUnknownWmiFilter) { $script:contentMode.RemoveUnknownWmiFilter = $RemoveUnknownWmiFilter } + if ($ExcludeComponents) { + foreach ($pair in $ExcludeComponents.GetEnumerator()) { + if ($script:contentMode.ExcludeComponents.Keys -notcontains $pair.Key) { + Write-PSFMessage -Level Warning -String 'Set-FMContentMode.Error.UnknownExcludedComponent' -StringValues $pair.Key + continue + } + $script:contentMode.ExcludeComponents[$pair.Key] = $pair.Value -as [bool] + } + } + } +} diff --git a/ForestManagement/internal/configurations/configuration.ps1 b/ForestManagement/internal/configurations/configuration.ps1 index e355b69..ff96f77 100644 --- a/ForestManagement/internal/configurations/configuration.ps1 +++ b/ForestManagement/internal/configurations/configuration.ps1 @@ -30,3 +30,9 @@ Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoGrant' -Value 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.' Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Attributes.ReportUnconfigured' -Value $false -Initialize -Validation bool -Description 'Whether Schema attributes that were not configured should be reported as a test finding.' + +Set-PSFConfig -Module 'ForestManagement' -Name 'AccessRules.Remove.Option2' -Value $false -Initialize -Validation bool -Description 'In some environments, the default way of removing access rules have proved to not work out. Using this option enables a second way for removing access rules.' +# Should not use more than 4 for now, until retries are configurable +Set-PSFConfig -Module 'ForestManagement' -Name 'AccessRules.Threads' -Value 4 -Initialize -Validation integerpositive -Description 'The number of runspaces to use for access rule processing. This is an in-memory operation, using more cores than available is disadviced.' +# Should be disabled by default, as runspace feature not quite stable yet +Set-PSFConfig -Module 'ForestManagement' -Name 'AccessRules.Parallelize' -Value $false -Initialize -Validation bool -Description 'Whether to process AccessRules with multiple runspaces (recommended for performance reasons).' \ No newline at end of file diff --git a/ForestManagement/internal/functions/Assert-Configuration.ps1 b/ForestManagement/internal/functions/Assert-Configuration.ps1 index a16a7aa..ea7ae59 100644 --- a/ForestManagement/internal/functions/Assert-Configuration.ps1 +++ b/ForestManagement/internal/functions/Assert-Configuration.ps1 @@ -1,5 +1,4 @@ -function Assert-Configuration -{ +function Assert-Configuration { <# .SYNOPSIS Ensures a set of configuration settings has been provided for the specified setting type. @@ -22,9 +21,9 @@ Asserts, that users have already been specified. #> [CmdletBinding()] - Param ( + param ( [Parameter(Mandatory = $true)] - [string] + [string[]] $Type, [Parameter(Mandatory = $true)] @@ -32,9 +31,10 @@ $Cmdlet ) - process - { - if ((Get-Variable -Name $Type -Scope Script -ValueOnly).Count -gt 0) { return } + process { + foreach ($typeName in $type) { + if ((Get-Variable -Name $typeName -Scope Script -ValueOnly -ErrorAction SilentlyContinue).Count -gt 0) { return } + } Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues $Type -FunctionName $Cmdlet.CommandRuntime @@ -44,4 +44,4 @@ $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, $Type) $cmdlet.ThrowTerminatingError($recordObject) } -} +} \ No newline at end of file diff --git a/ForestManagement/internal/functions/Remove-RedundantAce.ps1 b/ForestManagement/internal/functions/Remove-RedundantAce.ps1 new file mode 100644 index 0000000..2ef8c69 --- /dev/null +++ b/ForestManagement/internal/functions/Remove-RedundantAce.ps1 @@ -0,0 +1,78 @@ +function Remove-RedundantAce { + <# + .SYNOPSIS + Removes redundant Access Rule entries. + + .DESCRIPTION + Removes redundant Access Rule entries. + This only considers explicit rules for the specified identity reference. + It compares the highest privileged access rule with other rules only. + + This is designed to help prevent an explicit "GenericAll" privilege making redundant other entries. + This function is explicitly called in Invoke-DMAccessRule, in case of a planned ACE removal failing (and only for the failing identity). + That will only lead to trouble if a conflicting ACE is in the desired state (and who would desire something like that??) + + .PARAMETER AccessControlList + The access control list to remove redundant ACE from. + + .PARAMETER IdentityReference + The identity for which to do the removing. + + .EXAMPLE + PS C:\> Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $identity + + Removes all redundant access rules on $aclobject that apply to $identity. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + [System.DirectoryServices.ActiveDirectorySecurity] + $AccessControlList, + + $IdentityReference + ) + + $relevantRules = $AccessControlList.Access | Where-Object { + ($_.IsInherited -eq $false) -and ($_.IdentityReference -eq $IdentityReference) + } | Sort-Object ActiveDirectoryRights -Descending + if (-not $relevantRules) { return } + + $master = $null + $results = foreach ($rule in $relevantRules) { + if ($null -eq $master) { + $master = $rule + $rule + continue + } + + # If rights are not a subset of master: It's not redundant + if (($master.ActiveDirectoryRights -band $rule.ActiveDirectoryRights) -ne $rule.ActiveDirectoryRights) { + $rule + continue + } + + if ($master.InheritanceType -ne $rule.InheritanceType) { + $rule + continue + } + if ($master.AccessControlType -ne $rule.AccessControlType) { + $rule + continue + } + if (($master.ObjectType -ne $rule.ObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.ObjectType)) { + $rule + continue + } + if (($master.InheritedObjectType -ne $rule.InheritedObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.InheritedObjectType)) { + $rule + continue + } + } + + # If none were filtered out: Don't do anything + if ($results.Count -eq $relevantRules.Count) { return } + + foreach ($rule in $relevantRules) { $null = $AccessControlList.RemoveAccessRule($rule) } + foreach ($rule in $results) { $AccessControlList.AddAccessRule($rule) } +} \ No newline at end of file diff --git a/ForestManagement/internal/functions/Resolve-ContentSearchBase.ps1 b/ForestManagement/internal/functions/Resolve-ContentSearchBase.ps1 new file mode 100644 index 0000000..6536ea2 --- /dev/null +++ b/ForestManagement/internal/functions/Resolve-ContentSearchBase.ps1 @@ -0,0 +1,248 @@ +function Resolve-ContentSearchBase +{ +<# + .SYNOPSIS + Resolves the ruleset for content enforcement into actionable search data. + + .DESCRIPTION + Resolves the ruleset for content enforcement into actionable search data. + This ensures that both Include and Exclude rules are properly translated into AD search queries. + This command is designed to be called by all Test- commands across the entire module. + + .PARAMETER Server + The server / domain to work with. + + .PARAMETER Credential + The credentials to use for this operation. + + .EXAMPLE + PS C:\> Resolve-ContentSearchBase @parameters + + Resolves the configured filters into searchbases for the targeted forest. +#> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + [CmdletBinding()] + Param ( + [string] + $Server, + + [pscredential] + $Credential + ) + begin + { + $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential + $parameters['Debug'] = $false + + #region Utility Functions + function Convert-DistinguishedName { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [string[]] + $Name, + + [switch] + $Exclude + ) + process { + foreach ($nameItem in $Name) { + [PSCustomObject]@{ + Name = $nameItem + Depth = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" }).Count + Elements = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" }) + Exclude = $Exclude.ToBool() + } + } + } + } + + function Get-ChildRelationship { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + $Parent, + + [Parameter(Mandatory = $true)] + $Items + ) + + foreach ($item in $Items) { + if ($item.Name -notlike "*,$($Parent.Name)") { continue } + + [PSCustomObject]@{ + Child = $item + Parent = $Parent + Delta = $item.Depth - $Parent.Depth + } + } + } + + function New-SearchBase { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + param ( + [string] + $Name, + + [ValidateSet('OneLevel', 'Subtree')] + [string] + $Scope = 'Subtree' + ) + + [PSCustomObject]@{ + SearchBase = $Name + SearchScope = $Scope + } + } + + function Resolve-SearchBase { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true)] + $Parent, + + [Parameter(Mandatory = $true)] + $Children, + + [string] + $Server, + + [pscredential] + $Credential + ) + New-SearchBase -Name $Parent.Name -Scope OneLevel + + $childPaths = @{ + $Parent.Name = @{} + } + foreach ($childItem in $Children) { + $subPath = $childItem.Name.Replace($Parent.Name, '').Trim(",") + $subPathSegments = $subPath.Split(",") + [System.Array]::Reverse($subPathSegments) + + $basePath = $Parent.Name + foreach ($pathSegment in $subPathSegments) { + $newDN = $pathSegment, $basePath -join "," + $childPaths[$basePath][$newDN] = $newDN + if (-not $childPaths[$newDN]) { $childPaths[$newDN] = @{ } } + $basePath = $newDN + } + } + + $currentPath = '' + [System.Collections.ArrayList]$pathsToProcess = @($Parent.Name) + while ($pathsToProcess.Count -gt 0) { + $currentPath = $pathsToProcess[0] + $nextContainerObjects = Get-ADObject @parameters -SearchBase $currentPath -SearchScope OneLevel -LDAPFilter '(|(objectCategory=container)(objectCategory=organizationalUnit))' + foreach ($containerObject in $nextContainerObjects) { + # Skip the actual children, as those (and their children) have already been processed + if ($containerObject.DistinguishedName -in $Children.Name) { continue } + if ($childPaths.ContainsKey($containerObject.DistinguishedName)) { + New-SearchBase -Name $containerObject.DistinguishedName -Scope OneLevel + $null = $pathsToProcess.Add($containerObject.DistinguishedName) + } + else { + New-SearchBase -Name $containerObject.DistinguishedName + } + } + $pathsToProcess.Remove($currentPath) + } + } + #endregion Utility Functions + } + process + { + #region preprocessing and early termination + # Don't process any OUs if in Additive Mode + if ($script:contentMode.Mode -eq 'Additive') { return } + + # If already processed, return previous results + if (($Server -eq $script:contentSearchBases.Server) -and (-not (Compare-Object $script:contentMode.Include $script:contentSearchBases.Include)) -and (-not (Compare-Object $script:contentMode.Exclude $script:contentSearchBases.Exclude))) { + $script:contentSearchBases.Bases + return + } + + # Parse Includes and excludes + $include = $script:contentMode.Include | Resolve-String | Convert-DistinguishedName + $exclude = $script:contentMode.Exclude | Resolve-String | Convert-DistinguishedName -Exclude + + # If no todo: Terminate + if (-not ($include -or $exclude)) { return } + + # Implicitly include domain when no custom include rules + if ($exclude -and -not $include) { + $include = Resolve-String '%ConfigurationDN%', '%SchemaDN%' | Convert-DistinguishedName + } + $allItems = @{} + foreach ($item in $include) { + if (-not (Test-ADObject @parameters -Identity $item.Name)) { + Write-PSFMessage -Level Verbose -String 'Resolve-ContentSearchBase.Include.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server + continue + } + $allItems[$item.Name] = $item + } + foreach ($item in $exclude) { + if (-not (Test-ADObject @parameters -Identity $item.Name)) { + Write-PSFMessage -Level Verbose -String 'Resolve-ContentSearchBase.Exclude.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server + continue + } + $allItems[$item.Name] = $item + } + $relationship_All = foreach ($item in $allItems.Values) { + Get-ChildRelationship -Parent $item -Items $allItems.Values + } + # Remove multiple include/exclude nestings producing reddundant inheritance detection + $relationship_Relevant = $relationship_All | Group-Object { $_.Child.Name } | ForEach-Object { + $_.Group | Sort-Object Delta | Select-Object -First 1 + } + #endregion preprocessing and early termination + + [System.Collections.ArrayList]$itemsProcessed = @() + [System.Collections.ArrayList]$targetOUsFound = @() + + foreach ($item in ($allItems.Values | Sort-Object Depth -Descending)) { + $children = $relationship_Relevant | Where-Object { $_.Parent.Name -eq $item.Name } + $allChildren = $relationship_All | Where-Object { $_.Parent.Name -eq $item.Name } + + # Case: Exclude Rule - will not be scanned + if ($item.Exclude) { + $null = $itemsProcessed.Add($item) + continue + } + + # Casse: No Children - Just add a plain searchbase + if (-not $children) { + $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name)) + $null = $itemsProcessed.Add($item) + continue + } + + # Case: No recursive Children that would exclude something - Add plain searchbase and remove all entries from all children as not needed + if (-not ($allChildren.Child | Where-Object Exclude)) { + $redundantFindings = $targetOUsFound | Where-Object SearchBase -in $allChildren.Child.Name + foreach ($finding in $redundantFindings) { $targetOUsFound.Remove($finding) } + $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name)) + $null = $itemsProcessed.Add($item) + continue + } + + # Case: Children that require processing + foreach ($searchbase in (Resolve-SearchBase @parameters -Parent $item -Children $children.Child)) { + $null = $targetOUsFound.Add($searchbase) + } + $null = $itemsProcessed.Add($item) + } + + $script:contentSearchBases.Include = $script:contentMode.Include + $script:contentSearchBases.Exclude = $script:contentMode.Exclude + $script:contentSearchBases.Server = $Server + $script:contentSearchBases.Bases = $targetOUsFound.ToArray() + + foreach ($searchBase in $script:contentSearchBases.Bases) { + if ($NoContainer -and ($searchBase.SearchBase -like 'CN=*')) { continue } + Write-PSFMessage -String 'Resolve-ContentSearchBase.Searchbase.Found' -StringValues $searchBase.SearchScope, $searchBase.SearchBase, $script:domainContext.Fqdn + $searchBase + } + } +} \ No newline at end of file diff --git a/ForestManagement/internal/functions/Set-FMDomainContext.ps1 b/ForestManagement/internal/functions/Set-FMDomainContext.ps1 index a1c2e36..f7c70b3 100644 --- a/ForestManagement/internal/functions/Set-FMDomainContext.ps1 +++ b/ForestManagement/internal/functions/Set-FMDomainContext.ps1 @@ -60,6 +60,8 @@ $forestRootSID = (Get-ADObject @parameters -SearchBase "CN=System,$($domainObject.DistinguishedName)" -SearchScope OneLevel -LDAPFilter "(&(objectClass=trustedDomain)(trustPartner=$($forestObject.RootDomain)))" -Properties securityIdentifier).securityIdentifier.Value } } + + $rootDSE = Get-ADRootDSE @parameters Register-StringMapping -Name '%DomainName%' -Value $domainObject.Name Register-StringMapping -Name '%DomainNetBIOSName%' -Value $domainObject.NetbiosName @@ -71,5 +73,13 @@ Register-StringMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName Register-StringMapping -Name '%RootDomainSID%' -Value $forestRootSID Register-StringMapping -Name '%ForestFqdn%' -Value $forestObject.Name + Register-StringMapping -Name '%ConfigurationDN%' -Value $rootDSE.configurationNamingContext + Register-StringMapping -Name '%SchemaDN%' -Value $rootDSE.schemaNamingContext + + if ($Credential) { + Set-AdcDomainCredential -Domain $domainObject.DNSRoot -Credential $Credential + Set-AdcDomainCredential -Domain $domainObject.Name -Credential $Credential + Set-AdcDomainCredential -Domain $domainObject.DistinguishedName -Credential $Credential + } } } \ No newline at end of file diff --git a/ForestManagement/internal/scripts/variables.ps1 b/ForestManagement/internal/scripts/variables.ps1 index e81c54e..d1b51af 100644 --- a/ForestManagement/internal/scripts/variables.ps1 +++ b/ForestManagement/internal/scripts/variables.ps1 @@ -1,4 +1,15 @@ -# Directory Certificate Stores +# Configured ACLs +$script:acls = @{ } +$script:aclByCategory = @{ } +$script:aclDefaultOwner = $null + +# Configured Access Rules - Based on OU / Path +$script:accessRules = @{ } + +# Configured Access Rules - Based on Object Category +$script:accessCategoryRules = @{ } + +# Directory Certificate Stores $script:dsCertificates = @{ } $script:dsCertificatesAuthorative = @{ } @@ -31,4 +42,27 @@ $script:schema = @{ } $script:schemaDefaultPermissions = @{ } # Schema Definitions for external LDIF files -$script:schemaLdif = @{ } \ No newline at end of file +$script:schemaLdif = @{ } + +#----------------------------------------------------------------------------# +# Context Data # +#----------------------------------------------------------------------------# + +# Content Mode +$script:contentMode = [PSCustomObject]@{ + PSTypeName = 'ForestManagement.Content.Mode' + Mode = 'Additive' + Include = @() + Exclude = @() + + # Note: Also update the help on Set-FMContentMode and on the website Content Mode documentation, when adding new entries here. + ExcludeComponents = @{ + AccessRules = $false + } +} +$script:contentSearchBases = [PSCustomObject]@{ + Include = @() + Exclude = @() + Bases = @() + Server = '' +} \ No newline at end of file diff --git a/ForestManagement/xml/ForestManagement.Format.ps1xml b/ForestManagement/xml/ForestManagement.Format.ps1xml index 8129b08..a41f12b 100644 --- a/ForestManagement/xml/ForestManagement.Format.ps1xml +++ b/ForestManagement/xml/ForestManagement.Format.ps1xml @@ -4,6 +4,8 @@ ForestManagement.TestResults + ForestManagement.AccessRule.TestResult + ForestManagement.Acl.TestResult ForestManagement.Certificate.TestResult ForestManagement.ExchangeSchema.TestResult ForestManagement.ForestLevel.TestResult