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 : §10 Validation et performances sur GPU | Section suivante : §12 Synthèse des réunions Teams |
Statut : validé en lab sur VM-RAG-LAB, septembre 2026. Toutes les étapes de cette section ont été exécutées et testées sur un tenant Microsoft 365 réel synchronisé par Entra Connect. L’indexation des documents SharePoint n’est pas couverte ici : elle fait l’objet de §13.
Ce que cette section documente : les fondations de la Partie 3. La stack RAG apprend à interroger Microsoft Graph et à connaître les groupes Entra ID d’un utilisateur, en plus de ses groupes Active Directory. Les scripts existants sont mis à niveau pour cohabiter avec de futurs documents SharePoint, sans rien changer au fonctionnement de la stack SMB validée en Parties 1 et 2.
La stack des Parties 1 et 2 cloisonne les documents du file server à partir des ACL NTFS : chaque chunk porte la liste des groupes AD autorisés (autorises[]), et auth.py résout les groupes AD de l’utilisateur par LDAP. Pour SharePoint, le principe reste identique, mais les permissions sont exprimées en identités Entra ID. Il faut donc que auth.py connaisse aussi les groupes Entra de l’utilisateur.
| Élément | Stack SMB (Parties 1 et 2) | Ajout Partie 3 |
|---|---|---|
| Source des permissions | ACL NTFS du file server | Permissions SharePoint (§13) |
Format dans autorises[] |
DOMAINE\Groupe |
entra:grp:<id>, entra:usr:<id> |
| Résolution de l’utilisateur | LDAP sur le contrôleur de domaine | Microsoft Graph |
| Interface utilisateur | Open WebUI, authentification LDAP AD | Inchangée |
Les deux formats coexistent dans la même liste de groupes. Un chunk SMB ne contient que des groupes DOMAINE\Groupe : les identifiants Entra ajoutés à l’utilisateur ne lui donnent accès à aucun document SMB supplémentaire.
La VM n’échange rien avec le serveur Entra Connect. Entra Connect synchronise l’AD vers Entra ID dans le cloud, et la VM interroge ensuite Entra ID par Microsoft Graph :
Active Directory → Entra Connect → Entra ID (cloud)
↑
VM-RAG-LAB (rag-api) → HTTPS 443 → login.microsoftonline.com (jeton)
→ graph.microsoft.com (groupes)
Le seul besoin réseau est une sortie HTTPS vers ces deux points de terminaison, depuis la VM et depuis le conteneur rag-api.
On pourrait penser que, si tous les groupes viennent de l’AD, la résolution LDAP suffit aussi pour SharePoint. Le test de §11.6 montre le contraire : pour le compte test-client, LDAP trouve deux groupes, Graph en trouve trois. Le troisième, « Tous les utilisateurs », est un groupe dynamique créé dans Entra ID, qui n’existe pas dans l’AD. Les groupes Microsoft 365, les groupes créés depuis Teams et les groupes dynamiques ne sont visibles que dans Entra ID. Un site SharePoint partagé avec l’un d’eux ne peut être cloisonné correctement qu’avec la résolution Graph.
votre-domaine.ch) déclaré comme domaine vérifié dans le tenant. Sans cela, l’UPN transmis par Open WebUI ne correspond à aucun compte dans Entra ID.Vérification dans le centre d’administration Entra : Utilisateurs → le compte de test → le nom d’utilisateur principal doit être identique à celui utilisé dans Open WebUI, et l’onglet Propriétés doit indiquer que la synchronisation locale est activée.
Depuis la VM :
curl -sI https://login.microsoftonline.com | head -1
curl -sI https://graph.microsoft.com/v1.0/ | head -1
docker exec rag-api python3 -c "import httpx; print(httpx.get('https://graph.microsoft.com/v1.0/').status_code)"
timedatectl | grep -E "synchronized|NTP"
Résultat obtenu en lab :
HTTP/1.1 200 OK
HTTP/1.1 405 Method Not Allowed
200
System clock synchronized: yes
NTP service: active
Le code 405 sur la deuxième ligne est normal : curl -I envoie une requête HEAD, que Graph refuse. Seule compte l’obtention d’une réponse HTTP. La troisième ligne confirme que le conteneur atteint Graph (DNS et sortie Internet depuis le réseau Docker).
L’heure système n’est pas un détail. L’authentification par certificat repose sur une assertion signée et horodatée. Un décalage de quelques minutes entre la VM et Microsoft suffit à faire rejeter le jeton, avec un message d’erreur peu explicite. La synchronisation NTP doit être active.
L’application s’authentifie auprès d’Entra ID par certificat, sans secret client. La clé privée est générée sur la VM et n’en sort jamais : seule la partie publique est chargée dans Entra ID.
sudo mkdir -p /etc/rag-certs && sudo chmod 700 /etc/rag-certs
cd /etc/rag-certs
sudo openssl req -x509 -newkey rsa:2048 -nodes \
-keyout rag-identity.key -out rag-identity.crt \
-days 730 -subj "/CN=RAG-Identity-Resolver"
sudo chmod 600 rag-identity.key
# Partie publique à charger dans Entra ID
sudo cat rag-identity.crt
# Empreinte SHA-1, pour contrôle après chargement
openssl x509 -in rag-identity.crt -noout -fingerprint -sha1
Copier le bloc affiché par cat, de -----BEGIN CERTIFICATE----- à -----END CERTIFICATE----- inclus, dans un fichier rag-identity.crt sur le poste d’administration. Ce fichier ne contient que la partie publique et peut circuler sans risque.
Échéance à suivre : le certificat est valable deux ans. À son expiration, la résolution Entra échoue et les utilisateurs perdent l’accès aux documents SharePoint (l’accès SMB n’est pas affecté, voir §11.5.2). Le renouvellement est à planifier au même titre que la rotation du mot de passe de
svc-rag(§9.5.1).
La Partie 3 utilise trois App Registrations distinctes, chacune limitée aux droits de sa fonction et authentifiée par son propre certificat :
| Application | Rôle | Permissions (application) | Section |
|---|---|---|---|
RAG-Identity-Resolver |
auth.py : résoudre les groupes Entra d’un utilisateur ; sp_indexer.py : traduire les UPN et lire les propriétaires des groupes |
Microsoft Graph : User.Read.All, GroupMember.Read.All |
§11 |
RAG-Teams-Reader |
Lire les transcriptions des réunions Teams | Microsoft Graph : OnlineMeetingTranscript.Read.All, limitée par une stratégie d’accès applicatif Teams au groupe d’adhésion |
§12 |
RAG-SharePoint-Indexer |
Lire les documents SharePoint, leurs permissions et les groupes des sites | Microsoft Graph et SharePoint : Sites.Selected, lecture accordée site par site |
§13 |
Séparer les applications limite l’impact d’une compromission : le certificat de résolution d’identité ne donne accès à aucun document, celui de la lecture des transcriptions ne donne accès qu’aux réunions des organisateurs du groupe d’adhésion, et celui de l’indexeur ne donne accès ni à l’annuaire complet ni aux sites qui ne lui ont pas été accordés. Le déchiffrement des documents protégés par Purview, à venir, fera l’objet d’une application distincte.
Ne jamais utiliser
Sites.Read.Allpour l’indexation : cette permission donne accès à tous les sites du tenant, y compris ceux qui ne doivent pas être indexés. Si une ancienne inscription dispose de cette permission, elle doit être supprimée, ainsi que l’application d’entreprise associée.
Dans le centre d’administration Entra (entra.microsoft.com) :
RAG-Identity-Resolverrag-identity.crt. Vérifier que l’empreinte affichée correspond à celle calculée en §11.3.User.Read.All et GroupMember.Read.All → Ajouter des autorisations.User.Read ajoutée par défaut : elle ne sert à rien pour une application sans utilisateur connecté.Aucun secret client ne doit être créé.
Attention au menu : l’inscription se fait dans Inscriptions d’applications, pas dans Applications d’entreprise. Ce second menu sert à ajouter des applications tierces de la galerie. L’application d’entreprise correspondant à l’inscription est créée automatiquement.
Les modifications de cette section préparent l’arrivée des documents SharePoint. Elles sont conçues pour ne rien changer au comportement de la stack SMB : sans document SharePoint et avec l’extension Entra désactivée, le résultat est strictement identique à la version validée en Parties 1 et 2.
Version de référence des Parties 1 et 2 : les scripts tels qu’ils ont été validés avant ces modifications restent disponibles dans la Release v2.12.0.
acl_resolver.py v4 : protection des futurs chunks SharePointLe problème. acl_resolver.py supprime les chunks « orphelins », présents dans Qdrant mais absents du partage SMB. Un chunk issu de SharePoint n’est, par définition, jamais sur le partage : sans modification, tous les chunks SharePoint seraient supprimés à chaque synchronisation horaire.
La correction. Chaque chunk porte désormais un champ source_type (smb ou sharepoint). La détection des orphelins ne considère que les chunks smb. Un chunk sans ce champ, comme tous ceux indexés avant la Partie 3, est traité comme smb : aucune réindexation n’est nécessaire.
Un défaut corrigé au passage. La suppression des orphelins récupérait les chunks à supprimer par un parcours limité à 500 points. Un fichier orphelin de plus de 500 chunks n’était donc supprimé que partiellement, et le reste restait interrogeable. La suppression se fait maintenant par filtre, sans limite.
Déploiement (script exécuté hors conteneur et, via /admin/sync, dans le conteneur par le montage en lecture seule) :
sudo cp /root/rag-pipeline/acl_resolver.py /root/rag-pipeline/acl_resolver.py.v3.bak
sudo cp /home/<utilisateur>/acl_resolver.py /root/rag-pipeline/acl_resolver.py
cd /root/rag-pipeline
source .venv/bin/activate
python -m py_compile acl_resolver.py && echo "OK"
deactivate
Aucun redémarrage n’est nécessaire.
auth.py : extension Entra IDUne nouvelle fonction get_entra_groups() interroge Graph et retourne :
entra:usr:<object-id> pour le compte lui-même ;entra:grp:<object-id> pour chaque groupe, imbrications comprises (transitiveMemberOf, l’équivalent de la récursion memberOf faite côté LDAP). Les rôles d’annuaire et les unités administratives sont écartés.get_user_groups() n’appelle cette fonction que si ENTRA_ENABLED=true. Le comportement en cas d’échec est conçu pour ne jamais élargir l’accès :
| Situation | Groupes retournés | Conséquence |
|---|---|---|
ENTRA_ENABLED=false |
Groupes AD uniquement | Comportement des Parties 1 et 2 |
| Graph répond | Groupes AD + identifiants Entra | Accès SMB et SharePoint |
| Graph échoue (réseau, certificat expiré, consentement retiré) | Groupes AD uniquement, non mis en cache | Accès SMB intact, aucun accès SharePoint, nouvel essai à la requête suivante |
| Utilisateur absent d’Entra ID | Groupes AD uniquement | Accès SMB intact, aucun accès SharePoint |
| LDAP échoue | Aucun groupe | Refus complet (403), comme en Parties 1 et 2 |
L’authentification par certificat utilise la bibliothèque MSAL de Microsoft, qui met le jeton en cache et ne le renouvelle qu’à expiration. msal est ajouté au requirements.txt de l’image.
Variables à ajouter au .env, extension désactivée dans un premier temps :
# Extension Entra ID (Partie 3)
ENTRA_ENABLED=false
ENTRA_TENANT_ID=<ID-ANNUAIRE>
ENTRA_CLIENT_ID=<ID-APPLICATION>
ENTRA_CERT_PATH=/etc/rag-certs/rag-identity.crt
ENTRA_KEY_PATH=/etc/rag-certs/rag-identity.key
ENTRA_CERT_THUMBPRINT=<EMPREINTE-SHA1-SANS-DEUX-POINTS>
Ajouts au service rag-api du docker-compose.yml :
environment:
# (après GROUPS_CACHE_TTL)
- ENTRA_ENABLED=${ENTRA_ENABLED}
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
- ENTRA_CERT_PATH=${ENTRA_CERT_PATH}
- ENTRA_KEY_PATH=${ENTRA_KEY_PATH}
- ENTRA_CERT_THUMBPRINT=${ENTRA_CERT_THUMBPRINT}
volumes:
# (après /var/log/rag)
- /etc/rag-certs:/etc/rag-certs:ro
Pour appliquer ces ajouts sur une installation existante sans éditeur, en prenant /var/log/rag comme repère pour le montage (la ligne du certificat AD existe aussi dans le service Open WebUI et ne convient pas) :
cd /root/rag-stack
cp docker-compose.yml docker-compose.yml.bak
grep -q 'ENTRA_ENABLED=' docker-compose.yml || sed -i \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_ENABLED=${ENTRA_ENABLED}' \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_TENANT_ID=${ENTRA_TENANT_ID}' \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}' \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_CERT_PATH=${ENTRA_CERT_PATH}' \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_KEY_PATH=${ENTRA_KEY_PATH}' \
-e '/- GROUPS_CACHE_TTL=\${GROUPS_CACHE_TTL}/a\ - ENTRA_CERT_THUMBPRINT=${ENTRA_CERT_THUMBPRINT}' \
docker-compose.yml
grep -q '/etc/rag-certs' docker-compose.yml || sed -i \
'/- \/var\/log\/rag:\/var\/log\/rag/a\ - /etc/rag-certs:/etc/rag-certs:ro' \
docker-compose.yml
docker compose config --quiet && echo "Compose OK"
Chaque variable référencée dans le Compose doit exister dans le .env : une variable absente est transmise vide au conteneur, ce qui écrase la valeur par défaut du code.
Déploiement de auth.py et requirements.txt (rebuild obligatoire, requirements.txt ayant changé) :
sudo cp /home/<utilisateur>/auth.py /root/rag-stack/api/auth.py
sudo cp /home/<utilisateur>/requirements.txt /root/rag-stack/api/requirements.txt
cd /root/rag-stack
docker compose build --no-cache rag-api
docker compose up -d rag-api
sleep 15
docker logs rag-api 2>&1 | grep "BM25\|startup\|Uvicorn\|Traceback" | tail -5
acl_resolver.py v4Non-régression, en dry-run dans le conteneur, c’est-à-dire dans l’environnement réellement utilisé par la synchronisation horaire :
cd /root/rag-stack
docker exec rag-api sh -c 'QDRANT_URL=$QDRANT_HOST python3 /rag-pipeline/acl_resolver.py \
--share "$SMB_SHARE" --mount "$SMB_MOUNT" --dry-run \
--rapport /var/log/rag/rapport_acl_dryrun.json' | tail -15
Validé en lab, septembre 2026 :
Fichiers traites : 69
Chunks mis a jour : 1691
Fichiers sans ACL : 0
Fichiers non indexes : 0
NETTOYAGE DES CHUNKS ORPHELINS
Aucun chunk orphelin détecté.
Protection des chunks SharePoint. Un faux chunk sharepoint, sans aucun droit d’accès (autorises vide, donc invisible pour tous les utilisateurs), est inséré dans Qdrant, puis une synchronisation complète est lancée :
python3 - <<'EOF'
import json, urllib.request
base = "http://localhost:6333/collections/documents"
dim = json.load(urllib.request.urlopen(base))["result"]["config"]["params"]["vectors"]["size"]
body = {"points": [{"id": "00000000-0000-0000-0000-00000000f001", "vector": [0.01] * dim,
"payload": {"source": "sharepoint/TEST/test-v4.docx", "source_type": "sharepoint",
"text": "test v4", "autorises": [], "interdits": []}}]}
req = urllib.request.Request(base + "/points?wait=true", data=json.dumps(body).encode(),
method="PUT", headers={"Content-Type": "application/json"})
print(urllib.request.urlopen(req).read().decode())
EOF
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
curl -s http://localhost:6333/collections/documents/points/00000000-0000-0000-0000-00000000f001 \
| python3 -m json.tool | head -8
Validé en lab, septembre 2026 : la synchronisation retourne "success": true et le faux chunk est toujours présent après son passage. Avec la version 3, il aurait été supprimé comme orphelin.
Nettoyage du chunk de test, puis contrôle des compteurs, qui doivent revenir à leur valeur initiale :
curl -s -X POST "http://localhost:6333/collections/documents/points/delete?wait=true" \
-H "Content-Type: application/json" \
-d '{"points": ["00000000-0000-0000-0000-00000000f001"]}'
docker compose exec n8n wget -qO- http://rag-api:8080/stats
Test 1 : non-régression, extension désactivée. Une question posée dans Open WebUI avec un compte AD obtient la même réponse qu’avant la mise à niveau, et les logs ne contiennent aucune ligne Entra :
docker logs rag-api 2>&1 | grep "\[AUTH\]" | tail -5
Test 2 : appel Graph direct, sans impact sur les utilisateurs. La fonction est appelée dans le conteneur alors que l’extension est encore désactivée. Ce test valide à lui seul le certificat, les permissions et le consentement :
docker exec rag-api python3 -c "import auth; print(auth.get_entra_groups('test-client@votre-domaine.ch'))"
Validé en lab, septembre 2026 :
['entra:usr:<ID-OBJET-UTILISATEUR>', 'entra:grp:<ID-GROUPE-1>', 'entra:grp:<ID-GROUPE-2>', 'entra:grp:<ID-GROUPE-3>']
L’identifiant entra:usr: correspond à l’ID d’objet affiché pour ce compte dans le centre d’administration Entra.
Pour identifier les groupes retournés :
docker exec rag-api python3 -c "
import auth, httpx
t = auth._get_graph_token()
for gid in ['<ID-GROUPE-1>', '<ID-GROUPE-2>', '<ID-GROUPE-3>']:
r = httpx.get(f'https://graph.microsoft.com/v1.0/groups/{gid}', params={'\$select':'displayName,onPremisesSyncEnabled,groupTypes'}, headers={'Authorization':'Bearer '+t}).json()
print(gid[:8], r.get('displayName'), '| synchronisé AD :', r.get('onPremisesSyncEnabled'), '| types :', r.get('groupTypes'))
"
Résultat obtenu en lab :
| Groupe | Synchronisé depuis l’AD | Type | Visible par LDAP |
|---|---|---|---|
| Tous les utilisateurs | Non | Groupe dynamique | Non |
| GRP-Clients-Niveau2 | Oui | Groupe de sécurité | Oui |
| GRP-Clients | Oui | Groupe de sécurité | Oui |
C’est la démonstration de §11.1.3 : le groupe dynamique « Tous les utilisateurs » n’existe que dans Entra ID.
Test 3 : extension activée.
sed -i 's/^ENTRA_ENABLED=false/ENTRA_ENABLED=true/' .env
docker compose up -d rag-api
Après une question posée dans Open WebUI avec test-client :
docker logs rag-api 2>&1 | grep "\[AUTH\]" | tail -5
Validé en lab, septembre 2026 :
[AUTH] Utilisateur trouvé : 'test-client' (test-client@votre-domaine.ch)
[AUTH] Groupes résolus pour 'test-client@votre-domaine.ch' : ['DOMAINE\\GRP-Clients-Niveau2', 'DOMAINE\\GRP-Clients', 'DOMAINE\\test-client']
[AUTH] Entra : 4 identifiant(s) pour 'test-client@votre-domaine.ch'
La réponse dans Open WebUI est identique à celle obtenue sans l’extension, citation et chemin UNC compris : l’ajout des identifiants Entra ne modifie pas l’accès aux documents SMB.
Retour arrière immédiat : repasser
ENTRA_ENABLED=falsedans le.envpuisdocker compose up -d rag-apineutralise l’extension sans rebuild.
Délai de prise en compte des changements de groupe. Un utilisateur ajouté à un groupe AD n’obtient les droits correspondants côté Entra qu’après le cycle de synchronisation d’Entra Connect (30 minutes par défaut), auquel s’ajoute le cache des groupes d’auth.py (GROUPS_CACHE_TTL, 5 minutes par défaut). Le délai cumulé peut atteindre 35 minutes.
Libellé de log trompeur. Avec l’extension activée, main.py affiche « 7 groupes AD » alors que ce nombre inclut les identifiants Entra. C’est un libellé, sans effet sur le filtrage. Il est corrigé depuis §12 (« groupe(s) et identité(s) »).
Portée de User.Read.All. Cette permission permet de lire les profils de tous les utilisateurs du tenant, pas seulement ceux qui utilisent la stack RAG. C’est le prix de la résolution par UPN. La protection repose sur la clé privée, qui ne quitte pas la VM (/etc/rag-certs, droits 600).
Autorisation « Tout le monde sauf les utilisateurs externes ». SharePoint propose cette autorisation, qui n’est pas un groupe Entra ID et n’aura donc jamais d’identifiant entra:grp:. Elle est traduite en entra:tous-internes, ajouté par auth.py aux comptes membres du tenant (§13.4.4).
| Point | Commande / vérification | Résultat attendu |
|---|---|---|
| Sortie HTTPS vers Microsoft | curl -sI https://login.microsoftonline.com \| head -1 |
HTTP/1.1 200 OK |
| Graph depuis le conteneur | Commande de §11.2.2 | 200 |
| Heure synchronisée | timedatectl |
System clock synchronized: yes |
| Clé privée protégée | ls -l /etc/rag-certs |
rag-identity.key en -rw------- |
| App Registration | Centre d’administration Entra | Deux permissions application, consentement accordé, aucun secret |
acl_resolver.py v4 |
Dry-run de §11.6.1 | Aucun orphelin, aucune erreur |
| Protection SharePoint | Test du faux chunk de §11.6.1 | Chunk conservé après /admin/sync |
Graph depuis auth.py |
Test 2 de §11.6.2 | entra:usr: + groupes |
| Extension active | Test 3 de §11.6.2 | Ligne [AUTH] Entra : N identifiant(s) |
| Non-régression SMB | Question dans Open WebUI | Réponse identique, avec citation |
| Retour au sommaire | Section précédente : §10 Validation et performances sur GPU | Section suivante : §12 Synthèse des réunions Teams |
Validé en lab sur VM-RAG-LAB, septembre 2026, sur un tenant Microsoft 365 synchronisé par Entra Connect. L’indexation SharePoint (§13) et le déchiffrement des documents protégés par Purview (§14) ne sont pas couverts par cette section.