LDAP Playground_
annuaire local en bac à sable.
Manipuler un annuaire LDAP avec Python, Docker et Apache Directory Studio. Bac à sable pour modéliser une entreprise fictive (GrosChat) — 50 utilisateurs, 3 régions, un groupe technique — et tester l'authentification annuaire avant de la brancher sur une vraie application.
Contexte & objectifs
Ce POC s'adresse aux développeurs, architectes ou équipes IT souhaitant créer un environnement de test local pour modéliser des utilisateurs, des groupes, et tester l'accès aux applications. Deux approches sont couvertes : un annuaire public pour vérifier la mécanique d'authentification, puis un OpenLDAP conteneurisé pour aller plus loin.
Objectifs du POC local
- Créer un annuaire LDAP local avec
osixia/openldap - Ajouter 50 utilisateurs d'une entreprise fictive GrosChat
- Répartir une partie dans trois régions : IDF, Savoie, CentreEst
- Créer un groupe
datastudio-userscontenant les développeurs - Gérer et visualiser l'ensemble via scripts Python & Apache Directory Studio
Tester un LDAP en ligne — sans rien installer
Avant de monter une infrastructure locale, il est utile de vérifier que la mécanique LDAP fonctionne depuis votre poste. Le site forumsys.com met à disposition un annuaire public en lecture seule.
Authentification Flask + ldap3
Le script ci-dessous tente une connexion à ldap.forumsys.com avec l'utilisateur
uid=einstein,dc=example,dc=com et le mot de passe password. Il valide un accès
LDAP simple en lecture seule via un formulaire web.
from flask import Flask, request, render_template_string
from ldap3 import Server, Connection, ALL
app = Flask(__name__)
LDAP_SERVER = 'ldap.forumsys.com'
BASE_DN = 'dc=example,dc=com'
HTML_FORM = """
<!doctype html>
<title>Login LDAP</title>
<h2>Connexion LDAP</h2>
<form method="post">
Nom d'utilisateur (uid): <input type="text" name="username"><br>
Mot de passe: <input type="password" name="password"><br>
<input type="submit" value="Se connecter">
</form>
<p>{{ message }}</p>
"""
@app.route('/', methods=['GET', 'POST'])
def login():
message = ''
if request.method == 'POST':
uid = request.form['username']
password = request.form['password']
user_dn = f"uid={uid},{BASE_DN}"
try:
server = Server(LDAP_SERVER, get_info=ALL)
conn = Connection(server, user=user_dn, password=password, auto_bind=True)
message = f"Authentification réussie pour {uid}"
except Exception as e:
message = f"Échec de l'authentification : {str(e)}"
return render_template_string(HTML_FORM, message=message)
if __name__ == '__main__':
app.run(debug=True)
ldap.jumpcloud.com
avec un compte gratuit.
Lancer un serveur LDAP local · Docker
L'image osixia/openldap permet de lancer un annuaire complet en une commande,
avec les variables d'environnement qui définissent l'organisation racine et l'utilisateur admin.
docker run --name my-openldap -p 389:389 -p 636:636 \
-e LDAP_ORGANISATION="MonEntreprise" \
-e LDAP_DOMAIN="mondomaine.com" \
-e LDAP_ADMIN_PASSWORD="admin" \
-d osixia/openldap:1.5.0
Après quelques secondes, l'annuaire est joignable sur le port 389 (clair) et
636 (TLS, mais non configuré ici). Le DN admin est cn=admin,dc=mondomaine,dc=com.
osixia/openldap n'est plus maintenue depuis 2022 — son
repo Docker Hub reste accessible en lecture seule. Elle fonctionne toujours pour un POC, mais
pour un usage durable on lui préférera aujourd'hui bitnami/openldap (maintenue par
Broadcom) ou lldap pour les cas d'usage simples. On aurait pu
partir directement sur Bitnami, mais le pattern osixia reste le plus
documenté dans la communauté et facilite la copie depuis les tutoriels existants.
cn=admin et son
mot de passe. Acceptable pour un bac à sable local, mais à proscrire dès que l'annuaire est
accessible au-delà de localhost. On aurait pu activer le TLS via
les variables LDAP_TLS_* de l'image et générer un certificat auto-signé pour le POC.
Se connecter avec Apache Directory Studio
Apache Directory Studio est un client LDAP graphique multi-plateforme : il permet de naviguer dans l'arbre, modifier les attributs, exécuter des recherches et exporter les entrées en LDIF.
Paramètres de connexion
Peupler l'annuaire · 50 utilisateurs + 3 régions
Le script populate.py commence par nettoyer les éventuelles anciennes entrées,
recrée les unités organisationnelles ou=people et ou=regions,
puis génère 50 utilisateurs aléatoirement répartis dans trois régions (avec 20% de comptes
sans région, pour simuler du réel imparfait).
Vocabulaire LDAP — repères avant lecture
dc=mondomaine,dc=com)inetOrgPerson, groupOfNames…)
dc=mondomaine,dc=com
├── ou=people · 50 inetOrgPerson
└── ou=regions · 3 groupOfNames (IDF, Savoie, CentreEst) + datastudio-users (créé en §07)
from ldap3 import Server, Connection, ALL, MODIFY_ADD, SUBTREE
import random
# Connexion
server = Server('localhost', port=389, get_info=ALL)
conn = Connection(server, user='cn=admin,dc=mondomaine,dc=com', password='admin', auto_bind=True)
# Fonctions utilitaires
def delete_children(base_dn):
conn.search(base_dn, '(objectClass=*)', search_scope=SUBTREE, attributes=['*'])
for entry in reversed(conn.entries): # du bas vers le haut
conn.delete(entry.entry_dn)
# Nettoyage des anciennes entrées
delete_children('ou=people,dc=mondomaine,dc=com')
delete_children('ou=regions,dc=mondomaine,dc=com')
conn.delete('ou=people,dc=mondomaine,dc=com')
conn.delete('ou=regions,dc=mondomaine,dc=com')
# Recréation des OU
conn.add('ou=people,dc=mondomaine,dc=com', 'organizationalUnit')
conn.add('ou=regions,dc=mondomaine,dc=com', 'organizationalUnit')
# Définition des régions
regions = ['IDF', 'Savoie', 'CentreEst']
# Création des groupes
for region in regions:
group_dn = f"cn={region},ou=regions,dc=mondomaine,dc=com"
conn.add(group_dn, ['groupOfNames'], {
'cn': region,
'member': 'cn=admin,dc=mondomaine,dc=com' # membre bidon requis pour init
})
# Création des utilisateurs
for i in range(1, 51):
uid = f"user{i:02d}"
user_dn = f"uid={uid},ou=people,dc=mondomaine,dc=com"
conn.add(user_dn, ['inetOrgPerson'], {
'cn': f'Utilisateur {i}',
'sn': f'Nom{i}',
'uid': uid,
'mail': f'{uid}@groschat.com',
'userPassword': 'password123'
})
# 20% sans groupe, sinon une seule région
if random.random() < 0.2:
continue
region = random.choice(regions)
region_dn = f"cn={region},ou=regions,dc=mondomaine,dc=com"
conn.modify(region_dn, {'member': [(MODIFY_ADD, [user_dn])]})
print("Réinitialisation complète terminée : 50 utilisateurs avec 0 ou 1 région.")
groupOfNames
Le schéma groupOfNames impose au moins un membre à la création
du groupe — d'où le cn=admin injecté de force, qu'on aurait normalement vocation
à retirer ensuite. On aurait pu éviter cette gymnastique de deux façons :
en utilisant groupOfUniqueNames (même contrainte, syntaxe différente, peu d'intérêt),
ou plus proprement en basculant sur posixGroup qui n'a pas de membre obligatoire.
Pour un POC, la rustine documentée est acceptable ; en prod, laisser cn=admin
résiduel dans un groupe applicatif est une fuite d'attribution silencieuse.
userPassword='password123' sans hash. OpenLDAP accepte
le mot de passe en clair côté API (il le stocke ensuite hashé selon sa config par défaut),
mais le fait de l'écrire dans le script Python le laisse traîner dans l'historique Git et
dans la mémoire du processus. On aurait pu générer un hash {SSHA}
via passlib avant l'envoi, ou tirer un mot de passe aléatoire unique par compte
et le logger à part.
Enrichir certains utilisateurs · statut « dev »
Le script enrichir.py parcourt les utilisateurs et flagge aléatoirement 40% d'entre eux
comme développeurs, via l'attribut standard description. Approche volontairement
simpliste : en production on utiliserait un attribut dédié ou un schéma étendu.
from ldap3 import Server, Connection, ALL, MODIFY_REPLACE, SUBTREE
import random
# Connexion
server = Server('localhost', port=389, get_info=ALL)
conn = Connection(server, user='cn=admin,dc=mondomaine,dc=com', password='admin', auto_bind=True)
# Recherche des utilisateurs
conn.search('ou=people,dc=mondomaine,dc=com', '(objectClass=inetOrgPerson)', search_scope=SUBTREE)
# Mise à jour avec "statut" dev (via description)
for entry in conn.entries:
if random.random() < 0.4: # 40% des users
dn = entry.entry_dn
conn.modify(dn, {'description': [(MODIFY_REPLACE, ['dev'])]})
print(f"Ajout de 'dev' à {dn}")
description comme flag métier
L'attribut description est un champ texte libre, multi-valué, sans
contrainte ni sémantique. L'utiliser comme drapeau métier (« dev », « manager »…)
revient à empiler des post-its sur un meuble : ça vieillit très mal — collisions avec d'autres
usages, pas d'intégrité référentielle, requêtes fragiles.
On aurait pu, par ordre de propreté croissante :
-
›
Utiliser
employeeType, déjà défini parinetOrgPersonet fait pour ça -
›
Créer un schéma custom
orgPersonavec un attributjobFamily -
›
Faire du groupe LDAP la source de vérité : on est dev parce qu'on est
dans
cn=devs,ou=roles,…, pas parce qu'un attribut texte le dit
description=dev est volontairement le pattern le
plus simple à montrer en 3 lignes — mais à ne pas reproduire en production.
Constituer le groupe datastudio-users
group-datastudio.py crée le groupe applicatif puis y ajoute automatiquement
tous les utilisateurs identifiés comme dev à l'étape précédente. C'est le pattern
classique pour piloter l'accès à une application à partir d'un attribut métier.
from ldap3 import Server, Connection, ALL, MODIFY_ADD, SUBTREE
server = Server('localhost', port=389, get_info=ALL)
conn = Connection(server, user='cn=admin,dc=mondomaine,dc=com', password='admin', auto_bind=True)
# 1. Créer le groupe
group_dn = 'cn=datastudio-users,ou=regions,dc=mondomaine,dc=com'
conn.add(group_dn, ['groupOfNames'], {
'cn': 'datastudio-users',
'member': 'cn=admin,dc=mondomaine,dc=com' # membre initial bidon obligatoire
})
# 2. Rechercher les "dev" (stockés ici dans description)
conn.search('ou=people,dc=mondomaine,dc=com', '(description=dev)', search_scope=SUBTREE)
# 3. Ajouter chaque dev au groupe
for entry in conn.entries:
conn.modify(group_dn, {'member': [(MODIFY_ADD, [entry.entry_dn])]})
print(f"Ajout de {entry.entry_dn} au groupe datastudio-users")
Purger totalement l'annuaire
Une fois les tests terminés, purge-all.py supprime récursivement les deux branches
principales — utile pour repartir d'un annuaire propre sans relancer le conteneur Docker.
from ldap3 import Server, Connection, ALL, SUBTREE
# Connexion
server = Server('localhost', port=389, get_info=ALL)
conn = Connection(server, user='cn=admin,dc=mondomaine,dc=com', password='admin', auto_bind=True)
def delete_branch(base_dn):
conn.search(base_dn, '(objectClass=*)', search_scope=SUBTREE)
for entry in reversed(conn.entries):
print(f"Suppression de {entry.entry_dn}")
conn.delete(entry.entry_dn)
conn.delete(base_dn)
# Supprimer les branches clés
for ou in ['ou=people,dc=mondomaine,dc=com', 'ou=regions,dc=mondomaine,dc=com']:
delete_branch(ou)
print("Annuaire nettoyé.")
Résultat dans Apache Directory Studio
Après exécution complète des scripts, l'annuaire affiche les utilisateurs enrichis et le groupe applicatif peuplé automatiquement. Captures prises depuis Apache Directory Studio.
description=dev
datastudio-users et ses membres