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

378 lines
12 KiB
PowerShell

# GPOPolicy.ps1
# GptTmpl.inf handling and registry-based Administrative Template settings.
# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Get-GPOSecurityTemplatePath)
function Read-GptTmplInf {
<#
.SYNOPSIS
Parses a GptTmpl.inf file into a nested hashtable keyed by section name.
#>
param(
[Parameter(Mandatory)]
[string]$Path
)
if (-not (Test-Path $Path)) {
return @{}
}
$result = @{}
$currentSection = $null
# GptTmpl.inf is UTF-16LE
$lines = [System.IO.File]::ReadAllLines($Path, [System.Text.Encoding]::Unicode)
foreach ($line in $lines) {
$line = $line.Trim()
if ($line -match '^\[(.+)\]$') {
$currentSection = $Matches[1]
if (-not $result.Contains($currentSection)) {
$result[$currentSection] = [ordered]@{}
}
}
elseif ($line -match '^(.+?)\s*=\s*(.*)$' -and $currentSection) {
$result[$currentSection][$Matches[1].Trim()] = $Matches[2].Trim()
}
}
return $result
}
function ConvertTo-GptTmplInf {
<#
.SYNOPSIS
Converts a settings hashtable into GptTmpl.inf content string.
.DESCRIPTION
Takes the SecurityPolicy hashtable from a settings.ps1 file and produces
the INI-format content for a GptTmpl.inf file.
#>
param(
[Parameter(Mandatory)]
[hashtable]$Settings
)
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('[Unicode]')
[void]$sb.AppendLine('Unicode=yes')
# Ordered section output
$sectionOrder = @(
'System Access'
'Kerberos Policy'
'Event Audit'
'Registry Values'
'Privilege Rights'
'Group Membership'
)
foreach ($section in $sectionOrder) {
if ($Settings.Contains($section)) {
[void]$sb.AppendLine("[$section]")
foreach ($key in $Settings[$section].Keys) {
[void]$sb.AppendLine("$key = $($Settings[$section][$key])")
}
}
}
# Include any sections not in the predefined order
foreach ($section in $Settings.Keys) {
if ($section -notin $sectionOrder -and $section -ne 'Unicode') {
[void]$sb.AppendLine("[$section]")
foreach ($key in $Settings[$section].Keys) {
[void]$sb.AppendLine("$key = $($Settings[$section][$key])")
}
}
}
[void]$sb.AppendLine('[Version]')
[void]$sb.AppendLine('signature="$CHICAGO$"')
[void]$sb.AppendLine('Revision=1')
return $sb.ToString()
}
function Set-GPOSecurityPolicy {
<#
.SYNOPSIS
Writes security policy settings to a GPO's GptTmpl.inf in SYSVOL
and bumps the GPO version number in AD so clients pick up the change.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[hashtable]$SecurityPolicy,
[string]$Domain = (Get-ADDomain).DNSRoot
)
$infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain
$infDir = Split-Path $infPath -Parent
# Ensure directory structure exists
if (-not (Test-Path $infDir)) {
New-Item -ItemType Directory -Path $infDir -Force | Out-Null
}
# Generate and write the inf content as UTF-16LE (required by Windows)
$content = ConvertTo-GptTmplInf -Settings $SecurityPolicy
[System.IO.File]::WriteAllText($infPath, $content, [System.Text.Encoding]::Unicode)
Write-Host " Written: $infPath" -ForegroundColor Green
}
function Compare-GPOSecurityPolicy {
<#
.SYNOPSIS
Compares desired security policy settings against what's currently
in the GPO's GptTmpl.inf. Returns differences.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[hashtable]$SecurityPolicy,
[string]$Domain = (Get-ADDomain).DNSRoot
)
$infPath = Get-GPOSecurityTemplatePath -GPOName $GPOName -Domain $Domain
$current = Read-GptTmplInf -Path $infPath
$differences = @()
foreach ($section in $SecurityPolicy.Keys) {
foreach ($key in $SecurityPolicy[$section].Keys) {
$desired = [string]$SecurityPolicy[$section][$key]
$actual = $null
if ($current.Contains($section) -and $current[$section].Contains($key)) {
$actual = [string]$current[$section][$key]
}
if ($actual -ne $desired) {
$differences += [PSCustomObject]@{
Section = $section
Setting = $key
Current = if ($null -eq $actual) { '(not set)' } else { $actual }
Desired = $desired
}
}
}
}
return $differences
}
# -------------------------------------------------------------------
# Restricted Groups ([Group Membership] section in GptTmpl.inf)
# -------------------------------------------------------------------
function ConvertTo-RestrictedGroupEntries {
<#
.SYNOPSIS
Converts a friendly RestrictedGroups hashtable into [Group Membership]
key-value pairs for GptTmpl.inf.
.DESCRIPTION
GptTmpl.inf [Group Membership] uses SID-based keys:
*S-1-5-32-544__Members = *S-1-5-21-xxx,*S-1-5-21-yyy
*S-1-5-32-544__Memberof =
This function resolves group/account names to SIDs automatically.
#>
param(
[Parameter(Mandatory)]
[hashtable]$RestrictedGroups
)
$entries = [ordered]@{}
foreach ($groupName in $RestrictedGroups.Keys) {
$groupDef = $RestrictedGroups[$groupName]
# Resolve target group to SID
try {
$ntAccount = New-Object System.Security.Principal.NTAccount($groupName)
$groupSid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
} catch {
throw "RestrictedGroups: cannot resolve group '$groupName' to a SID: $_"
}
# Members
$memberSids = @()
if ($groupDef.Members) {
foreach ($member in $groupDef.Members) {
try {
$memberNt = New-Object System.Security.Principal.NTAccount($member)
$memberSid = $memberNt.Translate([System.Security.Principal.SecurityIdentifier]).Value
$memberSids += "*$memberSid"
} catch {
throw "RestrictedGroups: cannot resolve member '$member' of group '$groupName' to a SID: $_"
}
}
}
$entries["*${groupSid}__Members"] = $memberSids -join ','
# Memberof
$memberofSids = @()
if ($groupDef.Memberof) {
foreach ($parent in $groupDef.Memberof) {
try {
$parentNt = New-Object System.Security.Principal.NTAccount($parent)
$parentSid = $parentNt.Translate([System.Security.Principal.SecurityIdentifier]).Value
$memberofSids += "*$parentSid"
} catch {
throw "RestrictedGroups: cannot resolve parent group '$parent' of '$groupName' to a SID: $_"
}
}
}
$entries["*${groupSid}__Memberof"] = $memberofSids -join ','
}
return $entries
}
# -------------------------------------------------------------------
# Registry-Based Settings (Administrative Templates)
# -------------------------------------------------------------------
function Compare-GPORegistrySettings {
<#
.SYNOPSIS
Compares desired registry-based (Administrative Template) settings
against the current values in a GPO. Returns differences.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[array]$RegistrySettings,
[string]$Domain = (Get-ADDomain).DNSRoot
)
$differences = @()
foreach ($reg in $RegistrySettings) {
$desiredDisplay = "$($reg.Value) ($($reg.Type))"
try {
$current = Get-GPRegistryValue `
-Name $GPOName `
-Domain $Domain `
-Key $reg.Key `
-ValueName $reg.ValueName `
-ErrorAction Stop
$actualValue = $current.Value
$actualType = $current.Type.ToString()
if ([string]$actualValue -ne [string]$reg.Value -or $actualType -ne $reg.Type) {
$differences += [PSCustomObject]@{
Key = $reg.Key
ValueName = $reg.ValueName
Current = "$actualValue ($actualType)"
Desired = $desiredDisplay
}
}
} catch {
# Setting doesn't exist in the GPO yet
$differences += [PSCustomObject]@{
Key = $reg.Key
ValueName = $reg.ValueName
Current = '(not set)'
Desired = $desiredDisplay
}
}
}
# --- Stale value detection ---
# For each unique key in settings, check if the GPO has values not in the declared set
$uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique
$declaredLookup = @{}
foreach ($reg in $RegistrySettings) {
$declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true
}
foreach ($key in $uniqueKeys) {
try {
$currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain `
-Key $key -ErrorAction Stop
} catch { continue }
foreach ($val in $currentValues) {
if (-not $val.ValueName) { continue } # skip subkey entries
$lookupKey = "$key|$($val.ValueName)"
if (-not $declaredLookup.ContainsKey($lookupKey)) {
$differences += [PSCustomObject]@{
Key = $key
ValueName = $val.ValueName
Current = "$($val.Value) ($($val.Type))"
Desired = '(stale -- should be removed)'
}
}
}
}
return $differences
}
function Set-GPORegistrySettings {
<#
.SYNOPSIS
Applies registry-based (Administrative Template) settings to a GPO
using Set-GPRegistryValue. With -Cleanup, removes stale values under
managed keys that are not in the declared settings.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[array]$RegistrySettings,
[switch]$Cleanup,
[string]$Domain = (Get-ADDomain).DNSRoot
)
foreach ($reg in $RegistrySettings) {
Set-GPRegistryValue `
-Name $GPOName `
-Domain $Domain `
-Key $reg.Key `
-ValueName $reg.ValueName `
-Type $reg.Type `
-Value $reg.Value | Out-Null
Write-Host " Set: $($reg.Key)\$($reg.ValueName) = $($reg.Value)" -ForegroundColor Green
}
# Remove stale values under managed keys
if ($Cleanup) {
$declaredLookup = @{}
foreach ($reg in $RegistrySettings) {
$declaredLookup["$($reg.Key)|$($reg.ValueName)"] = $true
}
$uniqueKeys = $RegistrySettings | ForEach-Object { $_.Key } | Select-Object -Unique
foreach ($key in $uniqueKeys) {
try {
$currentValues = Get-GPRegistryValue -Name $GPOName -Domain $Domain `
-Key $key -ErrorAction Stop
} catch { continue }
foreach ($val in $currentValues) {
if (-not $val.ValueName) { continue }
$lookupKey = "$key|$($val.ValueName)"
if (-not $declaredLookup.ContainsKey($lookupKey)) {
Remove-GPRegistryValue -Name $GPOName -Domain $Domain `
-Key $key -ValueName $val.ValueName | Out-Null
Write-Host " [REMOVED] Stale: $key\$($val.ValueName)" -ForegroundColor Yellow
}
}
}
}
}