amorcage_acces : le role qui cree UN acces puis se retire

Cree uid=sysadmin et cn=sysadmin dans LDAP. Prouve sur idm-01 : le compte
s'authentifie, et le second passage ne touche a rien (changed=0, 7 taches
sautees) — idempotence par EXISTENCE, pas par conformite (D-67).

Il suit l'annuaire au lieu de se declarer au plan : il ecrit par `ldapi:///` et
doit tourner sur cet hote. Le declarer comme groupe obligerait chaque instance a
le poser sur le bon hote, et elles ne le nomment pas pareil (idm-01 ici,
id-ldap-01 chez Technolibre) — je l'ai d'abord pose sur infra-pki-01 par erreur.

Trois defauts trouves en le construisant :

1. `pwdReset` n'existe pas dans ce schema (overlay ppolicy non charge) :
   l'entree entiere etait rejetee et `no_log` masquait la cause. J'avais suppose
   un mecanisme sans verifier. Le role le DETECTE maintenant, et la doctrine ne
   promet plus un changement force qui n'a pas lieu.

2. Le mot de passe aurait ete stocke EN CLAIR : `ldap_entry` ecrit userPassword
   litteralement. Hache par `slappasswd -h {SSHA}` desormais.

3. `voute.py` ne scannait que roles/<groupe> : un role applique par un playbook
   sans etre un groupe echappait au recensement, ce que D-20 interdit. Il suit
   maintenant les listes `roles:` des playbooks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-07 13:42:27 -04:00
parent be20ab43a9
commit ba70b80197
7 changed files with 310 additions and 2 deletions

View file

@ -2,6 +2,39 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### Le rôle d'amorçage : `amorcage_acces`
Crée **un** compte (`uid=sysadmin`) et **un** groupe (`cn=sysadmin`) dans LDAP, puis se
retire. Prouvé sur `idm-01` : le compte s'authentifie (`ldapwhoami` retourne son DN), et le
**second passage ne touche à rien** — `changed=0`, sept tâches sautées.
**Il suit l'annuaire, il ne se déclare pas au plan.** Le rôle écrit par `ldapi:///` — socket
locale — donc il doit tourner sur l'hôte de l'annuaire. Le déclarer comme groupe obligerait
chaque instance à le poser sur le bon hôte, et les instances ne nomment pas cet hôte pareil :
`idm-01` chez Chezlepro, `id-ldap-01` chez Technolibre. Je m'en suis convaincu en le posant
d'abord sur `infra-pki-01` par erreur. Il est donc appliqué par le playbook de
`serveur_openldap`.
**Trois défauts trouvés en le construisant, tous par la machine :**
`pwdReset` **n'existe pas dans ce schéma** — il vient de l'overlay `ppolicy`, non chargé
(seul `back_mdb` l'était). L'entrée entière était rejetée, et `no_log: true` masquait la
cause. J'avais supposé un mécanisme sans vérifier qu'il existait. Le rôle le **détecte**
désormais : overlay présent → changement forcé ; absent → il le dit en clair, et le
changement devient une obligation d'exploitation. La doctrine a été corrigée pour ne plus
promettre ce qui n'a pas lieu.
**Le mot de passe aurait été stocké en clair.** `ldap_entry` écrit `userPassword`
littéralement ; le jeton d'amorçage aurait été lisible par quiconque lit l'annuaire. Il est
maintenant haché par `slappasswd -h {SSHA}` sur la cible.
**Le recensement des secrets ne voyait pas ce rôle.** `voute.py` ne scannait que
`roles/<groupe>` — un rôle appliqué par un playbook sans être lui-même un groupe échappait au
recensement, ce que D-20 interdit. Il suit désormais les listes `roles:` des playbooks, ce qui
vaut pour tout rôle futur dans ce cas. Le gabarit signale correctement
`vault_sysadmin_amorcage`, généré ensuite dans les deux tenants.
### Set-OPS amorce les accès, le sysadmin gouverne (D-65 → D-67)
L'authentification était résolue et gardée (P29) ; l'**autorisation** n'existait nulle part.

View file

@ -103,7 +103,9 @@ ansible-vault view instance/inventories/principal/group_vars/all/vault.yml \
| grep vault_sysadmin_amorcage
```
Il ne servira qu'une fois : la première connexion impose son changement.
**Change-le immédiatement.** Tant que l'overlay `ppolicy` n'est pas chargé, rien ne
l'impose techniquement (§3) — c'est à toi de le faire, et c'est le premier geste de la
reprise.
> Le mot de passe de la voûte est lu depuis `ANSIBLE_VAULT_PASSWORD_FILE`
> (`~/.config/setops-vault-pass` par défaut). **Sans ce fichier, rien de ce qui suit n'est
@ -137,7 +139,8 @@ dépendent ni de Keycloak, ni de l'annuaire. C'est le sens de D-40.
### 6.4 Ce que tu dois changer en priorité
1. **Le mot de passe d'amorçage** — dès la première connexion, imposé.
1. **Le mot de passe d'amorçage** — dès la première connexion. Rien ne l'impose encore :
voir §3.
2. **Les comptes de secours** générés au déploiement : ils sont en voûte, et l'auteur du
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.

View file

@ -13,3 +13,11 @@
roles:
- serveur_openldap
# L'AMORCAGE DES ACCES suit l'annuaire, il ne se declare pas au plan : il
# ecrit par `ldapi:///` (socket locale) et doit donc tourner ICI. Le declarer
# comme groupe obligerait chaque instance a le poser sur le bon hote — et les
# instances ne nomment pas cet hote pareil (`idm-01` ici, `id-ldap-01` la).
#
# Rejoue a chaque deploiement, et c'est voulu : il est idempotent PAR
# EXISTENCE (D-67). Compte present -> aucune action, quel que soit son etat.
- amorcage_acces

View file

@ -0,0 +1,44 @@
# amorcage_acces — l'accès initial du sysadmin
Crée **un** compte (`uid=sysadmin`) et **un** groupe (`cn=sysadmin`) dans LDAP, une
seule fois, pour que l'exploitant puisse prendre la main. Puis se retire.
## Ce qu'il ne fait pas
Ce n'est **pas** un gestionnaire de comptes. Après son passage, créer, suspendre ou
réaffecter relève d'une personne — pas du dépôt (D-67, `docs/autorisation.md`).
## Idempotence par existence, pas par conformité
C'est ce qui distingue ce rôle de tous les autres du dépôt.
```
compte absent → créé, secret généré en voûte, changement forcé
compte présent → AUCUNE action, quel que soit son état
```
Un compte présent est laissé strictement intact : ni mot de passe, ni groupes, ni
attributs. Il a pu être renommé, promu, déplacé — c'est le droit de celui qui exploite.
Partout ailleurs dans Set-OPS, un écart entre le déclaré et le réel est un défaut à
corriger. **Ici, l'écart est le travail du sysadmin.** Réconcilier effacerait le compte
créé la veille pour un nouvel employé.
## Le secret d'amorçage
`vault_sysadmin_amorcage` est **généré** — le dépôt en est la source, puisque personne
n'existe encore pour le choisir. Mais ce n'est jamais *le* mot de passe de quelqu'un :
`pwdReset` impose son changement à la première ouverture.
Sans ce changement, l'auteur du déploiement connaîtrait le mot de passe du sysadmin.
## Où il s'exécute
Sur l'hôte qui porte `serveur_openldap` — il écrit par `ldapi:///`, socket locale. La
connexion à l'annuaire est dérivée par `resoudre_annuaire`, jamais écrite.
## Après
`docs/autorisation.md` §6 est le runbook de reprise : où administrer quoi, comment se
rouvrir si on se ferme dehors, et ce qu'il faut régénérer pour que la livraison soit un
vrai transfert.

View file

@ -0,0 +1,36 @@
---
# --- AMORCAGE DES ACCES (D-67) ------------------------------------------------
# Ce role cree UN acces — celui du sysadmin — puis se retire. Il n'est PAS un
# gestionnaire de comptes : apres son passage, c'est une personne qui gouverne.
#
# IDEMPOTENCE PAR EXISTENCE, PAS PAR CONFORMITE. Si le compte est la, on n'y
# touche pas : ni mot de passe, ni groupes, ni attributs. Il a pu etre renomme,
# promu, deplace — c'est le droit de celui qui exploite. Reconcilier effacerait
# le compte cree la veille pour un nouvel employe.
#
# Voir docs/autorisation.md (§6 = runbook de reprise).
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
# Le groupe qui porte l'habilitation. Les services le reconnaissent par
# `meta/acces.yml` — eux sont reconcilies, l'appartenance ne l'est pas.
amorcage_acces_groupe: "sysadmin"
# Le secret d'amorcage. GENERE par le depot (il en est la source : personne
# n'existe encore pour le choisir), depose en voute, et CHANGE A LA PREMIERE
# OUVERTURE. Ce n'est donc jamais *le* mot de passe de quelqu'un : c'est un jeton
# a usage unique. Sans le changement force, l'auteur du deploiement connaitrait
# le mot de passe du sysadmin.
amorcage_acces_secret: "{{ vault_sysadmin_amorcage | default('') }}"
# Force le changement a la premiere connexion (attribut LDAP `pwdReset`, lu par
# Keycloak et par la politique de mot de passe de slapd).
amorcage_acces_changement_force: true
# Connexion a l'annuaire — DERIVEE par le role `resoudre_annuaire`, jamais ecrite.
amorcage_acces_admin_password: "{{ vault_openldap_admin | default('') }}"

View file

@ -0,0 +1,165 @@
---
# Amorcage des acces — D-67. Voir defaults/main.yml pour la doctrine, et
# docs/autorisation.md §6 pour le runbook de reprise.
- name: Résoudre la connexion à l'annuaire
ansible.builtin.include_role:
name: resoudre_annuaire
- name: Exiger le secret d'amorçage et le mot de passe d'annuaire (Vault)
ansible.builtin.assert:
that:
- amorcage_acces_secret | length > 0
- amorcage_acces_admin_password | length > 0
fail_msg: >-
`vault_sysadmin_amorcage` et `vault_openldap_admin` sont requis (voûte).
Le premier est un jeton d'amorçage à usage unique — pas un mot de passe
permanent : voir docs/autorisation.md §6.
when: amorcage_acces_actif | bool
# --- LE CŒUR DU RÔLE : on REGARDE avant d'agir --------------------------------
# La condition n'est pas la conformité, c'est l'EXISTENCE. Un compte présent est
# laissé strictement intact, quel que soit son état — c'est ce qui distingue ce
# rôle de tous les autres du dépôt (D-67).
- name: Le compte d'amorçage existe-t-il déjà ?
community.general.ldap_search:
dn: "ou=people,{{ resoudre_annuaire_base_dn }}"
scope: onelevel
filter: "(uid={{ amorcage_acces_uid }})"
server_uri: "ldapi:///"
bind_dn: "{{ resoudre_annuaire_bind_dn }}"
bind_pw: "{{ amorcage_acces_admin_password }}"
register: amorcage_acces_existant
changed_when: false
no_log: true
when: amorcage_acces_actif | bool
- name: Retenir si l'amorçage a déjà eu lieu
ansible.builtin.set_fact:
amorcage_acces_deja_fait: >-
{{ (amorcage_acces_existant.results | default([]) | length) > 0 }}
when: amorcage_acces_actif | bool
- name: Annoncer que l'amorçage est déjà fait (aucune action)
ansible.builtin.debug:
msg: >-
Compte `{{ amorcage_acces_uid }}` déjà présent : Set-OPS n'y touche pas.
Les habilitations sont gouvernées par une personne (D-67) — voir
docs/autorisation.md §6.
when:
- amorcage_acces_actif | bool
- amorcage_acces_deja_fait | bool
# --- Tout ce qui suit ne s'exécute QUE si le compte est absent -----------------
# 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.
- name: Hacher le jeton d'amorçage (slappasswd, SSHA)
ansible.builtin.command:
argv:
- slappasswd
- "-h"
- "{SSHA}"
- "-s"
- "{{ amorcage_acces_secret }}"
register: amorcage_acces_hache
changed_when: false
no_log: true
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool
# Le changement forcé s'appuie sur `pwdReset`, qui vient de l'overlay `ppolicy`.
# On le DÉTECTE au lieu de le supposer : sans l'overlay, l'attribut est inconnu du
# schéma et l'entrée entière est rejetée — panne obscure, et le compte n'existe pas
# du tout. Mesuré le 2026-08-07 : seul `back_mdb` était chargé.
- name: L'overlay ppolicy est-il chargé (attribut `pwdReset` connu) ?
ansible.builtin.command:
argv:
- ldapsearch
- "-Y"
- EXTERNAL
- "-H"
- "ldapi:///"
- "-b"
- "cn=schema,cn=config"
- "(olcAttributeTypes=*pwdReset*)"
- dn
register: amorcage_acces_ppolicy
changed_when: false
failed_when: false
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool
- name: Retenir si le changement forcé est applicable
ansible.builtin.set_fact:
amorcage_acces_reset_possible: >-
{{ amorcage_acces_changement_force | bool
and 'dn:' in (amorcage_acces_ppolicy.stdout | default('')) }}
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool
- name: Créer le compte d'amorçage du sysadmin
community.general.ldap_entry:
dn: "uid={{ amorcage_acces_uid }},ou=people,{{ resoudre_annuaire_base_dn }}"
objectClass:
- inetOrgPerson
- organizationalPerson
- person
- top
attributes:
uid: "{{ amorcage_acces_uid }}"
cn: "{{ amorcage_acces_nom }}"
sn: "{{ amorcage_acces_nom.split(' ') | last }}"
userPassword: "{{ amorcage_acces_hache.stdout | trim }}"
pwdReset: "{{ 'TRUE' if amorcage_acces_reset_possible | bool else omit }}"
mail: "{{ amorcage_acces_courriel | default(omit, true) }}"
server_uri: "ldapi:///"
bind_dn: "{{ resoudre_annuaire_bind_dn }}"
bind_pw: "{{ amorcage_acces_admin_password }}"
no_log: true
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool
# `groupOfNames` EXIGE au moins un membre : un groupe vide est refusé par le
# schéma — ce qui tombe bien, un groupe d'habilitation sans personne n'aurait
# aucun sens à l'amorçage.
- name: Créer le groupe d'habilitation avec le sysadmin pour membre
community.general.ldap_entry:
dn: "cn={{ amorcage_acces_groupe }},ou=groups,{{ resoudre_annuaire_base_dn }}"
objectClass:
- groupOfNames
- top
attributes:
cn: "{{ amorcage_acces_groupe }}"
member: "uid={{ amorcage_acces_uid }},ou=people,{{ resoudre_annuaire_base_dn }}"
description: >-
Habilitation d'administration. Amorcé par Set-OPS, gouverné ensuite par
une personne — le dépôt ne réconcilie pas ses membres (D-67).
server_uri: "ldapi:///"
bind_dn: "{{ resoudre_annuaire_bind_dn }}"
bind_pw: "{{ amorcage_acces_admin_password }}"
no_log: true
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool
- name: Annoncer la reprise en main
ansible.builtin.debug:
msg: >-
{{ [
'Accès d amorçage créé : uid=' ~ amorcage_acces_uid ~ ', groupe=' ~ amorcage_acces_groupe ~ '.',
'Mot de passe en voûte sous `vault_sysadmin_amorcage`.',
(amorcage_acces_reset_possible | bool)
| ternary('Changement FORCÉ à la première ouverture (ppolicy actif).',
'ATTENTION : rien ne force le changement — l overlay ppolicy n est pas chargé. '
~ 'Le changer à la main dès la première connexion (docs/autorisation.md §6).'),
'À partir d ici, Set-OPS ne touche plus aux habilitations (D-67).'
] }}
when:
- amorcage_acces_actif | bool
- not amorcage_acces_deja_fait | bool

View file

@ -101,6 +101,25 @@ def secrets_exiges() -> dict[str, set[str]]:
for nom in _refs_vault(f):
noter(nom, f"role {groupe}")
# Roles appliques PAR UN PLAYBOOK sans etre eux-memes un groupe (ex.
# `amorcage_acces`, porte par le playbook de l'annuaire). Sans ce passage,
# leurs secrets echappaient au recensement — ce que D-20 interdit : « la
# liste des secrets se recense, elle ne s'ecrit pas ». Constate le 2026-08-07.
for pb in sorted((RACINE / "playbooks" / "groupes").glob("*.yml")):
try:
docs = yaml.safe_load(pb.read_text(encoding="utf-8")) or []
except yaml.YAMLError:
continue
for jeu in docs if isinstance(docs, list) else [docs]:
for r in (jeu or {}).get("roles") or []:
nom_role = r if isinstance(r, str) else (r or {}).get("role", "")
dossier = ROLES / str(nom_role)
if not dossier.is_dir():
continue
for f in list(dossier.rglob("*.yml")) + list(dossier.rglob("*.j2")):
for nom in _refs_vault(f):
noter(nom, f"role {nom_role} (playbook {pb.name})")
d = _inventaire_dir()
if d and (d / "group_vars").is_dir():
for f in (d / "group_vars").rglob("*.yml"):