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

12 KiB

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.


📁 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)

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

<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)

<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)
  • 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 :

OAUTH2_PROXY_SCOPE=openid email profile urn:zitadel:iam:org:project:roles

Puis restart :

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 ?

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

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.

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

: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