davidmabillot.com
pocs / ldap-playground
POC · Annuaire LDAP · 2025 ~1h de setup

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.

Public
Dev · Architecte · IT
Niveau
Intermédiaire
Durée
~1h setup complet
Stack
Python ldap3 Docker OpenLDAP Flask
01 / 09

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-users contenant les développeurs
  • Gérer et visualiser l'ensemble via scripts Python & Apache Directory Studio
02 / 09

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.

app.py · Flask + ldap3
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)
Note Cette étape sert uniquement de validation : la suite du POC bascule sur un annuaire local où l'on peut écrire, supprimer et restructurer librement.
Disponibilité du service public forumsys.com a connu plusieurs coupures durables depuis 2024. Si la connexion échoue, c'est probablement le serveur, pas votre script. Alternatives qui fonctionnent : monter directement l'OpenLDAP local (chapitre suivant), ou utiliser le serveur de test ldap.jumpcloud.com avec un compte gratuit.
03 / 09

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.

bash · lancement du conteneur
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.

Image non maintenue L'image 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.
Sécurité réseau Le port 636 (LDAPS) est exposé mais aucun certificat n'est configuré : toutes les communications passent en clair sur le port 389, y compris le 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.
04 / 09

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

Hostlocalhost
Port389
Bind DNcn=admin,dc=mondomaine,dc=com
Passwordadmin
05 / 09

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

DNDistinguished Name — chemin unique d'une entrée dans l'arbre, lu de droite à gauche
DCDomain Component — l'équivalent d'un composant DNS (dc=mondomaine,dc=com)
OUOrganizational Unit — un « dossier » dans l'arbre (people, regions, services…)
CNCommon Name — nom usuel de l'entrée (utilisateur, groupe, ressource)
UIDUser ID — identifiant de connexion (souvent l'identifiant Unix historique)
objectClassSchéma d'attributs auquel l'entrée appartient (inetOrgPerson, groupOfNames…)
Modèle de données du POC L'arbre construit est : dc=mondomaine,dc=com
├── ou=people · 50 inetOrgPerson
└── ou=regions · 3 groupOfNames (IDF, Savoie, CentreEst) + datastudio-users (créé en §07)
populate.py
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.")
Le « membre bidon » dans 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.
Mot de passe en clair pour les 50 users Chaque utilisateur reçoit 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.
06 / 09

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.

enrichir.py
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}")
Anti-pattern assumé — 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 par inetOrgPerson et fait pour ça
  • Créer un schéma custom orgPerson avec un attribut jobFamily
  • 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
Pour ce POC, description=dev est volontairement le pattern le plus simple à montrer en 3 lignes — mais à ne pas reproduire en production.
07 / 09

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.

group-datastudio.py
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")
08 / 09

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.

purge-all.py
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é.")
Attention Ce script supprime sans confirmation. Vérifier le DN de connexion avant exécution : sur un annuaire de prod par erreur, les conséquences sont immédiates et difficilement réversibles.
09 / 09

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.

Utilisateur dev dans Directory Studio
Fig. 1 — Un utilisateur avec statut description=dev
Groupe datastudio-users avec ses membres
Fig. 2 — Le groupe datastudio-users et ses membres