IA locale pour PME suisse, guide décisionnel et procédures

Architectures IA locales pour PME suisses : RTX Spark, DGX Spark, RTX PRO 6000, H100. TCO réel, ingénierie RAG, conformité nLPD. Procédures opérationnelles publiées progressivement.

View the Project on GitHub doit4everyone/ia-locale-pme-suisse

§5 Connecteurs SMB et cloisonnement documentaire

Retour au sommaire Section précédente : §4 Interfaces utilisateur : Onyx CE et Open WebUI Section suivante : §6 Cline : agent de codage

Statut : validé en lab sur VM-RAG-LAB, septembre 2026. Formats indexés : .docx, .pdf, .pptx, .txt, .md. Les cas de test documentés ont été reproduits en session avec des comptes et corpus réels.


Ce que cette section documente : comment connecter la stack RAG à un file server Windows, propager les ACL NTFS jusqu’aux chunks Qdrant, et filtrer les résultats selon l’identité AD de l’utilisateur connecté. Le résultat : un utilisateur ne voit que les documents auxquels ses groupes AD donnent accès sur le file server.


§5.1 Principe : deux champs, deux rôles distincts

La première décision architecturale est de séparer clairement les deux types de métadonnées dans le payload Qdrant.

Champ Origine Usage
autorises[] ACL NTFS, lu par smbcacls Sécurité, filtrage d’accès
org_name Déduction heuristique Confort de navigation uniquement

autorises[] est une donnée constatée : elle reflète qui a réellement accès sur le file server. org_name est une donnée déduite par heuristique depuis le contenu ou la structure des dossiers. La confusion entre les deux est la cause principale des failles de cloisonnement dans les déploiements RAG d’entreprise.

Règle absolue : org_name ne doit jamais entrer dans une décision d’accès. Le cloisonnement repose exclusivement sur autorises[].


§5.2 Préparation du file server Windows

§5.2.1 Structure des dossiers et des partages SMB

La structure du file server conditionne la qualité du cloisonnement. Une organisation par département est la plus courante dans une PME.

<NOM-FILESERVER> désigne dans ce guide un serveur membre du domaine AD, distinct du contrôleur de domaine. Il héberge les partages SMB et gère les ACL NTFS. Dans un déploiement typique, c’est un Windows Server 2019 ou 2022 joint au domaine. Ne pas installer le rôle file server sur le contrôleur de domaine.

D:\FileService\
├── CLIENTS\          → partage SMB "CLIENTS"
│   ├── Client A\
│   └── Client B\
├── DIRECTION\        → partage SMB "DIRECTION"
├── RH\               → partage SMB "RH"
│   ├── CONTRATS DE TRAVAIL\
│   └── POLITIQUE RH\
├── COMPTABILITE\     → partage SMB "COMPTABILITE"
└── SERVICE INFO\     → partage SMB "SERVICE INFO"
    └── ADMIN\

Créer également un partage racine pour l’indexation unifiée :

# Partage racine pour l'indexeur (accès lecture à tous les départements)
New-SmbShare -Name "FileService" -Path "D:\FileService" `
    -FullAccess "DOMAINE\Admins du domaine" `
    -ReadAccess "DOMAINE\GRP-RAG-Indexation" `
    -Description "Racine FileService - indexeur RAG"

§5.2.2 Groupes AD par département

Créer un groupe de sécurité AD par périmètre d’accès. Ne jamais utiliser Utilisateurs du domaine comme groupe d’accès aux données sensibles.

# Adapter le chemin de l'OU à votre organisation
$ouGroupes = "OU=GROUPES,DC=votre-domaine,DC=ch"

New-ADGroup -Name "GRP-Clients" -GroupScope Global -GroupCategory Security `
    -Path $ouGroupes -Description "Accès documents clients"

New-ADGroup -Name "GRP-RH" -GroupScope Global -GroupCategory Security `
    -Path $ouGroupes -Description "Accès documents RH"

New-ADGroup -Name "GRP-Direction" -GroupScope Global -GroupCategory Security `
    -Path $ouGroupes

New-ADGroup -Name "GRP-Finances" -GroupScope Global -GroupCategory Security `
    -Path $ouGroupes

New-ADGroup -Name "GRP-ServiceInfo" -GroupScope Global -GroupCategory Security `
    -Path $ouGroupes

§5.2.3 ACL NTFS par dossier

Casser l’héritage et poser des ACL explicites sur chaque dossier. La permission Modify est suffisante pour les utilisateurs : elle permet la lecture, l’écriture et la suppression de fichiers, mais pas la modification des ACL elles-mêmes. Ne jamais accorder FullControl sur un dossier source de RAG : un utilisateur avec FullControl peut modifier les ACL du dossier et casser le cloisonnement depuis l’intérieur.

function Set-DossierACLPropre {
    param($Chemin, $Groupe)

    $acl = Get-Acl $Chemin
    # Casser l'héritage en conservant les règles existantes
    $acl.SetAccessRuleProtection($true, $true)
    Set-Acl $Chemin $acl

    $acl = Get-Acl $Chemin
    # Retirer Utilisateurs du domaine
    $acl.Access | Where-Object {
        $_.IdentityReference -like "*Utilisateurs du domaine*"
    } | ForEach-Object { $acl.RemoveAccessRule($_) | Out-Null }

    # Ajouter le groupe métier avec Modify (pas FullControl)
    $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
        "DOMAINE\$Groupe", "Modify",
        "ContainerInherit,ObjectInherit", "None", "Allow"
    )
    $acl.AddAccessRule($rule)
    Set-Acl $Chemin $acl
    Write-Host "OK : $Chemin -> $Groupe (Modify)"
}

Set-DossierACLPropre "D:\FileService\CLIENTS"         "GRP-Clients"
Set-DossierACLPropre "D:\FileService\RH"              "GRP-RH"
Set-DossierACLPropre "D:\FileService\DIRECTION"       "GRP-Direction"
Set-DossierACLPropre "D:\FileService\COMPTABILITE"    "GRP-Finances"
Set-DossierACLPropre "D:\FileService\SERVICE INFO\ADMIN" "GRP-ServiceInfo"

Casser l’héritage retire aussi, sur ces dossiers, la lecture du compte d’indexation accordée plus haut dans l’arborescence : Set-AccesIndexationRAG.ps1 (§5.2.4) la rétablit.

Sur les accès nominatifs : une ACL posée directement sur un compte utilisateur (DOMAINE\prenom.nom) fonctionne et est correctement lue par acl_resolver.py. Elle apparaîtra dans autorises[] sous la forme DOMAINE\prenom.nom, et auth.py l’ajoutera automatiquement dans les groupes résolus de l’utilisateur concerné (voir §5.5). C’est un comportement intentionnel, utile pour les exceptions ponctuelles. Par hygiène, préférer les groupes pour les accès structurels et réserver les accès nominatifs aux exceptions documentées.

§5.2.4 Compte de service svc-rag

Créer un compte de service dédié avec accès lecture à tous les dossiers à indexer. Ce compte peut lire l’ensemble du corpus : c’est la condition de l’indexation, et cela déplace le risque vers ce compte et son mot de passe. Le traiter comme un compte à privilèges : mot de passe long et unique, rotation planifiée, pas de session interactive, accès SSH désactivé, journalisation des accès activée sur le contrôleur de domaine.

# Adapter le chemin de l'OU à votre organisation
New-ADUser -Name "svc-rag" `
    -SamAccountName "svc-rag" `
    -UserPrincipalName "svc-rag@votre-domaine.ch" `
    -AccountPassword (ConvertTo-SecureString "<mot-de-passe-fort>" -AsPlainText -Force) `
    -PasswordNeverExpires $false `
    -CannotChangePassword $true `
    -Enabled $true `
    -Path "OU=COMPTES-SERVICE,DC=votre-domaine,DC=ch" `
    -Description "Compte de service indexeur RAG - lecture seule"

Donner la lecture par un groupe dédié, pas par les groupes métier

La lecture est accordée à un groupe dédié, GRP-RAG-Indexation, dont svc-rag est le seul membre. Ne pas ajouter svc-rag aux groupes métier (GRP-RH, GRP-Finances, etc.) :

Avec un groupe dédié, le périmètre de l’indexation est une décision explicite : les dossiers où ce groupe a la lecture, et rien d’autre. Les dossiers personnels, par exemple, en restent exclus.

Cas réel en lab. Dans la première version de ce guide, svc-rag lisait les dossiers RH, Direction et Comptabilité par les groupes utilisés aussi pour les droits des étiquettes Purview. Lors d’une revue des droits, il a été retiré de ces groupes, légitimement : un compte d’indexation n’a pas à pouvoir déchiffrer les documents. À la synchronisation suivante, acl_resolver.py n’a plus pu lire les permissions de six fichiers et les a signalés « acl_illisible ». Leurs autorises[] ont été vidés : les fichiers sont devenus invisibles pour tous dans le RAG, y compris pour les personnes autorisées. Aucune fuite, grâce au refus par défaut, mais une perte de service. Le montage SMB de la VM conservait l’ancien jeton du compte : indexer.py lisait encore les fichiers, et aurait perdu l’accès au redémarrage suivant.

Automatisation : Set-AccesIndexationRAG.ps1

Une permission posée sur un dossier n’atteint pas les sous-dossiers et fichiers dont l’héritage est coupé, ce que fait précisément §5.2.3 pour cloisonner les départements. Le script Set-AccesIndexationRAG.ps1, à exécuter en administrateur sur le serveur de fichiers :

  1. crée le groupe dédié s’il n’existe pas et y ajoute svc-rag ;
  2. accorde la lecture (RX, héritée) sur chaque dossier de premier niveau de la racine, sauf ceux exclus ;
  3. recherche les sous-dossiers et fichiers à héritage coupé, et accorde la lecture sur chacun ;
  4. contrôle, fichier par fichier, que le groupe a bien la lecture partout ;
  5. liste les autres groupes de svc-rag, qui ne sont plus nécessaires à l’indexation.
# Vérification seule : affiche ce qui manque, ne modifie rien
.\Set-AccesIndexationRAG.ps1 -Racine "D:\FileService" -Exclure "UTILISATEURS" -Verifier

# Application
.\Set-AccesIndexationRAG.ps1 -Racine "D:\FileService" -Exclure "UTILISATEURS"

Le script est idempotent : un droit déjà présent n’est pas ajouté une seconde fois. Le relancer après toute création de dossier à héritage coupé. Le rapport de synchronisation du RAG le rappelle : un fichier « acl_illisible » signale presque toujours un dossier où le groupe n’a pas la lecture.

Après une modification des groupes de svc-rag, remonter le partage sur la VM (§5.3.3) : la session SMB conserve le jeton d’accès du compte tel qu’il était au montage.

Une fois une synchronisation validée sans fichier « acl_illisible », retirer svc-rag des groupes métier dont il serait encore membre, puis relancer une synchronisation et comparer les compteurs : ils doivent être identiques.


§5.3 Montage des partages sur VM-RAG-LAB

§5.3.1 Prérequis

# Installer les outils SMB et LDAP
sudo apt install -y cifs-utils smbclient samba-common-bin ldap-utils

# Vérifier d'abord la résolution des noms AD : si elle fonctionne déjà, ne rien modifier
getent hosts <NOM-FILESERVER>
resolvectl status | grep -E "DNS Servers"
# Sinon, configurer le DNS du domaine
sudo nano /etc/systemd/resolved.conf

Seuls les DNS du domaine doivent figurer dans la configuration de la VM, jamais un DNS public à côté (détail et cas vécu : §0, étape 4). Si aucun DNS du domaine n’est configuré, ajouter dans le fichier :

[Resolve]
DNS=<IP-DC>
Domains=votre-domaine.ch
sudo systemctl restart systemd-resolved

# Vérifier la résolution
nslookup <NOM-FILESERVER>
# doit retourner l'IP du file server

§5.3.2 Fichier de credentials CIFS

Ne jamais passer le mot de passe en ligne de commande ou dans l’option -o password= : il apparaît dans la liste des processus (ps aux) et dans l’historique du shell, lisibles par tout utilisateur de la machine.

Sur le volume chiffré (2.22.3). Le fichier contient le mot de passe de svc-rag : comme le .env, il va sur le volume chiffré (§1.5), avec un lien à l’emplacement habituel. Les commandes de mise à jour (nano /etc/smbcredentials/svc-rag, rotation du §9.5.1) passent alors par le lien et écrivent sur le volume chiffré :

sudo mkdir -p /etc/smbcredentials
sudo ln -s /srv/rag-donnees/secrets/smb-svc-rag /etc/smbcredentials/svc-rag

Sans volume chiffré, le fichier est créé directement dans /etc/smbcredentials, sur le disque système, en clair.

# Créer le fichier de credentials (à travers le lien s'il existe)
sudo nano /etc/smbcredentials/svc-rag

# Contenu du fichier :
username=svc-rag
password=<mot-de-passe-fort>
domain=DOMAINE

# Protéger en lecture root uniquement (chmod et chown suivent le lien)
sudo chmod 600 /etc/smbcredentials/svc-rag
sudo chown root:root /etc/smbcredentials/svc-rag

§5.3.3 Monter le partage racine

sudo mkdir -p /mnt/corpus-root

sudo mount -t cifs //<NOM-FILESERVER>/FileService /mnt/corpus-root \
    -o credentials=/etc/smbcredentials/svc-rag,vers=3.1.1,uid=1000,gid=1000,ro

# Vérifier
ls /mnt/corpus-root/
# doit afficher : CLIENTS  COMPTABILITE  DIRECTION  RH  SERVICE INFO  ...
# Le répertoire DfsrPrivate est un répertoire système de réplication DFS,
# ainsi que System Volume Information et $RECYCLE.BIN.
# Ces répertoires sont dans EXCLUDE_DIR_PATTERNS d'indexer.py et ne sont pas indexés.
# Formats indexés : .docx, .pdf, .pptx, .txt, .md
# Un fichier dont l'organisation propriétaire n'est pas identifiable est mis en quarantaine
# (statut "quarantaine" dans le rapport JSON).
# Un PDF scanné sans couche texte ou un fichier corrompu ne produit aucun chunk
# (statut "vide"). Un document court est indexé en un seul chunk.
# Les tables et SmartArt PowerPoint ne sont pas extraits.

Pour rendre le montage persistant après redémarrage, ajouter dans /etc/fstab :

sudo tee -a /etc/fstab << 'FSTAB'

# Partage SMB FileService (corpus RAG, monté par svc-rag)
//<NOM-FILESERVER>/FileService /mnt/corpus-root cifs credentials=/etc/smbcredentials/svc-rag,vers=3.1.1,uid=1000,gid=1000,ro,soft,nofail,_netdev,x-systemd.after=/srv/rag-donnees 0 0
FSTAB

ro : lecture seule, la stack ne modifie jamais le partage (les versions du guide antérieures à la 2.20.4 indiquaient rw ici, deploy.sh montait déjà en ro). soft : si le serveur est inaccessible, les opérations I/O retournent une erreur au lieu de bloquer indéfiniment. nofail : si le partage est inaccessible au démarrage, la VM démarre quand même au lieu de rester bloquée en attente. _netdev : montage réseau. x-systemd.after=/srv/rag-donnees : le partage n’est monté qu’après le volume chiffré, qui porte ses identifiants (sans volume chiffré, retirer cette option).

Tester sans rebooter :

sudo mount -a
mount | grep fileservice
ls /mnt/corpus-root | head -5

Rotation du mot de passe : le fichier /etc/smbcredentials/svc-rag doit être mis à jour en même temps que .env lors de la rotation (§9.5.1). Si le fichier contient l’ancien mot de passe, le montage tombe au prochain reboot sans message d’erreur explicite.

Pourquoi le partage racine ? L’indexeur et le résolveur d’ACL doivent partager la même racine pour que les chemins relatifs soient identiques dans Qdrant. Si l’indexeur tourne depuis /mnt/corpus-root et stocke CLIENTS/ClientA/contrat.docx, le résolveur doit calculer ce chemin depuis la même racine.


§5.4 Résolveur d’ACL : acl_resolver.py

acl_resolver.py lit les ACL NTFS de chaque fichier via smbcacls et met à jour le champ autorises[] dans le payload Qdrant. Il tourne séparément de l’indexeur parce que les permissions peuvent changer sans que le contenu des fichiers ne change.

Fenêtre de péremption : les droits ne sont pas lus sur le partage à chaque question, mais recopiés dans l’index à chaque synchronisation. Deux fenêtres en découlent :

  • la latence normale : entre deux passes du résolveur, un droit révoqué reste actif dans l’index, que le contenu du document ait changé ou non. Avec une synchronisation horaire, un utilisateur dont l’accès vient d’être révoqué peut encore interroger les documents concernés pendant une heure au plus. C’est un arbitrage entre performance (éviter un appel smbcacls à chaque requête) et conformité ; la cadence est à définir selon les exigences de l’organisation (§7) ;
  • la fenêtre supplémentaire, quand une synchronisation est lente, interrompue ou en échec : depuis la 2.21.8, elle est bornée par ACL_AGE_MAX_HEURES (3 h par défaut). Au-delà, la RAG API refuse les questions et /sante le signale (§16.5.2).

Aucun de ces mécanismes ne rend une révocation instantanée. Si l’écriture des droits échoue, le document est retiré de la recherche et une alerte part (§16.5.2).

§5.4.1 Installation

cd /root/rag-pipeline
source .venv/bin/activate
# qdrant-client et httpx sont déjà installés pour l'indexeur
# smbclient est installé au §5.3.1

Copier acl_resolver.py dans /root/rag-pipeline/.

§5.4.2 Variables d’environnement

# Les variables sont lues depuis le fichier .env via :
set -a && source ~/rag-stack/.env && set +a

# Variables utilisées par acl_resolver.py :
# SMB_USER, SMB_PASSWORD, SMB_DOMAIN  : credentials svc-rag (pour smbcacls)
# QDRANT_URL                          : http://localhost:6333
#                                       (QDRANT_HOST n'est pas lu par acl_resolver.py)
# QDRANT_COLLECTION                   : documents (défaut)
# DOCUMENTATION_COLLECTION            : documentation (défaut)
# DOCUMENTATION_PATHS                 : préfixes routés vers documentation

# Note : SMB_SHARE et SMB_MOUNT ne sont pas lus par acl_resolver.py.
# Ils sont passés en arguments --share et --mount sur la ligne de commande
# (ou lus par main.py pour les transmettre au sous-processus /admin/sync).
# SMB_CREDENTIALS n'est pas une variable du script :
# acl_resolver.py utilise SMB_USER, SMB_PASSWORD et SMB_DOMAIN directement.

§5.4.2b Garde-fou en cas de panne globale

Si plus de 20% des fichiers du partage sont illisibles lors d’une passe (svc-rag verrouillé, DC injoignable, montage SMB tombé), acl_resolver.py abandonne sans modifier Qdrant et retourne le code de sortie 1 :

[GARDE-FOU] 66/66 fichiers en ACL illisible (100% > seuil 20%).
[GARDE-FOU] Abandon sans modification de Qdrant.
[GARDE-FOU] Verifier : svc-rag actif, DC joignable, montage SMB present.

Le workflow n8n détecte le code 1 et envoie un email d’alerte. Les ACL dans Qdrant ne sont pas modifiées : les accès restent tels qu’ils étaient avant la passe échouée.

Depuis la 2.20.4, deux cas s’arrêtent avant même ce seuil, toujours sans modifier Qdrant :

Le message [GARDE-FOU] est aussi écrit sur la sortie d’erreur, que la RAG API reprend dans le rapport de synchronisation et donc dans le courriel d’alerte.

Tester le garde-fou sans risque : désigner un partage inexistant sur le bon serveur. Le compte s’authentifie normalement, chaque lecture échoue, et le garde-fou doit abandonner (code 1). Ne pas tester avec un mot de passe faux.

python acl_resolver.py --share //<NOM-FILESERVER>/PartageInexistant --mount /mnt/corpus-root; echo "code retour : $?"

Ce test par un partage inexistant n’a pas encore été rejoué en lab.

§5.4.3 Premier lancement en dry-run

Toujours tester en dry-run avant d’écrire dans Qdrant.

python acl_resolver.py \
    --share //<NOM-FILESERVER>/FileService \
    --mount /mnt/corpus-root \
    --dry-run

Résultat attendu :

[1/12] CLIENTS\ClientA\contrat_maintenance.docx
Autorises (3) : S-1-5-21-1111111111-2222222222-3333333333-500, S-1-5-21-1111111111-2222222222-3333333333-512, S-1-5-21-1111111111-2222222222-3333333333-1124
  [DRY-RUN] 7 chunks
    autorises  : ['S-1-5-21-1111111111-2222222222-3333333333-500', 'S-1-5-21-1111111111-2222222222-3333333333-512', 'S-1-5-21-1111111111-2222222222-3333333333-1124']
→ 7 chunks mis à jour

Depuis la 2.22.0, les droits sont des SID (identifiants de sécurité Windows), lus par smbcacls --numeric : -500 est le compte Administrateur, -512 les Admins du domaine, -1124 ici le groupe GRP-Clients. Un groupe renommé garde son SID ; deux groupes de même nom ne se confondent plus (§16.5.3). Pour retrouver le nom d’un SID : Get-ADGroup -Identity <SID> ou Get-ADUser -Identity <SID> en PowerShell sur un contrôleur de domaine.

§5.4.4 Lancement réel

python acl_resolver.py \
    --share //<NOM-FILESERVER>/FileService \
    --mount /mnt/corpus-root

§5.4.5 Vérifier le payload Qdrant

curl -s http://localhost:6333/collections/documents/points/scroll \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {"must": [{"key": "source",
      "match": {"value": "CLIENTS/ClientA/contrat_maintenance.docx"}}]},
    "limit": 1,
    "with_payload": true
  }' | python3 -c "
import json, sys
r = json.load(sys.stdin)
p = r['result']['points'][0]['payload']
print('source:', p.get('source'))
print('autorises:', p.get('autorises'))
print('acl_updated_at:', p.get('acl_updated_at'))
"

Résultat attendu :

source: CLIENTS/ClientA/contrat_maintenance.docx
autorises: ['S-1-5-21-1111111111-2222222222-3333333333-500', 'S-1-5-21-1111111111-2222222222-3333333333-512', 'S-1-5-21-1111111111-2222222222-3333333333-1124']
acl_updated_at: 2026-09-10T11:03:30Z

§5.4.6 Rapport de quarantaine ACL

Le résolveur signale les fichiers présents sur le partage mais absents de Qdrant. Le rapport JSON est sauvegardé dans le dossier du script.

RAPPORT ACL RESOLVER
Fichiers traités        : 12
Chunks mis à jour       : 68
Fichiers sans ACL       : 0
Fichiers non indexés    : 3  ← à indexer avec indexer.py

§5.5 Authentification utilisateur : auth.py

auth.py résout l’identité d’un utilisateur à partir de son email, via LDAP, et retourne ses SID : celui du compte et ceux de tous ses groupes de sécurité, comparables aux SID de autorises[] (2.22.0, §16.5.3).

§5.5.1 Principe

Utilisateur connecté → email → LDAP → SID du compte et de ses groupes → filtre Qdrant

La résolution se fait en deux étapes : trouver le compte par son userPrincipalName, puis lire l’attribut calculé tokenGroups, qui donne en une lecture les SID de tous ses groupes de sécurité : appartenances directes, groupes imbriqués et groupe principal (« Utilisateurs du domaine »).

§5.5.2 Installation

auth.py, ldap3, et le Dockerfile complet sont décrits en §3. La stack déployée en §3 inclut déjà tout le nécessaire. Aucune modification manuelle du requirements.txt n’est nécessaire si §3 a été suivi.

Pour vérifier que ldap3 est bien dans l’image en place :

docker exec rag-api python3 -c "import ldap3; print('ldap3 OK')"

§5.5.3 Variables d’environnement

Ajouter dans /root/rag-stack/.env :

LDAP_HOST=<IP-DC>
LDAP_PORT=636
LDAP_USE_TLS=true
LDAP_BASE_DN=DC=votre-domaine,DC=ch
LDAP_BIND_DN=CN=svc-rag,OU=COMPTES-SERVICE,DC=votre-domaine,DC=ch
LDAP_BIND_PWD=<mot-de-passe-fort>
LDAP_DOMAIN=DOMAINE
# Groupes intégrés, « Tout le monde », « Utilisateurs authentifiés » (§16.5.3) : 0 par défaut
ACL_SID_ETENDUS=0

LDAP_BIND_PWD en clair dans le .env : ce compte lit l’annuaire AD complet pour résoudre les groupes. Traiter le fichier .env comme un secret : droits 600, propriétaire root, jamais commité dans Git. En production, préférer un gestionnaire de secrets (HashiCorp Vault, Azure Key Vault) ou les secrets Docker Swarm.

Ajouter dans le service rag-api du docker-compose.yml :

rag-api:
  environment:
    - LDAP_HOST=${LDAP_HOST}
    - LDAP_PORT=${LDAP_PORT}
    - LDAP_USE_TLS=${LDAP_USE_TLS}
    - LDAP_BASE_DN=${LDAP_BASE_DN}
    - LDAP_BIND_DN=${LDAP_BIND_DN}
    - LDAP_BIND_PWD=${LDAP_BIND_PWD}
    - LDAP_DOMAIN=${LDAP_DOMAIN}

§5.5.4 Points de sécurité

Injection LDAP : l’email reçu du header HTTP est échappé via escape_filter_chars() avant injection dans le filtre LDAP. Un email malformé comme user@domaine)(|(cn=*) ne peut pas casser la requête.

Groupes imbriqués et groupe principal : tokenGroups les contient. Jusqu’à la 2.21.11, auth.py remontait memberOf récursivement : les groupes imbriqués étaient trouvés, mais pas le groupe principal, que memberOf ne contient jamais. Un document autorisé à « Utilisateurs du domaine » seulement était invisible pour tous.

Deny by default : en cas d’erreur LDAP, d’utilisateur introuvable ou de tokenGroups illisible, auth.py retourne une liste vide. L’utilisateur ne voit aucun document. Il n’y a pas de repli sur une résolution partielle des groupes.

Cache TTL : les groupes sont mis en cache 5 minutes (configurable via GROUPS_CACHE_TTL) pour éviter un appel LDAP bloquant à chaque requête RAG.

Droit de lecture de tokenGroups : dans le lab, le compte de service l’a lu sans droit supplémentaire. Dans certains domaines durcis, il faut l’ajouter au groupe « Groupe d’accès d’autorisation Windows ». Le symptôme est sans ambiguïté : [AUTH] SID des groupes de '…' illisibles (…) : accès refusé dans le journal, et aucun document visible. Contrôle : docker exec -w /app rag-api python3 /rag-pipeline/diag_sid.py <compte> (§16.5.3).


§5.6 Configuration LDAP dans Open WebUI

Open WebUI doit transmettre l’identité de l’utilisateur connecté à la RAG API. Deux étapes sont nécessaires.

§5.6.1 Activer la transmission de l’identité

Dans docker-compose.yml, service open-webui (déjà présent dans le Compose du dépôt) :

open-webui:
  environment:
    - ENABLE_FORWARD_USER_INFO_HEADERS=true
    - FORWARD_USER_INFO_HEADER_JWT_SECRET=${OWUI_JWT_SECRET}

Sans la première variable, Open WebUI traite la RAG API comme un simple endpoint OpenAI et ne transmet pas l’identité. La seconde, depuis la 2.21.0, fait signer cette identité : Open WebUI (v0.11.3) n’envoie plus les en-têtes simples X-OpenWebUI-User-*, mais un seul en-tête X-OpenWebUI-User-Jwt, jeton HS256 qui porte l’adresse, le nom et le rôle de l’utilisateur, émis par open-webui et valable cinq minutes. La RAG API le vérifie avec le même secret (OWUI_JWT_SECRET, généré par deploy.sh) et refuse toute requête sans jeton valide (403). Les en-têtes simples ne sont plus pris en compte : quiconque connaissait API_TOKEN pouvait y écrire l’adresse d’un autre compte (§16.6).

cd /root/rag-stack
docker compose up -d open-webui

§5.6.2 Configurer l’authentification LDAP

Dans l’interface admin Open WebUI, naviguer vers Settings → Admin → Authentication → LDAP :

Paramètre Valeur
Étiquette Annuaire AD
Hôte <IP-DC>
Port 636
TLS Activé
Validate Certificate Désactivé
DN de l’application CN=svc-rag,OU=COMPTES-SERVICE,DC=votre-domaine,DC=ch
Mot de passe DN de l’application mot de passe de svc-rag
Attribut pour l’e-mail userPrincipalName
Attribut pour le nom d’utilisateur sAMAccountName
Base de recherche DC=votre-domaine,DC=ch
Filtres de recherche (objectClass=user)

Port 636 obligatoire : le port 389 est refusé par les contrôleurs de domaine Windows Server récents qui exigent LDAP Signing. LDAPS sur le port 636 contourne cette contrainte.

Validate Certificate désactivé : les DC Windows utilisent des certificats auto-signés par défaut. Pour la production, exporter le certificat CA du DC et le pointer via le champ “Chemin du certificat”. Voir §9.

Base de recherche DC=votre-domaine,DC=ch : certains comptes sont dans le conteneur CN=Users (par défaut Windows) plutôt que dans une OU. La base OU=UTILISATEURS ne les trouvera pas. La base racine couvre tous les cas.

userPrincipalName comme attribut email : l’attribut mail n’est pas toujours renseigné dans l’AD. userPrincipalName (format utilisateur@votre-domaine.ch) est toujours présent et utilisé comme identifiant unique par la RAG API.

§5.6.3 Paramètres utilisateurs

Dans Settings → Admin → Authentication → User Access :

Dans Settings → Admin → Models :


§5.7 Intégration dans main.py

La RAG API extrait l’adresse de l’utilisateur du jeton signé par Open WebUI (§5.6.1), résout les groupes AD via auth.py, et filtre la recherche Qdrant en conséquence.

§5.7.1 Filtre Qdrant par identité

from auth import get_user_groups

# Dans l'endpoint /v1/chat/completions (identité signée depuis la 2.21.0) :
# Jeton absent, expiré ou invalide → 403 Forbidden (identite_open_webui)
# Si la résolution LDAP échoue → 403 Forbidden
# Aucun accès non filtré n'est possible
owui_email2 = identite_open_webui(raw_request)   # vérifie X-OpenWebUI-User-Jwt
user_groups = get_user_groups(owui_email2)
if not user_groups:
    raise HTTPException(status_code=403, detail="Résolution des droits impossible")

# Dans search_qdrant() :
query_filter = None
if user_groups:
    query_filter = Filter(must=[
        FieldCondition(
            key="autorises",
            match=MatchAny(any=user_groups)
        )
    ])

results = qdrant.query_points(
    collection_name=COLLECTION,
    query=embedding,
    query_filter=query_filter,
    limit=top_k,
    with_payload=True
).points

§5.7.2 Vérification des DENY explicites

En NTFS, un DENY explicite est prioritaire sur un ALLOW. Après le filtre Qdrant, chaque chunk est vérifié individuellement :

for r in results:
    interdits = r.payload.get("interdits", [])
    if interdits and user_groups:
        if not check_access(user_groups,
                           r.payload.get("autorises", []),
                           interdits):
            continue  # chunk exclu malgré l'ALLOW
    chunks.append({...})

La fonction check_access() dans auth.py implémente la logique NTFS : accès accordé si (user_groups ∩ autorises != ∅) AND (user_groups ∩ interdits == ∅).

Chemin DENY validé en lab, septembre 2026. Voir §9.4.5 pour les résultats complets. En résumé : test-client, membre de GRP-Clients (dans autorises[]) mais visé par un ACE de refus nominatif (dans interdits[]), ne voit pas test-deny-explicite.docx, alors qu’un compte sans DENY dans le même groupe y accède normalement. Le DENY nominatif l’emporte sur l’autorisation par groupe, y compris sur le chemin d’extension de contexte.

Comportement sur les cas limites (corrigé en v2.5.0) :

  • interdits est toujours écrit dans Qdrant, même vide ([]). Si un DENY est retiré sur le file server, il sera écrasé au prochain passage du résolveur ACL.
  • Si les ACL d’un fichier sont illisibles (panne SMB, droits insuffisants), autorises est vidé dans Qdrant : le fichier devient invisible pour tous les utilisateurs filtrés jusqu’au prochain passage où les ACL redeviennent lisibles (deny by default).

§5.8 Validation du cloisonnement

Deux cas de test ont été validés en lab sur un corpus de documents d’entreprise.

§5.8.1 Cas 1 : administrateur du domaine (accès complet)

Compte : membre de Admins du domaine.

Logs RAG API :

Utilisateur trouvé : 'admin' (admin@votre-domaine.ch)
Groupes résolus : ['DOMAINE\GRP-ServiceInfo', 'DOMAINE\Admins du domaine',
                   'DOMAINE\admin', ...]  → 15 groupes
/v1 user 'admin@votre-domaine.ch' : 15 groupes AD

Admins du domaine figure dans autorises[] de tous les documents. L’utilisateur voit l’ensemble du corpus.

§5.8.2 Cas 2 : utilisateur limité à GRP-Clients (cloisonnement négatif)

Compte : membre de GRP-Clients uniquement.

Logs RAG API :

Utilisateur trouvé : 'user-clients' (user-clients@votre-domaine.ch)
Groupes résolus : ['DOMAINE\GRP-Clients', 'DOMAINE\user-clients']  → 2 groupes
/v1 user 'user-clients@votre-domaine.ch' : 2 groupes AD

Question posée : “Quelles sont les conditions du contrat de travail de [employé RH] ?”

Le document RH a autorises: ['DOMAINE\GRP-RH', ...]. GRP-Clients n’y figure pas. Qdrant retourne 0 chunk.

Réponse : “Cette information ne figure pas dans les documents disponibles.”

Les suggestions de suivi portaient uniquement sur les contrats clients, confirmant que seul le périmètre autorisé est visible.

§5.8.3 Cas 3 : utilisateur limité à GRP-Clients (cloisonnement positif)

Question posée : “Quelles sont les prestations incluses dans le contrat de maintenance avec [Client A] ?”

Réponse : réponse détaillée avec citations du document CLIENTS/ClientA/contrat_maintenance.docx.

Ce cas confirme que le filtre ne renvoie pas systématiquement zéro chunk : il renvoie exactement les documents autorisés, ni plus ni moins.

Tests validés en lab, septembre 2026. Le chemin DENY explicite (§5.7.2) et la résolution des groupes imbriqués multi-niveaux ont été exercés par des cas de validation dédiés. Les résultats sont documentés en §9.4.5.


§5.9 Deux collections Qdrant : règles de gouvernance

La stack utilise deux collections Qdrant distinctes pour séparer le corpus d’entreprise de la documentation technique :

Collection Contenu Cloisonnement ACL
documents Corpus entreprise (CLIENTS, RH, DIRECTION, etc.) Oui, par groupes AD
documentation Documentation technique : les dossiers de DOCUMENTATION_PATHS (DOIT4EVERYONE/ dans le lab ; aucun par défaut depuis la 2.20.4) Oui, selon les ACL NTFS du dossier dans Qdrant

Le routage se fait automatiquement à l’indexation selon le chemin relatif du fichier. Les dossiers dont le chemin commence par un préfixe de DOCUMENTATION_PATHS vont dans documentation. Tout le reste va dans documents.

Le filtre ACL s’applique identiquement aux deux collections. L’accès “universel” à documentation dépend des ACL NTFS posées sur le dossier correspondant sur le file server, pas du code. Si le dossier DOIT4EVERYONE/ a des ACL restrictives, ses chunks ne seront visibles que pour les utilisateurs autorisés.

Ces règles sont structurelles. Leur non-respect produit un routage silencieusement incorrect sans message d’erreur : des documents confidentiels peuvent se retrouver dans documentation sans cloisonnement ACL effectif.

Règle 1 : dossiers de documentation uniquement à la racine du partage. Un dossier déclaré dans DOCUMENTATION_PATHS doit être directement à la racine de \\SERVEUR\FileService. Le routage compare le début du chemin relatif du fichier au nom déclaré : un dossier CLIENTS\DOIT4EVERYONE\ n’est pas reconnu, et ses fichiers restent dans documents. Un chemin avec sous-dossier (CLIENTS/DOC) serait reconnu par le code, mais contredit cette règle : deploy.sh le refuse.

Règle 2 : droits AD sur les dossiers racine. Seul un administrateur peut créer des dossiers à la racine du partage.

Règle 3 : modifier DOCUMENTATION_PATHS exige une réindexation complète.

# Modifier .env, puis :
docker compose build --no-cache rag-api && docker compose up -d rag-api
cd /root/rag-pipeline && source .venv/bin/activate
set -a && source /root/rag-stack/.env && set +a
python indexer.py --corpus /mnt/corpus-root --reset
# Le port 8080 n'est pas publié. Tester depuis le réseau Compose :
# --post-data est obligatoire : /admin/sync n'accepte que POST (sinon réponse 405).
cd /root/rag-stack
TOKEN=$(grep '^ADMIN_TOKEN=' .env | cut -d= -f2-)
docker compose exec n8n wget -qO- --post-data='' \
  --header="Authorization: Bearer $TOKEN" \
  http://rag-api:8080/admin/sync | python3 -m json.tool
unset TOKEN

Règle 4 : vérifier les deux compteurs après chaque réindexation.

curl -s http://localhost:6333/collections/documents | python3 -m json.tool | grep points_count
curl -s http://localhost:6333/collections/documentation | python3 -m json.tool | grep points_count
# Les deux compteurs additionnés doivent correspondre au total des chunks indexés.

Retour au sommaire Section précédente : §4 Interfaces utilisateur : Onyx CE et Open WebUI Section suivante : §6 Cline : agent de codage

Validé en lab sur VM-RAG-LAB, septembre 2026. Les commandes et résultats présentés sont issus de sessions de test réelles.