Set-OPS-Public/playbooks/maintenance/devis-identite.yml

176 lines
7.7 KiB
YAML
Raw Normal View History

---
# Devis d'identite — RELEVE seul. Ne modifie rien (D-23 : un devis se lit d'abord).
#
# Le playbook collecte deux dictionnaires — ce que le depot DECLARE, ce que la machine
# PORTE — et les depose en JSON sur le controleur. La comparaison est faite par
# `scripts/devis_identite.py` : elle n'a rien a faire en Jinja, et le depot a deja
# cette forme (devis_opnsense, devis_sdn : Python raisonne, Ansible releve).
#
# Pourquoi ce devis existe. Les 30 preuves de `prouver.py` sont STATIQUES : elles
# montrent que le depot est coherent avec lui-meme. Aucune ne demande au systeme
# deploye s'il ressemble a ce que le depot annonce. Les quatre defauts du 2026-08-08
# etaient tous de ce second type — chacun trouve en relisant apres avoir ecrit,
# aucun signale par un test.
#
# make identite-plan
- name: Devis d'identité — relever le déclaré et le réel
hosts: serveur_keycloak
become: true
gather_facts: false
vars:
kc: "http://localhost:8080"
devis_identite_sortie: "{{ playbook_dir }}/../../instance/devis-identite.json"
tasks:
devis des certificats : disque contre memoire, et l'AC etait expiree Deuxieme application du patron devis/applicateur aux services. Trouve a la premiere execution : sur infra-pki-01 — l'autorite elle-meme — le certificat etait expire depuis plus de 8 h et le renouvellement echouait toutes les 14 minutes sur « 'step ca renew' requires the '--ca-url' flag ». Rien ne le signalait. Cause : sur l'hote de l'AC, /etc/step est le STEPPATH du SERVEUR, pas un amorcage client — pas de defaults.json, et l'unite de renouvellement en dependait. La lecon etait deja ecrite dans le commentaire de la tache d'emission (« l'autorite ne bootstrape pas »), jamais reportee sur l'unite. Le role ne pouvait pas non plus se soigner : la re-emission ne regardait que la FORME (cert absent ou SAN manquant), jamais la validite. client_pki verifie desormais l'echeance (client_pki_marge_renouvellement). Ce qu'il a fallu desapprendre : les certificats vivent 24 h et se renouvellent toutes les ~14 min ; « empreinte servie != empreinte disque » est l'etat NORMAL. Comparer les empreintes aurait donne un verificateur qui crie en permanence. Le signal est l'echeance de ce qui est SERVI, plus l'absence de client_pki_reload_services. Le devis a d'abord menti, du defaut meme qu'il traque : include_vars au niveau du play prime sur les group_vars. Et le premier correctif a PARU marcher — set_fact accepte un dictionnaire entier en argument libre sans erreur et n'en fait rien. Il faut reimposer cle par cle. Les deux devis sont corriges et le piege est consigne dans docs/devis-services.md avant d'ecrire le prochain. Verifie dans les deux sens : CONFORME sur 14 hotes ; sur un releve ou l'on rejoue une copie perimee en memoire, 2 ecarts et code de sortie 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:20:56 -04:00
# PRECEDENCE — `include_vars` au niveau du play PRIME sur les `group_vars`.
# Charger les defauts d'un role tels quels ferait donc mentir le devis : il
# comparerait au defaut du depot au lieu de la valeur reellement declaree par
# l'inventaire. Mesure du 2026-08-08 : le premier jet rapportait
# `client_pki_reload_services: []` sur les 14 hotes alors que quatre groupes le
# declarent. Le devis portait exactement le defaut qu'il est cense traquer.
#
# On retient donc ce que l'inventaire declare AVANT de charger les defauts, puis
# on le reimpose. (Charger sous un `name:` ne marche pas : les defauts se citent
# entre eux — `serveur_keycloak_cert` reference `serveur_keycloak_steppath` — et ces
# references ne se resolvent plus une fois enfermees dans un dictionnaire.)
- name: Retenir ce que l'inventaire déclare pour serveur_keycloak
ansible.builtin.set_fact:
devis_inventaire: >-
{{ hostvars[inventory_hostname] | dict2items
| selectattr('key', 'match', '^serveur_keycloak_') | items2dict }}
- name: Charger les défauts du rôle serveur_keycloak
ansible.builtin.include_vars:
dir: "{{ playbook_dir }}/../../roles/serveur_keycloak/defaults"
devis des certificats : disque contre memoire, et l'AC etait expiree Deuxieme application du patron devis/applicateur aux services. Trouve a la premiere execution : sur infra-pki-01 — l'autorite elle-meme — le certificat etait expire depuis plus de 8 h et le renouvellement echouait toutes les 14 minutes sur « 'step ca renew' requires the '--ca-url' flag ». Rien ne le signalait. Cause : sur l'hote de l'AC, /etc/step est le STEPPATH du SERVEUR, pas un amorcage client — pas de defaults.json, et l'unite de renouvellement en dependait. La lecon etait deja ecrite dans le commentaire de la tache d'emission (« l'autorite ne bootstrape pas »), jamais reportee sur l'unite. Le role ne pouvait pas non plus se soigner : la re-emission ne regardait que la FORME (cert absent ou SAN manquant), jamais la validite. client_pki verifie desormais l'echeance (client_pki_marge_renouvellement). Ce qu'il a fallu desapprendre : les certificats vivent 24 h et se renouvellent toutes les ~14 min ; « empreinte servie != empreinte disque » est l'etat NORMAL. Comparer les empreintes aurait donne un verificateur qui crie en permanence. Le signal est l'echeance de ce qui est SERVI, plus l'absence de client_pki_reload_services. Le devis a d'abord menti, du defaut meme qu'il traque : include_vars au niveau du play prime sur les group_vars. Et le premier correctif a PARU marcher — set_fact accepte un dictionnaire entier en argument libre sans erreur et n'en fait rien. Il faut reimposer cle par cle. Les deux devis sont corriges et le piege est consigne dans docs/devis-services.md avant d'ecrire le prochain. Verifie dans les deux sens : CONFORME sur 14 hotes ; sur un releve ou l'on rejoue une copie perimee en memoire, 2 ecarts et code de sortie 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:20:56 -04:00
# Cle par cle : `set_fact` ne prend pas un dictionnaire entier en argument libre —
# il l'accepte sans erreur et n'en fait rien. C'est ainsi que le premier correctif
# a paru fonctionner tout en laissant le devis mentir.
- name: Rendre le dernier mot à l'inventaire
ansible.builtin.set_fact:
"{{ item.key }}": "{{ item.value }}"
loop: "{{ devis_inventaire | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: Résoudre la politique de mot de passe (déclaration unique)
ansible.builtin.include_role:
name: resoudre_politique_mdp
# Meme raison : les DN de l'annuaire ne se recopient pas, ils se resolvent.
- name: Résoudre la connexion à l'annuaire
ansible.builtin.include_role:
name: resoudre_annuaire
- name: Charger le registre des applications
ansible.builtin.include_vars:
file: "{{ setops_plan_dir }}/applications.yml"
- name: Obtenir un jeton d'administration
ansible.builtin.uri:
url: "{{ kc }}/realms/master/protocol/openid-connect/token"
method: POST
body_format: form-urlencoded
body:
grant_type: password
client_id: admin-cli
username: "{{ serveur_keycloak_admin_user }}"
password: "{{ serveur_keycloak_admin_password }}"
register: devis_jeton
no_log: true
- name: Lire le realm
ansible.builtin.uri:
url: "{{ kc }}/admin/realms/{{ serveur_keycloak_realm }}"
headers:
Authorization: "Bearer {{ devis_jeton.json.access_token }}"
register: devis_realm
no_log: true
- name: Lire la fédération LDAP
ansible.builtin.uri:
url: "{{ kc }}/admin/realms/{{ serveur_keycloak_realm }}/components?type=org.keycloak.storage.UserStorageProvider"
headers:
Authorization: "Bearer {{ devis_jeton.json.access_token }}"
register: devis_fed
no_log: true
- name: Lire les mappeurs de la fédération
ansible.builtin.uri:
url: >-
{{ kc }}/admin/realms/{{ serveur_keycloak_realm }}/components?parent={{
devis_fed.json[0].id }}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper
headers:
Authorization: "Bearer {{ devis_jeton.json.access_token }}"
register: devis_mappeurs
no_log: true
portabilite : Technolibre debout, six devis, et P35 L'epreuve de portabilite est passee. Un second ecosysteme souverain complet, monte depuis zero par le meme moteur : 14 hotes, 2583 taches ok, 331 changed, 0 failed. Plan distinct, voute separee, realm technolibre, sa propre AC — et une topologie differente : LDAP et SSO sur des machines separees la ou Chezlepro les co-localise. Cinq devis CONFORME (identite, certificats, PostgreSQL, courriel, frontiere — 55 lignes 0 ecart). Le sixieme dit exactement la bonne chose : les 6 services repondent depuis l'edge, aucun depuis le poste, qui ne resout pas encore technolibre.internal (6 entrees /etc/hosts absentes — le plancher). SIXIEME DEFAUT MOTEUR. Le devis d'identite interrogeait LDAP en `ldapi:///` — un socket UNIX LOCAL — depuis l'hote serveur_keycloak. Cela ne marchait que par CO-LOCATION ACCIDENTELLE. Un tenant qui separe l'annuaire du SSO echouait sur « Failed to import python-ldap » : l'hote SSO n'a pas de client LDAP. Les deux lectures sont deleguees a l'hote DERIVE par resoudre_annuaire. P35 (D-75) : toute application dont le role exige une base en a une au plan. La garde de resoudre_base existait deja, mais s'est declenchee a la 92e tache de collab-01, apres quarante minutes, pour un ecart entierement lisible dans le plan. Rien n'y est code en dur : les roles concernes sont ceux qui INCLUENT resoudre_base, et le groupe reclame est lu dans le DEFAUT de la variable passee — jamais deduit du nom. serveur_icingaweb2 reclame la base de serveur_icinga ; une preuve supposant « role = groupe » aurait crie sur un cas sain. Eprouvee dans les deux sens ET sur les deux tenants, dont les registres n'ont pas la meme portee : base retiree -> ECHEC la nommant ; restauree -> OK. Verifie : prouver.py 35 OK sur les deux instances, ansible-lint production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:36:44 -04:00
# `ldapi:///` est un socket UNIX LOCAL : ces deux lectures ne peuvent avoir lieu que
# SUR l'annuaire. Le play tourne sur `serveur_keycloak`, et cela fonctionnait par
# ACCIDENT — chez l'instance d'origine, l'annuaire et le SSO sont co-localises sur le
# meme hote. Un tenant qui les separe (`id-ldap-01` / `id-sso-01`) faisait echouer le
# devis sur « Failed to import the required Python library (python-ldap) » : l'hote
# SSO n'a evidemment pas de client LDAP. Mesure du 2026-08-10 sur Technolibre.
#
# On delegue donc a l'hote DERIVE par `resoudre_annuaire`, qui sait deja ou vit
# l'annuaire. Co-localises, la delegation est un aller-retour sans effet ; separes,
# elle est la seule facon que ca marche.
- name: Lire la politique de mot de passe de l'annuaire
community.general.ldap_search:
dn: "ou=policies,{{ resoudre_annuaire_base_dn }}"
scope: onelevel
filter: "(objectClass=pwdPolicy)"
server_uri: "ldapi:///"
portabilite : Technolibre debout, six devis, et P35 L'epreuve de portabilite est passee. Un second ecosysteme souverain complet, monte depuis zero par le meme moteur : 14 hotes, 2583 taches ok, 331 changed, 0 failed. Plan distinct, voute separee, realm technolibre, sa propre AC — et une topologie differente : LDAP et SSO sur des machines separees la ou Chezlepro les co-localise. Cinq devis CONFORME (identite, certificats, PostgreSQL, courriel, frontiere — 55 lignes 0 ecart). Le sixieme dit exactement la bonne chose : les 6 services repondent depuis l'edge, aucun depuis le poste, qui ne resout pas encore technolibre.internal (6 entrees /etc/hosts absentes — le plancher). SIXIEME DEFAUT MOTEUR. Le devis d'identite interrogeait LDAP en `ldapi:///` — un socket UNIX LOCAL — depuis l'hote serveur_keycloak. Cela ne marchait que par CO-LOCATION ACCIDENTELLE. Un tenant qui separe l'annuaire du SSO echouait sur « Failed to import python-ldap » : l'hote SSO n'a pas de client LDAP. Les deux lectures sont deleguees a l'hote DERIVE par resoudre_annuaire. P35 (D-75) : toute application dont le role exige une base en a une au plan. La garde de resoudre_base existait deja, mais s'est declenchee a la 92e tache de collab-01, apres quarante minutes, pour un ecart entierement lisible dans le plan. Rien n'y est code en dur : les roles concernes sont ceux qui INCLUENT resoudre_base, et le groupe reclame est lu dans le DEFAUT de la variable passee — jamais deduit du nom. serveur_icingaweb2 reclame la base de serveur_icinga ; une preuve supposant « role = groupe » aurait crie sur un cas sain. Eprouvee dans les deux sens ET sur les deux tenants, dont les registres n'ont pas la meme portee : base retiree -> ECHEC la nommant ; restauree -> OK. Verifie : prouver.py 35 OK sur les deux instances, ansible-lint production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:36:44 -04:00
delegate_to: "{{ resoudre_annuaire_hote }}"
register: devis_ppolicy
changed_when: false
# Un compte sans adresse ne peut pas recevoir de lien de reinitialisation : pour
# lui, « mot de passe oublie » est un ecran qui ne mene nulle part, et le seul
# recours redevient l'exploitant manipulant le mot de passe d'autrui.
- name: Lire les comptes de l'annuaire
community.general.ldap_search:
dn: "{{ resoudre_annuaire_users_dn }}"
scope: onelevel
filter: "(objectClass=inetOrgPerson)"
attrs: ["uid", "mail", "givenName"]
server_uri: "ldapi:///"
portabilite : Technolibre debout, six devis, et P35 L'epreuve de portabilite est passee. Un second ecosysteme souverain complet, monte depuis zero par le meme moteur : 14 hotes, 2583 taches ok, 331 changed, 0 failed. Plan distinct, voute separee, realm technolibre, sa propre AC — et une topologie differente : LDAP et SSO sur des machines separees la ou Chezlepro les co-localise. Cinq devis CONFORME (identite, certificats, PostgreSQL, courriel, frontiere — 55 lignes 0 ecart). Le sixieme dit exactement la bonne chose : les 6 services repondent depuis l'edge, aucun depuis le poste, qui ne resout pas encore technolibre.internal (6 entrees /etc/hosts absentes — le plancher). SIXIEME DEFAUT MOTEUR. Le devis d'identite interrogeait LDAP en `ldapi:///` — un socket UNIX LOCAL — depuis l'hote serveur_keycloak. Cela ne marchait que par CO-LOCATION ACCIDENTELLE. Un tenant qui separe l'annuaire du SSO echouait sur « Failed to import python-ldap » : l'hote SSO n'a pas de client LDAP. Les deux lectures sont deleguees a l'hote DERIVE par resoudre_annuaire. P35 (D-75) : toute application dont le role exige une base en a une au plan. La garde de resoudre_base existait deja, mais s'est declenchee a la 92e tache de collab-01, apres quarante minutes, pour un ecart entierement lisible dans le plan. Rien n'y est code en dur : les roles concernes sont ceux qui INCLUENT resoudre_base, et le groupe reclame est lu dans le DEFAUT de la variable passee — jamais deduit du nom. serveur_icingaweb2 reclame la base de serveur_icinga ; une preuve supposant « role = groupe » aurait crie sur un cas sain. Eprouvee dans les deux sens ET sur les deux tenants, dont les registres n'ont pas la meme portee : base retiree -> ECHEC la nommant ; restauree -> OK. Verifie : prouver.py 35 OK sur les deux instances, ansible-lint production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:36:44 -04:00
delegate_to: "{{ resoudre_annuaire_hote }}"
register: devis_comptes
changed_when: false
- name: Déposer le relevé sur le contrôleur
ansible.builtin.copy:
dest: "{{ devis_identite_sortie }}"
mode: "0600"
content: "{{ {
'hote': inventory_hostname,
'realm': serveur_keycloak_realm,
'declare': {
'passwordPolicy': resoudre_politique_mdp_keycloak,
'brute': resoudre_politique_mdp_brute,
'ppolicy': resoudre_politique_mdp_ldap,
'durcissement': serveur_keycloak_ldap_durcissement,
'mappeurs': serveur_keycloak_ldap_attributs,
'editMode': serveur_keycloak_ldap_edit_mode,
'resetPasswordAllowed': serveur_keycloak_reset_mot_de_passe | bool,
'smtpHost': applications[serveur_keycloak_smtp_app].hote ~ '.' ~ domaine_interne,
},
'reel': {
'realm': devis_realm.json,
'federation': devis_fed.json[0].config,
'mappeurs': dict(devis_mappeurs.json | map(attribute='name') | list
| zip(devis_mappeurs.json | map(attribute='config') | list)),
'ppolicy': (devis_ppolicy.results | first) | default({}),
'comptes': devis_comptes.results,
},
} | to_nice_json }}"
delegate_to: localhost
become: false
- name: Indiquer le relevé
ansible.builtin.debug:
msg: "Relevé déposé : {{ devis_identite_sortie }} — comparaison par scripts/devis_identite.py"