Private/Set-AclConstructor4.ps1

function Set-AclConstructor4 {

    <#
        .SYNOPSIS
            Modifies ACLs on Active Directory objects using a 4-parameter constructor.

        .DESCRIPTION
            This function adds or removes access control entries (ACEs) on Active Directory objects
            using the ActiveDirectoryAccessRule constructor with 4 parameters:
            - Identity Reference
            - Active Directory Rights
            - Access Control Type
            - Object Type GUID

            The function provides granular control over permissions by allowing you to specify
            precise object types (schema GUIDs) for operations. It is optimized for large AD
            environments through efficient error handling and validation.

            This constructor is particularly useful when you need to apply permissions to specific
            object types or attributes in AD, rather than to all objects or properties.

        .PARAMETER Id
            Specifies the security principal (user, group, computer) that will receive the permission.
            This parameter accepts:
            - String: SamAccountName of the delegated group or user
            - AD object: Variable containing an AD user or group object
            - SID: Security Identifier object or string

        .PARAMETER LDAPPath
            Specifies the LDAP path (Distinguished Name) of the target Active Directory object
            on which the permissions will be set. This must be a valid LDAP Distinguished Name.

        .PARAMETER AdRight
            Specifies the Active Directory rights to assign. This parameter accepts multiple values
            separated by commas. Valid values include:
            - CreateChild: Permission to create child objects
            - DeleteChild: Permission to delete child objects
            - ListChildren: Permission to list child objects
            - Self: Permission to perform validated writes to self
            - ReadProperty: Permission to read properties
            - WriteProperty: Permission to write properties
            - DeleteTree: Permission to delete all child objects
            - ListObject: Permission to list a particular object
            - ExtendedRight: Permission to perform extended operations
            - Delete: Permission to delete the object
            - ReadControl: Permission to read security information
            - GenericExecute: Generic execute access
            - GenericWrite: Generic write access
            - GenericRead: Generic read access
            - WriteDacl: Permission to modify permissions
            - WriteOwner: Permission to assume ownership
            - GenericAll: Generic all access
            - Synchronize: Permission to synchronize
            - AccessSystemSecurity: Permission to access system security

        .PARAMETER AccessControlType
            Specifies whether to Allow or Deny the permission. Valid values are:
            - Allow: Grant the specified permissions
            - Deny: Explicitly deny the specified permissions

        .PARAMETER ObjectType
            Specifies the object type GUID that defines the type of object the permission applies to.
            This can be:
            - Property set GUID: Defines a set of properties
            - Extended right GUID: Defines extended rights operations
            - Object class GUID: Defines specific object classes

            Object type GUIDs determine the specific attributes or operations the permission applies to.

        .PARAMETER RemoveRule
            If specified, the access rule will be removed instead of added.
            By default, the function adds the specified permission.

        .EXAMPLE
            Set-AclConstructor4 -Id 'SG_SiteAdmins_XXXX' -LDAPPath 'OU=Users,OU=XXXX,OU=Sites,DC=EguibarIT,DC=local' -AdRight 'CreateChild,DeleteChild' -AccessControlType 'Allow' -ObjectType 'bf967aba-0de6-11d0-a285-00aa003049e2'

            Adds permission for the SG_SiteAdmins_XXXX group to create and delete user objects
            (specified by the user class GUID) in the Users OU.

        .EXAMPLE
            $Splat = @{
                Id = 'SG_SiteAdmins_XXXX'
                LDAPPath = 'OU=Users,OU=XXXX,OU=Sites,DC=EguibarIT,DC=local'
                AdRight = 'ReadProperty,WriteProperty'
                AccessControlType = 'Allow'
                ObjectType = 'bf967a9c-0de6-11d0-a285-00aa003049e2'
            }
            Set-AclConstructor4 @Splat

            Adds permission for the SG_SiteAdmins_XXXX group to read and write the telephone number
            attribute (specified by the attribute GUID) for objects in the Users OU.

        .EXAMPLE
            $Group = Get-AdGroup 'SG_SiteAdmins_XXXX'

            $Splat = @{
                Id = $Group
                LDAPPath = 'OU=Users,OU=XXXX,OU=Sites,DC=EguibarIT,DC=local'
                AdRight = 'ExtendedRight'
                AccessControlType = 'Allow'
                ObjectType = '00299570-246d-11d0-a768-00aa006e0529'
                RemoveRule = $true
            }
            Set-AclConstructor4 @Splat

            Removes the extended right permission (Reset Password) for the SG_SiteAdmins_XXXX group
            on the Users OU using an AD object for the identity.

        .INPUTS
            None. You cannot pipe objects to this function.

        .OUTPUTS
            [System.Void]

            This function does not return any objects. It modifies ACLs directly
            on Active Directory objects.

        .NOTES
            Used Functions:
                Name ║ Module/Namespace
                ═════════════════════════════════╬══════════════════════════════
                Get-ADObject ║ ActiveDirectory
                Get-Acl ║ Microsoft.PowerShell.Security
                Set-Acl ║ Microsoft.PowerShell.Security
                Write-Verbose ║ Microsoft.PowerShell.Utility
                Write-Error ║ Microsoft.PowerShell.Utility
                Write-Debug ║ Microsoft.PowerShell.Utility
                Write-Warning ║ Microsoft.PowerShell.Utility
                Get-Date ║ Microsoft.PowerShell.Utility
                Set-StrictMode ║ Microsoft.PowerShell.Utility
                Get-FunctionDisplay ║ EguibarIT.DelegationPS
                Test-IsValidDN ║ EguibarIT.DelegationPS
                Get-AdObjectType ║ EguibarIT.DelegationPS

        .NOTES
            Version: 4.2
            DateModified: 15/Jan/2025
            LastModifiedBy: Vicente Rodriguez Eguibar
                            vicente@eguibarit.com
                            Eguibar IT
                            http://www.eguibarit.com

        .LINK
            https://github.com/vreguibar/EguibarIT.DelegationPS/blob/main/Private/Set-AclConstructor4.ps1

        .LINK
            https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.activedirectoryaccessrule.-ctor?view=windowsdesktop-9.0#system-directoryservices-activedirectoryaccessrule-ctor(system-security-principal-identityreference-system-directoryservices-activedirectoryrights-system-security-accesscontrol-accesscontroltype-system-guid)

        .COMPONENT
            Active Directory
            EguibarIT.DelegationPS

        .ROLE
            Administrator

        .FUNCTIONALITY
            Active Directory ACL Management
    #>


    [CmdletBinding(
        SupportsShouldProcess = $true,
        ConfirmImpact = 'Low',
        DefaultParameterSetName = 'Default',
        PositionalBinding = $true
    )]
    [OutputType([void])]

    param (
        # PARAM1 STRING for the Delegated Identity
        # An IdentityReference object that identifies the trustee of the access rule.
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $True,
            ValueFromPipelineByPropertyName = $True,
            ValueFromRemainingArguments = $false,
            HelpMessage = 'SamAccountName of the Delegated Group (It also valid variable containing the group). An IdentityReference object that identifies the trustee of the access rule.',
            Position = 0)]
        [ValidateNotNullOrEmpty()]
        [Alias('IdentityReference', 'Identity', 'Trustee', 'GroupID', 'Group')]
        $Id,

        # PARAM2: STRING for the object's LDAP path
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false,
            HelpMessage = 'Distinguished (DN) Name of the object. The LDAP path to the object where the ACL will be changed.',
            Position = 1)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript(
            { Test-IsValidDN -ObjectDN $_ },
            ErrorMessage = 'DistinguishedName provided is not valid! Please Check.'
        )]
        [Alias('DN', 'DistinguishedName')]
        [String]
        $LDAPpath,

        # PARAM3: STRING representing AdRight
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Active Directory Right',
            Position = 2)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet([ActiveDirectoryRights])]
        [Alias('ActiveDirectoryRights')]
        [String[]]
        $AdRight,

        # PARAM4: STRING representing Access Control Type
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Allow or Deny access to the given object',
            Position = 3)]
        #[ValidateSet('Allow', 'Deny')]
        [ValidateSet([AccessControlType])]
        [String]
        $AccessControlType,

        # PARAM5: STRING representing Object GUID
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Schema GUID of the affected object, either object or Extended Right.',
            Position = 4)]
        [AllowNull()]
        $ObjectType,

        # PARAM6: SWITCH if $false (default) will add the rule. If $true, it will remove the rule
        [Parameter(
            Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'If present, the access rule will be removed.',
            Position = 5)]
        [Switch]
        $RemoveRule
    )

    Begin {

        Set-StrictMode -Version Latest

        # Display function header if variables exist
        if ($null -ne $Variables -and
            $null -ne $Variables.HeaderDelegation) {

            $txt = ($Variables.HeaderDelegation -f
                (Get-Date).ToString('dd/MMM/yyyy'),
                $MyInvocation.Mycommand,
                (Get-FunctionDisplay -HashTable $PsBoundParameters -Verbose:$False)
            )
            Write-Verbose -Message $txt
        } #end if

        ##############################
        # Module imports

        ##############################
        # Variables Definition

        [System.Security.Principal.SecurityIdentifier]$GroupSid = $null
        [System.DirectoryServices.ActiveDirectoryAccessRule]$AccessRule = $null
        [String]$ObjectPath = $null
        [Bool]$IsWellKnownSid = $false
        [HashTable]$AdObjectCache = @{}
        [int]$RulesRemovedCount = 0

        # Convert ObjectType to GUID if it's a string
        if ($null -ne $PSBoundParameters['ObjectType']) {

            if ($PSBoundParameters['ObjectType'] -is [System.String]) {

                try {

                    $ObjectTypeGuid = [Guid]::Parse($PSBoundParameters['ObjectType'])

                    Write-Debug -Message (
                        'Successfully parsed ObjectType string to GUID: {0}' -f
                        $ObjectTypeGuid
                    )

                } catch {

                    Write-Error -Message (
                        'Failed to parse ObjectType as GUID: {0}' -f
                        $PSBoundParameters['ObjectType']
                    )
                    throw

                } #end try-catch

            } elseif ($PSBoundParameters['ObjectType'] -is [Guid]) {

                $ObjectTypeGuid = $PSBoundParameters['ObjectType']
                Write-Debug -Message ('Using provided ObjectType GUID: {0}' -f $ObjectTypeGuid)

            } #end if-elseif

        } #end if

    } #end Begin

    Process {

        try {
            #############################
            # Identify and resolve the trustee
            #############################

            # Check if Identity is a Well-Known SID
            if ($null -ne $Variables -and
                $null -ne $Variables.WellKnownSIDs -and
                $Variables.WellKnownSIDs.Values -contains $Id) {

                # Find and create SID for well-known identity
                $TmpSid = ($Variables.WellKnownSIDs.GetEnumerator() | Where-Object { $_.Value -eq $Id }).Name

                if ($null -ne $TmpSid) {

                    $GroupSid = [System.Security.Principal.SecurityIdentifier]::new($TmpSid)
                    $IsWellKnownSid = $true

                    Write-Debug -Message ('Identity {0} is a Well-Known SID. Retrieved SID: {1}' -f $Id, $GroupSid.Value)

                } else {

                    Write-Error -Message ('Well-known identity {0} found but unable to resolve SID' -f $Id)
                    return

                } #end if-else

            } else {

                # Get object information for the identity
                try {

                    $GroupObject = Get-AdObjectType -Identity $Id

                    if ($null -ne $GroupObject -and
                        $null -ne $GroupObject.SID) {

                        $GroupSid = [System.Security.Principal.SecurityIdentifier]::new($GroupObject.SID)

                        Write-Debug -Message ('Resolved identity {0} to SID: {1}' -f $Id, $GroupSid.Value)

                    } else {

                        Write-Error -Message ('Failed to resolve identity {0} to a valid security principal' -f $Id)
                        return

                    } #end if-else

                } catch {

                    Write-Error -Message ('Error resolving identity {0}: {1}' -f $Id, $_.Exception.Message)
                    throw

                } #end try-catch

            } #end if-else

            #############################
            # Get reference to target object
            #############################
            try {

                # Use caching to avoid redundant queries
                if ($AdObjectCache.ContainsKey($LDAPPath)) {

                    $Object = $AdObjectCache[$LDAPPath]

                    Write-Debug -Message ('Using cached object for LDAP path: {0}' -f $LDAPPath)

                } else {

                    # Use server-side filtering for better performance
                    $Object = Get-ADObject -Identity $LDAPPath -Properties nTSecurityDescriptor

                    $AdObjectCache[$LDAPPath] = $Object

                    Write-Debug -Message ('Retrieved object from AD: {0}' -f $Object.DistinguishedName)

                } #end if-else

                # Prepare the AD path for Get-Acl
                $ObjectPath = ('AD:\{0}' -f $Object.DistinguishedName)

            } catch {

                Write-Error -Message ('Error retrieving AD object {0}: {1}' -f $LDAPPath, $_.Exception.Message)
                throw

            } #end try-catch

            #############################
            # Get current ACL
            #############################
            try {

                $Acl = Get-Acl -Path $ObjectPath

                Write-Debug -Message ('Retrieved current DACL for object: {0}' -f $Object.DistinguishedName)

            } catch {

                Write-Error -Message ('Error retrieving ACL for {0}: {1}' -f $Object.DistinguishedName, $_.Exception.Message)
                throw

            } #end try-catch


            #############################
            # Prepare access rule arguments
            #############################
            # 1. Identity Reference (Trustee)
            $IdentityRef = [System.Security.Principal.IdentityReference]$GroupSid

            # 2. Active Directory Rights
            $ActiveDirectoryRight = [System.DirectoryServices.ActiveDirectoryRights]$PSBoundParameters['AdRight']

            # 3. Access Control Type (Allow/Deny)
            $ACType = [System.Security.AccessControl.AccessControlType]$PSBoundParameters['AccessControlType']

            # 4. Object Type (GUID)
            # Parameter already properly typed as Guid


            # Create Access Rule object
            $AccessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new(
                $IdentityRef,
                $ActiveDirectoryRight,
                $ACType,
                $ObjectTypeGuid
            )

            #############################
            # Add or Remove the rule
            #############################
            if ($RemoveRule) {

                # Remove the access rule
                if ($PSCmdlet.ShouldProcess(
                        $Object.DistinguishedName,
                    ('Remove {0} access rule for {1}' -f $ActiveDirectoryRight, $Id))) {

                    # Find and remove matching rules
                    $RulesToRemove = $Acl.Access | Where-Object {
                        $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $GroupSid.Value -and
                        $_.ActiveDirectoryRights -eq $ActiveDirectoryRight -and
                        $_.AccessControlType -eq $ACType -and
                        $_.ObjectType -eq $ObjectTypeGuid
                    }

                    # Check if any rules were found
                    if ($null -ne $RulesToRemove) {
                        if ($RulesToRemove -is [array]) {
                            foreach ($RuleToRemove in $RulesToRemove) {
                                $null = $Acl.RemoveAccessRule($RuleToRemove)
                                $RulesRemovedCount++
                            } #end foreach
                        } else {
                            # Single object case
                            $null = $Acl.RemoveAccessRule($RulesToRemove)
                            $RulesRemovedCount = 1
                        }
                    } #end if

                    Write-Verbose -Message ('Removed {0} access rule(s) from {1} for {2}' -f
                        $RulesRemovedCount, $Object.DistinguishedName, $Id)
                } #end if

            } else {

                # Add the access rule
                if ($PSCmdlet.ShouldProcess(
                        $Object.DistinguishedName,
                    ('Add {0} access rule for {1}' -f $ActiveDirectoryRight, $Id))) {

                    $null = $Acl.AddAccessRule($AccessRule)

                    Write-Verbose -Message ('Added {0} access rule to {1} for {2}' -f
                        $ActiveDirectoryRight, $Object.DistinguishedName, $Id)
                } #end if

            } #end if-else

            #############################
            # Apply the modified ACL
            #############################
            try {

                if ($PSCmdlet.ShouldProcess($Object.DistinguishedName, 'Apply modified ACL')) {

                    try {

                        # Attempt to set ACL with standard method first
                        Set-Acl -AclObject $Acl -Path $ObjectPath -ErrorAction Stop
                        Write-Verbose -Message ('Applied modified ACL to {0}' -f $Object.DistinguishedName)

                    } catch [System.UnauthorizedAccessException] {

                        # Handle access denied errors by using a different approach
                        Write-Verbose -Message (
                            'Access denied using Set-Acl. Attempting alternative method for {0}' -f
                            $Object.DistinguishedName
                        )

                        # Get the DirectoryEntry object directly
                        $DirectoryEntry = [ADSI]"LDAP://$($Object.DistinguishedName)"

                        # Set the security descriptor
                        $DirectoryEntry.psbase.ObjectSecurity = $Acl

                        # Commit the changes
                        $DirectoryEntry.psbase.CommitChanges()

                        Write-Verbose -Message (
                            'Successfully applied ACL to {0} using DirectoryEntry method' -f
                            $Object.DistinguishedName
                        )

                    } #end try-catch

                } #end if

            } catch {

                Write-Error -Message ('
                    Error applying modified ACL to {0}: {1}
                    '
 -f $Object.DistinguishedName, $_.Exception.Message
                )
                throw
            } #end try-catch

        } catch {

            Write-Error -Message ('Error processing {0}: {1}' -f $LDAPPath, $_.Exception.Message)
            Write-Error -Message $_.ScriptStackTrace
            throw

        } #end try-catch

    } #end Process

    End {
        # Display function footer if variables exist
        if ($null -ne $Variables -and
            $null -ne $Variables.FooterDelegation) {

            $txt = ($Variables.FooterDelegation -f $MyInvocation.InvocationName,
                'adding access rule with 4 arguments (Private Function).'
            )
            Write-Verbose -Message $txt
        } #end if
    } #end End
} #end Function Set-AclConstructor4