Usuarios en Linux con Ansible: claves SSH, sudo e historial en Git

Mascote LinuxPro gira uma chave dourada na fechadura de um rack de servidores, com os logos do Ansible e do Git na parede e o cachorro caramelo deitado ao lado da mesa

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.

Diagrama: repositório Git com a lista de usuários, revisão, máquina de controle rodando ansible-playbook e servidores Ubuntu e Debian recebendo conta, chave SSH e regra de sudo

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) ALL en /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: NOPASSWD via /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: true añade los grupos listados sin sacar a la persona de los grupos que ya tenga — el equivalente a usermod -aG. El -G sin -a sustituye la lista entera; mira la pega de esto más abajo.
  • update_password: on_create graba la contraseña del vault solo en la creación; luego la persona cambia su propia contraseña con passwd y Ansible no sobrescribe.
  • expires: -1 garantiza cuenta sin fecha de expiración — es lo que deshace un bloqueo si alguien vuelve a ativo.
  • ansible.posix.authorized_key con exclusive: true deja en ~/.ssh/authorized_keys solamente las claves del repositorio. Clave añadida a mano se va en la próxima ejecución. El módulo crea ~/.ssh con el modo 700 y el archivo con 600, como exige sshd. La collection ansible.posix viene en el paquete ansible (en Ubuntu 24.04, versión 1.5.4); con sólo el ansible-core, instálela con ansible-galaxy collection install ansible.posix.
  • O path explícito en authorized_key existe por un motivo práctico: sin él, el --check falla 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 %s prueba 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 ubuntu con 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, fija uid: en la entrada de cada persona.
  • Permisos del homeHOME_MODE en /etc/login.defs é 0750 en Ubuntu 24.04 y 0700 en Debian 13. Otro detalle de Debian 13: ENCRYPT_METHOD é YESCRYPT, contra SHA512 en 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.

Diagrama: ciclo de vida do usuário no código — adicionar, aplicar, bloquear e remover — com os comandos executados e um commit Git em cada passo

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ó del sudo);
  • o cambiar a append: false en 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

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.