Compare commits

...

7 Commits

Author SHA1 Message Date
cab9e9cf2d docs: add auth stack + main protected PR workflow
All checks were successful
CI / build-and-anchors (push) Successful in 1m27s
SMOKE / smoke (push) Successful in 16s
2026-02-13 19:17:35 +01:00
010601be63 docs: add runbooks (proposer/whoami gate, blue-green deploy, gitea PR workflow)
All checks were successful
CI / build-and-anchors (push) Successful in 1m30s
SMOKE / smoke (push) Successful in 15s
2026-02-13 17:33:33 +01:00
add688602a Merge pull request 'Fix: Proposer gate non-destructive + show/hide consistent' (#75) from fix/proposer-non-destructif into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m29s
SMOKE / smoke (push) Successful in 14s
Reviewed-on: #75
2026-02-12 15:37:28 +01:00
3f3c717185 Fix: Proposer gate non-destructive + show/hide consistent
All checks were successful
CI / build-and-anchors (push) Successful in 1m39s
SMOKE / smoke (push) Successful in 11s
2026-02-12 15:28:58 +01:00
b5f32da0c8 Merge pull request 'Gate 'Proposer' to editors via /_auth/whoami' (#74) from feat/proposer-editors-only into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m29s
SMOKE / smoke (push) Successful in 14s
Reviewed-on: #74
2026-02-12 09:47:03 +01:00
6e7ed8e041 Gate 'Proposer' to editors via /_auth/whoami
All checks were successful
CI / build-and-anchors (push) Successful in 1m57s
SMOKE / smoke (push) Successful in 13s
2026-02-12 09:46:30 +01:00
90f79a7ee7 Merge pull request 'Supprimer docs/SESSION_BILAN_CI_RUNNER_DNS_2026-01.md' (#71) from archicratia-patch-1 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m36s
SMOKE / smoke (push) Successful in 24s
Reviewed-on: #71
2026-02-02 12:13:12 +01:00
7 changed files with 654 additions and 4 deletions

201
docs/auth-stack.md Normal file
View File

@@ -0,0 +1,201 @@
# Auth Stack — LLDAP + Authelia + Redis (DSM 7.3 / Synology DS220+)
## Objectif
Fournir une pile dauthentification robuste (anti-lockout) pour protéger des services web via reverse-proxy :
- Annuaire utilisateurs : **LLDAP**
- Portail / SSO / MFA : **Authelia**
- Cache/sessions (optionnel selon config) : **Redis**
- Exposition publique : **Reverse proxy** (Synology / Nginx / Traefik) vers Authelia
---
## Architecture
### Composants
- **LLDAP**
- UI admin (HTTP) : `127.0.0.1:17170`
- LDAP : `127.0.0.1:3890`
- Base : sqlite dans `/volume2/docker/auth/data/lldap`
- **Authelia**
- API/portal : `127.0.0.1:9091`
- Stockage : sqlite dans `/volume2/docker/auth/data/authelia/db.sqlite3`
- Accès externe : via reverse proxy -> `https://auth.<domaine>`
- **Redis**
- Local uniquement : `127.0.0.1:6379`
- (peut servir plus tard à sessions/rate-limit selon config)
### Exposition réseau (principe de sécurité)
- Tous les services **bindés sur 127.0.0.1** (loopback NAS)
- Seul le **reverse proxy** expose `https://auth.<domaine>` vers `127.0.0.1:9091`
---
## Fichiers de référence
### 1) docker-compose.auth.yml
- Déploie redis + lldap + authelia.
- Recommandation DSM : **network_mode: host** + bind sur localhost.
- Supprime les aléas “bridge + DNS + subnets”
- Évite les timeouts LDAP sporadiques.
### 2) /volume2/docker/auth/compose/.env
Variables attendues :
#### LLDAP
- `LLDAP_JWT_SECRET=...` (random 32+)
- `LLDAP_KEY_SEED=...` (random 32+)
- `LLDAP_LDAP_USER_PASS=...` (mot de passe admin LLDAP)
#### Authelia
- `AUTHELIA_JWT_SECRET=...` (utilisé ici comme source pour reset_password)
- `AUTHELIA_SESSION_SECRET=...`
- `AUTHELIA_STORAGE_ENCRYPTION_KEY=...`
> Ne jamais committer `.env`. Stocker dans DSM / secrets.
### 3) /volume2/docker/auth/config/authelia/configuration.yml
- LDAP address en mode robuste : `ldap://127.0.0.1:3890`
- Cookie domain : `archicratie.trans-hands.synology.me`
- `authelia_url` : `https://auth.archicratie.trans-hands.synology.me`
- `default_redirection_url` : service principal (ex: gitea)
---
## Procédures opératoires
### Restart safe (redémarrage propre)
en bash :
cd /volume2/docker/auth/compose
sudo docker compose --env-file .env -f docker-compose.auth.yml down --remove-orphans
sudo docker compose --env-file .env -f docker-compose.auth.yml up -d --force-recreate
### Tests santé (sans dépendances DSM)
curl -fsS http://127.0.0.1:17170/ >/dev/null && echo "LLDAP UI OK"
curl -fsS http://127.0.0.1:9091/api/health && echo "AUTHELIA LOCAL OK"
curl -kfsS https://auth.archicratie.trans-hands.synology.me/api/health && echo "AUTHELIA HTTPS OK"
### Test TCP LDAP :
sudo docker run --rm --network host nicolaka/netshoot:latest sh -lc 'nc -vz -w2 127.0.0.1 3890'
### Rotate secrets (rotation)
# Principes :
Rotation = redémarrage forcé dAuthelia (sessions invalidées)
Rotation de LLDAP_KEY_SEED est sensible : peut affecter chiffrement des mots de passe.
# Procédure conseillée :
Sauvegarder DBs :
/volume2/docker/auth/data/lldap/users.db
/volume2/docker/auth/data/authelia/db.sqlite3
Changer dabord secrets Authelia (AUTHELIA_SESSION_SECRET, AUTHELIA_STORAGE_ENCRYPTION_KEY)
docker compose up -d --force-recreate authelia
Vérifier /api/health + login.
Reset admin LLDAP (break-glass)
# Si tu perds le mot de passe admin :
Activer temporairement LLDAP_FORCE_LDAP_USER_PASS_RESET=true dans lenvironnement LLDAP
Redémarrer LLDAP une seule fois
Désactiver immédiatement après.
⚠️ Ne jamais laisser ce flag en permanence : il force le reset à chaque boot.
## Checklist anti-lockout (indispensable)
### 1) Accès direct local (bypass)
LLDAP UI accessible en local : http://127.0.0.1:17170
Authelia health local : http://127.0.0.1:9091/api/health
### 2) Règle Authelia : domaine auth en bypass
Dans configuration.yml :
access_control:
rules:
- domain: "auth.<domaine>"
policy: bypass
But : pouvoir charger le portail même si les règles des autres domaines cassent.
### 3) Route de secours reverse-proxy
Prévoir une route non protégée (ou protégée différemment) pour pouvoir corriger :
ex: https://admin.<domaine>/ ou un vhost interne LAN-only.
### 4) Fenêtre privée pour tester
Toujours tester login/authelia dans un onglet privé pour éviter cookies “fantômes”.
## Troubleshooting (ce quon a rencontré et résolu)
### A) YAML/Compose cassé (tabs, doublons)
# Symptômes :
mapping key "ports" already defined
found character that cannot start any token
# Fix :
supprimer tabs
supprimer doublons (volumes/ports/networks)
valider : docker compose ... config
### B) Substitution foireuse des variables dans healthcheck
# Problème :
$VAR évalué par compose au parse-time
# Fix :
utiliser $$VAR dans CMD-SHELL si nécessaire.
### C) /config monté read-only
# Symptômes :
chown: /config/... Read-only file system
# Fix :
monter /config en :rw si Authelia doit écrire des backups/keys.
### D) Timeouts LDAP aléatoires en bridge
# Symptômes :
dial tcp <ip>:3890: i/o timeout
IP Docker “surprise” (subnet 192.168.32.0/20 etc.)
# Fix robuste DSM :
passer en network_mode: host + bind 127.0.0.1
Authelia -> ldap://127.0.0.1:3890
### E) “Authelia OK mais Gitea redemande login”
# Normal :
tant que Gitea nest pas configuré en OIDC vers Authelia, ce nest pas du SSO.
Authelia protège laccès, mais ne crée pas de session Gitea.

View File

@@ -0,0 +1,67 @@
# Workflow Git/Gitea — main protégé (PR only)
## Objectif
Éviter toute casse de `main` : on travaille **toujours** via branche + Pull Request.
## 1) Démarrer propre (local)
en bash :
git fetch origin --prune
git checkout main
git reset --hard origin/main
git clean -fd
## 2) Créer une branche
git checkout -b fix/ma-modif
## 3) Modifier, tester, commit
npm test
git add -A
git commit -m "Mon changement"
## 4) Push (création branche distante)
git push -u origin fix/ma-modif
## 5) Créer la Pull Request (UI Gitea)
Gitea → repository → Pull Requests → New Pull Request
base : main
compare : fix/ma-modif
Si “je ne vois pas de PR”
Vérifie dabord quil y a un diff réel :
git log --oneline origin/main..HEAD
Si la commande ne sort rien : ta branche ne contient aucun commit différent → PR inutile/invisible.
## 6) Conflits
Ne merge pas en local vers main (push refusé si main protégé).
On met à jour la branche de PR :
Option A (simple) : merge main dans la branche
git fetch origin
git merge origin/main
# résoudre conflits
npm test
git push
Option B (plus propre) : rebase
git fetch origin
git rebase origin/main
# résoudre conflits, puis:
npm test
git push --force-with-lease
## 7) Merge
Toujours depuis lUI de la Pull Request (ou via un mainteneur).

View File

@@ -0,0 +1,69 @@
# “Proposer” protégé par groupe (whoami / editors)
## But
Le bouton **Proposer** (création dissue Gitea pré-remplie) doit être :
- visible **uniquement** pour les membres du groupe `editors`,
- **absent** pour les autres utilisateurs,
- robuste (fail-closed), mais **non-collant** (pas de “bloqué” après un échec transitoire).
## Pré-requis (build-time)
Les variables publiques Astro doivent être injectées au build :
- `PUBLIC_GITEA_BASE`
- `PUBLIC_GITEA_OWNER`
- `PUBLIC_GITEA_REPO`
Si une seule manque → `giteaReady=false` → Proposer est désactivé.
### Vérification NAS (slots blue/green)
Exemple :
- blue : http://127.0.0.1:8081/...
- green : http://127.0.0.1:8082/...
Commande (ex) :
`curl -sS http://127.0.0.1:8081/archicratie/archicrat-ia/chapitre-4/ | grep -n "const GITEA_" | head`
## Signal dauth (runtime) : `/_auth/whoami`
Le site appelle `/_auth/whoami` (same-origin) pour récupérer :
- `Remote-User`
- `Remote-Groups`
Ces headers sont injectés par la chaîne edge (Traefik → Authelia forward-auth).
### Appel robuste
- cache-bust : `?_=${Date.now()}`
- `cache: "no-store"`
- `credentials: "include"`
### Critère
`groups.includes("editors")`
## Comportement attendu (UX)
- utilisateur editors : le bouton “Proposer” est visible, ouvre la modal, puis ouvre Gitea.
- utilisateur non editors : le bouton “Proposer” nexiste pas (retiré du DOM).
## Pièges connus
1) Tester en direct 8081/8082 ne reflète pas toujours la chaîne Traefik+Authelia.
2) Un gate “collant” peut rester OFF si léchec est mis en cache trop agressivement.
3) Si “Proposer” est caché via `style.display="none"`, il faut le réafficher via `style.display=""` (pas via `hidden=false`).
## Debug rapide (console navigateur)
en js :
(async () => {
const r = await fetch("/_auth/whoami?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
redirect: "follow",
});
const t = await r.text();
const groups = (t.match(/^Remote-Groups:\s*(.*)$/mi)?.[1] || "")
.split(",").map(s => s.trim()).filter(Boolean);
console.log({ ok: r.ok, status: r.status, groups, raw: t.slice(0, 220) + "..." });
})();
## Définition “done”
Archicratia (editors) voit Proposer et peut ouvrir un ticket.
s-FunX (non editors) ne voit pas Proposer.
Les deux slots blue/green injectent les constantes Gitea dans le HTML.

View File

@@ -0,0 +1,82 @@
# Runbook — Déploiement Archicratie Web Édition (Blue/Green)
## Arborescence NAS (repère)
- `/volume2/docker/archicratie-web/current/` : état courant (Dockerfile, docker-compose.yml, dist buildé en image)
- `/volume2/docker/archicratie-web/releases/` : historiques éventuels
- `/volume2/docker/edge/` : Traefik + config dynamique
> Important : les commandes `docker compose -f ...` doivent viser le **docker-compose.yml présent dans `current/`**.
---
## Pré-requis Synology
Sur NAS, les commandes ont été exécutées avec :
en bash
sudo env DOCKER_API_VERSION=1.43 docker ...
(contexte DSM / compat API)
### 1) Variables de build (Gitea)
Dans /volume2/docker/archicratie-web/current créer/maintenir :
cat > .env <<'EOF'
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
PUBLIC_GITEA_OWNER=Archicratia
PUBLIC_GITEA_REPO=archicratie-edition
EOF
### 2) Build images (blue + green) — méthode robuste
cd /volume2/docker/archicratie-web/current
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml build --no-cache web_blue web_green
Puis recréer les conteneurs sans rebuild :
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue web_green
### 3) Vérifier que les deux slots sont OK
curl -sS -D- http://127.0.0.1:8081/ | head -n 12
curl -sS -D- http://127.0.0.1:8082/ | head -n 12
Attendu :
HTTP/1.1 200 OK
Server: nginx/...
### 4) Traefik : sassurer quun seul backend est actif
Fichier :
/volume2/docker/edge/config/dynamic/20-archicratie-backend.yml
Attendu : une seule URL (8081 OU 8082)
http:
services:
archicratie_web:
loadBalancer:
servers:
- url: "http://127.0.0.1:8081"
### 5) Smoke via Traefik (entrée réelle)
curl -sS -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20
Attendu :
si non loggé : 302 vers Authelia
si loggé : HTML du site
### 6) Piège classique : conflit de nom de conteneur
Si :
Conflict. The container name "/archicratie-web-blue" is already in use...
Faire :
sudo docker rm -f archicratie-web-blue
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue

View File

@@ -0,0 +1,71 @@
# Runbook — Gitea : Branches, PR, Merge (sans se faire piéger)
## Règle n°1 (hyper importante)
Une PR napparaît dans Gitea que si la branche contient **au moins 1 commit différent de `main`**.
Symptôme typique :
- `git push -u origin fix/xxx`
- et tu vois : `Total 0 ...`
→ ça veut dire : **aucun nouveau commit** → la branche est identique à main → pas de vraie PR à proposer.
---
## Workflow “propre” (pas à pas)
### 1) Remettre `main` propre
en bash
git checkout main
git pull --ff-only
### 2) Créer une branche de travail
git checkout -b fix/mon-fix
### 3) Faire un changement réel
Modifier le fichier (ex : src/layouts/EditionLayout.astro)
Vérifier :
git status -sb
→ doit montrer un fichier modifié.
### 4) Tester
npm test
### 5) Commit
git add src/layouts/EditionLayout.astro
git commit -m "Fix: ..."
### 6) Push
git push -u origin fix/mon-fix
### 7) Créer la PR dans lUI Gitea
# Aller dans Pull Requests
# New Pull Request
Base : main
Compare : fix/mon-fix
Branch protection (si “Not allowed to push to protected branch main”)
# Cest normal si main est protégé :
On ne pousse jamais directement sur main.
On merge via PR (UI), avec un compte autorisé.
Si Gitea refuse de merger automatiquement :
soit tu actives le réglage côté Gitea “manual merge detection” (admin),
soit tu fais le merge localement MAIS tu ne pourras pas pousser sur main si la protection linterdit.
Conclusion : la voie “pro” = PR + merge UI.

View File

@@ -0,0 +1,67 @@
# Runbook — Bouton “Proposer” (site → Gitea issue) + Gate Authelia
## Objectif
Permettre une proposition de correction éditoriale depuis un paragraphe du site, en créant une *issue* Gitea pré-remplie, uniquement pour les membres du groupe `editors`.
---
## Pré-requis
- Traefik (edge) en front
- Authelia (forwardAuth) opérationnel
- Router `/_auth/whoami` exposé (whoami)
- Variables `PUBLIC_GITEA_*` injectées au build du site
---
## Vérification rapide (navigateur)
### 1) Qui suis-je ? (groupes)
Dans la console :
en js :
await fetch("/_auth/whoami?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
}).then(r => r.text());
Attendu (extraits) :
Remote-User: <login>
Remote-Groups: ...,editors,... pour un éditeur
### 2) Le bouton existe ?
document.querySelectorAll(".para-propose").length
> 0 si editors
0 si non-editor
## Vérification côté NAS (build vars)
### 1) Blue et Green contiennent les constantes ?
P="/archicratie/archicrat-ia/chapitre-4/"
curl -sS "http://127.0.0.1:8081$P" | grep -n "const GITEA_BASE" | head -n 2
curl -sS "http://127.0.0.1:8082$P" | grep -n "const GITEA_BASE" | head -n 2
### 2) Si une des deux est vide → rebuild propre
Dans /volume2/docker/archicratie-web/current :
cat > .env <<'EOF'
PUBLIC_GITEA_BASE=https://gitea.archicratie.trans-hands.synology.me
PUBLIC_GITEA_OWNER=Archicratia
PUBLIC_GITEA_REPO=archicratie-edition
EOF
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml build --no-cache web_blue web_green
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml up -d --force-recreate --no-build web_blue web_green
## Dépannage (si Proposer “disparaît”)
Vérifier groupes via /_auth/whoami
Vérifier const GITEA_BASE via curl sur le slot actif
Vérifier que Traefik sert bien le slot actif (grep via curl -H Host: ... http://127.0.0.1:18080/...)
Ouvrir la console : vérifier quaucune erreur JS nempêche linjection des outils paragraphe

View File

@@ -379,6 +379,64 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
const FULL_TEXT_SOFT_LIMIT = 1600;
const URL_HARD_LIMIT = 6500;
// ✅ Gate par groupe (Traefik→Authelia→LLDAP via /_auth/whoami)
const WHOAMI_PATH = "/_auth/whoami";
const PROPOSE_REQUIRED_GROUP = "editors";
let _authInfoPromise = null;
function parseWhoamiLine(text, key) {
const re = new RegExp(`^${key}:\\s*(.*)$`, "mi");
const m = String(text || "").match(re);
return (m?.[1] ?? "").trim();
}
async function getAuthInfo() {
if (_authInfoPromise) return _authInfoPromise;
_authInfoPromise = (async () => {
const res = await fetch(`${WHOAMI_PATH}?_=${Date.now()}`, {
credentials: "include",
cache: "no-store",
redirect: "follow",
});
const text = await res.text().catch(() => "");
const groups = parseWhoamiLine(text, "Remote-Groups")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
ok: Boolean(res && res.ok),
user: parseWhoamiLine(text, "Remote-User"),
name: parseWhoamiLine(text, "Remote-Name"),
email: parseWhoamiLine(text, "Remote-Email"),
groups,
raw: text,
};
})().catch((err) => {
console.warn("[proposer] whoami fetch failed", err);
return {
ok: false,
user: "",
name: "",
email: "",
groups: [],
raw: "",
};
});
return _authInfoPromise;
}
// Promise unique : est-on editor ?
// ⚠️ On reste fail-closed, mais NON destructif (on ne supprime pas sur erreur réseau)
const isEditorP = giteaReady
? getAuthInfo().then((info) => info.groups.includes(PROPOSE_REQUIRED_GROUP))
: Promise.resolve(false);
const quoteBlock = (s) =>
String(s || "")
.split(/\r?\n/)
@@ -447,6 +505,24 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
// ==========================
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
// Petit helper : fail-closed mais réversible (hide ≠ remove)
function hidePropose(el) {
try {
el.hidden = true;
el.style.display = "none";
el.setAttribute("aria-hidden", "true");
el.setAttribute("tabindex", "-1");
} catch {}
}
function showPropose(el) {
try {
el.hidden = false;
el.style.display = "";
el.removeAttribute("aria-hidden");
el.removeAttribute("tabindex");
} catch {}
}
for (const p of paras) {
if (p.querySelector(".para-tools")) continue;
@@ -493,12 +569,16 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
propose.textContent = "Proposer";
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
// ✅ fail-closed (mais NON destructif)
propose.dataset.requiresGroup = PROPOSE_REQUIRED_GROUP;
hidePropose(propose);
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
const issueUrl = buildIssueURL(p.id, raw, excerpt);
// Lien fallback (si JS casse totalement)
// Lien fallback (si JS modal casse totalement)
propose.href = issueUrl;
// ✅ Marqueurs pour ProposeModal (interception 2 étapes)
@@ -506,9 +586,6 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
propose.dataset.url = issueUrl;
propose.dataset.full = raw;
// ❌ PAS de target=_blank ici
// ❌ PAS de rel noopener ici
row.appendChild(propose);
}
@@ -544,6 +621,22 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
p.appendChild(tools);
}
// ✅ Après insertion : on autorise Proposer seulement si groupe editors
// - ok=false => remove (pas dUI “Proposer” pour les non-éditeurs)
// - erreur fetch => on garde HIDDEN (non destructif) ; un reload pourra réussir
if (giteaReady) {
isEditorP.then((ok) => {
const els = document.querySelectorAll(".para-propose");
for (const el of els) {
if (ok) showPropose(el);
else el.remove();
}
}).catch((err) => {
console.warn("[proposer] gate failed; keeping Proposer hidden", err);
document.querySelectorAll(".para-propose").forEach((el) => hidePropose(el));
});
}
// Auto-checkpoint
let lastAuto = 0;
function writeLastSeen(id) {