Damien Coles f172d00514 Initial release: Declarative AD Framework v2.1.0
Infrastructure-as-code framework for Active Directory objects and Group Policy.
Sanitized from production deployment for public sharing.
2026-02-19 17:02:42 +00:00

225 lines
8.4 KiB
PowerShell

# 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
}