diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a1705..9454649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` — 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. diff --git a/docs/autorisation.md b/docs/autorisation.md index 90bcdfb..eaaf65a 100644 --- a/docs/autorisation.md +++ b/docs/autorisation.md @@ -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. diff --git a/playbooks/groupes/serveur_openldap.yml b/playbooks/groupes/serveur_openldap.yml index 3194780..67ea6e6 100644 --- a/playbooks/groupes/serveur_openldap.yml +++ b/playbooks/groupes/serveur_openldap.yml @@ -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 diff --git a/roles/amorcage_acces/README.md b/roles/amorcage_acces/README.md new file mode 100644 index 0000000..db03f4e --- /dev/null +++ b/roles/amorcage_acces/README.md @@ -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. diff --git a/roles/amorcage_acces/defaults/main.yml b/roles/amorcage_acces/defaults/main.yml new file mode 100644 index 0000000..ef11f20 --- /dev/null +++ b/roles/amorcage_acces/defaults/main.yml @@ -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('') }}" diff --git a/roles/amorcage_acces/tasks/main.yml b/roles/amorcage_acces/tasks/main.yml new file mode 100644 index 0000000..dc83dff --- /dev/null +++ b/roles/amorcage_acces/tasks/main.yml @@ -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 diff --git a/scripts/voute.py b/scripts/voute.py index 0eb5820..808ef5e 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -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"):