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:
co-authored by
Claude Sonnet 5.5
parent
840bea1cba
commit
003ce6e0c2
@@ -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 }
|
||||
Reference in New Issue
Block a user