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

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 -TestOnly for read-only drift detection.
  • Helper libraries (ADHelper.ps1, GPOHelper.ps1 + modules) contain the Ensure-* and Compare-* 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 -TestOnly mode.

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 omitting Members means "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.p7b in SYSVOL
  • Sets HKLM\SOFTWARE\Policies\Microsoft\Windows\DeviceGuard\DeployConfigCIPolicy = 1 via Set-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.

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

  1. Create directory: gpo/<name>/
  2. Create gpo/<name>/settings.ps1 returning the standard hashtable (see GPO Definition Format)
  3. Create gpo/<name>/README.md documenting all settings with tables (follow existing READMEs as template)
  4. Add a row to the GPO Inventory table in README.md
  5. 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

  1. Edit the GPO's settings.ps1:
    • For security policy: add to the appropriate section hashtable
    • For registry settings: add a new hashtable to the RegistrySettings array
  2. Update the GPO's README.md with the new setting
  3. Run Apply-GPOBaseline.ps1 -TestOnly to verify the drift, then apply

Adding a New User

  1. Add a hashtable to ad-objects/users.ps1 with all required keys
  2. Run Apply-ADBaseline.ps1 -- creates the user with a CSPRNG password
  3. Read the password from ad-objects/.credentials/<username>.txt
  4. Securely share with the user, then delete the file

Adding a New Group

  1. Add a hashtable to ad-objects/groups.ps1
  2. If the group has members, they must already be defined in users.ps1
  3. Run Apply-ADBaseline.ps1

Adding a New OU

  1. Add a hashtable to ad-objects/ous.ps1
  2. Run Apply-ADBaseline.ps1

Adding a New Delegation Right

  1. Look up the attribute's schema GUID from Microsoft AD Schema docs
  2. Add the GUID to $Script:ADAttributeGuid in ad-objects/lib/ADHelper.ps1
  3. Use the new ReadWriteProperty:<attrName> token in delegations.ps1
  4. 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

  1. Edit $ManagementGroups in gpo/Apply-GPOBaseline.ps1:
$ManagementGroups = @('MasterAdmins', 'NewGroup')
  1. The group will automatically get GpoEditDeleteModifySecurity on every managed GPO on the next run.

Adding DSC Validation for a GPO

  1. Create a DSC configuration file in the GPO's directory (e.g., gpo/<name>/MyPolicy.ps1)
  2. Read from the same settings.ps1 to maintain single source of truth:
$settingsPath = Join-Path $PSScriptRoot 'settings.ps1'
$settings = & $settingsPath
  1. Transform values from GptTmpl.inf format to DSC format (e.g., '4,1'.Split(',')[1] to extract the DWORD value)
  2. Add the configuration to Apply-DscBaseline.ps1

Credential Handling

When Apply-ADBaseline.ps1 creates new users:

  1. Password generated using System.Security.Cryptography.RandomNumberGenerator (CSPRNG)
  2. Saved to ad-objects/.credentials/<SamAccountName>.txt
  3. File ACL-locked to the admin who ran the script (inheritance disabled, only current user gets FullControl)
  4. Password never appears in console output, PowerShell transcripts, or logs
  5. Script displays an ACTION REQUIRED banner listing pending handoffs
  6. Admin reads file, securely shares password, deletes file
  7. 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:

  1. The versionNumber attribute on the GPO's AD object
  2. The Version= line in GPT.INI on 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.