seize-infra-doc/docs/07-rbac-intranet.md
Hedi Kalboussi 1e90a85359 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
2026-06-05 17:49:43 +00:00

404 lines
12 KiB
Markdown

# 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