authentification : SSO Keycloak devant, secours par sudo, formulaire local fermé

Directive : 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. Les trois sont indissociables — la chaîne service → Keycloak → LDAP est
en série, donc sans secours une panne exclut tout le monde, y compris pour
réparer. Portée : le web seulement ; IMAP/SMTP se lient à LDAP directement et
SSH est en clé seule.

Posture <rôle>_connexion_locale, false par défaut. Le compte local existe — il
ne peut pas dépendre de Keycloak — mais son formulaire n'est plus proposé au
repos : ouvert en permanence, il contourne la politique de mot de passe, le MFA
et surtout la révocation centrale.

Vérifié auprès de l'amont, puis par rendu réel des gabarits dans les deux
postures :
- Grafana  GF_AUTH_DISABLE_LOGIN_FORM  → ferme ;
- Forgejo  ENABLE_INTERNAL_SIGNIN + ENABLE_BASIC_AUTHENTICATION → ferme, API
  Basic comprise. N'existe que depuis la v10 (ticket amont 7476) ; le rôle
  épingle 10.0.0 et un assert refuse la fermeture en deçà, car le réglage serait
  ignoré sans erreur ;
- Nextcloud hide_login_form → MASQUE seulement : ?direct=1 reste le chemin de
  secours documenté par l'amont. Écrit comme tel, sans prétendre à l'équivalence.

Défaut attrapé par le rendu : la condition Forgejo sans `| bool` n'émettait rien
dans aucune posture — une valeur en chaîne est vraie au sens Jinja, la connexion
locale serait restée ouverte en silence.

docs/authentification.md, décisions D-38 à D-41. ansible-lint (production) sans
échec, 28 preuves OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-03 16:50:36 -04:00
parent 6bef0e0343
commit 6a62f0a4c7
11 changed files with 238 additions and 0 deletions

View file

@ -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 : `<rôle>_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

96
docs/authentification.md Normal file
View file

@ -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 `<rôle>_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é.

View file

@ -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', <nom>)` | — |
| 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é | `<rôle>_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` |

View file

@ -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** (`<rôle>_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 |

View file

@ -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 <organisation> » ---
# 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).

View file

@ -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:

View file

@ -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 %}

View file

@ -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 <organisation> » : Grafana délègue l'auth à Keycloak (client confidentiel).
# Requiert client_pki sur ce nœud (confiance step_ca pour les appels serveur→Keycloak) +

View file

@ -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 }}

View file

@ -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 }}"

View file

@ -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: