Idempotent PowerShell deployer that creates a read-only Active Directory role for SIEM ingestion, auditing, and security posture management.
The deployer creates a security group (GS-Global-Readers by default) and applies a single ACE to the target container granting:
Allow | ReadProperty, ListChildren, ListObject | Inheritance: All
ControlAccess is intentionally omitted, meaning confidential attributes (LAPS passwords, ms-PKI-DPAPIMasterKeys, etc., flagged with searchFlags bit 128) remain inaccessible even though ReadProperty is granted.
| Requirement | Detail |
|---|---|
| PowerShell | 5.1 or later |
| RSAT module | ActiveDirectory (included in RSAT on Windows Server; install via Add-WindowsCapability on workstations) |
| Execution context | Domain Admin, or an account with WriteDacl on the target container |
| Pester | v5+ (required for tests; auto-installed by Tests\Bootstrap.ps1) |
ad_global_reader/
Deploy-GlobalReader.ps1 # Orchestrator: create group + apply ACE
Remove-GlobalReader.ps1 # Orchestrator: remove ACE (and optionally group)
Get-GRReport.ps1 # Audit and reporting
Verify-Deployment.ps1 # Post-deploy verification (v1; see note below)
Modules/
New-GR-Group.ps1 # Security group creation
Set-GR-Delegation.ps1 # ACL delegation
Remove-GR-Delegation.ps1 # ACL removal
Set-GR-AdminSDHolder.ps1 # AdminSDHolder ACE application
Remove-GR-AdminSDHolder.ps1 # AdminSDHolder ACE removal
Helpers/
Write-GRLog.ps1 # CSV + console logging
Test-GRPreFlight.ps1 # Pre-flight checks
Sign-GRScripts.ps1 # Authenticode signing for AllSigned environments
Tests/
Bootstrap.ps1 # Pester v5 installer and test runner
New-GR-Group.Tests.ps1 # Unit tests
Set-GR-Delegation.Tests.ps1 # Unit tests
Remove-GR-Delegation.Tests.ps1 # Unit tests
Integration.Tests.ps1 # Integration tests (requires domain-joined session)
Docs/
V2-Decisions.md # Design decisions and rationale for v2
DSC-Evaluation.md # Evaluation of DSC for role drift remediation
Logs/ # Runtime CSV logs (git-ignored)
Logs/Reports/ # Get-GRReport HTML/CSV output (git-ignored)
Targets the domain root. Creates GS-Global-Readers in CN=Users.
.\Deploy-GlobalReader.ps1.\Deploy-GlobalReader.ps1 `
-TargetOU 'OU=Servers,DC=ad,DC=example,DC=com'.\Deploy-GlobalReader.ps1 `
-IdentityName 'GS-SIEM-Readers' `
-TargetOU 'DC=ad,DC=example,DC=com' `
-GroupOU 'OU=SecurityGroups,OU=Admin,DC=ad,DC=example,DC=com'Closes the AdminSDHolder gap so that domain-protected accounts (Domain Admins, Schema Admins, etc.) are also readable. Requires -Force to acknowledge the security implication.
.\Deploy-GlobalReader.ps1 -ApplyAdminSDHolder -ForceTo immediately propagate via SDProp (rather than waiting up to 60 minutes):
.\Deploy-GlobalReader.ps1 -ApplyAdminSDHolder -Force -TriggerSDPropAll operations are simulated. No changes are made to AD. A CSV log is still written with a WhatIf_Active marker row so the simulated run is auditable.
.\Deploy-GlobalReader.ps1 -WhatIf.\Deploy-GlobalReader.ps1 -LogPath 'C:\Logs\GR-Deploy.csv'| Parameter | Default | Description |
|---|---|---|
-IdentityName |
GS-Global-Readers |
Name of the security group to create or use |
-TargetOU |
Domain root DN | DN of the container to receive the ACE |
-GroupOU |
Domain CN=Users container |
DN of the OU in which to create the group |
-ApplyAdminSDHolder |
$false |
Also apply the ACE to CN=AdminSDHolder,CN=System,... |
-Force |
$false |
Required when -ApplyAdminSDHolder is specified |
-TriggerSDProp |
$false |
Trigger immediate SDProp propagation after AdminSDHolder change |
-LogPath |
.\Logs\GR-Deploy-<timestamp>.csv |
Output path for the structured deployment log |
-WhatIf |
— | Simulate all operations; log is still written with WhatIf_Active marker |
Removes the domain root ACE. By default the security group is preserved, making re-deployment a single command.
.\Remove-GlobalReader.ps1.\Remove-GlobalReader.ps1 -RemoveAdminSDHolderTo trigger immediate SDProp propagation after removal:
.\Remove-GlobalReader.ps1 -RemoveAdminSDHolder -TriggerSDPropWarns if the group has members. Clears ProtectedFromAccidentalDeletion before deleting.
.\Remove-GlobalReader.ps1 -RemoveGroup.\Remove-GlobalReader.ps1 -WhatIf| Parameter | Default | Description |
|---|---|---|
-IdentityName |
GS-Global-Readers |
Name of the security group |
-TargetOU |
Domain root DN | DN from which to remove the ACE |
-RemoveAdminSDHolder |
$false |
Also remove the ACE from AdminSDHolder |
-RemoveGroup |
$false |
Delete the security group after removing ACEs |
-TriggerSDProp |
$false |
Trigger immediate SDProp propagation after AdminSDHolder change |
-LogPath |
.\Logs\GR-Remove-<timestamp>.csv |
Output path for the removal log |
-WhatIf |
— | Simulate all operations; log is still written with WhatIf_Active marker |
All removal operations are idempotent. Re-running when the ACE or group is already absent logs a _Skipping action and exits cleanly.
Without AdminSDHolder coverage, GS-Global-Readers cannot read accounts protected by SDProp (Domain Admins, Schema Admins, Enterprise Admins, Administrators, Account Operators, etc.). SDProp runs every 60 minutes on the PDC Emulator and overwrites the DACL on those accounts, stripping any inherited ACE from the domain root delegation.
Applying the ACE to CN=AdminSDHolder,CN=System,<DomainDN> causes SDProp to propagate it forward to all protected accounts. This is opt-in (-ApplyAdminSDHolder -Force) and reversible (Remove-GlobalReader.ps1 -RemoveAdminSDHolder).
SDProp timing: Changes take effect within the next SDProp cycle (up to 60 minutes) unless -TriggerSDProp is used, which writes runProtectAdminGroupsTask=1 to the PDC Emulator's RootDSE for immediate propagation.
Get-GRReport.ps1 produces an HTML and/or CSV report without modifying any AD objects.
# Default: HTML + CSV report, current domain
.\Get-GRReport.ps1
# Specify output path and format
.\Get-GRReport.ps1 -OutputPath 'C:\Reports' -Format HTML
# Check inherited ACE presence on sampled OUs
.\Get-GRReport.ps1 -CheckInheritance
# Use in a monitoring pipeline: exits 1 if health is degraded
.\Get-GRReport.ps1 -FailOnMissingAce
# SIEM logon activity stub (documents what query to run against your SIEM)
.\Get-GRReport.ps1 -SiemEndpoint 'https://splunk.corp.example.com:8089'
# Acknowledge a legitimate membership change and reset the baseline
.\Get-GRReport.ps1 -RefreshBaselineReport sections:
- Role Health — group existence, domain root ACE, AdminSDHolder ACE
- Group Membership — members with
LastLogonDateand enabled status - Membership Change Alert — compares current membership against a stored baseline CSV; writes to the Windows Application event log (EventId 8650) and emits a console warning on delta. Baseline auto-created on first run; update with
-RefreshBaselineafter intentional changes. - Logon Activity — queries the PDC Emulator Security event log for event 4624 (network/remote logons) for group member accounts. If access is denied or WinRM is unavailable, surfaces a clear "not configured" notice with remediation steps. When
-SiemEndpointis provided, displays sample Splunk/Sentinel/Elastic queries instead. - AdminSDHolder Gap Analysis — all
adminCount=1objects and whether they are covered - Inheritance Spot-Check (optional,
-CheckInheritance) — samples up to 5 OUs and verifies inherited ACE presence
| Parameter | Default | Description |
|---|---|---|
-IdentityName |
GS-Global-Readers |
Group name |
-TargetDN |
Domain root DN | DN expected to carry the ACE |
-OutputPath |
.\Logs\Reports\ |
Output directory |
-Format |
Both |
HTML, CSV, or Both |
-CheckInheritance |
— | Enable OU inheritance spot-check |
-FailOnMissingAce |
— | Exit 1 when role health is degraded |
-SiemEndpoint |
— | SIEM URI; activates query stub instead of local event log query |
-RefreshBaseline |
— | Update membership baseline to current state |
Before making any changes the deployer verifies:
- AD module —
ActiveDirectoryis imported or can be imported. - Domain connectivity —
Get-ADDomainreturns a valid domain object. - Privilege — Enumerates all SIDs in the current Windows token and walks the target object's DACL for a matching
WriteDaclorGenericAllAllow ACE. Logs a warning (not a hard stop) if none is found;Set-Aclwill surface the real error if rights are insufficient. - Protected containers — Warns if the target DN is under
CN=System,CN=Configuration, orCN=Schema, where inheritance may be blocked on child objects.
Every operation is safe to re-run:
| Operation | Already done | Action |
|---|---|---|
| Group creation | Group exists | Group_Exists_Skipping logged; existing object returned |
| ACE application | ACE present | ACE_Exists_Skipping logged; Set-Acl not called |
| ACE removal | ACE absent | ACE_NotFound_Skipping logged; exits cleanly |
| Group removal | Group absent | Group_NotFound_Skipping logged; exits cleanly |
| AdminSDHolder apply | ACE present | AdminSDHolder_ACE_Exists_Skipping logged |
| AdminSDHolder remove | ACE absent | AdminSDHolder_ACE_NotFound_Skipping logged |
Verify-Deployment.ps1 is a human-facing spot-check tool. It confirms the group exists, the domain root ACE is in place, and re-runs the deployer to verify idempotency.
# Basic verification
.\Verify-Deployment.ps1
# Include AdminSDHolder ACE check
.\Verify-Deployment.ps1 -CheckAdminSDHolder
# Custom group / target
.\Verify-Deployment.ps1 -IdentityName 'GS-SIEM-Readers' `
-TargetDN 'OU=Servers,DC=ad,DC=example,DC=com' -CheckAdminSDHolderThe AdminSDHolder check is informational (INFO) — its absence is not treated as a failure since AdminSDHolder coverage is opt-in. Exits with code 1 if the group or domain root ACE is missing, or if the idempotency run produces errors.
Tests require Pester v5. Bootstrap.ps1 installs it automatically from PSGallery if needed.
# Unit tests only (no AD required)
.\Tests\Bootstrap.ps1 -Tags Unit
# Integration tests (requires Domain Admin session)
.\Tests\Bootstrap.ps1 -Tags IntegrationUnit tests: 22 tests covering New-GR-Group, Set-GR-Delegation, and Remove-GR-Delegation across happy-path, idempotency, WhatIf, and error contexts. All AD and ACL calls are mocked.
Integration tests: 29 tests in five phases executed against a real domain:
- Deploy (group + ACE creation)
- Idempotency (re-run produces only skip actions)
- Remove (WhatIf first, then real; WhatIf log verified)
- Restore (re-deploy after removal)
- AdminSDHolder (real apply + remove with SDProp trigger)
All operations write a UTF-8 CSV at $LogPath. Logs are written even under -WhatIf; a WhatIf_Active marker row is prepended so simulated runs are clearly distinguishable in audit trails.
| Field | Description |
|---|---|
Timestamp |
UTC time (yyyy-MM-ddTHH:mm:ssZ) |
TargetDN |
DN of the object where the action occurred |
Action |
See action codes below |
Principal |
Group name or executing user |
Details |
Human-readable result or exception message |
Action codes
| Code | Meaning |
|---|---|
WhatIf_Active |
First row in any WhatIf run; confirms log is a simulation |
Group_Created |
Group was created successfully |
Group_Exists_Skipping |
Group already existed; no change made |
Group_Removed |
Group was deleted successfully |
Group_NotFound_Skipping |
Group already absent; removal skipped |
ACE_Added |
ACE was applied via Set-Acl |
ACE_Exists_Skipping |
ACE already present; Set-Acl not called |
ACE_Removed |
ACE was removed via Set-Acl |
ACE_NotFound_Skipping |
ACE already absent; removal skipped |
AdminSDHolder_ACE_Added |
ACE applied to AdminSDHolder |
AdminSDHolder_ACE_Exists_Skipping |
AdminSDHolder ACE already present; skipped |
AdminSDHolder_ACE_Removed |
ACE removed from AdminSDHolder |
AdminSDHolder_ACE_NotFound_Skipping |
AdminSDHolder ACE already absent; skipped |
PreFlight_OK |
Pre-flight check passed |
PreFlight_Warning |
Pre-flight check produced a non-fatal warning |
PreFlight_Error |
Pre-flight check failed (deployment aborted) |
Error |
Unexpected error; see Details for the exception |
- ReadProperty on all attributes of all objects (subject to schema
searchFlags) - ListChildren — enumerate child objects in a container
- ListObject — see that objects exist (required when
List Objectmode is enabled) - Inheritance scope: This object and all descendant objects
ControlAccess— no Extended Rights of any kind, including Read Password, Return Property, DS-Replication-Get-ChangesWriteProperty,WriteDacl,WriteOwner,Delete,DeleteTree- Access to confidential attributes (
searchFlagsbit 128 in the schema, e.g., LAPSms-LAPS-Password,ms-PKI-DPAPIMasterKeys) — these require an explicitControlAccessdelegation that this deployer never grants
Environments that enforce an AllSigned execution policy via GPO require every .ps1 file to carry a valid Authenticode signature — including dot-sourced helpers and modules, not just the entry-point scripts.
Helpers\Sign-GRScripts.ps1 handles this in one pass.
# Sign all production scripts
.\Helpers\Sign-GRScripts.ps1 -Thumbprint 'A1B2C3D4E5F6...'
# With an RFC 3161 timestamp (recommended for production)
.\Helpers\Sign-GRScripts.ps1 -Thumbprint 'A1B2C3D4E5F6...' `
-TimestampServer 'http://timestamp.digicert.com'
# Also sign test scripts (if running Pester in an AllSigned session)
.\Helpers\Sign-GRScripts.ps1 -Thumbprint 'A1B2C3D4E5F6...' `
-TimestampServer 'http://timestamp.digicert.com' -IncludeTestsThe certificate must carry the Code Signing EKU (1.3.6.1.5.5.7.3.3) and resolve to a trusted root on the target machine. The script validates both before signing.
Invoke workaround — do not use in production: A common bypass for unsigned scripts is to read the file content and execute it as a ScriptBlock:
# These bypass ExecutionPolicy but also bypass Authenticode verification entirely
& ([ScriptBlock]::Create((Get-Content $path -Raw)))
Invoke-Expression (Get-Content $path -Raw)PowerShell's AllSigned policy applies to script files, not ScriptBlock literals. These patterns defeat the security purpose of code signing. Sign all constituent scripts with Sign-GRScripts.ps1 instead.
For development sessions without AllSigned, a per-process bypass is safe:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
# Reverts automatically when the session ends; does not modify machine or user policyRe-signing after edits: Set-AuthenticodeSignature appends a signature block to the file. Any edit — even a single character — invalidates the signature. Re-run Sign-GRScripts.ps1 after every change before deploying to an AllSigned environment.
See Docs/DSC-Evaluation.md for a full evaluation of using the ActiveDirectoryDsc module (ADObjectPermissionEntry, ADGroup) to keep the role healthy via drift remediation. Short summary: a scheduled task running Deploy-GlobalReader.ps1 is simpler and equally effective for most environments; DSC is recommended only where DSC infrastructure is already in place.
- Deleted Objects container (requires specialized rights)
- Restricted groups / SAMR policy
- Auditing pre-existing permissive ACEs that may make this role redundant
- Cross-domain / forest-wide deployment (v3 candidate; see
Docs/V2-Decisions.md)