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

1399 lines
58 KiB
Markdown

# 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:
```powershell
$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:
```powershell
# 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:
```powershell
# 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.
```powershell
'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).
```powershell
'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.
```powershell
'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.
```powershell
'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.
```powershell
'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) |
```powershell
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.
```powershell
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
```powershell
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'`
```powershell
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.
```powershell
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).
```powershell
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.
```powershell
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.
```powershell
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 |
```powershell
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).
```powershell
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` |
```powershell
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.
```powershell
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 |
```powershell
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.
```powershell
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:
```powershell
# 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**:
```powershell
.\Restore-GPOBaseline.ps1 # List all backups
.\Restore-GPOBaseline.ps1 -GPOName 'Admins-01' # List backups for one GPO
```
**Restoring**:
```powershell
.\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
```powershell
# 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
```powershell
$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
```powershell
@(
@{
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
```powershell
@(
@{
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](#credential-handling).
### delegations.ps1
```powershell
$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](#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:
```powershell
.\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](https://learn.microsoft.com/en-us/windows/win32/adschema/attributes-all)
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.
```powershell
@(
@{
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`:
```powershell
$ManagementGroups = @('MasterAdmins', 'NewGroup')
```
2. 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:
```powershell
$settingsPath = Join-Path $PSScriptRoot 'settings.ps1'
$settings = & $settingsPath
```
3. Transform values from GptTmpl.inf format to DSC format (e.g., `'4,1'.Split(',')[1]` to extract the DWORD value)
4. 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:
```powershell
# 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:
```powershell
# 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:
```powershell
$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.