docs: test-environment runbook and scripts for ticket 1.2

Setup script for the test OU, scoped service account, groups and test users;
an LDAPS check script; and a runbook for the CA file, authentik, Zoraxy and
a test mailbox. Not run against a live directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 15:11:14 +02:00
co-authored by Claude Sonnet 5.5
parent 840bea1cba
commit 003ce6e0c2
5 changed files with 483 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
<#
.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 }
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 }