
No Primeiros passos com Ansible você instalou o Ansible, montou o inventário, rodou comandos ad-hoc e escreveu o primeiro playbook. Este post parte dali e resolve um problema que todo administrador tem: dar e tirar acesso de pessoas aos servidores. Conta criada à mão em cada máquina vira bagunça rápido — ninguém sabe quem tem sudo, a chave SSH do estagiário que saiu continua lá e o único registro é a memória de alguém. Aqui a lista de usuários vira código: um arquivo YAML num repositório Git, aplicado pelo Ansible. Criar, bloquear ou remover alguém passa a ser um commit, com autor, data e revisão, e o git log vira a auditoria.
Tudo o que aparece abaixo foi executado de verdade num laboratório com dois servidores — Ubuntu 24.04 e Debian 13 — e uma máquina de controle Ubuntu 24.04 com o Ansible do repositório oficial da distribuição (ansible 9.2.0, ansible-core 2.16.3). As saídas são reais.
O que vamos montar
O projeto tem um arquivo com a lista de pessoas (nome, chave pública, grupos, sudo e estado), um playbook que transforma essa lista em contas nos servidores e um template para o sudo. A máquina de controle só aplica o que está na branch principal; o que chega lá passou por commit e, se você quiser, por revisão.

Cada pessoa passa por três estados, sempre declarados no YAML:
ativo— conta criada, chave SSH instalada, grupos e sudo aplicados;bloqueado— ninguém entra mais, nem por chave, mas a conta e os arquivos ficam para análise;removido— conta, grupo pessoal e home apagados.
A estrutura do projeto no Git
Na máquina de controle, crie o diretório e o repositório. A estrutura final é 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 aponta o inventário, o arquivo com a senha do vault (fora do repositório) e liga o become:
[defaults]
inventory = inventory.ini
vault_password_file = ~/.vault-pass-usuarios
nocows = true
retry_files_enabled = false
[privilege_escalation]
become = true
O inventário usa um usuário de automação, deploy, com chave SSH e sudo sem senha — o mesmo esquema do post anterior. No laboratório os endereços eram contêineres; aqui ficam IPs de documentação:
[servidores]
srv-ubuntu ansible_host=192.0.2.10
srv-debian ansible_host=192.0.2.11
[servidores:vars]
ansible_user=deploy
E o .gitignore existe para uma coisa só: garantir que segredo não entra no histórico. Chave privada e senha em texto puro nunca vão para o Git — o que vai é chave pública e senha cifrada com ansible-vault.
# segredos e chaves privadas nunca entram no repositório
.vault-pass*
id_*
*.pem
*.key
*.retry
A lista de usuários
group_vars/all/usuarios.yml é o arquivo que as pessoas vão editar. Cada entrada tem nome, nome completo, uma lista de chaves públicas, grupos extras e o estado. No primeiro commit a lista começa vazia; este é o estado depois de conceder acesso à Ana e ao 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
As chaves foram geradas com ssh-keygen -t ed25519 no notebook de cada pessoa, que manda só o .pub. Se ainda não tem intimidade com chaves SSH, veja Usando chaves de autenticação no SSH.
Duas formas de dar sudo
Um usuário que entra só por chave não tem senha — e o sudo padrão pede a senha do usuário. Há duas saídas, e o projeto usa as duas para mostrar a diferença:
- Ana: grupo
sudo, com senha. Tanto o Ubuntu quanto o Debian já trazem%sudo ALL=(ALL:ALL) ALLem/etc/sudoers. A senha inicial vem de um hash guardado no vault; é o modelo mais seguro, porque roubar a chave SSH não basta para virar root. - Bruno:
NOPASSWDvia/etc/sudoers.d/. Prático para automação e plantão, mas quem tiver a chave privada dele vira root sem nenhuma barreira extra. Use para poucas pessoas, com chave protegida por passphrase, e revise essa lista com frequência.
O hash da senha da Ana é gerado com openssl passwd -6 e guardado 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
O arquivo cifrado pode ir para o Git; a senha do vault, não. Para mais sobre o vault, veja Automatizando Servidores Linux com Ansible.
A regra de sudo sem senha sai de um template. Ele só lista quem está ativo e tem sudo_nopasswd: true — então bloquear alguém já tira o sudo dele:
# 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 %}
O playbook
usuarios.yml separa a lista pelos três estados e trata cada um. Por baixo, o módulo ansible.builtin.user chama useradd, usermod e userdel — os mesmos comandos que você rodaria à mão, só que em todos os servidores e sempre do mesmo jeito.
---
- 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 }}"
Os detalhes que importam:
append: trueacrescenta os grupos listados sem tirar a pessoa de grupos que ela já tenha — o equivalente ausermod -aG. O-Gsem-asubstitui a lista inteira; veja a pegadinha disso mais abaixo.update_password: on_creategrava a senha do vault só na criação; depois a pessoa troca a própria senha compasswde o Ansible não sobrescreve.expires: -1garante conta sem data de expiração — é o que desfaz um bloqueio se alguém voltar aativo.ansible.posix.authorized_keycomexclusive: truedeixa em~/.ssh/authorized_keyssomente as chaves do repositório. Chave acrescentada à mão some na próxima execução. O módulo cria~/.sshcom modo 700 e o arquivo com 600, como o sshd exige. A collectionansible.posixvem no pacoteansible(no Ubuntu 24.04, versão 1.5.4); com só oansible-core, instale-a comansible-galaxy collection install ansible.posix.- O
pathexplícito noauthorized_keyexiste por um motivo prático: sem ele, o--checkfalha para usuários que ainda não existem, com “Either user must exist or you must provide full path to key file in check mode”. validate: /usr/sbin/visudo -cf %stesta o arquivo de sudo antes de instalá-lo. Um erro de sintaxe em/etc/sudoers.d/pode travar o sudo da máquina inteira; com a validação, o Ansible falha e o arquivo antigo continua lá.
Antes do primeiro commit, rode o ansible-lint (pacote ansible-lint no Ubuntu 24.04, versão 6.17.2):
ansible-lint
Passed: 0 failure(s), 0 warning(s) on 7 files. Last profile that met the validation criteria was 'production'.
Primeiro commit e primeira aplicação
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 mexer em qualquer servidor, veja o que vai mudar com --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
O diff mostra exatamente as chaves e a linha de sudo que vão entrar. Tudo certo, aplique de verdade nos dois 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
E rode de novo. Um playbook bem escrito é idempotente: a segunda execução não muda 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
Conferindo nos servidores
Os comandos clássicos continuam valendo para verificar o que o Ansible fez: getent lê /etc/passwd e /etc/group, id mostra os grupos e chage -l mostra expiração.
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)
Duas diferenças entre as distribuições apareceram aqui:
- UIDs diferentes — a imagem do Ubuntu 24.04 já tinha o usuário
ubuntucom UID 1000, então a Ana ficou com 1002 lá e 1001 no Debian. Não importa para login e sudo, mas importa se os servidores compartilham arquivos por NFS ou restauram backups entre si. Nesse caso, fixeuid:na entrada de cada pessoa. - Permissão do home —
HOME_MODEem/etc/login.defsé0750no Ubuntu 24.04 e0700no Debian 13. Outro detalhe do Debian 13:ENCRYPT_METHODéYESCRYPT, contraSHA512no Ubuntu; o hash$6$(SHA-512) do vault funciona nos dois.
Testando o login sem senha e o sudo
Do “notebook” de cada pessoa (outro contêiner no laboratório), com a chave privada dela:
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).
A Ana entrou sem senha e o sudo dela pediu a senha, como esperado; o Bruno virou root sem senha; e a chave de uma pessoa não abre a conta da outra. O resultado foi idêntico no Debian 13. Com todos entrando por chave, o próximo passo natural é desligar PasswordAuthentication no sshd.
Nova pessoa: branch, revisão e merge
Com o repositório num servidor Git (Gitea, GitLab ou GitHub), dar acesso vira um pull request: alguém propõe, outra pessoa revisa, e só o que entra na main é aplicado. Aqui a Ana pede a conta da Carla, que vai ler logs no plantão mas não tem 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 testar o exclusive: true, antes de aplicar alguém acrescentou à mão uma chave “esquecida” no authorized_keys do Bruno no Ubuntu. A execução com --diff criou a Carla e removeu a chave 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)
A Carla entra, lê /var/log pelo grupo adm e não tem sudo:
uid=1003(carla) gid=1004(carla) groups=1004(carla),4(adm),1001(equipe)
sudo: a password is required
Bloquear antes de remover
Quando alguém sai, a tentação é apagar a conta na hora. Melhor bloquear primeiro: a pessoa perde o acesso imediatamente, mas o home, os crontabs e os arquivos continuam lá para você conferir o que precisa ser transferido. Só depois vem a remoção.

O usermod -L não bloqueia login por chave
Esse é o erro mais comum. usermod -L (e passwd -l, e o password_lock: true do Ansible) só põe um ! na frente do hash em /etc/shadow: invalida a senha. Login por chave não usa senha. Testei cada método isolado, nos dois servidores, com uma conta que só tinha chave 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."
Com a expiração, o servidor registra o motivo — é o módulo pam_unix, na fase account, que recusa mesmo depois de a chave ter sido aceita:
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]
Isso vale porque Ubuntu e Debian usam UsePAM yes no sshd, e o PAM checa a data de expiração em qualquer método de autenticação. A própria página de manual do usermod avisa: para travar a conta, e não só a senha, defina também a expiração como 1. E o shell nologin sozinho também não basta: ele recusa o shell interativo, mas a conexão continua aberta para túneis — um ssh -N com a conta em nologin ficou conectado até eu matá-lo.
Por isso o estado bloqueado do playbook faz quatro coisas: trava a senha, expira a conta em 1970-01-02 (expires: 86400, um dia depois da época Unix), apaga o authorized_keys e mata os processos da pessoa. O template de sudo deixa de listar quem não está ativo.
Bloqueando o Bruno com uma sessão aberta
Com o Bruno conectado no Ubuntu rodando um 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
No notebook do Bruno, a sessão caiu na hora:
Connection to users-lab-ubuntu2404 closed by remote host.
E, para provar que a expiração segura o acesso mesmo que alguém devolva a chave à mão, recoloquei o authorized_keys dele e tentei de novo:
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
Mesmo resultado no Debian 13. Antes de remover, procure o que o Bruno deixou fora do home — arquivos em /srv, /var/www, crontab em /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"
Aqui um FAILED é a resposta boa: o crontab -l sai com código 1 e a mensagem “no crontab for bruno” quando não há crontab. No laboratório não havia nada. Faça também o backup do home, se ele tiver algo de valor.
Remover a conta
A remoção é outro commit — e aqui a regra de ouro do projeto: não apague a entrada do YAML, mude o estado para 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 com remove: true equivale a userdel -r: apaga a conta, o grupo pessoal e o home. Nos dois servidores, o Bruno sumiu de todos os arquivos:
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
Se ainda houver processo do usuário rodando, o userdel se recusa a apagar a conta e o Ansible falha nessa tarefa com “userdel: user … is currently used by process …” (testei) — mais um motivo para passar pelo bloqueio, que mata os processos antes. Arquivos que ficarem fora do home passam a pertencer a um UID “órfão”, que o próximo usuário criado pode herdar; daí a busca com find -user antes da remoção.
Por que não apagar a entrada? Porque o Ansible só gerencia o que está na lista. Testei tirando a Carla do YAML e rodando o playbook: changed=0, e ela continuou entrando por SSH normalmente. Apagar a linha não remove ninguém — só faz o Ansible esquecer que a conta existe.
Auditoria com o git log
Agora a pergunta “quem deu acesso a quem, e quando?” tem resposta em 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
O histórico completo de uma pessoa, com o diff de cada mudança 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
Quem escreveu cada linha da conta da 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
E quem concedeu sudo sem senha, em qualquer ponto da história — o -S procura commits que adicionaram ou removeram o 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)
O autor de um commit é o que a pessoa configurou no Git, então vale o quanto você confia nele. Para auditoria séria, exija commits assinados (git commit -S) e proteja a branch main no servidor Git, aceitando só merge revisado.
O Git não substitui o log do servidor, ele o complementa. O useradd, o usermod e o userdel registram cada operação no journal, e o sudo registra o que o usuário deploy do Ansible executou:
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'
O horário do journal casa com o commit: dá para amarrar cada mudança no servidor ao commit que a pediu.
Desfazendo um engano com git revert
Bloqueou a pessoa errada? O conserto é o mesmo fluxo ao contrário. No laboratório, bloqueei a Carla de propósito, confirmei que o login dela passou a falhar e desfiz:
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)
A conta voltou a não expirar, a chave voltou e a Carla entrou de novo — e o engano e a correção ficaram, os dois, no histórico. Um limite: para quem tem senha (como a Ana), o bloqueio também trava a senha com !, e o revert não destrava; nesse caso rode usermod -U ou defina uma senha nova. O playbook não usa password_lock: false nas contas ativas porque, em conta só com chave, o usermod -U se recusa (“unlocking the user’s password would result in a passwordless account”) e o Ansible marcaria changed em toda execução.
Pegadinha: o append não tira ninguém de um grupo
append: true protege grupos que outras roles adicionaram (o docker, por exemplo), mas tem um custo: tirar sudo da lista de grupos da Ana não tira a Ana do grupo. Testei: com grupos: [], o playbook deu changed=0 e o id ana continuou mostrando 27(sudo). Para revogar, você tem duas saídas:
- remover o grupo explicitamente, uma vez:
ansible servidores -m ansible.builtin.user -a "name=ana groups=ana,equipe append=false"(testado: a Ana saiu dosudo); - ou trocar para
append: falseno playbook, se o YAML for a única fonte de verdade para os grupos das pessoas — aí a lista passa a ser exata, e qualquer grupo acrescentado fora dela some.
É o mesmo perigo do usermod -G sem -a no terminal, só que ao contrário: lá você tira grupos sem querer; aqui você acha que tirou e não tirou.
Código completo
O projeto virou um repositório no GitHub, pronto para clonar: jniltinho/ansible-linux-users. Lá o código está organizado numa role reutilizável, linux_users, com validação da lista antes de qualquer mudança, testes de integração no Ubuntu 24.04 e no Debian 13 e CI no GitHub Actions. Para quem quer só os arquivos deste post, eles também estão num Gist.
O código do repositório e do Gist está em inglês, com os mesmos passos deste post: usuarios vira linux_users_accounts (no Gist, users), estado vira state, e os estados ativo, bloqueado e removido viram active, locked e removed. As chaves de exemplo são públicas e foram geradas só para o laboratório, os IPs são de documentação, e o arquivo do vault aparece como exemplo em texto puro: cifre o seu antes do primeiro commit.
Referências
- Módulo ansible.builtin.user
- Módulo ansible.posix.authorized_key
- Módulo ansible.builtin.template (parâmetro validate)
- Guia do Ansible Vault
- usermod(8) e shadow(5)
Conclusão
Com a lista de usuários no Git, o acesso aos servidores deixa de depender de memória: cada conta tem um commit que explica por que ela existe, cada sudo tem um autor e cada saída tem data. O Ansible garante que os servidores batem com o repositório — inclusive apagando a chave que alguém colocou à mão. Duas lições do laboratório para levar: bloqueie antes de remover, e lembre que usermod -L trava a senha, não a conta; quem barra a chave SSH é a expiração. Para os fundamentos, volte ao Primeiros passos com Ansible; para vault, roles e casos maiores, siga para Automatizando Servidores Linux com Ansible. E, se o Git ainda é novidade, comece por Git simples e rápido.