Skip to content

Active Directory Playbooks

The AD lifecycle playbooks manage objects in Active Directory — OUs, computer objects, security groups, and gMSAs. All of them are idempotent and safe to re-run.

Playbook Purpose
playbooks/create-ad-ous.yml Creates the standard OU tree (OU=<ad_ou_root> containing Servers with one child OU per application, ServiceAccounts, and Groups), deriving the per-application server OUs from the inventory's tag-derived application variable. Also links the AADDS default AADDC Computers GPO to OU=Servers (see below).
playbooks/ensure-computer-ou.yml Moves existing computer objects into the OU given by each host's computer_ou inventory variable. Needed because domain join only honors the OU when it creates the computer account — re-joining a rebuilt VM reuses the old object wherever it currently sits.
playbooks/create-gmsa.yml Creates security groups and gMSAs from gmsa_definitions, then purges the Kerberos ticket cache on member hosts in the play so the new group membership applies without a reboot — see the gMSA usage guide.
playbooks/create-ad-groups.yml Ensures plain AD security groups exist (create-if-absent) from ad_security_groups, in OU=Groups by default. Membership is only touched when an entry supplies members (add-if-absent). Companion to create-gmsa.yml for RBAC groups — e.g. a group granted access in Kuiper via kuiper_security_groups[].domainGroups.
playbooks/create-ad-users.yml Ensures plain AD service accounts exist (create-if-absent) from ad_service_accounts, in OU=ServiceAccounts by default — see Service accounts.
playbooks/remove-computer-ad.yml Removes computer objects: -e computers=<name> or -e '{"computers": ["name1", "name2"]}'. The computers being removed do not need to be reachable, in the play, or even exist any more.

AADDS attaches its default AADDC Computers GPO only to the built-in AADDC Computers OU. That GPO is what grants AAD DC Administrators local Administrator rights on domain-joined machines — AADDS otherwise seeds local Administrators with only Domain Admins, a group no AADDS account can belong to. Because machines here join into OU=<application>,OU=Servers,OU=<ad_ou_root> (our own tree, outside AADDC Computers), they would never receive that grant.

create-ad-ous.yml therefore links the AADDC Computers GPO to OU=Servers, from which it inherits down to every application child OU. Kuiper's deploy credential (AADDS\AAD DC Administrators) then becomes a local Administrator on each managed machine natively, which is required for it to open PSSessions for certificate and package deployment. The link is written directly as the OU's gPLink attribute with microsoft.ad.object, over the same LDAP bind the OU-creation tasks use — deliberately not through GPMC / microsoft.ad.gpo with become: runas. Driving it as the domain admin via runas fails before the hosts are joined: the local-logon right runas needs is exactly the one this GPO grants, and pre-join the account cannot even be translated to a SID on the target. Machines pick up the grant on the reboot that domain join performs, so the OU tree and this link must exist before hosts are joined.

This GPO is specific to AADDS. On non-AADDS Active Directory (on-prem, AWS Managed AD) it does not exist, so the playbook looks it up first and skips the link when it is absent rather than failing — those environments grant the deploy credential local admin by other means.

The AD management host (ad_management_host)

All AD operations in these playbooks are delegated to a single inventory host, ad_management_host, rather than running on the play's own targets. The management host must be a Windows host with network line of sight to the domain controllers, and the playbooks install the RSAT-AD-PowerShell feature on it automatically. It does not need to be domain-joined when explicit domain credentials are supplied; it does when they are omitted in favour of the Kerberos path described under Credentials.

Default

The default is defined once, in group_vars/all/ad.yml:

ad_management_host: "{{ groups['app_kuiper'] | default([]) | first | default('') }}"

That is: the first Kuiper server, taken from the tag-derived app_kuiper group (see Application & Environment Groups). There is deliberately no fallback — an environment without a Kuiper server fails fast with an explicit message instead of delegating to an arbitrary Windows host that may not be able to reach the DCs.

Overriding

  • One-off: -e ad_management_host=<inventory_hostname> on the command line.
  • Per environment: set ad_management_host in a group or host var — group and host vars take precedence over the group_vars/all default.

Removing the management host itself

When the computer object being removed is the default management host (e.g. rebuilding the Kuiper VM), pin the delegation to a different Windows host:

ansible-playbook -e computers=azcusikpr001 -e ad_management_host=azcusihsw001 playbooks/remove-computer-ad.yml

Service accounts (ad_service_accounts)

create-ad-users.yml creates plain domain users — the accounts an application binds to AD with, such as System Pulse's LDAP bind account. Each entry needs a name (used as the sAMAccountName, CN, and UPN prefix) and a password, and may set description and path. An entry with create: false instead references an account managed outside this playbook — it is asserted to exist and never created or modified (no password needed), for environments where the account is pre-provisioned by the customer's AD team:

ad_service_accounts:
  - name: svc-systempulse
    password: "{{ system_pulse_ldap_bind_password }}"
    path: OU=ServiceAccounts,OU=IRE,DC=aadds,DC=ire-copier,DC=sapphirehealth,DC=org
    description: System Pulse LDAP bind account.

path defaults to OU=ServiceAccounts under ad_ou_root. On AADDS the built-in Users container is read-only, so the account must land in a custom OU — the one create-ad-ous.yml creates.

The variable is read per host, so each application group declares the accounts it needs in its own group_vars (the example above lives in group_vars/ire_copier_azure_app_systempulse/vars.yml, next to the vaulted password it references). Hosts that do not define it are skipped.

Accounts are created with password_never_expires and user_cannot_change_password, and the password is applied only at creation — re-running never rewrites an existing account's password.

Credentials

The playbooks resolve the domain and admin credentials from domain_name / domain_admin_user / domain_admin_password (normally vaulted — see Secret Management), falling back to the DOMAIN_NAME / DOMAIN_ADMIN_USER / DOMAIN_ADMIN_PASSWORD environment variables. A domain controller is discovered automatically via a DNS SRV lookup against the domain.

Only the domain and the domain controller are mandatory. When domain_admin_user and domain_admin_password are both unset, the playbooks omit them and the microsoft.ad modules bind to AD as the identity the connection to the management host is already running under — the Kerberos path, for a management host reached over WinRM with Kerberos authentication and credential delegation. Set both or neither; supplying only one fails the pre-flight assertion rather than the module.

To run this way, connect with Kerberos instead of NTLM: leave server_admin_user and server_admin_password unset so no explicit connection credential is built, set ansible_psrp_auth: kerberos, and make sure the controller has a valid ticket. playbooks/configure-ansible-for-kerberos-auth.yml writes the realm config from the domain's SRV records (a repo-relative krb5.conf by default, or /etc/krb5.conf with -e krb5_conf_path=/etc/krb5.conf -e krb5_conf_become=true), and playbooks/configure-winrm-kerberos.yml registers the WSMAN SPNs on the target. The delegated identity needs the same directory rights the domain admin account would have used, so this is an alternative to storing a domain admin password, not a way to run these playbooks with fewer privileges.