Set-OPS-Public/playbooks/maintenance/devis-identite.yml
Daniel Allaire 777bea8408 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

175 lines
7.7 KiB
YAML

---
# 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:
# 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"
# 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
# `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:///"
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:///"
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"