declarative-ad-framework/gpo/lib/GPOPreferences.ps1
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

838 lines
32 KiB
PowerShell

# GPOPreferences.ps1
# Group Policy Preferences XML generation for 10 types.
# Depends on: GPOCore.ps1 (Get-GPOSysvolPath, Add-GPOExtensionGuids)
$Script:GppToolGuid = '{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}'
$Script:GppActionCode = @{ Create = 'C'; Replace = 'R'; Update = 'U'; Delete = 'D' }
$Script:GppActionImage = @{ Create = '0'; Replace = '1'; Update = '2'; Delete = '3' }
$Script:GppTypeInfo = @{
ScheduledTasks = @{
RootClsid = '{CC63F200-7309-4ba0-B154-A71CD118DBCC}'
ItemClsid = '{D8896631-B747-47a7-84A6-C155337F3BC8}'
SysvolDir = 'ScheduledTasks'
FileName = 'ScheduledTasks.xml'
CseGuid = '{AADCED64-746C-4633-A97C-D61349046527}'
NameKey = 'Name'
}
DriveMaps = @{
RootClsid = '{8FDDCC1A-0C3C-43cd-A6B4-71A6DF20DA8C}'
ItemClsid = '{935D1B74-9CB8-4e3c-9914-7DD559B7A417}'
SysvolDir = 'Drives'
FileName = 'Drives.xml'
CseGuid = '{5794DAFD-BE60-433f-88A2-1A31939AC01F}'
NameKey = 'Letter'
}
EnvironmentVariables = @{
RootClsid = '{D76B9641-3288-4f75-942D-087DE603E3EA}'
ItemClsid = '{B1A3FA3F-E5D1-41ea-88D1-55CE96B6C78B}'
SysvolDir = 'EnvironmentVariables'
FileName = 'EnvironmentVariables.xml'
CseGuid = '{0E28E245-9368-4853-AD84-6DA3BA35BB75}'
NameKey = 'Name'
}
Services = @{
RootClsid = '{2CFB484A-4E86-4eb1-8B6A-E1535488BDBF}'
ItemClsid = '{AB6F0B67-D4E1-4f59-A9E5-F6BD3A72F4FF}'
SysvolDir = 'Services'
FileName = 'Services.xml'
CseGuid = '{91FBB303-0CD5-4055-BF42-E512A681B325}'
NameKey = 'ServiceName'
}
Printers = @{
RootClsid = '{1F577D12-3D1B-471e-A1B7-060317597B9C}'
ItemClsid = '{9A5E9697-9095-436d-A0EE-4D128FDFBCE5}'
SysvolDir = 'Printers'
FileName = 'Printers.xml'
CseGuid = '{A8C42CEA-CDB8-4388-97F4-5831F933DA84}'
NameKey = 'Path'
}
Shortcuts = @{
RootClsid = '{872ECB34-B2EC-401b-A585-D32574AA90EE}'
ItemClsid = '{4F2F7C55-2790-433e-8127-0739D1CFA327}'
SysvolDir = 'Shortcuts'
FileName = 'Shortcuts.xml'
CseGuid = '{C418DD9D-0D14-4EFB-8FBF-CFE535C8FAC7}'
NameKey = 'Name'
}
Files = @{
RootClsid = '{215B2E53-57CE-475c-80FE-9EEC14635851}'
ItemClsid = '{50BE44C8-567A-4ed1-B1D0-9234FE1F38AF}'
SysvolDir = 'Files'
FileName = 'Files.xml'
CseGuid = '{7150F9BF-48AD-4DA4-A49C-29EF4A8369BA}'
NameKey = 'TargetPath'
}
NetworkShares = @{
RootClsid = '{520870D8-A6E7-47e8-A8D8-E6A4E76EAEC2}'
ItemClsid = '{2888C5E7-94FC-4739-90AA-2C1536D68BC0}'
SysvolDir = 'NetworkShares'
FileName = 'NetworkShares.xml'
CseGuid = '{6A4C88C6-C502-4F74-8F60-2CB23EDC24E2}'
NameKey = 'Name'
}
RegistryItems = @{
RootClsid = '{A3CCFC41-DFDB-43a5-8D26-0FE8B954DA51}'
ItemClsid = '{9CD4B2F4-923D-47f5-A062-E897DD1DAD50}'
SysvolDir = 'Registry'
FileName = 'Registry.xml'
CseGuid = '{B087BE9D-ED37-454F-AF9C-04291E351182}'
NameKey = 'Name'
}
LocalUsersAndGroups = @{
RootClsid = '{3125E937-EB16-4b4c-9934-544FC6D24D26}'
ItemClsid = '{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}'
SysvolDir = 'Groups'
FileName = 'Groups.xml'
CseGuid = '{17D89FEC-5C44-4972-B12D-241CAEF74509}'
NameKey = 'GroupName'
}
}
function ConvertTo-ILTFilterXml {
<#
.SYNOPSIS
Converts a Filters array from a GPP item into the ILT <Filters> XML block.
Returns empty string if no filters are defined.
.DESCRIPTION
Supports SecurityGroup, OrgUnit, Computer, User, OperatingSystem, and WMI
filter types. SecurityGroup filters resolve names to SIDs at runtime.
#>
param(
[array]$Filters
)
if (-not $Filters -or $Filters.Count -eq 0) { return '' }
$esc = [System.Security.SecurityElement]
$filterXml = foreach ($f in $Filters) {
$bool = if ($f.Bool) { $f.Bool } else { 'AND' }
$not = if ($f.Not) { '1' } else { '0' }
$name = $esc::Escape($f.Name)
switch ($f.Type) {
'SecurityGroup' {
try {
$ntAccount = New-Object System.Security.Principal.NTAccount($f.Name)
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
} catch {
throw "ILT Filter: cannot resolve group '$($f.Name)' to a SID: $_"
}
$userContext = if ($f.ContainsKey('UserContext') -and -not $f.UserContext) { '0' } else { '1' }
$primaryGroup = if ($f.PrimaryGroup) { '1' } else { '0' }
$localGroup = if ($f.LocalGroup) { '1' } else { '0' }
" <FilterGroup bool=`"$bool`" not=`"$not`" name=`"$name`" sid=`"$sid`" userContext=`"$userContext`" primaryGroup=`"$primaryGroup`" localGroup=`"$localGroup`"/>"
}
'OrgUnit' {
$type = if ($f.ContainsKey('OUType')) { $f.OUType } else { '' }
" <FilterOrgUnit bool=`"$bool`" not=`"$not`" name=`"$name`" type=`"$type`"/>"
}
'Computer' {
$type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' }
" <FilterComputer bool=`"$bool`" not=`"$not`" type=`"$type`" name=`"$name`"/>"
}
'User' {
$type = if ($f.ContainsKey('NameType')) { $f.NameType } else { 'NETBIOS' }
" <FilterUser bool=`"$bool`" not=`"$not`" type=`"$type`" name=`"$name`"/>"
}
'OperatingSystem' {
$edition = if ($f.Edition) { $esc::Escape($f.Edition) } else { '' }
$version = if ($f.Version) { $esc::Escape($f.Version) } else { '' }
" <FilterOperatingSystem bool=`"$bool`" not=`"$not`" type=`"$name`" edition=`"$edition`" version=`"$version`"/>"
}
'WMI' {
$property = if ($f.Property) { $esc::Escape($f.Property) } else { '' }
$namespace = if ($f.Namespace) { $esc::Escape($f.Namespace) } else { 'root\CIMv2' }
$query = $esc::Escape($f.Query)
" <FilterWmi bool=`"$bool`" not=`"$not`" name=`"$name`" property=`"$property`" query=`"$query`" namespace=`"$namespace`"/>"
}
default {
Write-Host " [WARN] Unknown ILT filter type: $($f.Type)" -ForegroundColor Yellow
$null
}
}
}
$validFilters = @($filterXml | Where-Object { $_ })
if ($validFilters.Count -eq 0) { return '' }
return "`n <Filters>`n$($validFilters -join "`n")`n </Filters>"
}
function ConvertTo-ScheduledTaskXml {
<#
.SYNOPSIS
Generates ScheduledTasks.xml GPP content for TaskV2 entries.
#>
param(
[Parameter(Mandatory)]
[array]$Tasks,
[string]$Scope = 'Machine'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$userContext = if ($Scope -eq 'User') { '1' } else { '0' }
$itemsXml = foreach ($task in $Tasks) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$task.Action]
$image = $Script:GppActionImage[$task.Action]
$name = $esc::Escape($task.Name)
$runAs = if ($task.RunAs) { $task.RunAs } else { 'NT AUTHORITY\System' }
$command = $esc::Escape($task.Command)
$arguments = if ($task.Arguments) { $esc::Escape($task.Arguments) } else { '' }
$trigger = switch ($task.Trigger) {
'AtStartup' { '<BootTrigger><Enabled>true</Enabled></BootTrigger>' }
'AtLogon' { '<LogonTrigger><Enabled>true</Enabled></LogonTrigger>' }
default { '<BootTrigger><Enabled>true</Enabled></BootTrigger>' }
}
$filterBlock = ConvertTo-ILTFilterXml -Filters $task.Filters
@"
<TaskV2 clsid="{D8896631-B747-47a7-84A6-C155337F3BC8}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" name="$name" runAs="$runAs" logonType="S4U">
<Task version="1.2">
<RegistrationInfo><Description></Description></RegistrationInfo>
<Principals>
<Principal id="Author">
<UserId>$runAs</UserId>
<LogonType>S4U</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<IdleSettings>
<Duration>PT10M</Duration>
<WaitTimeout>PT1H</WaitTimeout>
<StopOnIdleEnd>true</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<ExecutionTimeLimit>PT72H</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Triggers>$trigger</Triggers>
<Actions>
<Exec>
<Command>$command</Command>
<Arguments>$arguments</Arguments>
</Exec>
</Actions>
</Task>
</Properties>$filterBlock
</TaskV2>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}">
$($itemsXml -join "`n")
</ScheduledTasks>
"@
}
function ConvertTo-DriveMapXml {
<#
.SYNOPSIS
Generates Drives.xml GPP content for drive map entries.
#>
param(
[Parameter(Mandatory)]
[array]$Drives
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$itemsXml = foreach ($drive in $Drives) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$drive.Action]
$image = $Script:GppActionImage[$drive.Action]
$letter = $drive.Letter.TrimEnd(':')
$path = $esc::Escape($drive.Path)
$label = if ($drive.Label) { $esc::Escape($drive.Label) } else { '' }
$persistent = if ($drive.Reconnect) { '1' } else { '0' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $drive.Filters
@"
<Drive clsid="{935D1B74-9CB8-4e3c-9914-7DD559B7A417}" name="${letter}:" image="$image" changed="$timestamp" uid="$uid" userContext="1" removePolicy="0">
<Properties action="$action" thisDrive="NOCHANGE" allDrives="NOCHANGE" userName="" path="$path" label="$label" persistent="$persistent" useLetter="1" letter="$letter"/>$filterBlock
</Drive>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Drives clsid="{8FDDCC1A-0C3C-43cd-A6B4-71A6DF20DA8C}">
$($itemsXml -join "`n")
</Drives>
"@
}
function ConvertTo-EnvironmentVariableXml {
<#
.SYNOPSIS
Generates EnvironmentVariables.xml GPP content.
#>
param(
[Parameter(Mandatory)]
[array]$Variables,
[string]$Scope = 'Machine'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$userContext = if ($Scope -eq 'User') { '1' } else { '0' }
$user = if ($Scope -eq 'User') { '1' } else { '0' }
$itemsXml = foreach ($var in $Variables) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$var.Action]
$image = $Script:GppActionImage[$var.Action]
$name = $esc::Escape($var.Name)
$value = $esc::Escape($var.Value)
$filterBlock = ConvertTo-ILTFilterXml -Filters $var.Filters
@"
<EnvironmentVariable clsid="{B1A3FA3F-E5D1-41ea-88D1-55CE96B6C78B}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext">
<Properties action="$action" name="$name" value="$value" user="$user"/>$filterBlock
</EnvironmentVariable>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<EnvironmentVariables clsid="{D76B9641-3288-4f75-942D-087DE603E3EA}">
$($itemsXml -join "`n")
</EnvironmentVariables>
"@
}
function ConvertTo-ServiceXml {
<#
.SYNOPSIS
Generates Services.xml GPP content for NTService entries.
#>
param(
[Parameter(Mandatory)]
[array]$Services
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$startupMap = @{
Automatic = 'AUTOMATIC'
Manual = 'MANUAL'
Disabled = 'DISABLED'
}
$itemsXml = foreach ($svc in $Services) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$svc.Action]
$image = $Script:GppActionImage[$svc.Action]
$serviceName = $esc::Escape($svc.ServiceName)
$startupType = $startupMap[$svc.StartupType]
if (-not $startupType) { $startupType = 'NOCHANGE' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $svc.Filters
@"
<NTService clsid="{AB6F0B67-D4E1-4f59-A9E5-F6BD3A72F4FF}" name="$serviceName" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties startupType="$startupType" serviceName="$serviceName" serviceAction="NONE" timeout="30"/>$filterBlock
</NTService>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<NTServices clsid="{2CFB484A-4E86-4eb1-8B6A-E1535488BDBF}">
$($itemsXml -join "`n")
</NTServices>
"@
}
function ConvertTo-PrinterXml {
<#
.SYNOPSIS
Generates Printers.xml GPP content for shared printer entries.
#>
param(
[Parameter(Mandatory)]
[array]$Printers
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$itemsXml = foreach ($printer in $Printers) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$printer.Action]
$image = $Script:GppActionImage[$printer.Action]
$path = $esc::Escape($printer.Path)
# Derive name from last segment of UNC path
$name = ($printer.Path -split '\\' | Where-Object { $_ })[-1]
$default = if ($printer.Default) { '1' } else { '0' }
$skipLocal = if ($printer.SkipLocal) { '1' } else { '0' }
$comment = if ($printer.Comment) { $esc::Escape($printer.Comment) } else { '' }
$location = if ($printer.Location) { $esc::Escape($printer.Location) } else { '' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $printer.Filters
@"
<SharedPrinter clsid="{9A5E9697-9095-436d-A0EE-4D128FDFBCE5}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="1" removePolicy="0">
<Properties action="$action" path="$path" comment="$comment" location="$location" default="$default" skipLocal="$skipLocal" deleteAll="0" persistent="0" deleteMaps="0" port=""/>$filterBlock
</SharedPrinter>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Printers clsid="{1F577D12-3D1B-471e-A1B7-060317597B9C}">
$($itemsXml -join "`n")
</Printers>
"@
}
function ConvertTo-ShortcutXml {
<#
.SYNOPSIS
Generates Shortcuts.xml GPP content for shortcut entries.
#>
param(
[Parameter(Mandatory)]
[array]$Shortcuts,
[string]$Scope = 'User'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$userContext = if ($Scope -eq 'User') { '1' } else { '0' }
$itemsXml = foreach ($shortcut in $Shortcuts) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$shortcut.Action]
$image = $Script:GppActionImage[$shortcut.Action]
$name = $esc::Escape($shortcut.Name)
$targetType = if ($shortcut.TargetType) { $shortcut.TargetType } else { 'FILESYSTEM' }
$targetPath = $esc::Escape($shortcut.TargetPath)
$shortcutPath = $esc::Escape($shortcut.ShortcutPath)
$arguments = if ($shortcut.Arguments) { $esc::Escape($shortcut.Arguments) } else { '' }
$startIn = if ($shortcut.StartIn) { $esc::Escape($shortcut.StartIn) } else { '' }
$comment = if ($shortcut.Comment) { $esc::Escape($shortcut.Comment) } else { '' }
$iconPath = if ($shortcut.IconPath) { $esc::Escape($shortcut.IconPath) } else { '' }
$iconIndex = if ($shortcut.IconIndex) { $shortcut.IconIndex } else { '0' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $shortcut.Filters
@"
<Shortcut clsid="{4F2F7C55-2790-433e-8127-0739D1CFA327}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" targetType="$targetType" targetPath="$targetPath" shortcutPath="$shortcutPath" arguments="$arguments" startIn="$startIn" comment="$comment" shortcutKey="0" iconPath="$iconPath" iconIndex="$iconIndex" window="" pidl=""/>$filterBlock
</Shortcut>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Shortcuts clsid="{872ECB34-B2EC-401b-A585-D32574AA90EE}">
$($itemsXml -join "`n")
</Shortcuts>
"@
}
function ConvertTo-FileXml {
<#
.SYNOPSIS
Generates Files.xml GPP content for file copy/replace entries.
#>
param(
[Parameter(Mandatory)]
[array]$Files,
[string]$Scope = 'Machine'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$userContext = if ($Scope -eq 'User') { '1' } else { '0' }
$itemsXml = foreach ($file in $Files) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$file.Action]
$image = $Script:GppActionImage[$file.Action]
$fromPath = $esc::Escape($file.FromPath)
$targetPath = $esc::Escape($file.TargetPath)
$readOnly = if ($file.ReadOnly) { '1' } else { '0' }
$hidden = if ($file.Hidden) { '1' } else { '0' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $file.Filters
@"
<File clsid="{50BE44C8-567A-4ed1-B1D0-9234FE1F38AF}" name="$targetPath" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" fromPath="$fromPath" targetPath="$targetPath" readOnly="$readOnly" archive="1" hidden="$hidden" suppress="1"/>$filterBlock
</File>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Files clsid="{215B2E53-57CE-475c-80FE-9EEC14635851}">
$($itemsXml -join "`n")
</Files>
"@
}
function ConvertTo-NetworkShareXml {
<#
.SYNOPSIS
Generates NetworkShares.xml GPP content for network share entries.
#>
param(
[Parameter(Mandatory)]
[array]$Shares
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$itemsXml = foreach ($share in $Shares) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$share.Action]
$image = $Script:GppActionImage[$share.Action]
$name = $esc::Escape($share.Name)
$path = $esc::Escape($share.Path)
$comment = if ($share.Comment) { $esc::Escape($share.Comment) } else { '' }
$allRegular = if ($share.AllRegular) { $share.AllRegular } else { '' }
$allHidden = if ($share.AllHidden) { $share.AllHidden } else { '' }
$allAdminDrive = if ($share.AllAdminDrive) { $share.AllAdminDrive } else { '' }
$limitUsers = if ($share.LimitUsers) { $share.LimitUsers } else { '0' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $share.Filters
@"
<NetShare clsid="{2888C5E7-94FC-4739-90AA-2C1536D68BC0}" name="$name" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties action="$action" name="$name" path="$path" comment="$comment" allRegular="$allRegular" allHidden="$allHidden" allAdminDrive="$allAdminDrive" limitUsers="$limitUsers"/>$filterBlock
</NetShare>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<NetworkShareSettings clsid="{520870D8-A6E7-47e8-A8D8-E6A4E76EAEC2}">
$($itemsXml -join "`n")
</NetworkShareSettings>
"@
}
function ConvertTo-RegistryItemXml {
<#
.SYNOPSIS
Generates Registry.xml GPP content for registry preference items.
These are GPP Registry items (not Administrative Templates).
#>
param(
[Parameter(Mandatory)]
[array]$Items,
[string]$Scope = 'Machine'
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$userContext = if ($Scope -eq 'User') { '1' } else { '0' }
$itemsXml = foreach ($item in $Items) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$item.Action]
$image = $Script:GppActionImage[$item.Action]
$hive = $esc::Escape($item.Hive)
$key = $esc::Escape($item.Key)
$name = if ($item.Name) { $esc::Escape($item.Name) } else { '' }
$type = if ($item.Type) { $item.Type } else { 'REG_SZ' }
$value = if ($item.Value) { $esc::Escape($item.Value) } else { '' }
$displayName = if ($name) { $name } else { '(Default)' }
$filterBlock = ConvertTo-ILTFilterXml -Filters $item.Filters
@"
<Registry clsid="{9CD4B2F4-923D-47f5-A062-E897DD1DAD50}" name="$displayName" image="$image" changed="$timestamp" uid="$uid" userContext="$userContext" removePolicy="0">
<Properties action="$action" hive="$hive" key="$key" name="$name" type="$type" value="$value" displayDecimal="0" default="0"/>$filterBlock
</Registry>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<RegistrySettings clsid="{A3CCFC41-DFDB-43a5-8D26-0FE8B954DA51}">
$($itemsXml -join "`n")
</RegistrySettings>
"@
}
function ConvertTo-LocalGroupXml {
<#
.SYNOPSIS
Generates Groups.xml GPP content for local user/group management.
Supports ADD/REMOVE individual members (unlike RestrictedGroups
which enforces exact membership).
#>
param(
[Parameter(Mandatory)]
[array]$Groups
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$esc = [System.Security.SecurityElement]
$itemsXml = foreach ($group in $Groups) {
$uid = "{$([Guid]::NewGuid().ToString().ToUpper())}"
$action = $Script:GppActionCode[$group.Action]
$image = $Script:GppActionImage[$group.Action]
$groupName = $esc::Escape($group.GroupName)
$newName = if ($group.NewName) { $esc::Escape($group.NewName) } else { '' }
$description = if ($group.Description) { $esc::Escape($group.Description) } else { '' }
$deleteAllUsers = if ($group.DeleteAllUsers) { '1' } else { '0' }
$deleteAllGroups = if ($group.DeleteAllGroups) { '1' } else { '0' }
$removeAccounts = if ($group.RemoveAccounts) { '1' } else { '0' }
# Build <Members> block
$membersXml = ''
if ($group.Members -and $group.Members.Count -gt 0) {
$memberLines = foreach ($m in $group.Members) {
$memberName = $esc::Escape($m.Name)
$memberAction = $m.Action.ToUpper()
$sid = ''
try {
$ntAccount = New-Object System.Security.Principal.NTAccount($m.Name)
$sid = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier]).Value
} catch {
Write-Host " [WARN] Cannot resolve '$($m.Name)' to SID -- leaving empty" -ForegroundColor Yellow
}
" <Member name=`"$memberName`" action=`"$memberAction`" sid=`"$sid`"/>"
}
$membersXml = "`n <Members>`n$($memberLines -join "`n")`n </Members>"
}
$filterBlock = ConvertTo-ILTFilterXml -Filters $group.Filters
@"
<Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="$groupName" image="$image" changed="$timestamp" uid="$uid" userContext="0" removePolicy="0">
<Properties action="$action" groupName="$groupName" newName="$newName" description="$description" deleteAllUsers="$deleteAllUsers" deleteAllGroups="$deleteAllGroups" removeAccounts="$removeAccounts">$membersXml
</Properties>$filterBlock
</Group>
"@
}
return @"
<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}">
$($itemsXml -join "`n")
</Groups>
"@
}
function Set-GPOPreferences {
<#
.SYNOPSIS
Writes Group Policy Preferences XML files to SYSVOL.
Supports 10 GPP types: ScheduledTasks, DriveMaps, EnvironmentVariables,
Services, Printers, Shortcuts, Files, NetworkShares, RegistryItems,
LocalUsersAndGroups.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[hashtable]$Preferences,
[string]$Domain = (Get-ADDomain).DNSRoot
)
$sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain
$utf8Bom = [System.Text.UTF8Encoding]::new($true)
foreach ($typeName in $Preferences.Keys) {
$typeInfo = $Script:GppTypeInfo[$typeName]
if (-not $typeInfo) {
Write-Host " [WARN] Unknown preference type: $typeName" -ForegroundColor Yellow
continue
}
$items = $Preferences[$typeName]
if (-not $items -or $items.Count -eq 0) { continue }
# Group items by scope
$byScope = @{ Machine = @(); User = @() }
foreach ($item in $items) {
$scope = switch ($typeName) {
'DriveMaps' { 'User' }
'Printers' { 'User' }
'Services' { 'Machine' }
'NetworkShares' { 'Machine' }
'LocalUsersAndGroups' { 'Machine' }
default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } }
}
$byScope[$scope] += $item
}
foreach ($scope in @('Machine', 'User')) {
$scopeItems = $byScope[$scope]
if ($scopeItems.Count -eq 0) { continue }
# Generate XML
$xml = switch ($typeName) {
'ScheduledTasks' { ConvertTo-ScheduledTaskXml -Tasks $scopeItems -Scope $scope }
'DriveMaps' { ConvertTo-DriveMapXml -Drives $scopeItems }
'EnvironmentVariables' { ConvertTo-EnvironmentVariableXml -Variables $scopeItems -Scope $scope }
'Services' { ConvertTo-ServiceXml -Services $scopeItems }
'Printers' { ConvertTo-PrinterXml -Printers $scopeItems }
'Shortcuts' { ConvertTo-ShortcutXml -Shortcuts $scopeItems -Scope $scope }
'Files' { ConvertTo-FileXml -Files $scopeItems -Scope $scope }
'NetworkShares' { ConvertTo-NetworkShareXml -Shares $scopeItems }
'RegistryItems' { ConvertTo-RegistryItemXml -Items $scopeItems -Scope $scope }
'LocalUsersAndGroups' { ConvertTo-LocalGroupXml -Groups $scopeItems }
}
# Write to SYSVOL
$prefDir = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)"
if (-not (Test-Path $prefDir)) {
New-Item -ItemType Directory -Path $prefDir -Force | Out-Null
}
$xmlPath = Join-Path $prefDir $typeInfo.FileName
[System.IO.File]::WriteAllText($xmlPath, $xml, $utf8Bom)
Write-Host " Written: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName) ($($scopeItems.Count) item(s))" -ForegroundColor Green
# Register CSE extension GUIDs
Add-GPOExtensionGuids -GPOName $GPOName -CseGuid $typeInfo.CseGuid -ToolGuid $Script:GppToolGuid -Scope $scope -Domain $Domain
}
}
}
function Compare-GPOPreferences {
<#
.SYNOPSIS
Compares desired Group Policy Preferences against what's currently
deployed in SYSVOL. Checks XML file existence and item presence by name.
#>
param(
[Parameter(Mandatory)]
[string]$GPOName,
[Parameter(Mandatory)]
[hashtable]$Preferences,
[string]$Domain = (Get-ADDomain).DNSRoot
)
$sysvolPath = Get-GPOSysvolPath -GPOName $GPOName -Domain $Domain
$diffs = @()
Write-Host " Comparing preferences..." -ForegroundColor Yellow
foreach ($typeName in $Preferences.Keys) {
$typeInfo = $Script:GppTypeInfo[$typeName]
if (-not $typeInfo) { continue }
$items = $Preferences[$typeName]
if (-not $items -or $items.Count -eq 0) { continue }
# Group items by scope
$byScope = @{ Machine = @(); User = @() }
foreach ($item in $items) {
$scope = switch ($typeName) {
'DriveMaps' { 'User' }
'Printers' { 'User' }
'Services' { 'Machine' }
'NetworkShares' { 'Machine' }
'LocalUsersAndGroups' { 'Machine' }
default { if ($item.Scope -eq 'User') { 'User' } else { 'Machine' } }
}
$byScope[$scope] += $item
}
foreach ($scope in @('Machine', 'User')) {
$scopeItems = $byScope[$scope]
if ($scopeItems.Count -eq 0) { continue }
$xmlPath = Join-Path $sysvolPath "$scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)"
if (-not (Test-Path $xmlPath)) {
Write-Host " [DRIFT] Missing: $scope\Preferences\$($typeInfo.SysvolDir)\$($typeInfo.FileName)" -ForegroundColor Red
$diffs += [PSCustomObject]@{
Type = 'Preference'
PrefType = $typeName
Scope = $scope
Item = '(entire file)'
Status = 'Missing'
}
continue
}
# Parse existing XML and check for each desired item
try {
[xml]$existingXml = Get-Content $xmlPath -Raw
foreach ($item in $scopeItems) {
$itemName = $item.($typeInfo.NameKey)
# DriveMaps use "Z:" format in XML but settings use "Z"
$searchName = $itemName
if ($typeName -eq 'DriveMaps') {
$searchName = "$($itemName.TrimEnd(':')):"
}
$found = $existingXml.SelectNodes("//*[@name='$searchName']")
if (-not $found -or $found.Count -eq 0) {
Write-Host " [DRIFT] Missing $typeName item: $itemName" -ForegroundColor Red
$diffs += [PSCustomObject]@{
Type = 'Preference'
PrefType = $typeName
Scope = $scope
Item = $itemName
Status = 'Missing item'
}
} else {
Write-Host " [OK] $typeName`: $itemName" -ForegroundColor Green
}
}
} catch {
Write-Host " [DRIFT] Cannot parse: $($typeInfo.FileName) -- $($_.Exception.Message)" -ForegroundColor Red
$diffs += [PSCustomObject]@{
Type = 'Preference'
PrefType = $typeName
Scope = $scope
Item = '(parse error)'
Status = 'Invalid XML'
}
}
}
}
if ($diffs.Count -eq 0) {
Write-Host " [OK] All preferences match desired state" -ForegroundColor Green
} else {
Write-Host " [DRIFT] $($diffs.Count) preference difference(s) found" -ForegroundColor Red
}
return $diffs
}