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
| 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.
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_namene doit jamais entrer dans une décision d’accès. Le cloisonnement repose exclusivement surautorises[].
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"
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
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 paracl_resolver.py. Elle apparaîtra dansautorises[]sous la formeDOMAINE\prenom.nom, etauth.pyl’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.
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"
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-raglisait 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.pyn’a plus pu lire les permissions de six fichiers et les a signalés « acl_illisible ». Leursautorises[]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.pylisait encore les fichiers, et aurait perdu l’accès au redémarrage suivant.
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 :
svc-rag ;RX, héritée) sur chaque dossier de premier niveau de la racine, sauf ceux exclus ;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.
# 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
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
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-ragdoit être mis à jour en même temps que.envlors 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-rootet stockeCLIENTS/ClientA/contrat.docx, le résolveur doit calculer ce chemin depuis la même racine.
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/santele 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).
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/.
# 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.
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 :
NT_STATUS_LOGON_FAILURE, NT_STATUS_ACCOUNT_LOCKED_OUT, compte désactivé ou expiré) : arrêt au premier fichier. Continuer sur les autres verrouillerait svc-rag dans l’Active Directory, et avec lui la connexion de tous les utilisateurs. Cas vécu dans le lab : un test avec un mot de passe faux a verrouillé le compte dès le quatrième fichier.NT_STATUS_INVALID_PARAMETER, un message qui n’oriente pas vers le DNS.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.
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.
python acl_resolver.py \
--share //<NOM-FILESERVER>/FileService \
--mount /mnt/corpus-root
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
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
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).
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 »).
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')"
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_PWDen clair dans le.env: ce compte lit l’annuaire AD complet pour résoudre les groupes. Traiter le fichier.envcomme 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}
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).
Open WebUI doit transmettre l’identité de l’utilisateur connecté à la RAG API. Deux étapes sont nécessaires.
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
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 conteneurCN=Users(par défaut Windows) plutôt que dans une OU. La baseOU=UTILISATEURSne les trouvera pas. La base racine couvre tous les cas.
userPrincipalNamecomme attribut email : l’attributuserPrincipalName(formatutilisateur@votre-domaine.ch) est toujours présent et utilisé comme identifiant unique par la RAG API.
Dans Settings → Admin → Authentication → User Access :
en attente (pas utilisateur). Conforme à §4.2.4 : chaque nouveau compte LDAP est examiné par un administrateur avant d’accéder aux documents. Un compte activé reçoit ensuite le rôle utilisateur.Dans Settings → Admin → Models :
... à côté du modèle rag-api → Make Public. Sans cette étape, les utilisateurs non-admin voient une liste de modèles vide à la connexion.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.
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
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 deGRP-Clients(dansautorises[]) mais visé par un ACE de refus nominatif (dansinterdits[]), ne voit pastest-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) :
interditsest 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),
autorisesest 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).
Deux cas de test ont été validés en lab sur un corpus de documents d’entreprise.
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.
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.
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.
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” à
documentationdépend des ACL NTFS posées sur le dossier correspondant sur le file server, pas du code. Si le dossierDOIT4EVERYONE/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
documentationsans 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.