Usuários no Linux com Ansible: chaves SSH, sudo e histórico no 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

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.

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 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) ALL em /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: NOPASSWD via /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: true acrescenta os grupos listados sem tirar a pessoa de grupos que ela já tenha — o equivalente a usermod -aG. O -G sem -a substitui a lista inteira; veja a pegadinha disso mais abaixo.
  • update_password: on_create grava a senha do vault só na criação; depois a pessoa troca a própria senha com passwd e o Ansible não sobrescreve.
  • expires: -1 garante conta sem data de expiração — é o que desfaz um bloqueio se alguém voltar a ativo.
  • ansible.posix.authorized_key com exclusive: true deixa em ~/.ssh/authorized_keys somente as chaves do repositório. Chave acrescentada à mão some na próxima execução. O módulo cria ~/.ssh com modo 700 e o arquivo com 600, como o sshd exige. A collection ansible.posix vem no pacote ansible (no Ubuntu 24.04, versão 1.5.4); com só o ansible-core, instale-a com ansible-galaxy collection install ansible.posix.
  • O path explícito no authorized_key existe por um motivo prático: sem ele, o --check falha 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 %s testa 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 ubuntu com 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, fixe uid: na entrada de cada pessoa.
  • Permissão do home — HOME_MODE em /etc/login.defs é 0750 no Ubuntu 24.04 e 0700 no Debian 13. Outro detalhe do Debian 13: ENCRYPT_METHOD é YESCRYPT, contra SHA512 no 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.

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 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 do sudo);
  • ou trocar para append: false no 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

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.