Automating Active Directory: Users, Groups, and OUs with Ansible
TL;DR Active Directory changes via GUI or non-versioned PowerShell scripts are error-prone, hard to …
azure.azcollection: users, groups, app registrations, and even documentation of your Conditional Access Policies.For Windows admins and Azure admins, the center of identity management has long shifted from classic on-prem AD to Azure Entra ID. Users, groups, and app registrations are increasingly managed directly there – often still via portal clicks.
This works until one of the following situations occurs:
Automation with Ansible solves exactly these problems:
With Polycrate, this automation is not a loose collection of playbooks on multiple laptops but a structured workspace, including encrypted secrets and a reproducible container environment.
Before we handle users and groups in Entra ID, it’s worth looking at the “how.”
You probably know the classic Ansible problem for Azure:
azure.azcollection pulls in half an Azure SDK landscape.Polycrate removes this pain:
Dockerfile.poly – always from the official base image (see below).azure.azcollection) in that layer; Python and ansible-core come from the base image.Details can be found in the Ansible Integration and the Best Practices.
A Dockerfile.poly must always extend the official Polycrate image (cargo.ayedo.cloud/library/polycrate), not a bare python: image: Polycrate relies on the bundled toolchain, mounts, and environment. A plain Python image will not work as the action runtime. See The Polycrate Container.
Install additional collections in a thin layer on top. A minimal example:
FROM cargo.ayedo.cloud/library/polycrate:latest
# requirements.yml for collections (e.g. next to Dockerfile.poly in the workspace)
COPY requirements.yml /workspace/requirements.yml
# Install Azure Collection (Ansible is already in the Polycrate base image)
RUN ansible-galaxy collection install -r /workspace/requirements.yml
WORKDIR /workspaceThe corresponding requirements.yml:
---
collections:
- name: azure.azcollection
version: 1.18.0This makes azure.azcollection available in the Polycrate container for all blocks – no local installation needed.
We use the example setup acme-corp-automation and create a dedicated block for Entra ID.
name: acme-corp-automation
organization: acme
blocks:
- name: entra-id-mgmt
from: registry.acme.corp/blocks/entra-id-mgmt:0.1.0
config:
default_domain: "acme-corp.com"
azure_credentials_file: "artifacts/secrets/azure-sp.json"Important:
from: uses registry-style block notation (<registry>/<path>:<version>). registry.acme.corp is a fictitious example; in practice you use polycrate blocks pull … or your own OCI registry.workspace.poly and block.poly have no Jinja2 – azure_credentials_file is a literal path to the file under artifacts/secrets/ (available decrypted in the container). Ansible playbooks still use {{ block.config.azure_credentials_file }}.In the block directory blocks/entra-id-mgmt, we create a block.poly:
name: entra-id-mgmt
version: 0.1.0
kind: ansible
config:
default_domain: ""
azure_credentials_file: ""
actions:
- name: users
description: "Manage users in Entra ID"
playbook: users.yml
- name: groups
description: "Manage groups and memberships"
playbook: groups.yml
- name: apps
description: "Manage app registrations and service principals"
playbook: apps.yml
- name: policies
description: "Document Conditional Access Policies (read-only)"
playbook: policies.ymlUnder config: you only list the expected keys (here with empty defaults); there is no Jinja2 in .poly files. Set concrete values on the block instance in workspace.poly (see above).
This provides clear guardrails:
polycrate run entra-id-mgmt users instead of complex Ansible CLI calls.More about blocks and actions can be found under Blocks and Actions.
For Entra ID automation, we need a dedicated Service Principal.
Depending on what you automate, you need different Entra roles. A common setup:
User AdministratorGroups AdministratorApplication Administrator or more finely grained app registration permissionsBest Practice:
Create the file artifacts/secrets/azure-sp.json in the workspace:
{
"client_id": "00000000-0000-0000-0000-000000000000",
"client_secret": "SUPER-SECRET-VALUE",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"subscription_id": "22222222-2222-2222-2222-222222222222"
}Configure this file via secrets.poly as a secret to be encrypted (details in the Workspace Encryption) and then encrypt the workspace:
polycrate workspace encryptWith this:
artifacts/secrets/azure-sp.json).Now it gets concrete: We manage Entra ID users with azure.azcollection.azure_rm_aduser.
The file blocks/entra-id-mgmt/users.yml:
---
- name: Manage users in Entra ID
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_file: "{{ block.config.azure_credentials_file }}"
default_domain: "{{ block.config.default_domain }}"
tasks:
- name: Load Azure Service Principal Credentials
ansible.builtin.include_vars:
file: "{{ azure_credentials_file }}"
name: azure
- name: Create or update user
azure.azcollection.azure_rm_aduser:
state: present
user_principal_name: "alice@{{ default_domain }}"
display_name: "Alice Example"
password: "InitialPassw0rd!"
account_enabled: true
force_password_change: true
tenant: "{{ azure.tenant_id }}"
client_id: "{{ azure.client_id }}"
secret: "{{ azure.client_secret }}"
auth_source: client_secret
register: alice_result
- name: Disable user (example)
azure.azcollection.azure_rm_aduser:
state: present
user_principal_name: "bob@{{ default_domain }}"
display_name: "Bob Example"
account_enabled: false
tenant: "{{ azure.tenant_id }}"
client_id: "{{ azure.client_id }}"
secret: "{{ azure.client_secret }}"
auth_source: client_secret
- name: Delete user (example)
azure.azcollection.azure_rm_aduser:
state: absent
user_principal_name: "temp.user@{{ default_domain }}"
tenant: "{{ azure.tenant_id }}"
client_id: "{{ azure.client_id }}"
secret: "{{ azure.client_secret }}"
auth_source: client_secret
- name: Output brief summary
ansible.builtin.debug:
msg: "User management completed. Alice change: {{ alice_result.changed }}"Important:
hosts: localhost and connection: local are correct here – the playbook runs in the Polycrate container and communicates with the Azure APIs, not an SSH host.block.poly or the playbook.Executing the action:
polycrate run entra-id-mgmt usersWith plain Ansible, you would need to ensure that locally:
azure.azcollection, and all dependencies are installed.With Polycrate, it’s enough to clone the workspace, use polycrate run, and everything runs in the defined container.
Groups are the heart of permissions in Entra ID. The principle is the same: declaratively describe which groups exist and which members belong to them.
---
- name: Manage groups in Entra ID
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_file: "{{ block.config.azure_credentials_file }}"
default_domain: "{{ block.config.default_domain }}"
tasks:
- name: Load Azure Service Principal Credentials
ansible.builtin.include_vars:
file: "{{ azure_credentials_file }}"
name: azure
- name: Ensure security group "HR-Staff"
azure.azcollection.azure_rm_adgroup:
state: present
display_name: "HR-Staff"
mail_nickname: "hr-staff"
security_enabled: true
description: "HR Staff"
tenant: "{{ azure.tenant_id }}"
client_id: "{{ azure.client_id }}"
secret: "{{ azure.client_secret }}"
auth_source: client_secret
register: hr_group
- name: Maintain members in HR-Staff
azure.azcollection.azure_rm_adgroup:
state: present
display_name: "HR-Staff"
security_enabled: true
members:
# Example UPNs, better to use Object IDs in practice
- "alice@{{ default_domain }}"
tenant: "{{ azure.tenant_id }}"
client_id: "{{ azure.client_id }}"
secret: "{{ azure.client_secret }}"
auth_source: client_secretTL;DR Active Directory changes via GUI or non-versioned PowerShell scripts are error-prone, hard to …
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 …