On-Prem Active Directory
Domain controller
ad.devhome.cloud's domain controller is the dc VM, provisioned by
terraform/vsphere/compute/ad-dc.tf and promoted via domain_controller_setup.ps1.tftpl
(Install-ADDSForest, DSRM password from Vault).
The forest's own DNS name (ad.devhome.cloud) becomes the default UPN suffix
automatically — not what you want for SSO, since the verified Entra ID custom domain is
devhome.cloud, not ad.devhome.cloud. domain_controller_setup.ps1.tftpl adds the
additional suffix right after Install-ADDSForest (before the post-promotion reboot,
which is exactly why -NoRebootOnCompletion is there — it leaves room for this):
Every user account needs -UserPrincipalName "user@devhome.cloud" set explicitly at
creation time (ADUC's suffix dropdown, New-ADUser, or
create_ad_users.ps1.tftpl/ad_user_accounts var) — the additional suffix doesn't
become the default, it just becomes available.
AD security groups and users
terraform/vsphere/compute/scripts/win/dc/create_ad_groups.ps1.tftpl and
create_ad_users.ps1.tftpl, both driven by variables.tf and both idempotent —
safe to re-run any time a group/member is added, and they only ever add, never remove
(day-to-day membership changes can also be made directly in ADUC).
variable "ad_security_groups" {
type = map(list(string))
default = {
app-argocd-admins = ["psharma", "testadmin"]
app-rancher-admins = ["psharma"]
app-vault-users = ["psharma"]
app-openvpn-users = ["psharma"]
app-smb-admins = ["psharma"]
app-vcenter-admins = ["psharma"]
app-rdp-users = ["psharma"]
}
}
Groups land in the default Users container — the whole domain is Cloud Sync scoped, so
no dedicated OU is needed for them to sync up to Entra ID. Every one of these groups now
has a real consumer:
| Group | Consumed by |
|---|---|
app-argocd-admins |
ArgoCD SSO |
app-rancher-admins |
Rancher SSO |
app-vault-users |
Vault SSO |
app-vcenter-admins |
VMware — Identity, SCIM & Automation |
app-rdp-users |
RDP/local-admin Restricted Groups GPO, below |
app-smb-admins |
TrueNAS Shares Access |
app-openvpn-users |
pfSense OpenVPN LDAP auth (ansible/pfsense-openvpn-ldap-playbook.yml) |
create_ad_users.ps1.tftpl creates accounts with a devhome.cloud UPN (not the
forest's own ad.devhome.cloud) and a temporary password forcing a change at next
logon — used for one-off test accounts (testadmin/testnobody, proving group-gated
access works both positively and negatively), not for psharma itself (see below).
RDP and local-admin access via a Restricted Groups GPO
Domain users get neither Remote Desktop logon rights nor local admin by default
after a plain domain join, and none of the app-*-admins groups above govern local
machine access — so rdp_access_gpo.ps1.tftpl creates/updates a GPO that adds
app-rdp-users to two local groups on every domain-joined machine: Remote Desktop
Users and Administrators.
The local-admin grant specifically exists so psharma can run Setup.ps1 -PostReboot
(win11-devhome's own post-domain-join provisioning script) under their own account — that
script has #Requires -RunAsAdministrator, which Remote Desktop Users membership alone
can't satisfy no matter what WinRM permissions are separately granted.
Hand-authored directly against Restricted Groups (GptTmpl.inf) rather than through the
GroupPolicy PowerShell module, which has no cmdlet for this specific policy area.
Missing * prefix on a SID silently drops that one line
Every principal reference in a Restricted Groups [Group Membership] section —
including the "key" naming which principal the line is even about — needs a leading
* to be recognized as a literal SID rather than a (nonexistent) account name.
Confirmed 2026-07-25: a line missing that prefix was silently skipped by Windows,
while gpresult still reported the GPO as "applied" — no error anywhere pointing at
the actual cause.
gpupdate can decide there's nothing new to reprocess, even after a real change
gpupdate /force can determine the Security Settings CSE specifically has "no
changes" for this GPO — skipping Restricted Groups reprocessing entirely — even when
the GPO's version genuinely incremented and SYSVOL's content is correct. Visible via
Microsoft-Windows-GroupPolicy/Operational naming other GPOs as changed, but not
this one, right after a version bump. Root cause not fully understood.
Fix, not fully explained but reliable: ensure_rdp_gpo_applied.ps1.tftpl runs on
win11-devhome itself (as Administrator) after every domain join, forces gpupdate
/target:computer /force, then verifies app-rdp-users actually landed in the local
Administrators group — falling back to applying the exact same Restricted Groups
content directly via secedit /configure ... /areas GROUP_MGMT if gpupdate didn't
take effect. This bypasses GPO change-detection entirely, so it works regardless of
whether the caching quirk above is in play on a given run.
Can't read GptTmpl.inf back from SYSVOL to verify it — WinRM's double-hop
A domain-joined machine's own WinRM session can't read a UNC path on a third
machine (here, the DC's SYSVOL) — NTLM/WinRM credentials aren't delegated to a
second network hop. ensure_rdp_gpo_applied.ps1.tftpl avoids this entirely by
resolving the AD group's SID locally via
([System.Security.Principal.NTAccount]"DEVHOME\app-rdp-users").Translate(...)
instead of reading the policy file back from the DC.
Shared LDAP bind account
create_ldap_bind_account.ps1.tftpl creates svc-ldap-bind — a read-only,
non-interactive, never-expiring-password account with no elevated group membership
(default AD permissions already let any authenticated account bind and read standard
user/group attributes, which is all a search-only LDAP client needs). Its password is
generated and published by terraform/vault/ldap-service-account.tf, excluding a
handful of characters some LDAP clients have historically mishandled in bind passwords.
Two consumers share this one account rather than each getting their own:
- pfSense's OpenVPN LDAP authentication server (
ansible/pfsense-openvpn-ldap-playbook.yml) - vCenter's classic "Active Directory over LDAP" identity source — see VMware — Identity, SCIM & Automation
psharma's Vault-managed AD password
reset_psharma_ad_password.ps1.tftpl resets the real psharma AD account's password to
a value generated and stored in Vault (devhome/vmware's psharma_ad_password), run
against the DC by a null_resource in ad-dc-post.tf. Unlike create_ad_users.ps1.tftpl
(which forces a change at next logon for brand-new temp accounts), this does not
force a change — the whole point is for the password to keep working for repeated
automated use, not a single human login. Uses -Reset (not the old password) since it
runs as a Domain Admin-equivalent account resetting someone else's password.
This isn't a one-time fix — the null_resource's triggers re-run it automatically
whenever either the DC is rebuilt/replaced (a fresh AD forest has no memory of the old
password) or psharma_ad_password is rotated in Vault, so a future rotation gets pushed
to AD on the next terraform apply with no manual step.
The reason this needs to be Vault-managed at all: Terraform's own WinRM client couldn't reliably authenticate directly as a domain account (see below) — the fix routes around that using a known-good, automation-owned psharma credential.
Automating a Windows workstation after domain join (win11-devhome)
terraform/vsphere/compute/win11.tf domain-joins win11-devhome via clone.customize,
then runs a multi-phase post-join sequence — the same app-rdp-users GPO above is what
makes phase 2 possible at all, since it's the only thing granting psharma local admin.
| Phase | Runs as | What it does |
|---|---|---|
| 1 | Administrator (WinRM) | Setup.ps1 — enables WSL2/Hyper-V features, schedules a reboot |
| 2a | Administrator (WinRM) | ensure_rdp_gpo_applied.ps1.tftpl — the GPO self-heal described above |
| 2b | Administrator (WinRM), then psharma via loopback | run_psharma_phase2.ps1.tftpl — Setup.ps1 -PostReboot: VSCode settings/extensions, Windows Terminal settings, WSL/Ubuntu install |
Terraform's WinRM client couldn't reliably authenticate as a domain account
A remote-exec connection block authenticating directly as DEVHOME\psharma
failed with 401 - invalid content type for the entire 30-minute connection retry
window, on two separate live rebuilds — despite the identical connection succeeding
instantly when tested manually right after each failure, and psharma's local admin
membership already confirmed correct. Every other connection in win11.tf
authenticates as the local Administrator account and has been 100% reliable.
Fix: run_psharma_phase2.ps1.tftpl runs under Terraform's already-reliable
Administrator connection, then uses standard PowerShell loopback remoting —
Invoke-Command -ComputerName $env:COMPUTERNAME -Credential <psharma> — to actually
execute Setup.ps1 -PostReboot as psharma. Terraform's own WinRM client never
authenticates externally as a domain account at all.
winget doesn't work reliably in non-interactive WinRM/remoting sessions
Confirmed for both Windows Terminal settings and WSL install — winget calls failed identically in psharma's loopback session even with PATH fully resolved, consistent with winget's documented need for a genuine interactive desktop session for some operations.
- Windows Terminal: ships pre-installed machine-wide, but its per-user
LocalStatefolder (wheresettings.jsonlives) is only created by a per-user AppX registration step that normally happens on first interactive launch — a session that's never interactively logged on (psharma, via remoting) never triggers it. Fixed viaAdd-AppxPackage -DisableDevelopmentMode -Registeragainst the already-provisioned package's own manifest — a pure local operation, no winget/Store/download needed. - WSL: has no equivalent AppX package to re-register at all
(
Get-AppxPackage -AllUsers | Where-Object Name -match "WSL|Linux"returns nothing even when WSL is genuinely installed and working) — see the next gotcha instead.
wsl.exe --version can falsely report \"not installed\" right after this machine's own reboot
Confirmed on two separate clean rebuilds: soon after the reboot Bootstrap's own
WSL2/Hyper-V feature enablement triggers, wsl --version reports WSL as absent, even
though the identical check succeeds when run standalone later (more wall-clock time
elapsed) or from Administrator's own session. Points at the WSL platform component
needing genuine settling time post-reboot, not a per-session/per-user
incompatibility. Fixed with a patient retry (10 attempts, 15s apart) before
concluding WSL is genuinely absent — still well within the overall provisioner
timeout. Also: wsl --install --no-distribution bootstraps a genuinely fresh WSL
platform; wsl --update only works on an already-installed one and fails outright
on a truly clean machine.
Where the scripts live
terraform/vsphere/compute/scripts/win/ is split by which VM actually runs each script
— each VM's file provisioner copies only its own subdirectory, not the whole tree:
| Subdirectory | Runs on |
|---|---|
dc/ |
The domain controller — everything on this page |
win11/ |
win11-devhome — the GPO self-heal and psharma Phase 2 scripts above |
k8s/ |
Windows Kubernetes worker nodes (terraform/vsphere/modules/k8s) — unrelated to AD |
Summary
What's actually automated on-prem in ad.devhome.cloud: the domain controller itself,
AD security groups/users, and — the newest piece — using those same groups to gate
local Windows access (RDP, local admin) on every domain-joined machine, not just
app-level SSO. See Entra for how these groups get carried up
to Microsoft Entra ID, and My Apps for how
Entra-side app access ties back to them.