Private/New-IdleADAdapter.ps1
|
function New-IdleADAdapter { <# .SYNOPSIS Creates an internal adapter that wraps Active Directory cmdlets. .DESCRIPTION This adapter provides a testable boundary between the provider and AD cmdlets. Unit tests can inject a fake adapter without requiring a real AD environment. .PARAMETER Credential Optional PSCredential for AD operations. If not provided, uses integrated auth. .PARAMETER PasswordGenerationFallbackMinLength Fallback minimum password length when domain policy cannot be read. Default is 24. .PARAMETER PasswordGenerationRequireUpper Fallback requirement for uppercase characters in generated passwords. Default is $true. .PARAMETER PasswordGenerationRequireLower Fallback requirement for lowercase characters in generated passwords. Default is $true. .PARAMETER PasswordGenerationRequireDigit Fallback requirement for digit characters in generated passwords. Default is $true. .PARAMETER PasswordGenerationRequireSpecial Fallback requirement for special characters in generated passwords. Default is $true. .PARAMETER PasswordGenerationSpecialCharSet Set of special characters to use in generated passwords. Default is '!@#$%&*+-_=?'. .NOTES PSScriptAnalyzer suppression: This function intentionally uses ConvertTo-SecureString -AsPlainText as an explicit escape hatch for AccountPasswordAsPlainText. This is a documented design decision with automatic redaction via Copy-IdleRedactedObject. #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Intentional escape hatch for AccountPasswordAsPlainText with explicit opt-in and automatic redaction')] param( [Parameter()] [AllowNull()] [PSCredential] $Credential, [Parameter()] [int] $PasswordGenerationFallbackMinLength = 24, [Parameter()] [bool] $PasswordGenerationRequireUpper = $true, [Parameter()] [bool] $PasswordGenerationRequireLower = $true, [Parameter()] [bool] $PasswordGenerationRequireDigit = $true, [Parameter()] [bool] $PasswordGenerationRequireSpecial = $true, [Parameter()] [string] $PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?' ) $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.ADAdapter' Credential = $Credential PasswordGenerationFallbackMinLength = $PasswordGenerationFallbackMinLength PasswordGenerationRequireUpper = $PasswordGenerationRequireUpper PasswordGenerationRequireLower = $PasswordGenerationRequireLower PasswordGenerationRequireDigit = $PasswordGenerationRequireDigit PasswordGenerationRequireSpecial = $PasswordGenerationRequireSpecial PasswordGenerationSpecialCharSet = $PasswordGenerationSpecialCharSet } # Add LDAP filter escaping as a ScriptMethod to make it available in the adapter's scope # Uses 'Protect' prefix as 'Escape' is not an approved PowerShell verb $adapter | Add-Member -MemberType ScriptMethod -Name ProtectLdapFilterValue -Value { param( [Parameter(Mandatory)] [string] $Value ) $escaped = $Value -replace '\\', '\5c' $escaped = $escaped -replace '\*', '\2a' $escaped = $escaped -replace '\(', '\28' $escaped = $escaped -replace '\)', '\29' $escaped = $escaped -replace "`0", '\00' return $escaped } -Force # Add Manager DN validation helper $adapter | Add-Member -MemberType ScriptMethod -Name TestManagerDN -Value { param( [Parameter(Mandatory)] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $true } if ($Value -isnot [string]) { throw "Manager must be a DistinguishedName (DN) string, but received type: $($Value.GetType().FullName)" } if ([string]::IsNullOrWhiteSpace($Value)) { throw "Manager must be a DistinguishedName (DN) string, but received empty or whitespace-only value." } if (-not ($Value -match '=' -and $Value -match ',')) { throw "Manager must be a DistinguishedName (DN). Expected format: 'CN=Name,OU=Unit,DC=domain,DC=com'. Received: '$Value'" } return $true } -Force # Add Manager DN resolution helper # Accepts DN, GUID, UPN, or sAMAccountName and resolves to DN $adapter | Add-Member -MemberType ScriptMethod -Name ResolveManagerDN -Value { param( [Parameter(Mandatory)] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } if ($Value -isnot [string]) { throw "Manager must be a string (DN, GUID, UPN, or sAMAccountName), but received type: $($Value.GetType().FullName)" } if ([string]::IsNullOrWhiteSpace($Value)) { throw "Manager cannot be an empty or whitespace-only string." } # Check if already a DN (contains = and ,) if ($Value -match '=' -and $Value -match ',') { # Already a DN, validate and return $this.TestManagerDN($Value) | Out-Null return $Value } # Try to resolve as GUID, UPN, or sAMAccountName Write-Verbose "Manager value '$Value' is not a DN. Attempting to resolve to DN..." # Try GUID format first $guid = [System.Guid]::Empty if ([System.Guid]::TryParse($Value, [ref]$guid)) { try { $managerUser = $this.GetUserByGuid($guid.ToString()) if ($null -ne $managerUser) { Write-Verbose "Resolved Manager GUID '$Value' to DN: $($managerUser.DistinguishedName)" return $managerUser.DistinguishedName } } catch { Write-Verbose "Failed to resolve Manager GUID '$Value': $_" } throw "Manager: Could not find user with GUID '$Value'." } # Try UPN format (contains @) if ($Value -match '@') { $managerUser = $this.GetUserByUpn($Value) if ($null -ne $managerUser) { Write-Verbose "Resolved Manager UPN '$Value' to DN: $($managerUser.DistinguishedName)" return $managerUser.DistinguishedName } throw "Manager: Could not find user with UPN '$Value'." } # Fallback to sAMAccountName $managerUser = $this.GetUserBySam($Value) if ($null -ne $managerUser) { Write-Verbose "Resolved Manager sAMAccountName '$Value' to DN: $($managerUser.DistinguishedName)" return $managerUser.DistinguishedName } throw "Manager: Could not find user with sAMAccountName '$Value'." } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Upn ) $escapedUpn = $this.ProtectLdapFilterValue($Upn) # Escape single quotes for PowerShell -Filter single-quoted string syntax by doubling them $escapedUpn = $escapedUpn -replace '''', '''''' $params = @{ Filter = "UserPrincipalName -eq '$escapedUpn'" Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $user = Get-ADUser @params return $user } catch { return $null } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserBySam -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SamAccountName ) $escapedSam = $this.ProtectLdapFilterValue($SamAccountName) # Escape single quotes for PowerShell -Filter single-quoted string syntax by doubling them $escapedSam = $escapedSam -replace '''', '''''' $params = @{ Filter = "sAMAccountName -eq '$escapedSam'" Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $user = Get-ADUser @params return $user } catch { return $null } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByGuid -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Guid ) $params = @{ Identity = $Guid Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $user = Get-ADUser @params return $user } catch { return $null } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name NewUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Attributes, [Parameter()] [bool] $Enabled = $true ) # Create a local copy of Attributes to avoid mutating the caller's hashtable $effectiveAttributes = $Attributes.Clone() # Classify IdentityKey: GUID, UPN, or SamAccountName-like $isGuid = $false $isUpn = $false $isSamAccountNameLike = $false $guid = [System.Guid]::Empty if ([System.Guid]::TryParse($IdentityKey, [ref]$guid)) { $isGuid = $true } elseif ($IdentityKey -match '@') { $isUpn = $true } else { $isSamAccountNameLike = $true } # 1. Derive SamAccountName from IdentityKey if missing $hasSamAccountName = $effectiveAttributes.ContainsKey('SamAccountName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['SamAccountName']) if (-not $hasSamAccountName) { if ($isSamAccountNameLike) { $effectiveAttributes['SamAccountName'] = $IdentityKey Write-Verbose "AD Provider: Derived SamAccountName='$IdentityKey' from IdentityKey (SamAccountName-like)" } elseif ($isUpn) { throw "SamAccountName is required when IdentityKey is a UPN. IdentityKey='$IdentityKey' appears to be a UPN (contains '@'). Please provide an explicit 'SamAccountName' in Attributes." } elseif ($isGuid) { throw "SamAccountName is required when IdentityKey is a GUID. IdentityKey='$IdentityKey' is a GUID. Please provide an explicit 'SamAccountName' in Attributes." } } # 2. Auto-set UserPrincipalName when IdentityKey is a UPN $hasUpn = $effectiveAttributes.ContainsKey('UserPrincipalName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['UserPrincipalName']) if (-not $hasUpn -and $isUpn) { $effectiveAttributes['UserPrincipalName'] = $IdentityKey Write-Verbose "AD Provider: Derived UserPrincipalName='$IdentityKey' from IdentityKey (UPN format)" } # 3. Derive CN/RDN Name with priority: Name > DisplayName > GivenName+Surname > IdentityKey $derivedName = $null $hasExplicitName = $effectiveAttributes.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Name']) if ($hasExplicitName) { $derivedName = $effectiveAttributes['Name'] Write-Verbose "AD Provider: Using explicit Name='$derivedName' for CN/RDN" } elseif ($effectiveAttributes.ContainsKey('DisplayName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['DisplayName'])) { $derivedName = $effectiveAttributes['DisplayName'] Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from DisplayName" } elseif ($effectiveAttributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['GivenName']) -and $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { $derivedName = "$($effectiveAttributes['GivenName']) $($effectiveAttributes['Surname'])" Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from GivenName+Surname" } else { $derivedName = $IdentityKey Write-Verbose "AD Provider: Falling back to IdentityKey='$derivedName' for CN/RDN Name (no DisplayName or GivenName+Surname provided)" } $params = @{ Name = $derivedName Enabled = $Enabled ErrorAction = 'Stop' } if ($effectiveAttributes.ContainsKey('SamAccountName')) { $params['SamAccountName'] = $effectiveAttributes['SamAccountName'] } if ($effectiveAttributes.ContainsKey('UserPrincipalName')) { $params['UserPrincipalName'] = $effectiveAttributes['UserPrincipalName'] } if ($effectiveAttributes.ContainsKey('Path')) { $params['Path'] = $effectiveAttributes['Path'] } if ($effectiveAttributes.ContainsKey('GivenName')) { $params['GivenName'] = $effectiveAttributes['GivenName'] } if ($effectiveAttributes.ContainsKey('Surname')) { $params['Surname'] = $effectiveAttributes['Surname'] } if ($effectiveAttributes.ContainsKey('DisplayName')) { $params['DisplayName'] = $effectiveAttributes['DisplayName'] } if ($effectiveAttributes.ContainsKey('Description')) { $params['Description'] = $effectiveAttributes['Description'] } if ($effectiveAttributes.ContainsKey('Department')) { $params['Department'] = $effectiveAttributes['Department'] } if ($effectiveAttributes.ContainsKey('Title')) { $params['Title'] = $effectiveAttributes['Title'] } if ($effectiveAttributes.ContainsKey('EmailAddress')) { $params['EmailAddress'] = $effectiveAttributes['EmailAddress'] } if ($effectiveAttributes.ContainsKey('Manager')) { $managerValue = $effectiveAttributes['Manager'] $resolvedManagerDN = $this.ResolveManagerDN($managerValue) if ($null -ne $resolvedManagerDN) { $params['Manager'] = $resolvedManagerDN } } # Password handling: support SecureString, ProtectedString, explicit PlainText, and auto-generation $hasAccountPassword = $effectiveAttributes.ContainsKey('AccountPassword') $hasAccountPasswordAsPlainText = $effectiveAttributes.ContainsKey('AccountPasswordAsPlainText') $generatedPasswordInfo = $null if ($hasAccountPassword -and $hasAccountPasswordAsPlainText) { throw "Ambiguous password configuration: both 'AccountPassword' and 'AccountPasswordAsPlainText' are provided. Use only one." } if ($hasAccountPassword) { $passwordValue = $effectiveAttributes['AccountPassword'] if ($null -eq $passwordValue) { throw "AccountPassword: Value cannot be null. Provide a SecureString or ProtectedString (from ConvertFrom-SecureString)." } if ($passwordValue -is [securestring]) { # Mode 1: SecureString - use directly $params['AccountPassword'] = $passwordValue } elseif ($passwordValue -is [string]) { # Mode 2: ProtectedString (from ConvertFrom-SecureString) try { $params['AccountPassword'] = ConvertTo-SecureString -String $passwordValue -ErrorAction Stop } catch { $errorMsg = "AccountPassword: Expected a ProtectedString (output from ConvertFrom-SecureString without -Key) but conversion failed. " $errorMsg += "Only DPAPI-scoped ProtectedStrings are supported (created under the same Windows user and machine). " $errorMsg += "Key-based protected strings (using -Key or -SecureKey) are not supported. " if ($null -ne $_.Exception) { $errorMsg += "Exception type: $($PSItem.Exception.GetType().FullName). " if (-not [string]::IsNullOrWhiteSpace($_.Exception.Message)) { $errorMsg += "Message: $($_.Exception.Message)" } } throw $errorMsg } } else { throw "AccountPassword: Expected a SecureString or ProtectedString (string from ConvertFrom-SecureString), but received type: $($passwordValue.GetType().FullName)" } } elseif ($hasAccountPasswordAsPlainText) { $plainTextPassword = $effectiveAttributes['AccountPasswordAsPlainText'] if ($null -eq $plainTextPassword) { throw "AccountPasswordAsPlainText: Value cannot be null. Provide a non-empty plaintext password string." } if ($plainTextPassword -isnot [string]) { throw "AccountPasswordAsPlainText: Expected a string but received type: $($plainTextPassword.GetType().FullName)" } if ([string]::IsNullOrWhiteSpace($plainTextPassword)) { throw "AccountPasswordAsPlainText: Password cannot be null or empty." } # Mode 3: Explicit plaintext - convert with -AsPlainText # This is an intentional escape hatch with explicit opt-in via AccountPasswordAsPlainText. # The value is redacted from logs/events via Copy-IdleRedactedObject. $params['AccountPassword'] = ConvertTo-SecureString -String $plainTextPassword -AsPlainText -Force } elseif ($Enabled) { # Mode 4: Auto-generate password when enabled and no password provided # Generate a policy-compliant password Write-Verbose "AD Provider: No password provided for enabled account. Generating policy-compliant password..." $passwordGenParams = @{ FallbackMinLength = $this.PasswordGenerationFallbackMinLength FallbackRequireUpper = $this.PasswordGenerationRequireUpper FallbackRequireLower = $this.PasswordGenerationRequireLower FallbackRequireDigit = $this.PasswordGenerationRequireDigit FallbackRequireSpecial = $this.PasswordGenerationRequireSpecial FallbackSpecialCharSet = $this.PasswordGenerationSpecialCharSet } if ($null -ne $this.Credential) { $passwordGenParams['Credential'] = $this.Credential } $generatedPasswordInfo = New-IdleADPassword @passwordGenParams $params['AccountPassword'] = $generatedPasswordInfo.SecureString Write-Verbose "AD Provider: Generated password using $($generatedPasswordInfo.UsedPolicy) policy (MinLength=$($generatedPasswordInfo.MinLength))" } # Handle ResetOnFirstLogin (ChangePasswordAtLogon) # Default to true when a password was set or generated, unless explicitly overridden $resetOnFirstLogin = $true if ($effectiveAttributes.ContainsKey('ResetOnFirstLogin')) { $resetOnFirstLogin = [bool]$effectiveAttributes['ResetOnFirstLogin'] } # Only set ChangePasswordAtLogon if a password was provided or generated if ($hasAccountPassword -or $hasAccountPasswordAsPlainText -or ($null -ne $generatedPasswordInfo)) { $params['ChangePasswordAtLogon'] = $resetOnFirstLogin } # Handle OtherAttributes for custom LDAP attributes if ($effectiveAttributes.ContainsKey('OtherAttributes')) { $otherAttrs = $effectiveAttributes['OtherAttributes'] if ($null -ne $otherAttrs -and $otherAttrs.Count -gt 0) { $params['OtherAttributes'] = $otherAttrs } } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } $user = New-ADUser @params -PassThru # Return user with optional password generation info if ($null -ne $generatedPasswordInfo) { # Attach password generation info to user object for caller to access $user | Add-Member -MemberType NoteProperty -Name '_GeneratedPasswordInfo' -Value $generatedPasswordInfo -Force } return $user } -Force $adapter | Add-Member -MemberType ScriptMethod -Name SetUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AttributeName, [Parameter()] [AllowNull()] [object] $Value ) $params = @{ Identity = $Identity ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } switch ($AttributeName) { 'GivenName' { $params['GivenName'] = $Value } 'Surname' { $params['Surname'] = $Value } 'DisplayName' { $params['DisplayName'] = $Value } 'Description' { $params['Description'] = $Value } 'Department' { $params['Department'] = $Value } 'Title' { $params['Title'] = $Value } 'EmailAddress' { $params['EmailAddress'] = $Value } 'UserPrincipalName' { $params['UserPrincipalName'] = $Value } 'Manager' { # Expect $Value to be a normalized DN or $null. if ($null -eq $Value) { $params['Clear'] = 'manager' } else { $params['Manager'] = $Value } } default { $params['Replace'] = @{ $AttributeName = $Value } } } Set-ADUser @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name DisableUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity ) $params = @{ Identity = $Identity Enabled = $false ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Set-ADUser @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name EnableUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity ) $params = @{ Identity = $Identity Enabled = $true ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Set-ADUser @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name MoveObject -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $TargetPath ) $params = @{ Identity = $Identity TargetPath = $TargetPath ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Move-ADObject @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity ) $params = @{ Identity = $Identity Confirm = $false ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Remove-ADUser @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity ) $params = @{ Identity = $Identity Properties = @('DistinguishedName', 'Name', 'sAMAccountName', 'ObjectGuid') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $group = Get-ADGroup @params return $group } catch { return $null } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupIdentity, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $MemberIdentity ) $params = @{ Identity = $GroupIdentity Members = $MemberIdentity ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Add-ADGroupMember @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupIdentity, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $MemberIdentity ) $params = @{ Identity = $GroupIdentity Members = $MemberIdentity Confirm = $false ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } Remove-ADGroupMember @params } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserGroups -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Identity ) $params = @{ Identity = $Identity ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $groups = Get-ADPrincipalGroupMembership @params return $groups } catch { return @() } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param( [Parameter()] [hashtable] $Filter ) $filterString = '*' if ($null -ne $Filter -and $Filter.ContainsKey('Search') -and -not [string]::IsNullOrWhiteSpace($Filter['Search'])) { $searchValue = [string] $Filter['Search'] $escapedSearch = $this.ProtectLdapFilterValue($searchValue) # Escape single quotes for use inside a single-quoted -Filter string (PowerShell/AD filter syntax) $filterSafeSearch = $escapedSearch -replace "'", "''" $filterString = "sAMAccountName -like '$filterSafeSearch*' -or UserPrincipalName -like '$filterSafeSearch*'" } $params = @{ Filter = $filterString Properties = @('ObjectGuid', 'sAMAccountName', 'UserPrincipalName') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } try { $users = Get-ADUser @params return $users } catch { return @() } } -Force return $adapter } |