Skip to content

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):

Get-ADForest | Set-ADForest -UPNSuffixes @{Add = "devhome.cloud" }

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 LocalState folder (where settings.json lives) 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 via Add-AppxPackage -DisableDevelopmentMode -Register against 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.