<# .SYNOPSIS Checks the portal service account's main rights and refusals over LDAPS. .DESCRIPTION Binds over LDAPS (port 636) with certificate verification as the service account and checks: 1. the bind works and the server certificate is trusted by this machine, 2. a test user can be found and its account state read, 3. the domain password policy can be read, 4. each of the six allowed attributes can be written (and is restored), 5. a not-allowed attribute cannot be written, 6. a user OUTSIDE the test OU cannot be written (a no-change request, safe even if it succeeds), 7. optionally a password change works with the Change Password right (-TestPasswordChange). Not covered: that the account cannot create or delete objects or reset passwords. Check those in Active Directory (Delegation of Control / dsacls) if you need that proof. Every check prints PASS or FAIL; any unexpected error stops the script with a non-zero exit code, so "All checks passed." is only printed when every check ran. Nothing is left changed except the test user's password if you use -TestPasswordChange. Run it from a machine that can reach the directory server. Not tested by the author against a live directory. .EXAMPLE .\Test-PortalLdaps.ps1 -Server dc1.example.local -BindUser svc-portal-test@example.local ` -TestOuDn "OU=PortalTest,OU=Organisation,DC=example,DC=local" ` -OutsideUserDn "CN=Some Person,OU=Elsewhere,DC=example,DC=local" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Server, [Parameter(Mandatory = $true)][string]$BindUser, [Parameter(Mandatory = $true)][string]$TestOuDn, [string]$TestSam = 'ptest-user1', [string]$OutsideUserDn, [switch]$TestPasswordChange ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.DirectoryServices.Protocols $P = [System.DirectoryServices.Protocols.SearchScope] $failed = 0 function Report([string]$Name, [bool]$Ok, [string]$Detail = '') { if ($Ok) { Write-Host "PASS $Name" -ForegroundColor Green } else { Write-Host "FAIL $Name $Detail" -ForegroundColor Red; $script:failed++ } } function Plain([securestring]$s) { $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s) try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) } } function Search([string]$Base, [string]$Filter, $Scope, [string[]]$Attrs) { $req = New-Object System.DirectoryServices.Protocols.SearchRequest($Base, $Filter, $Scope, $Attrs) return [System.DirectoryServices.Protocols.SearchResponse]($script:conn.SendRequest($req)) } function Modify([string]$Dn, [string]$Attr, $Values) { $m = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification $m.Name = $Attr $m.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace foreach ($v in $Values) { [void]$m.Add($v) } $req = New-Object System.DirectoryServices.Protocols.ModifyRequest($Dn, $m) [void]$script:conn.SendRequest($req) } function Denied([scriptblock]$Action) { try { & $Action; return $false } catch [System.DirectoryServices.Protocols.DirectoryOperationException] { return ($_.Exception.Response.ResultCode -eq 'InsufficientAccessRights') } } # 1. bind with certificate verification (the default uses this machine's trust store) $pw = Read-Host -AsSecureString "Password for $BindUser" $id = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, 636, $false, $false) $conn = New-Object System.DirectoryServices.Protocols.LdapConnection($id) $conn.SessionOptions.SecureSocketLayer = $true $conn.SessionOptions.ProtocolVersion = 3 $conn.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic $conn.Credential = New-Object System.Net.NetworkCredential($BindUser, (Plain $pw)) try { $conn.Bind(); Report 'LDAPS bind with a trusted certificate' $true } catch { Report 'LDAPS bind with a trusted certificate' $false $_.Exception.Message; exit 1 } # 2. find the test user and read its state $allowed = @('givenName', 'sn', 'displayName', 'mail', 'telephoneNumber', 'mobile') try { $root = Search '' '(objectClass=*)' $P::Base @('defaultNamingContext') $domainDn = $root.Entries[0].Attributes['defaultNamingContext'][0] $r = Search $TestOuDn "(sAMAccountName=$TestSam)" $P::Subtree (@('distinguishedName', 'pwdLastSet', 'userAccountControl', 'memberOf') + $allowed) $found = $r.Entries.Count -eq 1 Report "find $TestSam in the test OU and read its state" ($found -and $r.Entries[0].Attributes.Contains('userAccountControl')) } catch { Report "find $TestSam in the test OU and read its state" $false $_.Exception.Message; exit 1 } if (-not $found) { Write-Host 'Cannot continue without the test user.'; exit 1 } $entry = $r.Entries[0] $userDn = $entry.DistinguishedName # 3. domain password policy try { $pol = Search $domainDn '(objectClass=*)' $P::Base @('minPwdLength', 'pwdProperties', 'pwdHistoryLength') Report 'read the domain password policy' ($pol.Entries[0].Attributes.Contains('minPwdLength')) } catch { Report 'read the domain password policy' $false $_.Exception.Message } # 4. every allowed attribute: write a marker, then restore the original value foreach ($attr in $allowed) { $old = @(); if ($entry.Attributes.Contains($attr)) { $old = @([string]$entry.Attributes[$attr][0]) } $marker = if ($attr -eq 'mail') { 'portal-test-marker@example.test' } else { 'PortalTest-marker' } try { Modify $userDn $attr @($marker) Modify $userDn $attr $old Report "write the allowed attribute $attr and restore it" $true } catch { Report "write the allowed attribute $attr and restore it" $false $_.Exception.Message } } # 5. not-allowed attribute must be refused Report 'writing a not-allowed attribute (description) is refused' (Denied { Modify $userDn 'description' @('portal test') }) # 6. outside the test OU must be refused (no-change request, safe even if it were allowed) if ($OutsideUserDn) { try { $o = Search $OutsideUserDn '(objectClass=*)' $P::Base @('telephoneNumber') $same = @(); if ($o.Entries[0].Attributes.Contains('telephoneNumber')) { $same = @([string]$o.Entries[0].Attributes['telephoneNumber'][0]) } Report 'writing a user outside the test OU is refused' (Denied { Modify $OutsideUserDn 'telephoneNumber' $same }) } catch { Report 'writing a user outside the test OU is refused' $false "cannot read -OutsideUserDn: $($_.Exception.Message)" } } else { Write-Host 'SKIP outside-OU check (pass -OutsideUserDn to run it)' -ForegroundColor Yellow } # 7. password change with the Change Password right (changes the test user's password!) if ($TestPasswordChange) { $oldPw = Plain (Read-Host -AsSecureString "Current password of $TestSam") $newPw = Plain (Read-Host -AsSecureString "New password for $TestSam (must meet the policy)") $enc = [System.Text.Encoding]::Unicode $del = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification $del.Name = 'unicodePwd'; $del.Operation = 'Delete'; [void]$del.Add($enc.GetBytes('"' + $oldPw + '"')) $add = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification $add.Name = 'unicodePwd'; $add.Operation = 'Add'; [void]$add.Add($enc.GetBytes('"' + $newPw + '"')) $ok = $true; $detail = '' try { [void]$conn.SendRequest((New-Object System.DirectoryServices.Protocols.ModifyRequest($userDn, @($del, $add)))) } catch { $ok = $false; $detail = $_.Exception.Message $inner = $_.Exception.InnerException if ($inner -is [System.DirectoryServices.Protocols.DirectoryOperationException]) { $detail += " [LDAP result: $($inner.Response.ResultCode); server: $($inner.Response.ErrorMessage)]" } } Report 'change a password with the Change Password right' $ok $detail } else { Write-Host 'SKIP password change (use -TestPasswordChange; it changes the test user password)' -ForegroundColor Yellow } Write-Host '' if ($failed -eq 0) { Write-Host 'All checks passed.' -ForegroundColor Green } else { Write-Host "$failed check(s) failed." -ForegroundColor Red; exit 1 }