identite : « mot de passe oublie » — raccord SMTP derive du plan

Le realm portait une politique d'acces complete et aucun moyen d'ecrire a qui
que ce soit. Tout oubli remontait donc a l'exploitant, qui n'avait d'autre
choix que de manipuler le mot de passe d'autrui.

- serveur_keycloak/tasks/courriel-realm.yml : reconcilie smtpServer et
  resetPasswordAllowed ; hote du relais DERIVE de applications.postfix.hote,
  et refus explicite si le plan ne declare pas de MTA.
- passe par l'API d'administration : kcadm.sh accepte les deux formes -s sur
  une map, sort en succes et n'ecrit rien (smtpServer reste vide).
- amorcage_acces_courriel redevient a declarer : cette adresse designe une
  personne, hors du systeme qu'on amorce ; une boite interne serait illisible
  tant qu'on n'a pas l'acces qu'on cherche justement a recuperer.
- autorisation.md §6.6 : le mecanisme, ses deux conditions, et l'ecart
  d'adresse laisse par l'ancien mode READ_ONLY de la federation.

Preuve : banniere SMTP lue depuis idm-01, RCPT TO accepte, execute-actions-email
declenche, MTA en starttls -> relay=mx.chezlepro.ca status=sent (250). Second
deploiement changed=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-08 06:08:06 -04:00
parent 4642ffec0a
commit 775df924cb
8 changed files with 273 additions and 5 deletions

View file

@ -1,5 +1,49 @@
# CHANGELOG — Set-OPS
## 2026-08-08 — « Mot de passe oublié » : Keycloak sait enfin envoyer
Le realm affichait une politique d'accès complète et **aucun moyen d'écrire à qui que ce
soit** : `smtpServer` vide, `resetPasswordAllowed` à `false`. Conséquence concrète — tout
oubli de mot de passe remontait à l'exploitant, qui n'avait alors d'autre choix que de
manipuler le mot de passe de quelqu'un d'autre. C'est précisément ce que « une identité,
une personne » (§3 d'`autorisation.md`) cherche à écarter.
**Ce qui est ajouté.** `serveur_keycloak/tasks/courriel-realm.yml` réconcilie la strophe
courriel du realm et le drapeau « mot de passe oublié ». L'hôte du relais est **dérivé du
plan** (`applications.postfix.hote`) : aucun nom de machine n'est écrit. Si le plan ne
déclare pas de MTA, le rôle **refuse** — un écran qui promet un courriel que personne
n'enverrait serait pire que pas d'écran du tout.
**`kcadm.sh` ne sait pas écrire une map, et ne le dit pas.** Sur `smtpServer`, les deux
formes documentées — `-s smtpServer.host=…` et `-s 'smtpServer={"host":…}'` — sortent en
**succès, sans rien écrire**. Le champ est resté `{ }` après deux déploiements verts. La
tâche passe donc par l'API d'administration (`uri`), qui répond `204` et écrit vraiment.
Même famille que le reste de ce journal : une valeur déclarée d'un côté, jamais vérifiée
de l'autre. Ce qui l'a rattrapée, c'est d'avoir relu l'état après l'avoir posé — pas le
code de retour.
**L'adresse de l'amorçage ne se dérive pas.** J'avais d'abord posé
`{{ amorcage_acces_uid }}@{{ domaine_interne }}` comme défaut : c'est un piège. Cette
adresse désigne une **personne**, donc quelque chose d'extérieur au système qu'on amorce,
et une boîte interne n'est pas lisible tant qu'on n'a pas justement l'accès qu'on essaie de
récupérer. `amorcage_acces_courriel` redevient donc à déclarer, et le rôle refuse de créer
le compte sans elle — mais seulement à la création, pour qu'un écosystème déjà amorcé ne se
mette pas à échouer parce qu'on a durci la règle après coup.
**Un reliquat de `READ_ONLY` mis au jour.** Le compte `sysadmin` portait
`sysadmin@chezlepro.ca` dans Keycloak et **rien** dans LDAP. L'adresse avait été saisie
dans la console de compte quand la fédération était encore en lecture seule : Keycloak
l'avait gardée pour lui, l'annuaire ne l'a jamais reçue, et les deux côtés ont affiché des
valeurs différentes sans que rien ne le signale. Corrigé dans LDAP (source de vérité) puis
resynchronisé ; consigné au runbook (§6.6) parce que d'autres comptes créés avant la
bascule en `WRITABLE` peuvent porter le même écart.
**Preuve de bout en bout**, et pas un `connect()` : bannière SMTP lue depuis `idm-01`,
`RCPT TO` accepté, puis un vrai `execute-actions-email` déclenché — journal du MTA :
`starttls=1`, `to=<sysadmin@chezlepro.ca>, relay=mx.chezlepro.ca[69.70.26.53]:25,
status=sent (250 2.0.0 Ok)`. Le second déploiement rapporte `changed=0` : la tâche
réconcilie, elle ne réécrit pas.
## 2026-08-06 — le chemin nord-sud devient dérivable
### `vault_openldap_admin` — quatre consommateurs et deux pièges
@ -70,13 +114,13 @@ pu s'authentifier pour poser la nouvelle valeur.
```
C'est une **procédure**, pas un redéploiement — et la même contrainte vaut pour
`vault_openldap_admin`, qui reste à faire. Consigné au runbook (§6.7), avec le rappel qu'une
`vault_openldap_admin`, qui reste à faire. Consigné au runbook (§6.8), avec le rappel qu'une
vérification n'est pas un message de succès.
### La rotation des comptes de secours devient possible — elle ne l'était pas
Le runbook §6.6 promettait de régénérer les comptes de secours ; **le code ne savait pas le
Le runbook §6.7 promettait de régénérer les comptes de secours ; **le code ne savait pas le
faire**. Les trois rôles ne posaient le mot de passe qu'à la *création* :
```

View file

@ -0,0 +1,66 @@
# Preuve de conformite — Set-OPS — 2026-08-08
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (29 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 5 tests passes. |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 74 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 25 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 31 regles, 2 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 38 groupe(s), 64 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 0 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-08._

View file

@ -186,7 +186,39 @@ occ user:resetpassword
Et si LDAP lui-même est en panne, ces trois commandes fonctionnent quand même : elles ne
dépendent ni de Keycloak, ni de l'annuaire. C'est le sens de D-40.
### 6.6 Ce que tu dois changer en priorité
### 6.6 « Mot de passe oublié » — pour ne pas dépendre de toi
Un utilisateur qui oublie son mot de passe ne doit pas avoir à t'appeler : la seule issue
serait alors que tu manipules le mot de passe de quelqu'un d'autre, ce que « une identité,
une personne » cherche précisément à écarter. L'écran de connexion du realm porte donc le
lien **Forgot password?**, et Keycloak envoie lui-même le courriel.
Deux conditions, toutes deux tenues par le code :
- **Un relais SMTP.** `serveur_keycloak` le dérive du plan (`applications.postfix.hote`) —
aucun nom de machine n'est écrit nulle part. Le MTA accepte les hôtes du supernet sans
authentification (`mynetworks`) et `idm-01` porte déjà `client_smtp`, donc le flux existe.
Si le plan ne déclare pas de MTA, le rôle **refuse** plutôt que d'afficher un écran qui
promet un courriel que personne n'enverrait.
- **Une adresse sur le compte.** C'est la seule valeur de l'amorçage qui ne se dérive pas :
elle désigne une personne, donc quelque chose d'extérieur au système qu'on amorce.
`amorcage_acces_courriel` doit être déclarée, et le rôle refuse de créer le compte sans
elle. Une adresse `sysadmin@<domaine_interne>` serait un piège : elle est servie par une
boîte que tu ne peux pas encore lire.
**Attention à un reliquat.** Tant que la fédération LDAP était en `READ_ONLY`, une adresse
saisie dans la console de compte restait dans la base de Keycloak sans jamais atteindre
l'annuaire. Les deux côtés pouvaient donc afficher des adresses différentes sans que rien
ne le signale. En `WRITABLE` (le mode actuel) le problème ne se reproduit pas, mais les
comptes créés avant peuvent encore porter l'écart. Pour le voir et le réduire :
```
ldapsearch -x -H ldapi:/// -D "cn=admin,$BASE" -W -b "uid=<uid>,ou=people,$BASE" mail
# puis, si les deux diffèrent : corriger dans LDAP (source de vérité), et resynchroniser
# Identity providers → LDAP → Sync all users
```
### 6.7 Ce que tu dois changer en priorité
1. **Le mot de passe d'amorçage** — imposé dès la première connexion, tu n'as pas le
choix (§3).
@ -194,7 +226,7 @@ dépendent ni de Keycloak, ni de l'annuaire. C'est le sens de D-40.
déploiement y a eu accès. Les régénérer transfère réellement le contrôle.
3. **Le mot de passe de la voûte** elle-même, si le dépôt change de mains.
### 6.7 Faire tourner un secret : l'ordre n'est pas indifférent
### 6.8 Faire tourner un secret : l'ordre n'est pas indifférent
**Les comptes de secours des services** (`vault_grafana_admin`, `vault_forgejo_admin`,
`vault_nextcloud_admin`) se régénèrent en voûte, puis un redéploiement les applique. Les rôles

View file

@ -15,7 +15,15 @@ amorcage_acces_actif: true
# Le compte d'amorcage. `uid` est l'identifiant de connexion.
amorcage_acces_uid: "sysadmin"
amorcage_acces_nom: "Administrateur systeme"
amorcage_acces_courriel: "" # facultatif ; vide = pas d'attribut mail
# A DECLARER — la seule valeur de ce role qui ne peut PAS se deriver. Elle
# designe une personne, qui est par definition hors du systeme qu'on amorce :
# une adresse `{{ amorcage_acces_uid }}@{{ '{{' }} domaine_interne {{ '}}' }}`
# serait servie par une boite que l'exploitant ne peut pas encore lire.
#
# Sans elle, « mot de passe oublie » (serveur_keycloak) affiche un ecran qui
# promet un courriel dont l'adresse n'existe nulle part, et le seul recours
# redevient l'exploitant manipulant le mot de passe d'autrui.
amorcage_acces_courriel: ""
# Le groupe qui porte l'habilitation. Les services le reconnaissent par
# `meta/acces.yml` — eux sont reconcilies, l'appartenance ne l'est pas.

View file

@ -52,6 +52,22 @@
# --- Tout ce qui suit ne s'exécute QUE si le compte est absent -----------------
# Verifie ICI et non plus haut : un ecosysteme deja amorce ne doit pas se mettre
# a echouer parce qu'on a durci la regle apres coup.
- name: Exiger une adresse de courriel pour le compte d'amorçage
ansible.builtin.assert:
that:
- amorcage_acces_courriel | length > 0
fail_msg: >-
`amorcage_acces_courriel` est vide. Ce compte est le seul recours quand
plus rien d'autre ne repond : sans adresse joignable, « mot de passe
oublie » ne mene nulle part et la reprise repasse par un tiers qui
manipule le mot de passe de quelqu'un d'autre. Declarer une adresse que
la personne lit DEJA, hors de l'ecosysteme qu'on amorce.
when:
- amorcage_acces_actif | bool
- not (amorcage_acces_deja_fait | bool)
# Le mot de passe est HACHÉ par slapd lui-même. `ldap_entry` écrirait la chaîne
# littéralement : le jeton d'amorçage se retrouverait en clair dans la base, lisible
# par quiconque peut lire l'annuaire.

View file

@ -120,3 +120,25 @@ serveur_keycloak_amorcage_uid: "{{ amorcage_acces_uid | default('sysadmin') }}"
# Empreinte du mot de passe de LIAISON LDAP applique — Keycloak le masque, on ne
# peut donc pas le comparer. Permet sa rotation. Voir tasks/federation-ldap.yml.
serveur_keycloak_marqueur_bind: "/etc/keycloak/.setops-bind-sha256"
# --- Courriel du realm : « mot de passe oublie » ------------------------------
# Sans SMTP, `resetPasswordAllowed` ne sert a rien — Keycloak n'a aucun moyen
# d'envoyer le lien. Et sans « mot de passe oublie », chaque oubli passe par
# l'exploitant, qui manipule alors le mot de passe de quelqu'un d'autre : ce que
# « une identite, une personne » cherche precisement a eviter.
#
# Le relais est le MTA du tenant, DERIVE du plan (`applications.postfix.hote`) :
# il accepte les hotes du supernet sans authentification (`mynetworks`), et
# `idm-01` porte deja `client_smtp` — le flux existe.
serveur_keycloak_smtp_actif: true
serveur_keycloak_smtp_app: "postfix"
serveur_keycloak_smtp_port: 25
serveur_keycloak_smtp_starttls: true
serveur_keycloak_smtp_expediteur: "identite@{{ domaine_interne }}"
serveur_keycloak_smtp_nom_expediteur: "{{ organisation | default('Set-OPS') }}"
# Repli : utilise seulement si le plan ne declare pas d'application `postfix`.
serveur_keycloak_smtp_hote: ""
# « Mot de passe oublie » sur l'ecran de connexion. Exige le SMTP ci-dessus et une
# adresse `mail` sur le compte — sans elle, Keycloak n'a nulle part ou ecrire.
serveur_keycloak_reset_mot_de_passe: true

View file

@ -0,0 +1,76 @@
---
# Courriel du realm + « mot de passe oublie ». Voir defaults/main.yml.
#
# API d'administration et NON `kcadm.sh` : sur `smtpServer` (une map), kcadm
# accepte `-s smtpServer.host=…` COMME `-s 'smtpServer={"host":…}'`, sort en
# succes, n'ecrit rien — le champ restait `{ }` apres deux deploiements verts.
# Constate le 2026-08-08. Meme famille que le reste : une valeur declaree d'un
# cote, jamais verifiee de l'autre.
- name: Dériver l'hôte du relais SMTP depuis le plan
ansible.builtin.set_fact:
serveur_keycloak_smtp_hote: "{{ applications[serveur_keycloak_smtp_app].hote ~ '.' ~ domaine_interne }}"
when:
- serveur_keycloak_smtp_actif | bool
- ((applications | default({})).get(serveur_keycloak_smtp_app, {}).get('hote') | default('')) | length > 0
- name: Exiger un relais SMTP résolu
ansible.builtin.assert:
that:
- serveur_keycloak_smtp_hote | length > 0
fail_msg: >-
Aucun relais SMTP : l'application « {{ serveur_keycloak_smtp_app }} » n'est pas
déclarée au plan et `serveur_keycloak_smtp_hote` est vide. Activer
« mot de passe oublié » sans relais donnerait un écran qui promet un courriel
que personne n'enverrait.
when: serveur_keycloak_smtp_actif | bool
- name: Obtenir un jeton d'administration
ansible.builtin.uri:
url: "http://localhost:8080/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: serveur_keycloak_smtp_jeton
no_log: true
- name: Lire la strophe courriel en place
ansible.builtin.uri:
url: "http://localhost:8080/admin/realms/{{ serveur_keycloak_realm }}"
headers:
Authorization: "Bearer {{ serveur_keycloak_smtp_jeton.json.access_token }}"
register: serveur_keycloak_realm_actuel
no_log: true
- name: Composer la strophe courriel attendue
ansible.builtin.set_fact:
serveur_keycloak_smtp_attendu:
host: "{{ serveur_keycloak_smtp_hote }}"
port: "{{ serveur_keycloak_smtp_port | string }}"
from: "{{ serveur_keycloak_smtp_expediteur }}"
fromDisplayName: "{{ serveur_keycloak_smtp_nom_expediteur }}"
starttls: "{{ serveur_keycloak_smtp_starttls | bool | string | lower }}"
auth: "false"
ssl: "false"
- name: Configurer le courriel du realm et le « mot de passe oublié »
ansible.builtin.uri:
url: "http://localhost:8080/admin/realms/{{ serveur_keycloak_realm }}"
method: PUT
status_code: [204]
headers:
Authorization: "Bearer {{ serveur_keycloak_smtp_jeton.json.access_token }}"
body_format: json
body:
smtpServer: "{{ serveur_keycloak_smtp_attendu }}"
resetPasswordAllowed: "{{ serveur_keycloak_reset_mot_de_passe | bool }}"
no_log: true
changed_when: true
when: >-
(serveur_keycloak_realm_actuel.json.smtpServer | default({})) != serveur_keycloak_smtp_attendu
or (serveur_keycloak_realm_actuel.json.resetPasswordAllowed | default(false) | bool)
!= (serveur_keycloak_reset_mot_de_passe | bool)

View file

@ -163,6 +163,10 @@
ansible.builtin.include_tasks: federation-ldap.yml
when: serveur_keycloak_ldap_federation | bool
- name: Configurer le courriel du realm (« mot de passe oublié »)
ansible.builtin.include_tasks: courriel-realm.yml
when: serveur_keycloak_smtp_actif | bool
- name: Projeter les groupes LDAP (mapper + roles de groupe)
ansible.builtin.include_tasks: groupes-ldap.yml
when: