[couverture de Linux Magazine 68]

Utilisation de Net::LDAP

Article publié dans Linux Magazine 68, janvier 2005.

Copyright © 2004 - Jérôme Fenal.

[+ del.icio.us] [+ Developers Zone] [+ Bookmarks.fr] [Digg this] [+ My Yahoo!]

Chapeau de l'article

Après avoir vu un peu de théorie et de mise en pratique de LDAP avec Linux, et puisque nous sommes dans la rubrique tenue par les Mongueurs de Perl, attachons-nous au sujet qui nous intéresse : Net::LDAP.

Pourquoi utiliser Perl ?

Perl est un langage qui sait à peu près tout faire, et LDAP, ainsi que LDIF sont tout à fait dans ses capacités.

Ses capacités de traitement de chaînes de caractères ainsi que les structures de données qu'il met à disposition du programmeur permettent, par exemple, de réécrire à peu de frais notre propre version des outils de migration de PADL.com ; le but étant d'intégrer facilement d'autres sources de données qu'un simple fichier /etc/passwd.

Historiquement, Perl a aussi été un des premiers langages utilisés avec LDAP, Netscape ayant développé une interface Perl sur la base de ses API LDAP écrites en langage C. Le module en question est perldap, module toujours disponible lorsque vous achetez Sun Java System Directory Server (le nouveau nom de Netscape Directory Server, après son passage par la société iPlanet). Notez qu'une distribution de Perl est intégrée au produit. À ce sujet, vous pourrez même bientôt tester un petit frère de ce produit lorsque Red Hat aura ouvert le code de Netscape Directory Server, racheté à AOL, ex-coactionnaire de iPlanet avec Sun.

Citons enfin comme autre moyen d'accès à LDAP la suite de modules Net::LDAP développée par Graham Barr. Cette suite a le gros avantage d'être écrite nativement et totalement en Perl, et donc de ne pas employer de code XS nécessitant un compilateur C et surtout des bibliothèques LDAP. C'est ce module spécifique que nous allons voir plus en détail.

Pourquoi utiliser Perl avec LDAP ?

Plusieurs raisons à cela :

C'est ainsi que nous allons voir comment utiliser Net::LDAP afin de faire nos modifications directement dans notre annuaire.

Net::LDAP

Installation

Net::LDAP (aussi connu sous le nom de sa distribution sur CPAN, perl-ldap) est donc l'une des deux API LDAP pour Perl. En tant que module non-standard (i.e. pas distribué avec l'interpréteur Perl lui-même), il faut l'installer avant utilisation :

  root@ldap1# cpan Net::LDAP¶

Attention, il y a plusieurs dépendances, dont Convert::ASN1.

Net::LDAP est un module orienté objet, donc nous créerons un objet d'accès à l'annuaire. Nous utiliserons ensuite les méthodes associées à cet objet pour lancer nos requêtes sur l'annuaire.

Connexion à l'annuaire

La connexion à l'annuaire est très simple, il suffit d'instancier un objet Net::LDAP puis d'utiliser la méthode bind :

  my $ldap = Net::LDAP->new('ldap1.example.com', port => 389, version => 3)
    or die "erreur LDAP: Impossible de contacter l'annuaire ($@)";

La variable $@ indiquée dans le die est le message d'erreur du dernier appel à la fonction eval. Sa présence ici n'est pas forcément nécessaire, mais elle permet d'attraper le cas échéant les erreurs sur un require qui est utilisé par Net::LDAP. Dit plus court, ça sert (dans le cas de Net::LDAP) à savoir s'il y a des erreurs de syntaxe dans les modules.

Le premier paramètre est le nom du serveur LDAP. Ce premier paramètre peut aussi être une URI, telle que ldap://localhost:389/, ce qui dispenserait de spécifier le port dans les options. La notation par URI est surtout intéressante dans le cas où votre script Perl Net::LDAP devra s'exécuter sur la même machine que le serveur LDAP, et de communiquer avec lui sans utiliser le réseau. En effet, c'est le seul moyen de donner le nom du socket Unix pour une connexion LDAPI :

  my $ldap = Net::LDAP->new('ldapi://%2fvar%2frun%2fldapi_socket',
                            version => 3)

La méthode new peut prendre d'autres paramètres, dont le plus intéressant à connaître est timeout.

La connexion proprement dite (bind) est tellement simple que je ne détaillerai pas :

  $ldap->bind ( "cn=Manager,dc=example,dc=com", password => "secret" );

Pour une connexion anonyme, il suffit de ne pas passer d'argument à bind.

Requête et récupération des réponses

La méthode search permet d'effectuer vos requêtes, elle retourne un objet de classe Net::LDAP::Search. Cet objet référencera ensuite les objets de l'annuaire répondant aux filtres passés pour la recherche, qui seront de type Net::LDAP::Entry.

Net::LDAP::Search permet plusieurs méthodes d'accès aux objets retournés, que ce soit avec une sémantique tableau (méthode entries(), ou une sémantique pile ou liste, avec la méthode pop_entry().

Cette classe d'objet Net::LDAP::Search permet aussi la gestion des erreurs avec leur détection (méthode code()) et leur documentation (méthode error()). Ceci constitue une adaptation de la méthologie habituelle consistant à faire retourner une valeur scalaire (un entier en l'occurrence) véhiculant le code erreur. En C comme en Perl, on a souvent pour permettre des constructions des langages telle que :

  if ( $mon_objet->sa_methode(\%entrees, \%sorties) ) {
    # Nous traitons ici dans ce bloc de code une erreur.
    ...
  }

Ici, la méthode (ou la fonction en C) doit retourner 0 en cas de succès, et une valeur non nulle en cas d'erreur. Dans le cas de Net::LDAP, ce qui est retourné par les méthodes de l'accesseur Net::LDAP est donc un objet de classe Net::LDAP::Search. Or il se trouve que cette classe dérive de la classe Net::LDAP::Message, classe qui définit entre autres deux méthodes particulières : code() et error(). De la même façon, les autres méthodes retournent un objet dont la classe, adaptée au contexte de la méthode Net::LDAP appelée, dérive de Net::LDAP::Message. La référence sur le message, retournée par toutes les opérations hormis l'instanciation de l'objet d'accès à l'annuaire, permet ainsi de gérer le contrôle d'erreur. On peut se permettre de l'oublier rapidement (nous sommes dans le cadre de scripts rapides à développer et à jeter) grâce à la simple ligne :

  $mesg->code && die $mesg->error;

Ce qui diffère des habitudes. Mais nous sommes dans un contexte objet, et cette manière de retourner des objets ubiquitaires, retournant à la fois le code retour de l'opération les ayant créé en plus des données permet de tout gérer en même temps, au prix d'une ligne de code de plus.

Dans le cadre de programmes nécessitant une gestion plus fine des erreurs, utilisez un autre moyen d'informer l'utilisateur que die et optez pour des comportements différents suivant les valeurs de $mesg->code.

Les différentes valeurs du code erreur sont disponibles dans la page de manuel de Net::LDAP::Constant. Par exemple :

Des informations complémentaires vous sont aussi données dans les pages de manuel Net::LDAP::Message et Net::LDAP::FAQ.

Mais passons à notre première requête : ou=Paris

  #!/usr/bin/perl -w
  use strict;
  use Net::LDAP;

  my $mesg;

  my $ldap = Net::LDAP->new("ldap1.example.com", port => 389, version => 3)
     or die "erreur LDAP: Can't contact master ldap server ($@)";

  $mesg = $ldap->bind( "cn=Manager,dc=example,dc=com", password => 'secret' );
  $mesg->code && die $mesg->error;

  $mesg = $ldap->search(
    base   => 'dc=example,dc=com',
    scope  => 'sub',
    filter => '(ou=Paris)'
  );

  $mesg->code && die $mesg->error;
  foreach my $entry ($mesg->all_entries) {
    print "dn: ".$entry->dn()."\n";
    foreach my $attr ($entry->attributes) {
        foreach my $value ($entry->get_value($attr)) {
          print "$attr: $value\n";
      }
    }	
  }

  $ldap->unbind();

Notez l'imbrication des méthodes pour accéder au contenu des objets LDAP. Un fait qui peut être troublant est que la méthode attributes renvoie toujours un tableau (ou une référence sur un tableau si l'option asref => 1 est passée à la méthode get_values). Cela est dû au fait qu'il n'y a pas de différence de traitement entre les attributs multivalués et ceux qui ne le sont pas.

L'appel à la méthode unbind signifie la demande de déconnexion de l'annuaire par le programme.

La méthode search peut aussi prendre un paramètre nommé attrs qui permet de limiter la liste des attributs retournés par la requête. Par défaut, tous les attributs auxquels les listes de contrôle d'accès vous permettent d'accéder sont retournés. Si vous ne voulez que tester la présence ou non d'objets par rapport à votre filtre, spécifiez attrs => [ '1.1' ]. Vous pouvez aussi accéder aux attributs de gestion de l'annuaire, comme les horodatages sur les objets :

  $mesg =  $ldap->search(
    base   => 'dc=example,dc=com',
    scope  => 'sub',
    filter => '(&(objectClass=posixAccount)(ou=Paris))'
    attrs  => [ qw( * createTimestamp modifyTimestamp ) ]
  );

L'étoile est le joker désignant tous les attributs retournés par défaut. Ainsi, tous les attributs « normaux » sont retournés, plus createTimestamp et modifyTimestamp.

Ajout d'un enregistrement

L'ajout est tout aussi simple que le reste. Il suffit de spécifier à la méthode add le DN du nouvel objet, ainsi que la liste des attributs.

  $mesg = $ldap->add( "uid=dmitri,ou=Moscou,ou=People,dc=example,dc=com",
    attr => [
        uid           => 'dmitri',
        cn            => 'dmitri',
        objectClass   =>
            [ 'top', 'person', 'inetOrgPerson', 'posixAccount', 'shadowAccount' ],
        uidNumber     => 1007,
        gidNumber     => '513',
        userPassword  => '$pass',
        cn            => 'dmitri',
        loginShell    => '/bin/bash',
        gecos         => 'Charlie dmitri',
        description   => 'System User',
        homeDirectory => '/home/dmitri',
        sn            => 'dmitri'
    ]
  );

Lorsqu'un attribut est multivalué, on passe une référence à un tableau (éventuellement anonyme comme ici) contenant toutes les valeurs de l'attribut au lieu de la simple valeur. Nous sommes en contexte scalaire, attr est donc une référence à un tableau contenant tous les attributs de l'objet. De façon similaire, il est possible de modifier la valeur d'un attribut ou d'enlever un attribut (non nécessaire, i.e. en MAY dans le schéma).

Ces trois méthodes add, modify et delete doivent être suivies d'un appel à la méthode update de façon à lancer la mise à jour de l'annuaire. Dans le cas contraire, les modifications effectuées sur l'objet courant sont perdues.

Mais voyons plutôt ces trois méthodes en action.

Exemple concret : modification massive dans l'annuaire

Un besoin courant dans la mise en place d'un annuaire est l'ajout ou la modification en masse d'attributs sur tous les objets de l'annuaire.

Le script qui suit, volontairement non générique, permet donc de modifier/ajouter/enlever un attribut à la fois sur tous les objets de l'annuaire qui répondent au filtre spécifié dans la requête.

  #!/usr/bin/perl -w

  use strict;
  use Getopt::Std;
  use Net::LDAP;

  use vars qw($attr $val $opt_h $opt_a $opt_m $opt_d);

  sub say_usage {

    print "modldap - Modificateur simple d'attributs LDAP\n";
    print "\n\n\t$0  [-a] attribut valeur ...\n";
    print << 'EOT';

  Options:
    [-d] :			Détruire un attribut
    [-m] :			Modifier un attribut
    [-a] :			Ajouter un attribut (pour les attributs multivalués)
    [-h] :			help : montre ce texte d'aide
  EOT
    exit 0;
  }

  getopts('admh');  # Sets opt_*
  say_usage if $opt_h;

  if (! defined($attr=shift)) {
    print "ERREUR : Spécifiez un attribut LDAP à modifier\n";
    say_usage;
  };
  if (! $opt_d && ! defined($val=shift)) {
    print "ERREUR : Spécifiez une valeur pour l'attribut\n";
    say_usage;
  };

  my $ldap = Net::LDAP->new('ldap1.example.com', port => 389, version => 3)
    or die "ERREUR LDAP : Impossible de contacter l'annuaire ($@)";

  $ldap->bind ( "cn=Manager,dc=example,dc=com", password => "secret" );

  my $mesg = $ldap->search( base => 'ou=People, dc=example,dc=com',
          scope  => 'sub',
          filter => '(objectClass=*)'
          );
  $mesg->code && die $mesg->error;
  LOOP: foreach my $entry ($mesg->all_entries) {
    my $uid    = lc($entry->get_value('uid'));
    my $action = 'nop';
    my @attrl;
    $action = 'add'    if $opt_a;
    $action = 'modify' if $opt_m;
    $action = 'delete' if $opt_d;

    @attrl = $entry->get_value($attr);
    print "Old values :".join(" ", @attrl)."\n" if @attrl;

    my $newval = $val;
    $newval =~ s/\%U/$uid/g;

    ACTION: for ($action) {
      /nop/ and do {
        print "Rien à faire\n";
        last ACTION;
      };
      /add/ and do {
        print "Ajout attribut $attr à $newval\n";
        $entry->add($attr, $newval);
        $entry->update($ldap);
        last ACTION;
      };
      /modify/ and do {
        next LOOP if $newval eq $attrl[0];  # Attention ici, pas de modification
                                            # si l'attribut a déjà la bonne valeur
        print "Modification attribut $attr à $newval\n";
        $entry->replace($attr, $newval);
        $entry->update($ldap);
        last ACTION;
      };
      /delete/ and do {
        if (@attrl) {
          print "Destruction attribut $attr\n";
          $entry->delete($attr, [ ]);
          $entry->update($ldap);
        }
        last ACTION;
      };
    }
    print "-" x 60 . "\n";
  }
  $ldap->unbind;

Ce script m'a rendu maintes fois service sur des annuaires importants. N'oubliez pas que Perl sait facilement faire des manipulations (des filtres ?) sur le texte, comme par exemple la ligne $newval =~ s/\%U/$uid/g; qui permet de modifier un %U présent dans la valeur de l'attribut en l'uid du compte : pratique pour modifier un attribut tel que le répertoire racine (home directory) des comptes utilisateurs.

Renommage/déplacement d'objet

Le renommage et le déplacement d'objets dans l'arbre de l'annuaire se fait facilement à la main avec un outil graphique, avec un simple tirer-lâcher (drag'n drop). Cependant, au début d'un déploiement d'annuaire, on peut vouloir changer l'organisation du DIT, comme passer d'une organisation hiérarchisée avec plusieurs sous-OU à une organisation à plat de l'annuaire. Mais quand on a plus de 10 entrées à déplacer, il vaut mieux commencer à automatiser.

L'automatisation peut se faire par le biais d'un vidage de l'annuaire (avec un slapcat ou un ldapsearch), suivi d'un tripotage de tout le LDIF résultant (presque) manuel (quelques expressions rationnelles Perl aideront). Le résultat sera ensuite réinjecté dans l'annuaire, nécessitant un arrêt de production et l'indisponibilité de l'annuaire qui vont avec.

Une autre solution est de déplacer les objets dans l'annuaire (en ayant prêté attention aux requêtes telles que celles faites par PAM). Comme sous Unix, où la commande mv permet à la fois de renommer et de déplacer des fichiers entre répertoires, la méthode moddn permet de renommer voire de déplacer les objets entre différentes OU dans LDAP. C'est cette fonctionnalité qui est derrière le tirer-lâcher des outils graphiques.

Prêtons-nous, pour illustrer la solution à ce besoin, à un petit exercice d'utilisation de moddn. Nous allons faire passer tout notre petit monde dans une nouvelle OU qui va s'appeler Mongueurs. Voici comment faire :

  #!/usr/bin/perl -w

  use strict;
  use Net::LDAP;

  my $ldap = Net::LDAP->new('ldap1.example.com', port => 389, version => 3)
    or die "erreur LDAP : Impossible de contacter l'annuaire ($@)";

  $ldap->bind ( "cn=Manager,dc=example,dc=com", password => "secret" );

  my $mesg = $ldap->search( base => "ou=People, dc=example,dc=com",
                            scope => 'sub', filter => '(objectclass=person)'
  );
  $mesg->code && die $mesg->error;
  foreach my $entry ($mesg->all_entries) {
    my $dn = $entry->dn();
    my $newrdn = $1 if $dn =~ m/([^,]+)/;
    print STDERR "$dn sera renommé en $newrdn\n";
    $mesg = $ldap->moddn( $dn, newrdn => $newrdn,
        newsuperior => "ou=Mongueurs,ou=People,dc=example,dc=com");
    $mesg->code && die "Impossible de renommer $dn : ".$mesg->error;
  }
  $ldap->unbind;

Ici nous récupérons le RDN (en enlevant tout ce qui vient après la première virgule) sans pour autant le modifier. Si on modifie le RDN, il ne faudra pas oublier de penser à l'option deleteoldrdn de la méthode moddn, qui permet de ne pas conserver l'ancien RDN en plus du nouveau (attention à respecter le schéma et gare aux collisions). Cela peut permettre une certaine période de migration sur un annuaire.

Faites néanmoins attention à ce que vous faites, et n'hésitez jamais à faire vos tests sur un annuaire contenant une réplique des données de production, ou si vous n'avez pas de troisième serveur (car vous en avez déjà deux, n'est-ce-pas ?), ou à défaut sur une copie des données de production dans une OU ou mieux, une database LDAP de test sur votre annuaire OpenLDAP.

Synchronisation entre deux annuaires

Une autre des utilisations de Net::LDAP peut être de vous permettre de synchroniser deux annuaires ne sachant ou ne pouvant pas forcément parler entre eux (que ce soit pour une raison technique ou politique, la sécurité étant un exemple).

La solution est donc d'aller interroger les deux annuaires se présentant à vous, de faire les comparaisons sur les entrées de chacun, et ensuite soit de générer du LDIF permettant la synchronisation, soit d'aller ajouter directement les objets dans l'annuaire devant reprendre les informations de l'autre. L'approche pour ce faire va donc être de récupérer en masse les noms des objets, de façon à passer le moins de temps possible dans l'annuaire, de faire les comparaisons de présence/absence (je ne parle pas des modifications sur les objets en eux-même), puis de copier les objets du premier annuaire dans le second, voire de détruire les objets ayant disparu du premier. Un détail avant de commencer : n'escomptez pas sans outil particulier répliquer les mots de passe si votre annuaire source est un Active Directory. Celui-ci les stocke dans un attribut unicodePwd, mais ne les restitue pas. Tant pis pour la migration transparente à Samba...

La première passe de recherche va donc simplement consister en la requête sur le UID de chacun des deux annuaires, à les mettre dans des hachages, et à compter les clés d'un côté et de l'autre. Pour ce faire, on lance successivement sur chacun des deux annuaires une requête, et on stocke l'UID dans un hachage.

Pour émuler avec un seul annuaire deux arborescences de comptes, il nous suffit de dupliquer dans une nouvelle OU une partie de nos comptes. Ce qui peut nous donner ceci :

  $ ldapsearch -D "cn=Manager,dc=example,dc=com" -s sub -b 'dc=example,dc=com' \
  > -w secret -s sub -x '(objectClass=posixAccount)' -LLL dn | sed '/^$/d' | sort¶

  dn: uid=book,ou=Europe,dc=example,dc=com
  dn: uid=grinder,ou=Europe,dc=example,dc=com
  dn: uid=grinder,ou=Paris,ou=People,dc=example,dc=com
  dn: uid=leon,ou=Bath,ou=People,dc=example,dc=com
  dn: uid=leon,ou=Europe,dc=example,dc=com
  dn: uid=nicholas,ou=Europe,dc=example,dc=com
  dn: uid=nicholas,ou=London,ou=People,dc=example,dc=com
  dn: uid=sniper,ou=Paris,ou=People,dc=example,dc=com
  dn: uid=ymmv,ou=Europe,dc=example,dc=com
  dn: uid=ymmv,ou=Paris,ou=People,dc=example,dc=com

Nous avons donc cinq comptes dans l'OU Europe (book, grinder, leon, nicholas, et ymmv), et cinq autres dans l'arborescence créée auparavant (leon, nicholas, grinder, sniper et ymmv). BooK n'est plus dans l'arborescence originelle qui nous servira de source, alors que Sniper n'est que dans l'annuaire source.

Le petit script suivant vous permet de voir comment nous pouvons procéder pour détecter les présences ou absences d'un compte dans un annuaire ou l'autre :

  #!/usr/bin/perl -w
  use strict;
  use Net::LDAP;
  use Data::Dumper;

  my %comptes;
  my $debug = 0;

  sub ldirread($$$)
  {
      my ( $ldapconn, $pow2, $comptes) = @_;
      print STDERR "Lecture annuaire sur ". $ldapconn->{host} ."\n" if $debug;

      my $ldap = Net::LDAP->new( $ldapconn->{host} , port => 389, version => 3 );
      $ldap->bind( $ldapconn->{cn}, password => $ldapconn->{pass} );

      my $mesg = $ldap->search( base => $ldapconn->{base}, scope  => 'sub',
          filter => $ldapconn->{filter} );

      $mesg->code && die $mesg->error;
      foreach my $entry ( $mesg->all_entries ) {
          $comptes->{$entry->get_value('uid')} |= $pow2;
      }
  }

  my $annuaire = 0;
  ldirread(
      {
          host => 'localhost', cn => 'cn=Manager, dc=example, dc=com',
          filter => '(uid=*)', pass => 'secret',
          base => 'ou=People,dc=example,dc=com',
      },
      1 << $annuaire,
      \%comptes);

  $annuaire++;
  ldirread(
      {
          host => 'localhost', cn => 'cn=Manager, dc=example, dc=com',
          filter => '(uid=*)', pass => 'secret',
          base => 'ou=Europe,dc=example,dc=com',
      },
      1 << $annuaire,
      \%comptes);

  foreach ( keys %comptes ) {
      print " !  $_ uniquement dans l'annuaire source\n" if $comptes{$_} == 1; 
      print "!!  $_ uniquement dans l'annuaire cible\n"  if $comptes{$_} == 2;
      print "    $_ dans les deux annuaires\n"           if $comptes{$_} == (1|2); 
  }
  print Dumper \%comptes if $debug;

Ainsi que vous pouvez le voir, nous créons une fonction ldirread qui se charge de lire l'un ou l'autre des annuaires. On lui passe en paramètre une référence sur un hachage qui contient l'essentiel des informations de connexion (%ldapconn dans la fonction). En plus de ces informations, nous passons aussi une variable contenant la valeur à ajouter aux valeurs existantes (quand elles existent) du troisième paramètre : le hachage global %comptes, passé par référence, qui est vu comme $comptes dans la fonction.

Ainsi, nous appelons deux fois la fonction ldirread, avec des informations de connexion différentes (dans notre cas, seule la base de recherche diffère), en ne faisant qu'incrémenter le numéro de l'annuaire. Et par la magie des puissances de deux, nous allumons un bit dans %comptes pour la présence d'un enregistrement dans les annuaires. Pour allumer ce bit, nous utilisons un ou logique, de façon à conserver la valeur précédente, et à y ajouter la valeur courante. Si la valeur pour la clé n'existe pas, ce qui est le cas lors de la lecture du premier annuaire, l'auto-vivification de Perl la crée.

Une fois que les annuaires sont lus, il suffit ensuite de relire le hachage, en regardant les valeurs pour chaque clé. Si la valeur est à 1, l'enregistrement n'est que dans le premier annuaire, si elle est à 2, il n'est que dans le second, et si elle est à 3 (en fait 1|2 qui a le bon goût de valoir 1+2 en arithmétique booléenne), l'enregistrement dont l'uid est la clé considérée est présent dans les deux annuaires.

Le résultat à l'exécution donne ceci :

  $ ./ds.pl
      grinder dans les deux annuaires
      nicholas dans les deux annuaires
  1   sniper uniquement dans l'annuaire source
      leon dans les deux annuaires
      ymmv dans les deux annuaires
   2  book uniquement dans l'annuaire cible

Libre à vous d'étendre ensuite le script avec éventuellement une troisième source, du malaxage sur les données, etc. La technique employée permet de gagner en espace mémoire, et donc en performance, en stockant pour une clé donnée une seule valeur : l'addition des annuaires dans lequel il est présent.

Vérification des ACL

Une autre tâche qui peut être confiée à Perl pour éviter les non-régressions est la vérification du paramétrage des ACL. En effet, la syntaxe de ces ACL n'est pas toujours intuitive et leur comportement dépend en plus de leur ordre de spécification. Il est donc important pour la sécurité d'un système d'information d'en contrôler leur bon fonctionnement.

Le principe est le suivant : lors de l'écriture des différentes ACL, vous savez ce que vous voulez ouvrir à tel ou tel public. Ce public est soit anonyme, soit nommé, et on veut lui masquer le maximum d'informations dont il n'a pas besoin. À noter que cela comprend, par exemple, l'attribut contenant le mot de passe userPassword, mais aussi des objets voire des branches complètes, et que ces choix se font aussi en fonction du contenu de votre annuaire. Par exemple, un utilisateur non authentifié ne doit pas pouvoir lire les mots de passe des divers comptes utilisateurs présents dans l'annuaire, mais seulement pouvoir s'authentifier à leur encontre lors d'un bind. De même, un utilisateur sans privilège particulier ne doit pas voir les mots de passe de ses collègues (ce fut la raison de la création du fichier /etc/shadow). Au contraire, le DN d'un compte de réplication de l'annuaire doit pouvoir lui lire les mots de passe de tout le monde afin de pouvoir les répliquer.

Ainsi, au vu des diverses combinaisons possibles, il vaut presque mieux, avec la connaissance que vous avez de votre annuaire, d'abord créer les vérifications qui vous semblent importantes avant de les transformer en ACL. Ces vérifications seront (bien sûr) écrites en Perl, de la manière la plus simple possible, ce dans le but de masquer la complexité des tests et de façon à éviter le dégoût de l'écriture du test ; et par là même de favoriser ces écritures.

Le principe est donc le même que pour l'écriture de tests permettant de valider le bon fonctionnement de vos modules Perl : (dans l'idéal) on écrit d'abord les tests, qui va d'abord échouer, puis on modifie le code (dans notre cas, les ACL) jusqu'à ce que tous les tests passent. Pourquoi tous ? Pour valider l'absence d'effet de bord.

Pour ces tests, un cadre est donc nécessaire de façon à simplifier au maximum l'écriture des tests. Un test devra donc pouvoir n'être qu'un appel à une simple fonction, ayant une sémantique expressive, par exemple :

  check(
      name   => 'userPassword de grinder visible par manager',
      ldap   => $ldap{manager},
      base   => 'ou=People, dc=example, dc=com',
      scope  => 'sub',
      filter => '(&(objectclass=posixAccount)(uid=grinder))',
      has    => { userPassword => [ '{crypt}$1$BsSFeKPv$9SMR3ck.AA71EPG6vvA3N0' ] },
      code   => 0
  );

Un test a un nom (paramètre nommé name, désolé de coder en anglais, mais c'est la langue de CPAN), une référence à l'objet Net::LDAP permettant la connexion à l'annuaire, la base de la recherche, son scope, un filtre, et une référence sur un hachage contenant un ou plusieurs attributs, et la ou les valeurs (cas d'attribut multivalué) qu'il doit avoir dans le contexte courant.

On sort de la vérification elle-même les points suivants : initialisation des accesseurs Net::LDAP et authentification auprès de l'annuaire. En effet, ces phases ne sont que des à-côtés (certes importants car définissant le contexte) des vérifications.

Les différentes vérifications seront donc intercalées par les connexions et déconnexions à l'annuaire.

Cela donne le script suivant :

  #!/usr/bin/perl -w
  # vim: et ai tw=100 sw=4 ts=4

  use strict;
  use Net::LDAP;
  use Data::Dumper;

  my %comptes;
  my $debug = 1;

  sub check(@) {
      my @args = @_;
      my %parms;
      my $i = 0;

      # liste des paramètres nommés autorisés
      my $re = join "|", qw( name ldap base scope filter has code );
      $re = qr/^(?:$re)$/;
      while ( $i < @args ) {
          if ( $args[$i] =~ $re ) {
              my ( $k, $v ) = splice( @args, $i, 2 );
              $parms{$k} = $v;
          }
          else {
              $i++;
          }
      }

      # Il ne devrait plus rien rester dans @args
      die "Paramètre(s) inconnu(s): " . join( ", ", @args ) if @args;

      # Il n'y a pas de façon directe de savoir si on est connecté ou non
      die "Authentification non réalisée"
        if !defined $parms{ldap}->{net_ldap_mesg};

      # construction de la requête, profitant de la dualité
      # liste / paire (clé => valeur)
      my $mesg = $parms{ldap}->search(
          ( $parms{base}   ? ( base   => $parms{base} )   : () ),
          ( $parms{scope}  ? ( scope  => $parms{scope} )  : () ),
          ( $parms{filter} ? ( filter => $parms{filter} ) : () ),
          ( $parms{has}    ? ( attrs  => keys %{$parms{has}} )  : () ),
      );

      my $rc=0;
      $rc++ if ( $parms{code} != $mesg->code() );

      foreach my $entry ( $mesg->all_entries ) {
          foreach my $attr ( keys %{$parms{has}} ) {
              my %tab = map { $_ => 1 } $entry->get_value($attr);   
              $tab{$_}++ foreach (@{ $parms{has}->{$attr} } );
              foreach (keys %tab) {
                  $rc++ if $tab{$_} != 2
              }
          }
      }
      print ((($rc == 0) ? 'ok' : 'not ok'), (defined($parms{name})?" - $parms{name}":''),"\n");
      $rc;
  }

  my %ldap;
  my $mesg;

  $ldap{anonymous} = Net::LDAP->new( 'localhost', port => 389, version => 3 );
  $mesg = $ldap{anonymous}->bind();
  $mesg->code && die $mesg->error;

  check(
      name   => 'userPassword de grinder visible par anonymous bind',
      ldap   => $ldap{anonymous},
      base   => 'ou=People, dc=example, dc=com',
      scope  => 'sub',
      filter => '(&(objectclass=posixAccount)(uid=grinder))',
      has    => { userPassword => [ ] },
      code   => 0
  );

  $ldap{anonymous}->unbind;

  $ldap{ymmv} = Net::LDAP->new( 'localhost', port => 389, version => 3 );
  $mesg = $ldap{ymmv}->bind('uid=ymmv,ou=Paris,ou=People,dc=example,dc=com', password => 'topsecret') ;
  $mesg->code && die $mesg->error;

  check(
      name   => 'userPassword de grinder visible par ymmv',
      ldap   => $ldap{ymmv},
      base   => 'ou=People, dc=example, dc=com',
      scope  => 'sub',
      filter => '(&(objectclass=posixAccount)(uid=grinder))',
      has    => { userPassword => [ ] },
      code   => 0
  );

  $ldap{ymmv}->unbind;

  $ldap{manager} = Net::LDAP->new( 'localhost', port => 389, version => 3 );
  $mesg =
    $ldap{manager}->bind( 'cn=Manager,dc=example,dc=com', password => 'secret' );
  $mesg->code && die $mesg->error;

  check(
      name   => 'userPassword de grinder visible par manager',
      ldap   => $ldap{manager},
      base   => 'ou=People, dc=example, dc=com',
      scope  => 'sub',
      filter => '(&(objectclass=posixAccount)(uid=grinder))',
      has    => { userPassword => [ '{crypt}$1$BsSFeKPv$9SMR3ck.AA71EPG6vvA3N0' ] },
      code   => 0
  );

  check(
      name   => 'membres du groupe posix tea',
      ldap   => $ldap{manager},
      base   => 'ou=Group, dc=example, dc=com',
      scope  => 'sub',
      filter => '(&(objectclass=posixGroup)(cn=tea))',
      has    => { memberUid => [ qw(nicholas leon book ymmv) ] },
      code   => 0
  );

  $ldap{manager}->unbind;

La première partie de la fonction check s'occupe de vérifier les paramètres nommés qui sont passés, et de les récupérer dans le hachage %parms. Ensuite, la requête LDAP est construite en fonction de l'existence d'un paramètre ou d'un autre. Un point : aucune précaution n'est prise pour vérifier que des paramètres obligatoires ont bien été passés. Un simple return if not defined($parms() fera l'affaire si vous en avez besoin.

Ensuite, un mouchard, $rc, est positionné à 0 et sera incrémenté en cas de problème. Viennent enfin les boucles imbriquées de vérification du contenu des hachages attributs vers listes de valeurs. Les listes de valeurs d'attributs multivalués sont vérifiés quel que soit l'ordre retourné par la méthode get_values ou par ce qui se trouve dans $parms{has}. Cette vérification passe par la création d'un hachage temporaire %tab, dans lequel on ajoute 1 à chaque fois qu'on rencontre une valeur d'attribut. Chaque valeur étant censée avoir été rencontrée deux fois (une fois dans $parms{has}, la seconde dans get_values), il suffit de tester que toutes les valeurs du hachage %tab sont bien à 2.

À la fin de ces boucles, on affiche ok ou not ok suivi d'un éventuel nom de test, et l'on retourne $rc. Le code retour de check n'est pas testé dans notre cas, car l'affichage (?:not\s)?ok permet par la suite d'intégrer la séquence de tests avec les modules Test::* de Perl. Mais ceci est une autre histoire.

Afin d'être complet, je vous précise avoir utilisé un hachage (%ldap) pour stocker les accesseurs Net::LDAP afin d'être sûr que le test de vérification de la connexion à l'annuaire qui est fait dans la fonction check se passe bien. Ça permet en plus d'être expressif via la clé de hachage sans pour autant consommer des variables globales (et tous les my qui vont avec, il n'y a pas de petite économie ^_^).

La sortie du script ressemble à ça, ce qui permet déjà de voir d'un rapide coup d'œil ce qui passe ou non :

  ok - userPassword de grinder visible par anonymous bind
  ok - userPassword de grinder visible par ymmv
  not ok - userPassword de grinder visible par manager
  ok - membres du groupe posix tea

Pour la petite histoire, le troisème test échoue sur un des deux annuaires utilisés pour écrire cet article, mais passe sur l'autre (test écrit sur l'un, pour un mot de passe, qui est différent de l'autre annuaire, car créé aléatoirement).

Les autres sous-modules de Net::LDAP

Nous avons donc vu pour l'essentiel les deux modules Net::LDAP et Net::LDAP::LDIF. Sachez qu'il existe d'autres sous-modules à Net::LDAP, que vous pouvez voir avec i /Net::LDAP/ sur la ligne de commande de cpan.

Ours

jfenal@free.fr et jerome.fenal@logicacmg.com.

Membre de Paris.pm, Jérôme Fenal est utilisateur de GNU/Linux depuis 1994, de divers Unix (OSF/1 2.0) ou Unix-like depuis un peu plus longtemps.

Merci aux Mongueurs Marseillais, Lyonnais, Parisiens et Grenoblois qui ont assuré la relecture de cet article.

À mon père.

[IE7, par Dean Edwards] [Validation du HTML] [Validation du CSS]