Automating Azure Entra ID: Users, Groups, and Apps with Ansible
TL;DR Ansible can fully automate Azure Entra ID (formerly Azure AD) via the azure.azcollection: …
community.windows.win_domain_user, community.windows.win_domain_group, and community.windows.win_domain_ou—including CSV-based bulk onboarding and offboarding playbook with audit log.polycrate workspace sync: A clean audit trail for compliance requirements like GDPR, effective since 25.05.2018.If you manage a classic Active Directory today, the daily routine often looks like this:
This works as long as you have few changes and the same two people do everything. As soon as teams grow, audits come in, or multiple locations arise, it becomes difficult:
With plain Ansible, some things improve—you get idempotency, understandable YAML playbooks, and a powerful ecosystem. But:
community.windows) on each machine.This is where Polycrate comes in.
Polycrate turns your Ansible playbooks into versioned, shareable blocks and solves several typical problems:
community.windows) are defined in the container image. No local Ansible, no Python chaos, less supply chain risk. More on the Ansible integration of Polycrate.from: (see the workspace example below).polycrate workspace sync (see Git integration of Polycrate).We will now build a workspace with an AD management block that:
First, we create the workspace acme-corp-automation and define our AD block and inventory.
name: acme-corp-automation
organization: acme
blocks:
- name: ad-mgmt
from: registry.acme-corp.com/acme/infra/ad-mgmt:1.0.0
config:
domain_name: "acme-corp.com"
default_ou: "OU=Users,DC=acme-corp,DC=com"from: holds the full OCI registry reference including the version tag at the end of the line (same idea as container images); there is no separate version field under the block instance. The block package’s block.poly uses the same logical name (ad-mgmt). The registry registry.acme-corp.com is a fictional example (not ayedo’s product registry). If the block is missing locally, Polycrate can pull it on the first polycrate run …; unpacked it lives under blocks/registry.acme-corp.com/acme/infra/ad-mgmt/. See also Multi-server management with Ansible inventories.
More about workspaces: Polycrate Workspaces.
Polycrate expects a YAML inventory in the workspace root as inventory.yml. All hosts live under all.hosts; under children, the domain_controllers group references them by name only—shared WinRM settings go in the group vars. No passwords in the inventory—set ansible_password in playbooks from secrets.poly / workspace.secrets.* (see Workspace Encryption). For a domain controller dc01.acme-corp.com, it might look like this:
all:
hosts:
dc01.acme-corp.com: {}
children:
domain_controllers:
hosts:
dc01.acme-corp.com:
vars:
ansible_user: Administrator
ansible_connection: winrm
ansible_port: 5986
ansible_winrm_transport: credssp
ansible_winrm_server_cert_validation: ignoreImportant:
secrets.poly and are wired in playbooks as variables—not as plaintext in inventory.yml.polycrate workspace encrypt (see Workspace Encryption).WinRM password for playbooks: Store e.g. ad_dc_password in secrets.poly; in every play against domain_controllers, add under vars: ansible_password: "{{ workspace.secrets.ad_dc_password }}" (the examples below include this).
Our block blocks/registry.acme-corp.com/acme/infra/ad-mgmt/block.poly (after pulling from the registry) encapsulates all AD activities:
name: ad-mgmt
version: 0.1.0
kind: generic
config:
domain_name: "acme-corp.com"
default_ou: "OU=Users,DC=acme-corp,DC=com"
users_csv: "users.csv"
audit_log: "audit.log"
actions:
- name: setup_ou_and_groups
playbook: setup_ou_and_groups.yml
- name: onboarding
playbook: onboarding.yml
- name: offboarding
playbook: offboarding.ymlconfig.* values can be overridden in the workspace if needed.blocks/…). Runtime data such as CSV and audit log do not go inside the block; place them under artifacts/blocks/<block-instance-name>/ in the workspace—e.g. artifacts/blocks/ad-mgmt/users.csv. In Ansible, block.artifacts.path points to that directory (in the container: /workspace/artifacts/blocks/<name>/).Further details on blocks can be found in the Polycrate Blocks Docs.
Create the file in the workspace at artifacts/blocks/ad-mgmt/users.csv (not under the pulled block in blocks/…). Polycrate exposes the path in playbooks as {{ block.artifacts.path }} (same as artifacts/blocks/ad-mgmt/ in the workspace).
username;givenName;sn;password;enabled;ou_dn;groups
jdoe;John;Doe;Start123!;true;OU=IT,OU=Users,DC=acme-corp,DC=com;IT-Admins,VPN-Users
mroe;Maria;Roe;Start123!;true;;Sales,VPN-Usersou_dn is optional—if missing, we use default_ou.groups is a comma-separated list.win_domain_user and win_domain_groupThe onboarding consists of two plays:
# blocks/registry.acme-corp.com/acme/infra/ad-mgmt/onboarding.yml
- name: Load users from CSV in Polycrate container
hosts: localhost
gather_facts: false
vars:
users_csv: "{{ block.artifacts.path }}/{{ block.config.users_csv }}"
tasks:
- name: Read users from CSV
community.general.read_csv:
path: "{{ users_csv }}"
delimiter: ";"
register: csv_users
- name: Normalize user list
ansible.builtin.set_fact:
ad_users: "{{ csv_users.list }}"
- name: Create OUs and groups on domain controllers
hosts: domain_controllers
gather_facts: false
vars:
ansible_password: "{{ workspace.secrets.ad_dc_password }}"
domain_name: "{{ block.config.domain_name }}"
default_ou: "{{ block.config.default_ou }}"
tasks:
- name: Ensure base OU exists
community.windows.win_domain_ou:
name: "{{ default_ou }}"
state: present
- name: Ensure department OUs from CSV exist
community.windows.win_domain_ou:
name: "{{ item.ou_dn }}"
state: present
loop: "{{ hostvars['localhost'].ad_users | map(attribute='ou_dn') | list | unique }}"
when: item.ou_dn is defined and item.ou_dn | length > 0
- name: Collect all group names from CSV
ansible.builtin.set_fact:
all_groups: >-
{{ hostvars['localhost'].ad_users
| map(attribute='groups')
| select('defined')
| map('split', ',')
| sum(start=[])
| map('trim')
| reject('equalto', '')
| list }}
run_once: true
- name: Ensure groups exist
community.windows.win_domain_group:
name: "{{ item }}"
scope: global
state: present
loop: "{{ all_groups | default([]) }}"
run_once: true
- name: Ensure AD users exist and are in the right groups
community.windows.win_domain_user:
name: "{{ item.username }}"
upn: "{{ item.username }}@{{ domain_name }}"
given_name: "{{ item.givenName }}"
surname: "{{ item.sn }}"
password: "{{ item.password }}"
enabled: "{{ (item.enabled | default('true')) | bool }}"
path: "{{ item.ou_dn | default(default_ou) }}"
state: present
groups: >-
{{ (item.groups | default(''))
| split(',')
| map('trim')
| reject('equalto', '')
| list }}
loop: "{{ hostvars['localhost'].ad_users }}"
run_once: trueWhat happens here?
community.general.read_csv runs on localhost in the Polycrate container. It does not modify target systems, so hosts: localhost is correct here.community.windows.win_domain_ou, we first create a base OU and then possibly additional OUs from the CSV data.community.windows.win_domain_group creates all groups referenced in the CSV.community.windows.win_domain_user creates or updates users idempotently, including OU, password, status, and groups.With plain Ansible without Polycrate, you would need to:
community.windows is equally available everywhere.With Polycrate, a structured block and a reproducible container are sufficient.
From the workspace root:
cd ~/workspaces/acme-corp-automation
polycrate run ad-mgmt onboardingPolycrate takes care of:
For offboarding, you want to:
# blocks/registry.acme-corp.com/acme/infra/ad-mgmt/offboarding.yml
- name: Disable AD user and clean up group memberships
hosts: domain_controllers
gather_facts: false
vars:
ansible_password: "{{ workspace.secrets.ad_dc_password }}"
domain_name: "{{ block.config.domain_name }}"
disabled_ou: "OU=Disabled Users,DC=acme-corp,DC=com"
username: "{{ username }}"
tasks:
- name: Ensure OU for disabled users exists
community.windows.win_domain_ou:
name: "{{ disabled_ou }}"
state: present
- name: Disable user and move to disabled OU
community.windows.win_domain_user:
name: "{{ username }}"
upn: "{{ username }}@{{ domain_name }}"
enabled: false
path: "{{ disabled_ou }}"
state: present
- name: Remove user from sensitive groups
community.windows.win_domain_group:
name: "{{ item }}"
state: present
members:
- "{{ username }}"
members_state: absent
loop:
- "IT-Admins"
- "Domain Admins"
- "VPN-Users"
- name: Write audit log entry in Polycrate workspace
hosts: localhost
gather_facts: false
vars:
audit_log: "{{ block.artifacts.path }}/{{ block.config.audit_log }}"
tasks:
- name: Append audit log entry
ansible.builtin.lineinfile:
path: "{{ audit_log }}"
line: "{{ lookup('ansible.builtin.pipe', 'date -u +%Y-%m-%dT%H:%M:%SZ') }};{{ username }};offboarded"
create: yes
insertafter: EOFNotes:
localhost) and writes to audit.log under {{ block.artifacts.path }} (workspace: artifacts/blocks/ad-mgmt/). That file is part of the workspace and is versioned and encrypted with it.Pass the user via an extra variable:
cd ~/workspaces/acme-corp-automation
polycrate run ad-mgmt offboarding -- -e "username=jdoe"The -- separator splits Polycrate arguments from those passed to ansible-playbook.
The real compliance strength comes from combining:
After an onboarding or offboarding run:
users.csv may have changed (e.g. new users).audit.log may have a new entry.Keep the workspace in sync with Git, for example:
cd ~/workspaces/acme-corp-automation
polycrate workspace syncPolycrate:
Details: Git integration of Polycrate.
The compliance advantage over GUI clicks or loose PowerShell:
audit.log adds a human-readable overview (“when was which user offboarded?”).That is gold in audits or forensic analysis.
You develop the block in the workspace under the registry-compatible directory that matches your from: reference—for example blocks/registry.acme-corp.com/acme/infra/ad-mgmt/ (registry host and path as in the OCI reference without the version tag), not a short path like ./blocks/ad-mgmt/. Then you publish it to your OCI registry or PolyHub—other workspaces consume it with a full from: reference and version tag, as in the first workspace.poly example. The same directory layout applies to the Polycrate cargo registry (blocks/cargo.ayedo.cloud/…). Alternatively (or in addition), use cargo in from:—for example:
blocks:
- name: ad-mgmt
from: cargo.ayedo.cloud/acme/infra/ad-mgmt:0.1.0
config:
domain_name: "acme-corp.com"
default_ou: "OU=Users,DC=acme-corp,DC=com"This means:
0.2.0) without breaking existing workspaces.More: Registry and PolyHub.
No. Polycrate ships a container image with Ansible, Python, and required collections such as community.windows already installed. You only run polycrate run ...; Polycrate starts the container, mounts the workspace, and runs ansible-playbook inside it.
Benefits:
The workspace can be encrypted with age via polycrate workspace encrypt. That lets you store files such as:
secrets.poly and encrypted artifacts (AD credentials, no plaintext passwords in inventory.yml)encrypted in the Git repository. Only people with the right keys can decrypt the workspace.
Details: Workspace encryption.
Yes. Via blocks.config in workspace.poly you can set different parameters per workspace (or environment), e.g.:
domain_namedefault_ouFor test and production, create separate workspaces that reference the same block from the registry but use different configuration and inventories. The logic stays the same while targets and data vary per environment.
More questions? See our FAQ.
With the AD block shown here you have taken an important step:
polycrate workspace sync, and the audit log create auditable evidence for audits, pen tests, or internal controls.We help organizations build automations like this systematically—from the first AD action to whole landscapes of standardized Polycrate blocks. Whether you want to improve onboarding/offboarding only or establish broader policy-as-code for Active Directory, we bring platform engineering, Windows, and compliance experience together.
If you want to explore how this could look in your environment and which extensions (e.g. automatic GPO mapping or HR integration) make sense, a joint review of your requirements is a good next step:
TL;DR Ansible can fully automate Azure Entra ID (formerly Azure AD) via the azure.azcollection: …
TL;DR You build a reusable Polycrate workflow that automatically executes backup → update → verify …
TL;DR Ansible is a strong foundation: agentless, idempotent, human-readable YAML, and a vast module …