Infrastructure-as-code framework for Active Directory objects and Group Policy. Sanitized from production deployment for public sharing.
58 KiB
Framework Reference
Developer reference for extending the declarative-ad-framework infrastructure-as-code framework. For day-to-day operations, see README.md.
Architecture
The framework follows a declarative definitions + idempotent orchestration pattern:
- Definition files (
ous.ps1,groups.ps1,users.ps1,delegations.ps1,password-policies.ps1,settings.ps1) are pure data. They return PowerShell hashtables or arrays describing desired state. No logic, no side effects. - Orchestration scripts (
Apply-ADBaseline.ps1,Apply-GPOBaseline.ps1) consume definitions and converge reality to match. They accept-TestOnlyfor read-only drift detection. - Helper libraries (
ADHelper.ps1,GPOHelper.ps1+ modules) contain theEnsure-*andCompare-*functions that do the actual work.
There are two independent subsystems:
| Subsystem | Directory | Orchestrator | Helper | Scope |
|---|---|---|---|---|
| AD Objects | ad-objects/ |
Apply-ADBaseline.ps1 |
lib/ADHelper.ps1 (loader + 6 modules) |
OUs, groups, users, delegation ACLs, password policies (PSOs) |
| AD Hygiene | ad-objects/ |
Get-StaleADObjects.ps1 |
(standalone) | Stale accounts, orphans, unmanaged objects, pending credentials |
| GPO Settings | gpo/ |
Apply-GPOBaseline.ps1 |
lib/GPOHelper.ps1 (loader + 12 modules) |
GPO security policy, registry, links, permissions, scripts, audit, preferences, WMI filters, backup, firewall, AppLocker, WDAC, folder redirection |
| GPO Audit | gpo/ |
Get-UnmanagedGPOs.ps1 |
(standalone) | Discovers orphan GPOs and framework GPOs not yet created in AD |
Both work from any domain-joined machine with RSAT installed. Only Apply-DscBaseline.ps1 (DC compliance validation) requires execution on the domain controller itself.
Single Source of Truth
DSC configurations (DefaultDomainPolicy.ps1, DefaultDCPolicy.ps1) read directly from the same settings.ps1 files used by Apply-GPOBaseline.ps1. Values are transformed from GptTmpl.inf format into DSC format (SIDs to NTAccount names, registry DWORD strings to integers, etc.) -- never duplicated.
Auto-Discovery vs Fixed Loading
GPOs are auto-discovered at runtime:
$gpoDirs = Get-ChildItem -Path $ScriptRoot -Directory |
Where-Object { Test-Path (Join-Path $_.FullName 'settings.ps1') }
Adding a new GPO requires only creating a directory with a settings.ps1. No registration, no manifest, no edits to the orchestration script.
AD objects are loaded from known file paths (ous.ps1, groups.ps1, etc.) because the set of AD object types is fixed, while the number of GPOs can grow.
The Ensure/Compare Pattern
Every managed object type has a paired set of functions:
Ensure-*: Idempotent apply. Checks current state, creates if missing, updates if different, skips if correct. Writes status to console:[CREATED],[UPDATED],[OK].Compare-*: Read-only drift detection. Returns an array of diff objects describing differences, or empty/null if no drift. Used by-TestOnlymode.
Function Pairs
| Object Type | Ensure | Compare | Library |
|---|---|---|---|
| Organizational Unit | Ensure-ADOU |
Compare-ADOU |
ADOrganizationalUnit.ps1 |
| Security Group | Ensure-ADSecurityGroup |
Compare-ADSecurityGroup |
ADGroup.ps1 |
| User Account | Ensure-ADUserAccount |
Compare-ADUserAccount |
ADUser.ps1 |
| OU Delegation (ACLs) | Ensure-OUDelegation |
Compare-OUDelegation |
ADDelegation.ps1 |
| Password Policy (PSO) | Ensure-ADPasswordPolicy |
Compare-ADPasswordPolicy |
ADPasswordPolicy.ps1 |
| Security Policy | Set-GPOSecurityPolicy |
Compare-GPOSecurityPolicy |
GPOPolicy.ps1 |
| Registry Settings | Set-GPORegistrySettings |
Compare-GPORegistrySettings |
GPOPolicy.ps1 |
| GPO Link | Ensure-GPOLink |
Compare-GPOLink |
GPOPermissions.ps1 |
| Management Permission | Ensure-GPOManagementPermission |
Compare-GPOManagementPermission |
GPOPermissions.ps1 |
| Security Filtering | Ensure-GPOSecurityFiltering |
Compare-GPOSecurityFiltering |
GPOPermissions.ps1 |
| Scripts | Set-GPOScripts |
Compare-GPOScripts |
GPOScripts.ps1 |
| Advanced Audit Policy | Set-GPOAdvancedAuditPolicy |
Compare-GPOAdvancedAuditPolicy |
GPOAudit.ps1 |
| Preferences | Set-GPOPreferences |
Compare-GPOPreferences |
GPOPreferences.ps1 |
| WMI Filter | Ensure-GPOWmiFilter |
Compare-GPOWmiFilter |
GPOWmiFilter.ps1 |
| GPO Status | Ensure-GPOStatus |
Compare-GPOStatus |
GPOCore.ps1 |
| Restricted Groups | ConvertTo-RestrictedGroupEntries |
(merged into SecurityPolicy) | GPOPolicy.ps1 |
| GPO Backup | Backup-GPOState / Restore-GPOState |
Get-GPOBackups |
GPOBackup.ps1 |
| ILT Filters | ConvertTo-ILTFilterXml |
(generated on every apply) | GPOPreferences.ps1 |
| Firewall Rules | Set-GPOFirewall |
Compare-GPOFirewall |
GPOFirewall.ps1 |
| Firewall Profiles | Set-GPOFirewallProfiles |
Compare-GPOFirewallProfiles |
GPOFirewall.ps1 |
| AppLocker Policy | Set-GPOAppLockerPolicy |
Compare-GPOAppLockerPolicy |
GPOAppLocker.ps1 |
| WDAC Policy | Set-GPOWdacPolicy |
Compare-GPOWdacPolicy |
GPOWdac.ps1 |
| Folder Redirection | Set-GPOFolderRedirection |
Compare-GPOFolderRedirection |
GPOFolderRedirection.ps1 |
Diff Object Format
Compare functions return objects with properties that describe the drift:
# Security policy diff
@{ Section = 'Event Audit'; Setting = 'AuditLogonEvents'; Current = '1'; Desired = '3' }
# Registry setting diff
@{ Key = 'HKLM\Software\...'; ValueName = 'MaxSize'; Current = 32768; Desired = 65536 }
Self-Healing Permissions
Apply-GPOBaseline.ps1 ensures that every group in $ManagementGroups (default: MasterAdmins) has GpoEditDeleteModifySecurity permission on every managed GPO. This runs on every invocation, not just on GPO creation. If someone manually removes MasterAdmins' permission, the next run restores it.
Bootstrap requirement: The first run must be executed as the built-in Administrator (Domain Admins) to grant MasterAdmins the initial permissions. After that, MasterAdmins is self-maintaining and Domain Admins membership is never needed for day-to-day operations.
Overwrite Semantics
The framework is declarative: the settings files define the complete desired state. However, different setting types have different overwrite behavior:
| Setting Type | Behavior | Implication |
|---|---|---|
| SecurityPolicy | Full overwrite | Regenerates entire GptTmpl.inf. Undeclared settings are removed. |
| RegistrySettings | Full overwrite (with cleanup) | Sets declared values via Set-GPRegistryValue, then removes stale values under managed keys. Use -NoCleanup to opt out. |
| Scripts | Full overwrite | Regenerates ini files per scope. Undeclared scripts lose their registration (files remain in SYSVOL but are not executed). |
| AdvancedAuditPolicy | Full overwrite | Regenerates entire audit.csv. Undeclared subcategories are removed. |
| Preferences | Full overwrite | Regenerates entire XML per type/scope. Undeclared items are removed. |
| RestrictedGroups | Full overwrite | Merged into SecurityPolicy [Group Membership]. Subject to SecurityPolicy's full-overwrite behavior. |
| WMIFilter | Additive | Creates/updates the filter and links it. Removing the key from settings.ps1 leaves the link in place. |
| FirewallRules | Full overwrite | Removes all existing rules, then creates declared rules. |
| FirewallProfiles | Additive | Sets declared profile values. Undeclared profiles/values are not touched. |
| AppLockerPolicy | Full overwrite | Replaces entire AppLocker policy via Set-AppLockerPolicy. |
| WDACPolicy | Full overwrite | Replaces SIPolicy.p7b and registry pointer. |
| FolderRedirection | Full overwrite | Regenerates entire fdeploy1.ini. Undeclared folders are removed. |
Practical implication: If you remove a setting from settings.ps1, it disappears from the GPO on the next apply for all types. SecurityPolicy, Scripts, AdvancedAuditPolicy, and Preferences regenerate their SYSVOL files entirely. RegistrySettings removes stale values under managed keys (any key path that appears in settings.ps1). Use -NoCleanup to preserve stale registry values during transition periods.
Version Bumping
When SYSVOL files are written directly (SecurityPolicy, Scripts, AdvancedAuditPolicy, Preferences), the GPO version must be incremented so clients re-process the policy. The version number is a packed 32-bit integer:
- Upper 16 bits: user configuration version
- Lower 16 bits: machine configuration version
The orchestrator tracks which scopes were modified during a GPO's processing and bumps the appropriate half once at the end. This avoids multiple increments per apply and correctly tracks which scope changed. Registry settings are exempt because Set-GPRegistryValue handles version bumping internally.
Console Output Conventions
All orchestration scripts use consistent color coding:
| Color | Tags | Meaning |
|---|---|---|
| Green | [OK] |
Object/setting is in desired state |
| Yellow | [CREATED], [UPDATED], [GRANTED] |
Change was made |
| Red | [DRIFT], [MISSING] |
Out of desired state (TestOnly) |
| Magenta | ACTION REQUIRED, WARNING |
Operator attention needed |
GPO Definition Format
Each GPO lives in its own directory under gpo/ and must contain a settings.ps1 that returns a hashtable.
Naming Conventions
- GPO names:
<Target>-<Number>pattern (e.g.,Admins-01,Workstations-01,Servers-01). The number allows multiple GPOs per target in the future. - Directory names: Lowercase-with-hyphens matching the GPO name (e.g.,
admins-01/,default-domain-controller/).
settings.ps1 Contract
The file is invoked with & $settingsPath and must return a hashtable with these keys:
| Key | Type | Required | Description |
|---|---|---|---|
GPOName |
String | Yes | Display name in AD (e.g., 'Workstations-01') |
Description |
String | No | GPMC description/comment. Displayed in the GPO console and helps prevent manual edits. |
LinkTo |
String, Hashtable, Array, or $null |
No | Target OU. String for simple links, hashtable/array for order + enforcement (see below). $null for default GPOs. |
SecurityPolicy |
Hashtable | No | GptTmpl.inf sections (see below) |
RegistrySettings |
Array | No | Registry value definitions (see below) |
SecurityFiltering |
Hashtable | No | Contains DenyApply -- array of group names |
Scripts |
Hashtable | No | Startup/shutdown/logon/logoff scripts (see below) |
AdvancedAuditPolicy |
Hashtable | No | Subcategory-level audit settings (see below) |
Preferences |
Hashtable | No | GPP items: 10 types including ScheduledTasks, DriveMaps, Printers, Shortcuts, Files, NetworkShares, RegistryItems, LocalUsersAndGroups (see below) |
FirewallRules |
Array | No | Windows Firewall rules (see below) |
FirewallProfiles |
Hashtable | No | Firewall profile settings for Domain/Private/Public (see below) |
AppLockerPolicy |
Hashtable | No | AppLocker rule collections and enforcement modes (see below) |
WDACPolicy |
Hashtable | No | WDAC policy file deployment (see below) |
FolderRedirection |
Hashtable | No | User folder redirection paths (see below) |
RestrictedGroups |
Hashtable | No | Local group membership control via GptTmpl.inf [Group Membership] (see below) |
WMIFilter |
Hashtable | No | WMI filter definition and GPO link (see below) |
DisableUserConfiguration |
Bool | No | Disable the User Configuration section of the GPO (default: $false) |
DisableComputerConfiguration |
Bool | No | Disable the Computer Configuration section of the GPO (default: $false) |
Dynamic code is allowed before the return hashtable. Use this for SID resolution:
# Resolve at evaluation time -- never hardcode SIDs
$masterAdminsSID = (Get-ADGroup -Identity 'MasterAdmins').SID.Value
@{
GPOName = 'Default Domain Controllers Policy'
SecurityPolicy = @{
'Privilege Rights' = [ordered]@{
SeRemoteInteractiveLogonRight = "*S-1-5-32-544,*$masterAdminsSID"
}
}
}
SecurityPolicy Sections
SecurityPolicy is a hashtable of hashtables, keyed by GptTmpl.inf section name:
System Access
Password policy, lockout policy, and account settings. Values are integers or strings.
'System Access' = [ordered]@{
MinimumPasswordAge = 1 # Days
MaximumPasswordAge = 42 # Days
MinimumPasswordLength = 7 # Characters
PasswordComplexity = 1 # 1=Enabled
PasswordHistorySize = 24 # Previous passwords remembered
LockoutBadCount = 5 # Failed attempts before lockout (0=disabled!)
ResetLockoutCount = 30 # Minutes before counter resets
LockoutDuration = 30 # Minutes locked
EnableGuestAccount = 0 # 0=Disabled
}
Kerberos Policy
Only effective in the Default Domain Policy (domain-level GPO).
'Kerberos Policy' = [ordered]@{
MaxTicketAge = 10 # TGT lifetime in hours
MaxRenewAge = 7 # TGT max renewal in days
MaxServiceAge = 600 # Service ticket lifetime in minutes
MaxClockSkew = 5 # Max clock difference in minutes
TicketValidateClient = 1 # 1=Enabled
}
Event Audit
Legacy audit categories. Values: 0=None, 1=Success, 2=Failure, 3=Success+Failure.
'Event Audit' = [ordered]@{
AuditSystemEvents = 3 # Success + Failure
AuditLogonEvents = 3 # Success + Failure
AuditObjectAccess = 2 # Failure only
AuditPrivilegeUse = 2 # Failure only
AuditPolicyChange = 1 # Success only
AuditAccountManage = 3 # Success + Failure
AuditProcessTracking = 0 # None
AuditDSAccess = 0 # None (irrelevant unless DC)
AuditAccountLogon = 3 # Success + Failure
}
Registry Values (Security Options)
Security options stored in GptTmpl.inf. Format: 'MACHINE\registry\path' = 'type,value'.
Type codes: 4=REG_DWORD, 1=REG_SZ, 7=REG_MULTI_SZ.
'Registry Values' = [ordered]@{
'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\InactivityTimeoutSecs' = '4,900'
'MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\System\DontDisplayLastUserName' = '4,1'
'MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableCAD' = '4,0'
}
Privilege Rights
User rights assignments. Trustees listed as *SID with asterisk prefix, comma-separated.
'Privilege Rights' = [ordered]@{
SeRemoteInteractiveLogonRight = "*S-1-5-32-544,*$masterAdminsSID"
# Administrators, MasterAdmins
SeBackupPrivilege = '*S-1-5-32-549,*S-1-5-32-551,*S-1-5-32-544'
# Server Operators, Backup Operators, Administrators
}
RegistrySettings Format
Administrative Template settings applied via Set-GPRegistryValue. Each entry is a hashtable with four required keys:
| Key | Type | Description |
|---|---|---|
Key |
String | Registry path. HKLM\... for Computer Configuration, HKCU\... for User Configuration |
ValueName |
String | Registry value name |
Type |
String | DWord, String, ExpandString, or MultiString |
Value |
Mixed | The value to set (integer for DWord, string for String types) |
RegistrySettings = @(
@{
Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
ValueName = 'EnableScriptBlockLogging'
Type = 'DWord'
Value = 1
}
@{
Key = 'HKLM\Software\Policies\Microsoft\Windows\PowerShell\Transcription'
ValueName = 'OutputDirectory'
Type = 'String'
Value = 'C:\PSlogs\Transcripts'
}
)
SecurityFiltering
Deny Apply prevents a group from receiving the GPO even if they're in the linked OU.
SecurityFiltering = @{
DenyApply = @('DelegatedAdmins')
}
Implementation note: this is applied as a raw AD ACL (Deny ACE for the Apply-Group-Policy extended right), not via Set-GPPermission, which was found to be unreliable for deny entries.
Scripts Format
Scripts deploys files to SYSVOL and generates the psscripts.ini / scripts.ini registration files. Supported types:
| Key | SYSVOL Path | Runs As |
|---|---|---|
MachineStartup |
Machine\Scripts\Startup\ |
SYSTEM at boot |
MachineShutdown |
Machine\Scripts\Shutdown\ |
SYSTEM at shutdown |
UserLogon |
User\Scripts\Logon\ |
User at logon |
UserLogoff |
User\Scripts\Logoff\ |
User at logoff |
Each entry has two keys:
| Key | Type | Description |
|---|---|---|
Source |
String | File path relative to the GPO directory (e.g., 'Install-RSAT.ps1') |
Parameters |
String | Command-line parameters (empty string for none) |
.ps1 files are registered in psscripts.ini, all others (.bat, .cmd, .exe) in scripts.ini. Both ini files live in the scope's Scripts\ directory (e.g., Machine\Scripts\psscripts.ini) and contain sections for each script type ([Startup], [Shutdown], [Logon], [Logoff]).
The framework automatically:
- Copies script files from the repo to the correct SYSVOL location
- Generates ini files with numbered entries (UTF-16LE encoding)
- Registers the Script CSE GUID (
{42B5FAAE-6536-11D2-AE5A-0000F87571E3}) in the GPO's extension attributes - Bumps the GPO version
Scripts = @{
MachineStartup = @(
@{
Source = 'Install-RSAT.ps1'
Parameters = ''
}
)
MachineShutdown = @(
@{
Source = 'Cleanup.bat'
Parameters = '/silent'
}
)
}
Compare mode checks: script file existence in SYSVOL, content hash match (SHA256), and ini file presence.
AdvancedAuditPolicy Format
Subcategory-level audit control (replaces the 9 legacy Event Audit categories with 53+ subcategories). Written to Machine\Microsoft\Windows NT\Audit\audit.csv in SYSVOL.
A GPO can have both Event Audit (legacy SecurityPolicy) and AdvancedAuditPolicy. When both are present, Advanced Audit takes precedence on the client (standard Windows behavior).
Valid values: 'Success and Failure', 'Success', 'Failure', 'No Auditing'
AdvancedAuditPolicy = @{
# Account Logon
'Credential Validation' = 'Success and Failure'
'Kerberos Authentication Service' = 'Success and Failure'
'Kerberos Service Ticket Operations' = 'Success and Failure'
# Logon/Logoff
'Logon' = 'Success and Failure'
'Logoff' = 'Success'
'Account Lockout' = 'Success'
'Special Logon' = 'Success and Failure'
# Object Access
'File System' = 'Success and Failure'
'Registry' = 'Success and Failure'
'File Share' = 'Success and Failure'
# Detailed Tracking
'Process Creation' = 'Success and Failure'
'Process Termination' = 'Success'
# Policy Change
'Audit Policy Change' = 'Success and Failure'
'Authentication Policy Change' = 'Success'
# Account Management
'User Account Management' = 'Success and Failure'
'Security Group Management' = 'Success and Failure'
# DS Access (DCs only)
'Directory Service Access' = 'Success and Failure'
'Directory Service Changes' = 'Success and Failure'
}
Subcategory names must match the keys in $Script:AuditSubcategoryGuid (GPOAudit.ps1). Unknown subcategories produce a [WARN] and are skipped. The full list of 53 supported subcategories is defined in the lookup table, organized by category: System, Logon/Logoff, Object Access, Privilege Use, Detailed Tracking, Policy Change, Account Management, DS Access, Account Logon.
The framework automatically:
- Generates audit.csv with subcategory GUIDs and numeric setting values
- Writes as UTF-8 with BOM (audit.csv uses UTF-8, unlike GptTmpl.inf which uses UTF-16LE)
- Registers the Audit Policy CSE GUID (
{F3BC9527-C350-4C90-861C-1EC90034520B}) in extension attributes - Bumps the GPO version
Preferences Format
Group Policy Preferences (GPP) allow configuring items that aren't part of traditional policy. Ten types are supported:
ScheduledTasks
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | Task display name |
Action |
String | Yes | Create, Replace, Update, or Delete |
Command |
String | Yes | Executable path (e.g., 'powershell.exe') |
Arguments |
String | No | Command-line arguments |
RunAs |
String | No | Account to run as (default: 'NT AUTHORITY\System') |
Trigger |
String | No | AtStartup (default), AtLogon |
Scope |
String | No | Machine (default) or User |
DriveMaps
Always User scope. Written to User\Preferences\Drives\Drives.xml.
| Key | Type | Required | Description |
|---|---|---|---|
Letter |
String | Yes | Drive letter (e.g., 'Z') |
Path |
String | Yes | UNC path (e.g., '\\server\share') |
Label |
String | No | Drive label shown in Explorer |
Action |
String | Yes | Create, Replace, Update, or Delete |
Reconnect |
Bool | No | Reconnect at logon ($true/$false) |
EnvironmentVariables
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | Variable name |
Value |
String | Yes | Variable value |
Action |
String | Yes | Create, Replace, Update, or Delete |
Scope |
String | No | Machine (default) or User |
Services
Always Machine scope. Written to Machine\Preferences\Services\Services.xml.
| Key | Type | Required | Description |
|---|---|---|---|
ServiceName |
String | Yes | Windows service name (e.g., 'Spooler') |
StartupType |
String | Yes | Automatic, Manual, or Disabled |
Action |
String | Yes | Create, Replace, Update, or Delete |
Printers
Always User scope. Written to User\Preferences\Printers\Printers.xml.
| Key | Type | Required | Description |
|---|---|---|---|
Path |
String | Yes | UNC path to shared printer (e.g., '\\printserver\LaserJet') |
Action |
String | Yes | Create, Replace, Update, or Delete |
Default |
Bool | No | Set as default printer |
SkipLocal |
Bool | No | Skip if local printer attached |
Comment |
String | No | Printer comment |
Location |
String | No | Printer location string |
Shortcuts
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | Shortcut display name |
TargetType |
String | No | URL, FILESYSTEM (default), or SHELL |
TargetPath |
String | Yes | Target URL or file path |
ShortcutPath |
String | Yes | Where to create the shortcut (e.g., '%DesktopDir%\IT Help Desk') |
Action |
String | Yes | Create, Replace, Update, or Delete |
Scope |
String | No | User (default) or Machine |
Arguments |
String | No | Command-line arguments |
StartIn |
String | No | Working directory |
Comment |
String | No | Shortcut comment |
IconPath |
String | No | Icon file path |
IconIndex |
Int | No | Icon index (default: 0) |
Files
| Key | Type | Required | Description |
|---|---|---|---|
FromPath |
String | Yes | Source file path (UNC or local) |
TargetPath |
String | Yes | Destination file path |
Action |
String | Yes | Create, Replace, Update, or Delete |
Scope |
String | No | Machine (default) or User |
ReadOnly |
Bool | No | Set read-only attribute |
Hidden |
Bool | No | Set hidden attribute |
NetworkShares
Always Machine scope. Written to Machine\Preferences\NetworkShares\NetworkShares.xml.
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | Share name |
Path |
String | Yes | Local folder path to share |
Action |
String | Yes | Create, Replace, Update, or Delete |
Comment |
String | No | Share comment |
AllRegular |
String | No | Default share permissions (e.g., 'READ') |
AllHidden |
String | No | Hidden share permissions |
AllAdminDrive |
String | No | Admin share permissions |
LimitUsers |
Int | No | Max concurrent users (0 = unlimited) |
RegistryItems (GPP)
GPP Registry items -- distinct from RegistrySettings (Administrative Templates). These provide finer control (action modes, ILT targeting) but are processed by the GPP CSE rather than the Registry CSE.
| Key | Type | Required | Description |
|---|---|---|---|
Hive |
String | Yes | HKEY_LOCAL_MACHINE or HKEY_CURRENT_USER |
Key |
String | Yes | Registry key path (no hive prefix) |
Name |
String | No | Value name (empty = default value) |
Type |
String | No | REG_SZ (default), REG_DWORD, REG_BINARY, etc. |
Value |
String | No | Value data |
Action |
String | Yes | Create, Replace, Update, or Delete |
Scope |
String | No | Machine (default) or User |
LocalUsersAndGroups (GPP)
Always Machine scope. Written to Machine\Preferences\Groups\Groups.xml.
Unlike RestrictedGroups, GPP Local Groups supports ADD/REMOVE individual members without replacing the full membership list. Use RestrictedGroups for security-critical groups (Administrators); use LocalUsersAndGroups for additive membership management.
| Key | Type | Required | Description |
|---|---|---|---|
GroupName |
String | Yes | Local group name (e.g., 'Remote Desktop Users') |
Action |
String | Yes | Create, Replace, Update, or Delete |
Members |
Array | No | Array of @{ Name = '...'; Action = 'ADD' or 'REMOVE' } entries |
DeleteAllUsers |
Bool | No | Remove all existing user members before applying |
DeleteAllGroups |
Bool | No | Remove all existing group members before applying |
NewName |
String | No | Rename the group |
Description |
String | No | Group description |
Member SIDs are resolved at apply time using NTAccount.Translate().
Item-Level Targeting (Filters)
Any GPP item can include an optional Filters array for conditional targeting. Filters control which computers or users receive the item, evaluated client-side during policy processing.
DriveMaps = @(
@{
Letter = 'Z'
Path = '\\fileserver\shared'
Label = 'Shared'
Action = 'Replace'
Filters = @(
@{ Type = 'SecurityGroup'; Name = 'EXAMPLE\MasterAdmins' }
)
}
)
Supported filter types:
| Type | Required Keys | Optional Keys | Description |
|---|---|---|---|
SecurityGroup |
Name |
Bool, Not, UserContext, PrimaryGroup, LocalGroup |
Resolves group to SID at apply time |
OrgUnit |
Name (DN) |
Bool, Not |
Target OU distinguished name |
Computer |
Name |
Bool, Not |
NETBIOS computer name |
User |
Name |
Bool, Not |
NETBIOS user name |
OperatingSystem |
Name |
Bool, Not, Edition, Version |
OS type string (e.g., 'Windows 11 Enterprise') |
WMI |
Name, Query |
Bool, Not, Namespace |
WQL query with display label |
Common keys for all filter types:
| Key | Default | Description |
|---|---|---|
Bool |
'AND' |
Logical operator (AND or OR) combining with other filters |
Not |
$false |
Negate the filter result |
SecurityGroup names are resolved to SIDs at apply time using NTAccount.Translate() -- never hardcode SIDs. Multiple filters are combined using Boolean logic (AND/OR).
Preferences = @{
ScheduledTasks = @(
@{
Name = 'Install-RSAT'
Action = 'Replace'
Command = 'powershell.exe'
Arguments = '-ExecutionPolicy Bypass -File C:\Scripts\Install-RSAT.ps1'
RunAs = 'NT AUTHORITY\System'
Trigger = 'AtStartup'
Scope = 'Machine'
}
)
DriveMaps = @(
@{
Letter = 'Z'
Path = '\\fileserver.example.internal\shared'
Label = 'Shared Drive'
Action = 'Replace'
Reconnect = $true
}
)
EnvironmentVariables = @(
@{
Name = 'COMPANY_NAME'
Value = 'Example Corp'
Action = 'Replace'
Scope = 'Machine'
}
)
Services = @(
@{
ServiceName = 'Spooler'
StartupType = 'Disabled'
Action = 'Replace'
}
)
}
The framework automatically:
- Generates type-specific XML with proper CLSIDs, UIDs, and timestamps
- Groups items by scope (Machine/User) and writes to the correct SYSVOL directory
- Registers per-type CSE GUIDs in the GPO's extension attributes
- Bumps the GPO version
Compare mode checks XML file existence and verifies each desired item is present by name.
RestrictedGroups Format
Controls local group membership on target machines via the [Group Membership] section in GptTmpl.inf. Uses friendly group/account names -- the framework resolves them to SIDs automatically.
RestrictedGroups = @{
'BUILTIN\Administrators' = @{
Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins')
}
'BUILTIN\Remote Desktop Users' = @{
Members = @('EXAMPLE\MasterAdmins')
Memberof = @() # Optional: groups this group should belong to
}
}
Members: accounts/groups that should be in the target group. Windows enforces this list exactly -- unauthorized members are removed.Members = @()or omittingMembersmeans "empty the group" (removes all members).Memberof: optional -- controls which groups the target group belongs to.- SID resolution failure throws a terminating error with a clear message.
Mutually exclusive: Use RestrictedGroups OR SecurityPolicy['Group Membership'], not both. The orchestrator throws an error if both are present.
The RestrictedGroups key is merged into SecurityPolicy before processing, so it follows SecurityPolicy's full-overwrite semantics and triggers a Machine scope version bump.
WMIFilter Format
Declaratively defines WMI filters as AD objects and links them to GPOs. WMI filters are stored in CN=SOM,CN=WMIPolicy,CN=System in AD.
WMIFilter = @{
Name = 'Workstations Only'
Description = 'Targets physical workstations -- not servers or DCs'
Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1"
}
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | Filter display name (must be unique across all GPOs) |
Description |
String | No | Filter description |
Query |
String | Yes | WQL query evaluated on the client |
Common WQL queries:
| Target | Query |
|---|---|
| Workstations only | SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1 |
| Servers only | SELECT * FROM Win32_OperatingSystem WHERE ProductType = 3 |
| Windows 11+ | SELECT * FROM Win32_OperatingSystem WHERE Version LIKE '10.0.2%' |
Shared filters: If two GPOs reference the same filter name, they share the AD object. Both settings.ps1 files must declare identical Name/Query -- the last apply wins if they differ.
WMI filter creation/linking is an AD-only operation (no SYSVOL write, no version bump needed).
FirewallRules Format
Windows Firewall rules managed via Open-NetGPO / New-NetFirewallRule -GPOSession / Save-NetGPO. Full overwrite semantics -- all existing rules are removed and replaced with declared rules.
| Key | Type | Required | Description |
|---|---|---|---|
DisplayName |
String | Yes | Rule display name (unique identifier) |
Direction |
String | Yes | Inbound or Outbound |
Action |
String | Yes | Allow or Block |
Protocol |
String | No | TCP, UDP, ICMPv4, ICMPv6, or number |
LocalPort |
String/Array | No | Port(s) or range (e.g., '80', '80,443') |
RemotePort |
String/Array | No | Port(s) or range |
LocalAddress |
String/Array | No | IP addresses |
RemoteAddress |
String/Array | No | IP addresses |
Profile |
String | No | Domain, Private, Public, or Any |
Program |
String | No | Executable path |
Enabled |
Bool | No | Default $true |
Description |
String | No | Rule description |
FirewallRules = @(
@{
DisplayName = 'Allow ICMP Echo (Inbound)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'ICMPv4'
Profile = 'Domain'
}
@{
DisplayName = 'Allow WinRM (Inbound)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'TCP'
LocalPort = '5985'
Profile = 'Domain'
Program = 'System'
}
)
Firewall rules use GPO session cmdlets that handle version bumping internally -- no manual version bump needed.
FirewallProfiles Format
Controls the default behavior for each firewall profile (Domain/Private/Public).
FirewallProfiles = @{
Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' }
Private = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' }
Public = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' }
}
Profile settings are applied via Set-GPRegistryValue under HKLM\Software\Policies\Microsoft\WindowsFirewall\{DomainProfile|PrivateProfile|PublicProfile}.
AppLockerPolicy Format
Application whitelisting rules organized by collection type. Uses Set-AppLockerPolicy / Get-AppLockerPolicy with -LDAP parameter.
Rule collections: Exe, Msi, Script, Appx, Dll
Each collection has an EnforcementMode (AuditOnly or Enabled) and an array of Rules:
| Key | Type | Required | Description |
|---|---|---|---|
Type |
String | Yes | Publisher, Path, or Hash |
Action |
String | Yes | Allow or Deny |
User |
String | Yes | NTAccount name (e.g., 'Everyone', 'BUILTIN\Administrators') |
Name |
String | No | Rule display name |
Description |
String | No | Rule description |
Type-specific keys:
| Rule Type | Keys |
|---|---|
| Publisher | Publisher, Product, Binary, LowVersion, HighVersion |
| Path | Path |
| Hash | Hash, FileName, SourceFileLength |
AppLockerPolicy = @{
Exe = @{
EnforcementMode = 'AuditOnly'
Rules = @(
@{
Type = 'Publisher'
Action = 'Allow'
User = 'Everyone'
Publisher = 'O=MICROSOFT CORPORATION, L=REDMOND, S=WASHINGTON, C=US'
Product = '*'
Binary = '*'
}
@{
Type = 'Path'
Action = 'Allow'
User = 'BUILTIN\Administrators'
Path = '*'
}
)
}
Script = @{
EnforcementMode = 'AuditOnly'
Rules = @(
@{
Type = 'Path'
Action = 'Allow'
User = 'Everyone'
Path = '%WINDIR%\*'
}
)
}
}
AppLocker uses Set-AppLockerPolicy which handles registry writes and CSE registration internally. No manual version bump needed.
WDACPolicy Format
Lightweight WDAC (Windows Defender Application Control) policy deployment. Copies a compiled policy to SYSVOL and sets the registry pointer.
WDACPolicy = @{
PolicyFile = 'WDACPolicy.xml' # Path relative to GPO config dir
}
| Key | Type | Required | Description |
|---|---|---|---|
PolicyFile |
String | Yes | Policy file path relative to the GPO directory. .xml files are auto-converted to .p7b via ConvertFrom-CIPolicy. .p7b files are copied directly. |
The framework:
- Copies/converts the policy to
Machine\Microsoft\Windows\DeviceGuard\SIPolicy.p7bin SYSVOL - Sets
HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard\DeployConfigCIPolicy= 1 viaSet-GPRegistryValue
Compare mode checks registry key presence, .p7b file existence, and file hash.
FolderRedirection Format
Redirects user shell folders (Documents, Desktop, etc.) to network paths. Written to User\Documents & Settings\fdeploy1.ini in SYSVOL. User scope only.
Supported folders: Desktop, Documents, Pictures, Music, Videos, Favorites, AppDataRoaming, Contacts, Downloads, Links, Searches, StartMenu
| Key | Type | Required | Description |
|---|---|---|---|
Path |
String | Yes | Redirect target path (supports %USERNAME% variable) |
GrantExclusive |
Bool | No | Grant user exclusive rights to the redirected folder |
MoveContents |
Bool | No | Move existing contents to the new location |
FolderRedirection = @{
Documents = @{
Path = '\\fileserver\users\%USERNAME%\Documents'
GrantExclusive = $true
MoveContents = $true
}
Desktop = @{
Path = '\\fileserver\users\%USERNAME%\Desktop'
GrantExclusive = $true
MoveContents = $true
}
}
The framework:
- Generates fdeploy1.ini with folder sections (e.g.,
[.Documents],[.Desktop]) - Writes as UTF-16LE encoding (same as GptTmpl.inf)
- Registers the Folder Redirection CSE GUID (
{25537BA6-77A8-11D2-9B6C-0000F8080861}) - Bumps User scope version
GPO Status (Disable Configuration Sections)
Controls which configuration sections of the GPO are active. Useful for machine-only GPOs (disable User Configuration to speed up processing) or temporarily disabling a section.
DisableUserConfiguration = $true # Disable User Configuration
DisableComputerConfiguration = $false # Keep Computer Configuration active
| Flags | GpoStatus Value |
|---|---|
Both $false (default) |
AllSettingsEnabled |
DisableUserConfiguration = $true |
UserSettingsDisabled |
DisableComputerConfiguration = $true |
ComputerSettingsDisabled |
Both $true |
AllSettingsDisabled |
Settings are still written to SYSVOL even when the section is disabled -- they take effect immediately when re-enabled, matching GPMC behavior.
Fully backward compatible: omitting both keys defaults to AllSettingsEnabled.
Link Order and Enforcement
The LinkTo key supports three formats, all backward compatible:
# Format 1: Plain string (original behavior, no order/enforcement management)
LinkTo = 'OU=ExampleUsers,DC=example,DC=internal'
# Format 2: Single hashtable with order and enforcement
LinkTo = @{
Target = 'OU=ExampleUsers,DC=example,DC=internal'
Order = 1
Enforced = $false
}
# Format 3: Array for multiple links
LinkTo = @(
@{ Target = 'OU=ExampleUsers,DC=example,DC=internal'; Order = 1; Enforced = $false }
@{ Target = 'OU=ExampleWorkstations,DC=example,DC=internal'; Order = 2; Enforced = $true }
)
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
Target |
String | Yes | -- | OU distinguished name |
Order |
Int or $null |
No | $null |
Link precedence (1 = highest). $null = do not manage order. |
Enforced |
Bool | No | $false |
Whether the link is enforced (overrides Block Inheritance) |
Backward compatibility: Plain string format (LinkTo = 'OU=...') defaults to Order = $null, Enforced = $false -- identical to previous behavior. Existing settings.ps1 files work without modification.
Backup and Restore
The framework automatically snapshots each GPO's state before applying changes. Backups are stored in gpo/backups/ (gitignored) with a retention limit of 5 per GPO.
Automatic backups: Every Apply-GPOBaseline.ps1 run (non-TestOnly) creates a timestamped backup for each GPO before making changes. Use -NoBackup to skip.
What's captured per backup:
- SYSVOL directory tree (GptTmpl.inf, audit.csv, psscripts.ini, scripts.ini, GPP XMLs, script files)
- AD attributes: versionNumber, description, gPCMachineExtensionNames, gPCUserExtensionNames, gPCWQLFilter
- Metadata: timestamp, admin account, domain, GPO GUID
Listing backups:
.\Restore-GPOBaseline.ps1 # List all backups
.\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' # List backups for one GPO
Restoring:
.\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' -Timestamp '20260214-153000'
Restore copies SYSVOL files back, restores AD attributes, and bumps the version to force client reprocessing. After restoring, run Apply-GPOBaseline.ps1 -TestOnly to verify the state, then update settings.ps1 to match the restored state (otherwise the next apply will overwrite the restore).
Complete settings.ps1 Example
# Example-01 -- Settings Declaration
# Linked to: OU=ExampleOU,DC=example,DC=internal
@{
GPOName = 'Example-01'
Description = 'Example policy -- managed by declarative-ad-framework, do not edit manually'
LinkTo = 'OU=ExampleOU,DC=example,DC=internal'
SecurityPolicy = @{
'System Access' = [ordered]@{
EnableGuestAccount = 0
}
'Event Audit' = [ordered]@{
AuditSystemEvents = 3
AuditLogonEvents = 3
AuditAccountLogon = 3
}
'Registry Values' = [ordered]@{
'MACHINE\...\InactivityTimeoutSecs' = '4,900'
}
}
RegistrySettings = @(
@{
Key = 'HKLM\Software\Policies\...'
ValueName = 'EnableFeature'
Type = 'DWord'
Value = 1
}
)
SecurityFiltering = @{
DenyApply = @('ExemptGroup')
}
Scripts = @{
MachineStartup = @(
@{
Source = 'Install-RSAT.ps1'
Parameters = ''
}
)
}
AdvancedAuditPolicy = @{
'Credential Validation' = 'Success and Failure'
'Logon' = 'Success and Failure'
'Process Creation' = 'Success and Failure'
}
RestrictedGroups = @{
'BUILTIN\Administrators' = @{
Members = @('EXAMPLE\Domain Admins', 'EXAMPLE\MasterAdmins')
}
}
WMIFilter = @{
Name = 'Workstations Only'
Description = 'Targets physical workstations only'
Query = "SELECT * FROM Win32_OperatingSystem WHERE ProductType = 1"
}
DisableUserConfiguration = $true
Preferences = @{
Services = @(
@{
ServiceName = 'Spooler'
StartupType = 'Disabled'
Action = 'Replace'
Filters = @(
@{ Type = 'OperatingSystem'; Name = 'Windows Server 2025' }
)
}
)
Printers = @(
@{
Path = '\\printserver\LaserJet'
Action = 'Replace'
Default = $true
}
)
}
FirewallRules = @(
@{
DisplayName = 'Allow WinRM (Inbound)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'TCP'
LocalPort = '5985'
Profile = 'Domain'
}
)
FirewallProfiles = @{
Domain = @{ Enabled = $true; DefaultInboundAction = 'Block'; DefaultOutboundAction = 'Allow' }
}
}
AD Object Definition Files
All definition files live under ad-objects/ and return arrays of hashtables.
ous.ps1
$domainDN = 'DC=example,DC=internal'
@(
@{
Name = 'ExampleUsers' # OU name (creates OU=ExampleUsers,...)
Path = $domainDN # Parent container DN
Description = 'Standard user accounts' # Optional description
}
)
groups.ps1
@(
@{
Name = 'MasterAdmins'
Path = 'OU=ExampleAdmins,DC=example,DC=internal'
Scope = 'Global' # Global, DomainLocal, or Universal
Description = 'Master administrators'
Members = @('t0admin') # SamAccountNames to sync
}
)
Members are reconciled in a separate pass (Step 4) after users are created in Step 3.
users.ps1
@(
@{
SamAccountName = 't0admin'
Name = 'Tier Zero Admin' # Display name
GivenName = 'Tier'
Surname = 'Admin'
Path = 'OU=ExampleAdmins,DC=example,DC=internal'
Enabled = $true
MemberOf = @('MasterAdmins', 'Administrators', 'Group Policy Creator Owners')
Description = 'Tier 0 master admin' # Optional AD properties
Title = 'Master Administrator'
Department = 'IT'
}
)
Core keys (SamAccountName, Name, GivenName, Surname, Path, Enabled, MemberOf) are explicit parameters. All other keys are treated as optional AD properties and passed through a -Properties hashtable to Set-ADUser -Replace (for existing users) or merged into the New-ADUser splat (for new users).
New users get a CSPRNG password saved to .credentials/<SamAccountName>.txt. See Credential Handling.
delegations.ps1
$domainDN = 'DC=example,DC=internal'
@(
@{
GroupName = 'MasterAdmins'
TargetOUs = @(
"OU=ExampleUsers,$domainDN"
"OU=ExampleServers,$domainDN"
)
Rights = 'FullControl' # Single string for simple cases
}
@{
GroupName = 'DelegatedAdmins'
TargetOUs = @("OU=ExampleUsers,$domainDN")
Rights = @( # Array for granular rights
'ListContents'
'ReadAllProperties'
'ResetPassword'
'ReadWriteProperty:userAccountControl'
'ReadWriteProperty:lockoutTime'
'ReadWriteProperty:displayName'
)
}
)
Rights Syntax
| Token | AD Translation | Scope |
|---|---|---|
FullControl |
GenericAll |
OU and all descendants |
ListContents |
ListChildren |
OU itself |
ReadAllProperties |
ReadProperty (all) |
OU itself |
ResetPassword |
Extended right 00299570-246d-11d0-a768-00aa006e0529 |
Descendant user objects |
ReadWriteProperty:<attr> |
ReadProperty + WriteProperty on specific attribute GUID |
Descendant user objects |
Supported attributes for ReadWriteProperty: userAccountControl, lockoutTime, displayName, givenName, sn, mail, telephoneNumber, description. To add more, add the attribute's schema GUID to $Script:ADAttributeGuid in ADDelegation.ps1.
Dependency Ordering
AD Objects (Apply-ADBaseline.ps1)
Step 1: OUs -- must exist before groups/users can be placed in them
Step 2: Groups -- created WITHOUT members (users may not exist yet)
Step 3: Users -- created with MemberOf (adds user to groups)
Step 4: Membership -- second pass on groups, reconciles Members lists
Step 5: Delegations -- ACLs applied last (groups and OUs must exist)
Step 6: Password Policies -- PSOs linked to groups (groups must exist)
The two-pass group membership sync is a deliberate design choice. Groups are created in Step 2 without members because the users listed in Members may not exist yet (they're created in Step 3). Step 4 does the full reconciliation after all users exist.
GPO Settings (Apply-GPOBaseline.ps1)
Per GPO, processed in this order:
1. Create GPO if missing (New-GPO)
2. Description ($gpo.Description)
3. GPO status (Ensure-GPOStatus -> gPCOptions)
4. Pre-apply backup (Backup-GPOState -> gpo/backups/)
5. Management permissions (Ensure-GPOManagementPermission)
6. Restricted groups merge (ConvertTo-RestrictedGroupEntries -> SecurityPolicy)
7. Security policy (Set-GPOSecurityPolicy -> GptTmpl.inf)
8. Registry settings (Set-GPORegistrySettings -> Set-GPRegistryValue, cleanup stale)
9. GPO link(s) (Ensure-GPOLink -> New-GPLink / Set-GPLink)
10. Security filtering (Ensure-GPOSecurityFiltering)
11. WMI filter (Ensure-GPOWmiFilter -> AD msWMI-Som object)
12. Scripts (Set-GPOScripts -> SYSVOL copy + psscripts.ini)
13. Advanced audit policy (Set-GPOAdvancedAuditPolicy -> audit.csv)
14. Preferences (Set-GPOPreferences -> GPP XML files)
15. Firewall profiles (Set-GPOFirewallProfiles -> registry values)
16. Firewall rules (Set-GPOFirewall -> Open-NetGPO session)
17. AppLocker policy (Set-GPOAppLockerPolicy -> Set-AppLockerPolicy -LDAP)
18. WDAC policy (Set-GPOWdacPolicy -> SYSVOL .p7b + registry)
19. Folder redirection (Set-GPOFolderRedirection -> fdeploy1.ini)
GPOs are independent of each other and processed in filesystem order (whatever Get-ChildItem returns).
How-To Recipes
Adding a New GPO
- Create directory:
gpo/<name>/ - Create
gpo/<name>/settings.ps1returning the standard hashtable (see GPO Definition Format) - Create
gpo/<name>/README.mddocumenting all settings with tables (follow existing READMEs as template) - Add a row to the GPO Inventory table in
README.md - Commit, push, pull on DC, run:
.\gpo\Apply-GPOBaseline.ps1 -TestOnly # Shows new GPO as drift
.\gpo\Apply-GPOBaseline.ps1 -GpUpdate # Creates GPO, applies, links
.\gpo\Apply-GPOBaseline.ps1 -TestOnly # All [OK]
No changes to Apply-GPOBaseline.ps1 are needed -- auto-discovery handles it.
Adding a Setting to an Existing GPO
- Edit the GPO's
settings.ps1:- For security policy: add to the appropriate section hashtable
- For registry settings: add a new hashtable to the
RegistrySettingsarray
- Update the GPO's
README.mdwith the new setting - Run
Apply-GPOBaseline.ps1 -TestOnlyto verify the drift, then apply
Adding a New User
- Add a hashtable to
ad-objects/users.ps1with all required keys - Run
Apply-ADBaseline.ps1-- creates the user with a CSPRNG password - Read the password from
ad-objects/.credentials/<username>.txt - Securely share with the user, then delete the file
Adding a New Group
- Add a hashtable to
ad-objects/groups.ps1 - If the group has members, they must already be defined in
users.ps1 - Run
Apply-ADBaseline.ps1
Adding a New OU
- Add a hashtable to
ad-objects/ous.ps1 - Run
Apply-ADBaseline.ps1
Adding a New Delegation Right
- Look up the attribute's schema GUID from Microsoft AD Schema docs
- Add the GUID to
$Script:ADAttributeGuidinad-objects/lib/ADHelper.ps1 - Use the new
ReadWriteProperty:<attrName>token indelegations.ps1 - Run
Apply-ADBaseline.ps1
For extended rights, add to $Script:ADExtendedRight and create a corresponding token in ConvertTo-DelegationACEs.
password-policies.ps1
Fine-grained password policies (PSOs) override the Default Domain Policy for specific groups. Lower Precedence number = higher priority.
@(
@{
Name = 'PSO-MasterAdmins'
Description = 'Strict password policy for Tier 0 admin accounts'
Precedence = 10
MinPasswordLength = 16
PasswordHistoryCount = 48
MaxPasswordAge = '30.00:00:00' # TimeSpan string
MinPasswordAge = '1.00:00:00'
ComplexityEnabled = $true
ReversibleEncryptionEnabled = $false
LockoutThreshold = 3
LockoutDuration = '00:30:00'
LockoutObservationWindow = '00:30:00'
AppliesTo = @('MasterAdmins') # Groups to link
}
)
| Key | Type | Required | Description |
|---|---|---|---|
Name |
String | Yes | PSO display name (unique) |
Description |
String | No | PSO description |
Precedence |
Int | Yes | Priority (lower = higher priority) |
MinPasswordLength |
Int | No | Minimum password length |
PasswordHistoryCount |
Int | No | Previous passwords remembered |
MaxPasswordAge |
String | No | Maximum password age (TimeSpan: 'days.hours:minutes:seconds') |
MinPasswordAge |
String | No | Minimum password age |
ComplexityEnabled |
Bool | No | Require complexity |
ReversibleEncryptionEnabled |
Bool | No | Store passwords with reversible encryption |
LockoutThreshold |
Int | No | Failed attempts before lockout |
LockoutDuration |
String | No | Lockout duration (TimeSpan) |
LockoutObservationWindow |
String | No | Failed-attempt counter reset window (TimeSpan) |
AppliesTo |
Array | No | Group SamAccountNames to link the PSO to |
PSOs are created with ProtectedFromAccidentalDeletion = $true. Group linkage is fully synced -- groups not in AppliesTo are unlinked.
Adding a New Management Group
- Edit
$ManagementGroupsingpo/Apply-GPOBaseline.ps1:
$ManagementGroups = @('MasterAdmins', 'NewGroup')
- The group will automatically get
GpoEditDeleteModifySecurityon every managed GPO on the next run.
Adding DSC Validation for a GPO
- Create a DSC configuration file in the GPO's directory (e.g.,
gpo/<name>/MyPolicy.ps1) - Read from the same
settings.ps1to maintain single source of truth:
$settingsPath = Join-Path $PSScriptRoot 'settings.ps1'
$settings = & $settingsPath
- Transform values from GptTmpl.inf format to DSC format (e.g.,
'4,1'.Split(',')[1]to extract the DWORD value) - Add the configuration to
Apply-DscBaseline.ps1
Credential Handling
When Apply-ADBaseline.ps1 creates new users:
- Password generated using
System.Security.Cryptography.RandomNumberGenerator(CSPRNG) - Saved to
ad-objects/.credentials/<SamAccountName>.txt - File ACL-locked to the admin who ran the script (inheritance disabled, only current user gets FullControl)
- Password never appears in console output, PowerShell transcripts, or logs
- Script displays an
ACTION REQUIREDbanner listing pending handoffs - Admin reads file, securely shares password, deletes file
- User must change password on first login (
ChangePasswordAtLogon = $true)
Credential files older than 24 hours trigger a WARNING banner on every subsequent run, catching forgotten handoffs.
Encoding and Compatibility
PowerShell Scripts
DC01 runs Windows PowerShell 5.1. Files must be saved as UTF-8 with BOM or use only ASCII characters.
Why: PowerShell 5.1 without a BOM interprets files as Windows-1252 (ANSI). Byte 0x97 (em dash in CP-1252) is treated as a string delimiter, causing cryptic parse errors. Avoid em dashes, smart quotes, and other non-ASCII characters in string literals.
GptTmpl.inf (SYSVOL)
Must be written as UTF-16LE (what .NET calls [System.Text.Encoding]::Unicode). GPOHelper.ps1 handles this automatically with [System.IO.File]::WriteAllText. Writing as UTF-8 or ASCII causes the Group Policy engine to silently ignore the file.
psscripts.ini / scripts.ini (SYSVOL)
Must be written as UTF-16LE, same as GptTmpl.inf. These files register startup/shutdown/logon/logoff scripts with the Group Policy Script CSE.
audit.csv (SYSVOL)
Must be written as UTF-8 with BOM (unlike GptTmpl.inf which is UTF-16LE). This is the Advanced Audit Policy configuration file at Machine\Microsoft\Windows NT\Audit\audit.csv.
GPP XML files (SYSVOL)
Must be written as UTF-8 with BOM. These are the Group Policy Preferences XML files (10 types including ScheduledTasks.xml, Drives.xml, Printers.xml, Shortcuts.xml, Files.xml, NetworkShares.xml, Registry.xml, Groups.xml, etc.) in {Machine|User}\Preferences\<Type>\.
GPO Version Bumping
When writing GptTmpl.inf directly to SYSVOL, the GPO version must be incremented in two places:
- The
versionNumberattribute on the GPO's AD object - The
Version=line inGPT.INIon SYSVOL
The version number encodes user and computer versions in a single 32-bit integer (upper 16 bits = user version, lower 16 bits = computer version). GPOHelper.ps1 handles this automatically.
Gotchas and Edge Cases
ACE Merging in Active Directory
AD automatically merges compatible ACEs on the same security principal. For example, separate ListChildren and ReadProperty ACEs may be merged into a single ACE with both flags set. The delegation code uses bitwise subset checking rather than exact equality:
# Correct: checks if desired rights are a SUBSET of existing rights
($existing.ActiveDirectoryRights -band $DesiredACE.ActiveDirectoryRights) -ne $DesiredACE.ActiveDirectoryRights
# Wrong: fails when AD merges ACEs
$existing.ActiveDirectoryRights -ne $DesiredACE.ActiveDirectoryRights
DSC Parameter Sets
Test-DscConfiguration -Path and -Detailed are in incompatible parameter sets. To get detailed output, use -ReferenceConfiguration pointing to a specific MOF file:
# Correct
Test-DscConfiguration -ReferenceConfiguration "$mofDir\localhost.mof" -Verbose
# Wrong -- will fail
Test-DscConfiguration -Path $mofDir -Detailed
DSC is Test-Only by Design
Apply-DscBaseline.ps1 is a read-only compliance validation tool. It compiles DSC configurations and tests local state against them, but never modifies the system. To fix drift detected by DSC, edit settings.ps1 and run Apply-GPOBaseline.ps1 -- this keeps the GPO as the single source of truth.
Security Filtering Deny
Deny Apply Group Policy is implemented as a raw AD ACL, not via Set-GPPermission. The cmdlet was found to be unreliable for deny entries during development.
Account Lockout Threshold
LockoutBadCount = 0 disables account lockout entirely. This is a common misconfiguration -- always set it to a positive integer (e.g., 5).
SID Resolution
Never hardcode SIDs in settings.ps1. If a group is deleted and recreated, it gets a new SID. Resolve SIDs dynamically:
$masterAdminsSID = (Get-ADGroup -Identity 'MasterAdmins').SID.Value
TestOnly with Non-Existent GPOs
When -TestOnly encounters a GPO that doesn't exist in AD, it reports [DRIFT] and skips to the next GPO with continue. You can't compare settings against a GPO that hasn't been created yet.