
En el Primeros pasos con Ansible has instalado Ansible, montado el inventario, ejecutado comandos ad-hoc y escrito el primer playbook. Esta entrada parte de ahí y resuelve un problema que todo administrador tiene: dar y quitar acceso de personas a los servidores. Una cuenta creada a mano en cada máquina se vuelve un desastre rápido — nadie sabe quién tiene sudo, la clave SSH del becario que se fue sigue ahí y el único registro es la memoria de alguien. Aquí la lista de usuarios se convierte en código: un archivo YAML en un repositorio Git, aplicado por Ansible. Crear, bloquear o eliminar a alguien pasa a ser un commit, con autor, fecha y revisión, y el git log se convierte en la auditoría.
Todo lo que aparece a continuación se ha ejecutado de verdad en un laboratorio con dos servidores — Ubuntu 24.04 e Debian 13 — y una máquina de control Ubuntu 24.04 con Ansible del repositorio oficial de la distribución (ansible 9.2.0, ansible-core 2.16.3). Las salidas son reales.
Lo que vamos a montar
El proyecto tiene un archivo con la lista de personas (nombre, clave pública, grupos, sudo y estado), un playbook que convierte esa lista en cuentas en los servidores y una plantilla para el sudo. La máquina de control solo aplica lo que está en la rama principal; lo que llega allí pasó por commit y, si quieres, por revisión.

Cada persona pasa por tres estados, siempre declarados en el YAML:
ativo— cuenta creada, clave SSH instalada, grupos y sudo aplicados;bloqueado— nadie entra más, ni por clave, pero la cuenta y los archivos quedan para análisis;removido— cuenta, grupo personal y home borrados.
La estructura del proyecto en Git
En la máquina de control, crea el directorio y el repositorio. La estructura final es esta:
usuarios-linux/
├── .gitignore
├── ansible.cfg
├── inventory.ini
├── usuarios.yml # o playbook
├── templates/
│ └── sudoers.j2
└── group_vars/
└── all/
├── usuarios.yml # a lista de pessoas
└── vault.yml # senhas, sempre cifradas
O ansible.cfg apunta el inventario, el archivo con la contraseña del vault (fuera del repositorio) y conecta el become:
[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault-pass-usuarios
nocows = true
retry_files_enabled = false
[privilege_escalation]
become = true
El inventario usa un usuario de automatización, deploy, con clave SSH y sudo sin contraseña — el mismo esquema del post anterior. En el laboratorio las direcciones eran contenedores; aquí quedan IPs de documentación:
[servidores]
srv-ubuntu ansible_host=192.0.2.10
srv-debian ansible_host=192.0.2.11
[servidores:vars]
ansible_user=deploy
Y el .gitignore existe para una sola cosa: garantizar que el secreto no entra en el historial. La clave privada y la contraseña en texto plano nunca van a Git — lo que va es la clave pública y la contraseña cifrada con ansible-vault.
# segredos e chaves privadas nunca entram no repositório
.vault-pass*
id_*
*.pem
*.key
*.retry
La lista de usuarios
group_vars/all/usuarios.yml es el archivo que la gente va a editar. Cada entrada tiene nombre, nombre completo, una lista de claves públicas, grupos extra y el estado. En el primer commit la lista empieza vacía; este es el estado después de conceder acceso a Ana y a Bruno:
---
# Contas de pessoas nos servidores. Cada mudança aqui é um commit revisado.
# estado: ativo | bloqueado | removido
# Nunca apague uma entrada: mude o estado para "removido".
usuarios:
- nome: ana
nome_completo: Ana Souza
chaves:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFheyJLtuZlL6mZue2Ha2VlsOrCpQokkeAI+MQAbIozQ ana@notebook
grupos: [sudo] # sudo pedindo a senha (hash no vault)
estado: ativo
- nome: bruno
nome_completo: Bruno Lima
chaves:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEvt3LKu4Ue9C2ewbKyC1F41f//kWyOoGLZ5RXeMJEq bruno@notebook
grupos: [adm] # lê os logs em /var/log
sudo_nopasswd: true # sudo sem senha, via /etc/sudoers.d
estado: ativo
Las claves se generaron con ssh-keygen -t ed25519 en el portátil de cada persona, que envía solo el .pub. Si aún no tienes familiaridad con las claves SSH, consulta Usando claves de autenticación en SSH.
Dos formas de dar sudo
Un usuario que entra solo por clave no tiene contraseña — y el sudo por defecto pide la contraseña del usuario. Hay dos soluciones, y el proyecto utiliza ambas para mostrar la diferencia:
- Ana: grupo
sudo, con contraseña. Tanto Ubuntu como Debian ya traen%sudo ALL=(ALL:ALL) ALLen/etc/sudoers. La contraseña inicial viene de un hash guardado en el vault; es el modelo más seguro, porque robar la clave SSH no basta para convertirse en root. - Bruno:
NOPASSWDvia/etc/sudoers.d/. Práctico para automatización y guardia, pero quien tenga su clave privada se convierte en root sin ninguna barrera extra. Úselo para pocas personas, con clave protegida por passphrase, y revise esa lista con frecuencia.
El hash de la contraseña de Ana se genera con openssl passwd -6 y se guarda cifrado:
openssl passwd -6 # digita a senha, recebe o hash $6$...
echo 'senha-longa-do-vault' > ~/.vault-pass-usuarios && chmod 600 ~/.vault-pass-usuarios
cat > group_vars/all/vault.yml <<'EOF'
---
vault_senhas:
ana: "$6$...hash gerado acima..."
EOF
ansible-vault encrypt group_vars/all/vault.yml
head -2 group_vars/all/vault.yml
Encryption successful
$ANSIBLE_VAULT;1.1;AES256
30613166333535333030386164623937653739393238316439323662363831393935353039613739
El archivo cifrado puede ir a Git; la contraseña del vault, no. Para más sobre el vault, vea Automatizando Servidores Linux con Ansible.
La regla de sudo sin contraseña sale de una plantilla. Solo lista quién está ativo y tiene sudo_nopasswd: true — entonces bloquear a alguien ya le quita el sudo:
# Gerenciado pelo Ansible (repositório usuarios-linux). Não edite à mão.
{% for u in usuarios if u.estado == 'ativo' and u.sudo_nopasswd | default(false) %}
{{ u.nome }} ALL=(ALL:ALL) NOPASSWD: ALL
{% endfor %}
El playbook
usuarios.yml separa la lista por los tres estados y trata cada uno. Por debajo, el módulo ansible.builtin.user llama useradd, usermod e userdel — los mismos comandos que ejecutarías a mano, solo que en todos los servidores y siempre de la misma manera.
---
- name: Usuários Linux declarados no Git
hosts: servidores
become: true
vars:
ativos: "{{ usuarios | selectattr('estado', 'eq', 'ativo') | list }}"
bloqueados: "{{ usuarios | selectattr('estado', 'eq', 'bloqueado') | list }}"
removidos: "{{ usuarios | selectattr('estado', 'eq', 'removido') | list }}"
tasks:
- name: Grupo comum da equipe
ansible.builtin.group:
name: equipe
state: present
- name: Contas ativas
ansible.builtin.user:
name: "{{ item.nome }}"
comment: "{{ item.nome_completo }}"
shell: /bin/bash
groups: "{{ ['equipe'] + item.grupos | default([]) }}"
append: true
create_home: true
password: "{{ vault_senhas[item.nome] | default(omit) }}"
update_password: on_create
expires: -1
loop: "{{ ativos }}"
loop_control:
label: "{{ item.nome }}"
- name: Chaves SSH autorizadas (somente as do repositório)
ansible.posix.authorized_key:
user: "{{ item.nome }}"
path: "/home/{{ item.nome }}/.ssh/authorized_keys" # permite o --check antes de a conta existir
key: "{{ item.chaves | join('\n') }}"
exclusive: true
loop: "{{ ativos }}"
loop_control:
label: "{{ item.nome }}"
- name: Regras de sudo sem senha
ansible.builtin.template:
src: sudoers.j2
dest: /etc/sudoers.d/90-usuarios-ansible
owner: root
group: root
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Contas bloqueadas - trava a senha e expira a conta
ansible.builtin.user:
name: "{{ item.nome }}"
password_lock: true
expires: 86400 # 1970-01-02: o PAM recusa até o login por chave
loop: "{{ bloqueados }}"
loop_control:
label: "{{ item.nome }}"
- name: Contas bloqueadas - remove as chaves SSH
ansible.builtin.file:
path: "/home/{{ item.nome }}/.ssh/authorized_keys"
state: absent
loop: "{{ bloqueados }}"
loop_control:
label: "{{ item.nome }}"
- name: Contas bloqueadas - encerra processos e sessões abertas
ansible.builtin.command: pkill -KILL -u {{ item.nome }}
register: pkill
changed_when: pkill.rc == 0
failed_when: pkill.rc > 1
loop: "{{ bloqueados }}"
loop_control:
label: "{{ item.nome }}"
- name: Contas removidas - apaga usuário e home
ansible.builtin.user:
name: "{{ item.nome }}"
state: absent
remove: true
loop: "{{ removidos }}"
loop_control:
label: "{{ item.nome }}"
Los detalles que importan:
append: trueañade los grupos listados sin sacar a la persona de los grupos que ya tenga — el equivalente ausermod -aG. El-Gsin-asustituye la lista entera; mira la pega de esto más abajo.update_password: on_creategraba la contraseña del vault solo en la creación; luego la persona cambia su propia contraseña conpasswdy Ansible no sobrescribe.expires: -1garantiza cuenta sin fecha de expiración — es lo que deshace un bloqueo si alguien vuelve aativo.ansible.posix.authorized_keyconexclusive: truedeja en~/.ssh/authorized_keyssolamente las claves del repositorio. Clave añadida a mano se va en la próxima ejecución. El módulo crea~/.sshcon el modo 700 y el archivo con 600, como exige sshd. La collectionansible.posixviene en el paqueteansible(en Ubuntu 24.04, versión 1.5.4); con sólo elansible-core, instálela conansible-galaxy collection install ansible.posix.- O
pathexplícito enauthorized_keyexiste por un motivo práctico: sin él, el--checkfalla para usuarios que aún no existen, con “Either user must exist or you must provide full path to key file in check mode”. validate: /usr/sbin/visudo -cf %sprueba el archivo de sudo antes de instalarlo. Un error de sintaxis en/etc/sudoers.d/puede bloquear el sudo de toda la máquina; con la validación, Ansible falla y el archivo antiguo permanece.
Antes del primer commit, ejecuta ansible-lint (paquete ansible-lint en Ubuntu 24.04, versión 6.17.2):
ansible-lint
Passed: 0 failure(s), 0 warning(s) on 7 files. Last profile that met the validation criteria was 'production'.
Primer commit y primera aplicación
git init -b main
git add -A
git commit -m "Estrutura inicial: inventário, playbook e lista vazia de usuários"
# ... edita group_vars/all/usuarios.yml e cria o vault.yml ...
git add -A
git commit -m "Concede acesso a Ana (sudo com senha) e Bruno (sudo sem senha)"
Antes de tocar ningún servidor, mira lo que va a cambiar con --check --diff:
ansible-playbook usuarios.yml --check --diff --limit srv-ubuntu
PLAY [Usuários Linux declarados no Git] ****************************************
TASK [Gathering Facts] *********************************************************
ok: [srv-ubuntu]
TASK [Grupo comum da equipe] ***************************************************
changed: [srv-ubuntu]
TASK [Contas ativas] ***********************************************************
changed: [srv-ubuntu] => (item=ana)
changed: [srv-ubuntu] => (item=bruno)
TASK [Chaves SSH autorizadas (somente as do repositório)] **********************
--- before: /home/ana/.ssh/authorized_keys
+++ after: /home/ana/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFheyJLtuZlL6mZue2Ha2VlsOrCpQokkeAI+MQAbIozQ ana@notebook
changed: [srv-ubuntu] => (item=ana)
--- before: /home/bruno/.ssh/authorized_keys
+++ after: /home/bruno/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEvt3LKu4Ue9C2ewbKyC1F41f//kWyOoGLZ5RXeMJEq bruno@notebook
changed: [srv-ubuntu] => (item=bruno)
TASK [Regras de sudo sem senha] ************************************************
--- before
+++ after: /root/.ansible/tmp/ansible-local-369ok2z5l9m/tmp9vnvv456/sudoers.j2
@@ -0,0 +1,2 @@
+# Gerenciado pelo Ansible (repositório usuarios-linux). Não edite à mão.
+bruno ALL=(ALL:ALL) NOPASSWD: ALL
changed: [srv-ubuntu]
TASK [Contas bloqueadas - trava a senha e expira a conta] **********************
TASK [Contas bloqueadas - remove as chaves SSH] ********************************
TASK [Contas bloqueadas - encerra processos e sessões abertas] *****************
TASK [Contas removidas - apaga usuário e home] *********************************
PLAY RECAP *********************************************************************
srv-ubuntu : ok=5 changed=4 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
El diff muestra exactamente las claves y la línea de sudo que se van a añadir. Todo correcto, aplícalo de verdad en los dos servidores:
ansible-playbook usuarios.yml
PLAY [Usuários Linux declarados no Git] ****************************************
TASK [Gathering Facts] *********************************************************
ok: [srv-ubuntu]
ok: [srv-debian]
TASK [Grupo comum da equipe] ***************************************************
changed: [srv-debian]
changed: [srv-ubuntu]
TASK [Contas ativas] ***********************************************************
changed: [srv-debian] => (item=ana)
changed: [srv-ubuntu] => (item=ana)
changed: [srv-debian] => (item=bruno)
changed: [srv-ubuntu] => (item=bruno)
TASK [Chaves SSH autorizadas (somente as do repositório)] **********************
changed: [srv-debian] => (item=ana)
changed: [srv-ubuntu] => (item=ana)
changed: [srv-debian] => (item=bruno)
changed: [srv-ubuntu] => (item=bruno)
TASK [Regras de sudo sem senha] ************************************************
changed: [srv-debian]
changed: [srv-ubuntu]
PLAY RECAP *********************************************************************
srv-debian : ok=5 changed=4 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
srv-ubuntu : ok=5 changed=4 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
Y ejecútalo de nuevo. Un playbook bien escrito es idempotente: la segunda ejecución no cambia nada.
PLAY RECAP *********************************************************************
srv-debian : ok=5 changed=0 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
srv-ubuntu : ok=5 changed=0 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
Comprobando en los servidores
Los comandos clásicos siguen siendo válidos para comprobar lo que hizo Ansible: getent lee /etc/passwd e /etc/group, id muestra los grupos y chage -l muestra la caducidad.
getent passwd ana bruno
id ana; id bruno
getent group equipe sudo
cat /etc/sudoers.d/90-usuarios-ansible
chage -l bruno | head -4
== ubuntu2404
ana:x:1002:1003:Ana Souza:/home/ana:/bin/bash
bruno:x:1003:1004:Bruno Lima:/home/bruno:/bin/bash
uid=1002(ana) gid=1003(ana) groups=1003(ana),27(sudo),1002(equipe)
uid=1003(bruno) gid=1004(bruno) groups=1004(bruno),4(adm),1002(equipe)
equipe:x:1002:ana,bruno
sudo:x:27:ubuntu,ana
# Gerenciado pelo Ansible (repositório usuarios-linux). Não edite à mão.
bruno ALL=(ALL:ALL) NOPASSWD: ALL
-r--r----- 1 root root 111 Sep 23 16:16 /etc/sudoers.d/90-usuarios-ansible
Last password change : Sep 23, 2026
Password expires : never
Password inactive : never
Account expires : never
== debian13
ana:x:1001:1002:Ana Souza:/home/ana:/bin/bash
bruno:x:1002:1003:Bruno Lima:/home/bruno:/bin/bash
uid=1001(ana) gid=1002(ana) groups=1002(ana),27(sudo),1001(equipe)
uid=1002(bruno) gid=1003(bruno) groups=1003(bruno),4(adm),1001(equipe)
Aquí aparecieron dos diferencias entre las distribuciones:
- UIDs diferentes — la imagen de Ubuntu 24.04 ya tenía al usuario
ubuntucon UID 1000, así que Ana quedó con 1002 allí y 1001 en Debian. No importa para iniciar sesión ni para sudo, pero sí importa si los servidores comparten archivos por NFS o restauran backups entre sí. En ese caso, fijauid:en la entrada de cada persona. - Permisos del home —
HOME_MODEen/etc/login.defsé0750en Ubuntu 24.04 y0700en Debian 13. Otro detalle de Debian 13:ENCRYPT_METHODéYESCRYPT, contraSHA512en Ubuntu; el hash$6$(SHA-512) del vault funciona en ambos.
Probando el inicio de sesión sin contraseña y sudo
Desde el “notebook” de cada persona (otro contenedor en el laboratorio), con su clave privada:
ssh -i ~/chaves/ana ana@srv-ubuntu 'whoami; sudo -n true; sudo -S -p "" id -un <<< "a-senha-da-ana"'
ssh -i ~/chaves/bruno bruno@srv-ubuntu 'whoami; sudo -n id -un'
ssh -i ~/chaves/ana bruno@srv-ubuntu true # chave da Ana na conta do Bruno
ana
sudo: a password is required
root
bruno
root
bruno@users-lab-ubuntu2404: Permission denied (publickey,password).
Ana entró sin contraseña y su sudo pidió la contraseña, como se esperaba; Bruno se convirtió en root sin contraseña; y la clave de una persona no abre la cuenta de la otra. El resultado fue idéntico en Debian 13. Con todos entrando mediante clave, el siguiente paso natural es desactivar PasswordAuthentication sin sshd.
Nueva persona: rama, revisión y merge
Con el repositorio en un servidor Git (Gitea, GitLab o GitHub), dar acceso se convierte en un pull request: alguien propone, otra persona revisa, y solo lo que entra en la main se aplica. Aquí Ana pide la cuenta de Carla, que va a leer logs en la guardia pero no tiene sudo:
git switch -c acesso-carla
cat >> group_vars/all/usuarios.yml <<'EOF'
- nome: carla
nome_completo: Carla Mendes
chaves:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVS+xmSmHpXvCptaasmycC0QDTcPDfQ6UaLpnNx9+ai carla@notebook
grupos: [adm] # só leitura de logs, sem sudo
estado: ativo
EOF
git commit -am "Cria conta da Carla (plantão, leitura de logs)" # autora: Ana Souza
# revisão aprovada:
git switch main
git merge --no-ff -m "Merge: acesso da Carla (revisado por Nilton)" acesso-carla
git log --oneline --graph
* 7749b7e Merge: acesso da Carla (revisado por Nilton)
|\
| * b49a9f8 Cria conta da Carla (plantão, leitura de logs)
|/
* 87268a9 Concede acesso a Ana (sudo com senha) e Bruno (sudo sem senha)
* 16c8739 Estrutura inicial: inventário, playbook e lista vazia de usuários
Para probar el exclusive: true, antes de aplicar alguien añadió a mano una clave “olvidada” en el authorized_keys de Bruno en Ubuntu. La ejecución con --diff creó a Carla y eliminó la clave intrusa:
TASK [Chaves SSH autorizadas (somente as do repositório)] **********************
ok: [srv-debian] => (item=ana)
ok: [srv-ubuntu] => (item=ana)
ok: [srv-debian] => (item=bruno)
--- before: /home/bruno/.ssh/authorized_keys
+++ after: /home/bruno/.ssh/authorized_keys
@@ -1,2 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEvt3LKu4Ue9C2ewbKyC1F41f//kWyOoGLZ5RXeMJEq bruno@notebook
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHnDTe6LIwLxY8m0dTCmmv2L1d0cNQ9DxbhGnBgd7W8z intruso@fora-do-git
changed: [srv-ubuntu] => (item=bruno)
--- before: /home/carla/.ssh/authorized_keys
+++ after: /home/carla/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVS+xmSmHpXvCptaasmycC0QDTcPDfQ6UaLpnNx9+ai carla@notebook
changed: [srv-debian] => (item=carla)
Carla entra, lee /var/log por el grupo adm y no tiene sudo:
uid=1003(carla) gid=1004(carla) groups=1004(carla),4(adm),1001(equipe)
sudo: a password is required
Bloquear antes de eliminar
Cuando alguien se va, la tentación es eliminar la cuenta de inmediato. Mejor bloquearla primero: la persona pierde el acceso inmediatamente, pero el home, los crontabs y los archivos siguen ahí para que puedas comprobar lo que hay que transferir. Solo después viene la eliminación.

O usermod -L no bloquea el login por clave
Ese es el error más común. usermod -L (y passwd -l, y el password_lock: true de Ansible) solo pone un ! delante del hash en /etc/shadow: invalida la contraseña. El inicio de sesión por clave no usa contraseña. Probé cada método por separado, en los dos servidores, con una cuenta que solo tenía clave SSH:
# senha já é "!" (conta criada sem senha): chave funciona
teste:!:20719:0:99999:7::: -> login OK (Ubuntu e Debian)
# usermod -L teste
teste:!:20719:0:99999:7::: -> login OK (Ubuntu e Debian)
# usermod -e 1 teste (expira em 1970-01-02)
teste:!:20719:0:99999:7::1: -> Connection closed (Ubuntu e Debian)
# usermod -s /usr/sbin/nologin teste
-> "This account is currently not available."
Con la expiración, el servidor registra el motivo — es el módulo pam_unix, en la fase account, que rechaza incluso después de que la clave haya sido aceptada:
sshd[307]: pam_unix(sshd:account): account teste has expired (account expired)
sshd[307]: fatal: Access denied for user teste by PAM account configuration [preauth]
Esto vale porque Ubuntu y Debian usan UsePAM yes en sshd, y el PAM comprueba la fecha de expiración en cualquier método de autenticación. La propia página de manual de usermod advierte: para bloquear la cuenta, y no solo la contraseña, define también la expiración como 1. Y el shell nologin por sí solo tampoco basta: rechaza el shell interactivo, pero la conexión sigue abierta para túneles — un ssh -N con la cuenta en nologin se quedó conectado hasta que lo maté.
Por eso el estado bloqueado del playbook hace cuatro cosas: bloquea la contraseña, expira la cuenta en 1970-01-02 (expires: 86400, un día después de la época Unix), borra el authorized_keys y mata los procesos de la persona. La plantilla de sudo deja de listar a quien no está ativo.
Bloqueando a Bruno con una sesión abierta
Con Bruno conectado en Ubuntu ejecutando un sleep 900:
git diff
@@ -16,7 +16,7 @@ usuarios:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKEvt3LKu4Ue9C2ewbKyC1F41f//kWyOoGLZ5RXeMJEq bruno@notebook
grupos: [adm] # lê os logs em /var/log
sudo_nopasswd: true # sudo sem senha, via /etc/sudoers.d
- estado: ativo
+ estado: bloqueado # saiu da equipe em 2026-09-23
git commit -am "Bloqueia Bruno: saiu da equipe"
ansible-playbook usuarios.yml --diff
TASK [Regras de sudo sem senha] ************************************************
--- before: /etc/sudoers.d/90-usuarios-ansible
+++ after: /root/.ansible/tmp/ansible-local-107075lrwl0p/tmpq25ijl7j/sudoers.j2
@@ -1,2 +1 @@
# Gerenciado pelo Ansible (repositório usuarios-linux). Não edite à mão.
-bruno ALL=(ALL:ALL) NOPASSWD: ALL
changed: [srv-debian]
--- before: /etc/sudoers.d/90-usuarios-ansible
+++ after: /root/.ansible/tmp/ansible-local-107075lrwl0p/tmpd2p_rq9_/sudoers.j2
@@ -1,2 +1 @@
# Gerenciado pelo Ansible (repositório usuarios-linux). Não edite à mão.
-bruno ALL=(ALL:ALL) NOPASSWD: ALL
changed: [srv-ubuntu]
TASK [Contas bloqueadas - trava a senha e expira a conta] **********************
changed: [srv-debian] => (item=bruno)
changed: [srv-ubuntu] => (item=bruno)
TASK [Contas bloqueadas - remove as chaves SSH] ********************************
--- before
+++ after
@@ -1,4 +1,4 @@
{
"path": "/home/bruno/.ssh/authorized_keys",
- "state": "file"
+ "state": "absent"
}
changed: [srv-debian] => (item=bruno)
--- before
+++ after
@@ -1,4 +1,4 @@
{
"path": "/home/bruno/.ssh/authorized_keys",
- "state": "file"
+ "state": "absent"
}
changed: [srv-ubuntu] => (item=bruno)
TASK [Contas bloqueadas - encerra processos e sessões abertas] *****************
changed: [srv-ubuntu] => (item=bruno)
TASK [Contas removidas - apaga usuário e home] *********************************
PLAY RECAP *********************************************************************
srv-debian : ok=8 changed=3 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
srv-ubuntu : ok=8 changed=4 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
En el portátil de Bruno, la sesión cayó al instante:
Connection to users-lab-ubuntu2404 closed by remote host.
Y, para demostrar que la expiración bloquea el acceso incluso si alguien devuelve la clave en mano, volví a colocar el authorized_keys suyo e intenté de nuevo:
bruno:!:20719:0:99999:7::1:
Account expires : Jan 02, 1970
Your account has expired; please contact your system administrator.
Connection closed by 192.168.48.2 port 22
Mismo resultado en Debian 13. Antes de eliminar, busque lo que Bruno dejó fuera del home — archivos en /srv, /var/www, crontab en /var/spool/cron/crontabs:
ansible servidores -m ansible.builtin.command -a "find / -xdev -user bruno -not -path '/home/bruno*'"
ansible servidores -m ansible.builtin.command -a "crontab -l -u bruno"
Aquí un FAILED es la respuesta correcta: el crontab -l salí con el código 1 y el mensaje “no crontab for bruno” cuando no hay crontab. En el laboratorio no había nada. Haz también la copia de seguridad del home, si tiene algo de valor.
Eliminar la cuenta
La eliminación es otro commit — y aquí la regla de oro del proyecto: no borres la entrada del YAML, cambia el estado a removido.
sed -i 's/estado: bloqueado # saiu/estado: removido # saiu/' group_vars/all/usuarios.yml
git commit -am "Remove a conta do Bruno (bloqueada desde 2026-09-23)"
ansible-playbook usuarios.yml
TASK [Contas removidas - apaga usuário e home] *********************************
changed: [srv-debian] => (item=bruno)
changed: [srv-ubuntu] => (item=bruno)
PLAY RECAP *********************************************************************
srv-debian : ok=6 changed=1 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0
srv-ubuntu : ok=6 changed=1 unreachable=0 failed=0 skipped=3 rescued=0 ignored=0
O state: absent con remove: true equivale a userdel -r: borra la cuenta, el grupo personal y el home. En los dos servidores, Bruno desapareció de todos los archivos:
id: 'bruno': no such user
ls: cannot access '/home/bruno': No such file or directory
/etc/passwd:0
/etc/shadow:0
/etc/group:0
/etc/gshadow:0
/etc/sudoers.d/90-usuarios-ansible:0
Si todavía hay procesos del usuario ejecutándose, el userdel se niega a borrar la cuenta y Ansible falla en esa tarea con “userdel: user … is currently used by process …” (lo probé) — otra razón más para pasar por el bloqueo, que mata los procesos antes. Los archivos que queden fuera del home pasan a pertenecer a un UID “huérfano”, que el siguiente usuario creado puede heredar; de ahí la búsqueda con find -user antes de la eliminación.
¿Por qué no borrar la entrada? Porque Ansible solo gestiona lo que está en la lista. Probé quitando a Carla del YAML y ejecutando el playbook: changed=0, y ella siguió entrando por SSH con normalidad. Borrar la línea no elimina a nadie — solo hace que Ansible olvide que la cuenta existe.
Auditoría con git log
Ahora la pregunta “¿quién dio acceso a quién y cuándo?” tiene respuesta en segundos:
git log --format="%h %ad %an %s" --date=short
491d64d 2026-09-23 Nilton Remove a conta do Bruno (bloqueada desde 2026-09-23)
500ce95 2026-09-23 Nilton Bloqueia Bruno: saiu da equipe
7749b7e 2026-09-23 Nilton Merge: acesso da Carla (revisado por Nilton)
b49a9f8 2026-09-23 Ana Souza Cria conta da Carla (plantão, leitura de logs)
87268a9 2026-09-23 Nilton Concede acesso a Ana (sudo com senha) e Bruno (sudo sem senha)
16c8739 2026-09-23 Nilton Estrutura inicial: inventário, playbook e lista vazia de usuários
El historial completo de una persona, con el diff de cada cambio de estado:
git log -p -G"estado: (bloqueado|removido)" -- group_vars/all/usuarios.yml
491d64d Nilton 2026-09-23 16:17:56 +0000
Remove a conta do Bruno (bloqueada desde 2026-09-23)
...
- estado: bloqueado # saiu da equipe em 2026-09-23
+ estado: removido # saiu da equipe em 2026-09-23
...
500ce95 Nilton 2026-09-23 16:17:28 +0000
Bloqueia Bruno: saiu da equipe
...
- estado: ativo
+ estado: bloqueado # saiu da equipe em 2026-09-23
Quién escribió cada línea de la cuenta de Carla:
git blame --date=short -L "/nome: carla/,+6" group_vars/all/usuarios.yml
b49a9f8d (Ana Souza 2026-09-23 21) - nome: carla
b49a9f8d (Ana Souza 2026-09-23 22) nome_completo: Carla Mendes
b49a9f8d (Ana Souza 2026-09-23 23) chaves:
b49a9f8d (Ana Souza 2026-09-23 24) - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVS+xmSmHpXvCptaasmycC0QDTcPDfQ6UaLpnNx9+ai carla@notebook
b49a9f8d (Ana Souza 2026-09-23 25) grupos: [adm] # só leitura de logs, sem sudo
b49a9f8d (Ana Souza 2026-09-23 26) estado: ativo
Y quién concedió sudo sin contraseña, en cualquier punto de la historia — el -S busca commits que añadieron o quitaron el texto:
git log -S"sudo_nopasswd: true" --format="%h %ad %an %s" --date=short -- group_vars/all/usuarios.yml
87268a9 2026-09-23 Nilton Concede acesso a Ana (sudo com senha) e Bruno (sudo sem senha)
El autor de un commit es lo que la persona configuró en Git, así que vale lo que confíes en ello. Para auditoría seria, exige commits firmados (git commit -S) y protege la rama main en el servidor Git, aceptando solo merge revisado.
Git no sustituye al log del servidor, lo complementa. El useradd, el usermod y el userdel registran cada operación en el journal, y sudo registra lo que el usuario deploy do Ansible ejecutó:
journalctl -t useradd -t usermod -t userdel | grep bruno
useradd[926]: new user: name=bruno, UID=1003, GID=1004, home=/home/bruno, shell=/bin/bash, from=/dev/pts/1
usermod[2253]: change user 'bruno' expiration from 'never' to '1970-01-02'
userdel[2901]: delete user 'bruno'
userdel[2901]: removed group 'bruno' owned by 'bruno'
userdel[2901]: removed shadow group 'bruno' owned by 'bruno'
El horario del journal coincide con el commit: se puede vincular cada cambio en el servidor al commit que lo solicitó.
Deshaciendo un error con git revert
¿Has bloqueado a la persona equivocada? El arreglo es el mismo flujo a la inversa. En el laboratorio, bloqueé a Carla a propósito, confirmé que su login empezó a fallar y lo deshice:
git commit -am "Bloqueia Carla"
ansible-playbook usuarios.yml # Carla: Permission denied (publickey,password)
git revert --no-edit HEAD
ansible-playbook usuarios.yml --diff
[main 456bd14] Revert "Bloqueia Carla"
...
TASK [Contas ativas] ***********************************************************
changed: [srv-ubuntu] => (item=carla)
TASK [Chaves SSH autorizadas (somente as do repositório)] **********************
--- before: /home/carla/.ssh/authorized_keys
+++ after: /home/carla/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBVS+xmSmHpXvCptaasmycC0QDTcPDfQ6UaLpnNx9+ai carla@notebook
changed: [srv-ubuntu] => (item=carla)
La cuenta volvió a no expirar, la clave volvió y Carla entró de nuevo — y tanto el error como la corrección quedaron en el historial. Un límite: para quien tiene contraseña (como Ana), el bloqueo también bloquea la contraseña con !, y el revert no la desbloquea; en ese caso ejecuta usermod -U o define una contraseña nueva. El playbook no usa password_lock: false en las cuentas activas porque, en una cuenta solo con clave, el usermod -U se niega (“unlocking the user’s password would result in a passwordless account”) y Ansible marcaría changed en cada ejecución.
Trampa: el append no saca a nadie de un grupo
append: true protege grupos que otros roles añadieron (el docker, por ejemplo), pero tiene un coste: quitar sudo de la lista de grupos de Ana no saca a Ana del grupo. Probé: con grupos: [], el playbook dio changed=0 y el id ana siguió mostrando 27(sudo). Para revocar, tienes dos opciones:
- eliminar el grupo explícitamente, una vez:
ansible servidores -m ansible.builtin.user -a "name=ana groups=ana,equipe append=false"(probado: Ana salió delsudo); - o cambiar a
append: falseen el playbook, si el YAML es la única fuente de verdad para los grupos de las personas — entonces la lista pasa a ser exacta, y cualquier grupo añadido fuera de ella desaparece.
Es el mismo peligro del usermod -G sin -a en la terminal, pero al revés: allí quitas grupos sin querer; aquí crees que los quitaste y no lo hiciste.
Código completo
El proyecto se convirtió en un repositorio en GitHub, listo para clonar: jniltinho/ansible-linux-users. Allí el código está organizado en un rol reutilizable, linux_users, con validación de la lista antes de cualquier cambio, pruebas de integración en Ubuntu 24.04 y en Debian 13 y CI en GitHub Actions. Para quienes solo quieran los archivos de esta publicación, también están en un Gist.
El código del repositorio y del Gist está en inglés, con los mismos pasos de esta publicación: usuarios se convierte en linux_users_accounts (en el Gist, users), estado se convierte en state, y los estados ativo, bloqueado e removido se convierten active, locked e removed. Las claves de ejemplo son públicas y se generaron solo para el laboratorio, las IP son de documentación, y el archivo del vault aparece como ejemplo en texto plano: cifre el suyo antes del primer commit.
Referencias
- Módulo ansible.builtin.user
- Módulo ansible.posix.authorized_key
- Módulo ansible.builtin.template (parámetro validate)
- Guía de Ansible Vault
- usermod(8) e shadow(5)
Conclusión
Con la lista de usuarios en Git, el acceso a los servidores deja de depender de la memoria: cada cuenta tiene un commit que explica por qué existe, cada sudo tiene un autor y cada baja tiene fecha. Ansible se encarga de que los servidores coincidan con el repositorio, incluso borrando la clave que alguien metió a mano. Dos lecciones del laboratorio para llevarse: bloquee antes de eliminar, y recuerde que usermod -L bloquea la contraseña, no la cuenta; quien bloquea la clave SSH es la caducidad. Para los fundamentos, vuelva a Primeros pasos con Ansible; para vault, roles y casos mayores, siga hacia Automatizando Servidores Linux con Ansible. Y, si Git todavía es nuevo, empiece por Git simple y rápido.