Privilege Windows for Automation Accounts
The Account Nobody Hardens
Every Ansible-managed host has one: a service account with an SSH key and NOPASSWD: ALL in sudoers. It exists so automation can run unattended, and it usually stays that way forever.
On a hardened host this is an awkward contradiction. Password policies, faillock, audit rules, SSH restrictions and a locked-down kernel all protect against an attacker who gains a foothold. Meanwhile one account offers root without a password to anyone who holds a single private key.
The fix is not to remove the account. It is to grant its privileges only while a deployment runs.
The Workflow
A deployment becomes five explicit steps:
./platform preflight c_00000_acme 101---app-01 # once per host: bootstrap
./platform unseal c_00000_acme 101---app-01 # open the window
./platform deploy c_00000_acme 101---app-01
./platform verify bsi c_00000_acme 101---app-01
./platform seal c_00000_acme 101---app-01 # close the window
Between seal and the next unseal, the automation account exists, still has its key, and cannot become root.
Preflight: A Predictable Account
preflight prepares a fresh host once. Apart from installing a supported Python interpreter, it creates the automation account in a defined way.
A Fixed UID Outside the Login Range
ANSIBLE_USER="ops-ansible"
ANSIBLE_UID="60001"
ANSIBLE_GID="60001"
sudo groupadd --gid "$ANSIBLE_GID" "$ANSIBLE_USER"
sudo useradd --create-home --shell /bin/bash \
--uid "$ANSIBLE_UID" --gid "$ANSIBLE_GID" \
--comment 'Ansible automation user' "$ANSIBLE_USER"
The range 60001–60999 is reserved for infrastructure service accounts. It sits just above the default UID_MAX of 60000 in /etc/login.defs, so these accounts never collide with UIDs that useradd assigns to people, and below nobody at 65534.
A fixed UID is the same on every host. File ownership survives backups and restores, audit logs can be correlated across hosts by UID, and a rule such as "alert on any process of UID 60001 outside a deployment window" becomes possible. If an existing account has a different UID, preflight corrects it with usermod -u and fixes ownership of the home directory.
Key-Only, and Exempt from Password Ageing
The account has no usable password. It authenticates with a key only. That creates a trap on hardened hosts:
# Exempt the key-only service account from password aging (PASS_MAX_DAYS / INACTIVE).
# An expired password makes pam_unix reject the account even for publickey logins.
sudo chage -M -1 -I -1 "$ANSIBLE_USER"
Hardening sets a maximum password age and an inactivity period. pam_unix enforces account expiry in the account phase, which runs for public-key logins too. Ninety days after creation, the password "expires", and automation can no longer log in, although nobody ever used a password. The failure shows up long after the change that caused it, which makes it hard to diagnose. Exempt service accounts explicitly.
Unseal: Open the Window
unseal connects as a separate interactive admin account (the automation account may not have sudo at this point) and does four things:
- Unlocks the account.
- Re-applies the password ageing exemption.
- Writes a sudoers drop-in, validated before it is used:
SUDOERS_FILE="/etc/sudoers.d/90-ops-ansible"
printf '%s\n%s\n%s\n' \
"Defaults:$ANSIBLE_USER !requiretty" \
"Defaults:$ANSIBLE_USER !log_input, !log_output" \
"$ANSIBLE_USER ALL=(ALL) NOPASSWD:ALL" | sudo tee "$SUDOERS_FILE" >/dev/null
sudo chmod 440 "$SUDOERS_FILE"
if ! sudo visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1; then
echo "sudoers validation failed, removing drop-in"
sudo rm -f "$SUDOERS_FILE"
exit 1
fi
- Checks that the authorized key is present, and restores it if not.
visudo -cf matters more than it looks. A syntax error in any file under /etc/sudoers.d/ breaks sudo for everyone on the host, including the admin account you would use to fix it. Writing, validating and removing on failure keeps a typo from locking you out.
The !log_input, !log_output defaults exclude the automation account from sudo I/O logging, if the host enables it: a full hardening run through I/O logging produces large logs of module transfer noise. The audit trail for deployments comes from the structured deployment log instead (see below).
On directory-integrated hosts, unseal additionally opens a drop-in for a deployment group in the directory, so named operators can deploy with their own Kerberos identity instead of a shared key. The same validation applies.
Seal: Close the Window
seal reverses the grant:
sudo rm -f /etc/sudoers.d/90-ops-ansible
sudo rm -f /etc/sudoers.d/90-ad-deployment
sudo usermod -L "$ANSIBLE_USER"
The account and its key are preserved, so the next unseal can reactivate it. A --remove-user option deletes the account entirely when a host leaves automation.
Be precise about what sealing achieves:
- Removing the sudoers drop-in is the security control. Without it, the automation account is an ordinary unprivileged user.
- Locking the password does not block SSH key logins on systems where
sshduses PAM; a locked password only prevents password authentication. If key logins must be impossible between deployments too, remove or disable the authorized key as part ofseal(and restore it duringunseal), or restrict the key with afrom=option to the deployment hosts.
Every Step Is an Event
preflight, unseal and seal write events in the same JSONL format as the deployment itself, into the same per-host log directory:
{"timestamp": "2025-11-04T09:08:12.551Z", "host": "101---app-01", "role": "unseal", "context": "ops-ansible", "phase": "end", "outcome": "ok", "extra": {"source": "platform_unseal", "target_ip": "10.0.10.11", "exit_code": 0, "smoke_test": "pass"}}
{"timestamp": "2025-11-04T09:21:47.004Z", "host": "101---app-01", "role": "seal", "context": "ops-ansible", "phase": "end", "outcome": "ok", "extra": {"source": "platform_seal", "target_ip": "10.0.10.11", "exit_code": 0}}
The deployment log then contains a complete window per host: opened, roles applied, closed. Two simple checks follow:
- A window that was opened and never closed (an
unsealwithout a laterseal) is an open finding. - Root activity by the automation UID outside a window (from the host’s audit log) is an incident.
See Auditing Ansible at Scale with Structured Action Logging for the log format.
Making It Hard to Forget
A privilege window is only as good as the discipline to close it. A few measures help:
- Make
sealpart of the deployment command for unattended runs, so closing the window is the default and leaving it open requires a flag. - Show the state on the host. A login banner or dynamic MOTD that displays whether the automation account is sealed makes an open window visible to every administrator who logs in.
- Alert on long windows. A periodic check that reports hosts with an open window for longer than a deployment usually takes.
Trade-offs
- Deployments need an interactive admin account with sudo to open the window. That account must be protected properly (named users, MFA where possible), because it is now the path to root.
- Fully unattended, scheduled runs need the open-run-close sequence automated, which brings back a credential with the power to open windows. Keep that credential on the deployment host only, and log every use.
- There is more to explain to new team members than "Ansible has sudo".
In exchange, the most privileged credential in the estate is no longer permanently privileged.
Conclusion
Hardening a host and leaving a passwordless root path open next to it undermines the work. Treat automation privileges like any other elevated access: grant them for a defined window, validate every change to sudoers before it takes effect, take them away afterwards, and log both steps where auditors already look.