Documentation initiale infra Seize — passation J7

- 6 docs racine (README, HANDOVER, ARCHITECTURE, DECISIONS, INSTALL, OPERATIONS)
- 9 docs thématiques dans docs/
- 10 templates docker-compose dans configs/
- 7 scripts Python/Shell d'import Furious vers Zitadel
- 2 matrices RBAC

Etat infra au 5 juin 2026 :
- 88 users Furious importes dans Zitadel
- 8 roles RBAC crees et attribues
- Intranet filtre les cartes selon role (Approche A)
- 7 outils SSO operationnels
This commit is contained in:
Hedi Kalboussi 2026-06-05 17:49:43 +00:00
commit 1e90a85359
43 changed files with 7751 additions and 0 deletions

68
.env.example Normal file
View File

@ -0,0 +1,68 @@
# ============================================================================
# .ENV.EXAMPLE — Variables globales pour l'install
# ============================================================================
# Copier ce fichier en .env et adapter les valeurs.
# NE JAMAIS commit .env sur Git.
# ===== INFRA =====
DOMAIN=seizeconsulting.com
SERVER_IP=151.115.148.243
SERVER_USER=admin
LETSENCRYPT_EMAIL=admin@seizeconsulting.com
# ===== POSTGRES PRINCIPAL =====
POSTGRES_PASSWORD=GENERATE_STRONG_PASSWORD_HERE
POSTGRES_USER=postgres
# Databases dédiées par service
GITEA_DB_PASSWORD=GENERATE_STRONG_PASSWORD_HERE
N8N_DB_PASSWORD=GENERATE_STRONG_PASSWORD_HERE
# ===== ZITADEL =====
ZITADEL_DOMAIN=sso.seizeconsulting.com
ZITADEL_MASTER_KEY=GENERATE_32_CHARS_RANDOM
ZITADEL_ADMIN_EMAIL=zitadel-admin@zitadel.sso.seizeconsulting.com
ZITADEL_ADMIN_PASSWORD=GENERATE_STRONG_PASSWORD_HERE
# ===== OAUTH2-PROXY (commun à tous les outils) =====
# Pour chaque outil, créer une App dans Zitadel et récupérer Client ID / Secret
# Cookie secret : généré par exemple via
# python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
# ===== GITEA =====
GITEA_DOMAIN=git.seizeconsulting.com
GITEA_SSH_PORT=22
# ===== N8N =====
N8N_DOMAIN=n8n.seizeconsulting.com
N8N_ENCRYPTION_KEY=GENERATE_32_CHARS_RANDOM
# ===== CODE SERVER =====
CODE_DOMAIN=code.seizeconsulting.com
CODE_PASSWORD=GENERATE_STRONG_PASSWORD_HERE_FALLBACK
# Note : avec SSO, ce mdp ne sert que de fallback si SSO down
# ===== UPTIME KUMA =====
STATUS_DOMAIN=status.seizeconsulting.com
# ===== PORTAINER =====
PORTAINER_DOMAIN=portainer.seizeconsulting.com
# ===== INTRANET =====
INTRANET_DOMAIN=intranet.seizeconsulting.com
# ===== TRAEFIK DASHBOARD =====
TRAEFIK_DOMAIN=traefik.seizeconsulting.com
# Pour le basic auth Traefik :
# Générer avec : htpasswd -nb admin <password>
TRAEFIK_DASHBOARD_AUTH=admin:$$apr1$$xxxxxxxx$$xxxxxxxxxxxxxxxxxxxxxxxx
# ===== FURIOUS SQUAD API =====
FURIOUS_USERNAME=GENERATE_FROM_FURIOUS_CONFIG
FURIOUS_PASSWORD=GENERATE_FROM_FURIOUS_CONFIG
FURIOUS_API_URL=https://seize-consulting.furious-squad.com/api/v2
# ===== ZITADEL SERVICE ACCOUNT (pour scripts) =====
# Chemin vers la clé JSON du service account furious-import-service
ZITADEL_SERVICE_KEY_PATH=/home/admin/furious-to-zitadel/zitadelfuriousimportkey.json

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
.env
*.env
!.env.example
!.env.template
*.key
*.pem
zitadelfuriousimportkey.json
users-credentials.csv
distribution-teams.csv
roles-mapping.json
letsencrypt/acme.json
*.log
venv/
__pycache__/
*.pyc
.DS_Store

344
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,344 @@
# ARCHITECTURE — Vue d'ensemble technique
> Comprendre comment tout s'imbrique dans l'infra Seize.
---
## 🏗️ Vue d'ensemble
```
Internet
│ HTTPS (443) / HTTP (80)
┌──────────────────────┐
│ Traefik (proxy) │
│ - Let's Encrypt │
│ - Routing par Host │
└──────────┬───────────┘
┌──────────────┬─────────┼─────────┬──────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌─────────────┐ ... ┌─────────────┐ ┌─────────────┐
│ Zitadel │ │ OAuth2-Proxy│ │ OAuth2-Proxy│ │ Gitea │
│ (sso.*) │ │ (intranet) │ │ (n8n) │ │ (git.*) SSO │
│ │ │ │ │ │ │ integré │
└────┬─────┘ └──────┬──────┘ └──────┬──────┘ └─────────────┘
│ │ │
│ Auth OIDC │ Forward proxy │ Forward proxy
│ ▼ ▼
│ ┌───────────┐ ┌───────────┐
│ │ Intranet │ │ n8n │
│ │ (nginx) │ │ │
│ │ + RBAC JS │ │ │
│ └───────────┘ └─────┬─────┘
│ │
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Postgres │ │ Postgres │
│ (zitadel)│ │ (n8n) │
└──────────┘ └──────────┘
```
---
## 🌐 Réseaux Docker
| Réseau | Type | Utilisé par |
|---|---|---|
| `web` | external | Traefik, tous les services exposés publiquement |
| `backend` | external | Postgres, Redis, et services backend internes |
| `zitadel` | local | Zitadel + son Traefik dédié + Postgres |
| `proxy` | bridge | Réseau auxiliaire (vérifier usage) |
⚠️ **Important** : Zitadel a son **propre réseau** + son **propre Traefik** (intégré dans son docker-compose). Il est totalement isolé du Traefik principal pour des raisons de sécurité. Le Traefik principal route uniquement vers le Traefik Zitadel sur `sso.seizeconsulting.com`.
---
## 🔐 Flux d'authentification SSO
### Flux complet pour un user accédant à l'intranet
```
1. User → https://intranet.seizeconsulting.com
2. Traefik principal → route vers OAuth2-Proxy "intranet"
3. OAuth2-Proxy vérifie le cookie de session
┌────┴────┐
│ Pas de │
│ session │
▼ │
4. OAuth2-Proxy redirige vers Zitadel
(URL : /oauth2/start → Zitadel login)
5. User saisit ses identifiants Zitadel + MFA
6. Zitadel valide + génère un code OIDC
7. Redirect vers OAuth2-Proxy /oauth2/callback?code=xxx
8. OAuth2-Proxy échange le code contre un token JWT
(avec scope : openid email profile urn:zitadel:iam:org:project:roles)
9. OAuth2-Proxy stocke le token + cookie de session
10. OAuth2-Proxy → forward request vers nginx (intranet)
avec headers :
- X-Forwarded-User: <user_id>
- X-Forwarded-Email: hedi.kalboussi@seizeconsulting.com
- X-Forwarded-Preferred-Username: HKA
- Authorization: Bearer <jwt_token>
11. nginx lit le header X-Forwarded-Email
Substitue __USER_EMAIL__ par la vraie valeur dans index.html (sub_filter)
12. Browser reçoit l'HTML avec const USER_EMAIL = "hedi.kalboussi@..."
13. JavaScript :
- Fetch /roles-mapping.json
- Lookup userEmail → role (ex: "consultant_junior")
- Cache les cartes que ce rôle n'a pas le droit de voir
14. User voit uniquement les cartes autorisées
```
---
## 🗂️ Structure des dossiers Docker sur le serveur
```
/home/hedi/docker/
├── traefik/ # Reverse proxy principal
│ ├── docker-compose.yml
│ ├── traefik.yml # Config statique
│ ├── dynamic.yml # Config dynamique (middlewares, etc.)
│ └── letsencrypt/ # Certificats TLS auto
├── zitadel/ # IDP Zitadel (avec son propre Traefik)
│ ├── docker-compose.yml # Multi-services (proxy + login + api + db)
│ ├── .env # Tous les secrets Zitadel
│ └── machinekey/ # Clés service accounts
├── postgres/ # Postgres principal (Gitea, n8n, Authentik historique)
│ ├── docker-compose.yml
│ └── data/ # Persistance
├── redis/ # Plus utilisé (Authentik historique)
├── intranet/ # ⭐ Page d'accueil avec RBAC
│ ├── docker-compose.yml
│ ├── index.html # HTML avec data-app="xxx" + script RBAC
│ ├── roles-mapping.json # Mapping email → rôle (généré par script)
│ ├── nginx-rbac.conf # nginx avec sub_filter pour __USER_EMAIL__
│ └── logo.png
├── gitea/ # Forge Git avec SSO Zitadel intégré
│ ├── docker-compose.yml
│ ├── .env
│ └── data/
├── n8n/ # Workflows
│ ├── docker-compose.yml
│ ├── .env
│ └── data/
├── portainer/ # Gestion Docker
│ ├── docker-compose.yml
│ └── data/
├── code-server/ # VS Code dans le navigateur
│ ├── docker-compose.yml
│ ├── .env
│ └── config/
├── uptime-kuma/ # Monitoring
│ ├── docker-compose.yml
│ └── data/
├── oauth2-proxy/ # ⭐ OAuth2-Proxy pour l'intranet
│ ├── docker-compose.yml
│ └── .env
├── oauth2-proxy-n8n/ # OAuth2-Proxy pour n8n
│ ├── docker-compose.yml
│ └── .env
├── oauth2-proxy-portainer/ # OAuth2-Proxy pour Portainer
│ ├── docker-compose.yml
│ └── .env
├── oauth2-proxy-code/ # OAuth2-Proxy pour Code Server
│ ├── docker-compose.yml
│ └── .env
├── oauth2-proxy-status/ # OAuth2-Proxy pour Uptime Kuma
│ ├── docker-compose.yml
│ └── .env
├── authentik/ # ❌ Plus utilisé (à archiver/supprimer)
├── whoami/ # Test debug (à garder ou supprimer)
└── ctop/ # Outil CLI monitoring conteneurs
```
---
## 🎭 Le pattern "OAuth2-Proxy par outil"
### Pourquoi ce pattern ?
Chaque outil (n8n, Portainer, etc.) a son **propre OAuth2-Proxy dédié**. C'est volontaire :
**Avantages** :
- Configuration différenciée par outil (scopes, callbacks, etc.)
- Pas de SPOF : si un OAuth2-Proxy crash, les autres outils marchent
- Plus simple à débugger
- Permet d'avoir des secrets/clients OIDC différents
**Inconvénients** :
- 5 conteneurs OAuth2-Proxy en plus (impact RAM minime)
- 5 configurations à maintenir
### Anatomie d'un OAuth2-Proxy
Pour chaque outil, on a :
1. **Un dossier `~/docker/oauth2-proxy-<outil>/`**
2. **Un `docker-compose.yml`** avec labels Traefik routant `<outil>.seizeconsulting.com` → OAuth2-Proxy port 4180
3. **Un `.env`** avec :
- `OAUTH2_PROXY_PROVIDER=oidc`
- `OAUTH2_PROXY_CLIENT_ID=<id_zitadel>`
- `OAUTH2_PROXY_CLIENT_SECRET=<secret_zitadel>`
- `OAUTH2_PROXY_OIDC_ISSUER_URL=https://sso.seizeconsulting.com`
- `OAUTH2_PROXY_REDIRECT_URL=https://<outil>.seizeconsulting.com/oauth2/callback`
- `OAUTH2_PROXY_UPSTREAMS=http://<outil>:<port>`
- `OAUTH2_PROXY_COOKIE_SECRET=<32_chars_random>`
- `OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles`
4. **Une App créée dans Zitadel** (project "Infra Seize") avec les bons callbacks
Détails complets dans [docs/04-oauth2-proxy-pattern.md](./docs/04-oauth2-proxy-pattern.md).
---
## 🧬 Le bug Traefik historique (à connaître)
**Symptôme initial** : tous les outils SSO étaient déconnectés à chaque requête.
**Cause** : Traefik supprimait par défaut le header `Cookie` quand il faisait un ForwardAuth vers OAuth2-Proxy. Du coup OAuth2-Proxy ne voyait pas le cookie de session et redirigeait vers login à chaque fois.
**Fix** : ajouter dans `~/docker/traefik/dynamic.yml` :
```yaml
http:
middlewares:
oauth2-headers:
forwardAuth:
address: "http://oauth2-proxy:4180/oauth2/auth"
authRequestHeaders:
- Cookie # ← LIGNE CRITIQUE
- Authorization
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
```
→ Sans `Cookie` dans `authRequestHeaders`, **rien ne fonctionne**.
---
## 🎯 RBAC : architecture choisie (Approche A)
```
1. Furious Squad (source de vérité RH)
│ API GraphQL-like
2. Scripts Python d'import (~/furious-to-zitadel/)
3. Zitadel : 88 users avec metadata custom
4. Script generate-roles-mapping.py
→ génère /docker/intranet/roles-mapping.json
5. Intranet (nginx + sub_filter)
→ injecte __USER_EMAIL__ depuis X-Forwarded-Email
→ JavaScript lookup dans roles-mapping.json
→ cache/affiche les cartes selon rôle
```
### Pourquoi Approche A (et pas RBAC complet sur chaque outil) ?
Détails dans [DECISIONS.md](./DECISIONS.md), section "RBAC Approche A".
**TL;DR** : 0€, 2-3h de boulot, suffisant pour Seize (88 employés majoritairement non-techniques).
---
## 📊 Volumes critiques à backuper
| Données | Volume / Chemin | Criticité |
|---|---|---|
| Base PostgreSQL Zitadel | `~/docker/zitadel/.../data/postgres/` | 🔴 Critique |
| Base PostgreSQL Gitea/n8n | `~/docker/postgres/data/` | 🔴 Critique |
| Données Gitea (repos) | `~/docker/gitea/data/` | 🔴 Critique |
| Données Portainer | `~/docker/portainer/data/` | 🟡 Important |
| Données Uptime Kuma | `~/docker/uptime-kuma/data/` | 🟡 Important |
| Données n8n | volume Docker `n8n_data` | 🟡 Important |
| Données Code Server | `~/docker/code-server/config/` | 🟢 Récupérable |
| Certificats Let's Encrypt | `~/docker/traefik/letsencrypt/` | 🟢 Auto-régénéré |
| Scripts d'import + clés | `~/furious-to-zitadel/` | 🔴 Critique |
⚠️ **Aucun backup automatique en place** au moment de la passation. À mettre en priorité.
---
## 🔧 Outils CLI utiles installés
| Outil | Usage |
|---|---|
| `docker` | Gestion conteneurs |
| `docker compose` | Orchestration |
| `ctop` | Top des conteneurs (consommation ressources) |
| `sudo journalctl` | Logs système |
| `htop` | Top processus |
| `python3` + venv | Pour les scripts d'import |
---
## 🌍 DNS — Records configurés (chez le registrar)
Tous les sous-domaines pointent vers `151.115.148.243` :
| Record | Type | Valeur |
|---|---|---|
| `seizeconsulting.com` | A | 151.115.148.243 |
| `sso.seizeconsulting.com` | A | 151.115.148.243 |
| `intranet.seizeconsulting.com` | A | 151.115.148.243 |
| `git.seizeconsulting.com` | A | 151.115.148.243 |
| `n8n.seizeconsulting.com` | A | 151.115.148.243 |
| `portainer.seizeconsulting.com` | A | 151.115.148.243 |
| `code.seizeconsulting.com` | A | 151.115.148.243 |
| `status.seizeconsulting.com` | A | 151.115.148.243 |
| `traefik.seizeconsulting.com` | A | 151.115.148.243 |
| `app.seizeconsulting.com` | A | 151.115.148.243 |
⚠️ **Ne PAS oublier** d'ajouter un record DNS si tu ajoutes un nouvel outil. Sinon Let's Encrypt échoue.

356
DECISIONS.md Normal file
View File

@ -0,0 +1,356 @@
# DECISIONS — Choix architecturaux et anti-patterns
> Pourquoi telle décision plutôt qu'une autre. À lire pour comprendre les **trade-offs** et **ne pas refaire les mêmes erreurs**.
---
## 🎯 D1 — Zitadel plutôt qu'Authentik comme IDP
### Contexte
Au jour 2, on a commencé par déployer **Authentik** comme IDP. Au jour 3, on a migré vers **Zitadel**.
### Décision
**Zitadel gagne.**
### Pourquoi
- ✅ **API plus stable** : la doc Zitadel est plus claire, l'API v2 est très propre
- ✅ **Support de Custom Claims** plus mature (urn:zitadel:iam:org:project:roles)
- ✅ **Performance** : Zitadel scale mieux que Authentik en background workers
- ✅ **Communauté** plus active sur les vrais besoins entreprise
- ✅ **MFA TOTP** plus simple à configurer
### Inconvénients acceptés
- ❌ Pas de GUI de gestion des flows aussi avancée qu'Authentik
- ❌ Custom Claims via Actions V2 (instable au moment de la décision)
### Anti-pattern évité
**Ne PAS hésiter à migrer** si l'outil initial pose problème, même si on a investi du temps. Mieux vaut perdre 1 jour de migration que rester bloqué 6 mois sur une stack inadaptée.
---
## 🎯 D2 — Pattern "OAuth2-Proxy par outil" plutôt que centralisé
### Contexte
Pour activer le SSO sur 5 outils différents (intranet, n8n, Portainer, Code Server, Uptime Kuma), 2 options :
- **Centralisé** : 1 seul OAuth2-Proxy pour tous les outils, distinguant par route
- **Par outil** : 1 OAuth2-Proxy dédié à chaque outil
### Décision
**OAuth2-Proxy par outil.**
### Pourquoi
- ✅ **Isolation** : si un OAuth2-Proxy crash, les autres outils continuent
- ✅ **Configuration différenciée** : chaque outil peut avoir son propre cookie, scope, upstream
- ✅ **Debugging simple** : `docker logs oauth2-proxy-n8n` ne mélange pas avec les autres
- ✅ **Clients OIDC séparés** dans Zitadel : permission fine
### Inconvénients acceptés
- 5 conteneurs OAuth2-Proxy au lieu de 1 (~50 Mo RAM chacun, négligeable)
- 5 configurations à maintenir (mais elles se ressemblent toutes)
### Anti-pattern évité
**Ne PAS factoriser à outrance** "pour faire propre" si ça crée du couplage entre outils indépendants.
---
## 🎯 D3 — Bug Traefik authRequestHeaders=Cookie
### Contexte
Pendant 1 journée entière, **TOUS les SSO étaient cassés** : à chaque requête, l'user était redirigé vers le login Zitadel, même après login réussi.
### Cause découverte
Par défaut, le middleware **ForwardAuth de Traefik** ne transmet **PAS** le header `Cookie` à OAuth2-Proxy. Du coup OAuth2-Proxy ne voit jamais le cookie de session qu'il a lui-même créé → tout est "non authentifié".
### Décision / Fix
Dans `~/docker/traefik/dynamic.yml` :
```yaml
http:
middlewares:
oauth2-headers:
forwardAuth:
address: "http://oauth2-proxy:4180/oauth2/auth"
authRequestHeaders:
- Cookie # ← LA LIGNE CRITIQUE
- Authorization
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
- X-Auth-Request-Preferred-Username
- X-Auth-Request-Groups
```
### Anti-pattern évité
**Ne PAS supposer que les valeurs par défaut sont sensées.** Toujours vérifier la doc des middlewares quand quelque chose ne marche pas.
### Référence
- Issue Traefik : https://github.com/traefik/traefik/issues/...
- Forum OAuth2-Proxy : https://github.com/oauth2-proxy/oauth2-proxy/issues/...
---
## 🎯 D4 — Service account avec IAM_USER_MANAGER (pas IAM_OWNER)
### Contexte
Pour le script d'import Furious → Zitadel, il fallait un compte API avec droits admin Zitadel.
### Décision
**`IAM_USER_MANAGER`** uniquement, **pas** `IAM_OWNER`.
### Pourquoi (principe du moindre privilège)
| Rôle | Peut faire |
|---|---|
| IAM_USER_MANAGER | Créer, modifier, supprimer des **users** uniquement |
| IAM_OWNER | **TOUT** : modifier l'instance, supprimer projects, désactiver MFA d'admins, etc. |
→ Si la clé du service account fuite, l'attaquant peut **créer des users** mais **pas détruire l'instance**.
### Anti-pattern évité
**Ne JAMAIS donner les droits maximum à un service account** par flemme. Toujours commencer minimal, augmenter si besoin.
---
## 🎯 D5 — Fusion des doublons : garder anciens, supprimer nouveaux
### Contexte
Au moment de l'import, 4 users existaient déjà dans Zitadel avec leur trigramme :
- HKA (Hedi Kalboussi)
- PSP (Philippe Spoljaric)
- PYT (Pierre-Yves Tachon)
- Jules (Jules Bataille)
L'import a créé des **doublons** : `hedi.kalboussi@...`, `philippe.spoljaric@...`, etc.
### Décision
**Garder les anciens (avec MFA configuré), supprimer les nouveaux.**
### Pourquoi
| Critère | Anciens (HKA, PSP, ...) | Nouveaux (email) |
|---|---|---|
| MFA configuré | ✅ Oui | ❌ Non |
| Mdp connu par l'user | ✅ Oui | 🟡 Temporaire dans CSV |
| Si suppression : impact | 🔴 Hedi se bloque hors de Zitadel | 🟢 Aucun |
| Trigramme pratique | ✅ Oui (HKA, PSP) | ❌ Email long |
| Metadata Furious | ❌ Non (à recopier) | ✅ Oui |
### Solution finale
Script `merge-duplicates.py` :
1. Lit les metadata du nouveau compte
2. Les copie sur l'ancien compte
3. Supprime le nouveau
### Anti-pattern évité
**Ne JAMAIS supprimer un compte admin sans avoir validé** qu'on garde un accès admin alternatif. Risque de blocage hors de Zitadel.
---
## 🎯 D6 — Metadata custom plutôt que rôles "vrais" Zitadel au moment de l'import
### Contexte
Au moment de l'import (Phase 1 le jour 7 matin), 2 options :
- A : Importer les users **avec rôles** (decisions business prises seul)
- B : Importer les users **avec metadata custom** (job_title, bu_name, manager...), rôles attribués ensuite après validation
### Décision
**Option B — metadata d'abord, rôles ensuite (Phase 2)**.
### Pourquoi
- ✅ **Pas de décision business prématurée** (Philippe a validé l'import, pas le mapping de rôles)
- ✅ **Évolutif** : si Philippe change la matrice plus tard, on relance juste `assign-roles.py`
- ✅ **Traçabilité** : metadata Furious préservées de toute façon (utiles plus tard)
### Inconvénient accepté
- Phase 2 (attribution des rôles) à faire dans la même journée → finalement OK car on a eu le temps
### Anti-pattern évité
**Ne PAS mélanger import technique et décision business** dans un même script. Séparer les responsabilités.
---
## 🎯 D7 — RBAC Approche A (intranet uniquement) — la grande décision
### Contexte
Pour que chaque user voie uniquement les outils auxquels il a accès, 3 options évaluées :
### Option A — RBAC sur l'intranet uniquement
- Le HTML de l'intranet **cache** les cartes selon le rôle
- L'outil lui-même reste accessible si l'URL directe est tapée
- **Sécurité** : par "obscurité" (suffisant pour 88 employés non-malveillants)
### Option B — RBAC complet avec migrations gratuites
- Migrer n8n → Activepieces, Portainer CE → Komodo, Uptime Kuma → Gatus, Code Server → OpenVSCode
- Chaque outil filtre nativement par OIDC claims
- **Effort** : 5-6 jours
- **Coût** : 0€
### Option C — RBAC complet avec licences pro
- n8n Enterprise (~12k€/an), Portainer Business (~1800€/an), etc.
- **Effort** : 1 jour
- **Coût** : ~25 800€/an
### Décision (par Hedi, sponsor Philippe)
**Option A.**
### Pourquoi
- ✅ **0€** vs ~25 800€/an
- ✅ **2-3h** vs 5-6 jours
- ✅ **Suffisant** pour la culture Seize (88 employés, peu de techniciens)
- ✅ **Évolutif** : on peut passer à B ou C plus tard si besoin
- 🟡 **Limite acceptée** : un user qui connaît `n8n.seizeconsulting.com` peut bypass
### Risque accepté
Si un user malveillant connaît l'URL directe d'un outil, il y accède. Mais :
- L'authentification reste obligatoire (OAuth2-Proxy bloque les non-authentifiés)
- Tous les accès sont **logués** par Zitadel + Traefik
- On peut détecter des accès anormaux dans les logs
### Anti-pattern évité
**Ne JAMAIS sur-ingénier la sécurité** au-delà de la menace réelle. Pour 88 collègues non-malveillants, l'Approche A est suffisante.
---
## 🎯 D8 — Pas de SMTP configuré : distribution manuelle des mdp
### Contexte
Sans serveur SMTP configuré dans Zitadel, **aucun email automatique** :
- Pas d'email de bienvenue
- Pas de "Mot de passe oublié"
- Pas de notifications MFA
### Décision (validée par Philippe)
**Distribution manuelle des 88 mdp temporaires via Teams en privé.**
### Pourquoi
- 🟡 L'admin M365 chez Seize n'est pas Hedi → solliciter quelqu'un d'autre
- 🟡 Configuration SMTP M365 = créer compte `noreply@`, App Password, etc.
- ✅ Distribution manuelle = 88 messages Teams en privé, étalés sur 5-7 jours
### Anti-pattern évité
**Ne PAS bloquer le projet** parce qu'une dépendance externe (admin M365) n'est pas prête. Trouver un workaround manuel acceptable.
### À faire plus tard
Configurer SMTP M365 pour les futurs onboardings (cf. HANDOVER.md).
---
## 🎯 D9 — Action "Return user roles during authentication" cochée mais "Only authorized users" décochée
### Contexte
Dans la config du Project Zitadel "Infra Seize", il y a 2 options :
- **"Return user roles during authentication"** : ajoute les rôles dans le token OIDC
- **"Only authorized users can authenticate"** : bloque les users sans rôle
### Décision (par Hedi)
- ✅ **Cocher "Return user roles"** : nécessaire pour transmettre les rôles à l'intranet
- ❌ **Ne PAS cocher "Only authorized users"** : un nouvel arrivant qui n'a pas encore son rôle attribué doit pouvoir se logger
### Pourquoi le 2ème non
- Pendant l'onboarding RH d'un nouveau collaborateur, son compte est créé dans Furious, importé dans Zitadel, mais le **rôle** est attribué peut-être 1-2 jours après (le temps de remplir son `job_title`).
- Si on coche "Only authorized users", il se prendra un **"Access Denied"** à son premier login, même s'il a un compte valide.
### Anti-pattern évité
**Ne PAS optimiser pour le 99% des cas** si ça casse 1% des cas critiques (nouveau collaborateur premier jour).
---
## 🎯 D10 — Token JWT dans Authorization plutôt que dans cookie
### Contexte
OAuth2-Proxy peut transmettre le token JWT à l'outil via :
- Header `Authorization: Bearer <token>` (par défaut)
- Cookie HTTP-only
### Décision
**Authorization header.**
### Pourquoi
- ✅ **Standard OIDC** : tous les outils backends savent lire le header Authorization
- ✅ **Pas accessible par JavaScript** côté navigateur (sécurité XSS)
- ❌ **JavaScript ne peut PAS lire** le token côté browser (mais ce n'est pas notre besoin avec l'Approche A)
### Anti-pattern évité
**Ne PAS exposer le token JWT au JavaScript** côté navigateur via cookie non-HTTP-only. C'est une faille de sécurité courante.
---
## 🎯 D11 — Sub_filter nginx pour injecter l'email dans le HTML
### Contexte
Le JavaScript de l'intranet a besoin de connaître l'email de l'user connecté pour déterminer son rôle. Mais JS ne peut pas lire les headers HTTP serveur.
### Décision
**Module `sub_filter` de nginx** pour substituer `__USER_EMAIL__` par la vraie valeur du header `X-Forwarded-Email`.
### Pourquoi
- ✅ **Module natif nginx** : pas besoin d'installer Lua/njs
- ✅ **Simple à comprendre** : 3 lignes de config nginx
- ✅ **Performant** : aucun overhead notable
### Inconvénient accepté
- Si on change la valeur du placeholder (`__USER_EMAIL__`) dans l'HTML, faut aussi changer la config nginx
### Anti-pattern évité
**Ne PAS sur-ingénier** avec un mini-backend ou des modules nginx exotiques quand `sub_filter` natif suffit.
---
## 🎯 D12 — Stockage des secrets dans des `.env` séparés (1 par service)
### Contexte
Tous les services Docker ont leurs secrets dans des fichiers `.env`. On aurait pu :
- A : 1 seul `.env` global avec tous les secrets
- B : 1 `.env` par service
### Décision
**Option B — 1 `.env` par service.**
### Pourquoi
- ✅ **Isolation** : si un .env fuite, seul ce service est compromis
- ✅ **Permissions Linux** : `chmod 600` par fichier
- ✅ **Modifications ciblées** : changer le mdp Postgres ne touche pas les autres services
- ✅ **Sauvegarde différenciée** possible
### Anti-pattern évité
**Ne JAMAIS centraliser tous les secrets dans un seul fichier** "pour simplifier". C'est juste centraliser le risque.
---
## 🎯 D13 — Gitea SSO auto-registration activée
### Contexte
Gitea peut être configuré pour :
- A : Pré-créer les users en base + ils se loggent ensuite
- B : **Auto-créer** les users à leur 1ère connexion via OIDC
### Décision
**Option B — auto-registration via OIDC.**
### Configuration
Dans `~/docker/gitea/data/gitea/conf/app.ini` (ou via env vars) :
```ini
[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
USERNAME = preferred_username
```
### Pourquoi
- ✅ **Pas de double création** : l'user existe dans Zitadel → 1ère connexion crée le compte Gitea
- ✅ **Username = trigramme** (préféré dans Seize)
- ✅ **Mise à jour automatique** des infos profile
### Anti-pattern évité
**Ne PAS créer manuellement les comptes** dans chaque outil si l'outil supporte l'auto-registration OIDC.
---
## 📚 Récap des anti-patterns à NE PAS reproduire
1. ❌ **IAM_OWNER pour les service accounts** → utiliser le rôle minimal
2. ❌ **Supprimer un compte admin sans backup** → garder un accès alternatif
3. ❌ **Nouveau project Zitadel** quand un existant a déjà les apps → ajouter dedans
4. ❌ **`scp` depuis le serveur SSH vers lui-même** → toujours depuis le PC client
5. ❌ **Coller des secrets dans des conversations IA** → gestionnaire de mots de passe
6. ❌ **Activer "Only authorized users"** sans process d'onboarding fluide
7. ❌ **Importer avec rôles + decisions business non validées** → metadata d'abord
8. ❌ **Pas de backup des données** → critique pour Zitadel et Gitea
9. ❌ **Sur-ingénierie sécurité** au-delà de la menace réelle
10. ❌ **Hardcoder les secrets** dans les `docker-compose.yml` → utiliser des `.env`

226
HANDOVER.md Normal file
View File

@ -0,0 +1,226 @@
# 🚨 HANDOVER — Passation au prochain mainteneur
> Ce document est **prioritaire**. Lis-le **avant tout** si tu reprends le projet.
---
## ✅ Ce qui marche (état au 5 juin 2026)
### Infrastructure
- ✅ Stack Docker sur Scaleway, 24 conteneurs actifs
- ✅ Traefik comme reverse proxy avec Let's Encrypt auto
- ✅ Zitadel comme IDP, accessible sur `sso.seizeconsulting.com`
- ✅ 7 sous-domaines en HTTPS opérationnels
- ✅ Pattern OAuth2-Proxy par outil pour le SSO
### Utilisateurs
- ✅ 88 collaborateurs Seize importés dans Zitadel
- ✅ 4 anciens comptes (HKA, PSP, PYT, Jules) conservés avec leur MFA
- ✅ Metadata Furious copiées sur tous les comptes (job_title, bu_name, manager, etc.)
- ✅ Mots de passe temporaires générés dans `users-credentials.csv`
### RBAC
- ✅ 8 rôles créés dans le project "Infra Seize" (Zitadel)
- ✅ 87/88 users avec rôle attribué (1 sans : le user "vide" Furious à corriger)
- ✅ Intranet filtre les cartes selon le rôle
- ✅ Testé sur HKA (consultant_junior) : voit Gitea + Code Server uniquement
### Imports automatisés
- ✅ Scripts Python dans `~/furious-to-zitadel/` opérationnels
- ✅ Mode dry-run / test / prod pour `import-furious-to-zitadel.py`
- ✅ Script de fusion de doublons (`merge-duplicates.py`)
- ✅ Script de génération du mapping RBAC (`generate-roles-mapping.py`)
- ✅ Script d'attribution des rôles (`assign-roles.py`)
---
## 🔴 À faire en priorité (urgent)
### 1. Rotation des secrets exposés
Plusieurs secrets ont été partagés dans des conversations IA pendant le développement. **À régénérer immédiatement** :
| Secret | Où | Action |
|---|---|---|
| Compte API Furious `mick.emmaus.90849` | Furious > Configuration API | Régénérer le mdp |
| Token JWT user HKA (1 fois, expiré) | N/A | Déjà expiré, juste vérifier |
| Mdp Postgres Gitea | `~/docker/gitea/.env` (`GITEA_DB_PASSWORD`) | Régénérer + restart |
| Secrets Gitea (LFS_JWT, INTERNAL_TOKEN, JWT_SECRET) | `~/docker/gitea/.env` | Régénérer + restart |
| Clé service account Zitadel `zitadelfuriousimportkey.json` | `~/furious-to-zitadel/` | Régénérer si suspicion compromission |
### 2. Distribuer les 88 mdp temporaires
Le fichier `~/furious-to-zitadel/distribution-teams.csv` contient 88 lignes :
`Nom complet | Email | Mot de passe temporaire`
**Procédure recommandée** (validée avec Philippe) :
- Envoyer 1 mdp par message **privé Teams** (jamais en canal public)
- Indiquer URL : https://intranet.seizeconsulting.com
- Préciser : "Mdp temporaire à changer à la 1ère connexion"
- Étaler sur 5-7 jours (10-20 par jour) pour gérer le support 1er login
**Une fois distribution terminée** : supprimer le CSV (corbeille + vider la corbeille).
### 3. Configurer SMTP M365 (délégué à l'admin M365)
Sans SMTP, Zitadel ne peut pas envoyer d'emails (welcome, reset password, MFA).
**Procédure** :
1. Demander à l'admin M365 de créer `noreply@seizeconsulting.com`
2. Activer SMTP AUTH sur ce compte
3. Générer un App Password
4. Configurer dans Zitadel : Default settings → SMTP Provider
5. Tester l'envoi
Détails dans [docs/08-secrets-management.md](./docs/08-secrets-management.md).
### 4. Nettoyer Authentik (plus utilisé)
```bash
cd ~/docker/authentik
sudo docker compose down
# Optionnel : sauvegarder le dossier avant suppression
mv ~/docker/authentik ~/docker/authentik.archived
```
Idem pour Redis si plus utilisé par d'autres services (à vérifier).
---
## 🟡 À faire ensuite (important)
### 5. Documenter pour Philippe
Philippe a validé l'import et la matrice RBAC en oral mais **n'a pas vu la liste exacte** des 88 users.
À lui transmettre :
- Liste des 88 users importés (sans les mdp, pour traçabilité)
- Matrice RBAC effective (cf. [matrices/rbac-matrix.md](./matrices/rbac-matrix.md))
- Date/heure d'import : 5 juin 2026 07:27-07:42
### 6. Workshop validation matrice RBAC
La matrice actuelle a été **tranchée par Hedi seul** car Philippe pas dispo le jour de l'import. À valider en workshop :
- Direction = Président + Directeur + Directeur Associé ?
- Manager = inclut Manager BI ?
- Experts = au-dessus ou en parallèle des Managers ?
- Apprentis vs Stagiaires : 2 rôles distincts ou 1 seul ?
### 7. Corriger l'anomalie du user "vide" Furious
1 collaborateur a `job_title` vide dans Furious → pas de rôle Zitadel attribué.
**Action** : identifier qui (Hedi sait) et compléter dans Furious, puis relancer `assign-roles.py`.
### 8. Corriger les anomalies d'orthographe Furious
Doublons dans Furious à standardiser :
- `Senior Manager` vs `Sénior Manager`
- `Consultant Expérimenté` vs `Consultant Expérimentée` vs `Consultant Sénior`
- `Consultant Expert niveau 1` (avec espace en début parfois)
Le script `assign-roles.py` gère ces variantes via mapping mais c'est mieux de standardiser à la source.
---
## 🟢 À faire à terme (moyen/long terme)
### 9. Décision business : migrations outils gratuits OU licences pro ?
Pour avoir un **vrai RBAC** (pas juste cosmétique sur l'intranet), il faut :
**Option A** — Migrations gratuites (5-6j de boulot, 0€/an) :
- n8n → Activepieces
- Portainer CE → Komodo
- Uptime Kuma → Gatus
- Code Server → OpenVSCode Server (RBAC limité)
**Option B** — Licences pro (~25 800€/an, 1j de config) :
- n8n Enterprise
- Portainer Business
- (le reste reste gratuit)
**Décision sponsor** : Philippe + Pierre-Yves Tachon.
### 10. Workflow n8n de sync continue Furious → Zitadel
Idée : tous les jours à 3h du matin, n8n :
- Récupère la liste des users Furious
- Identifie les nouveaux arrivants → crée dans Zitadel
- Identifie les départs (`departure_date != "0000-00-00"`) → désactive dans Zitadel
- Met à jour les metadata si changement
⏱️ ~1 journée de dev n8n.
### 11. Custom Claims OIDC (attendre Actions V2 Zitadel)
Pour vraiment transmettre les rôles aux outils backends (Gitea, n8n, etc.) en plus de l'intranet, on attend la stabilisation d'**Actions V2** dans Zitadel.
### 12. Fédération M365/Entra ID ↔ Zitadel
Plus tard : permettre aux users de se logger via leur compte M365 entreprise.
### 13. Backup chiffré + plan de reprise
Actuellement, **aucun backup automatisé**. Critique pour la base PostgreSQL de Zitadel et Gitea.
À mettre en place :
- Backup quotidien de PostgreSQL (avec `pg_dump`)
- Backup des volumes Docker (Gitea data, Zitadel data)
- Stockage offsite (Scaleway Object Storage chiffré)
### 14. Monitoring sécurité avancé
- **Fail2ban** ou **Crowdsec** pour bloquer les IPs malveillantes (les logs OAuth2-Proxy montrent beaucoup de tentatives de scans)
- Alerting sur tentatives de login échouées (via Uptime Kuma ou autre)
---
## 🗣️ Conversations / décisions clés à connaître
### Décisions architecturales tranchées
1. **Zitadel plutôt qu'Authentik** : meilleur support API, plus stable, mieux documenté
2. **OAuth2-Proxy par outil (pas centralisé)** : permet config différenciée par app
3. **RBAC sur intranet uniquement (Approche A)** : zéro coût, suffisant à 90% pour 88 employés non-techniques
4. **Garder anciens comptes (HKA, PSP, PYT, Jules)** avec MFA, supprimer les doublons importés
5. **Metadata custom plutôt que rôles "vrais" Zitadel** au moment de l'import (rôles ajoutés ensuite)
6. **Pas de SMTP configuré** : mdp distribués manuellement via Teams
7. **Pas de migration M365 immédiate** : les users gardent leurs comptes Zitadel locaux
### Anti-patterns identifiés
- ❌ **Ne PAS donner IAM_OWNER aux service accounts** → utiliser le rôle minimal (`IAM_USER_MANAGER`)
- ❌ **Ne PAS créer un nouveau project Zitadel** pour les rôles si "Infra Seize" existe déjà (sinon OAuth2-Proxy ne les voit pas)
- ❌ **Ne PAS exécuter `scp` depuis le serveur SSH** vers lui-même — toujours depuis le PC client
- ❌ **Ne PAS coller de secrets** (tokens, mdp, clés API) dans des conversations IA — utiliser un gestionnaire de mots de passe
- ❌ **Ne PAS supprimer un user admin** sans avoir vérifié qu'on a un autre accès admin (risque de blocage)
---
## 📞 Contacts utiles
| Personne | Rôle | Contact |
|---|---|---|
| Hedi Kalboussi (HKA) | Mainteneur sortant | Slack/Teams interne |
| Philippe Spoljaric (PSP) | Sponsor + Admin Infra | Slack/Teams interne |
| Pierre-Yves Tachon (PYT) | CEO + arbitrage budget | Slack/Teams interne |
| Support Furious Squad | API + données RH | seize-consulting.furious-squad.com |
| Scaleway | Hébergeur | console.scaleway.com |
---
## 🎯 Premier jour de la passation — checklist
- [ ] Lire ce HANDOVER en entier
- [ ] Lire ARCHITECTURE.md
- [ ] Se connecter en SSH au serveur : `ssh hedi@151.115.148.243`
- [ ] Demander accès Zitadel admin à Hedi
- [ ] Lister les conteneurs : `sudo docker ps`
- [ ] Vérifier les logs récents : `sudo docker logs --tail 50 traefik`
- [ ] Tester un login en HKA depuis incognito
- [ ] Faire un point téléphone avec Hedi pour Q&A
- [ ] Identifier les 1-2 actions urgentes à reprendre
---
**Bon courage, et bienvenue dans le projet !** 🚀

522
INSTALL.md Normal file
View File

@ -0,0 +1,522 @@
# INSTALL — Déploiement from scratch sur un nouveau serveur
> Procédure complète pour reproduire l'infrastructure Seize sur un nouveau serveur.
⏱️ **Temps total estimé** : 4-6h pour un sysadmin expérimenté.
---
## 📋 Prérequis
### Côté infrastructure
- [ ] **Serveur Linux** : 4 vCPU, 8 Go RAM minimum (16 Go recommandé), 80 Go disque
- [ ] **OS** : Debian 13 (Trixie) ou Ubuntu 22.04+
- [ ] **IP publique fixe** (chez Scaleway ou autre)
- [ ] **Ports ouverts** : 22 (SSH), 80 (HTTP), 443 (HTTPS)
### Côté domaine
- [ ] Un **domaine** que vous contrôlez (ex: `seizeconsulting.com`)
- [ ] Accès à la **zone DNS** pour ajouter des records A
### Côté tiers
- [ ] Compte **Furious Squad** avec droits admin (pour créer un compte API)
- [ ] (Optionnel) Compte **Microsoft 365** avec admin pour le SMTP
### Côté local
- [ ] Un **PC** avec SSH et SCP (Windows PowerShell, Linux, ou macOS)
- [ ] Un **gestionnaire de mots de passe** (Bitwarden, KeePass, 1Password)
---
## 🌐 Étape 0 — Configuration DNS
⏱️ **15 min** (mais propagation peut prendre 1h+)
Chez votre registrar (Scaleway, OVH, Gandi, etc.), ajouter les records A suivants :
| Record | Type | Valeur |
|---|---|---|
| `@` (ou `seizeconsulting.com`) | A | `<IP_DU_SERVEUR>` |
| `sso` | A | `<IP_DU_SERVEUR>` |
| `intranet` | A | `<IP_DU_SERVEUR>` |
| `git` | A | `<IP_DU_SERVEUR>` |
| `n8n` | A | `<IP_DU_SERVEUR>` |
| `portainer` | A | `<IP_DU_SERVEUR>` |
| `code` | A | `<IP_DU_SERVEUR>` |
| `status` | A | `<IP_DU_SERVEUR>` |
| `traefik` | A | `<IP_DU_SERVEUR>` |
**Vérification** :
```bash
nslookup sso.seizeconsulting.com
```
→ Doit retourner l'IP du serveur.
---
## 🐧 Étape 1 — Préparation du serveur
⏱️ **20 min**
### 1.1 — Connexion SSH initiale
```bash
ssh root@<IP_DU_SERVEUR>
```
### 1.2 — Créer un user non-root
```bash
adduser admin
usermod -aG sudo admin
mkdir -p /home/admin/.ssh
cp ~/.ssh/authorized_keys /home/admin/.ssh/
chown -R admin:admin /home/admin/.ssh
chmod 600 /home/admin/.ssh/authorized_keys
```
### 1.3 — Hardening SSH
Éditer `/etc/ssh/sshd_config` :
```
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
```
Restart : `systemctl restart sshd`
### 1.4 — Reconnexion en tant que l'utilisateur non-root
```bash
exit
ssh admin@<IP_DU_SERVEUR>
```
### 1.5 — Mise à jour système
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget git vim htop unzip
```
---
## 🐳 Étape 2 — Installation Docker
⏱️ **10 min**
```bash
# Installation Docker depuis le repo officiel
curl -fsSL https://get.docker.com | sudo sh
# Ajouter l'utilisateur au groupe docker
sudo usermod -aG docker $USER
# Se relogger pour prendre en compte le groupe
exit
ssh admin@<IP_DU_SERVEUR>
# Vérification
docker --version
docker compose version
```
→ Tu dois voir : Docker version 27.x ou plus récent, Docker Compose v2.x
---
## 🌐 Étape 3 — Création des réseaux Docker
⏱️ **2 min**
```bash
docker network create web
docker network create backend
```
→ Ces réseaux seront partagés entre tous les services.
---
## 🛡️ Étape 4 — Installation Traefik
⏱️ **30 min**
Voir [docs/02-traefik-setup.md](./docs/02-traefik-setup.md) pour les détails.
### Résumé
```bash
mkdir -p ~/docker/traefik/letsencrypt
cd ~/docker/traefik
# Copier le docker-compose.yml depuis configs/traefik/
# Copier traefik.yml et dynamic.yml depuis configs/traefik/
# Adapter les emails Let's Encrypt
docker compose up -d
```
**Vérification** :
```bash
docker logs traefik
# Doit afficher "Server is up"
```
Visiter `https://traefik.seizeconsulting.com` → doit afficher la page de login basic auth.
---
## 🔐 Étape 5 — Installation Zitadel
⏱️ **45 min**
Voir [docs/03-zitadel-setup.md](./docs/03-zitadel-setup.md) pour les détails complets.
### Résumé des points clés
1. Créer `~/docker/zitadel/` avec son `docker-compose.yml` et `.env`
2. **Important** : Zitadel a son propre Traefik intégré + Postgres dédié
3. Lancer : `docker compose up -d`
4. Aller sur `https://sso.seizeconsulting.com/ui/console`
5. Créer le user **zitadel-admin** initial via console
6. Configurer les politiques :
- Password complexity (14 chars, maj+min+chiffres+symboles)
- MFA obligatoire
- Lockout après 5 échecs
⚠️ **Sauvegarder le mdp zitadel-admin** immédiatement dans le gestionnaire de mots de passe.
---
## 🗄️ Étape 6 — Installation Postgres (pour Gitea, n8n)
⏱️ **10 min**
```bash
mkdir -p ~/docker/postgres/data
cd ~/docker/postgres
# Copier docker-compose.yml et .env.example depuis configs/postgres/
# Renommer .env.example en .env et générer un mdp fort
cat > .env << EOF
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '+/=')
EOF
chmod 600 .env
docker compose up -d
# Vérification
docker exec -it postgres psql -U postgres -c "SELECT version();"
```
### Créer les databases pour Gitea et n8n
```bash
docker exec -it postgres psql -U postgres << EOF
CREATE USER gitea WITH PASSWORD 'PWD_GITEA';
CREATE DATABASE gitea OWNER gitea;
CREATE USER n8n WITH PASSWORD 'PWD_N8N';
CREATE DATABASE n8n OWNER n8n;
EOF
```
⚠️ Remplacer `PWD_GITEA` et `PWD_N8N` par des mdp générés (`openssl rand -base64 24`) et les stocker dans le gestionnaire.
---
## 🐙 Étape 7 — Installation Gitea avec SSO Zitadel
⏱️ **45 min**
Voir [docs/05-applications.md](./docs/05-applications.md) section Gitea.
### Étapes principales
1. Créer `~/docker/gitea/` avec son `docker-compose.yml` et `.env`
2. Démarrer Gitea : `docker compose up -d`
3. Aller sur `https://git.seizeconsulting.com` → faire le setup initial
4. **Dans Zitadel** : créer une application "Gitea" dans le project "Infra Seize"
5. Configurer dans Gitea : Settings → Authentication Sources → Add OAuth2
6. **Activer auto-registration** dans `app.ini` :
```ini
[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
USERNAME = preferred_username
```
7. Restart Gitea : `docker compose restart`
---
## 🔐 Étape 8 — Pattern OAuth2-Proxy pour les autres outils
⏱️ **2h** (à répéter pour chaque outil)
Voir [docs/04-oauth2-proxy-pattern.md](./docs/04-oauth2-proxy-pattern.md) pour le pattern complet.
### Outils à protéger via OAuth2-Proxy
- n8n
- Portainer
- Code Server
- Uptime Kuma
- Intranet
### Étapes par outil (exemple : n8n)
1. **Déployer l'outil** (sans SSO) :
```bash
mkdir -p ~/docker/n8n
# Copier docker-compose.yml depuis configs/n8n/
docker compose up -d
```
2. **Créer l'application dans Zitadel** :
- Console Zitadel → Projects → Infra Seize → Applications → New
- Type : Web
- Authentication Method : Basic
- Grant Types : Authorization Code
- Redirect URL : `https://n8n.seizeconsulting.com/oauth2/callback`
- Token Type : JWT
- Cocher : User roles inside ID Token, User profile info, Add user roles to access token
3. **Récupérer Client ID + Client Secret**
4. **Déployer OAuth2-Proxy dédié** :
```bash
mkdir -p ~/docker/oauth2-proxy-n8n
# Copier docker-compose.yml et .env.example depuis configs/oauth2-proxy/
# Adapter les valeurs (host, client_id, client_secret, upstream)
cd ~/docker/oauth2-proxy-n8n
docker compose up -d
```
5. **Tester** : aller sur `https://n8n.seizeconsulting.com` → doit rediriger vers Zitadel login.
---
## 🏠 Étape 9 — Installation Intranet avec RBAC
⏱️ **30 min**
Voir [docs/07-rbac-intranet.md](./docs/07-rbac-intranet.md) pour les détails.
### Étapes
1. **Créer le dossier intranet** :
```bash
mkdir -p ~/docker/intranet
```
2. **Copier les fichiers** depuis `configs/intranet/` :
- `docker-compose.yml`
- `nginx-rbac.conf`
- `index.html.template` (à personnaliser)
- `logo.png` (le logo Seize)
3. **Créer le `roles-mapping.json`** :
Il sera généré plus tard par le script `generate-roles-mapping.py` (cf. Étape 10).
En attendant, créer un mapping vide :
```bash
echo '{}' > roles-mapping.json
```
4. **Déployer OAuth2-Proxy pour l'intranet** : voir Étape 8 mais avec :
- Host : `intranet.seizeconsulting.com`
- Upstream : `http://intranet:80`
5. **Démarrer l'intranet** :
```bash
cd ~/docker/intranet
docker compose up -d
```
6. **Tester** : aller sur `https://intranet.seizeconsulting.com` → login Zitadel → voir les cartes (toutes affichées tant que roles-mapping.json est vide).
---
## 👥 Étape 10 — Import des users depuis Furious Squad
⏱️ **1h**
Voir [docs/06-import-furious.md](./docs/06-import-furious.md) pour la procédure complète.
### Étapes principales
1. **Créer un compte API Furious** :
- Connexion Furious → Configuration → Générer un nouvel utilisateur d'API
- Cocher les permissions : `User > Search` uniquement
- Sauvegarder identifiant + mdp dans le gestionnaire
2. **Créer le service account Zitadel** :
- Console Zitadel → Users → New → Service user
- Nom : `furious-import-service`
- Access Token Type : JWT
- Dans Default settings → IAM Members → ajouter ce service user avec rôle `IAM User Manager`
- Générer une clé : Keys → New → Type JSON → télécharger immédiatement
3. **Préparer l'environnement Python** :
```bash
mkdir -p ~/furious-to-zitadel
cd ~/furious-to-zitadel
python3 -m venv venv
source venv/bin/activate
pip install requests pyjwt cryptography
```
4. **Copier les scripts** depuis `scripts/furious-to-zitadel/` du repo.
5. **Créer le `.env`** :
```env
FURIOUS_USERNAME=<credentials créés à l'étape 1>
FURIOUS_PASSWORD=<credentials créés à l'étape 1>
FURIOUS_API_URL=https://seize-consulting.furious-squad.com/api/v2
```
`chmod 600 .env`
6. **Placer la clé JSON Zitadel** :
- Transférer la clé depuis le PC vers le serveur via SCP
- `chmod 600 zitadelfuriousimportkey.json`
7. **Lancer l'import** :
```bash
# Étape A : Lire les users depuis Furious
./read-all-users.sh
# → Génère furious-users-raw.json
# Étape B : Dry-run (simulation, aucune écriture)
python3 import-furious-to-zitadel.py --mode dry-run
# Étape C : Test sur 3 users
python3 import-furious-to-zitadel.py --mode test
# Étape D : Vérifier dans Zitadel UI que les 3 users sont créés avec metadata
# Étape E : Import production (les 85 restants)
python3 import-furious-to-zitadel.py --mode prod
```
8. **Gérer les doublons** (si users existaient déjà dans Zitadel) :
```bash
# Adapter DUPLICATES dans merge-duplicates.py selon ton cas
python3 merge-duplicates.py
```
9. **Vérifier le CSV** :
```bash
wc -l users-credentials.csv
# Doit être : 1 header + N users
```
---
## 🎭 Étape 11 — Configuration des rôles RBAC
⏱️ **30 min**
Voir [docs/07-rbac-intranet.md](./docs/07-rbac-intranet.md) pour les détails.
### Étapes
1. **Créer les 8 rôles dans Zitadel** :
- Console Zitadel → Projects → Infra Seize → Roles → New
- Créer un par un :
- direction / Direction / hierarchie
- manager / Manager / hierarchie
- expert / Expert / hierarchie
- consultant_senior / Consultant Senior / hierarchie
- consultant_junior / Consultant Junior / hierarchie
- alternant / Alternant / hierarchie
- stagiaire / Stagiaire / hierarchie
- support / Support / hierarchie
2. **Activer "Return user roles during authentication"** :
- Console Zitadel → Projects → Infra Seize → General
- Cocher "Return user roles during authentication"
- Save
3. **Activer "User roles inside ID Token"** sur chaque application :
- Pour chaque app (Intranet, n8n, etc.) : Token Settings
- Auth Token Type : JWT
- Cocher : User roles inside ID Token, Include user's profile info, Add user roles to the access token
4. **Modifier OAuth2-Proxy** pour demander le scope des rôles :
- Pour chaque OAuth2-Proxy : éditer `.env`
- Modifier : `OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles`
- Restart : `docker compose down && docker compose up -d`
5. **Lancer les scripts d'attribution** :
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
# Générer le mapping email → rôle (pour l'intranet)
python3 generate-roles-mapping.py
# Attribuer les rôles dans Zitadel (87 users)
python3 assign-roles.py
```
6. **Copier le mapping vers l'intranet** :
```bash
cp roles-mapping.json ~/docker/intranet/
```
7. **Restart de l'intranet** :
```bash
cd ~/docker/intranet
docker compose down
docker compose up -d
```
8. **Test final** :
- Login en incognito avec un user (ex: consultant_junior)
- Doit voir uniquement les cartes autorisées (Gitea + Code Server par défaut)
---
## ✅ Étape 12 — Validation finale
⏱️ **30 min**
### Checklist de validation
- [ ] Tous les conteneurs `Up` : `docker ps`
- [ ] Tous les sous-domaines répondent en HTTPS
- [ ] Login Zitadel fonctionne avec MFA
- [ ] Login intranet fonctionne et filtre les cartes
- [ ] Login Gitea fonctionne avec auto-registration
- [ ] Login n8n / Portainer / Code Server / Uptime Kuma fonctionne
- [ ] Le service account Furious peut lire l'API Furious
- [ ] Les scripts Python sont opérationnels
- [ ] Les fichiers sensibles ont `chmod 600`
- [ ] Les `.env` ne sont pas committés sur Git
- [ ] Backup automatique en place (à mettre en place — voir OPERATIONS.md)
---
## 🚨 Points d'attention pendant l'install
### Ne pas oublier
- ✅ Sauvegarder TOUS les secrets dans le gestionnaire de mdp
- ✅ Configurer le firewall (ufw ou iptables) pour limiter aux ports 22/80/443
- ✅ Tester chaque étape avant de passer à la suivante
- ✅ Documenter les écarts par rapport à cette procédure si tu en fais
### Pièges classiques
- ❌ Oublier d'ajouter le record DNS avant de tester le HTTPS (Let's Encrypt échoue)
- ❌ Lancer `docker compose up -d` sans avoir créé le `.env` (erreurs cryptiques)
- ❌ Donner IAM_OWNER au service account (utiliser IAM_USER_MANAGER)
- ❌ Coller un secret dans une conversation IA (gestionnaire de mdp uniquement)
---
## 📚 Pour aller plus loin
Une fois l'install terminée, consulter :
- [OPERATIONS.md](./OPERATIONS.md) pour la maintenance quotidienne
- [docs/09-troubleshooting.md](./docs/09-troubleshooting.md) pour les problèmes fréquents
- [HANDOVER.md](./HANDOVER.md) pour les actions à mener

512
OPERATIONS.md Normal file
View File

@ -0,0 +1,512 @@
# OPERATIONS — Maintenance et opérations courantes
> Procédures pour maintenir, dépanner, et faire évoluer l'infrastructure Seize.
---
## 🔍 Vérifications quotidiennes (5 min/jour)
### Healthcheck rapide
```bash
# Tous les conteneurs sont-ils Up ?
sudo docker ps --format "table {{.Names}}\t{{.Status}}" | grep -v "Up"
# → Aucune ligne sauf le header = tout va bien
# Tous les sous-domaines répondent-ils en HTTPS ?
for domain in sso intranet git n8n portainer code status; do
echo -n "$domain : "
curl -sk -o /dev/null -w "%{http_code}\n" "https://$domain.seizeconsulting.com"
done
# → 200 ou 302 sur chaque ligne = OK
# → 500, 502, 503 = problème
# Espace disque restant
df -h /
# → Surveiller si > 80% utilisé
```
### Logs récents
```bash
# Logs Traefik (erreurs uniquement)
sudo docker logs --tail 100 traefik 2>&1 | grep -i "error\|warn"
# Logs Zitadel (erreurs)
sudo docker logs --tail 100 zitadel-zitadel-api-1 2>&1 | grep -i "error\|level=err"
# Logs OAuth2-Proxy intranet (erreurs récentes)
sudo docker logs --tail 100 oauth2-proxy 2>&1 | grep -i "error"
```
---
## 📦 Backups
### ⚠️ État actuel : AUCUN backup automatique
À mettre en place en priorité.
### Procédure backup manuel (à exécuter avant grosse modif)
```bash
# Créer un dossier de backup daté
BACKUP_DIR=~/backups/$(date +%Y%m%d-%H%M%S)
mkdir -p $BACKUP_DIR
# Backup Postgres (Gitea, n8n)
docker exec postgres pg_dumpall -U postgres > $BACKUP_DIR/postgres.sql
# Backup Postgres Zitadel (sa propre instance)
docker exec zitadel-postgres-1 pg_dumpall -U postgres > $BACKUP_DIR/postgres-zitadel.sql
# Backup volumes Docker (Gitea data, etc.)
sudo tar czf $BACKUP_DIR/gitea-data.tar.gz -C ~/docker/gitea data/
sudo tar czf $BACKUP_DIR/uptime-data.tar.gz -C ~/docker/uptime-kuma data/
sudo tar czf $BACKUP_DIR/portainer-data.tar.gz -C ~/docker/portainer data/
# Backup configs (.env, docker-compose.yml)
tar czf $BACKUP_DIR/configs.tar.gz -C ~/docker .
# Backup scripts furious-to-zitadel
tar czf $BACKUP_DIR/furious-scripts.tar.gz -C ~/ furious-to-zitadel/
echo "✅ Backup terminé dans $BACKUP_DIR"
ls -lh $BACKUP_DIR
```
### Procédure backup automatique (à mettre en place)
Voir [docs/09-troubleshooting.md](./docs/09-troubleshooting.md) section Backup automatisé.
---
## 🔄 Mises à jour
### Mise à jour d'un service Docker
```bash
cd ~/docker/<service>
docker compose pull
docker compose up -d
```
### Vérifier qu'une mise à jour ne casse rien
```bash
# 1. Backup avant
~/backup-manual.sh # script à créer si pas existant
# 2. Pull la nouvelle image
docker compose pull
# 3. Restart
docker compose down
docker compose up -d
# 4. Vérifier les logs
docker compose logs -f
# Ctrl+C pour quitter
# 5. Tester le service (login, fonctionnalités principales)
```
### Mises à jour critiques à surveiller
| Service | Risque | Procédure |
|---|---|---|
| Traefik | Changement breaking config | Lire le CHANGELOG avant |
| Zitadel | Schema DB peut changer | Backup obligatoire avant |
| Postgres | Migration majeure | Backup + lire migration guide |
| OAuth2-Proxy | Changement de scopes/headers | Tester en dry-run |
---
## Ajouter un nouvel outil
### Procédure standard (~1h)
#### 1. Ajouter le record DNS
Chez le registrar : créer record A `<outil>.seizeconsulting.com` → IP du serveur.
#### 2. Déployer l'outil (sans SSO)
```bash
mkdir -p ~/docker/<outil>
cd ~/docker/<outil>
# Créer docker-compose.yml
cat > docker-compose.yml << EOF
services:
<outil>:
image: <image>:<tag>
container_name: <outil>
restart: unless-stopped
networks:
- web
# PAS de labels Traefik ici → c'est OAuth2-Proxy qui sera exposé
networks:
web:
external: true
EOF
docker compose up -d
```
#### 3. Créer l'application dans Zitadel
Console Zitadel → Projects → Infra Seize → New application :
- Type : **Web**
- Authentication Method : **Basic**
- Grant Types : **Authorization Code**
- Redirect URL : `https://<outil>.seizeconsulting.com/oauth2/callback`
- Token Settings :
- Auth Token Type : **JWT**
- User roles inside ID Token : ✅
- Include user's profile info in the ID Token : ✅
- Add user roles to the access token : ✅
Récupérer : **Client ID** + **Client Secret**.
#### 4. Déployer un OAuth2-Proxy dédié
```bash
mkdir -p ~/docker/oauth2-proxy-<outil>
cd ~/docker/oauth2-proxy-<outil>
# docker-compose.yml (adapter depuis configs/oauth2-proxy/docker-compose.yml.template)
# .env (adapter depuis configs/oauth2-proxy/.env.example)
```
`.env` (variables à remplacer) :
```env
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_CLIENT_ID=<de Zitadel>
OAUTH2_PROXY_CLIENT_SECRET=<de Zitadel>
OAUTH2_PROXY_COOKIE_SECRET=<32 chars random>
OAUTH2_PROXY_OIDC_ISSUER_URL=https://sso.seizeconsulting.com
OAUTH2_PROXY_REDIRECT_URL=https://<outil>.seizeconsulting.com/oauth2/callback
OAUTH2_PROXY_UPSTREAMS=http://<outil>:<port_interne>
OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles
OAUTH2_PROXY_EMAIL_DOMAINS=*
OAUTH2_PROXY_COOKIE_DOMAINS=.seizeconsulting.com
OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_SET_XAUTHREQUEST=true
OAUTH2_PROXY_WHITELIST_DOMAINS=.seizeconsulting.com
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
```
`chmod 600 .env` + `docker compose up -d`.
#### 5. Ajouter une carte sur l'intranet
Éditer `~/docker/intranet/index.html` et ajouter une nouvelle carte dans la section `<a class="service-card">` :
```html
<a class="service-card" data-app="<outil>" data-color="<color>" href="https://<outil>.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-header">
<div class="card-icon"><!-- SVG icon --></div>
<div class="card-arrow"><!-- SVG arrow --></div>
</div>
<div class="card-body">
<div class="card-title"><Nom de l'outil></div>
<div class="card-desc">Description courte</div>
<div class="card-meta">
<span class="card-url"><outil>.seizeconsulting.com</span>
<span class="card-tag">Catégorie</span>
</div>
</div>
</a>
```
#### 6. Mettre à jour la matrice RBAC dans le JavaScript
Éditer le `<script>` RBAC dans `index.html` :
```javascript
const ACCESS_MATRIX = {
"portainer": ["direction", "manager"],
"status": ["direction"],
// ...
"<outil>": ["direction", "manager"], // ← Ajouter ta nouvelle ligne
};
```
#### 7. Restart de l'intranet
```bash
cd ~/docker/intranet
docker compose down
docker compose up -d
```
#### 8. Tester
Se logger en incognito avec un user ayant accès → vérifier que la carte s'affiche et que le clic mène à l'outil avec SSO.
---
## 🗑️ Retirer un outil
### Procédure inverse de l'ajout
```bash
# 1. Arrêter l'outil et son OAuth2-Proxy
cd ~/docker/<outil>
docker compose down
cd ~/docker/oauth2-proxy-<outil>
docker compose down
# 2. Archiver les configs (au cas où)
mv ~/docker/<outil> ~/docker/<outil>.archived-$(date +%Y%m%d)
mv ~/docker/oauth2-proxy-<outil> ~/docker/oauth2-proxy-<outil>.archived-$(date +%Y%m%d)
# 3. Supprimer l'application dans Zitadel
# Console → Projects → Infra Seize → Applications → <outil> → Delete
# 4. Retirer la carte sur l'intranet (HTML + matrice RBAC)
# Éditer ~/docker/intranet/index.html
# Restart : docker compose restart intranet
# 5. Optionnel : retirer le record DNS chez le registrar
```
---
## 👥 Gestion des utilisateurs
### Ajouter un nouveau user manuellement
Quand un nouveau collaborateur arrive et n'est pas encore dans Furious :
```
Console Zitadel → Users → New → Human user :
- Email : prenom.nom@seizeconsulting.com
- Username : <trigramme> (ex: ABC)
- Prénom, Nom
- Cocher : Email verified, Password change required
- Generate password : oui (mdp temporaire 16 chars)
```
⚠️ **Distribuer le mdp via Teams privé** au nouveau collaborateur.
### Désactiver un user (départ)
```
Console Zitadel → Users → <user> → Actions → Deactivate
```
⚠️ **Ne PAS supprimer** le user : ses traces dans les outils (commits Gitea, workflows n8n, etc.) seraient orphelines. Désactiver suffit.
### Changer le rôle d'un user
#### Méthode UI (1 user)
```
Console Zitadel → Users → <user> → Authorizations → Edit roles
```
#### Méthode script (plusieurs users)
1. Mettre à jour le mapping dans `assign-roles.py` (variable `JOB_TITLE_TO_ROLE`)
2. Lancer :
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
python3 assign-roles.py
```
⚠️ Le script **ajoute** les rôles, il ne **retire pas** les anciens. Pour retirer, utiliser l'UI ou un script custom.
### Synchronisation périodique avec Furious
Workflow recommandé (à mettre en place) :
1. **Tous les jours** : un cron exécute `read-all-users.sh` pour récupérer les users Furious
2. **Compare** avec les users Zitadel actuels
3. **Pour chaque diff** :
- Nouveau dans Furious → créer dans Zitadel
- Départ détecté dans Furious (`departure_date != "0000-00-00"`) → désactiver dans Zitadel
- Changement de `job_title` → réattribuer le rôle
⚠️ **Pas encore automatisé**. À mettre en place via n8n.
---
## 🔐 Rotation des secrets
### Quand rotater ?
- 🔴 **Immédiatement** : si secret exposé (dans un commit Git, conversation IA, etc.)
- 🟡 **Tous les 6 mois** : pratique de bonne hygiène
- 🟢 **Tous les ans** : pour les secrets très internes (postgres root, etc.)
### Procédure pour un secret OAuth2-Proxy (Client Secret Zitadel)
```bash
# 1. Aller dans Zitadel : Projects → Infra Seize → Applications → <outil>
# 2. Cliquer "Generate Client Secret" → copier le nouveau secret
# 3. Mettre à jour le .env d'OAuth2-Proxy
cd ~/docker/oauth2-proxy-<outil>
# Éditer .env, remplacer OAUTH2_PROXY_CLIENT_SECRET
# Sauvegarder dans le gestionnaire de mots de passe
# 4. Restart OAuth2-Proxy
docker compose down
docker compose up -d
```
### Procédure pour la clé service account Zitadel
```bash
# 1. Console Zitadel → Users → furious-import-service → Keys
# 2. Supprimer l'ancienne clé (et l'invalider)
# 3. Créer une nouvelle clé (Type : JSON)
# 4. Télécharger la nouvelle clé immédiatement
# 5. Remplacer sur le serveur
scp <nouvelle_cle>.json admin@<serveur>:~/furious-to-zitadel/zitadelfuriousimportkey.json
ssh admin@<serveur>
chmod 600 ~/furious-to-zitadel/zitadelfuriousimportkey.json
# 6. Tester les scripts
cd ~/furious-to-zitadel
source venv/bin/activate
python3 assign-roles.py # Doit fonctionner avec la nouvelle clé
```
### Procédure pour Postgres (Gitea/n8n)
⚠️ **À faire HORS heures de bureau** car les services s'arrêteront temporairement.
```bash
# 1. Générer un nouveau mdp
NEW_PWD=$(openssl rand -base64 32 | tr -d '+/=' | head -c 32)
echo "Nouveau mdp : $NEW_PWD"
# → Sauvegarder dans le gestionnaire de mots de passe
# 2. Changer le mdp dans Postgres
docker exec -it postgres psql -U postgres -c "ALTER USER gitea WITH PASSWORD '$NEW_PWD';"
# 3. Mettre à jour le .env de Gitea
nano ~/docker/gitea/.env
# Remplacer GITEA_DB_PASSWORD=$NEW_PWD
# 4. Restart Gitea
cd ~/docker/gitea
docker compose down
docker compose up -d
# 5. Tester Gitea
curl -sI https://git.seizeconsulting.com # Doit retourner 200 ou 302
```
---
## 🐛 Troubleshooting commun
Voir [docs/09-troubleshooting.md](./docs/09-troubleshooting.md) pour une liste exhaustive.
### "Mon SSO ne fonctionne plus, l'user est redirigé en boucle"
→ Probablement le bug Traefik authRequestHeaders=Cookie. Vérifier que `~/docker/traefik/dynamic.yml` contient bien `Cookie` dans `authRequestHeaders` du middleware ForwardAuth.
### "Le certificat Let's Encrypt a expiré"
→ Traefik renouvelle automatiquement. Si pas le cas :
```bash
# Vérifier les logs Traefik
sudo docker logs traefik 2>&1 | grep -i "letsencrypt\|certificate"
# Forcer le renouvellement
cd ~/docker/traefik
docker compose down
docker compose up -d
```
### "Le script Python d'import échoue avec un 401 Unauthorized"
→ Le token Zitadel a expiré (1h) OU les droits du service account ont été révoqués.
```bash
# Vérifier que la clé JSON est valide
python3 -c "import json; data=json.load(open('zitadelfuriousimportkey.json')); print('UserId:', data.get('userId'), 'KeyId:', data.get('keyId'))"
# Vérifier dans Zitadel Console que le service account a toujours IAM_USER_MANAGER
```
### "Un user ne peut pas se logger après avoir été créé"
→ Vérifier :
1. `Email verified` cocher dans son profil Zitadel
2. Si "Only authorized users can authenticate" est activé sur le project → il doit avoir un rôle
3. MFA pas mal configuré → faire un reset MFA si bloqué
---
## 📊 Monitoring
### Métriques à surveiller
| Métrique | Outil | Seuil d'alerte |
|---|---|---|
| Disponibilité des sous-domaines | Uptime Kuma | Down > 5 min |
| Taux d'erreur HTTP (5xx) | Logs Traefik | > 1% sur 5 min |
| Espace disque | `df -h` | > 80% |
| RAM utilisée | `free -h` | > 90% |
| CPU load | `uptime` | > 4 (sur 4 vCPU) |
| Tentatives de login échouées | Logs Zitadel | > 100/heure (potentielle attaque) |
### Mettre en place le monitoring
Uptime Kuma est déjà déployé. Ajouter des moniteurs pour :
- Chaque sous-domaine (HTTP check)
- Le serveur lui-même (ping)
- L'API Furious (HTTP check)
---
## 📞 Escalation
### Quand appeler Philippe ?
- 🔴 Service critique down (Zitadel, Gitea, intranet) > 1h
- 🔴 Faille sécurité avérée
- 🔴 Perte de données
### Quand appeler le support Scaleway ?
- 🔴 Serveur inaccessible (panne hardware)
- 🔴 Problème réseau Scaleway
### Quand appeler le support Furious ?
- 🟡 API Furious down ou très lente
- 🟡 Permissions API qui ne fonctionnent pas
---
## 📝 Checklist hebdomadaire (5-10 min)
- [ ] Vérifier tous les conteneurs Up
- [ ] Vérifier l'espace disque < 80%
- [ ] Lire les alertes Uptime Kuma
- [ ] Vérifier les logs Traefik pour erreurs récurrentes
- [ ] Backup manuel des données critiques (si pas encore automatisé)
- [ ] Vérifier les certificats Let's Encrypt (expire dans > 7 jours)
## 📝 Checklist mensuelle (30 min)
- [ ] Faire un Docker prune des images inutilisées : `docker image prune -a`
- [ ] Vérifier les mises à jour disponibles : `apt list --upgradable`
- [ ] Auditer les users Zitadel (départs non synchronisés ?)
- [ ] Vérifier que les scripts Python fonctionnent toujours
- [ ] Lire les logs sécurité (tentatives de login échouées, etc.)

149
README.md Normal file
View File

@ -0,0 +1,149 @@
# Seize Consulting — Infrastructure SSO & RBAC
> Documentation complète de l'infrastructure auto-hébergée de Seize Consulting.
> Stack Docker sur Scaleway, SSO Zitadel, RBAC sur intranet, import automatisé depuis Furious Squad.
**Dernière mise à jour :** 5 juin 2026
**Mainteneur sortant :** Hedi Kalboussi (HKA)
**Repère :** Jour 7 — Import 88 users + RBAC opérationnel
---
## 🚀 Démarrage rapide pour le nouveau mainteneur
Si tu reprends ce projet, **lis dans cet ordre** :
1. **[HANDOVER.md](./HANDOVER.md)** — Ce qui marche, ce qui reste à faire, alertes, secrets exposés
2. **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Comprendre comment tout s'imbrique
3. **[DECISIONS.md](./DECISIONS.md)** — Pourquoi tel choix plutôt qu'un autre
4. **[OPERATIONS.md](./OPERATIONS.md)** — Comment maintenir, dépanner, ajouter un outil
5. **[INSTALL.md](./INSTALL.md)** — Comment redéployer from scratch sur un nouveau serveur
---
## 📋 État actuel de l'infrastructure (au 5 juin 2026)
### Serveur
- **Provider :** Scaleway
- **OS :** Debian 13 (Trixie)
- **Docker :** 29.5.1
- **IP publique :** 151.115.148.243 (à ne pas exposer dans la doc publique)
- **Domaine principal :** `seizeconsulting.com`
### Services en production
| Service | URL | Rôle | SSO |
|---|---|---|---|
| Traefik | `traefik.seizeconsulting.com` | Reverse proxy + TLS | ✅ |
| Zitadel | `sso.seizeconsulting.com` | Identity Provider (IDP) | N/A |
| Intranet | `intranet.seizeconsulting.com` | Page d'accueil + RBAC | ✅ |
| Gitea | `git.seizeconsulting.com` | Forge Git | ✅ auto-registration |
| n8n | `n8n.seizeconsulting.com` | Workflows | ✅ |
| Portainer | `portainer.seizeconsulting.com` | Gestion Docker | ✅ |
| Code Server | `code.seizeconsulting.com` | IDE web | ✅ |
| Uptime Kuma | `status.seizeconsulting.com` | Monitoring | ✅ |
### Utilisateurs Zitadel
- **88 collaborateurs Seize** importés depuis Furious Squad (jour 7)
- **1 admin technique** : `zitadel-admin`
- **1 service account** : `furious-import-service` (IAM_USER_MANAGER)
- **1 client technique** : `login-client` (auto-créé par Zitadel)
### RBAC (Approche A : filtrage côté intranet)
- **8 rôles** dans le project Zitadel "Infra Seize" :
`direction`, `manager`, `expert`, `consultant_senior`, `consultant_junior`,
`alternant`, `stagiaire`, `support`
- **Filtrage** : l'intranet HTML cache/affiche les cartes selon le rôle de l'user (lu via fichier JSON statique `roles-mapping.json`)
- **Limitation acceptée** : un user qui connaît l'URL directe d'un outil bypass le filtre
---
## 🗂️ Structure du repo
```
seize-infra-doc/
├── README.md # ← Tu es ici
├── HANDOVER.md # 🚨 À LIRE EN PRIORITÉ
├── ARCHITECTURE.md # Vue d'ensemble technique
├── DECISIONS.md # Choix architecturaux et anti-patterns
├── INSTALL.md # Procédure d'install from scratch
├── OPERATIONS.md # Maintenance, dépannage, ajouts
├── install.sh # Script d'installation modulaire
├── .env.example # Template variables d'environnement
├── docs/ # Documentation thématique détaillée
│ ├── 01-dns-prerequis.md
│ ├── 02-traefik-setup.md
│ ├── 03-zitadel-setup.md
│ ├── 04-oauth2-proxy-pattern.md
│ ├── 05-applications.md
│ ├── 06-import-furious.md
│ ├── 07-rbac-intranet.md
│ ├── 08-secrets-management.md
│ └── 09-troubleshooting.md
├── configs/ # Templates docker-compose anonymisés
│ ├── traefik/
│ ├── zitadel/
│ ├── intranet/
│ ├── oauth2-proxy/
│ ├── gitea/
│ ├── n8n/
│ ├── portainer/
│ ├── code-server/
│ ├── uptime-kuma/
│ └── postgres/
├── scripts/ # Scripts opérationnels
│ ├── furious-to-zitadel/ # Scripts Python import users
│ └── intranet/ # Template index.html avec RBAC
└── matrices/ # Matrices RBAC et mappings
├── rbac-matrix.md
└── job-title-to-role.csv
```
---
## ⚠️ Avertissements importants
1. **Authentik** tourne encore sur le serveur mais **n'est plus utilisé** (migration faite vers Zitadel). À supprimer pour libérer ressources.
2. **Redis** tourne encore — vérifier s'il est utilisé par autre chose avant suppression.
3. **Secrets exposés dans des conversations IA** : voir [HANDOVER.md](./HANDOVER.md) pour la liste des rotations à faire.
4. **SMTP non configuré dans Zitadel** : les emails de bienvenue/reset password ne partent pas. Distribution des mdp temporaires se fait manuellement via Teams.
5. **Migration outils gratuits** vs **licences pro** : décision business reportée — voir [DECISIONS.md](./DECISIONS.md).
---
## 🆘 Support
- **Mainteneur sortant** : Hedi Kalboussi (`HKA`)
- **Sponsor projet** : Philippe Spoljaric (`PSP`, Admin Infra)
- **CEO** : Pierre-Yves Tachon (`PYT`)
Pour toute question, contacter d'abord Hedi pour passation, puis Philippe pour décisions architecturales.
---
## 📜 Historique du projet
| Jour | Date | Étape majeure |
|---|---|---|
| J1 | 20 mai 2026 | Provisioning Scaleway + Docker + premiers tests |
| J2 | 21 mai 2026 | Première tentative SSO via Authentik |
| J3 | 26-29 mai 2026 | Migration Zitadel, 7 apps SSO configurées |
| J4 | 30 mai 2026 | Gitea auto-registration, fix bug Traefik authRequestHeaders |
| J5 | 1-2 juin 2026 | Analyse Furious API, plan migration outils gratuits |
| J6 | 3-4 juin 2026 | Préparation import, briefings Philippe |
| **J7** | **5 juin 2026** | **Import 88 users + RBAC opérationnel** |
---
## 🔗 Liens utiles
- Console Zitadel : https://sso.seizeconsulting.com/ui/console
- Dashboard Traefik : https://traefik.seizeconsulting.com (direction only)
- Gitea : https://git.seizeconsulting.com
- Documentation Zitadel : https://zitadel.com/docs
- Documentation OAuth2-Proxy : https://oauth2-proxy.github.io/oauth2-proxy/
- Documentation Traefik : https://doc.traefik.io/traefik/

View File

@ -0,0 +1,22 @@
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: code-server
restart: unless-stopped
environment:
PUID: 1000
PGID: 1000
TZ: Europe/Paris
# Mdp fallback si SSO down (normalement on accède via SSO)
PASSWORD: ${CODE_PASSWORD}
SUDO_PASSWORD: ${CODE_PASSWORD}
DEFAULT_WORKSPACE: /config/workspace
volumes:
- ./config:/config
networks:
- web
# ⚠️ Pas de labels Traefik : OAuth2-Proxy expose le host
networks:
web:
external: true

View File

@ -0,0 +1,48 @@
services:
gitea:
image: gitea/gitea:1.22
container_name: gitea
restart: unless-stopped
environment:
USER_UID: 1000
USER_GID: 1000
# Base de données
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: postgres:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
# Serveur
GITEA__server__DOMAIN: git.seizeconsulting.com
GITEA__server__ROOT_URL: https://git.seizeconsulting.com/
GITEA__server__SSH_DOMAIN: git.seizeconsulting.com
# OIDC Auto-registration (rempli après config SSO)
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
GITEA__oauth2_client__USERNAME: preferred_username
GITEA__oauth2_client__ACCOUNT_LINKING: auto
# Désactiver le register manuel (SSO uniquement)
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__SHOW_REGISTRATION_BUTTON: "false"
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "22:22" # SSH Git
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`git.seizeconsulting.com`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
networks:
- web
- backend
depends_on:
- postgres
networks:
web:
external: true
backend:
external: true

View File

@ -0,0 +1,18 @@
services:
intranet:
image: nginx:alpine
container_name: intranet
restart: unless-stopped
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
- ./logo.png:/usr/share/nginx/html/logo.png:ro
- ./roles-mapping.json:/usr/share/nginx/html/roles-mapping.json:ro
- ./nginx-rbac.conf:/etc/nginx/nginx.conf:ro
networks:
- web
# ⚠️ Pas de labels Traefik ici : c'est OAuth2-Proxy qui est exposé sur intranet.<domain>
# L'intranet est en upstream d'OAuth2-Proxy
networks:
web:
external: true

View File

@ -0,0 +1,48 @@
# ============================================================================
# nginx-rbac.conf.template — Config nginx pour l'intranet avec RBAC
# ============================================================================
# Substitue __USER_EMAIL__ dans le HTML par la valeur du header X-Forwarded-Email
# (envoyé par OAuth2-Proxy après authentification Zitadel).
# ============================================================================
events {}
http {
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache désactivé pour l'HTML (sinon le sub_filter peut servir un email obsolète)
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
# ⭐ Substitue __USER_EMAIL__ par la valeur du header HTTP
sub_filter '__USER_EMAIL__' '$http_x_forwarded_email';
sub_filter_once off;
sub_filter_types text/html;
}
# Pareil pour la racine /
location = / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
sub_filter '__USER_EMAIL__' '$http_x_forwarded_email';
sub_filter_once off;
sub_filter_types text/html;
try_files /index.html =404;
}
# Le mapping JSON doit être accessible mais pas mis en cache
location = /roles-mapping.json {
add_header Cache-Control "no-store";
add_header Content-Type application/json;
}
# Logo et autres fichiers statiques
location / {
try_files $uri $uri/ =404;
}
}
}

View File

@ -0,0 +1,39 @@
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
environment:
# Base de données
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD}
# ⚠️ Critique : sans cette clé on perd accès aux credentials chiffrés
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
# Config serveur
N8N_HOST: n8n.seizeconsulting.com
N8N_PORT: 5678
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.seizeconsulting.com/
# Désactiver le sign-up : SSO uniquement
N8N_USER_MANAGEMENT_DISABLED: "false"
volumes:
- n8n_data:/home/node/.n8n
networks:
- web
- backend
# ⚠️ Pas de labels Traefik : OAuth2-Proxy est exposé devant
depends_on:
- postgres
volumes:
n8n_data:
networks:
web:
external: true
backend:
external: true

View File

@ -0,0 +1,51 @@
# ============================================================================
# OAuth2-Proxy .env.example
# ============================================================================
# Pour chaque outil, créer un dossier ~/docker/oauth2-proxy-<outil>/
# avec son docker-compose.yml et son .env adapté.
# ============================================================================
# ===== PROVIDER OIDC ZITADEL =====
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_CLIENT_ID=__REPLACE_WITH_ZITADEL_CLIENT_ID__
OAUTH2_PROXY_CLIENT_SECRET=__REPLACE_WITH_ZITADEL_CLIENT_SECRET__
# Générer un cookie secret 32 chars :
# python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
OAUTH2_PROXY_COOKIE_SECRET=__REPLACE_WITH_32_CHARS_RANDOM__
OAUTH2_PROXY_OIDC_ISSUER_URL=https://sso.seizeconsulting.com
OAUTH2_PROXY_REDIRECT_URL=https://__TOOL__.seizeconsulting.com/oauth2/callback
# ===== UPSTREAM (l'outil derrière) =====
# Exemple pour n8n : http://n8n:5678
# Exemple pour intranet : http://intranet:80
OAUTH2_PROXY_UPSTREAMS=http://__TOOL_CONTAINER__:__TOOL_PORT__
# ===== SCOPES OIDC =====
# Le scope urn:zitadel:iam:org:project:roles est CRITIQUE pour le RBAC
OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles
# ===== COOKIES =====
OAUTH2_PROXY_COOKIE_DOMAINS=.seizeconsulting.com
OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_COOKIE_HTTPONLY=true
OAUTH2_PROXY_COOKIE_SAMESITE=lax
# ===== SÉCURITÉ =====
OAUTH2_PROXY_EMAIL_DOMAINS=*
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_SET_XAUTHREQUEST=true
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true
# ===== RÉSEAU =====
OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY=true
OAUTH2_PROXY_WHITELIST_DOMAINS=.seizeconsulting.com
# ===== DIVERS =====
OAUTH2_PROXY_FOOTER=-
OAUTH2_PROXY_REDIRECT_URL_AUTH_DOMAIN_AS_REDIRECT_URL_SUFFIX=false

View File

@ -0,0 +1,26 @@
services:
oauth2-proxy-<outil>:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
container_name: oauth2-proxy-<outil>
restart: unless-stopped
env_file:
- .env
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.<outil>-sso.rule=Host(`<outil>.seizeconsulting.com`)"
- "traefik.http.routers.<outil>-sso.entrypoints=websecure"
- "traefik.http.routers.<outil>-sso.tls.certresolver=letsencrypt"
- "traefik.http.routers.<outil>-sso.service=<outil>-sso"
- "traefik.http.routers.<outil>-sso.priority=300"
- "traefik.http.services.<outil>-sso.loadbalancer.server.port=4180"
networks:
web:
external: true
# Pour adapter ce template :
# 1. Remplacer <outil> par le nom de l'outil (ex: n8n, portainer, code, status, intranet)
# 2. Adapter le host dans la règle Traefik
# 3. Créer le fichier .env avec les variables OAUTH2_PROXY_*

View File

@ -0,0 +1,16 @@
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: unless-stopped
volumes:
# ⚠️ Accès au socket Docker = pouvoir total sur les conteneurs
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
networks:
- web
# ⚠️ Pas de labels Traefik : OAuth2-Proxy expose le host
networks:
web:
external: true

View File

@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
volumes:
- ./data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Pas exposé publiquement (uniquement réseau backend)
networks:
backend:
external: true

View File

@ -0,0 +1,63 @@
services:
traefik:
image: traefik:v3.7.1
container_name: traefik
restart: unless-stopped
command:
# Dashboard sécurisé
- "--api.dashboard=true"
- "--api.insecure=false"
# Découverte Docker
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=web"
# Config file dynamique
- "--providers.file.filename=/etc/traefik/dynamic.yml"
- "--providers.file.watch=true"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Redirection HTTP → HTTPS
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
# Let's Encrypt
- "--certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
# Logs
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./dynamic.yml:/etc/traefik/dynamic.yml:ro
networks:
- web
labels:
# Dashboard accessible via traefik.<domain>
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`${TRAEFIK_DOMAIN}`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth"
# Basic auth (à remplacer par hash htpasswd réel)
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
networks:
web:
external: true

View File

@ -0,0 +1,54 @@
# ============================================================================
# dynamic.yml — Configuration dynamique Traefik
# ============================================================================
# Middlewares partagés, notamment ForwardAuth pour le SSO.
http:
middlewares:
# ⭐ Middleware ForwardAuth pour l'authentification SSO via OAuth2-Proxy
# ⚠️ La ligne "- Cookie" dans authRequestHeaders est CRITIQUE
oauth2-headers:
forwardAuth:
address: "http://oauth2-proxy:4180/oauth2/auth"
trustForwardHeader: true
authRequestHeaders:
- Cookie # ← INDISPENSABLE (sans, le SSO casse)
- Authorization
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-Uri
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
- X-Auth-Request-Preferred-Username
- X-Auth-Request-Groups
- X-Auth-Request-Access-Token
- X-Forwarded-User
- X-Forwarded-Email
- X-Forwarded-Preferred-Username
- X-Forwarded-Groups
- Authorization
# Middleware "secure headers" - bonne pratique sécurité
secure-headers:
headers:
accessControlAllowMethods:
- GET
- POST
- OPTIONS
accessControlMaxAge: 100
hostsProxyHeaders:
- "X-Forwarded-Host"
sslRedirect: true
sslHost: ""
sslForceHost: true
sslProxyHeaders:
X-Forwarded-Proto: https
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"

View File

@ -0,0 +1,14 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- ./data:/app/data
networks:
- web
# ⚠️ Pas de labels Traefik : OAuth2-Proxy expose le host
networks:
web:
external: true

View File

@ -0,0 +1,30 @@
# ============================================================================
# Zitadel .env.template
# ============================================================================
# Copier en .env et adapter.
# ⚠️ ZITADEL_MASTERKEY est IMMUABLE après le premier démarrage !
# ============================================================================
# ===== DOMAINE =====
ZITADEL_EXTERNALDOMAIN=sso.seizeconsulting.com
# ===== TRAEFIK PROXY INTERNE =====
TRAEFIK_IMAGE=traefik:v3.6.8
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACCESSLOG_ENABLED=false
PROXY_HTTP_PUBLISHED_PORT=8080
# ===== MASTER KEY (32 caractères) =====
# ⚠️ NE JAMAIS CHANGER après le premier démarrage
# Générer avec : openssl rand -base64 24 | head -c 32
ZITADEL_MASTERKEY=CHANGE_ME_32_CHARS_RANDOM_KEY_HERE
# ===== POSTGRES ZITADEL =====
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# ===== ADMIN INITIAL =====
# À changer au premier login
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=zitadel-admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=CHANGE_ME_AT_FIRST_LOGIN

View File

@ -0,0 +1,136 @@
# ============================================================================
# Zitadel docker-compose.yml.template
# ============================================================================
# Basé sur la quickstart officielle Zitadel avec proxy Traefik intégré.
# Source : https://github.com/zitadel/zitadel-charts/tree/main/charts/zitadel
#
# Ce docker-compose contient :
# - proxy : Traefik dédié à Zitadel
# - zitadel : Serveur API
# - zitadel-login : UI de login v2
# - postgres : DB dédiée
#
# ⚠️ Ce fichier est SIMPLIFIÉ. Pour la version complète production-ready,
# voir le repo officiel : https://github.com/zitadel/zitadel-charts
# ============================================================================
name: zitadel
services:
# Traefik dédié à Zitadel (isolé du Traefik principal)
proxy:
image: ${TRAEFIK_IMAGE}
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=zitadel
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --api.dashboard=${TRAEFIK_DASHBOARD_ENABLED}
- --api.insecure=false
- --ping=true
- --ping.entrypoint=web
- --log.level=${TRAEFIK_LOG_LEVEL}
- --accesslog=${TRAEFIK_ACCESSLOG_ENABLED}
ports:
- ${PROXY_HTTP_PUBLISHED_PORT}:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- zitadel
- web # Pour que le Traefik principal puisse router vers ici
labels:
# Permet au Traefik principal externe de router vers ce Traefik interne
- "traefik.enable=true"
- "traefik.http.routers.zitadel-external.rule=Host(`${ZITADEL_EXTERNALDOMAIN}`)"
- "traefik.http.routers.zitadel-external.entrypoints=websecure"
- "traefik.http.routers.zitadel-external.tls.certresolver=letsencrypt"
- "traefik.http.services.zitadel-external.loadbalancer.server.port=80"
zitadel:
image: ghcr.io/zitadel/zitadel:v4.13.0
restart: unless-stopped
command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled'
environment:
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_POSTGRES_USER_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNALDOMAIN}
ZITADEL_EXTERNALPORT: 443
ZITADEL_EXTERNALSECURE: "true"
ZITADEL_TLS_ENABLED: "false"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD}
depends_on:
postgres:
condition: service_healthy
networks:
- zitadel
healthcheck:
test: ["CMD", "/app/zitadel", "ready", "--config", "/example-zitadel-config.yaml"]
interval: 10s
timeout: 30s
retries: 5
start_period: 20s
volumes:
- ./machinekey:/machinekey
labels:
- "traefik.enable=true"
- "traefik.http.routers.zitadel.rule=Host(`${ZITADEL_EXTERNALDOMAIN}`) && (PathPrefix(`/zitadel.`) || PathPrefix(`/oauth/v2`) || PathPrefix(`/openid`) || PathPrefix(`/saml`) || PathPrefix(`/.well-known`))"
- "traefik.http.routers.zitadel.entrypoints=web"
- "traefik.http.services.zitadel.loadbalancer.server.port=8080"
zitadel-login:
image: ghcr.io/zitadel/zitadel-login:v4.13.0
restart: unless-stopped
environment:
ZITADEL_API_URL: http://zitadel:8080
ZITADEL_LOGIN_BASE_URL: https://${ZITADEL_EXTERNALDOMAIN}/ui/v2/login
NEXTAUTH_URL: https://${ZITADEL_EXTERNALDOMAIN}/ui/v2/login
depends_on:
zitadel:
condition: service_healthy
networks:
- zitadel
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000"]
interval: 10s
timeout: 30s
retries: 5
labels:
- "traefik.enable=true"
- "traefik.http.routers.zitadel-login.rule=Host(`${ZITADEL_EXTERNALDOMAIN}`)"
- "traefik.http.routers.zitadel-login.entrypoints=web"
- "traefik.http.routers.zitadel-login.priority=10"
- "traefik.http.services.zitadel-login.loadbalancer.server.port=3000"
postgres:
image: postgres:17.2-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD}
POSTGRES_DB: zitadel
networks:
- zitadel
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"]
interval: 10s
timeout: 30s
retries: 5
start_period: 20s
volumes:
- ./postgres-data:/var/lib/postgresql/data:rw
networks:
zitadel:
driver: bridge
web:
external: true

218
docs/01-dns-prerequis.md Normal file
View File

@ -0,0 +1,218 @@
# 01 — DNS et prérequis
> Tout ce qu'il faut côté DNS et serveur avant de commencer.
---
## 🌐 Records DNS à configurer
Chez votre registrar (Scaleway, OVH, Gandi, etc.), ajouter les records suivants.
⚠️ **Tous les records sont des `A`** (pas de `CNAME`) pointant vers l'IP publique du serveur.
| Sous-domaine | Type | Valeur | Usage |
|---|---|---|---|
| `@` (racine) | A | `<IP>` | Site vitrine (optionnel) |
| `sso` | A | `<IP>` | Zitadel — Identity Provider |
| `intranet` | A | `<IP>` | Page d'accueil RBAC |
| `git` | A | `<IP>` | Gitea |
| `n8n` | A | `<IP>` | n8n workflows |
| `portainer` | A | `<IP>` | Portainer Docker |
| `code` | A | `<IP>` | Code Server (VS Code web) |
| `status` | A | `<IP>` | Uptime Kuma |
| `traefik` | A | `<IP>` | Dashboard Traefik (admin) |
| `app` | A | `<IP>` | whoami debug (optionnel) |
### TTL recommandé : 300 (5 min)
Pour pouvoir corriger rapidement en cas d'erreur.
### Vérification après ajout
```bash
# Sur ton PC
for sub in sso intranet git n8n portainer code status traefik; do
echo -n "$sub.seizeconsulting.com : "
dig +short $sub.seizeconsulting.com
done
```
→ Toutes les lignes doivent afficher l'IP du serveur.
⚠️ La propagation DNS peut prendre **1-24h**. Tester depuis plusieurs réseaux (4G, autre wifi) si pas immédiat.
---
## 🖥️ Spécifications serveur recommandées
### Minimum (88 users actifs Seize)
- **CPU** : 4 vCPU
- **RAM** : 8 Go
- **Disque** : 80 Go SSD
- **Bande passante** : 100 Mbps
### Recommandé (pour confort + croissance)
- **CPU** : 8 vCPU
- **RAM** : 16 Go
- **Disque** : 160 Go SSD
- **Bande passante** : 1 Gbps
### Provider testé : Scaleway
- Type d'instance : `PRO2-S` (4 vCPU, 16 Go RAM)
- Région : `fr-par-1`
- OS : Debian 13 (Trixie)
---
## 🔒 Firewall (recommandé)
### Configuration via ufw
```bash
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw allow 80/tcp comment 'HTTP (Traefik redirect)'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw enable
```
### Vérification
```bash
sudo ufw status verbose
```
→ Doit montrer ports 22, 80, 443 autorisés.
⚠️ **Ne PAS oublier d'autoriser le port 22** avant d'activer ufw, sinon tu te bloques hors du serveur.
---
## 🔑 Hardening SSH
### Désactiver le login root
Éditer `/etc/ssh/sshd_config` :
```
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
```
Restart : `sudo systemctl restart sshd`
⚠️ **Avoir un user non-root configuré AVANT** de désactiver root.
### Changer le port SSH (optionnel)
Si beaucoup de scans bots :
```
Port 2222 # Au lieu de 22
```
Ne pas oublier d'ouvrir le nouveau port dans ufw avant le restart sshd.
---
## 🐳 Prérequis Docker
### Installer Docker
```bash
curl -fsSL https://get.docker.com | sudo sh
```
### Ajouter user au groupe docker
```bash
sudo usermod -aG docker $USER
exit # Se déconnecter
# Se reconnecter
```
### Vérifier l'install
```bash
docker --version
docker compose version
docker ps
```
Tous doivent fonctionner sans `sudo`.
---
## 🌐 Réseaux Docker à créer
```bash
docker network create web
docker network create backend
```
- **web** : réseau pour les services exposés publiquement (via Traefik)
- **backend** : réseau pour les services internes (Postgres, Redis si utilisé)
---
## ⏰ NTP / Synchronisation horaire
⚠️ **Critique** pour les certificats TLS et les tokens JWT.
```bash
sudo apt install -y systemd-timesyncd
sudo systemctl enable --now systemd-timesyncd
timedatectl status
```
→ "System clock synchronized: yes" doit apparaître.
---
## 📊 Monitoring serveur de base
### Installer htop, iotop
```bash
sudo apt install -y htop iotop ncdu
```
### Espace disque alert
Optionnel : alert si > 80% d'espace utilisé :
```bash
cat > ~/disk-alert.sh << 'EOF'
#!/bin/bash
USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if [ $USAGE -gt 80 ]; then
echo "⚠️ Espace disque > 80% : $USAGE%" | mail -s "Alert disk Seize" admin@seizeconsulting.com
fi
EOF
chmod +x ~/disk-alert.sh
# Crontab tous les jours à 8h
(crontab -l ; echo "0 8 * * * /home/admin/disk-alert.sh") | crontab -
```
⚠️ Nécessite un client mail configuré (mailutils, ssmtp, etc.).
---
## ✅ Checklist avant de passer aux étapes suivantes
- [ ] DNS configurés et propagés
- [ ] Serveur accessible en SSH avec user non-root
- [ ] Firewall actif (22, 80, 443 ouverts)
- [ ] SSH hardening fait (pas de root, pas de password auth)
- [ ] Docker + Docker Compose installés et fonctionnels
- [ ] Réseaux Docker `web` et `backend` créés
- [ ] Horloge synchronisée (NTP)
- [ ] htop / outils de base installés
- [ ] Espace disque > 50 Go libre
→ Tout coché ? Passer à [02-traefik-setup.md](./02-traefik-setup.md).

255
docs/02-traefik-setup.md Normal file
View File

@ -0,0 +1,255 @@
# 02 — Traefik (Reverse Proxy)
> Traefik est le **point d'entrée** unique de l'infrastructure. Il route le trafic vers les bons conteneurs et gère les certificats TLS automatiquement.
---
## 🎯 Rôle de Traefik
| Fonction | Description |
|---|---|
| **Reverse proxy** | Route `<sous-domaine>.seizeconsulting.com` → conteneur correspondant |
| **TLS termination** | Certificats Let's Encrypt automatiques pour tous les sous-domaines |
| **HTTP → HTTPS redirect** | Force le HTTPS partout |
| **ForwardAuth middleware** | Délègue l'auth à OAuth2-Proxy pour le SSO |
| **Dashboard** | UI admin sur `traefik.seizeconsulting.com` |
---
## 📁 Structure
```
~/docker/traefik/
├── docker-compose.yml
├── traefik.yml # Config statique (entrypoints, providers)
├── dynamic.yml # Config dynamique (middlewares, routers manuels)
└── letsencrypt/
└── acme.json # Certificats stockés (chmod 600)
```
---
## 📄 docker-compose.yml
```yaml
services:
traefik:
image: traefik:v3.7.1
container_name: traefik
restart: unless-stopped
command:
- "--api.dashboard=true"
- "--api.insecure=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=web"
- "--providers.file.filename=/etc/traefik/dynamic.yml"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.email=admin@seizeconsulting.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./dynamic.yml:/etc/traefik/dynamic.yml:ro
networks:
- web
labels:
# Dashboard sur traefik.seizeconsulting.com
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.seizeconsulting.com`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth"
# Basic auth pour le dashboard (à remplacer par OAuth2-Proxy plus tard si on veut SSO)
- "traefik.http.middlewares.traefik-auth.basicauth.users=admin:$$apr1$$xxxxxxxx$$xxxxxxxxxxxxxxxxxxxxxxx"
networks:
web:
external: true
```
⚠️ **Remplacer** :
- `admin@seizeconsulting.com` par votre email Let's Encrypt
- Le hash basicauth via `htpasswd -nb admin <mdp>`
---
## 📄 dynamic.yml (middlewares ForwardAuth)
```yaml
http:
middlewares:
# Middleware ForwardAuth générique pour les outils protégés par OAuth2-Proxy
oauth2-headers:
forwardAuth:
address: "http://oauth2-proxy:4180/oauth2/auth"
trustForwardHeader: true
# ⚠️ LIGNE CRITIQUE : sans 'Cookie', le SSO ne marche pas
authRequestHeaders:
- Cookie
- Authorization
- X-Forwarded-Host
- X-Forwarded-Proto
- X-Forwarded-Uri
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
- X-Auth-Request-Preferred-Username
- X-Auth-Request-Groups
- X-Auth-Request-Access-Token
- X-Forwarded-User
- X-Forwarded-Email
- X-Forwarded-Preferred-Username
- X-Forwarded-Groups
- Authorization
```
⚠️ **Le `Cookie` dans `authRequestHeaders` est ESSENTIEL** — sans ça, le SSO casse complètement (cf. [DECISIONS.md D3](../DECISIONS.md)).
---
## 🚀 Démarrage
```bash
cd ~/docker/traefik
# Créer l'acme.json
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
# Lancer
docker compose up -d
# Vérifier les logs
docker compose logs -f
# Ctrl+C pour quitter
```
### Vérifications
```bash
# Conteneur tourne ?
docker ps | grep traefik
# Logs propres ?
docker logs --tail 30 traefik 2>&1 | grep -i "error\|level=error"
# Accès au dashboard ?
curl -sI https://traefik.seizeconsulting.com
# Doit retourner 401 (demande de basic auth)
```
---
## 🌐 Comment ajouter un service derrière Traefik
### Méthode 1 — Labels Docker (recommandé)
Sur le `docker-compose.yml` du service à exposer, ajouter ces labels :
```yaml
services:
myservice:
image: ...
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.myservice.rule=Host(`myservice.seizeconsulting.com`)"
- "traefik.http.routers.myservice.entrypoints=websecure"
- "traefik.http.routers.myservice.tls.certresolver=letsencrypt"
- "traefik.http.services.myservice.loadbalancer.server.port=8080" # port interne du service
networks:
web:
external: true
```
⚠️ **Important** : le service doit être sur le réseau `web` partagé avec Traefik.
### Méthode 2 — Avec SSO (via OAuth2-Proxy)
Pour les services qui n'ont pas de SSO natif, on ajoute OAuth2-Proxy entre Traefik et le service. Voir [04-oauth2-proxy-pattern.md](./04-oauth2-proxy-pattern.md).
---
## 🐛 Troubleshooting
### "Certificat Let's Encrypt en échec"
Symptômes : navigateur dit "Connexion non sécurisée".
Causes possibles :
1. **DNS pas propagé** : `dig +short sub.seizeconsulting.com` doit retourner l'IP du serveur
2. **Ports 80/443 bloqués** : firewall + cloud provider
3. **Rate limit Let's Encrypt** : trop de tentatives, attendre 1h
4. **acme.json corrompu** :
```bash
sudo rm letsencrypt/acme.json
sudo touch letsencrypt/acme.json
sudo chmod 600 letsencrypt/acme.json
docker compose restart
```
### "404 page not found" sur un sous-domaine
Causes possibles :
1. Le service ciblé n'a pas les bons labels Traefik
2. Le service n'est pas sur le réseau `web`
3. Le port interne du service est incorrect dans `loadbalancer.server.port`
4. La règle `Host(...)` est mal écrite (vérifier les backticks)
Vérifier dans le dashboard Traefik : `https://traefik.seizeconsulting.com` → Routers, Services.
### "Bad Gateway 502"
Causes possibles :
1. Le service backend est down
2. Le service écoute sur un port différent de celui annoncé à Traefik
3. Le service écoute sur 127.0.0.1 au lieu de 0.0.0.0
```bash
# Vérifier que le service répond bien sur le port interne attendu
docker exec -it traefik wget -qO- http://myservice:8080
```
### "Le SSO ne marche plus depuis 5 min"
Vérifier la config ForwardAuth :
```bash
grep -A 10 "oauth2-headers" ~/docker/traefik/dynamic.yml
# La ligne "- Cookie" doit être présente dans authRequestHeaders
```
---
## 🔄 Mise à jour Traefik
```bash
cd ~/docker/traefik
docker compose pull
docker compose up -d
```
⚠️ Avant une **major version** (v2 → v3 par exemple) :
- Lire le CHANGELOG
- Tester en local d'abord
- Backup de `acme.json`
---
## 📚 Liens utiles
- Documentation Traefik : https://doc.traefik.io/traefik/
- Référence des middlewares : https://doc.traefik.io/traefik/middlewares/overview/
- ForwardAuth doc : https://doc.traefik.io/traefik/middlewares/http/forwardauth/

281
docs/03-zitadel-setup.md Normal file
View File

@ -0,0 +1,281 @@
# 03 — Zitadel (Identity Provider)
> Zitadel est le **cœur de l'authentification**. Tous les outils se loggent via lui.
---
## 🎯 Architecture Zitadel
⚠️ **Spécificité importante** : Zitadel se déploie via un **docker-compose complet** qui inclut :
- Le serveur API Zitadel
- L'UI de login (login v2)
- Postgres dédié (séparé du Postgres principal)
- Un Traefik dédié interne (séparé du Traefik principal !)
→ Le Traefik principal de Seize **route uniquement** vers le Traefik interne de Zitadel via la règle `Host(sso.seizeconsulting.com)`.
```
┌───────────────────────────────────┐
│ Réseau Docker "zitadel" │
│ │
Traefik │ ┌──────────┐ ┌──────────────┐ │
principal ─────▶│ │ Traefik │───▶│ Zitadel │ │
(web) │ │ interne │ │ - API │ │
│ │ │ │ - Login UI │ │
│ └──────────┘ └──────┬───────┘ │
│ │ │
│ ┌───────▼──────┐ │
│ │ Postgres │ │
│ │ (zitadel) │ │
│ └──────────────┘ │
└───────────────────────────────────┘
```
---
## 📁 Structure
```
~/docker/zitadel/
├── docker-compose.yml # Multi-services (proxy + api + login + db)
├── .env # Configuration et secrets
└── machinekey/ # Clés des service accounts (à backup)
```
---
## 📄 docker-compose.yml (résumé)
⚠️ Le fichier officiel de Zitadel fait ~150 lignes. Voir `configs/zitadel/docker-compose.yml.template` pour la version complète.
Services inclus :
- `proxy` (Traefik interne)
- `zitadel-api` (le moteur Zitadel)
- `zitadel-login` (UI de connexion v2)
- `postgres` (database dédiée)
### Volumes critiques à backuper
```yaml
volumes:
- ./postgres-data:/var/lib/postgresql/data # ⚠️ CRITIQUE
- ./machinekey:/machinekey # ⚠️ CRITIQUE
```
---
## 📄 .env (variables clés)
```env
# Domaine d'accès
ZITADEL_EXTERNALDOMAIN=sso.seizeconsulting.com
ZITADEL_EXTERNALPORT=443
ZITADEL_EXTERNALSECURE=true
ZITADEL_TLS_ENABLED=false # TLS géré par Traefik externe
# Master key (⚠️ NE JAMAIS perdre, NE JAMAIS changer après init)
ZITADEL_MASTERKEY=<32-char-random-key>
# Postgres
ZITADEL_DATABASE_POSTGRES_HOST=postgres
ZITADEL_DATABASE_POSTGRES_PORT=5432
ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=<password>
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=postgres
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=<admin-password>
# Admin initial
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME=zitadel-admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD=<temp-password-to-change>
# Traefik image (pour le proxy interne)
TRAEFIK_IMAGE=traefik:v3.6.8
TRAEFIK_DASHBOARD_ENABLED=false
TRAEFIK_LOG_LEVEL=INFO
TRAEFIK_ACCESSLOG_ENABLED=false
PROXY_HTTP_PUBLISHED_PORT=80
```
⚠️ **`ZITADEL_MASTERKEY` est immuable** une fois l'instance initialisée. Le perdre = pouvoir reconstruire from scratch uniquement.
---
## 🚀 Premier démarrage
### 1. Préparer l'environnement
```bash
mkdir -p ~/docker/zitadel/{postgres-data,machinekey}
cd ~/docker/zitadel
# Copier docker-compose.yml et .env depuis les templates
```
### 2. Lancer
```bash
docker compose up -d
```
### 3. Suivre les logs (premier démarrage = 1-2 min)
```bash
docker compose logs -f zitadel-api
```
Attendre le message :
```
init step done: type=instance_setup
```
### 4. Tester l'UI
Navigateur : `https://sso.seizeconsulting.com`
→ Doit afficher la page de login Zitadel.
### 5. Se logger en admin
- Username : `zitadel-admin@zitadel.sso.seizeconsulting.com`
- Password : celui défini dans `.env` `ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD`
⚠️ **Changer le mdp immédiatement** au premier login.
---
## ⚙️ Configuration post-install via UI
### 1. Default Settings → Policies
Aller dans **Default Settings** (icône ⚙️ en haut à droite).
#### Password Complexity
- ☑️ Min length : 14
- ☑️ Has uppercase
- ☑️ Has lowercase
- ☑️ Has number
- ☑️ Has symbol
#### Lockout Policy
- ☑️ Max password attempts : 5
- ☑️ Show lockout failures (sécurité : ne pas révéler les comptes)
#### MFA (Force MFA pour tous)
- ☑️ Force MFA : Enabled
- ☑️ MFA Init methods : TOTP, U2F, OTP Email
### 2. Branding
- Logo Seize
- Couleurs : `--c-accent: #008A81`
- Texte personnalisé
### 3. SMTP Provider (à faire quand admin M365 dispo)
Default Settings → Notifications → SMTP Provider :
```
Host : smtp.office365.com:587
Username : noreply@seizeconsulting.com
Password : <App Password M365>
From : noreply@seizeconsulting.com
TLS : ✅
```
### 4. Créer un Project "Infra Seize"
Menu Projects → New :
- Name : `Infra Seize`
- Type : Owned project
Project ID à noter : sera utilisé dans tous les scripts.
### 5. Créer le service account `furious-import-service`
Voir [06-import-furious.md](./06-import-furious.md).
---
## 🔐 Gestion des Administrators IAM
Pour ajouter un user/service account comme admin de toute l'instance :
1. Default Settings → en haut à droite, cliquer sur **"+"** à côté des avatars
2. Page "Administrators" → **"+ New"**
3. Sélectionner l'user
4. Cocher les **rôles minimum nécessaires** :
- `IAM_USER_MANAGER` : pour gérer les users via API
- `IAM_ORG_MANAGER` : pour gérer les organisations
- ❌ Éviter `IAM_OWNER` (trop puissant)
---
## 📊 Volumes critiques à backuper
| Volume | Contenu | Criticité |
|---|---|---|
| `postgres-data/` | Base Zitadel (users, projects, sessions) | 🔴 CRITIQUE |
| `machinekey/` | Clés des service accounts | 🔴 CRITIQUE |
| `.env` | Secrets de config + MASTERKEY | 🔴 CRITIQUE |
### Backup recommandé
```bash
# Backup quotidien automatique (cron)
0 3 * * * docker exec zitadel-postgres-1 pg_dump -U zitadel zitadel > /home/admin/backups/zitadel-$(date +\%Y\%m\%d).sql
```
---
## 🐛 Troubleshooting
### "Service zitadel-api ne démarre pas"
Causes possibles :
1. Postgres pas prêt → attendre 30s et `docker compose restart zitadel-api`
2. `.env` mal configuré → vérifier les variables
3. Migration DB échouée → `docker compose logs zitadel-api | grep -i error`
### "Master key mismatch"
⚠️ **Très grave** : ZITADEL_MASTERKEY a changé alors que l'instance était déjà init.
Solution :
- Soit retrouver l'ancienne clé
- Soit restore depuis backup
- Soit reset complet (perte de toutes les données)
→ Ne jamais changer ZITADEL_MASTERKEY après le premier démarrage.
### "Login impossible pour un user"
Vérifier :
1. User existe : Console → Users → Search
2. User actif (pas désactivé)
3. Email vérifié (sinon le login peut bloquer)
4. MFA bien configuré (si forcé au niveau policy)
5. Pas de lockout actif (Failed login attempts)
### "Le scope `urn:zitadel:iam:org:project:roles` ne retourne pas les rôles"
Vérifier :
1. Project setting **"Return user roles during authentication"** est coché
2. App setting **"User roles inside ID Token"** est coché
3. Auth Token Type = **JWT** (pas Bearer Token)
4. OAuth2-Proxy demande bien ce scope dans `OAUTH2_PROXY_SCOPE`
### "Service account peut pas créer de users (401/403)"
Vérifier :
1. Service account a bien le rôle `IAM_USER_MANAGER` (dans Default Settings → Administrators)
2. Clé JSON valide et non expirée
3. Token JWT signé correctement par le script Python
---
## 📚 Liens utiles
- Documentation Zitadel : https://zitadel.com/docs
- API v2 reference : https://zitadel.com/docs/apis/resources/user_service_v2
- Self-hosting guide : https://zitadel.com/docs/self-hosting/deploy/overview
- Custom claims (Actions V2) : https://zitadel.com/docs/concepts/features/actions_v2

View File

@ -0,0 +1,353 @@
# 04 — Pattern OAuth2-Proxy par outil
> Comment chaque outil "non-OIDC-native" reçoit le SSO via OAuth2-Proxy.
---
## 🎯 Principe
Beaucoup d'outils (Portainer, n8n, Code Server, Uptime Kuma...) **n'ont pas de SSO OIDC natif**. Pour leur ajouter le SSO, on intercale **OAuth2-Proxy** entre Traefik et l'outil :
```
User → Traefik → OAuth2-Proxy → Outil
Zitadel (auth)
```
### Pourquoi 1 OAuth2-Proxy par outil (et pas centralisé) ?
Voir [DECISIONS.md D2](../DECISIONS.md). En bref : **isolation**, **config différenciée**, **debug plus simple**.
---
## 📁 Anatomie d'un OAuth2-Proxy
Pour chaque outil, on a un **dossier** dédié :
```
~/docker/oauth2-proxy-<outil>/
├── docker-compose.yml
└── .env
```
⚠️ L'OAuth2-Proxy de **l'intranet** est dans `~/docker/oauth2-proxy/` (sans suffixe) pour des raisons historiques.
---
## 📄 docker-compose.yml — Template
```yaml
services:
oauth2-proxy-<outil>:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
container_name: oauth2-proxy-<outil>
restart: unless-stopped
env_file:
- .env
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.<outil>-sso.rule=Host(`<outil>.seizeconsulting.com`)"
- "traefik.http.routers.<outil>-sso.entrypoints=websecure"
- "traefik.http.routers.<outil>-sso.tls.certresolver=letsencrypt"
- "traefik.http.routers.<outil>-sso.service=<outil>-sso"
- "traefik.http.routers.<outil>-sso.priority=300"
- "traefik.http.services.<outil>-sso.loadbalancer.server.port=4180"
networks:
web:
external: true
```
### ⚠️ Détails importants
- `priority=300` : pour que ce router prenne le pas sur d'autres routers potentiellement définis sur le même host (notamment Gitea SSO natif).
- Le service backend (`<outil>`) **ne doit PAS avoir ses propres labels Traefik** pour exposer le host. Sinon conflit.
- `loadbalancer.server.port=4180` : OAuth2-Proxy écoute sur 4180 en interne.
---
## 📄 .env — Template
```env
# ===== PROVIDER OIDC ZITADEL =====
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_CLIENT_ID=<RECUPERER_DEPUIS_ZITADEL>
OAUTH2_PROXY_CLIENT_SECRET=<RECUPERER_DEPUIS_ZITADEL>
OAUTH2_PROXY_COOKIE_SECRET=<GENERER_32_CHARS_ALEATOIRES>
OAUTH2_PROXY_OIDC_ISSUER_URL=https://sso.seizeconsulting.com
OAUTH2_PROXY_REDIRECT_URL=https://<outil>.seizeconsulting.com/oauth2/callback
# ===== UPSTREAM (l'outil derrière) =====
OAUTH2_PROXY_UPSTREAMS=http://<outil>:<port_interne>
# ===== COOKIES =====
OAUTH2_PROXY_COOKIE_DOMAINS=.seizeconsulting.com
OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_COOKIE_HTTPONLY=true
OAUTH2_PROXY_COOKIE_SAMESITE=lax
# ===== SÉCURITÉ =====
OAUTH2_PROXY_EMAIL_DOMAINS=*
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_SET_XAUTHREQUEST=true
# ===== RÉSEAU =====
OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY=true
OAUTH2_PROXY_WHITELIST_DOMAINS=.seizeconsulting.com
# ===== SCOPES OIDC =====
# Le scope urn:zitadel:iam:org:project:roles est CRITIQUE pour le RBAC
OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles
# ===== DIVERS =====
OAUTH2_PROXY_REDIRECT_URL_AUTH_DOMAIN_AS_REDIRECT_URL_SUFFIX=false
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true
OAUTH2_PROXY_FOOTER=-
```
### 🔑 Génération du cookie secret
```bash
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
```
→ 32 caractères aléatoires, à utiliser comme `OAUTH2_PROXY_COOKIE_SECRET`.
⚠️ **Si tu changes le cookie secret**, tous les utilisateurs seront déconnectés et devront se relogger.
---
## 🔐 Côté Zitadel — Créer l'application
Pour chaque outil, créer une **application** dans Zitadel :
### 1. Console Zitadel → Projects → Infra Seize → Applications → New
### 2. Configuration
- **Name** : `<Outil>` (ex: "n8n", "Portainer")
- **Type** : **Web**
- Click Continue
### 3. Authentication Method
- **Basic** (Client ID + Client Secret)
### 4. Authorization
- **Authorization Code**
- Cocher **Authorization Code** uniquement
- Ne PAS cocher Refresh Token sauf besoin
### 5. Redirect URLs
- Redirect URLs : `https://<outil>.seizeconsulting.com/oauth2/callback`
- Post-logout Redirect URLs : `https://<outil>.seizeconsulting.com/`
### 6. Confirmation et Token Settings
Après création, aller dans **Token Settings** de l'app :
| Paramètre | Valeur |
|---|---|
| Auth Token Type | **JWT** |
| User roles inside ID Token | ✅ |
| Include user's profile info in the ID Token | ✅ |
| Add user roles to the access token | ✅ |
| ClockSkew | 0 (par défaut OK) |
**Save**.
### 7. Récupérer Client ID + Client Secret
- Client ID : visible en haut de l'app
- Client Secret : Actions → Generate Client Secret → copier (montré 1 seule fois)
⚠️ **Sauvegarder dans le gestionnaire de mots de passe immédiatement**.
---
## 🚀 Déploiement d'un OAuth2-Proxy (pas à pas)
### Exemple : OAuth2-Proxy pour n8n
```bash
# 1. Créer le dossier
mkdir -p ~/docker/oauth2-proxy-n8n
cd ~/docker/oauth2-proxy-n8n
# 2. Copier le template
cp ~/seize-infra-doc/configs/oauth2-proxy/docker-compose.yml.template docker-compose.yml
cp ~/seize-infra-doc/configs/oauth2-proxy/.env.example .env
# 3. Adapter le docker-compose.yml
# Remplacer <outil> par "n8n"
# Le sed ci-dessous fait ça automatiquement
sed -i 's|<outil>|n8n|g' docker-compose.yml
# 4. Éditer le .env (nano ou vim) avec les vraies valeurs
nano .env
# Remplir : CLIENT_ID, CLIENT_SECRET, COOKIE_SECRET, REDIRECT_URL, UPSTREAMS
# 5. Sécuriser le .env
chmod 600 .env
# 6. Démarrer
docker compose up -d
# 7. Vérifier les logs
docker logs --tail 30 oauth2-proxy-n8n
```
### Vérification
```bash
# Test depuis le serveur
curl -sI http://localhost:4180/ # depuis le conteneur OAuth2-Proxy
# Test depuis l'extérieur (navigateur)
# Aller sur https://n8n.seizeconsulting.com
# → Redirection vers https://sso.seizeconsulting.com/oauth/v2/authorize?...
```
---
## 🐛 Troubleshooting OAuth2-Proxy
### "Redirect loop infini"
Symptôme : le browser fait des allers-retours entre l'outil et Zitadel sans jamais aboutir.
Cause #1 : **Cookie pas transmis par Traefik**.
→ Vérifier `~/docker/traefik/dynamic.yml` : `authRequestHeaders` contient bien `Cookie`.
Cause #2 : **Cookie domain incorrect**.
`OAUTH2_PROXY_COOKIE_DOMAINS=.seizeconsulting.com` (avec le point au début).
Cause #3 : **Cookie secure mais HTTP utilisé**.
→ S'assurer que toute la chaîne est en HTTPS.
### "Bad Gateway" après login
Symptôme : login Zitadel OK, mais erreur 502 ensuite.
Cause : OAuth2-Proxy ne peut pas joindre l'upstream.
```bash
# Tester depuis le conteneur OAuth2-Proxy
docker exec -it oauth2-proxy-n8n wget -qO- http://n8n:5678
```
Si erreur : vérifier que l'upstream est sur le même réseau Docker (`web`).
### "Erreur 401 sur /oauth2/callback"
Cause : Client Secret incorrect ou expiré.
```bash
# Vérifier dans Zitadel que le secret correspond à celui dans .env
# Regenerer le secret dans Zitadel et le remettre dans .env
```
### "Access Denied" après login (alors qu'on a un compte)
Cause : projet Zitadel a "Only authorized users" coché, mais l'user n'a pas de rôle attribué.
Solution :
- Soit attribuer un rôle à l'user
- Soit décocher "Only authorized users" sur le project (voir [DECISIONS.md D9](../DECISIONS.md))
### "Le scope `urn:zitadel:iam:org:project:roles` ne fonctionne pas"
Vérifier :
1. La case "Return user roles during authentication" est cochée sur le **Project** (pas seulement l'app)
2. Le scope est bien dans `.env` OAuth2-Proxy
3. Restart le conteneur après modif de `.env`
```bash
docker compose down
docker compose up -d
```
---
## 🔍 Voir le contenu du token JWT (debug)
⚠️ **Procédure de debug uniquement** — ne pas laisser activée en prod.
Voir [09-troubleshooting.md](./09-troubleshooting.md) section "Nginx diagnostic".
---
## 📊 Headers transmis par OAuth2-Proxy à l'outil
Une fois la chaîne d'auth complète, OAuth2-Proxy passe ces headers à l'outil :
| Header | Valeur exemple | Usage |
|---|---|---|
| `X-Forwarded-User` | `375119988404518915` | User ID Zitadel |
| `X-Forwarded-Email` | `hedi.kalboussi@seizeconsulting.com` | Email principal |
| `X-Forwarded-Preferred-Username` | `HKA` | Trigramme ou username |
| `X-Forwarded-Groups` | `(vide si claim Zitadel non plat)` | Groupes (pas exploitable directement avec Zitadel) |
| `Authorization` | `Bearer eyJ...` | Token JWT complet |
⚠️ Pour récupérer les **rôles**, il faut **décoder le JWT** (les rôles sont dans un claim `urn:zitadel:iam:org:project:roles`, pas dans `X-Forwarded-Groups`).
→ C'est ce qu'on fait sur l'intranet via JavaScript + `roles-mapping.json`.
---
## 📋 Liste des OAuth2-Proxy déployés
| OAuth2-Proxy | Outil protégé | Host |
|---|---|---|
| `oauth2-proxy` | Intranet | intranet.seizeconsulting.com |
| `oauth2-proxy-n8n` | n8n | n8n.seizeconsulting.com |
| `oauth2-proxy-portainer` | Portainer | portainer.seizeconsulting.com |
| `oauth2-proxy-code` | Code Server | code.seizeconsulting.com |
| `oauth2-proxy-status` | Uptime Kuma | status.seizeconsulting.com |
⚠️ **Gitea** n'utilise PAS OAuth2-Proxy : il a son **SSO OIDC natif**. Voir [05-applications.md](./05-applications.md).
---
## 🔄 Maintenance
### Mise à jour OAuth2-Proxy
```bash
cd ~/docker/oauth2-proxy-<outil>
docker compose pull
docker compose up -d
```
### Renouveler le Client Secret
```bash
# 1. Dans Zitadel, regénérer le Client Secret de l'app
# 2. Copier la nouvelle valeur
# 3. Éditer .env
nano ~/docker/oauth2-proxy-<outil>/.env
# Remplacer OAUTH2_PROXY_CLIENT_SECRET
# 4. Restart
cd ~/docker/oauth2-proxy-<outil>
docker compose down
docker compose up -d
```
### Rotation du cookie secret
⚠️ Tous les utilisateurs seront déconnectés.
```bash
NEW_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
echo "Nouveau cookie secret : $NEW_SECRET"
# → Mettre à jour .env
```

358
docs/05-applications.md Normal file
View File

@ -0,0 +1,358 @@
# 05 — Applications déployées
> Détails de chaque application protégée par SSO.
---
## 📋 Vue d'ensemble
| App | Image Docker | SSO via | Port interne | URL |
|---|---|---|---|---|
| Gitea | `gitea/gitea:1.22` | OIDC natif | 3000 (HTTP), 22 (SSH) | git.seizeconsulting.com |
| n8n | `n8nio/n8n:latest` | OAuth2-Proxy | 5678 | n8n.seizeconsulting.com |
| Portainer | `portainer/portainer-ce:latest` | OAuth2-Proxy | 9000 | portainer.seizeconsulting.com |
| Code Server | `lscr.io/linuxserver/code-server:latest` | OAuth2-Proxy | 8443 | code.seizeconsulting.com |
| Uptime Kuma | `louislam/uptime-kuma:1` | OAuth2-Proxy | 3001 | status.seizeconsulting.com |
---
## 🐙 Gitea
### Spécificité : SSO OIDC natif
Contrairement aux autres, Gitea **ne passe PAS par OAuth2-Proxy**. Il a sa propre intégration OIDC.
### docker-compose.yml (résumé)
Voir `configs/gitea/docker-compose.yml.template`.
```yaml
services:
gitea:
image: gitea/gitea:1.22
container_name: gitea
environment:
USER_UID: 1000
USER_GID: 1000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: postgres:5432
GITEA__database__NAME: gitea
GITEA__database__USER: gitea
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
GITEA__server__DOMAIN: git.seizeconsulting.com
GITEA__server__ROOT_URL: https://git.seizeconsulting.com/
GITEA__server__SSH_DOMAIN: git.seizeconsulting.com
# Auto-registration via OIDC
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
GITEA__oauth2_client__USERNAME: preferred_username
GITEA__oauth2_client__ACCOUNT_LINKING: auto
# Désactiver le register manuel (SSO uniquement)
GITEA__service__DISABLE_REGISTRATION: "true"
volumes:
- ./data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "22:22" # SSH Git
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`git.seizeconsulting.com`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
networks:
- web
- backend
```
### Configuration SSO côté Gitea (après premier login)
1. Aller sur `https://git.seizeconsulting.com` → setup initial
2. Créer un compte admin Gitea (local, sera utilisé pour configurer le reste)
3. Site Administration → Authentication Sources → Add :
- Type : **OAuth2**
- Authentication Name : `Zitadel`
- OAuth2 Provider : **OpenID Connect**
- Client ID : `<de Zitadel>`
- Client Secret : `<de Zitadel>`
- OpenID Connect Auto Discovery URL : `https://sso.seizeconsulting.com/.well-known/openid-configuration`
- Skip Local 2FA : ✅ (Zitadel gère MFA)
4. **Save**
### Configuration côté Zitadel
Créer une app dans Projects → Infra Seize → Applications :
- Type : Web
- Authentication Method : Basic
- Grant Type : Authorization Code
- Redirect URL : `https://git.seizeconsulting.com/user/oauth2/Zitadel/callback`
- Post-logout Redirect : `https://git.seizeconsulting.com/`
- Token Settings : JWT + User profile info dans ID Token
### Caractéristiques
- ✅ **Auto-registration** : à la 1ère connexion via SSO, le compte Gitea est auto-créé
- ✅ **Username = trigramme** (lu depuis `preferred_username` Zitadel)
- ❌ **Registration manuelle désactivée** : SSO obligatoire pour les nouveaux users
### Rôles RBAC
Gitea n'utilise **pas** les rôles Zitadel directement. Les permissions Gitea sont gérées en interne (organizations, teams, repos). Pour l'instant, **tous les users ont accès en lecture** sur les repos publics.
À configurer plus tard : créer des **organizations** par BU et donner accès en fonction.
---
## ⚙️ n8n
### docker-compose.yml (résumé)
```yaml
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_HOST: n8n.seizeconsulting.com
N8N_PORT: 5678
N8N_PROTOCOL: https
WEBHOOK_URL: https://n8n.seizeconsulting.com/
# ⚠️ Pas de labels Traefik ici : c'est OAuth2-Proxy qui est exposé
networks:
- web
- backend
```
### Particularités
- ⚠️ **N8N_ENCRYPTION_KEY** : critique pour décrypter les workflows et credentials. **À sauvegarder absolument**.
- L'auth n8n native est désactivée car OAuth2-Proxy fait l'auth en amont.
- Les **workflows** sont stockés dans Postgres (base `n8n`).
### Limitation RBAC
n8n CE (Community Edition) **ne supporte pas le RBAC granulaire** :
- Tous les users authentifiés ont accès à TOUS les workflows
- Pas de notion de "team" ou "project" en CE
→ C'est pourquoi le RBAC se fait **côté intranet** (cacher la carte aux non-autorisés). Voir [DECISIONS.md D7](../DECISIONS.md).
Pour vrai RBAC :
- Soit migrer vers **Activepieces** (gratuit, multi-tenant natif)
- Soit acheter **n8n Enterprise** (~12k€/an)
---
## 🐳 Portainer
### docker-compose.yml (résumé)
```yaml
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ⚠️ Accès complet à Docker !
- ./data:/data
networks:
- web
```
### Particularités
- ⚠️ **Accès au socket Docker** : Portainer a un contrôle **total** sur tous les conteneurs du serveur. À protéger absolument derrière SSO.
- Auth Portainer native = créée au premier login, à protéger.
- **RBAC interne** dans Portainer CE = très limité.
### Limitation RBAC
Portainer CE :
- 1 seul utilisateur admin
- Pas d'organisations/teams
→ RBAC sur l'intranet uniquement. Si besoin de vrai RBAC : **Portainer Business** (~1800€/an) ou migration vers **Komodo**.
---
## 💻 Code Server
### docker-compose.yml (résumé)
```yaml
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: code-server
environment:
PUID: 1000
PGID: 1000
TZ: Europe/Paris
PASSWORD: ${CODE_PASSWORD} # Fallback si SSO down
SUDO_PASSWORD: ${CODE_PASSWORD}
DEFAULT_WORKSPACE: /config/workspace
volumes:
- ./config:/config
networks:
- web
```
### Particularités
- VS Code dans le navigateur
- L'OAuth2-Proxy gère l'auth en amont, mais Code Server a aussi son propre mdp en fallback (`CODE_PASSWORD`)
- Workspaces partagés dans `./config/workspace/`
### Limitation RBAC
Pas de notion de "team" ou "workspace par user" en local. Tout user accède au même workspace.
→ Pour vrai RBAC : **OpenVSCode Server** (multi-user natif).
---
## 📈 Uptime Kuma
### docker-compose.yml (résumé)
```yaml
services:
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
volumes:
- ./data:/app/data
networks:
- web
```
### Particularités
- Outil de monitoring (HTTP/TCP/Ping checks)
- Statuspages publiques possibles (mais on les laisse derrière SSO ici)
- 1 seul user admin
### Configuration recommandée
Une fois SSO en place, **désactiver le compte admin local** ou changer le mdp en quelque chose de très long (le SSO suffit).
### Limitation RBAC
Pareil que les autres : 1 seul user, pas de RBAC.
→ Pour vrai RBAC : **Gatus** (config-as-code, multi-tenant).
---
## 🏠 Intranet (page d'accueil)
### docker-compose.yml
```yaml
services:
intranet:
image: nginx:alpine
container_name: intranet
restart: unless-stopped
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
- ./logo.png:/usr/share/nginx/html/logo.png:ro
- ./roles-mapping.json:/usr/share/nginx/html/roles-mapping.json:ro
- ./nginx-rbac.conf:/etc/nginx/nginx.conf:ro
networks:
- web
```
### Caractéristiques
- Simple nginx avec un HTML statique
- **Filtrage RBAC côté JavaScript** (voir [07-rbac-intranet.md](./07-rbac-intranet.md))
- Nginx fait du `sub_filter` pour injecter l'email user dans l'HTML
- L'HTML cache les cartes selon le rôle
### Fichiers critiques
| Fichier | Usage |
|---|---|
| `index.html` | Le HTML avec les cartes et le JavaScript RBAC |
| `roles-mapping.json` | Mapping `email → role` (généré par script Python) |
| `nginx-rbac.conf` | Config nginx avec `sub_filter` pour injecter `__USER_EMAIL__` |
| `logo.png` | Logo Seize |
---
## 🗄️ PostgreSQL (principal)
### docker-compose.yml
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
volumes:
- ./data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
```
### Particularités
- Postgres **mutualisé** entre Gitea et n8n (databases séparées dans la même instance)
- Pas exposé publiquement (réseau `backend` uniquement)
- ⚠️ **Postgres Zitadel est séparé** (dans son propre docker-compose)
### Databases hébergées
| Database | Owner | Service |
|---|---|---|
| postgres | postgres | (admin) |
| gitea | gitea | Gitea |
| n8n | n8n | n8n |
| authentik | authentik | Authentik (obsolète) |
---
## 📦 Volumes critiques par app
| App | Volume | Quoi |
|---|---|---|
| Gitea | `./data` | Repos, config, attachments |
| Portainer | `./data` | Compose stacks, snapshots |
| Code Server | `./config` | Workspace, settings, plugins |
| Uptime Kuma | `./data` | Monitors, history, statuspages |
| Postgres | `./data` | Toutes les DBs (sauf Zitadel) |
| Zitadel | `./postgres-data` + `./machinekey` | Auth complète |
---
## 🔄 Ajouter une nouvelle app — checklist
Voir [OPERATIONS.md](../OPERATIONS.md) section "Ajouter un nouvel outil".
1. ✅ DNS record A `<app>.seizeconsulting.com`
2. ✅ Créer `~/docker/<app>/` avec docker-compose.yml
3. ✅ Créer l'app dans Zitadel (Project Infra Seize)
4. ✅ Récupérer Client ID + Secret
5. ✅ Créer OAuth2-Proxy dédié `~/docker/oauth2-proxy-<app>/`
6. ✅ Tester le login
7. ✅ Ajouter la carte dans `~/docker/intranet/index.html` avec `data-app="<app>"`
8. ✅ Ajouter la ligne dans la matrice RBAC du JavaScript
9. ✅ Restart intranet
10. ✅ Tester avec différents profils

435
docs/06-import-furious.md Normal file
View File

@ -0,0 +1,435 @@
# 06 — Import des utilisateurs depuis Furious Squad
> Procédure complète qui a permis d'importer 88 collaborateurs Seize de Furious vers Zitadel le 5 juin 2026.
---
## 🎯 Objectif
Synchroniser les **88 collaborateurs Seize actifs** depuis Furious Squad (source de vérité RH) vers Zitadel (Identity Provider), avec :
- 1 compte Zitadel par collaborateur
- Metadata Furious préservées (job_title, manager, BU, etc.)
- Mots de passe temporaires générés
- Possibilité d'attribuer des rôles RBAC ensuite
---
## 🏗️ Architecture du process
```
Furious API (REST)
│ HTTP Basic Auth (FURIOUS_USERNAME:PASSWORD)
read-all-users.sh ──────────► furious-users-raw.json
import-furious-to-zitadel.py
┌───────────────┼───────────────┐
│ │ │
dry-run test prod
(rien) (3 users) (85 users)
Zitadel API v2 ──── service account
│ furious-import-service
88 users créés
users-credentials.csv (mdps temporaires)
distribution-teams.csv (3 colonnes)
Distribution manuelle via Teams
```
---
## 📁 Structure des scripts
```
~/furious-to-zitadel/
├── .env # FURIOUS_USERNAME, PASSWORD, etc.
├── venv/ # Environnement Python
├── zitadelfuriousimportkey.json # ⚠️ Clé service account (chmod 600)
├── read-all-users.sh # Lecture Furious → JSON
├── test-auth.sh # Test connexion API Furious
├── test-1-user.sh # Test lecture d'un user spécifique
├── import-furious-to-zitadel.py # ⭐ Script principal d'import
├── merge-duplicates.py # Fusion des doublons (HKA, PSP, ...)
├── reset-test-users-password.py # Reset mdp des users de test
├── extract-distribution-csv.py # Génère CSV pour distribution
├── generate-roles-mapping.py # Génère JSON mapping email → rôle
├── assign-roles.py # Attribue les rôles dans Zitadel
├── furious-users-raw.json # Données brutes Furious
├── users-credentials.csv # 89 lignes avec mdps
├── distribution-teams.csv # 88 lignes pour Teams
├── roles-mapping.json # 87 mappings email → rôle
└── import.log # Logs d'exécution
```
---
## 📄 Configuration `.env`
```env
FURIOUS_USERNAME=<creer_dans_furious_config>
FURIOUS_PASSWORD=<creer_dans_furious_config>
FURIOUS_API_URL=https://seize-consulting.furious-squad.com/api/v2
```
⚠️ `chmod 600 .env`
---
## 🔑 Création du compte API Furious
### 1. Connexion Furious
Connexion à `https://seize-consulting.furious-squad.com` avec un compte admin.
### 2. Configuration → Générer un utilisateur d'API
- Nom : `import-zitadel` (ou similaire)
- Permissions :
- ☑️ User > Search (lecture seule)
- ❌ Tout le reste (principe du moindre privilège)
- Generate
### 3. Récupérer les identifiants
Format identifiant : `<nom>.<6chiffres>` (exemple : `import-zitadel.123456`)
+ Mot de passe généré aléatoirement.
⚠️ **Sauvegarder immédiatement dans le gestionnaire de mots de passe.**
### 4. Tester
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
./test-auth.sh
# Doit retourner un JSON avec une réponse valide (pas 401/403)
```
---
## 🤖 Création du service account Zitadel
### 1. Console Zitadel → Users → New
- **Type** : Service user
- **Username** : `furious-import-service`
- **Access Token Type** : **JWT** (important !)
### 2. Ajouter au rôle IAM_USER_MANAGER
- Aller dans Default Settings (top-right) → Administrators → New
- Sélectionner le service user `furious-import-service`
- Cocher **IAM_USER_MANAGER**
- ❌ Ne PAS cocher IAM_OWNER
### 3. Générer la clé JSON
- Profil du service user → Keys → New
- Type : **JSON**
- Expiration : 1 an (ou jamais selon besoin)
- **Download immediately** → fichier `<userId>.json`
- ⚠️ La clé n'est jamais re-affichée
### 4. Transférer sur le serveur
```bash
# Depuis le PC (Windows PowerShell ou Linux)
scp 375992380685287427.json hedi@<server>:~/furious-to-zitadel/zitadelfuriousimportkey.json
# Sur le serveur
ssh hedi@<server>
chmod 600 ~/furious-to-zitadel/zitadelfuriousimportkey.json
```
---
## 🐍 Environnement Python
```bash
cd ~/furious-to-zitadel
python3 -m venv venv
source venv/bin/activate
pip install requests pyjwt cryptography
```
---
## 🚀 Procédure d'import — pas à pas
### Étape 1 — Lire les users depuis Furious
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
./read-all-users.sh
```
→ Génère `furious-users-raw.json` (29 Ko, 94 users dont 88 actifs).
### Étape 2 — Test d'authentification Zitadel
```bash
# Test que la clé Zitadel marche
python3 -c "
import json
data = json.load(open('zitadelfuriousimportkey.json'))
print('UserId:', data.get('userId'))
print('KeyId:', data.get('keyId'))
print('Type:', data.get('type'))
"
```
### Étape 3 — Dry-run (simulation, aucune écriture)
```bash
python3 import-furious-to-zitadel.py --mode dry-run
```
→ Affiche **ce que ferait** le script sans rien créer.
Doit afficher :
```
[DRY-RUN] Would create: yves.canauxdebonfils@seizeconsulting.com (Yves Canaux de Bonfils)
[DRY-RUN] Would create: oussama.azzabi@seizeconsulting.com (Oussama Azzabi)
...
Total : 88 users would be created
```
### Étape 4 — Test sur 3 users
```bash
python3 import-furious-to-zitadel.py --mode test
```
→ Crée **3 users** dans Zitadel (les furious_id 1, 2, 3).
**Vérification dans Zitadel UI** : Console → Users → vérifier que les 3 users sont créés avec leurs metadata.
### Étape 5 — Import production
⚠️ **Étape sensible — ne pas interrompre.**
```bash
python3 import-furious-to-zitadel.py --mode prod | tee -a import.log
```
→ Crée les **85 users restants**. Durée : ~2 minutes.
Logs en temps réel + sauvegarde dans `import.log`.
### Étape 6 — Vérification
```bash
# Compter les users créés dans Zitadel via API
python3 -c "
# Voir le script pour récupérer le count
"
# Compter les lignes du CSV de credentials
wc -l users-credentials.csv
# Doit afficher : 89 (1 header + 88 users)
```
---
## 🔀 Gestion des doublons
### Contexte
4 anciens admins existaient déjà dans Zitadel avant l'import :
- HKA (Hedi Kalboussi) → ID `375119988404518915`
- PSP (Philippe Spoljaric) → ID `375120224527056899`
- PYT (Pierre-Yves Tachon) → ID `375778940960112643`
- Jules (Jules Bataille) → ID `375777592659148803`
L'import a créé des **doublons** avec leurs emails complets.
### Stratégie
Voir [DECISIONS.md D5](../DECISIONS.md). En bref :
- **Garder l'ancien** compte (avec MFA configuré)
- **Supprimer le nouveau** doublon
- **Copier les metadata Furious** du nouveau vers l'ancien (pour pas perdre l'info)
### Exécution
```bash
# 1. Éditer le script merge-duplicates.py pour adapter le mapping
nano merge-duplicates.py
# Configurer la variable DUPLICATES :
DUPLICATES = [
{
"keep_user_id": "375119988404518915", # HKA ancien
"delete_email": "hedi.kalboussi@seizeconsulting.com",
"furious_id": 5,
},
# ... 3 autres
]
# 2. Lancer
python3 merge-duplicates.py
```
→ Pour chaque doublon :
1. Lit le user "nouveau" via email
2. Copie ses metadata sur l'ancien
3. Supprime le nouveau
---
## 🎭 Attribution des rôles RBAC
### Prérequis
Avant d'attribuer des rôles, créer les 8 rôles dans Zitadel (Project Infra Seize).
Voir [07-rbac-intranet.md](./07-rbac-intranet.md) pour la création des rôles.
### Mapping job_title → rôle
Voir `matrices/job-title-to-role.csv` pour la matrice complète. Résumé :
```python
JOB_TITLE_TO_ROLE = {
"président": "direction",
"directeur": "direction",
"directeur associé": "direction",
"senior manager": "manager",
"sénior manager": "manager",
"manager": "manager",
"manager bi": "manager",
"expert": "expert",
"expert n2": "expert",
"consultant expert niveau 1": "expert",
"consultant sénior": "consultant_senior",
"consultant expérimenté": "consultant_senior",
"consultant expérimentée": "consultant_senior",
"consultant junior": "consultant_junior",
"stagiaire": "stagiaire",
"assistante": "support",
# ... etc
}
# Cas spécial : status="apprenti" → role="alternant"
```
### Exécution
```bash
# 1. Générer le mapping email → role
python3 generate-roles-mapping.py
# 2. Attribuer les rôles dans Zitadel
python3 assign-roles.py
```
→ Le script :
1. Lit chaque user actif Furious
2. Détermine son rôle (via mapping job_title ou status)
3. Cherche le user dans Zitadel par email
4. Lui attribue le rôle via API `POST /v1/users/{id}/grants`
**Vérification** : Console Zitadel → Users → 1 user → Authorizations → doit voir le rôle attribué.
---
## 📊 Format CSV produit
### `users-credentials.csv` (sensible, à protéger)
```csv
email,prenom,nom,trigramme,furious_id,zitadel_user_id,mdp_temporaire
yves.canauxdebonfils@seizeconsulting.com,Yves,Canaux de Bonfils,YCB,1,375990xxxxxxxxxxx,Y8sR3kp9XmF2
...
```
### `distribution-teams.csv` (pour distribution mdp)
```csv
Nom complet,Email,Mot de passe
Yves Canaux de Bonfils,yves.canauxdebonfils@seizeconsulting.com,Y8sR3kp9XmF2
...
```
---
## ⚠️ Anti-patterns à éviter
1. ❌ **Ne PAS sauter le dry-run** : permet de détecter des erreurs avant impact prod
2. ❌ **Ne PAS importer avec rôles directement** : importer d'abord, attribuer ensuite (cf. D6)
3. ❌ **Ne PAS donner IAM_OWNER au service account** : utiliser IAM_USER_MANAGER
4. ❌ **Ne PAS oublier de supprimer le CSV** après distribution des mdps
5. ❌ **Ne PAS coller les identifiants Furious** dans une conversation IA
---
## 🔄 Synchronisation périodique (à mettre en place)
Aujourd'hui, l'import est **manuel** (one-shot du 5 juin). À l'avenir :
### Option recommandée : workflow n8n quotidien
Workflow n8n à 3h du matin :
1. **HTTP Request** vers Furious : récupérer la liste des users
2. **HTTP Request** vers Zitadel : récupérer la liste des users actuels
3. **Code node** : calculer le diff
4. **Switch** :
- Nouveau dans Furious → POST Zitadel pour créer
- Départ dans Furious (departure_date != "0000-00-00") → PATCH Zitadel pour désactiver
- Changement job_title → PATCH metadata + réattribuer rôle
5. **Slack/Teams notification** : résumé du sync
⏱️ Estimation : 1 journée de dev n8n.
---
## 🐛 Troubleshooting import
### "Erreur 401 lors de l'auth Zitadel"
Causes possibles :
1. Clé JSON corrompue → re-télécharger depuis Zitadel
2. Service account n'a plus le rôle IAM_USER_MANAGER → re-attribuer
3. Token JWT mal généré → vérifier que `pyjwt` est bien installé
### "User déjà existant" lors de l'import
Normal pour les doublons. Le script `import-furious-to-zitadel.py` skip les users existants par défaut.
### "Erreur 400 sur certains users"
Causes courantes :
1. **Email invalide** (espace, caractères spéciaux) dans Furious → corriger dans Furious
2. **Mot de passe ne respecte pas la policy** → le script génère mdp 16 chars random
3. **Username déjà pris** → choisir un autre format (email complet par exemple)
### "Le script lit les bonnes données mais Zitadel ne les voit pas"
Vérifier la **réponse HTTP** retournée par chaque création :
```bash
python3 import-furious-to-zitadel.py --mode test --verbose
```
---
## 📚 Voir aussi
- [07-rbac-intranet.md](./07-rbac-intranet.md) — Suite de l'import : attribution des rôles RBAC
- [03-zitadel-setup.md](./03-zitadel-setup.md) — Création du service account Zitadel
- [../matrices/job-title-to-role.csv](../matrices/job-title-to-role.csv) — Matrice de mapping

403
docs/07-rbac-intranet.md Normal file
View File

@ -0,0 +1,403 @@
# 07 — RBAC sur l'intranet (Approche A)
> Comment l'intranet filtre les cartes affichées selon le rôle du user.
---
## 🎯 Principe
**Approche A** : le filtrage RBAC se fait **côté intranet uniquement**, pas sur chaque outil.
### Comment ça marche
```
1. User se logge sur intranet.seizeconsulting.com
│ OAuth2-Proxy fait l'auth Zitadel
2. nginx reçoit la requête avec header X-Forwarded-Email
│ sub_filter remplace __USER_EMAIL__ par hedi.kalboussi@...
3. Browser reçoit l'HTML avec const USER_EMAIL = "hedi.kalboussi@..."
│ JavaScript fetch /roles-mapping.json
4. JS détermine le rôle : "consultant_junior"
│ JS itère sur les cartes <a class="service-card" data-app="...">
5. JS cache les cartes que ce rôle n'a pas le droit de voir
6. User voit uniquement Gitea + Code Server
```
### Limite acceptée
⚠️ **Un user qui connaît `n8n.seizeconsulting.com` peut bypass le filtre** (l'outil reste accessible via URL directe). C'est acceptable pour Seize (88 employés non-malveillants). Pour un vrai RBAC, voir [DECISIONS.md D7](../DECISIONS.md).
---
## 📁 Structure des fichiers
```
~/docker/intranet/
├── docker-compose.yml
├── nginx-rbac.conf # ⭐ Config nginx avec sub_filter
├── index.html # ⭐ HTML avec data-app + JS RBAC
├── roles-mapping.json # ⭐ Mapping email → rôle (généré)
└── logo.png
```
---
## 📄 Config nginx (nginx-rbac.conf)
```nginx
events {}
http {
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Désactiver le cache pour l'HTML
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
# ⭐ Substituer __USER_EMAIL__ par la vraie valeur du header HTTP
sub_filter '__USER_EMAIL__' '$http_x_forwarded_email';
sub_filter_once off;
sub_filter_types text/html;
}
# Pareil pour la racine /
location = / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
sub_filter '__USER_EMAIL__' '$http_x_forwarded_email';
sub_filter_once off;
sub_filter_types text/html;
try_files /index.html =404;
}
# Le mapping JSON doit être servi normalement
location = /roles-mapping.json {
add_header Cache-Control "no-store";
}
# Tout le reste (logo, favicon, etc.)
location / {
try_files $uri $uri/ =404;
}
}
}
```
### Pourquoi sub_filter ?
- `$http_x_forwarded_email` lit le header HTTP envoyé par OAuth2-Proxy
- `sub_filter` substitue `__USER_EMAIL__` par cette valeur dans le HTML servi
- `sub_filter_once off` : remplace **toutes les occurrences** (pas juste la 1ère)
- `Cache-Control: no-store` : crucial pour que le HTML soit re-généré à chaque requête (sinon un user reçoit l'email d'un autre)
---
## 📄 HTML — Structure de chaque carte
```html
<a class="service-card"
data-app="portainer" ← ⭐ ATTRIBUT RBAC
data-color="teal"
href="https://portainer.seizeconsulting.com"
target="_blank" rel="noopener">
<div class="card-header">
<div class="card-icon"><!-- SVG --></div>
<div class="card-arrow"><!-- SVG --></div>
</div>
<div class="card-body">
<div class="card-title">Portainer</div>
<div class="card-desc">Console de gestion des conteneurs Docker.</div>
<div class="card-meta">
<span class="card-url">portainer.seizeconsulting.com</span>
<span class="card-tag">Infra</span>
</div>
</div>
</a>
```
→ Chaque carte a un attribut **`data-app="<nom>"`** que le JavaScript utilise pour la matcher avec la matrice RBAC.
---
## 📄 JavaScript RBAC (à la fin de index.html)
```html
<script>
/* ===== RBAC INTRANET — Filtrage cartes selon rôle Zitadel ===== */
(async function() {
const USER_EMAIL = "__USER_EMAIL__"; // ← Remplacé par nginx sub_filter
/* Matrice RBAC : qui voit quelle carte */
const ACCESS_MATRIX = {
"portainer": ["direction", "manager"],
"status": ["direction"],
"n8n": ["direction", "manager", "expert"],
"gitea": ["direction", "manager", "expert", "consultant_senior", "consultant_junior", "alternant", "stagiaire", "support"],
"code": ["direction", "manager", "expert", "consultant_senior", "consultant_junior", "alternant", "stagiaire", "support"],
"traefik": ["direction"]
};
let userRole = null;
try {
const response = await fetch("/roles-mapping.json", { cache: "no-store" });
if (response.ok) {
const mapping = await response.json();
userRole = mapping[USER_EMAIL.toLowerCase()] || null;
}
} catch (e) {
console.error("[RBAC] Erreur chargement mapping:", e);
}
console.log("[RBAC] User:", USER_EMAIL, "| Rôle:", userRole);
/* Filtrer les cartes selon le rôle */
document.querySelectorAll(".service-card[data-app]").forEach(card => {
const app = card.dataset.app;
const allowedRoles = ACCESS_MATRIX[app] || [];
if (!userRole || !allowedRoles.includes(userRole)) {
card.style.display = "none";
}
});
})();
</script>
```
---
## 🎭 Création des 8 rôles dans Zitadel
### Étape 1 — Aller dans le project
Console Zitadel → Projects → **Infra Seize** → Roles → New
### Étape 2 — Créer un par un
| Key | Display Name | Group |
|---|---|---|
| `direction` | Direction | hierarchie |
| `manager` | Manager | hierarchie |
| `expert` | Expert | hierarchie |
| `consultant_senior` | Consultant Senior | hierarchie |
| `consultant_junior` | Consultant Junior | hierarchie |
| `alternant` | Alternant | hierarchie |
| `stagiaire` | Stagiaire | hierarchie |
| `support` | Support | hierarchie |
### Étape 3 — Activer "Return user roles during authentication"
Console Zitadel → Projects → **Infra Seize** → General → Settings :
- ☑️ **Return user roles during authentication**
- ❌ **Only authorized users can authenticate** (à laisser DÉCOCHÉ — cf. [D9](../DECISIONS.md))
- ❌ **Authentication restricted to authorized orgs**
### Étape 4 — Configurer chaque App pour transmettre les rôles
Pour chaque application (Intranet, n8n, Portainer, etc.) :
Console Zitadel → Projects → Infra Seize → Applications → `<App>` → Token Settings :
- Auth Token Type : **JWT** (pas Bearer Token)
- ☑️ User roles inside ID Token
- ☑️ Include user's profile info in the ID Token
- ☑️ Add user roles to the access token
**Save**.
---
## 📊 Matrice RBAC effective
| Outil | direction | manager | expert | cons_sr | cons_jr | alternant | stagiaire | support |
|---|---|---|---|---|---|---|---|---|
| **Intranet** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Gitea** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Code Server** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **n8n** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Portainer** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Uptime Kuma (Status)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Traefik Dashboard** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
Voir aussi `matrices/rbac-matrix.md` pour la vue détaillée.
---
## 🔄 OAuth2-Proxy — Activer le scope des rôles
Modifier le `.env` de **chaque** OAuth2-Proxy pour demander le scope des rôles :
```env
OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles
```
Puis restart :
```bash
cd ~/docker/oauth2-proxy-<outil>
docker compose down
docker compose up -d
```
---
## 🎬 Workflow complet : du recrutement au compte fonctionnel
```
1. Nouveau collaborateur arrive
2. RH crée la fiche dans Furious Squad (job_title, manager, BU, etc.)
│ Le jour J ou J+1
3. Sync Furious → Zitadel
- Soit manuel : python3 import-furious-to-zitadel.py
- Soit auto : workflow n8n (à mettre en place)
4. Génération du mapping : python3 generate-roles-mapping.py
→ roles-mapping.json mis à jour
5. Copie vers l'intranet : cp roles-mapping.json ~/docker/intranet/
→ nginx ne nécessite pas de restart (volume monté)
6. Attribution du rôle : python3 assign-roles.py
→ Le user reçoit son rôle dans Zitadel
7. Distribution du mdp temporaire via Teams
8. User se logge → voit les bonnes cartes selon son rôle
```
---
## 🐛 Troubleshooting RBAC
### "Toutes les cartes sont visibles, le filtrage ne marche pas"
#### Vérification 1 : le sub_filter nginx fonctionne ?
```bash
# Depuis le serveur, vérifier que le HTML rendu contient le bon email
docker exec intranet sh -c "curl -s -H 'X-Forwarded-Email: test@seizeconsulting.com' http://localhost/ | grep 'USER_EMAIL'"
# Doit afficher : const USER_EMAIL = "test@seizeconsulting.com";
```
Si le HTML contient encore `__USER_EMAIL__` :
- Vérifier que `nginx-rbac.conf` est bien monté dans le conteneur
- Vérifier les logs nginx : `docker logs intranet`
#### Vérification 2 : roles-mapping.json est chargé ?
Ouvrir la console JS du browser (F12) :
```
[RBAC] User: xxx | Rôle: xxx
```
Si Rôle = `null` :
- Vérifier que l'email est bien dans roles-mapping.json
- Attention à la casse (le script fait `.toLowerCase()`)
#### Vérification 3 : la matrice JS est correcte ?
Dans la console :
```js
document.querySelectorAll('.service-card[data-app]').forEach(c => console.log(c.dataset.app, c.style.display));
```
Doit afficher chaque carte avec son `display: none` ou `display: ""`.
### "Le user voit __USER_EMAIL__ littéralement"
Cause : nginx ne fait pas le sub_filter.
Solutions :
1. Vérifier que `nginx-rbac.conf` est bien monté en RO sur `/etc/nginx/nginx.conf` (pas un autre chemin)
2. Vérifier que le header `X-Forwarded-Email` arrive bien (debug : tail logs nginx)
3. Restart : `docker compose down && docker compose up -d`
### "Le user voit ses cartes mais quand il clique sur une carte non-autorisée, il y accède quand même"
C'est **normal** et c'est la limite acceptée de l'Approche A.
Pour vrai RBAC, voir [DECISIONS.md D7](../DECISIONS.md).
### "Un user vient d'être ajouté dans Furious mais ne voit aucune carte"
Causes :
1. roles-mapping.json pas à jour → relancer `generate-roles-mapping.py` + copier
2. User pas encore dans Zitadel → relancer l'import
3. job_title pas dans le mapping → ajouter dans `JOB_TITLE_TO_ROLE` du script
---
## 🔄 Mise à jour de la matrice RBAC
### Cas 1 : Ajouter un nouvel outil
1. Ajouter la carte HTML dans `index.html` avec `data-app="newapp"`
2. Ajouter la ligne dans `ACCESS_MATRIX` :
```javascript
"newapp": ["direction", "manager"],
```
3. Restart : `cd ~/docker/intranet && docker compose restart`
### Cas 2 : Changer les autorisations d'un outil
1. Modifier la liste de rôles dans `ACCESS_MATRIX` du JS
2. Restart : `docker compose restart`
3. Tester avec différents profils
### Cas 3 : Ajouter un nouveau rôle
1. Créer le rôle dans Zitadel (Projects → Roles → New)
2. Mettre à jour le mapping dans `assign-roles.py` (variable `JOB_TITLE_TO_ROLE`)
3. Mettre à jour `ACCESS_MATRIX` dans le JS pour préciser ce que ce rôle voit
4. Lancer `generate-roles-mapping.py` + `assign-roles.py`
5. Copier `roles-mapping.json` vers l'intranet
6. Restart intranet
---
## 🎨 Personnalisation visuelle
Le CSS de l'intranet est dans `<style>` au début d'`index.html`. Couleurs principales :
```css
:root {
--c-bg-deep: #071C1C; /* Fond sombre */
--c-accent: #008A81; /* Vert Seize */
--c-accent-bright: #00C9B7;
--c-mint: #A9D4D3;
--c-lime: #F3FBBA;
--c-cream: #FBFBF4;
}
```
Pour changer le logo : remplacer `~/docker/intranet/logo.png`.
---
## 📚 Voir aussi
- [06-import-furious.md](./06-import-furious.md) — Import des users (étape préalable)
- [04-oauth2-proxy-pattern.md](./04-oauth2-proxy-pattern.md) — Comment OAuth2-Proxy transmet les headers
- [DECISIONS.md D7](../DECISIONS.md) — Pourquoi Approche A
- [matrices/rbac-matrix.md](../matrices/rbac-matrix.md) — Matrice complète
- [matrices/job-title-to-role.csv](../matrices/job-title-to-role.csv) — Mapping Furious → rôle

View File

@ -0,0 +1,314 @@
# 08 — Gestion des secrets
> Bonnes pratiques pour les secrets de l'infrastructure : où ils sont, comment les rotater, et comment ne pas les exposer.
---
## 🔐 Inventaire des secrets
### Secrets stockés sur le serveur
| Type | Localisation | Criticité | Rotation recommandée |
|---|---|---|---|
| Mdp Postgres principal | `~/docker/postgres/.env` | 🔴 Critique | 6 mois |
| Mdp Postgres Zitadel | `~/docker/zitadel/.env` | 🔴 Critique | 6 mois |
| Zitadel MASTERKEY | `~/docker/zitadel/.env` | 🔴⛔ IMMUABLE | Jamais |
| Mdp Gitea DB | `~/docker/gitea/.env` | 🟡 Important | 6 mois |
| Mdp n8n DB | `~/docker/n8n/.env` | 🟡 Important | 6 mois |
| n8n Encryption Key | `~/docker/n8n/.env` | 🔴 Critique | Jamais (perte = perte workflows chiffrés) |
| Client Secret OAuth2 (par outil) | `~/docker/oauth2-proxy-*/env` | 🟡 Important | 6 mois |
| Cookie Secret OAuth2 | `~/docker/oauth2-proxy-*/env` | 🟢 Mineur | 1 an (déconnecte les users) |
| Code Server password (fallback) | `~/docker/code-server/.env` | 🟢 Mineur | 6 mois |
| Furious API credentials | `~/furious-to-zitadel/.env` | 🟡 Important | 6 mois |
| Zitadel Service Account key | `~/furious-to-zitadel/zitadelfuriousimportkey.json` | 🔴 Critique | 1 an (avec expiration prévue) |
| Traefik basic auth | `~/docker/traefik/docker-compose.yml` (label) | 🟡 Important | 6 mois |
### Secrets stockés ailleurs
| Secret | Localisation |
|---|---|
| Mdp utilisateurs Zitadel | Dans la base Postgres Zitadel (hashés) |
| Tokens de session OAuth2-Proxy | Dans les cookies HTTP des navigateurs des users |
| Certificats Let's Encrypt | `~/docker/traefik/letsencrypt/acme.json` (chmod 600) |
---
## 🔒 Bonnes pratiques
### 1. Toujours utiliser un gestionnaire de mots de passe
Pour stocker **tous** les secrets de l'infra :
- **Bitwarden** (cloud + self-hosted possible) ← recommandé
- **KeePassXC** (offline)
- **1Password Business**
Créer une **collection partagée "Seize Infra"** accessible aux mainteneurs (Hedi, Philippe, futur collègue).
### 2. Permissions Linux strictes
Tous les fichiers contenant des secrets doivent avoir `chmod 600` :
```bash
# Vérifier toutes les permissions de .env
find ~/docker -name ".env" -exec ls -la {} \;
# Toutes doivent montrer "-rw-------" (600)
# Vérifier la clé Zitadel
ls -la ~/furious-to-zitadel/zitadelfuriousimportkey.json
# Doit être 600
```
Corriger si nécessaire :
```bash
chmod 600 ~/docker/<service>/.env
```
### 3. Ne JAMAIS committer de secret sur Git
Configurer `.gitignore` dans tous les dépôts :
```gitignore
# Secrets
.env
*.env
*.key
*.pem
*.p12
zitadelfuriousimportkey.json
users-credentials.csv
distribution-teams.csv
roles-mapping.json
letsencrypt/acme.json
# Logs (peuvent contenir des secrets)
*.log
import.log
```
### 4. Ne JAMAIS coller de secret dans une conversation IA
Si tu colles un token, mdp, ou clé dans Claude / ChatGPT / autre, considère-le **compromis** :
- Il peut être logué côté provider
- Il peut être utilisé pour entraîner des modèles
- L'historique est conservé pendant des mois
**Toujours masquer** les secrets (ex: `XXX_REDACTED_XXX`) avant de coller du contenu.
⚠️ Voir [HANDOVER.md](../HANDOVER.md) pour la liste des secrets qui ont été exposés pendant le développement et qui doivent être rotatés.
### 5. Audit des accès aux secrets
Périodiquement, vérifier :
- Qui a accès au gestionnaire de mots de passe ?
- Y a-t-il des anciens employés à révoquer ?
- Les permissions Linux sont-elles toujours OK ?
---
## 🔄 Procédures de rotation
### Rotation Client Secret OAuth2 (par outil)
⏱️ ~5 min par outil.
```bash
# 1. Aller dans Zitadel Console
# → Projects → Infra Seize → Applications → <outil>
# → Actions → Generate Client Secret → copier la nouvelle valeur
# 2. Mettre à jour le .env d'OAuth2-Proxy
nano ~/docker/oauth2-proxy-<outil>/.env
# Remplacer OAUTH2_PROXY_CLIENT_SECRET=...
# 3. Sauvegarder dans le gestionnaire de mdp
# 4. Restart OAuth2-Proxy
cd ~/docker/oauth2-proxy-<outil>
docker compose down
docker compose up -d
# 5. Tester le SSO en incognito
```
### Rotation Postgres password (Gitea ou n8n)
⏱️ ~10 min. **Hors heures de bureau** (le service s'arrête brièvement).
```bash
# 1. Générer nouveau mdp
NEW_PWD=$(openssl rand -base64 32 | tr -d '+/=' | head -c 32)
echo "Nouveau mdp : $NEW_PWD"
# Sauvegarder dans le gestionnaire
# 2. Changer le mdp dans Postgres
docker exec -it postgres psql -U postgres -c "ALTER USER gitea WITH PASSWORD '$NEW_PWD';"
# 3. Mettre à jour le .env du service
nano ~/docker/gitea/.env
# Remplacer GITEA_DB_PASSWORD=...
# 4. Restart
cd ~/docker/gitea
docker compose down
docker compose up -d
# 5. Tester
curl -sI https://git.seizeconsulting.com
```
### Rotation Zitadel Service Account key
⏱️ ~10 min.
```bash
# 1. Console Zitadel → Users → furious-import-service → Keys
# → Créer une nouvelle clé (Type JSON)
# → Télécharger immédiatement
# → Supprimer l'ancienne clé (pour l'invalider)
# 2. Transférer sur le serveur
scp <nouvelle_cle>.json admin@<server>:~/furious-to-zitadel/zitadelfuriousimportkey.json
# 3. Sécuriser
ssh admin@<server>
chmod 600 ~/furious-to-zitadel/zitadelfuriousimportkey.json
# 4. Tester les scripts
cd ~/furious-to-zitadel
source venv/bin/activate
python3 assign-roles.py --dry-run # ou un autre script sans effet
```
### Rotation Furious API credentials
```bash
# 1. Sur Furious : Configuration → Utilisateurs API
# → Supprimer l'ancien compte
# → Créer un nouveau avec les mêmes permissions (User > Search uniquement)
# → Sauvegarder identifiants dans le gestionnaire de mdp
# 2. Mettre à jour le .env
nano ~/furious-to-zitadel/.env
# Remplacer FURIOUS_USERNAME et FURIOUS_PASSWORD
# 3. Tester
cd ~/furious-to-zitadel
source venv/bin/activate
./test-auth.sh
```
### ⛔ ZITADEL_MASTERKEY — IMMUABLE
⚠️ **NE JAMAIS CHANGER** le `ZITADEL_MASTERKEY` après le premier démarrage de Zitadel.
Cette clé est utilisée pour chiffrer/déchiffrer les données sensibles en base. La changer = perdre l'accès à toutes les données.
Si elle a été exposée → option drastique uniquement :
1. Backup complet des données critiques (export users via API)
2. Détruire l'instance Zitadel
3. Re-déployer from scratch avec une nouvelle MASTERKEY
4. Restore les users via les scripts d'import
---
## 📧 Configuration SMTP M365 (à faire)
⚠️ **Pas encore fait** au moment de la passation.
### Prérequis
- Admin M365 (pas Hedi → solliciter quelqu'un d'autre)
- Compte M365 dédié : `noreply@seizeconsulting.com`
### Étape 1 — Créer le compte M365
Demander à l'admin M365 :
1. Créer le compte `noreply@seizeconsulting.com`
2. Activer SMTP AUTH (peut nécessiter ticket Microsoft)
3. Désactiver MFA sur ce compte (incompatible avec SMTP)
4. Générer un **App Password** pour ce compte
### Étape 2 — Configurer dans Zitadel
Console Zitadel → Default Settings → Notifications → SMTP Provider :
| Paramètre | Valeur |
|---|---|
| Host | `smtp.office365.com` |
| Port | `587` |
| User | `noreply@seizeconsulting.com` |
| Password | `<App Password généré>` |
| From | `noreply@seizeconsulting.com` |
| From Name | `Seize Consulting` |
| Reply-to | (vide ou `it@seizeconsulting.com`) |
| TLS | ✅ Required |
### Étape 3 — Tester
Console Zitadel → Default Settings → SMTP → **Test Email**.
→ Si OK, on peut désormais utiliser :
- Welcome emails pour nouveaux users
- Reset password
- MFA Email codes
- Notifications de sécurité
---
## 🔍 Audit des secrets exposés
Pendant le développement (cf. [HANDOVER.md](../HANDOVER.md)), plusieurs secrets ont été exposés dans des conversations IA :
| Secret | Statut | Action requise |
|---|---|---|
| Compte API Furious `mick.emmaus.90849` | 🔴 Exposé | **Rotater dans Furious** |
| ID API Furious `6a0b0396817841ef2d3c87c8` | 🔴 Exposé | **Rotater dans Furious** |
| Token JWT user HKA (1 instance) | 🟡 Expiré | OK (1h de vie) |
| Noms + emails + job_titles ~10 collaborateurs | 🟡 Exposé | Pas de rotation possible, vigilance |
### Procédure post-incident
1. Rotater immédiatement le secret (procédures ci-dessus)
2. Vérifier les **logs d'accès** Zitadel/Furious pour détecter usage suspect
3. Documenter dans un fichier sécurité (qui, quand, quoi)
4. Si données personnelles exposées → notification CNIL si applicable
---
## 🛡️ Hardening complémentaire
### Activer fail2ban (recommandé)
Pour bloquer automatiquement les IPs malveillantes qui scannent SSH ou OAuth2 :
```bash
sudo apt install -y fail2ban
# Config par défaut OK pour SSH
sudo systemctl enable --now fail2ban
# Vérifier
sudo fail2ban-client status sshd
```
### Activer Crowdsec (alternative moderne)
```bash
curl -s https://install.crowdsec.net | sudo sh
# Puis configurer les bouncers (cf-firewall-bouncer, etc.)
```
### Monitoring des tentatives de login échouées Zitadel
À mettre en place via :
- Workflow n8n qui scrape les logs Zitadel toutes les heures
- Alert si > 50 échecs en 1h (potentiellement attaque par brute force)
---
## 📚 Voir aussi
- [HANDOVER.md](../HANDOVER.md) — Liste des secrets exposés à rotater en priorité
- [03-zitadel-setup.md](./03-zitadel-setup.md) — Configuration du service account
- [04-oauth2-proxy-pattern.md](./04-oauth2-proxy-pattern.md) — Rotation des secrets OAuth2
- [OPERATIONS.md](../OPERATIONS.md) — Procédures complètes de rotation

486
docs/09-troubleshooting.md Normal file
View File

@ -0,0 +1,486 @@
# 09 — Troubleshooting
> Problèmes fréquents rencontrés et leurs solutions.
---
## 🔍 Démarrage de tout diagnostic
Avant d'essayer n'importe quoi, vérifier :
```bash
# 1. Conteneurs en route
sudo docker ps
# 2. Espace disque
df -h /
# 3. Mémoire libre
free -h
# 4. Charge système
uptime
# 5. Logs Traefik récents (souvent la source des problèmes)
sudo docker logs --tail 50 traefik
```
---
## 🔴 Problèmes critiques
### "Tout est down, je ne peux plus accéder à rien"
#### Diagnostic
```bash
# SSH au serveur
ssh hedi@<server>
# Vérifier Docker
sudo systemctl status docker
sudo docker ps | wc -l
# Doit afficher > 20 (24 conteneurs attendus)
# Vérifier ports 80/443
sudo netstat -tlnp | grep -E "80|443"
# Vérifier les certificats Let's Encrypt
sudo ls -la ~/docker/traefik/letsencrypt/
```
#### Causes possibles
**Cause 1 — Docker daemon down** :
```bash
sudo systemctl restart docker
sudo docker compose -f ~/docker/traefik/docker-compose.yml up -d
# Démarrer dans l'ordre : Traefik, puis Zitadel, puis les autres
```
**Cause 2 — Disque plein** :
```bash
# Nettoyer Docker
sudo docker system prune -a --volumes
# Nettoyer logs si > 1 Go
sudo du -sh /var/lib/docker/containers/*/
sudo truncate -s 0 /var/lib/docker/containers/*/*-json.log
```
**Cause 3 — IP du serveur a changé** :
- Vérifier DNS : `dig +short sso.seizeconsulting.com`
- Si IP différente → mettre à jour DNS chez le registrar
---
### "Le SSO ne fonctionne plus depuis 5 minutes"
Symptôme : tous les outils SSO renvoient une boucle de redirection ou un "Bad Gateway".
#### Diagnostic — Vérifier dans cet ordre
**1. Zitadel répond ?**
```bash
curl -sI https://sso.seizeconsulting.com
# Doit retourner 200 ou 302
```
Si non : `docker logs zitadel-zitadel-api-1 --tail 50`
**2. Traefik ForwardAuth marche ?**
```bash
grep -A 8 "authRequestHeaders" ~/docker/traefik/dynamic.yml
# La ligne "- Cookie" doit être présente
```
**3. OAuth2-Proxy de l'outil tourne ?**
```bash
sudo docker ps | grep oauth2-proxy
# Tous doivent être Up
```
**4. Logs OAuth2-Proxy ?**
```bash
sudo docker logs --tail 50 oauth2-proxy
sudo docker logs --tail 50 oauth2-proxy-n8n
# Chercher : "error", "unauthorized", "context deadline exceeded"
```
#### Solutions courantes
**Solution 1** : Restart de Traefik
```bash
cd ~/docker/traefik
docker compose down
docker compose up -d
```
**Solution 2** : Restart d'OAuth2-Proxy de l'outil concerné
```bash
cd ~/docker/oauth2-proxy-<outil>
docker compose down
docker compose up -d
```
**Solution 3** : Vérifier que les certificats TLS sont OK
```bash
echo | openssl s_client -connect sso.seizeconsulting.com:443 2>/dev/null | openssl x509 -noout -dates
# notAfter doit être dans > 7 jours
```
---
### "Le bug Traefik Cookie est revenu (boucle de redirect)"
C'est **le** bug historique. Cf. [DECISIONS.md D3](../DECISIONS.md).
#### Fix immédiat
Vérifier que `~/docker/traefik/dynamic.yml` contient :
```yaml
http:
middlewares:
oauth2-headers:
forwardAuth:
address: "http://oauth2-proxy:4180/oauth2/auth"
authRequestHeaders:
- Cookie # ← CRITIQUE
- Authorization
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
```
Si la ligne `- Cookie` manque → l'ajouter, puis :
```bash
cd ~/docker/traefik
docker compose restart
```
---
## 🟡 Problèmes courants
### "Le certificat Let's Encrypt va expirer / a expiré"
Traefik renouvelle automatiquement. Si pas le cas :
```bash
# Vérifier les logs Let's Encrypt
sudo docker logs traefik 2>&1 | grep -i "letsencrypt\|acme" | tail -20
# Causes possibles :
# 1. Rate limit Let's Encrypt (trop de tentatives) → attendre 1h
# 2. DNS pas correct → vérifier dig
# 3. Port 80 bloqué → vérifier firewall
# Forcer le renouvellement
cd ~/docker/traefik
docker compose down
docker compose up -d
```
⚠️ Cas critique : `acme.json` corrompu :
```bash
sudo cp letsencrypt/acme.json letsencrypt/acme.json.backup
sudo rm letsencrypt/acme.json
sudo touch letsencrypt/acme.json
sudo chmod 600 letsencrypt/acme.json
docker compose restart
```
→ Recrée tous les certificats (peut prendre 5 min).
---
### "Un user ne peut pas se logger"
#### Vérifier dans cet ordre
**1. Le compte existe-t-il dans Zitadel ?**
- Console Zitadel → Users → Search par email
- Status doit être "Active" (pas "Inactive" ou "Deleted")
**2. L'email est-il vérifié ?**
- Console → User → General → Email verified ?
- Si non : Actions → Resend verification (mais SMTP doit être configuré)
- Ou : Set as verified manually
**3. Le MFA est-il bloqué ?**
- Si l'user a perdu son téléphone : Reset MFA
- Console → User → Multi-factor authentication → Remove
**4. Lockout actif ?**
- Console → User → Activity → vérifier les failed attempts
- Reset : Actions → Unlock
**5. L'user a-t-il un rôle ?**
- Si "Only authorized users" est coché sur le project, il faut un rôle
- Console → User → Authorizations → Add role
---
### "Le script Python d'import échoue"
#### Erreur 401 / 403
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
# Vérifier la clé JSON
python3 -c "
import json
data = json.load(open('zitadelfuriousimportkey.json'))
print('UserId:', data.get('userId'))
print('KeyId:', data.get('keyId'))
"
# Si erreur → la clé est corrompue, en régénérer une
# Vérifier les permissions du service account
# Console Zitadel → Users → furious-import-service → IAM Members
# Doit avoir IAM_USER_MANAGER
```
#### Erreur 400 sur certains users
```bash
# Voir les détails dans le log
tail -100 import.log | grep -B 2 "ERROR"
# Causes courantes :
# - Email invalide dans Furious (espaces, caractères spéciaux)
# - Mdp trop court / ne respecte pas la policy
# - Username déjà existant
```
#### Erreur de connexion Furious
```bash
./test-auth.sh
# Doit retourner un JSON valide
# Si 401 → identifiants Furious à régénérer
# Si timeout → API Furious peut être down, vérifier status
```
---
### "L'intranet ne montre pas les bonnes cartes"
Voir [07-rbac-intranet.md](./07-rbac-intranet.md) section Troubleshooting.
Rapide :
1. Console JS (F12) → vérifier le log `[RBAC] User: ... | Rôle: ...`
2. Si rôle null → `roles-mapping.json` à régénérer
3. Si email = `__USER_EMAIL__` → nginx sub_filter pas appliqué
---
### "Le conteneur xxx redémarre en boucle"
```bash
# Voir les logs
sudo docker logs <container> --tail 50
# Voir le statut détaillé
sudo docker inspect <container> --format '{{.State.Health}}'
# Causes courantes :
# 1. Healthcheck failed → vérifier la commande de healthcheck
# 2. Erreur de config (mauvais env) → vérifier .env
# 3. Volume manquant ou permissions → vérifier les paths
# 4. Port déjà utilisé → docker compose down + up
# Voir les events Docker récents
sudo docker events --since 10m
```
---
### "Espace disque saturé"
```bash
# Voir où va l'espace
sudo du -sh /var/lib/docker/* | sort -h
sudo du -sh /home/*
# Nettoyer Docker
sudo docker system prune -a --volumes
# ⚠️ Supprime tout ce qui n'est pas utilisé activement
# Nettoyer les logs Docker
sudo find /var/lib/docker/containers -name "*-json.log" -exec truncate -s 0 {} \;
# Configurer la rotation des logs (à mettre dans /etc/docker/daemon.json) :
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "3"
}
}
# Restart Docker après modif
sudo systemctl restart docker
```
---
## 🟢 Problèmes mineurs
### "L'image d'un conteneur est obsolète"
```bash
cd ~/docker/<service>
docker compose pull
docker compose up -d
```
### "Comment voir les logs en temps réel ?"
```bash
docker compose logs -f
# Ctrl+C pour quitter
```
### "Comment redémarrer juste un service sans toucher les autres ?"
```bash
sudo docker restart <container_name>
# Plus rapide qu'un docker compose
```
### "Comment exécuter une commande dans un conteneur ?"
```bash
sudo docker exec -it <container> sh
# ou bash si dispo
```
---
## 🔧 Débogage avancé
### Nginx Diagnostic (debug RBAC)
⚠️ **Procédure temporaire** — supprimer immédiatement après usage.
Pour voir ce que les headers HTTP contiennent réellement :
```bash
# Créer une config nginx diagnostic
cat > ~/docker/intranet/nginx-diagnostic.conf << 'EOF'
events {}
http {
server {
listen 80;
location /debug {
add_header Content-Type text/plain;
return 200 "=== Headers reçus ===
X-Forwarded-User: $http_x_forwarded_user
X-Forwarded-Email: $http_x_forwarded_email
X-Forwarded-Preferred-Username: $http_x_forwarded_preferred_username
X-Forwarded-Groups: $http_x_forwarded_groups
Authorization: $http_authorization
";
}
location / { try_files $uri /index.html =404; }
}
}
EOF
# Modifier docker-compose pour utiliser cette config
nano ~/docker/intranet/docker-compose.yml
# Ajouter le volume : ./nginx-diagnostic.conf:/etc/nginx/nginx.conf:ro
# Restart
docker compose up -d
# Accéder à : https://intranet.seizeconsulting.com/debug
# (en étant logué)
# ⚠️ AVANT DE FERMER : restaurer la config normale !
rm ~/docker/intranet/nginx-diagnostic.conf
# Remettre nginx-rbac.conf dans docker-compose.yml
```
### Décoder un token JWT
Si tu as un token et veux voir son contenu (claims, exp, etc.) :
```bash
# Méthode 1 : jwt.io (en ligne, ATTENTION ne pas coller de vrais tokens)
# Méthode 2 : ligne de commande
TOKEN="<ton_jwt>"
echo $TOKEN | cut -d "." -f2 | base64 -d 2>/dev/null | python3 -m json.tool
```
### Vérifier la communication réseau entre 2 conteneurs
```bash
sudo docker exec -it traefik wget -qO- http://intranet:80
sudo docker exec -it oauth2-proxy wget -qO- http://intranet:80
```
Si erreur → vérifier que les conteneurs partagent un réseau Docker commun.
### Voir les connexions actives
```bash
sudo ss -tunlp | grep -E "80|443"
# Connexions en cours
sudo netstat -anp | grep ESTABLISHED | grep ":443" | wc -l
```
---
## 📋 Checklist quand quelque chose ne va pas
- [ ] Tous les conteneurs `Up` ? → `sudo docker ps`
- [ ] DNS OK ? → `dig +short <domain>`
- [ ] HTTPS valide ? → `curl -sI https://<domain>`
- [ ] Logs Traefik sans erreur ? → `docker logs --tail 50 traefik | grep -i error`
- [ ] Logs OAuth2-Proxy sans erreur ? → `docker logs --tail 50 oauth2-proxy*`
- [ ] Espace disque OK ? → `df -h /`
- [ ] Mémoire OK ? → `free -h`
- [ ] Authentification Zitadel OK depuis incognito ?
- [ ] Vérifier que rien n'a été modifié récemment (fichier .yml ou .env)
---
## 📞 Quand escalader
### Vers Philippe (sponsor)
- Service critique down > 30 min
- Demande de modification d'architecture
- Validation d'une décision business
### Vers le support Scaleway
- Serveur inaccessible (panne réseau ou hardware)
- Quota dépassé sur le compte
- Problème de facturation
### Vers le support Furious
- API Furious down ou très lente
- Permissions API qui ne fonctionnent pas malgré une bonne config
- Problèmes de données RH (départs mal renseignés, etc.)
### Vers la communauté Zitadel
- Discord Zitadel : https://discord.gg/zitadel
- Issues GitHub : https://github.com/zitadel/zitadel
- Forum : https://community.zitadel.com/
---
## 📚 Liens vers les docs internes
- [INSTALL.md](../INSTALL.md) — Procédure d'install from scratch
- [OPERATIONS.md](../OPERATIONS.md) — Maintenance et opérations courantes
- [HANDOVER.md](../HANDOVER.md) — Actions urgentes à mener
- [DECISIONS.md](../DECISIONS.md) — Anti-patterns à éviter

468
install.sh Executable file
View File

@ -0,0 +1,468 @@
#!/bin/bash
# ============================================================================
# install.sh — Déploiement infrastructure Seize from scratch
# ============================================================================
# Usage : ./install.sh [étape]
# Sans argument : affiche le menu
# Avec étape : lance directement cette étape
#
# Étapes :
# 1) check - Vérifications pré-install (DNS, Docker, etc.)
# 2) networks - Créer les réseaux Docker
# 3) traefik - Déployer Traefik
# 4) zitadel - Déployer Zitadel
# 5) postgres - Déployer Postgres principal
# 6) gitea - Déployer Gitea
# 7) intranet - Déployer l'intranet
# 8) oauth2-proxy - Déployer un OAuth2-Proxy (interactif)
# 9) apps - Déployer les autres apps (n8n, portainer, etc.)
# all - Tout déployer dans l'ordre (interactif)
#
# Variables : voir .env (à créer depuis .env.example)
# ============================================================================
set -e
# ===== COULEURS =====
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ===== CHARGEMENT .env =====
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
if [ -f "$SCRIPT_DIR/.env" ]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
else
echo -e "${RED}❌ Fichier .env manquant. Copier .env.example vers .env et adapter.${NC}"
exit 1
fi
# ===== FONCTIONS UTILITAIRES =====
log() { echo -e "${BLUE}[$(date +%H:%M:%S)]${NC} $1"; }
ok() { echo -e "${GREEN}$1${NC}"; }
warn(){ echo -e "${YELLOW}⚠️ $1${NC}"; }
err() { echo -e "${RED}$1${NC}"; }
confirm() {
read -p "$1 (y/N) " -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]]
}
# ===== ÉTAPES =====
step_check() {
log "===== ÉTAPE 1 : Vérifications pré-install ====="
# Vérifier l'OS
if [ -f /etc/os-release ]; then
. /etc/os-release
log "OS : $PRETTY_NAME"
fi
# Vérifier Docker
if ! command -v docker &> /dev/null; then
err "Docker n'est pas installé. Voir INSTALL.md étape 2."
return 1
fi
ok "Docker installé : $(docker --version)"
# Vérifier docker compose
if ! docker compose version &> /dev/null; then
err "Docker Compose v2 n'est pas installé."
return 1
fi
ok "Docker Compose installé : $(docker compose version | head -1)"
# Vérifier DNS pour chaque sous-domaine
log "Vérification DNS..."
for sub in sso intranet git n8n portainer code status traefik; do
DNS_IP=$(dig +short "$sub.$DOMAIN" 2>/dev/null | head -1)
if [ "$DNS_IP" == "$SERVER_IP" ]; then
ok "DNS $sub.$DOMAIN$DNS_IP"
else
warn "DNS $sub.$DOMAIN → '$DNS_IP' (attendu : $SERVER_IP)"
fi
done
# Vérifier les ports 80/443 disponibles
if sudo netstat -tlnp 2>/dev/null | grep -q ":80 "; then
warn "Port 80 déjà utilisé (vérifier que c'est Traefik et pas autre chose)"
fi
if sudo netstat -tlnp 2>/dev/null | grep -q ":443 "; then
warn "Port 443 déjà utilisé (vérifier que c'est Traefik et pas autre chose)"
fi
# Vérifier l'espace disque
DISK_FREE=$(df -h / | awk 'NR==2 {print $4}')
log "Espace libre disque : $DISK_FREE"
ok "Vérifications terminées."
}
step_networks() {
log "===== ÉTAPE 2 : Création des réseaux Docker ====="
for net in web backend; do
if docker network inspect $net &> /dev/null; then
warn "Réseau '$net' existe déjà"
else
docker network create $net
ok "Réseau '$net' créé"
fi
done
}
step_traefik() {
log "===== ÉTAPE 3 : Déploiement Traefik ====="
mkdir -p ~/docker/traefik/letsencrypt
cd ~/docker/traefik
# Copier les fichiers depuis configs/
if [ ! -f docker-compose.yml ]; then
cp $SCRIPT_DIR/configs/traefik/docker-compose.yml.template docker-compose.yml
warn "docker-compose.yml créé depuis le template. À adapter si besoin."
fi
if [ ! -f traefik.yml ]; then
cp $SCRIPT_DIR/configs/traefik/traefik.yml.template traefik.yml
fi
if [ ! -f dynamic.yml ]; then
cp $SCRIPT_DIR/configs/traefik/dynamic.yml.template dynamic.yml
warn "⚠️ Penser à mettre à jour LETSENCRYPT_EMAIL et TRAEFIK_DASHBOARD_AUTH dans dynamic.yml"
fi
# Permissions
touch letsencrypt/acme.json
chmod 600 letsencrypt/acme.json
# Lancement
docker compose up -d
sleep 5
if docker ps | grep -q traefik; then
ok "Traefik démarré"
else
err "Traefik n'a pas démarré. Logs :"
docker compose logs --tail 50
return 1
fi
}
step_postgres() {
log "===== ÉTAPE 4 : Déploiement Postgres principal ====="
mkdir -p ~/docker/postgres/data
cd ~/docker/postgres
if [ ! -f docker-compose.yml ]; then
cp $SCRIPT_DIR/configs/postgres/docker-compose.yml.template docker-compose.yml
fi
if [ ! -f .env ]; then
cat > .env << EOF
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
EOF
chmod 600 .env
fi
docker compose up -d
log "Attente démarrage Postgres..."
sleep 10
if docker exec postgres pg_isready -U postgres &> /dev/null; then
ok "Postgres prêt"
else
err "Postgres pas prêt"
return 1
fi
# Créer les databases dédiées
log "Création des databases gitea et n8n..."
docker exec postgres psql -U postgres << EOF
CREATE USER gitea WITH PASSWORD '${GITEA_DB_PASSWORD}';
CREATE DATABASE gitea OWNER gitea;
CREATE USER n8n WITH PASSWORD '${N8N_DB_PASSWORD}';
CREATE DATABASE n8n OWNER n8n;
EOF
ok "Databases créées"
}
step_zitadel() {
log "===== ÉTAPE 5 : Déploiement Zitadel ====="
warn "⚠️ Zitadel est un déploiement complexe. Voir docs/03-zitadel-setup.md."
warn "Cette étape lance le docker-compose Zitadel uniquement."
warn "La création du compte zitadel-admin se fait MANUELLEMENT via la console après."
if ! confirm "Continuer ?"; then
return 0
fi
mkdir -p ~/docker/zitadel/machinekey
cd ~/docker/zitadel
if [ ! -f docker-compose.yml ]; then
cp $SCRIPT_DIR/configs/zitadel/docker-compose.yml.template docker-compose.yml
fi
if [ ! -f .env ]; then
cp $SCRIPT_DIR/configs/zitadel/.env.template .env
warn "Adapter le .env avant de lancer (ZITADEL_MASTER_KEY notamment)"
return 1
fi
docker compose up -d
log "Attente démarrage Zitadel (peut prendre 1-2 min)..."
sleep 30
ok "Zitadel démarré. Aller sur https://sso.${DOMAIN}/ui/console pour finaliser."
}
step_intranet() {
log "===== ÉTAPE 6 : Déploiement Intranet ====="
mkdir -p ~/docker/intranet
cd ~/docker/intranet
# Copier les fichiers depuis configs/
if [ ! -f docker-compose.yml ]; then
cp $SCRIPT_DIR/configs/intranet/docker-compose.yml.template docker-compose.yml
fi
if [ ! -f nginx-rbac.conf ]; then
cp $SCRIPT_DIR/configs/intranet/nginx-rbac.conf.template nginx-rbac.conf
fi
if [ ! -f index.html ]; then
cp $SCRIPT_DIR/scripts/intranet/index.html.template index.html
warn "index.html créé depuis le template. À personnaliser (logo, couleurs, etc.)"
fi
if [ ! -f roles-mapping.json ]; then
echo '{}' > roles-mapping.json
warn "roles-mapping.json vide créé. Sera généré par generate-roles-mapping.py après import."
fi
docker compose up -d
if docker ps | grep -q intranet; then
ok "Intranet démarré"
else
err "Intranet n'a pas démarré. Logs :"
docker compose logs --tail 30
fi
}
step_gitea() {
log "===== ÉTAPE 7 : Déploiement Gitea ====="
warn "⚠️ La config SSO Zitadel pour Gitea se fait MANUELLEMENT via l'UI Gitea après ce script."
mkdir -p ~/docker/gitea/data
cd ~/docker/gitea
if [ ! -f docker-compose.yml ]; then
cp $SCRIPT_DIR/configs/gitea/docker-compose.yml.template docker-compose.yml
fi
if [ ! -f .env ]; then
cat > .env << EOF
GITEA_DB_PASSWORD=${GITEA_DB_PASSWORD}
EOF
chmod 600 .env
fi
docker compose up -d
ok "Gitea démarré. Aller sur https://git.${DOMAIN} pour setup initial."
}
step_oauth2_proxy() {
log "===== ÉTAPE 8 : Déploiement OAuth2-Proxy (générique) ====="
warn "Cette étape déploie 1 OAuth2-Proxy pour 1 outil."
read -p "Nom de l'outil (intranet, n8n, portainer, code, status) : " TOOL
read -p "Host (ex: intranet.seizeconsulting.com) : " TOOL_HOST
read -p "Upstream URL (ex: http://intranet:80) : " TOOL_UPSTREAM
read -p "Client ID Zitadel : " ZITADEL_CLIENT_ID
read -s -p "Client Secret Zitadel : " ZITADEL_CLIENT_SECRET
echo
COOKIE_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
DIR=~/docker/oauth2-proxy-${TOOL}
if [ "$TOOL" == "intranet" ]; then
DIR=~/docker/oauth2-proxy
fi
mkdir -p $DIR
cd $DIR
# docker-compose.yml
cat > docker-compose.yml << EOF
services:
oauth2-proxy-${TOOL}:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
container_name: oauth2-proxy-${TOOL}
restart: unless-stopped
env_file:
- .env
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.${TOOL}-sso.rule=Host(\`${TOOL_HOST}\`)"
- "traefik.http.routers.${TOOL}-sso.entrypoints=websecure"
- "traefik.http.routers.${TOOL}-sso.tls.certresolver=letsencrypt"
- "traefik.http.routers.${TOOL}-sso.service=${TOOL}-sso"
- "traefik.http.routers.${TOOL}-sso.priority=300"
- "traefik.http.services.${TOOL}-sso.loadbalancer.server.port=4180"
networks:
web:
external: true
EOF
# .env
cat > .env << EOF
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_CLIENT_ID=${ZITADEL_CLIENT_ID}
OAUTH2_PROXY_CLIENT_SECRET=${ZITADEL_CLIENT_SECRET}
OAUTH2_PROXY_COOKIE_SECRET=${COOKIE_SECRET}
OAUTH2_PROXY_OIDC_ISSUER_URL=https://sso.${DOMAIN}
OAUTH2_PROXY_REDIRECT_URL=https://${TOOL_HOST}/oauth2/callback
OAUTH2_PROXY_UPSTREAMS=${TOOL_UPSTREAM}
OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles
OAUTH2_PROXY_EMAIL_DOMAINS=*
OAUTH2_PROXY_COOKIE_DOMAINS=.${DOMAIN}
OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_COOKIE_HTTPONLY=true
OAUTH2_PROXY_COOKIE_SAMESITE=lax
OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_SET_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_SET_XAUTHREQUEST=true
OAUTH2_PROXY_WHITELIST_DOMAINS=.${DOMAIN}
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
EOF
chmod 600 .env
docker compose up -d
if docker ps | grep -q "oauth2-proxy-${TOOL}"; then
ok "OAuth2-Proxy ${TOOL} démarré"
else
err "Échec démarrage. Logs :"
docker compose logs --tail 30
fi
}
step_apps() {
log "===== ÉTAPE 9 : Déploiement des apps tierces ====="
warn "Chaque app nécessite sa propre config. Cette étape ne fait que créer la structure."
for app in n8n portainer code-server uptime-kuma; do
if confirm "Déployer $app ?"; then
mkdir -p ~/docker/$app
cd ~/docker/$app
if [ -f $SCRIPT_DIR/configs/$app/docker-compose.yml.template ]; then
cp $SCRIPT_DIR/configs/$app/docker-compose.yml.template docker-compose.yml
fi
if [ -f $SCRIPT_DIR/configs/$app/.env.template ]; then
cp $SCRIPT_DIR/configs/$app/.env.template .env
warn "Adapter $app/.env"
fi
docker compose up -d
ok "$app déployé"
fi
done
}
step_all() {
log "===== INSTALL COMPLET (mode interactif) ====="
step_check && \
step_networks && \
step_traefik && \
step_postgres && \
step_zitadel && \
step_intranet && \
step_gitea && \
step_apps
warn "Reste manuellement :"
warn " - Setup Zitadel via UI (créer admin, configurer policies, créer apps)"
warn " - Setup Gitea via UI (premier admin)"
warn " - OAuth2-Proxy par outil (étape 8, à répéter pour chaque)"
warn " - Import users Furious (voir scripts/furious-to-zitadel/)"
warn " - Attribution des rôles RBAC"
}
# ===== MENU PRINCIPAL =====
show_menu() {
echo "============================================================"
echo " Install Infrastructure Seize Consulting"
echo "============================================================"
echo ""
echo "Étapes disponibles :"
echo " 1) check - Vérifications pré-install"
echo " 2) networks - Créer les réseaux Docker"
echo " 3) traefik - Déployer Traefik"
echo " 4) postgres - Déployer Postgres principal"
echo " 5) zitadel - Déployer Zitadel"
echo " 6) intranet - Déployer l'intranet"
echo " 7) gitea - Déployer Gitea"
echo " 8) oauth2-proxy - Déployer 1 OAuth2-Proxy (interactif)"
echo " 9) apps - Déployer n8n, portainer, code, uptime"
echo " all - Tout déployer (interactif)"
echo " q - Quitter"
echo ""
read -p "Choix : " choice
case $choice in
1|check) step_check ;;
2|networks) step_networks ;;
3|traefik) step_traefik ;;
4|postgres) step_postgres ;;
5|zitadel) step_zitadel ;;
6|intranet) step_intranet ;;
7|gitea) step_gitea ;;
8|oauth2-proxy) step_oauth2_proxy ;;
9|apps) step_apps ;;
all) step_all ;;
q|Q) exit 0 ;;
*) err "Choix invalide" ;;
esac
}
# ===== ENTRY POINT =====
if [ -n "$1" ]; then
case $1 in
check) step_check ;;
networks) step_networks ;;
traefik) step_traefik ;;
postgres) step_postgres ;;
zitadel) step_zitadel ;;
intranet) step_intranet ;;
gitea) step_gitea ;;
oauth2-proxy) step_oauth2_proxy ;;
apps) step_apps ;;
all) step_all ;;
*) err "Étape inconnue : $1" ; exit 1 ;;
esac
else
show_menu
fi

View File

@ -0,0 +1,21 @@
job_title_furious,role_zitadel,group,notes
Président,direction,hierarchie,Top management
Directeur,direction,hierarchie,Top management
Directeur Associé,direction,hierarchie,Top management
Senior Manager,manager,hierarchie,
Sénior Manager,manager,hierarchie,Variante orthographique de Senior Manager
Manager,manager,hierarchie,
Manager BI,manager,hierarchie,Manager spécialité BI
Expert,expert,hierarchie,
Expert N2,expert,hierarchie,Expert niveau 2
Consultant Expert niveau 1,expert,hierarchie,Variante de naming
Consultant Sénior,consultant_senior,hierarchie,
Consultant Expérimenté,consultant_senior,hierarchie,Équivalent à Consultant Sénior
Consultant Expérimentée,consultant_senior,hierarchie,Variante féminine
Consultant Junior,consultant_junior,hierarchie,
Stagiaire,stagiaire,hierarchie,Distinct des apprentis
Assistante,support,hierarchie,Assistante de direction ou autre
Assistante de direction,support,hierarchie,
Chargé de recrutement,support,hierarchie,Recrutement RH
Marketing,support,hierarchie,Équipe marketing
,,(status=apprenti → alternant),Cas spécial : tous les users avec status=apprenti sont mappés en alternant indépendamment du job_title
1 job_title_furious role_zitadel group notes
2 Président direction hierarchie Top management
3 Directeur direction hierarchie Top management
4 Directeur Associé direction hierarchie Top management
5 Senior Manager manager hierarchie
6 Sénior Manager manager hierarchie Variante orthographique de Senior Manager
7 Manager manager hierarchie
8 Manager BI manager hierarchie Manager spécialité BI
9 Expert expert hierarchie
10 Expert N2 expert hierarchie Expert niveau 2
11 Consultant Expert niveau 1 expert hierarchie Variante de naming
12 Consultant Sénior consultant_senior hierarchie
13 Consultant Expérimenté consultant_senior hierarchie Équivalent à Consultant Sénior
14 Consultant Expérimentée consultant_senior hierarchie Variante féminine
15 Consultant Junior consultant_junior hierarchie
16 Stagiaire stagiaire hierarchie Distinct des apprentis
17 Assistante support hierarchie Assistante de direction ou autre
18 Assistante de direction support hierarchie
19 Chargé de recrutement support hierarchie Recrutement RH
20 Marketing support hierarchie Équipe marketing
21 (status=apprenti → alternant) Cas spécial : tous les users avec status=apprenti sont mappés en alternant indépendamment du job_title

131
matrices/rbac-matrix.md Normal file
View File

@ -0,0 +1,131 @@
# Matrice RBAC — Qui voit quoi sur l'intranet
> Décision tranchée par Hedi Kalboussi le 5 juin 2026 (validée verbalement par Philippe Spoljaric, à valider en workshop formel).
---
## 🎯 Matrice principale
| Application | direction | manager | expert | consultant_senior | consultant_junior | alternant | stagiaire | support |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **Intranet (page d'accueil)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Gitea (Git)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Code Server (IDE)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **n8n (Workflows)** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Portainer (Docker)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Uptime Kuma (Monitoring)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Traefik Dashboard** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
---
## 🎭 Définition des rôles
### `direction`
**Périmètre** : Top management, accès complet à toute l'infrastructure.
Inclut : Président, Directeur, Directeur Associé.
**Profil type** : Pierre-Yves Tachon (Président), Philippe Spoljaric (Admin Infra).
### `manager`
**Périmètre** : Managers opérationnels, gèrent des équipes.
Inclut : Senior Manager, Manager, Manager BI.
### `expert`
**Périmètre** : Experts techniques senior, peuvent intervenir sur des outils dev (n8n).
Inclut : Expert, Expert N2, Consultant Expert niveau 1.
### `consultant_senior`
**Périmètre** : Consultants expérimentés (3+ ans). Accès dev (Gitea + Code).
Inclut : Consultant Sénior, Consultant Expérimenté(e).
### `consultant_junior`
**Périmètre** : Consultants juniors, accès dev limité.
### `alternant`
**Périmètre** : Apprentis en alternance, accès dev limité.
⚠️ Tous les users avec `status: apprenti` dans Furious sont mappés en `alternant`.
### `stagiaire`
**Périmètre** : Stagiaires, accès dev limité.
### `support`
**Périmètre** : Support administratif et marketing. Accès aux outils collaboratifs.
Inclut : Assistante, Assistante de direction, Chargé de recrutement, Marketing.
---
## 📊 Distribution effective (au 5 juin 2026)
| Rôle | Nombre d'users |
|---|---|
| consultant_senior | 27 |
| consultant_junior | 16 |
| manager | 14 |
| alternant | 13 |
| expert | 8 |
| direction | 6 |
| support | 3 |
| **TOTAL** | **87** (sur 88, 1 vide à corriger) |
---
## ⚠️ Limites acceptées (Approche A)
L'Approche A filtre les cartes **côté intranet** mais **ne bloque pas l'accès direct** à un outil si l'user connaît l'URL.
Exemple : un `consultant_junior` ne voit pas la carte Portainer sur l'intranet, mais s'il tape `portainer.seizeconsulting.com` dans son navigateur, il sera authentifié et accédera à Portainer (car le SSO ne discrimine pas par rôle).
→ Acceptable pour Seize Consulting (88 collaborateurs non-malveillants).
Pour un **vrai RBAC** sur chaque outil, voir [DECISIONS.md D7](../DECISIONS.md).
---
## 🔄 Modification de la matrice
### Pour ajouter ou modifier les autorisations
1. Éditer le fichier `~/docker/intranet/index.html`
2. Trouver le bloc JavaScript `ACCESS_MATRIX`
3. Modifier les listes de rôles
4. Restart de l'intranet :
```bash
cd ~/docker/intranet
docker compose restart
```
### Pour ajouter un nouveau rôle
1. Créer le rôle dans Zitadel (Project Infra Seize → Roles → New)
2. Mettre à jour `JOB_TITLE_TO_ROLE` dans `generate-roles-mapping.py` et `assign-roles.py`
3. Mettre à jour `ACCESS_MATRIX` dans `index.html` pour préciser ce que ce rôle voit
4. Lancer :
```bash
python3 generate-roles-mapping.py
python3 assign-roles.py
cp roles-mapping.json ~/docker/intranet/
cd ~/docker/intranet && docker compose restart
```
---
## 🗓️ Historique des modifications
| Date | Action | Auteur |
|---|---|---|
| 2026-06-05 | Création initiale (8 rôles, 6 outils filtrés) | Hedi Kalboussi |
| | | |
---
## 📚 Voir aussi
- [job-title-to-role.csv](./job-title-to-role.csv) — Mapping détaillé Furious → rôle
- [docs/07-rbac-intranet.md](../docs/07-rbac-intranet.md) — Implémentation technique
- [DECISIONS.md D7](../DECISIONS.md) — Pourquoi Approche A

View File

@ -0,0 +1,125 @@
# Scripts d'import Furious → Zitadel
> Scripts Python/Shell pour l'import automatisé des collaborateurs Seize depuis Furious Squad vers Zitadel.
---
## 📁 Liste des scripts
| Fichier | Type | Description |
|---|---|---|
| `read-all-users.sh` | Shell | Récupère les users depuis Furious API → JSON |
| `test-auth.sh` | Shell | Teste l'auth Furious |
| `import-furious-to-zitadel.py` | Python | ⭐ Script principal d'import |
| `merge-duplicates.py` | Python | Fusion des doublons après import |
| `generate-roles-mapping.py` | Python | Génère le mapping email → rôle |
| `assign-roles.py` | Python | Attribue les rôles RBAC dans Zitadel |
| `extract-distribution-csv.py` | Python | Génère le CSV pour distribution Teams |
---
## 🔧 Setup initial
### 1. Environnement Python
```bash
cd ~/furious-to-zitadel
python3 -m venv venv
source venv/bin/activate
pip install requests pyjwt cryptography
```
### 2. Fichier `.env`
```env
FURIOUS_USERNAME=__creer_dans_furious_config__
FURIOUS_PASSWORD=__creer_dans_furious_config__
FURIOUS_API_URL=https://seize-consulting.furious-squad.com/api/v2
```
`chmod 600 .env`
### 3. Clé service account Zitadel
Voir [docs/06-import-furious.md](../../docs/06-import-furious.md).
Placer le fichier dans le dossier sous le nom `zitadelfuriousimportkey.json` avec `chmod 600`.
---
## 🚀 Procédure complète
```bash
cd ~/furious-to-zitadel
source venv/bin/activate
# 1. Lire les users Furious
./read-all-users.sh
# 2. Test dry-run
python3 import-furious-to-zitadel.py --mode dry-run
# 3. Test sur 3 users
python3 import-furious-to-zitadel.py --mode test
# 4. Vérification dans Zitadel UI
# 5. Import production (les 85 restants)
python3 import-furious-to-zitadel.py --mode prod
# 6. Gérer les doublons (adapter DUPLICATES dans le script avant)
nano merge-duplicates.py
python3 merge-duplicates.py
# 7. Extraire CSV de distribution
python3 extract-distribution-csv.py
# 8. Générer le mapping pour RBAC intranet
python3 generate-roles-mapping.py
# 9. Attribuer les rôles RBAC
python3 assign-roles.py
# 10. Copier le mapping vers l'intranet
cp roles-mapping.json ~/docker/intranet/
cd ~/docker/intranet
docker compose down
docker compose up -d
```
---
## ⚠️ Fichiers générés (sensibles)
Ces fichiers contiennent des données sensibles, à **protéger** :
| Fichier | Contenu | Action |
|---|---|---|
| `furious-users-raw.json` | Toutes les infos Furious des collaborateurs | chmod 600, ne pas commit |
| `users-credentials.csv` | 89 lignes avec mdp temporaires | chmod 600, supprimer après distribution |
| `distribution-teams.csv` | 88 lignes (Nom, Email, Mdp) pour Teams | chmod 600, supprimer après distribution |
| `roles-mapping.json` | Mapping email → rôle | OK à partager avec le mainteneur |
| `zitadelfuriousimportkey.json` | Clé privée RSA service account | chmod 600, ne JAMAIS partager |
| `import.log` | Logs de l'import (peut contenir des emails) | chmod 600 |
---
## 🔄 Synchronisation continue (à mettre en place)
Voir [docs/06-import-furious.md](../../docs/06-import-furious.md) section "Synchronisation périodique".
Idée : workflow n8n quotidien à 3h du matin qui :
1. Compare la liste Furious vs Zitadel
2. Crée les nouveaux
3. Désactive les départs
4. Met à jour les rôles
⏱️ ~1 journée de dev.
---
## 📚 Voir aussi
- [docs/06-import-furious.md](../../docs/06-import-furious.md) — Doc complète de l'import
- [docs/07-rbac-intranet.md](../../docs/07-rbac-intranet.md) — Suite : attribution des rôles
- [matrices/job-title-to-role.csv](../../matrices/job-title-to-role.csv) — Matrice de mapping

View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
assign-roles.py Attribue les rôles RBAC aux users dans Zitadel.
Pour chaque user actif Furious :
1. Détermine son rôle (via mapping job_title)
2. Cherche son compte Zitadel par email
3. Crée le UserGrant avec le rôle dans le project "Infra Seize"
Prérequis :
- 88 users déjà importés dans Zitadel (cf. import-furious-to-zitadel.py)
- 8 rôles déjà créés dans le project Zitadel "Infra Seize"
- Clé service account avec rôle IAM_USER_MANAGER
Auteur : Hedi Kalboussi
Date : 5 juin 2026
"""
import json
import logging
import sys
import time
from pathlib import Path
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
# Réutiliser le même mapping
from generate_roles_mapping import JOB_TITLE_TO_ROLE, determine_role # noqa
# Note : adapter selon disposition réelle des fichiers
# Si import échoue, copier le mapping ici directement
# ============================================================================
# CONFIGURATION
# ============================================================================
SCRIPT_DIR = Path(__file__).parent
KEY_FILE = SCRIPT_DIR / "zitadelfuriousimportkey.json"
RAW_DATA_FILE = SCRIPT_DIR / "furious-users-raw.json"
ZITADEL_DOMAIN = "https://sso.seizeconsulting.com"
PROJECT_ID = "375123185571463171" # ⚠️ ID du project "Infra Seize" — adapter si différent
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
# ============================================================================
# AUTHENTIFICATION ZITADEL (identique au script d'import)
# ============================================================================
def get_zitadel_token():
with open(KEY_FILE) as f:
key_data = json.load(f)
user_id = key_data["userId"]
key_id = key_data["keyId"]
private_key_pem = key_data["key"]
private_key = serialization.load_pem_private_key(
private_key_pem.encode(), password=None, backend=default_backend()
)
now = int(time.time())
payload = {"iss": user_id, "sub": user_id, "aud": ZITADEL_DOMAIN, "iat": now, "exp": now + 3600}
assertion = jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": key_id})
response = requests.post(
f"{ZITADEL_DOMAIN}/oauth/v2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"scope": "openid profile urn:zitadel:iam:org:project:id:zitadel:aud",
"assertion": assertion,
},
)
response.raise_for_status()
return response.json()["access_token"]
# ============================================================================
# API ZITADEL — Search user by email + create user grant
# ============================================================================
def find_user_by_email(email, token):
"""Cherche un user Zitadel par email. Retourne l'userId."""
response = requests.post(
f"{ZITADEL_DOMAIN}/v2/users",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={
"queries": [
{"emailQuery": {"emailAddress": email, "method": "TEXT_QUERY_METHOD_EQUALS"}}
]
},
)
if not response.ok:
log.error(f"Erreur recherche {email} : {response.status_code}")
return None
result = response.json()
users = result.get("result", [])
if not users:
return None
return users[0].get("userId")
def assign_role_to_user(user_id, role_key, token):
"""Attribue un rôle au user dans le project Infra Seize."""
response = requests.post(
f"{ZITADEL_DOMAIN}/management/v1/users/{user_id}/grants",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={
"projectId": PROJECT_ID,
"roleKeys": [role_key],
},
)
if response.status_code == 409:
log.info(f" Grant déjà existant pour user {user_id}")
return True
if not response.ok:
log.error(f"Échec attribution rôle pour {user_id} : {response.status_code} {response.text[:200]}")
return False
return True
# ============================================================================
# MAIN
# ============================================================================
def main():
log.info("===== Attribution des rôles RBAC =====")
with open(RAW_DATA_FILE) as f:
data = json.load(f)
actifs = [
u for u in data.get("data", {}).get("User", [])
if u.get("departure_date") == "0000-00-00"
]
log.info(f"Users actifs : {len(actifs)}")
token = get_zitadel_token()
log.info("✅ Token Zitadel OK")
success_count = 0
no_role_count = 0
not_found_count = 0
error_count = 0
for i, user in enumerate(actifs, 1):
email = user.get("email", "").lower()
role = determine_role(user)
log.info(f"[{i}/{len(actifs)}] {email} → rôle: {role}")
if not role:
log.warning(f" Pas de rôle déterminé (job_title='{user.get('job_title')}')")
no_role_count += 1
continue
user_id = find_user_by_email(email, token)
if not user_id:
log.error(f" User Zitadel introuvable pour {email}")
not_found_count += 1
continue
if assign_role_to_user(user_id, role, token):
success_count += 1
else:
error_count += 1
time.sleep(0.15) # Anti rate-limit
log.info(f"===== FIN =====")
log.info(f" ✅ Succès : {success_count}")
log.info(f" ⚠️ Sans rôle : {no_role_count}")
log.info(f" ❌ Non trouvés : {not_found_count}")
log.info(f" 💥 Erreurs : {error_count}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
extract-distribution-csv.py Extrait les colonnes pour distribution Teams.
Input : users-credentials.csv (sortie de import-furious-to-zitadel.py)
Output : distribution-teams.csv (3 colonnes : Nom complet, Email, Mot de passe)
Auteur : Hedi Kalboussi
Date : 5 juin 2026
"""
import csv
import os
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
INPUT = SCRIPT_DIR / "users-credentials.csv"
OUTPUT = SCRIPT_DIR / "distribution-teams.csv"
if not INPUT.exists():
print(f"❌ Fichier {INPUT} manquant")
exit(1)
count = 0
with open(INPUT, newline="", encoding="utf-8") as fin, \
open(OUTPUT, "w", newline="", encoding="utf-8") as fout:
reader = csv.DictReader(fin)
writer = csv.writer(fout)
writer.writerow(["Nom complet", "Email", "Mot de passe"])
for row in reader:
full_name = f"{row['first_name']} {row['last_name']}"
writer.writerow([full_name, row["email"], row["password"]])
count += 1
os.chmod(OUTPUT, 0o600)
print(f"{count} users écrits dans {OUTPUT}")

View File

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
generate-roles-mapping.py Génère le fichier JSON mappant email rôle.
Output : roles-mapping.json (utilisé par l'intranet pour le filtrage RBAC côté JS).
Mapping :
- status == "apprenti" "alternant"
- sinon, lookup dans JOB_TITLE_TO_ROLE par job_title
Stagiaires : ceux avec status="stagiaire" devraient être mappés en "stagiaire" mais
attention, certains apparaissent comme "apprenti" dans Furious (à corriger source).
Auteur : Hedi Kalboussi
Date : 5 juin 2026
"""
import json
from collections import Counter
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
RAW_DATA_FILE = SCRIPT_DIR / "furious-users-raw.json"
OUTPUT_FILE = SCRIPT_DIR / "roles-mapping.json"
# Mapping job_title (Furious) → role (Zitadel)
JOB_TITLE_TO_ROLE = {
# DIRECTION
"président": "direction",
"directeur": "direction",
"directeur associé": "direction",
# MANAGER
"senior manager": "manager",
"sénior manager": "manager",
"manager": "manager",
"manager bi": "manager",
# EXPERT
"expert": "expert",
"expert n2": "expert",
"consultant expert niveau 1": "expert",
# CONSULTANT SENIOR
"consultant sénior": "consultant_senior",
"consultant expérimenté": "consultant_senior",
"consultant expérimentée": "consultant_senior",
# CONSULTANT JUNIOR
"consultant junior": "consultant_junior",
# STAGIAIRE
"stagiaire": "stagiaire",
# SUPPORT
"assistante": "support",
"assistante de direction": "support",
"chargé de recrutement": "support",
"marketing": "support",
}
def determine_role(user):
"""Détermine le rôle d'un user selon son status (apprenti) ou job_title."""
# Cas spécial : apprenti = alternant
if user.get("status") == "apprenti":
return "alternant"
job_title = (user.get("job_title") or "").strip().lower()
if not job_title:
return None
return JOB_TITLE_TO_ROLE.get(job_title)
def main():
with open(RAW_DATA_FILE) as f:
data = json.load(f)
actifs = [
u for u in data.get("data", {}).get("User", [])
if u.get("departure_date") == "0000-00-00"
]
mapping = {}
for u in actifs:
email = u.get("email", "").lower()
role = determine_role(u)
if email and role:
mapping[email] = role
# Sauvegarder
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(mapping, f, indent=2, ensure_ascii=False)
# Statistiques
role_stats = Counter(mapping.values())
print(f"✅ Fichier généré : {OUTPUT_FILE}")
print(f" Total : {len(mapping)} users mappés")
print(f" Sans rôle : {len(actifs) - len(mapping)}")
print(f"\nRépartition par rôle :")
for role, count in role_stats.most_common():
print(f" {role:<20} : {count}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
import-furious-to-zitadel.py Import des users Furious Squad vers Zitadel.
Modes :
--mode dry-run : simulation sans écriture (par défaut)
--mode test : crée uniquement les 3 premiers users (Furious IDs 1, 2, 3)
--mode prod : crée tous les users actifs restants
Génère :
- users-credentials.csv : 1 ligne par user avec mdp temporaire
- import.log : trace de l'exécution
Auteur : Hedi Kalboussi
Date : 5 juin 2026
"""
import argparse
import csv
import json
import logging
import os
import random
import string
import sys
import time
from pathlib import Path
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
# ============================================================================
# CONFIGURATION
# ============================================================================
SCRIPT_DIR = Path(__file__).parent
ENV_FILE = SCRIPT_DIR / ".env"
KEY_FILE = SCRIPT_DIR / "zitadelfuriousimportkey.json"
RAW_DATA_FILE = SCRIPT_DIR / "furious-users-raw.json"
CREDENTIALS_CSV = SCRIPT_DIR / "users-credentials.csv"
LOG_FILE = SCRIPT_DIR / "import.log"
ZITADEL_DOMAIN = "https://sso.seizeconsulting.com"
# IDs Furious pour le mode test (3 premiers)
TEST_USER_IDS = [1, 2, 3]
# ============================================================================
# LOGGING
# ============================================================================
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger(__name__)
# ============================================================================
# AUTHENTIFICATION ZITADEL (JWT signé)
# ============================================================================
def get_zitadel_token():
"""Génère un JWT signé avec la clé du service account et l'échange contre un access token."""
with open(KEY_FILE) as f:
key_data = json.load(f)
user_id = key_data["userId"]
key_id = key_data["keyId"]
private_key_pem = key_data["key"]
# Charger la clé privée RSA
private_key = serialization.load_pem_private_key(
private_key_pem.encode(),
password=None,
backend=default_backend(),
)
# Créer le JWT
now = int(time.time())
payload = {
"iss": user_id,
"sub": user_id,
"aud": ZITADEL_DOMAIN,
"iat": now,
"exp": now + 3600, # 1h
}
headers = {"kid": key_id}
assertion = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
# Échange contre access token
response = requests.post(
f"{ZITADEL_DOMAIN}/oauth/v2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"scope": "openid profile urn:zitadel:iam:org:project:id:zitadel:aud",
"assertion": assertion,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.json()["access_token"]
# ============================================================================
# FONCTIONS UTILITAIRES
# ============================================================================
def generate_password(length=16):
"""Génère un mdp temporaire respectant la policy Zitadel (14+ chars, complexe)."""
chars = string.ascii_letters + string.digits + "@#$%&*"
# Assure au moins 1 maj, 1 min, 1 chiffre, 1 symbole
pwd = (
random.choice(string.ascii_uppercase) +
random.choice(string.ascii_lowercase) +
random.choice(string.digits) +
random.choice("@#$%&*") +
"".join(random.choices(chars, k=length - 4))
)
return "".join(random.sample(pwd, len(pwd)))
def determine_username(user):
"""Username = email complet (simple et unique)."""
return user.get("email", "").strip().lower()
def build_metadata(user):
"""Construit le dict de metadata à attacher à chaque user Zitadel."""
return {
"furious_id": str(user.get("id", "")),
"pseudo": user.get("pseudo", ""),
"job_title": user.get("job_title", ""),
"bu_name": user.get("bu_name", ""),
"manager": user.get("manager_pseudo", ""),
"status": user.get("status", ""),
"arrival_date": user.get("arrival_date", ""),
"entity": user.get("entity_name", ""),
}
# ============================================================================
# CRÉATION D'UN USER ZITADEL
# ============================================================================
def create_user_zitadel(user, token, dry_run=False):
"""Crée un user dans Zitadel via l'API v2."""
email = user.get("email", "").strip().lower()
given_name = user.get("first_name", "").strip()
family_name = user.get("last_name", "").strip()
if not email or not given_name or not family_name:
log.warning(f"Données incomplètes pour user Furious id={user.get('id')}, skip")
return None
username = determine_username(user)
password = generate_password()
payload = {
"username": username,
"profile": {
"givenName": given_name,
"familyName": family_name,
"displayName": f"{given_name} {family_name}",
"preferredLanguage": "fr",
},
"email": {
"email": email,
"isVerified": True,
},
"password": {
"password": password,
"changeRequired": True,
},
}
if dry_run:
log.info(f"[DRY-RUN] Would create: {email} ({given_name} {family_name})")
return {
"userId": "DRY_RUN_ID",
"username": username,
"password": password,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
response = requests.post(
f"{ZITADEL_DOMAIN}/v2/users/human",
headers=headers,
json=payload,
)
if response.status_code == 409:
log.warning(f"User déjà existant : {email}")
return None
if not response.ok:
log.error(f"Erreur création {email} : {response.status_code} - {response.text}")
return None
result = response.json()
user_id = result.get("userId")
log.info(f"✅ Créé : {email} → Zitadel ID {user_id}")
# Attacher les metadata
metadata = build_metadata(user)
for key, value in metadata.items():
if value:
attach_metadata(user_id, key, value, token)
return {
"userId": user_id,
"username": username,
"password": password,
}
def attach_metadata(user_id, key, value, token):
"""Attache une metadata custom à un user."""
import base64
value_b64 = base64.b64encode(str(value).encode()).decode()
response = requests.post(
f"{ZITADEL_DOMAIN}/management/v1/users/{user_id}/metadata/{key}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"value": value_b64},
)
if not response.ok:
log.warning(f" Metadata {key} échouée : {response.status_code}")
# ============================================================================
# MAIN
# ============================================================================
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--mode",
choices=["dry-run", "test", "prod"],
default="dry-run",
help="Mode d'exécution",
)
args = parser.parse_args()
log.info(f"===== Import Furious → Zitadel (mode: {args.mode}) =====")
# 1. Charger les données Furious
if not RAW_DATA_FILE.exists():
log.error(f"Fichier {RAW_DATA_FILE} manquant. Lancer read-all-users.sh d'abord.")
sys.exit(1)
with open(RAW_DATA_FILE) as f:
data = json.load(f)
all_users = data.get("data", {}).get("User", [])
# Filtrer les actifs (departure_date == "0000-00-00")
actifs = [u for u in all_users if u.get("departure_date") == "0000-00-00"]
log.info(f"Users actifs dans Furious : {len(actifs)} sur {len(all_users)} total")
# Filtrer selon le mode
if args.mode == "test":
users_to_import = [u for u in actifs if u.get("id") in TEST_USER_IDS]
else:
users_to_import = actifs
log.info(f"Users à importer : {len(users_to_import)}")
# 2. Obtenir le token Zitadel (si pas dry-run)
token = None
if args.mode != "dry-run":
log.info("Authentification Zitadel...")
token = get_zitadel_token()
log.info("✅ Token Zitadel OK")
# 3. Boucler sur les users
results = []
for i, user in enumerate(users_to_import, 1):
log.info(f"[{i}/{len(users_to_import)}] {user.get('email')}")
result = create_user_zitadel(user, token, dry_run=(args.mode == "dry-run"))
if result:
results.append({
"email": user.get("email"),
"first_name": user.get("first_name"),
"last_name": user.get("last_name"),
"pseudo": user.get("pseudo"),
"furious_id": user.get("id"),
"zitadel_user_id": result["userId"],
"password": result["password"],
})
# Pause anti-rate-limit
time.sleep(0.2)
# 4. Sauvegarder le CSV
if results and args.mode != "dry-run":
with open(CREDENTIALS_CSV, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=["email", "first_name", "last_name", "pseudo", "furious_id", "zitadel_user_id", "password"],
)
writer.writeheader()
writer.writerows(results)
os.chmod(CREDENTIALS_CSV, 0o600)
log.info(f"✅ CSV sauvegardé : {CREDENTIALS_CSV} ({len(results)} lignes)")
log.info(f"===== FIN : {len(results)} users importés =====")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
merge-duplicates.py Fusionne les doublons après import.
Pour chaque doublon :
1. Lit les metadata Furious sur le compte "nouveau"
2. Les copie sur le compte "ancien" (à garder)
3. Supprime le compte "nouveau"
Adapter la variable DUPLICATES pour ton cas.
Auteur : Hedi Kalboussi
Date : 5 juin 2026
"""
import base64
import json
import logging
import time
from pathlib import Path
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
# ============================================================================
# CONFIGURATION — À ADAPTER
# ============================================================================
# Liste des doublons à fusionner
# Format : { "keep_user_id": <id_ancien>, "delete_email": <email_du_nouveau>, "furious_id": <id_furious> }
DUPLICATES = [
# Exemple :
# {
# "keep_user_id": "375119988404518915",
# "delete_email": "hedi.kalboussi@seizeconsulting.com",
# "furious_id": 5,
# },
]
SCRIPT_DIR = Path(__file__).parent
KEY_FILE = SCRIPT_DIR / "zitadelfuriousimportkey.json"
RAW_DATA_FILE = SCRIPT_DIR / "furious-users-raw.json"
ZITADEL_DOMAIN = "https://sso.seizeconsulting.com"
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
# ============================================================================
# AUTH
# ============================================================================
def get_zitadel_token():
with open(KEY_FILE) as f:
key_data = json.load(f)
private_key = serialization.load_pem_private_key(
key_data["key"].encode(), password=None, backend=default_backend()
)
now = int(time.time())
payload = {
"iss": key_data["userId"],
"sub": key_data["userId"],
"aud": ZITADEL_DOMAIN,
"iat": now,
"exp": now + 3600,
}
assertion = jwt.encode(
payload, private_key, algorithm="RS256", headers={"kid": key_data["keyId"]}
)
response = requests.post(
f"{ZITADEL_DOMAIN}/oauth/v2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"scope": "openid profile urn:zitadel:iam:org:project:id:zitadel:aud",
"assertion": assertion,
},
)
response.raise_for_status()
return response.json()["access_token"]
# ============================================================================
# OPÉRATIONS
# ============================================================================
def find_user_by_email(email, token):
response = requests.post(
f"{ZITADEL_DOMAIN}/v2/users",
headers={"Authorization": f"Bearer {token}"},
json={
"queries": [
{"emailQuery": {"emailAddress": email, "method": "TEXT_QUERY_METHOD_EQUALS"}}
]
},
)
response.raise_for_status()
users = response.json().get("result", [])
return users[0].get("userId") if users else None
def get_user_metadata(user_id, token):
"""Récupère toutes les metadata d'un user."""
response = requests.get(
f"{ZITADEL_DOMAIN}/management/v1/users/{user_id}/metadata/_search",
headers={"Authorization": f"Bearer {token}"},
)
if not response.ok:
return {}
result = response.json().get("result", [])
return {
m["key"]: base64.b64decode(m["value"]).decode()
for m in result
}
def set_user_metadata(user_id, key, value, token):
value_b64 = base64.b64encode(str(value).encode()).decode()
response = requests.post(
f"{ZITADEL_DOMAIN}/management/v1/users/{user_id}/metadata/{key}",
headers={"Authorization": f"Bearer {token}"},
json={"value": value_b64},
)
return response.ok
def delete_user(user_id, token):
response = requests.delete(
f"{ZITADEL_DOMAIN}/v2/users/{user_id}",
headers={"Authorization": f"Bearer {token}"},
)
return response.ok
# ============================================================================
# MAIN
# ============================================================================
def main():
if not DUPLICATES:
log.error("Variable DUPLICATES vide. Adapter le script avant exécution.")
return
token = get_zitadel_token()
log.info("✅ Token Zitadel OK")
for dup in DUPLICATES:
keep_id = dup["keep_user_id"]
delete_email = dup["delete_email"]
log.info(f"\n=== Doublon : keep={keep_id}, delete={delete_email} ===")
# 1. Trouver le user "nouveau" à supprimer
delete_id = find_user_by_email(delete_email, token)
if not delete_id:
log.warning(f"User {delete_email} pas trouvé (peut-être déjà fusionné)")
continue
# 2. Récupérer ses metadata
metadata = get_user_metadata(delete_id, token)
log.info(f" Metadata récupérées : {list(metadata.keys())}")
# 3. Les copier sur l'ancien
for key, value in metadata.items():
if value and set_user_metadata(keep_id, key, value, token):
log.info(f" → Copié {key}={value[:30]}...")
# 4. Supprimer le nouveau
if delete_user(delete_id, token):
log.info(f" ✅ Supprimé : {delete_email}")
else:
log.error(f" 💥 Échec suppression : {delete_email}")
time.sleep(0.5)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,49 @@
#!/bin/bash
# ============================================================================
# read-all-users.sh — Récupère la liste des users depuis l'API Furious Squad.
# ============================================================================
# Output : furious-users-raw.json
# ============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
OUTPUT="$SCRIPT_DIR/furious-users-raw.json"
if [ ! -f "$ENV_FILE" ]; then
echo "❌ Fichier .env manquant"
exit 1
fi
source "$ENV_FILE"
if [ -z "$FURIOUS_USERNAME" ] || [ -z "$FURIOUS_PASSWORD" ] || [ -z "$FURIOUS_API_URL" ]; then
echo "❌ Variables manquantes dans .env"
exit 1
fi
echo "🔄 Récupération des users Furious..."
# Appel API : User > Search (retourne tous les users actifs et inactifs)
curl -s -u "$FURIOUS_USERNAME:$FURIOUS_PASSWORD" \
-X POST "$FURIOUS_API_URL" \
-H "Content-Type: application/json" \
-d '{
"User": {
"Search": {
"fields": ["id", "first_name", "last_name", "email", "pseudo", "job_title", "bu_name", "manager_pseudo", "status", "arrival_date", "departure_date", "entity_name"],
"limit": 200
}
}
}' > "$OUTPUT"
# Vérifier qu'on a bien des données
TOTAL=$(python3 -c "import json; data=json.load(open('$OUTPUT')); print(len(data.get('data', {}).get('User', [])))")
ACTIFS=$(python3 -c "import json; data=json.load(open('$OUTPUT')); print(sum(1 for u in data.get('data', {}).get('User', []) if u.get('departure_date') == '0000-00-00'))")
echo "✅ Fichier généré : $OUTPUT"
echo " Total : $TOTAL users"
echo " Actifs : $ACTIFS users"
chmod 600 "$OUTPUT"

View File

@ -0,0 +1,27 @@
#!/bin/bash
# ============================================================================
# test-auth.sh — Teste l'authentification à l'API Furious.
# ============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/.env"
echo "🔍 Test auth Furious..."
echo "URL : $FURIOUS_API_URL"
echo "User : $FURIOUS_USERNAME"
echo ""
# Test simple : lecture d'un user (id=1)
curl -s -u "$FURIOUS_USERNAME:$FURIOUS_PASSWORD" \
-X POST "$FURIOUS_API_URL" \
-H "Content-Type: application/json" \
-d '{
"User": {
"Search": {
"fields": ["id", "first_name"],
"limit": 1
}
}
}' | python3 -m json.tool

View File

@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seize Consulting — Intranet</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Inter+Tight:wght@400;500;600;700&display=swap" rel="stylesheet">
<!--
============================================================================
TEMPLATE INTRANET SEIZE CONSULTING
============================================================================
Ce fichier est un template. Le vrai fichier en production est sur le serveur
sur ~/docker/intranet/index.html.
Points clés :
- __USER_EMAIL__ est un placeholder substitué par nginx (sub_filter)
- Chaque carte service-card a un attribut data-app="<nom>" pour le RBAC
- Le JavaScript en bas fait le filtrage selon le rôle
============================================================================
-->
<style>
:root {
--c-bg-deep: #071C1C;
--c-surface: rgba(8, 57, 52, 0.6);
--c-surface-2: rgba(169, 212, 211, 0.04);
--c-accent: #008A81;
--c-accent-bright: #00C9B7;
--c-mint: #A9D4D3;
--c-lime: #F3FBBA;
--c-cream: #FBFBF4;
--c-text-dim: rgba(251, 251, 244, 0.55);
--c-text-muted: rgba(251, 251, 244, 0.4);
--c-border: rgba(169, 212, 211, 0.12);
--c-border-bright: rgba(169, 212, 211, 0.3);
--font-display: 'Space Grotesk', sans-serif;
--font-body: 'Inter Tight', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: var(--c-bg-deep);
color: var(--c-cream);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
}
/* ⚠️ Les styles complets (orbs, animations, etc.) sont dans le fichier réel.
Ici on garde le minimum pour ne pas surcharger le template. */
.container {
position: relative; z-index: 10;
max-width: 1400px; margin: 0 auto; padding: 60px 40px;
}
.brand {
display: flex; align-items: center; gap: 16px; margin-bottom: 60px;
}
.brand-logo img { height: 50px; }
.brand-title {
font-family: var(--font-display); font-size: 28px; font-weight: 700;
}
.intro {
margin-bottom: 50px;
}
.intro h1 {
font-family: var(--font-display); font-size: 48px; font-weight: 700;
margin-bottom: 12px;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.service-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 16px; padding: 24px;
display: flex; flex-direction: column; gap: 12px;
text-decoration: none; color: inherit;
transition: all 0.3s ease;
}
.service-card:hover {
border-color: var(--c-border-bright);
transform: translateY(-4px);
}
.card-title {
font-family: var(--font-display); font-size: 22px; font-weight: 600;
}
.card-desc {
color: var(--c-text-dim); font-size: 14px; line-height: 1.5;
}
.card-meta {
display: flex; gap: 8px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11px;
}
.card-url { color: var(--c-mint); }
.card-tag {
background: var(--c-surface-2); padding: 4px 10px; border-radius: 12px;
color: var(--c-accent-bright);
}
</style>
</head>
<body>
<div class="container">
<div class="brand">
<div class="brand-logo"><img src="/logo.png" alt="Seize"></div>
<div class="brand-title">Seize Consulting</div>
</div>
<div class="intro">
<h1>Tableau de bord</h1>
<p>Accédez à vos outils internes</p>
</div>
<div class="services-grid">
<!-- ⚠️ Chaque carte a un attribut data-app="<nom>" pour le RBAC -->
<!-- Le JavaScript en bas du fichier filtre ces cartes selon le rôle -->
<!-- PORTAINER -->
<a class="service-card" data-app="portainer" href="https://portainer.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">Portainer</div>
<div class="card-desc">Console de gestion des conteneurs Docker.</div>
<div class="card-meta">
<span class="card-url">portainer.seizeconsulting.com</span>
<span class="card-tag">Infra</span>
</div>
</a>
<!-- UPTIME KUMA / STATUS -->
<a class="service-card" data-app="status" href="https://status.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">Status</div>
<div class="card-desc">Surveillance de la disponibilité des services.</div>
<div class="card-meta">
<span class="card-url">status.seizeconsulting.com</span>
<span class="card-tag">Monitoring</span>
</div>
</a>
<!-- N8N -->
<a class="service-card" data-app="n8n" href="https://n8n.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">n8n</div>
<div class="card-desc">Plateforme d'automatisation de workflows.</div>
<div class="card-meta">
<span class="card-url">n8n.seizeconsulting.com</span>
<span class="card-tag">Automation</span>
</div>
</a>
<!-- GITEA -->
<a class="service-card" data-app="gitea" href="https://git.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">Gitea</div>
<div class="card-desc">Dépôts Git et gestion collaborative du code.</div>
<div class="card-meta">
<span class="card-url">git.seizeconsulting.com</span>
<span class="card-tag">Code</span>
</div>
</a>
<!-- CODE SERVER -->
<a class="service-card" data-app="code" href="https://code.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">Code Server</div>
<div class="card-desc">Environnement VS Code dans le navigateur.</div>
<div class="card-meta">
<span class="card-url">code.seizeconsulting.com</span>
<span class="card-tag">IDE</span>
</div>
</a>
<!-- TRAEFIK -->
<a class="service-card" data-app="traefik" href="https://traefik.seizeconsulting.com" target="_blank" rel="noopener">
<div class="card-title">Traefik</div>
<div class="card-desc">Reverse proxy et routage du trafic.</div>
<div class="card-meta">
<span class="card-url">traefik.seizeconsulting.com</span>
<span class="card-tag">Réseau</span>
</div>
</a>
</div>
<footer>
<div>SEIZE CONSULTING · © 2026</div>
<div>Hébergé en France · Made with care</div>
</footer>
</div>
<script>
/* ============================================================================
RBAC INTRANET — Filtrage des cartes selon rôle Zitadel
============================================================================
- __USER_EMAIL__ est remplacé par nginx (sub_filter) par la valeur du
header X-Forwarded-Email (envoyé par OAuth2-Proxy après auth Zitadel)
- On fetch /roles-mapping.json (généré par generate-roles-mapping.py)
- Pour chaque carte service-card[data-app], on cache si rôle pas autorisé
============================================================================ */
(async function() {
const USER_EMAIL = "__USER_EMAIL__"; // ← Substitué par nginx sub_filter
/* Matrice RBAC : qui voit quelle carte */
const ACCESS_MATRIX = {
"portainer": ["direction", "manager"],
"status": ["direction"],
"n8n": ["direction", "manager", "expert"],
"gitea": ["direction", "manager", "expert", "consultant_senior", "consultant_junior", "alternant", "stagiaire", "support"],
"code": ["direction", "manager", "expert", "consultant_senior", "consultant_junior", "alternant", "stagiaire", "support"],
"traefik": ["direction"]
};
let userRole = null;
try {
const response = await fetch("/roles-mapping.json", { cache: "no-store" });
if (response.ok) {
const mapping = await response.json();
userRole = mapping[USER_EMAIL.toLowerCase()] || null;
}
} catch (e) {
console.error("[RBAC] Erreur chargement mapping:", e);
}
console.log("[RBAC] User:", USER_EMAIL, "| Rôle:", userRole);
/* Filtrer les cartes selon le rôle */
document.querySelectorAll(".service-card[data-app]").forEach(card => {
const app = card.dataset.app;
const allowedRoles = ACCESS_MATRIX[app] || [];
if (!userRole || !allowedRoles.includes(userRole)) {
card.style.display = "none";
}
});
})();
</script>
</body>
</html>