Everything works when the service account is a Domain Admin. Nothing works when you remove it. Here is the complete map of what sits in between — learned the hard way.
If you have ever tried to run a monitoring tool against Active Directory domain controllers with a least-privilege service account, you have probably lived this exact sequence: you create a dedicated account, you grant it what every blog post says to grant (a couple of Builtin groups, some WMI namespace rights), and you get Access denied. You add the account to Domain Admins "just to test" — everything works instantly. You remove it — everything breaks again.
At that point, most people leave the account in an admin group and move on. That is how monitoring service accounts become the most attractive lateral-movement targets in the forest: credentials that sit in a scheduled task, touch every DC daily, and hold domain-wide admin rights.
I refused to leave it there while building ADEHM, an agentless AD health monitor (PowerShell + CIM/WinRM). My domain controllers run a security baseline, and getting read-only monitoring to work on them took me through five distinct permission layers — most of which are undocumented, and two of which produce actively misleading symptoms. This article is the map I wish I had.
A note before we start: on a default, non-hardened installation, layers 1, 2 and 4 are usually already open. Hardening baselines (Microsoft Security Baseline, CIS, ANSSI and friends) close them. If your DCs are hardened, expect to need all five.
The mental model: five doors in a corridor
A remote CIM query from your monitoring host to a DC passes through five successive access checks. Each one produces its own flavor of "access denied", and — this is the trap — the error message rarely tells you which door refused you. The doors, in order:
- Network logon right — may the account authenticate to this machine over the network at all?
- WinRM root SDDL — may it talk to the WinRM service?
-
WMI namespace DACL — may it use the
root\cimv2namespace remotely? - Service Control Manager SDDL — may it enumerate Windows services?
- Per-service security descriptors — may it read this particular service?
Adding the account to Administrators opens all five at once, which is exactly why it "fixes" everything and teaches you nothing.
Layer 1 — Access this computer from the network
Before WinRM even looks at your request, Windows checks SeNetworkLogonRight. By default it includes Authenticated Users; hardening baselines restrict it on DCs to a short list (Administrators, ENTERPRISE DOMAIN CONTROLLERS...). Your service account gets turned away at the front door, before any of the layers you actually configured.
The telltale sign lives in the DC security log — reproduce the failure, then look for event 4625 and read its Status field:
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4625; StartTime=(Get-Date).AddMinutes(-10)} |
Where-Object { $_.Message -match 'svc-monitoring' } |
Select-Object -First 2 -ExpandProperty Message
0xC000015B means "the user has not been granted the requested logon type" — this layer, confirmed.
The fix belongs in the hardening GPO, not on the machines. Add your monitoring group to Computer Configuration → Policies → Windows Settings → Security Settings → Local Policies → User Rights Assignment → Access this computer from the network in the GPO applied to the Domain Controllers OU. A local change would be silently reverted at the next policy refresh — a pattern you will meet again in this article. While you are there, check that the account does not match anything in Deny access to this computer from the network: a Deny wins over everything.
Layer 2 — The WinRM RootSDDL
WinRM gates all remote management through a root security descriptor. On modern Windows Server the default contains (A;;GA;;;RM) — an allow entry for BUILTIN\Remote Management Users, so putting your group in that Builtin group is enough. (Handy fact: DCs have no local groups; their "local" groups are the domain Builtin groups, so one membership covers every DC.)
Hardened or upgraded-in-place systems, however, sometimes carry a stripped RootSDDL. Check yours:
Invoke-Command -ComputerName $dcs -ScriptBlock {
(Get-Item WSMan:\localhost\Service\RootSDDL).Value
}
If you see something like O:NSG:BAD:P(A;;GA;;;BA)(A;;GR;;;IU)S:... — Administrators and Interactive only, no RM — that is your blocker. Insert an ACE for your group's SID at the end of the DACL, just before the S: (SACL) marker:
$sid = (Get-ADGroup 'GRP-AD-Monitoring').SID.Value
Invoke-Command -ComputerName $dcs -ScriptBlock {
param($sid)
$current = (Get-Item WSMan:\localhost\Service\RootSDDL).Value
if ($current -match [regex]::Escape(";;;$sid)")) { return 'Already present' }
Set-Content "C:\Windows\Temp\RootSDDL_backup_$(Get-Date -Format yyyyMMdd_HHmmss).txt" -Value $current
$ace = "(A;;GA;;;$sid)"
$idx = $current.IndexOf('S:')
$new = if ($idx -ge 0) { $current.Insert($idx, $ace) } else { $current + $ace }
Set-Item WSMan:\localhost\Service\RootSDDL -Value $new -Force
'Updated'
} -ArgumentList $sid
(Why String.Insert instead of a regex replace: it behaves identically whether or not a SACL exists, and cannot silently do nothing. SDDL strings punish creativity.)
Same GPO caveat as layer 1: if a baseline manages this value, codify the change in the baseline or watch it evaporate every 90–120 minutes.
Layer 3 — The WMI namespace DACL
The layer everyone knows about, with two classic mistakes. Remote CIM access to root\cimv2 requires two rights on the namespace: Enable Account (0x1) and Remote Enable (0x20). The wmimgmt.msc UI makes it very easy to tick only Remote Enable — which yields the signature symptom "WinRM accepts the connection, then WMI returns access denied". The second mistake is setting the rights on root instead of root\cimv2 with "this namespace only" scope, which does not propagate.
Do not trust the UI; read what the DC actually enforces:
Invoke-Command -ComputerName $dcs -ScriptBlock {
(Invoke-WmiMethod -Namespace 'root/cimv2' -Path '__SystemSecurity=@' -Name GetSecurityDescriptor).Descriptor.DACL |
ForEach-Object {
[PSCustomObject]@{
Trustee = '{0}\{1}' -f $_.Trustee.Domain, $_.Trustee.Name
EnableAccount = [bool]($_.AccessMask -band 0x1)
RemoteEnable = [bool]($_.AccessMask -band 0x20)
}
}
}
Two implementation notes that cost me hours. First, invoke GetSecurityDescriptor/SetSecurityDescriptor through Invoke-WmiMethod on the __SystemSecurity=@ singleton path — calling the method directly on the object returned by Get-WmiObject fails on some systems with "does not contain a method named GetSecurityDescriptor". Second, an ACE with mask 0x21, AceType 0, no inheritance is all you need; the account stays strictly read-only.
An idempotent grant script (with -WhatIf and rollback) ships in the ADEHM repo under Tools/Grant-ADEHMWmiPermission.ps1.
Layer 4 — The Service Control Manager SDDL
Here is where hardened systems diverge sharply from lab machines. Your OS query now works (Win32_OperatingSystem returns data), but SELECT * FROM Win32_Service fails with access denied. The WMI service provider impersonates the caller and asks the Service Control Manager to enumerate services — and the SCM's own security descriptor, on hardened (and even many default) systems, does not grant enumeration to remote non-admin callers.
Check and fix, granting the same read set Windows gives interactive users (CCLCRPRC: connect, enumerate, query status, read control — no start, stop or modify):
$sid = (Get-ADGroup 'GRP-AD-Monitoring').SID.Value
Invoke-Command -ComputerName $dcs -ScriptBlock {
param($sid)
$current = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join ''
if ($current -match [regex]::Escape(";;;$sid)")) { return 'Already present' }
Set-Content "C:\Windows\Temp\scmanager_sddl_backup_$(Get-Date -Format yyyyMMdd_HHmmss).txt" -Value $current
$ace = "(A;;CCLCRPRC;;;$sid)"
$idx = $current.IndexOf('S:')
$new = if ($idx -ge 0) { $current.Insert($idx, $ace) } else { $current + $ace }
"$(& sc.exe sdset scmanager $new)"
} -ArgumentList $sid
Layer 5 — Per-service security descriptors
The subtlest one, because it fails silently. With the SCM open, enumeration succeeds — but each service then filters the caller through its own security descriptor. A service whose SD does not grant read to your account is not reported as denied; it is simply absent from the results. Your query for NTDS returns nothing, your monitoring tool concludes the service does not exist, and no error is raised anywhere.
I only caught this because my diagnostic script printed OK (NTDS = ) — an empty state — on two DCs while a third returned Running. Same domain, same baseline on paper; per-service SDs had drifted.
The fix mirrors layer 4, per service, with the interactive-users read set (CCLCSWLOCRRC):
& sc.exe sdshow NTDS # inspect
# insert (A;;CCLCSWLOCRRC;;;<SID>) before S: and apply with: sc.exe sdset NTDS <new-sddl>
For a whole list of monitored services across all DCs, idempotent and with backups, see Tools/Grant-ADEHMServiceReadPermission.ps1 in the repo.
Three operational rules that will save you a day each
Restart WinRM after any ACL change. WinRM keeps per-user host processes (WsmProvHost.exe) alive between sessions. A stale host process serves your requests with pre-change access state — meaning a correct fix keeps failing for hours, until the process idles out. I watched identical client PIDs in the WMI log across a 40-minute debugging window and chased ghosts the whole time. Restart-Service WinRM on the DCs after ACL work; the impact is a brief drop of active remote-management sessions, nothing more.
root\interop denials are a red herring. Once you enable the WMI-Activity log analysis (below), you will see 0x80041003 denials for connect to namespace : root\interop on every CIM session — including fully working ones. The WinRM WMI plugin probes that namespace and shrugs off the refusal. I initially granted rights on it; field testing proved it unnecessary. Least privilege says: do not grant what is not needed. Ignore these entries.
Test with explicit credentials, never with runas. Hardening baselines deny service accounts interactive logon — runas fails with error 1385, and that is correct behavior, not a bug to fix. Test the way your tool will actually run: New-CimSession -Credential, and Test-WSMan -Credential -Authentication Default (without -Authentication Default, Test-WSMan sends an unauthenticated probe that succeeds for any account and proves nothing). For scheduled tasks, the batch logon type applies: grant Log on as a batch job on the execution host only — never on the DCs.
The diagnostic that ends the guessing
The single most valuable tool in this whole journey is the DC-side WMI-Activity operational log, because it names the refusal instead of letting you infer it:
Get-WinEvent -LogName 'Microsoft-Windows-WMI-Activity/Operational' -MaxEvents 50 |
Where-Object { $_.Message -match '0x80041003' } |
Select-Object TimeCreated, Message
Each denial entry carries User, Namespace and Operation — the exact identity refused, on the exact object, doing the exact thing. One look at Operation = connect to namespace : root\cimv2 versus Start IWbemServices::ExecQuery ... Win32_Service tells you whether you are fighting layer 3 or layer 4. Combine it with the layered functional test (WinRM auth → CIM session → OS read → service read) and the 4625 status codes from layer 1, and no failure mode in this stack can hide from you. The full one-shot diagnostic script — functional chain, namespace DACLs, WMI denials and SCM SDDL captured at the same instant — is in the repo as Tools/Test-ADEHMPermission.ps1.
Closing thought
None of these five layers is exotic. Each is a legitimate, documented-somewhere Windows security boundary doing its job. What is missing from the ecosystem is the map — the knowledge that they stack, the order they trip in, and the misleading symptoms each produces. That gap is why so many monitoring service accounts end up in Domain Admins.
The complete tooling (grant scripts, rollback, one-shot diagnostic) lives in the ADEHM repository, MIT licensed: https://github.com/oussangelo/ADEHM — where the tool itself gives you agentless, read-only health monitoring of your DCs as a daily HTML report. If your environment trips a sixth door I have not met, open an issue; the map only gets better with more territory.
Top comments (0)