Lot 3b. make forge-comptes cree un compte par mainteneur declare (plan/mainteneurs.yml du site), collaborateur en lecture sur les seuls depots de ses locataires, sans mot de passe connu ; et le compte console-site en lecture seule, son jeton range sur le runner du site hors de tout depot. La console du site met les demandes ouvertes dans A traiter ; celle du locataire propose des demandes pre-remplies et le lien vers les siennes. 95 preuves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
11 KiB
Python
227 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Les comptes de la forge du site : un par mainteneur, et le lecteur de la console du site.
|
|
|
|
POURQUOI (2026-10-11, lot 3b). Un mainteneur demande quelque chose au site (une machine a
|
|
creer, une ouverture) par un TICKET dans le depot prive de son locataire, sur la forge du site.
|
|
Il ecrit avec SON compte : chaque demande dit qui l'a faite. La console du site lit toutes les
|
|
demandes avec un compte qui ne peut QUE lire.
|
|
|
|
CE QUE LE SCRIPT TIENT, d'apres le plan du site (`plan/mainteneurs.yml`) :
|
|
- chaque mainteneur declare a un compte, collaborateur en LECTURE sur les seuls depots des
|
|
locataires qu'il maintient (la lecture suffit a ouvrir et commenter un ticket) ; une
|
|
collaboration qui n'est plus declaree est retiree. Un compte neuf nait avec un mot de passe
|
|
aleatoire que PERSONNE ne voit : la personne choisit le sien par « mot de passe oublie » ;
|
|
- le compte `console-site`, membre de l'equipe `Lecteurs` de l'organisation (lecture des
|
|
tickets et du code, sur tous ses depots), et son jeton a portee LECTURE, range sur le runner
|
|
du site hors de tout depot (`~/.config/setops-forge-jeton-console`, 0600). Absent, il est
|
|
recree ; l'ancien est revoque.
|
|
|
|
Lance sur le runner du site par `make forge-comptes` (mot de passe d'administration par
|
|
l'environnement, `FORGEJO_ADMIN`, depuis la voute du site ; jamais affiche).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import secrets
|
|
import ssl
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
COMPTE_CONSOLE = "console-site"
|
|
EQUIPE_LECTEURS = "Lecteurs"
|
|
JETON_NOM = "console-site-lecture"
|
|
JETON_FICHIER = Path.home() / ".config" / "setops-forge-jeton-console"
|
|
|
|
|
|
def _basic(utilisateur: str, mdp: str) -> str:
|
|
return "Basic " + base64.b64encode(f"{utilisateur}:{mdp}".encode()).decode()
|
|
|
|
|
|
class Forge:
|
|
def __init__(self, base: str, mdp_admin: str, organisation: str = "genome"):
|
|
self.base = base.rstrip("/")
|
|
self.org = organisation
|
|
self.admin = _basic("forgejo-admin", mdp_admin)
|
|
self.ctx = ssl.create_default_context()
|
|
|
|
def api(self, chemin: str, methode: str = "GET", corps=None, auth: str | None = None):
|
|
req = urllib.request.Request(f"{self.base}/api/v1{chemin}", method=methode,
|
|
data=json.dumps(corps).encode() if corps is not None else None)
|
|
req.add_header("Authorization", auth or self.admin)
|
|
req.add_header("Content-Type", "application/json")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=20, context=self.ctx) as r:
|
|
texte = r.read().decode()
|
|
return r.status, (json.loads(texte) if texte else None)
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, (e.read().decode(errors="replace") or "")[:200]
|
|
|
|
|
|
def mainteneurs_declares() -> dict[str, dict]:
|
|
import underlay as underlay_mod
|
|
m = (underlay_mod.lire_plan_site("mainteneurs.yml") or {}).get("mainteneurs") or {}
|
|
return {str(k): dict(v or {}) for k, v in m.items()}
|
|
|
|
|
|
def depots_des_locataires() -> dict[str, str]:
|
|
"""locataire -> son depot du genome, d'apres sa face publiee (`genome.depot`)."""
|
|
import contexte as _ctx
|
|
out = {}
|
|
for site, loc in _ctx.couples_hebergeur():
|
|
face = loc.face_publiee() or {}
|
|
if face.get("hebergeur") == site.nom and (face.get("genome") or {}).get("depot"):
|
|
out[loc.nom] = str(face["genome"]["depot"])
|
|
return out
|
|
|
|
|
|
def plan_collaborations(voulues: dict[str, set[str]], actuelles: dict[str, set[str]]):
|
|
"""(ajouter, retirer) : listes de (compte, depot). Pur. `actuelles` ne porte que les
|
|
depots de locataires, jamais le moteur : on ne retire que ce que ce script gere."""
|
|
ajouter = sorted((c, d) for c, ds in voulues.items() for d in ds - actuelles.get(c, set()))
|
|
retirer = sorted((c, d) for c, ds in actuelles.items() for d in ds - voulues.get(c, set()))
|
|
return ajouter, retirer
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--confirmer", action="store_true")
|
|
a = ap.parse_args(argv)
|
|
mdp = os.environ.get("FORGEJO_ADMIN", "")
|
|
if not mdp:
|
|
print("REFUS : FORGEJO_ADMIN absent (voute du site).", file=sys.stderr)
|
|
return 2
|
|
import underlay as underlay_mod
|
|
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
|
|
forge = Forge(f"https://forge.{dom}", mdp)
|
|
mainteneurs = mainteneurs_declares()
|
|
depots = depots_des_locataires()
|
|
refus = []
|
|
voulues: dict[str, set[str]] = {}
|
|
for compte, m in sorted(mainteneurs.items()):
|
|
if not str(m.get("courriel") or "").strip():
|
|
refus.append(f"{compte} : `courriel` vide au plan du site (la forge l'exige, et c'est "
|
|
f"par lui que la personne choisit son mot de passe)")
|
|
continue
|
|
inconnus = [l for l in m.get("locataires") or [] if l not in depots]
|
|
if inconnus:
|
|
refus.append(f"{compte} : locataire(s) sans face ni depot ici : {', '.join(inconnus)}")
|
|
voulues[compte] = {depots[l] for l in m.get("locataires") or [] if l in depots}
|
|
|
|
# L'ETAT DE LA FORGE
|
|
st, users = forge.api("/admin/users?limit=100")
|
|
if st != 200:
|
|
print(f"REFUS : lecture des comptes : HTTP {st}", file=sys.stderr)
|
|
return 2
|
|
existants = {u["login"] for u in users}
|
|
actuelles: dict[str, set[str]] = {}
|
|
for d in sorted(set(depots.values())):
|
|
st, collab = forge.api(f"/repos/{forge.org}/{d}/collaborators?limit=100")
|
|
for c in (collab or []) if st == 200 else []:
|
|
actuelles.setdefault(c["login"], set()).add(d)
|
|
ajouter, retirer = plan_collaborations(voulues, actuelles)
|
|
a_creer = sorted(c for c in voulues if c not in existants)
|
|
st, equipes = forge.api(f"/orgs/{forge.org}/teams")
|
|
lecteurs = next((t for t in (equipes or []) if t.get("name") == EQUIPE_LECTEURS), None) if st == 200 else None
|
|
|
|
print("Mainteneurs (plan du site) :")
|
|
for c in sorted(mainteneurs):
|
|
print(f" {c:<16} {'a creer' if c in a_creer else 'existe' if c in existants else 'refuse'}"
|
|
f" depots : {', '.join(sorted(voulues.get(c, set()))) or '—'}")
|
|
for c, d in ajouter:
|
|
print(f" + collaboration (lecture) {c} sur {d}")
|
|
for c, d in retirer:
|
|
print(f" - collaboration {c} sur {d} (plus declaree)")
|
|
print(f"Lecteur de la console : compte {COMPTE_CONSOLE} "
|
|
f"{'existe' if COMPTE_CONSOLE in existants else 'a creer'}, equipe {EQUIPE_LECTEURS} "
|
|
f"{'existe' if lecteurs else 'a creer'}, jeton {'present' if JETON_FICHIER.is_file() else 'a creer'}")
|
|
for r in refus:
|
|
print(f" REFUS {r}")
|
|
if not a.confirmer:
|
|
print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-comptes CONFIRMER=true).")
|
|
return 1 if refus else 0
|
|
|
|
echecs = 0
|
|
|
|
def creer_compte(login: str, courriel: str, nom: str, oblige_changer: bool) -> str:
|
|
nonlocal echecs
|
|
mdp_compte = secrets.token_urlsafe(32)
|
|
st, rep = forge.api("/admin/users", "POST", {
|
|
"username": login, "email": courriel, "full_name": nom, "password": mdp_compte,
|
|
"must_change_password": oblige_changer, "send_notify": False, "visibility": "private"})
|
|
if st != 201:
|
|
echecs += 1
|
|
print(f" ECHEC creation {login} : HTTP {st} {rep}")
|
|
else:
|
|
print(f" cree {login}")
|
|
return mdp_compte
|
|
|
|
for c in a_creer:
|
|
m = mainteneurs[c]
|
|
creer_compte(c, str(m["courriel"]).strip(), str(m.get("nom") or c), True)
|
|
print(f" -> {c} choisit son mot de passe par « mot de passe oublie » sur "
|
|
f"https://forge.{dom}/user/forgot_password")
|
|
for c, d in ajouter:
|
|
st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "PUT", {"permission": "read"})
|
|
echecs += st != 204
|
|
print(f" collab {c} sur {d} : HTTP {st}")
|
|
for c, d in retirer:
|
|
st, _ = forge.api(f"/repos/{forge.org}/{d}/collaborators/{c}", "DELETE")
|
|
echecs += st != 204
|
|
print(f" retire {c} de {d} : HTTP {st}")
|
|
|
|
# LE LECTEUR DE LA CONSOLE DU SITE
|
|
mdp_console = None
|
|
if COMPTE_CONSOLE not in existants:
|
|
mdp_console = creer_compte(COMPTE_CONSOLE, f"{COMPTE_CONSOLE}@{dom}",
|
|
"Console du site (lecture seule)", False)
|
|
if lecteurs is None:
|
|
st, lecteurs = forge.api(f"/orgs/{forge.org}/teams", "POST", {
|
|
"name": EQUIPE_LECTEURS, "permission": "read", "includes_all_repositories": True,
|
|
"description": "Lecture seule des tickets et du code — consoles",
|
|
"units": ["repo.code", "repo.issues"]})
|
|
echecs += st != 201
|
|
print(f" equipe {EQUIPE_LECTEURS} : HTTP {st}")
|
|
if isinstance(lecteurs, dict) and lecteurs.get("id"):
|
|
st, _ = forge.api(f"/teams/{lecteurs['id']}/members/{COMPTE_CONSOLE}", "PUT")
|
|
echecs += st not in (204,)
|
|
if not JETON_FICHIER.is_file():
|
|
# UN JETON NE SE CREE QU'AVEC L'IDENTITE DE SON PROPRIETAIRE. Le compte console n'a pas
|
|
# de mot de passe connu : on lui en donne un neuf, le temps de creer le jeton, puis il
|
|
# est jete. L'ancien jeton du meme nom est revoque d'abord.
|
|
if mdp_console is None:
|
|
mdp_console = secrets.token_urlsafe(32)
|
|
st, _ = forge.api(f"/admin/users/{COMPTE_CONSOLE}", "PATCH",
|
|
{"login_name": COMPTE_CONSOLE, "source_id": 0, "password": mdp_console,
|
|
"must_change_password": False})
|
|
echecs += st != 200
|
|
auth = _basic(COMPTE_CONSOLE, mdp_console)
|
|
forge.api(f"/users/{COMPTE_CONSOLE}/tokens/{JETON_NOM}", "DELETE", auth=auth)
|
|
st, rep = forge.api(f"/users/{COMPTE_CONSOLE}/tokens", "POST",
|
|
{"name": JETON_NOM, "scopes": ["read:issue", "read:repository"]}, auth=auth)
|
|
if st == 201 and isinstance(rep, dict) and rep.get("sha1"):
|
|
JETON_FICHIER.parent.mkdir(parents=True, exist_ok=True)
|
|
JETON_FICHIER.touch(mode=0o600)
|
|
JETON_FICHIER.write_text(rep["sha1"] + "\n", encoding="utf-8")
|
|
os.chmod(JETON_FICHIER, 0o600)
|
|
print(f" jeton {JETON_NOM} : cree, range dans {JETON_FICHIER} (0600)")
|
|
else:
|
|
echecs += 1
|
|
print(f" ECHEC jeton : HTTP {st} {rep}")
|
|
|
|
if echecs or refus:
|
|
print(f"\nECART : {echecs} echec(s), {len(refus)} refus.")
|
|
return 1
|
|
print("\nComptes et collaborations conformes au plan du site ; la console du site peut lire.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|