# ADPasswordPolicy.ps1 # Fine-grained password policy (PSO) management. # No dependencies on other AD modules. function Ensure-ADPasswordPolicy { <# .SYNOPSIS Idempotently creates or updates a fine-grained password policy (PSO) and syncs group linkage via AppliesTo. #> param( [Parameter(Mandatory)] [hashtable]$Definition ) $name = $Definition.Name # Separate AppliesTo from PSO properties $appliesTo = @() if ($Definition.ContainsKey('AppliesTo')) { $appliesTo = @($Definition.AppliesTo) } try { $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties Description -ErrorAction Stop Write-Host " [OK] PSO exists: $name" -ForegroundColor Green # Check each property for drift $propMap = @{ 'Precedence' = 'Precedence' 'MinPasswordLength' = 'MinPasswordLength' 'PasswordHistoryCount' = 'PasswordHistoryCount' 'MaxPasswordAge' = 'MaxPasswordAge' 'MinPasswordAge' = 'MinPasswordAge' 'ComplexityEnabled' = 'ComplexityEnabled' 'ReversibleEncryptionEnabled' = 'ReversibleEncryptionEnabled' 'LockoutThreshold' = 'LockoutThreshold' 'LockoutDuration' = 'LockoutDuration' 'LockoutObservationWindow' = 'LockoutObservationWindow' } $updates = @{} foreach ($key in $propMap.Keys) { if ($Definition.ContainsKey($key)) { $desired = $Definition[$key] $actual = $pso.$($propMap[$key]) # Normalize TimeSpan comparisons if ($desired -is [string] -and $actual -is [timespan]) { $desiredTS = [timespan]::Parse($desired) if ($actual -ne $desiredTS) { $updates[$key] = $desired } } elseif ("$actual" -ne "$desired") { $updates[$key] = $desired } } } if ($Definition.ContainsKey('Description')) { if ("$($pso.Description)" -ne "$($Definition.Description)") { $updates['Description'] = $Definition.Description } } if ($updates.Count -gt 0) { Set-ADFineGrainedPasswordPolicy -Identity $name @updates foreach ($key in $updates.Keys) { Write-Host " [UPDATED] $name $key='$($updates[$key])'" -ForegroundColor Yellow } } } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { # Build creation parameters $createParams = @{ Name = $name Precedence = $Definition.Precedence } # Map all supported properties $createKeys = @( 'Description', 'MinPasswordLength', 'PasswordHistoryCount', 'MaxPasswordAge', 'MinPasswordAge', 'ComplexityEnabled', 'ReversibleEncryptionEnabled', 'LockoutThreshold', 'LockoutDuration', 'LockoutObservationWindow' ) foreach ($key in $createKeys) { if ($Definition.ContainsKey($key)) { $createParams[$key] = $Definition[$key] } } # ProtectedFromAccidentalDeletion for consistency $createParams['ProtectedFromAccidentalDeletion'] = $true New-ADFineGrainedPasswordPolicy @createParams Write-Host " [CREATED] PSO: $name (precedence $($Definition.Precedence))" -ForegroundColor Yellow } # Sync AppliesTo group linkage if ($appliesTo.Count -gt 0) { $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties AppliesTo $currentSubjects = @() if ($pso.AppliesTo) { $currentSubjects = @($pso.AppliesTo | ForEach-Object { (Get-ADGroup -Identity $_ -ErrorAction SilentlyContinue).SamAccountName }) } foreach ($group in $appliesTo) { if ($group -notin $currentSubjects) { Add-ADFineGrainedPasswordPolicySubject -Identity $name -Subjects $group Write-Host " [LINKED] $name -> $group" -ForegroundColor Yellow } } foreach ($current in $currentSubjects) { if ($current -notin $appliesTo) { Remove-ADFineGrainedPasswordPolicySubject -Identity $name -Subjects $current -Confirm:$false Write-Host " [UNLINKED] $name -> $current" -ForegroundColor Red } } } } function Compare-ADPasswordPolicy { <# .SYNOPSIS Compares desired PSO state against AD. Returns diffs. #> param( [Parameter(Mandatory)] [hashtable]$Definition ) $name = $Definition.Name $diffs = @() # Separate AppliesTo from PSO properties $appliesTo = @() if ($Definition.ContainsKey('AppliesTo')) { $appliesTo = @($Definition.AppliesTo) } try { $pso = Get-ADFineGrainedPasswordPolicy -Identity $name -Properties AppliesTo, Description -ErrorAction Stop Write-Host " [OK] PSO exists: $name" -ForegroundColor Green # Check each property $propMap = @{ 'Precedence' = 'Precedence' 'MinPasswordLength' = 'MinPasswordLength' 'PasswordHistoryCount' = 'PasswordHistoryCount' 'MaxPasswordAge' = 'MaxPasswordAge' 'MinPasswordAge' = 'MinPasswordAge' 'ComplexityEnabled' = 'ComplexityEnabled' 'ReversibleEncryptionEnabled' = 'ReversibleEncryptionEnabled' 'LockoutThreshold' = 'LockoutThreshold' 'LockoutDuration' = 'LockoutDuration' 'LockoutObservationWindow' = 'LockoutObservationWindow' } foreach ($key in $propMap.Keys) { if ($Definition.ContainsKey($key)) { $desired = $Definition[$key] $actual = $pso.$($propMap[$key]) $isDrift = $false if ($desired -is [string] -and $actual -is [timespan]) { $desiredTS = [timespan]::Parse($desired) if ($actual -ne $desiredTS) { $isDrift = $true } } elseif ("$actual" -ne "$desired") { $isDrift = $true } if ($isDrift) { Write-Host " [DRIFT] $name $key='$actual', expected '$desired'" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'PSOProperty'; PSO = $name Property = $key; Current = $actual; Desired = $desired } } } } if ($Definition.ContainsKey('Description')) { if ("$($pso.Description)" -ne "$($Definition.Description)") { Write-Host " [DRIFT] $name Description='$($pso.Description)', expected '$($Definition.Description)'" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'PSOProperty'; PSO = $name Property = 'Description'; Current = $pso.Description; Desired = $Definition.Description } } } # Check AppliesTo linkage $currentSubjects = @() if ($pso.AppliesTo) { $currentSubjects = @($pso.AppliesTo | ForEach-Object { (Get-ADGroup -Identity $_ -ErrorAction SilentlyContinue).SamAccountName }) } foreach ($group in $appliesTo) { if ($group -notin $currentSubjects) { Write-Host " [DRIFT] $name not linked to $group" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'PSOSubject'; PSO = $name; Group = $group; Status = 'Missing' } } } foreach ($current in $currentSubjects) { if ($current -notin $appliesTo) { Write-Host " [DRIFT] $name linked to $current but not in desired state" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'PSOSubject'; PSO = $name; Group = $current; Status = 'Extra' } } } } catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { Write-Host " [MISSING] PSO: $name" -ForegroundColor Red $diffs += [PSCustomObject]@{ Type = 'PSO'; Name = $name; Status = 'Missing' } } return $diffs }