diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e3ae9..d1e183a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # CHANGELOG — Set-OPS +## 2026-08-03 (suite 15) — la porte de secours cesse d'être annoncée + +Client OIDC **Nextcloud** ajouté aux deux tenants — quatre clients chacun désormais, sur +le chemin que l'app `user_oidc` impose (`…/apps/user_oidc/code`). Le secret existait des +deux côtés depuis hier ; le client qui devait le porter manquait. + +### La directive + +Trois règles, indissociables parce que chacune crée le problème que la suivante résout : +toute authentification **web** passe par Keycloak ; **LDAP** est la source unique des +comptes ; chaque service garde un accès de secours par **`sudo`** sur l'hôte. + +La chaîne `service → Keycloak → LDAP` est en série. Le secours n'est donc pas une entorse +au SSO : c'est ce qui rend les deux premières règles tenables. Portée : le web seulement — +IMAP et SMTP se lient à LDAP directement, SSH est en clé seule. + +### La posture : `_connexion_locale: false` + +Le compte local **existe** — il ne peut pas dépendre de Keycloak, sinon il tomberait avec +lui — mais son formulaire n'est plus proposé au repos. Un formulaire ouvert en permanence +contourne la politique de mot de passe, le MFA, et surtout la **révocation centrale** : +désactiver quelqu'un dans LDAP laisserait son compte local valide, sans que rien ne le +signale. + +Fermer ne coûte rien depuis qu'on a tranché que `sudo` suffit : `sudo` *est* le mécanisme +de réouverture. + +### Ce que chaque service sait vraiment faire + +Vérifié auprès de l'amont, puis par **rendu réel des gabarits dans les deux postures**. + +| Service | Réglage | Effet réel | +|---|---|---| +| Grafana | `GF_AUTH_DISABLE_LOGIN_FORM` | ferme le formulaire | +| Forgejo ≥ 10 | `ENABLE_INTERNAL_SIGNIN` + `ENABLE_BASIC_AUTHENTICATION` | ferme la connexion interne **et** l'API en Basic | +| Nextcloud | `hide_login_form` | **masque seulement** | + +**Nextcloud est une exception, écrite comme telle.** `…/login?direct=1` atteint encore le +formulaire, et l'amont le documente comme voulu — c'est ainsi qu'un administrateur entre. +La protection est de ne plus l'annoncer, pas d'interdire. Le présenter comme équivalent +donnerait un faux confort. + +**Forgejo tombe juste.** `ENABLE_INTERNAL_SIGNIN` n'existe que depuis la v10 (ticket amont +7476, clos : « This option was added to Forgejo v10 ») et le rôle épingle `10.0.0`. Sur une +version antérieure il serait ignoré **sans erreur** : un `assert` refuse la fermeture sous +10.0.0 plutôt que de laisser le rôle croire qu'il a fermé la porte. + +### Le défaut que le rendu a attrapé + +Ma première version testait `{% if not serveur_forgejo_connexion_locale %}` sans `| bool`. +En rendu réel, **aucune des deux postures n'émettait quoi que ce soit** : une valeur +transmise en chaîne (`-e`, ou un `group_vars` non typé) est vraie au sens Jinja, donc le +bloc ne sortait jamais — la connexion locale serait restée ouverte en silence. C'est +exactement le genre de panne que lire le gabarit ne révèle pas. + +Décisions **D-38** à **D-41**, `docs/authentification.md`. + +### Ce qui reste + +Prometheus et Loki exposent une interface sans SSO ; `oauth2-proxy` est déjà éprouvé devant +Icinga Web 2. Et **aucune preuve ne garde cette directive** — même risque que les +intégrations universelles avant leur inversion. + ## 2026-08-03 (suite 14) — les deux secrets Nextcloud, et un qui ne se génère pas `vault_nextcloud_admin` et `vault_nextcloud_oidc` étaient exigés par le plan et absents diff --git a/docs/authentification.md b/docs/authentification.md new file mode 100644 index 0000000..68f20ce --- /dev/null +++ b/docs/authentification.md @@ -0,0 +1,96 @@ +# Authentification : Keycloak devant, LDAP dessous, `sudo` en secours + +> **Directive d'architecture du 2026-08-03.** Trois règles, décidées ensemble et +> volontairement indissociables — chacune crée le problème que la suivante résout. + +1. **Toute authentification web passe par Keycloak.** Aucun service ne tient son propre + répertoire de comptes. +2. **LDAP est la source de vérité unique** des comptes d'un tenant. Keycloak le fédère ; + les protocoles qui ne parlent pas OIDC s'y lient directement. +3. **Chaque service garde un accès de secours**, atteint par `sudo` sur l'hôte. + +## 1. Pourquoi les trois vont ensemble + +La chaîne est en série : `service → Keycloak → LDAP`. C'est ce qui donne « une identité, un +mot de passe » — et c'est aussi trois points de panne alignés. Si LDAP tombe, plus personne +n'entre nulle part, y compris pour réparer. + +La règle 3 n'est donc pas une entorse au SSO : c'est ce qui rend les deux premières +tenables. Sans elle, la panne d'un maillon devient une exclusion totale. + +## 2. Portée : le web, et seulement le web + +| Protocole | Chemin d'authentification | Pourquoi | +|---|---|---| +| **HTTP(S)** — interfaces humaines | **Keycloak** (OIDC natif, ou `oauth2-proxy` devant) | c'est là que la directive s'applique | +| **IMAP / SMTP** | **LDAP direct** (Dovecot, Postfix) | ces protocoles ne parlent pas OIDC ; le flux est prouvé de bout en bout | +| **SSH** | **clé publique** (`PasswordAuthentication no`) | pas de mot de passe du tout, donc rien à fédérer | +| **PostgreSQL, Redis** | secrets applicatifs en voûte | comptes de service, pas d'humains | + +Ce qui est interdit partout, c'est qu'un service tienne **son propre répertoire d'humains**. +Le chemin varie ; la source ne varie pas. + +## 3. La posture retenue : la porte de secours n'est pas annoncée + +Le compte local de chaque service **existe** — il ne peut pas dépendre de Keycloak, sinon il +tomberait avec lui. Mais son formulaire de connexion **n'est pas proposé au repos**. + +Ce n'est pas une préférence esthétique. Un formulaire local ouvert en permanence : + +- contourne la politique de mot de passe et le MFA ; +- contourne surtout la **révocation centrale** — désactiver quelqu'un dans LDAP laisserait + son compte local valide, sans que rien ne le signale ; +- offre une cible de bourrage d'identifiants sur `admin`, en continu, pour un usage qui + devrait être exceptionnel. + +Le coût de la fermeture est nul depuis qu'on a tranché que **`sudo` suffit** : `sudo` *est* +le mécanisme de réouverture. + +### Ce que chaque service sait vraiment faire + +Vérifié auprès de l'amont le 2026-08-03, puis par **rendu réel des gabarits** dans les deux +postures — pas seulement lu. + +| Service | Réglage | Effet réel | Secours par `sudo` | +|---|---|---|---| +| **Grafana** | `GF_AUTH_DISABLE_LOGIN_FORM=true` | ferme le formulaire | `grafana-cli admin reset-admin-password` | +| **Forgejo** ≥ 10 | `ENABLE_INTERNAL_SIGNIN=false` + `ENABLE_BASIC_AUTHENTICATION=false` | ferme la connexion interne **et** l'API en Basic | `forgejo admin user change-password` | +| **Nextcloud** | `hide_login_form=true` | **masque seulement** | `occ user:resetpassword`, ou `…/login?direct=1` | + +> **Nextcloud est une exception, et il faut la dire.** `hide_login_form` **masque** le +> formulaire ; `…/login?direct=1` y accède encore, et l'amont le documente comme voulu — +> c'est ainsi qu'un administrateur entre. La protection est donc de **ne plus l'annoncer**, +> pas de l'interdire. Le présenter comme équivalent à Grafana ou Forgejo donnerait un faux +> confort. + +Le réglage s'appelle `_connexion_locale` dans les trois rôles, et vaut `false` par +défaut. Le passer à `true` rouvre la porte, explicitement. + +**Garde de version.** `ENABLE_INTERNAL_SIGNIN` n'existe **que depuis Forgejo v10** (suivi +amont, ticket 7476 : *« This option was added to Forgejo v10 »*). Sur une version +antérieure il serait ignoré **sans erreur** — le rôle croirait avoir fermé la porte. Un +`assert` refuse donc `connexion_locale: false` sous 10.0.0 plutôt que de laisser croire. + +## 4. La chaîne de secours + +Trois maillons, aucun ne dépendant d'une porte web ouverte : + +``` +1. SSO Keycloak → LDAP ← la voie normale +2. sudo via SSH occ / grafana-cli / forgejo admin ← Keycloak ou LDAP en panne +3. console Proxmox de la VM ← SSH inaccessible +``` + +Un accès de secours qu'on ne sait pas emprunter n'existe pas : les commandes exactes sont +dans le tableau ci-dessus, et les mots de passe locaux vivent dans la voûte unique de +l'instance. + +## 5. Ce qui reste à faire + +- **Prometheus et Loki** exposent une interface sans SSO. `oauth2-proxy` est déjà éprouvé + devant Icinga Web 2 : le patron existe, il reste à l'appliquer. +- **Aucune preuve ne garde cette directive.** C'est le même risque que les intégrations + universelles avant leur inversion : une règle qu'aucune garde ne vérifie finit par ne + plus être vraie. Le remède symétrique serait qu'un rôle **déclare sa position** + (`web-sso` / `ldap-direct` / `sans-auth-humaine`, plus son accès de secours) et qu'une + preuve refuse une interface humaine sans SSO ni secours déclaré. diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 14eb9be..77fa9fd 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -48,6 +48,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Voûte au déploiement | secret jamais en clair | `ANSIBLE_VAULT_PASSWORD_FILE` / `~/.config/setops-vault-pass` ; déréférencé par `lookup('vars', )` | — | | Multi-instance | un dépôt par écosystème ; l'active = symlink `instance/`, les autres **découvertes par convention** (dossiers frères, aucun registre) | active : symlink `instance/` ; découverte : `scripts/instances.py` / `devis_reseau.py` (glob `../*/plan/nomenclature.yml` avec `index`) ; garde-fou collision : preuve **P21** | `multi-instances.md` | | Exposition → edge | app expose un FQDN public servi par un edge | `plan/domaines.yml` + `expose` (applications) | `bindings-conception.md` §4 | +| Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` | | Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 | | Frontière nord/sud | les flux `pair: externe` — **sautés** par le pare-feu d'hôte — sont la politique de bordure | `scripts/devis_opnsense.py` (`make devis-opnsense`) ; garde d'accès admin = preuve **P24** | `frontiere-opnsense.md` | diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 9154747..d910d6d 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -55,6 +55,11 @@ sont les seules vérifiables. | **D-37** | Chaque tenant a son **pool Proxmox** ; les noms courts de VM restent **identiques** d'un tenant à l'autre | 11 serveurs sur 14 sont homonymes — c'est la preuve que la nomenclature est un gabarit ; le coût est humain (la console affiche le nom), et le pool le corrige sans rien renommer | `devis_proxmox_pools.py` | P28 | | **D-18** | Chaque tenant a un **responsable désigné** | sans lui, « qui peut décider de déménager cette organisation ? » se pose au pire moment | `migration-tenant.md` §3 | — | +| **D-38** | Toute authentification **web** passe par Keycloak ; LDAP est la **source unique** des comptes | une identité, un mot de passe ; aucun service ne tient son propre répertoire d'humains | `authentification.md` §1-2 | — | +| **D-39** | Les protocoles qui ne parlent pas OIDC (IMAP, SMTP) se lient **directement à LDAP** | le chemin varie, la source ne varie pas | `authentification.md` §2 | — | +| **D-40** | L'accès de secours passe par **`sudo` sur l'hôte**, pas par un compte web permanent | `service → Keycloak → LDAP` est en série : sans secours, une panne exclut tout le monde, y compris pour réparer | `authentification.md` §4 | — | +| **D-41** | Le formulaire de connexion locale n'est **pas proposé au repos** (`_connexion_locale: false`) | il contourne la politique de mot de passe, le MFA et surtout la **révocation centrale** ; `sudo` est le mécanisme de réouverture | `authentification.md` §3 | — | + ## 3. Les secrets | # | Décision | Pourquoi | Détail | Garde | diff --git a/roles/serveur_forgejo/defaults/main.yml b/roles/serveur_forgejo/defaults/main.yml index 716f13e..357dbe1 100644 --- a/roles/serveur_forgejo/defaults/main.yml +++ b/roles/serveur_forgejo/defaults/main.yml @@ -39,6 +39,19 @@ serveur_forgejo_admin_user: "forgejo-admin" serveur_forgejo_admin_email: "admin@{{ domaine_interne }}" serveur_forgejo_admin_password: "{{ vault_forgejo_admin | default('') }}" # rempli depuis la voute (vault_forgejo_admin) +# --- Connexion locale (accès de secours) --- +# `false` = le formulaire identifiant/mot de passe n'est PAS proposé au repos ; le SSO +# Keycloak est le seul chemin annoncé. Le compte local continue d'exister : on l'atteint +# par `sudo` sur l'hôte (voir docs/authentification.md), qui est le mécanisme de secours +# retenu. Une porte de connexion ouverte en permanence contournerait la politique de mot +# de passe, le MFA et surtout la révocation centrale — désactiver quelqu'un dans LDAP +# laisserait le compte local valide, sans que rien ne le signale. +# Forgejo : `ENABLE_INTERNAL_SIGNIN` n'existe QUE depuis la v10 — verifie sur le +# suivi amont (issue 7476, close : « This option was added to Forgejo v10 »). Le +# role epingle 10.0.0, donc le reglage mord. Sur une version anterieure il serait +# ignore EN SILENCE : c'est le cas ou une garde vaut mieux qu'un commentaire. +serveur_forgejo_connexion_locale: false + # --- SSO OIDC (Keycloak, realm applicatif) — « Se connecter avec » --- # Requiert client_pki (confiance step_ca) + résolution de l'hôte Keycloak (plancher/DNS). # Le client OIDC doit exister dans le realm (serveur_keycloak_clients). diff --git a/roles/serveur_forgejo/tasks/main.yml b/roles/serveur_forgejo/tasks/main.yml index 248eed6..02ad693 100644 --- a/roles/serveur_forgejo/tasks/main.yml +++ b/roles/serveur_forgejo/tasks/main.yml @@ -1,4 +1,17 @@ --- +- name: Refuser de fermer la connexion locale sur un Forgejo anterieur a la v10 + # `ENABLE_INTERNAL_SIGNIN` a ete ajoute en v10 (suivi amont, issue 7476). Sur une + # version anterieure il est ignore EN SILENCE : le role croirait avoir ferme la porte + # alors qu'elle reste ouverte. On refuse plutot que de laisser croire. + ansible.builtin.assert: + that: + - serveur_forgejo_version is version('10.0.0', '>=') + fail_msg: >- + serveur_forgejo_connexion_locale=false exige Forgejo >= 10.0.0 + (ici {{ serveur_forgejo_version }}) : le reglage serait ignore sans erreur. + Mettre a jour, ou assumer la connexion locale en la passant a true. + when: not (serveur_forgejo_connexion_locale | bool) + - name: Exiger les secrets Forgejo (Vault) ansible.builtin.assert: that: diff --git a/roles/serveur_forgejo/templates/app.ini.j2 b/roles/serveur_forgejo/templates/app.ini.j2 index 38574dc..ea1d31b 100644 --- a/roles/serveur_forgejo/templates/app.ini.j2 +++ b/roles/serveur_forgejo/templates/app.ini.j2 @@ -30,6 +30,15 @@ INTERNAL_TOKEN = {{ serveur_forgejo_internal_token }} ; mais auto-enregistrement des utilisateurs OIDC. DISABLE_REGISTRATION = false ALLOW_ONLY_EXTERNAL_REGISTRATION = true +{# `| bool` obligatoire : sans lui, une valeur transmise en CHAINE (« false » via + `-e`, ou un group_vars non typé) est vraie au sens Jinja et le bloc ne sort JAMAIS — + la connexion locale resterait ouverte en silence. Verifie par rendu reel. #} +{% if not serveur_forgejo_connexion_locale | bool %} +; Connexion interne fermee : le SSO est le seul chemin. Le compte local +; survit et se rattrape par `forgejo admin user change-password` (sudo). +ENABLE_INTERNAL_SIGNIN = false +ENABLE_BASIC_AUTHENTICATION = false +{% endif %} {% else %} DISABLE_REGISTRATION = true {% endif %} diff --git a/roles/serveur_grafana/defaults/main.yml b/roles/serveur_grafana/defaults/main.yml index 979db72..e4245b4 100644 --- a/roles/serveur_grafana/defaults/main.yml +++ b/roles/serveur_grafana/defaults/main.yml @@ -18,6 +18,15 @@ serveur_grafana_dashboards_dir: "/var/lib/grafana/dashboards" # Secret OBLIGATOIRE (Ansible Vault). serveur_grafana_admin_password: "{{ vault_grafana_admin | default('') }}" # rempli depuis la voute (vault_grafana_admin) +# --- Connexion locale (accès de secours) --- +# `false` = le formulaire identifiant/mot de passe n'est PAS proposé au repos ; le SSO +# Keycloak est le seul chemin annoncé. Le compte local continue d'exister : on l'atteint +# par `sudo` sur l'hôte (voir docs/authentification.md), qui est le mécanisme de secours +# retenu. Une porte de connexion ouverte en permanence contournerait la politique de mot +# de passe, le MFA et surtout la révocation centrale — désactiver quelqu'un dans LDAP +# laisserait le compte local valide, sans que rien ne le signale. +serveur_grafana_connexion_locale: false + # --- SSO OIDC (Keycloak, realm applicatif) --- # « Se connecter avec » : Grafana délègue l'auth à Keycloak (client confidentiel). # Requiert client_pki sur ce nœud (confiance step_ca pour les appels serveur→Keycloak) + diff --git a/roles/serveur_grafana/templates/setops.conf.j2 b/roles/serveur_grafana/templates/setops.conf.j2 index 3021d21..2329eba 100644 --- a/roles/serveur_grafana/templates/setops.conf.j2 +++ b/roles/serveur_grafana/templates/setops.conf.j2 @@ -4,6 +4,10 @@ Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }} Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }} Environment=GF_USERS_ALLOW_SIGN_UP=false {% if serveur_grafana_oidc_actif %} +{# Masque le formulaire local. `grafana-cli admin reset-admin-password` + reste utilisable sur l'hote : le compte n'est pas supprime, il n'est + plus offert au reseau. #} +Environment=GF_AUTH_DISABLE_LOGIN_FORM={{ 'false' if serveur_grafana_connexion_locale | bool else 'true' }} Environment=GF_AUTH_GENERIC_OAUTH_ENABLED=true Environment=GF_AUTH_GENERIC_OAUTH_NAME={{ serveur_grafana_oidc_nom }} Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID={{ serveur_grafana_oidc_client_id }} diff --git a/roles/serveur_nextcloud/defaults/main.yml b/roles/serveur_nextcloud/defaults/main.yml index 9c384a3..e5b43d8 100644 --- a/roles/serveur_nextcloud/defaults/main.yml +++ b/roles/serveur_nextcloud/defaults/main.yml @@ -74,6 +74,18 @@ serveur_nextcloud_data_dir: "{{ serveur_nextcloud_racine }}/data" serveur_nextcloud_admin_user: "admin" serveur_nextcloud_admin_password: "{{ vault_nextcloud_admin | default('') }}" +# --- Connexion locale (acces de secours) --- +# `false` = le formulaire n'est pas propose au repos ; le SSO Keycloak est le seul +# chemin annonce. Le compte local existe toujours et s'atteint par `sudo` sur l'hote +# (`occ user:resetpassword`), qui est le mecanisme de secours retenu. +# +# NUANCE PROPRE A NEXTCLOUD, a ne pas gommer : `hide_login_form` MASQUE le formulaire, +# il ne le ferme pas — `…/login?direct=1` y accede encore, et l'amont le documente +# comme voulu (c'est ainsi qu'un admin entre). La protection reelle est donc de ne plus +# l'ANNONCER, pas de l'interdire. Grafana et Forgejo, eux, ferment vraiment. +# Le pretendre equivalent donnerait un faux confort. +serveur_nextcloud_connexion_locale: false + # Domaines de confiance : hostname exposé + nom court local. serveur_nextcloud_trusted_domains: - "{{ serveur_nextcloud_hostname }}" diff --git a/roles/serveur_nextcloud/tasks/oidc.yml b/roles/serveur_nextcloud/tasks/oidc.yml index 55c5fbe..6fe7557 100644 --- a/roles/serveur_nextcloud/tasks/oidc.yml +++ b/roles/serveur_nextcloud/tasks/oidc.yml @@ -17,6 +17,19 @@ register: serveur_nextcloud_oidc_app changed_when: "'SETOPS_CHANGED' in serveur_nextcloud_oidc_app.stdout" + - name: Ne plus annoncer le formulaire de connexion locale + # Masquage, pas fermeture : `?direct=1` reste le chemin de secours documente par + # l'amont. Voir serveur_nextcloud_connexion_locale dans defaults/main.yml. + ansible.builtin.command: + argv: + - php + - "{{ serveur_nextcloud_racine }}/occ" + - "config:system:set" + - "hide_login_form" + - "--type=boolean" + - "--value={{ 'false' if serveur_nextcloud_connexion_locale | bool else 'true' }}" + changed_when: false + - name: Configurer le fournisseur OIDC Keycloak (upsert idempotent) ansible.builtin.command: argv: