From 90ea4747454709e9e805dc98c41a47d7767623e7 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 28 Aug 2026 17:14:37 -0400 Subject: [PATCH] inseminer : le geste sort de mes mains et entre dans le depot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L insemination avait ete conduite A LA MAIN depuis le runner du site — hors du depot, donc sans preuve. Elle a maintenant sa cible : make inseminer TENANT=OPS-Chezlepro TENANT= PLUTOT QUE LE SYMLINK instance : le runner du SITE amorce PLUSIEURS locataires ; pointer un lien global sur l un d eux le ferait se prendre pour ce tenant. Il en NOMME un par commande. Ca borne aussi le couplage que creer-vm imposait en silence — rien ne disait sur quels tenants ce lien pouvait pointer. L HOTE SE DERIVE : celui qui porte serveur_ops_tenant. Meme critere que le flux d insemination et que la cle SSH du runner — le meme mot borne les trois pouvoirs. P54 GARDE LA LIGNE DE PARTAGE la ou elle glisserait sans bruit. Les deux couches retenues sont les seules qui ne reclament aucun secret. Le jour ou l on en ajouterait une, le deploiement echouerait chez le tenant sur une valeur vide, et ce message ne dirait pas qu un POUVOIR a ete franchi. Controle negatif : ajouter client_pki, la couche suivante, fait echouer la preuve. UN GARDE-FOU EXISTANT A INTERCEPTE UNE INSEMINATION MAL DIRIGEE. Le make parent exporte SETOPS_INVENTAIRE ; ma resolution en heritait et visait l inventaire d un AUTRE ecosysteme. Le refus vient d inventory_rules, pas de la cible — exactement l erreur qu un runner servant plusieurs locataires commettrait en silence. make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on --- CHANGELOG.md | 51 +++++++++++++++++++++++ Makefile | 72 +++++++++++++++++++++++++++++++++ docs/audit/preuve-2026-08-28.md | 5 ++- scripts/prouver.py | 58 ++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 708e69d..eab7af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-08-28 — `make inseminer` : le geste sort de mes mains et entre dans le depot + +**54 preuves.** L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site — +donc hors du depot, donc sans preuve, ce que ce projet refuse. Elle a maintenant sa cible : + +``` +make inseminer TENANT=OPS-Chezlepro # l'hote se DERIVE +``` + +**`TENANT=` plutot que le symlink `instance`.** Le runner du SITE materialise et amorce +PLUSIEURS locataires ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce +tenant. Il en **nomme** un par commande. C'est aussi ce qui borne le couplage que +`creer-vm` imposait en silence : rien ne disait sur quels tenants ce lien avait le droit de +pointer, ni jusqu'a quand. + +**L'hote se derive, il ne se tape pas** : c'est celui qui porte `serveur_ops_tenant`. Meme +critere que le flux d'insemination et que la cle SSH du runner — *le meme mot borne les +trois pouvoirs*. Un ecosysteme qui n'en declare aucun est refuse, avec la raison : +« l'insemination vise LE RUNNER d'un tenant, jamais une machine ordinaire ». + +### P54 — la ligne de partage, gardee la ou elle glisserait sans bruit + +`COUCHES_INSEMINATION` vaut `serveur_debian serveur_ops`, et ce n'est pas une commodite : +**ce sont les deux seules couches qui ne reclament aucun secret**. Le site ne detient pas +la voute d'un tenant, et ne doit jamais la detenir. + +Le jour ou l'on ajouterait une couche « pour aller un peu plus loin », le deploiement +echouerait chez le tenant sur une valeur vide — *un message qui ne dit pas qu'un pouvoir a +ete franchi*. P54 lit les roles reellement appliques et refuse toute citation de `vault_*`. +Controle negatif eloquent : ajouter `client_pki`, la couche **suivante**, la fait echouer, +parce qu'elle reclame le secret de l'autorite du tenant. + +### Un garde-fou existant a intercepte une insemination mal dirigee + +Premier essai sur un second tenant : + +``` +REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee +``` + +Le `make` parent **exporte** `SETOPS_INVENTAIRE`, derive de l'instance montee ; ma +resolution en heritait et visait l'inventaire d'un AUTRE ecosysteme. Le refus vient +d'`inventory_rules`, pas de la cible — et c'est exactement l'erreur qu'un runner de site, +qui sert plusieurs locataires, commettrait en silence. `env -u SETOPS_INVENTAIRE` ferme la +fuite. + +*La cible dit aussi ce qu'elle ne fait pas : la machine amorcee n'est PAS armee, ni voute +ni cle, et l'armer est le geste d'un humain.* + +make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute. + ## 2026-08-28 — `make sonder` : rapporter ce qui distingue, au lieu d'« échec » **53 preuves.** Trois faux diagnostics en une journée, **tous dus à l'instrument, aucun au diff --git a/Makefile b/Makefile index 3d3c833..cd39eae 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,19 @@ INVENTAIRE_LAB ?= $(firstword $(wildcard $(SETOPS_INSTANCE)/inventories/lab/host INVENTAIRE_PRODUCTION ?= $(SETOPS_INVENTAIRE) FICHIER_INVENTAIRE ?= $(SETOPS_INVENTAIRE) FICHIER_DEPENDANCES ?= docs/dependances-groupes.yml + +# LES COUCHES DE L'INSEMINATION — CE QUE LE SITE POSE CHEZ UN TENANT, ET RIEN DE PLUS. +# +# Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa premiere machine +# et lui donner de quoi continuer. Le runner du SITE le fait — socle, moteur, plan, +# plancher de resolution — puis s'arrete. +# +# CES DEUX-LA ET AUCUNE AUTRE, parce qu'elles ne demandent AUCUN SECRET. Le site ne +# detient pas la voute d'un tenant, et ne doit jamais la detenir : ce qui vient apres +# (`client_pki`, `serveur_ops_tenant`...) reclame des valeurs qu'il n'a pas. La liste +# n'est donc pas une commodite, c'est la LIGNE DE PARTAGE DU POUVOIR, et la preuve P54 +# refuse toute couche qui y ferait entrer un secret. +COUCHES_INSEMINATION ?= serveur_debian serveur_ops GROUPE_MODELE ?= modeles_vm GROUPE_DEBIAN ?= serveur_debian GROUPE_HOTES_ACTIFS ?= hotes_actifs @@ -266,6 +279,65 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine # n'existe pas », « une politique refuse » et « la frontiere se tait » dans le meme mot. +# L'INSEMINATION, CODIFIEE (2026-08-28). Elle a d'abord ete conduite A LA MAIN depuis le +# runner du site — donc hors du depot, donc sans preuve, ce que ce projet refuse. +# +# `TENANT=` PLUTOT QUE LE SYMLINK `instance`. Le runner du SITE materialise et amorce +# PLUSIEURS tenants ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce +# tenant. Il en NOMME un par commande, et `SETOPS_INSTANCE` suffit a le dire — verifie le +# 2026-08-28. C'est aussi ce qui borne le couplage que `creer-vm` imposait en silence : +# rien ne disait sur quels tenants ce lien avait le droit de pointer, ni jusqu'a quand. +# `env -u SETOPS_INVENTAIRE` EST OBLIGATOIRE, ET UN GARDE-FOU L'A PROUVE. +# +# Le make parent EXPORTE SETOPS_INVENTAIRE, derive de l'instance montee. Sans le retirer, +# la resolution du tenant demande heritait de l'inventaire d'un AUTRE ecosysteme : +# +# REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee +# +# Le refus vient d'inventory_rules, pas d'ici — il a intercepte une insemination qui +# aurait vise le mauvais ecosysteme (2026-08-28). C'est exactement le genre d'erreur +# qu'un runner de site, qui sert plusieurs locataires, commettrait en silence. +.PHONY: inseminer +inseminer: ansible-runtime ## Le SITE amorce le runner d'un tenant — TENANT= [HOTE=] + @set -e; \ + if [[ -z "$(TENANT)" ]]; then \ + printf '%s\n' 'Refus: relancer avec TENANT=, ex. TENANT=OPS-Chezlepro.'; \ + exit 2; \ + fi; \ + if [[ ! -d "../$(TENANT)/plan" ]]; then \ + printf '%s\n' 'Refus: ../$(TENANT)/plan introuvable — ce n est pas un ecosysteme.'; \ + exit 2; \ + fi; \ + inv="$$(env -u SETOPS_INVENTAIRE SETOPS_INSTANCE=../$(TENANT) python3 -c 'import sys; sys.path.insert(0,"scripts"); from inventory_rules import inventaire_de; print(inventaire_de() or "")')"; \ + if [[ -z "$$inv" || ! -f "$$inv" ]]; then \ + printf '%s\n' "Refus: aucun inventaire pour $(TENANT). Le generer chez lui (make instancier)."; \ + exit 2; \ + fi; \ + hote="$(HOTE)"; \ + if [[ -z "$$hote" ]]; then \ + hote="$$(python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1])) or {}; p=[d.get("all",{})]; out=[]; \ +[ (out.extend((n.get("children") or {}).get("serveur_ops_tenant",{}).get("hosts",{}) or {}), p.extend((n.get("children") or {}).values())) for n in iter(lambda: p.pop() if p else None, None) ]; print(" ".join(sorted(set(out))))' "$$inv")"; \ + fi; \ + if [[ -z "$$hote" ]]; then \ + printf '%s\n' "Refus: $(TENANT) ne declare aucun hote portant serveur_ops_tenant."; \ + printf '%s\n' "L insemination vise LE RUNNER d un tenant, jamais une machine ordinaire."; \ + exit 2; \ + fi; \ + if (( $$(wc -w <<< "$$hote") > 1 )); then \ + printf '%s\n' "Refus: plusieurs runners declares ($$hote). Nommer lequel avec HOTE=."; \ + exit 2; \ + fi; \ + printf 'Insemination de %s chez %s — couches sans secret : %s\n\n' "$$hote" "$(TENANT)" "$(COUCHES_INSEMINATION)"; \ + for groupe in $(COUCHES_INSEMINATION); do \ + printf '=== %s ===\n' "$$groupe"; \ + SETOPS_INSTANCE="../$(TENANT)" SETOPS_INVENTAIRE="$$inv" \ + ansible-playbook -i "$$inv" "$(DOSSIER_PLAYBOOKS_GROUPES)/$$groupe.yml" --limit "$$hote"; \ + done; \ + printf '\n%s\n' "$$hote porte desormais le moteur, son plan et la carte de la fabric."; \ + printf '%s\n' "IL N EST PAS ARME : ni voute, ni cle — le site ne les detient pas."; \ + printf '%s\n' "L armer est le geste d un humain, depuis son poste :"; \ + printf '%s\n' " make appliquer GROUPE=serveur_ops_tenant (voir docs/filiation-emancipation.md)" + .PHONY: sonder sonder: ## Sonde une cible et DIT ce qui distingue — CIBLE= [PORTS="22 443"] @if [[ -z "$(CIBLE)" ]]; then \ diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 48b44ae..4bd20be 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (53 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (54 OK · 0 echec · 0 saute) ## Preuves @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 30 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 19, 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, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 105 cibles make documentees, 62 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 106 cibles make documentees, 62 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). | @@ -66,6 +66,7 @@ | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | +| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. | ## Couverture des affirmations ✅ du registre diff --git a/scripts/prouver.py b/scripts/prouver.py index 3b53c6c..f35a089 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,62 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_insemination_sans_secret() -> tuple[bool, str]: + """Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant. + + POURQUOI. Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa + premiere machine. Le runner du SITE le fait — socle, moteur, plan, plancher — puis + s'arrete. Il s'arrete parce qu'il N'A PAS LA CLE, pas parce qu'une regle le lui + interdit : la voute d'un tenant ne vit dans aucun depot et son mot de passe sur aucune + de ses machines. + + Cette preuve garde la ligne a l'endroit ou elle pourrait glisser SANS BRUIT : le jour + ou l'on ajouterait une couche a `COUCHES_INSEMINATION` pour « aller un peu plus loin », + et ou cette couche reclamerait un `vault_*`. Le deploiement echouerait alors chez le + tenant, sur une valeur vide — un message qui ne dit pas qu'un POUVOIR a ete franchi. + + Elle mesure la propriete, pas l'intention : les roles reellement appliques, lus dans + leurs playbooks, ne doivent citer aucune variable de voute. + """ + mk = RACINE / "Makefile" + txt = mk.read_text(encoding="utf-8") + m = re.search(r"^COUCHES_INSEMINATION \?=(.*)$", txt, re.M) + if not m: + return False, "`COUCHES_INSEMINATION` n'est pas declare dans le Makefile." + groupes = m.group(1).split() + if not groupes: + return False, "`COUCHES_INSEMINATION` est vide : le site ne poserait rien." + + fautes: list[str] = [] + roles_vus: list[str] = [] + for g in groupes: + pb = RACINE / "playbooks" / "groupes" / f"{g}.yml" + if not pb.is_file(): + fautes.append(f"{g} : aucun playbook `playbooks/groupes/{g}.yml`") + continue + joue = yaml.safe_load(pb.read_text(encoding="utf-8")) or [] + roles = [r if isinstance(r, str) else (r or {}).get("role") + for r in ((joue[0] if joue else {}) or {}).get("roles") or []] + for role in [r for r in roles if r]: + roles_vus.append(role) + d = RACINE / "roles" / role + if not d.is_dir(): + fautes.append(f"{g} : role introuvable `{role}`") + continue + for f in sorted(list(d.rglob("*.yml")) + list(d.rglob("*.j2"))): + texte = f.read_text(encoding="utf-8", errors="ignore") + trouves = sorted(set(re.findall(r"\bvault_[a-z0-9_]+", texte))) + if trouves: + fautes.append( + f"{g} -> {role} cite {', '.join(trouves[:3])} dans " + f"{f.relative_to(RACINE)} : le SITE ne detient pas cette voute") + if fautes: + return False, ("L'insemination reclamerait un secret du tenant :\n - " + + "\n - ".join(fautes)) + return True, (f"{len(groupes)} couche(s) d'insemination ({', '.join(groupes)}), " + f"{len(set(roles_vus))} role(s) applique(s), aucun secret de tenant reclame.") + + def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE. @@ -2087,6 +2143,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer}, {"id": "P53", "titre": "L'interne refuse a voix haute, la bordure se tait", "refs": [], "func": preuve_interne_parle_bordure_se_tait}, + {"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant", + "refs": [], "func": preuve_insemination_sans_secret}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],