lc_ldap/lc_ldap.py
Antoine Durand-gasselin 6c35fc87b6 [lc_ldap] bugfix :S
2010-11-11 18:16:45 +01:00

518 lines
20 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# LC_LDAP.PY-- LightWeight CransLdap
#
# Copyright (C) 2010 Cr@ns <roots@crans.org>
# Author: Antoine Durand-Gasselin <adg@crans.org>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the Cr@ns nor the names of its contributors may
# be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT
# HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from __future__ import with_statement
import os, sys, ldap, re, netaddr, datetime, copy, time, random
from ldap.modlist import addModlist, modifyModlist
from Levenshtein import jaro
sys.path.append('/usr/scripts/gestion')
import config, crans_utils
from attributs import attrify, blacklist
from ldap_locks import CransLock
uri = 'ldapi:///' #'ldap://ldap.adm.crans.org/'
base_dn = 'ou=data,dc=crans,dc=org'
log_dn = "cn=log"
# Champs à ignorer dans l'historique
HIST_IGNORE_FIELDS = ["modifiersName", "entryCSN", "modifyTimestamp"]
def ldif_to_uldif(ldif):
uldif = {}
for attr, vals in ldif.items():
uldif[attr] = [ unicode(val, 'utf-8') for val in vals ]
return uldif
def ldif_to_cldif(ldif, conn, check_ctxt = True):
"""Transforme un dictionnaire renvoyé par python-ldap, en
un dictionnaire dont les valeurs sont des instances de Attr
Lorsqu'on récupère le ldif de la base ldap, on n'a pas besoin
de faire de tests...
"""
cldif = {}
for attr, vals in ldif.items():
cldif[attr] = [ attrify(val, attr, ldif, conn, check_ctxt) for val in vals]
return cldif
def cldif_to_ldif(cldif):
ldif = {}
for attr, vals in cldif.items():
ldif[attr] = [ str(val) for val in vals ]
return ldif
def lc_ldap_test():
"""Binding LDAP à la base de tests"""
return lc_ldap(dn='cn=admin,dc=crans,dc=org', cred='75bdb64f32')
class lc_ldap(ldap.ldapobject.LDAPObject):
def __init__(self, dn=None, user=None, cred=None, uri=uri):
"""Initialise la connexion ldap,
- En authentifiant avec dn et cred s'ils sont précisés
- Si dn n'est pas précisé, mais que user est précisé, récupère
le dn associé à l'uid user, et effectue l'authentification
avec ce dn et cred
- Sinon effectue une authentification anonyme
"""
ldap.ldapobject.LDAPObject.__init__(self, uri)
if user and not re.match('[a-z_][a-z0-9_-]*', user):
raise ValueError('Invalid user name: %s' % user)
if user and not dn:
self.simple_bind_s(base_dn)
res = self.search_s('uid=%s' % user)
if len(res) < 1:
raise ldap.INVALID_CREDENTIALS({'desc': 'No such user: %s' % user })
elif len(res) > 1:
raise ldap.INVALID_CREDENTIALS({'desc': 'Too many matches: uid=%s' % user })
else:
dn = res[0][0]
if dn:
self.conn = self.bind_s(dn, cred)
self.dn = dn
self.droits = self.search_s(dn, ldap.SCOPE_BASE, attrlist=['droits'])[0][1].get('droits', [])
else:
self.conn = self.simple_bind_s()
self.dn = None
self.droits = []
def search(self, filterstr, mode='ro', dn= base_dn, scope= 2, sizelimit=400):
res = self.search_ext_s(dn, scope, filterstr, sizelimit=sizelimit)
return [ new_cransldapobject(self, r[0], mode=mode) for r in res ]
def allMachines(self):
"""Renvoie la liste de toutes les machines,
Conçue pour s'éxécuter le plus rapidement possible"""
res = {}
machines = []
for dn, attrs in self.search_s(base_dn, scope=2):
res[dn] = attrs
for dn, attrs in res.items():
if dn.startswith('mid='):
m = new_cransldapobject(self, dn, ldif = attrs)
parent_dn = dn.split(',', 1)[1]
m._proprio = new_cransldapobject(self, parent_dn, res[parent_dn])
machines.append(m)
return machines
def newMachine(self, parent, realm, uldif):
"""Crée une nouvelle machine: realm peut être:
fil, fil-v6, wifi, wifi-v6, adm, gratuit, personnel-ens, special"""
#adm, serveurs, bornes, wifi, adherents, gratuit ou personnel-ens"""
owner = self.search('objectClass=*', dn=parent, scope=0)[0]
if realm in ["adm", "serveurs"]:
uldif['objectClass'] = [u'machineCrans']
assert isinstance(owner, AssociationCrans)
# XXX - Vérifier les droits
elif realm == "bornes":
uldif['objectClass'] = [u'borneWifi']
assert isinstance(owner, AssociationCrans)
# XXX - Vérifier les droits
elif realm in ["wifi", "wifi-v6"]:
uldif['objectClass'] = [u'machineWifi']
assert isinstance(owner, adherent) or isinstance(owner, club)
# XXX - Vérifier les droits (owner.type_connexion)
elif realm in ["fil", "fil-v6", "gratuit", "personnel-ens"]:
uldif['objectClass'] = [u'machineFixe']
assert isinstance(owner, adherent) or isinstance(owner, club)
# XXX - Vérifier les droits
else: raise ValueError("Realm inconnu: %s" % realm)
if realm == 'fil':
plage = xrange(256, 2047)
else:
plage = xrange( *(config.mid[realm]))
mid = uldif.setdefault('mid', [ unicode(self._find_id('mid', plage)) ])
uldif['ipHostNumber'] = [ unicode(crans_utils.ip_of_mid(int (mid[0]))) ]
return self._create_entity('mid=%s,%s' % (mid[0], parent), uldif)
def newAdherent(self, uldif):
"""Crée un nouvel adhérent"""
aid = uldif.setdefault('aid', [ unicode(self._find_id('aid')) ])
uldif['objectClass'] = [u'adherent']
return self._create_entity('aid=%s,%s' % (aid[0], base_dn), uldif)
def newClub(self, uldif):
"""Crée un nouveau club"""
raise NotImplementedError()
def newFacture(self, uldif):
"""Crée une nouvelle facture"""
raise NotImplementedError()
def _create_entity(self, dn, uldif):
'''Crée une nouvelle entité ldap en dn, avec attributs ldif:
uniquement en unicode'''
cldif = ldif_to_cldif(uldif, self)
#lock = CransLock(self)
# for item in ['aid', 'uid', 'chbre', 'mailAlias', 'canonicalAlias',
# 'fid', 'cid', 'mid', 'macAddress', 'host', 'hostAlias' ]:
# for val in cldif.get(item, []):
# lock.add(item, val)
ldif = cldif_to_ldif(cldif)
modlist = addModlist(ldif)
#with lock:
# print dn, modlist
#
self.add_s(dn, modlist)
return new_cransldapobject(self, dn, mode='w')
def _find_id(self, attr, plage = xrange(1, 32000)):
'''Trouve un <attr>id libre dans plage'''
res = self.search_s(base_dn, 2, '%s=*' % attr, attrlist = [attr])
nonfree = [ int(r[1].get(attr)[0]) for r in res if r[1].get(attr) ]
nonfree.sort()
for i in plage:
if nonfree and nonfree[0] <= i:
while nonfree and nonfree[0] <= i:
nonfree = nonfree[1:]
else:
break
else:
raise EnvironmentError('Aucun %s libre dans la plage [%d, %d]' %
(attr, plage[0], i))
return i
# def _hist(self, msg):
# now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M : ')
# return unicode(now) + msg
# ? def reconnect(self, conn=None):
def new_cransldapobject(conn, dn, mode='ro', ldif = None):
"""Crée un objet CransLdap en utilisant la classe correspondant à
l'objectClass du ldif"""
classe = None
if ldif:
classe = globals()[ldif['objectClass'][0]]
elif dn == base_dn:
classe = AssociationCrans
else:
res = conn.search_s(dn, 0)
if not res:
raise ValueError ('objet inexistant: %s' % dn)
_, attrs = res[0]
classe = globals()[attrs['objectClass'][0]]
return classe(conn, dn, mode, ldif)
class CransLdapObject(object):
"""Classe de base des objets CransLdap"""
def __init__(self, conn, dn, mode='ro', ldif = None):
'''
Créée une instance d'un objet Crans (machine, adhérent,
etc...) à ce dn, si ldif est précisé, n'effectue pas de
recherche dans la base ldap.
'''
self.mode = mode
self.attrs = None # Contient un dico uldif qui doit représenter ce qui
# est dans la base
self._modifs = None # C'est là qu'on met les modifications
if not isinstance(conn, lc_ldap):
raise TypeError("conn doit être une instance de lc_ldap")
self.conn = conn
self.dn = dn
if ldif:
# Vous précisez un ldif, l'objet est 'ro'
self.mode = 'ro'
self.attrs = ldif
elif dn != base_dn:
res = conn.search_s(dn, 0)
if not res:
raise ValueError ('objet inexistant: %s' % dn)
self.dn, self.attrs = res[0]
self.attrs = ldif_to_uldif(self.attrs)
if mode in ['w', 'rw']:
self._modifs = copy.deepcopy(self.attrs)
self.attrs = ldif_to_cldif(self.attrs, conn, check_ctxt = False)
### Vérification que `λv. str(Attr(v))` est bien une projection
oldif = res[0][1]
nldif = cldif_to_ldif(self.attrs)
for attr, vals in oldif.items():
if nldif[attr] != vals:
for v in nldif[attr]:
if v in vals:
vals.remove(v)
nvals = [nldif[attr][v.index(v)] for v in vals ]
raise EnvironmentError("λv. str(Attr(v)) n'est peut-être pas une projection:", attr, nvals, vals)
# self._modifs = copy.deepcopy(self.attrs)
def save(self):
"Vérifie que self._modifs contient des valeurs correctes et enregistre les modifications"
if self.mode not in ['w', 'rw']:
raise EnvironmentError("Objet en lecture seule, réessayer en lecture/écriture")
# Vérifications et Historique
#histo = self._gen_hist(self._modifs)
#self._modifs['historique'] += histo
# On récupère la liste des modifications
modlist = self.get_modlist()
self.conn.modify_s(self.dn, modlist)
# Vérification des modifications
self.attrs = ldif_to_uldif(self.conn.search_s(self.dn, 0)[0][1])
if self.attrs != self._modifs:
raise EnvironmentError("Les modifications apportées à l'objet %s n'ont pas été correctement sauvegardées\nexpected = %s, found = %s" % (self.dn, self._modifs, self.attrs))
def get_modlist(self):
"""Renvoie le dico des modifs"""
# unicode -> utf-8
ldif = cldif_to_ldif(self._modifs)
orig_ldif = cldif_to_ldif(self.attrs)
return modifyModlist(orig_ldif, ldif)
def get(self, attr, default):
try:
return self[attr]
except KeyError:
return default
def __getitem__(self, attr):
if self.mode in [ 'w', 'rw' ]:
return [ unicode(v) for v in self._modifs[attr] ]
else:
return [ unicode(v) for v in self.attrs[attr] ]
def __setitem__(self, attr, values):
if not isinstance(values, list):
values = [ values ]
self._modifs[attr] = values
self._modifs[attr] = [ attrify(val, attr, self._modifs, self.conn) for val in values ]
def search_historique(self, ign_fields=HIST_IGNORE_FIELDS):
u"""Récupère l'historique
l'argument optionnel ign_fields contient la liste des champs
à ignorer, HIST_IGNORE_FIELDS par défaut
Renvoie une liste de lignes de texte."""
res = self.conn.search_s(log_dn, 2, 'reqDN=%s' % self.dn)
res.sort(key=(lambda a: a[1]['reqEnd'][0]))
out = []
for cn, attrs in res:
date = crans_utils.format_ldap_time(attrs['reqEnd'][0])
if attrs['reqType'][0] == 'delete':
out.append("%s : [%s] Suppression" % (date, attrs['reqAuthzID'][0]))
elif attrs['reqType'][0] == 'modify':
fields = {}
for mod in attrs['reqMod']:
field, change = mod.split(':', 1)
if field not in ign_fields:
if field in fields:
fields[field].append(change)
else:
fields[field] = [change]
mod_list = []
for field in fields:
mods = fields[field]
mod_list.append("%s %s" %(field, ", ".join(mods)))
if mod_list != []:
out.append("%s : [%s] %s" % (date, attrs['reqAuthzID'][0], " ; ".join(mod_list)))
return out
def blacklist(self, sanction, commentaire, debut=time.time(), fin = '-'):
u"""
Blacklistage de la ou de toutes la machines du propriétaire
* debut et fin sont le nombre de secondes depuis epoch
* pour un début ou fin immédiate mettre now
* pour une fin indéterminée mettre '-'
Les données sont stockées dans la base sous la forme :
debut$fin$sanction$commentaire
"""
if debut == 'now':
debut = time.time()
if fin == 'now':
fin = time.time()
bl = blacklist(u'%s$%s$%s$%s' % (sanction, commentaire, debut, fin), {}, self.conn, False)
self._modifs.setdefault('blacklist', []).append(bl)
class proprio(CransLdapObject):
ufields = [ 'nom', 'chbre' ]
mfields = [ 'paiement', 'info', 'blacklist', 'controle']
ofields = []
xfields = []
def __init__(self, conn, dn, mode='ro', ldif = None):
super(proprio, self).__init__(conn, dn, mode, ldif)
self._machines = []
def machines(self):
if not self._machines:
self._machines = self.conn.search('mid=*', dn = self.dn, scope = 1)
for m in self._machines:
m._proprio = self
return self._machines
class machine(CransLdapObject):
ufields = ['mid', 'macAddress', 'host', 'midType']
ofields = []
mfields = ['info', 'blacklist', 'hostAlias', 'exempt',
'portTCPout', 'portTCPin', 'portUDPout', 'portUDPin']
xfields = ['ipHostNumber']
def __init__(self, conn, dn, mode='ro', ldif = None):
super(machine, self).__init__(conn, dn, mode, ldif)
self._proprio = None
def proprio(self):
parent_dn = self.dn.split(',', 1)[1]
if not self._proprio:
self._proprio = new_cransldapobject(self.conn, parent_dn, self.mode)
return self._proprio
class AssociationCrans(proprio): pass
class adherent(proprio):
ufields = proprio.ufields + ['aid', 'prenom', 'tel', 'mail', 'mailInvalide']
ofields = proprio.ofields + ['charteMA', 'adherentPayant', 'typeAdhesion',
'canonicalAlias', 'solde', 'contourneGreylist',
'rewriteMailHeaders', 'derniereConnexion',
'homepageAlias']
mfields = proprio.mfields + ['carteEtudiant', 'mailAlias', 'droits' ]
xfields = ['etudes', 'postalAddress']
def compte(self,login = None, uidNumber=0, hash_pass = '', shell=config.login_shell):
"""Renvoie le nom du compte crans, s'il n'existe pas, et que uid
est précisé, le crée"""
if u'posixAccount' in self.attrs['objectClass']:
return self.attrs['uid'][0]
elif login:
fn = crans_utils.strip_accents(unicode(self.attrs['prenom'][0]).capitalize())
ln = crans_utils.strip_accents(unicode(self.attrs['nom'][0]).capitalize())
login = crans_utils.strip_accents(login).lower()
if jaro(ln.lower(), login) < 0.75 and jaro(fn.lower() + ' ' + ln.lower(), login) < 0.75:
raise ValueError("Le login est trop différent du nom",
login, self.attrs['nom'][0])
if not re.match('^[a-z][-a-z]{1,15}$', login):
raise ValueError("Le login a entre 2 et 16 lettres, il peut contenir (pas au début) des - ")
if crans_utils.mailexist(login):
raise ValueError("Login existant ou correspondant à un alias mail.")
home = u'/home/' + login
if os.path.exists(home):
raise ValueError('Création du compte impossible : home existant')
if os.path.exists("/var/mail/" + login):
raise ValueError('Création du compte impossible : /var/mail/%s existant' % login)
self._modifs['homeDirectory'] = [home]
self._modifs['mail'] = [login]
self._modifs['uid' ] = [login]
calias = crans_utils.strip_spaces(fn) + u'.' + crans_utils.strip_spaces(ln)
if crans_utils.mailexist(calias):
calias = login
self._modifs['canonicalAlias'] = [calias]
self._modifs['objectClass'] = [u'adherent', u'cransAccount', u'posixAccount', u'shadowAccount']
self._modifs['cn'] = [ fn + u' ' + ln ]
self._modifs['loginShell'] = [unicode(shell)]
self._modifs['userPassword'] = [unicode(hash_pass)]
if uidNumber:
if self.conn.search('(uidNumber=%s)' % uidNumber):
raise ValueError(u'uidNumber pris')
else:
pool_uid = range(1001, 9999)
random.shuffle(pool_uid)
while len(pool_uid) > 0:
uidNumber = pool_uid.pop() # On choisit un uid
if not self.conn.search('(uidNumber=%s)' % uidNumber):
break
if not len(pool_uid):
raise ValueError("Plus d'uid disponibles !")
## try:
## self.lock('uidNumber', str(uidNumber))
## except:
## # Quelqu'un nous a piqué l'uid que l'on venait de choisir !
## return self.compte(login, uidNumber, hash_pass, shell)
self._modifs['uidNumber'] = [unicode(uidNumber)]
self._modifs['gidNumber'] = [unicode(config.gid)]
self._modifs['gecos'] = [self._modifs['cn'][0] + u',,,']
self.save()
else:
raise EnvironmentError("L'adhérent n'a pas de compte crans")
class club(proprio):
ufields = ['cid', 'responsable']
mfields = ['imprimeurClub']
class machineFixe(machine): pass
class machineWifi(machine):
ufields = machine.ufields + ['ipsec']
class machineCrans(machine):
ufields = machine.ufields + ['prise']
ofields = machine.ofields + ['nombrePrises']
class borneWifi(machine):
ufields = machine.ufields + ['canal', 'puissane', 'hotspot',
'prise', 'positionBorne', 'nvram']
class facture(CransLdapObject):
ufields = ['fid', 'modePaiement', 'recuPaiement']
class service(CransLdapObject): pass
class lock(CransLdapObject): pass
MODIFIABLE_ATTRS = [ 'tel', 'chbre', 'mailAlias', 'loginShell' ]