Infrastructure-as-code framework for Active Directory objects and Group Policy. Sanitized from production deployment for public sharing.
225 lines
8.4 KiB
PowerShell
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
|
|
}
|