379 lines
11 KiB
Python
Executable File
379 lines
11 KiB
Python
Executable File
#!/usr/bin/env python
|
|
import argparse
|
|
|
|
import ldap
|
|
import ldap.modlist
|
|
from ldap.ldapobject import ReconnectLDAPObject
|
|
import ldap.sasl
|
|
from ldap.controls import SimplePagedResultsControl
|
|
from ldap.filter import filter_format
|
|
|
|
|
|
attributes = [
|
|
'audio',
|
|
'businessCategory',
|
|
'carLicense',
|
|
'cn',
|
|
'dc',
|
|
'departmentNumber',
|
|
'description',
|
|
'destinationIndicator',
|
|
'displayName',
|
|
'eduOrgHomePageURI',
|
|
'eduOrgIdentityAuthNPolicyURI',
|
|
'eduOrgLegalName',
|
|
'eduOrgSuperiorURI',
|
|
'eduOrgWhitePagesURI',
|
|
'eduPersonAffiliation',
|
|
'eduPersonAssurance',
|
|
'eduPersonEntitlement',
|
|
'eduPersonNickname',
|
|
'eduPersonOrgDN',
|
|
'eduPersonOrgUnitDN',
|
|
'eduPersonPrimaryAffiliation',
|
|
'eduPersonPrimaryOrgUnitDN',
|
|
'eduPersonPrincipalName',
|
|
'eduPersonScopedAffiliation',
|
|
'eduPersonTargetedID',
|
|
'employeeNumber',
|
|
'employeeType',
|
|
'facsimileTelephoneNumber',
|
|
'givenName',
|
|
'homePhone',
|
|
'homePostalAddress',
|
|
'# http',
|
|
'initials',
|
|
'internationaliSDNNumber',
|
|
'jpegPhoto',
|
|
'l',
|
|
'labeledURI',
|
|
'mail',
|
|
'mailForwardingAddress',
|
|
'manager',
|
|
'member',
|
|
'mobile',
|
|
'o',
|
|
'ou',
|
|
'owner',
|
|
'pager',
|
|
'photo',
|
|
'physicalDeliveryOfficeName',
|
|
'postalAddress',
|
|
'postalCode',
|
|
'postOfficeBox',
|
|
'preferredDeliveryMethod',
|
|
'preferredLanguage',
|
|
'registeredAddress',
|
|
'roomNumber',
|
|
'searchGuide',
|
|
'secretary',
|
|
'seeAlso',
|
|
'sn',
|
|
'st',
|
|
'street',
|
|
'supannActivite',
|
|
'supannAffectation',
|
|
'supannAliasLogin',
|
|
'supannAutreMail',
|
|
'supannAutreTelephone',
|
|
'supannCivilite',
|
|
'supannCodeEntite',
|
|
'supannCodeEntiteParent',
|
|
'supannCodeINE',
|
|
'supannEmpCorps',
|
|
'supannEmpId',
|
|
'supannEntiteAffectation',
|
|
'supannEntiteAffectationPrincipale',
|
|
'supannEtablissement',
|
|
'supannEtuAnneeInscription',
|
|
'supannEtuCursusAnnee',
|
|
'supannEtuDiplome',
|
|
'supannEtuElementPedagogique',
|
|
'supannEtuEtape',
|
|
'supannEtuId',
|
|
'supannEtuInscription',
|
|
'supannEtuRegimeInscription',
|
|
'supannEtuSecteurDisciplinaire',
|
|
'supannEtuTypeDiplome',
|
|
'supannGroupeAdminDN',
|
|
'supannGroupeDateFin',
|
|
'supannGroupeLecteurDN',
|
|
'supannListeRouge',
|
|
'supannMailPerso',
|
|
'supannOrganisme',
|
|
'supannParrainDN',
|
|
'supannRefId',
|
|
'supannRole',
|
|
'supannRoleEntite',
|
|
'supannRoleGenerique',
|
|
'supannTypeEntite',
|
|
'supannTypeEntiteAffectation',
|
|
'telephoneNumber',
|
|
'teletexTerminalIdentifier',
|
|
'telexNumber',
|
|
'title',
|
|
'uid',
|
|
'userCertificate',
|
|
'userPassword',
|
|
'userPKCS12',
|
|
'userSMIMECertificate',
|
|
'x121Address',
|
|
'x500UniqueIdentifier',
|
|
'objectClass',
|
|
]
|
|
|
|
class PagedResultsSearchObject:
|
|
page_size = 500
|
|
|
|
def paged_search_ext_s(self,base,scope,filterstr='(objectClass=*)',attrlist=None,attrsonly=0,serverctrls=None,clientctrls=None,timeout=-1,sizelimit=0):
|
|
"""
|
|
Behaves exactly like LDAPObject.search_ext_s() but internally uses the
|
|
simple paged results control to retrieve search results in chunks.
|
|
|
|
This is non-sense for really large results sets which you would like
|
|
to process one-by-one
|
|
"""
|
|
|
|
while True: # loop for reconnecting if necessary
|
|
|
|
req_ctrl = SimplePagedResultsControl(True,size=self.page_size,cookie='')
|
|
|
|
try:
|
|
|
|
# Send first search request
|
|
msgid = self.search_ext(
|
|
base,
|
|
scope,
|
|
filterstr=filterstr,
|
|
attrlist=attrlist,
|
|
attrsonly=attrsonly,
|
|
serverctrls=(serverctrls or [])+[req_ctrl],
|
|
clientctrls=clientctrls,
|
|
timeout=timeout,
|
|
sizelimit=sizelimit
|
|
)
|
|
|
|
all_results = []
|
|
|
|
while True:
|
|
rtype, rdata, rmsgid, rctrls = self.result3(msgid)
|
|
for result in rdata:
|
|
yield result
|
|
all_results.extend(rdata)
|
|
# Extract the simple paged results response control
|
|
pctrls = [
|
|
c
|
|
for c in rctrls
|
|
if c.controlType == SimplePagedResultsControl.controlType
|
|
]
|
|
if pctrls:
|
|
if pctrls[0].cookie:
|
|
# Copy cookie from response control to request control
|
|
req_ctrl.cookie = pctrls[0].cookie
|
|
msgid = self.search_ext(
|
|
base,
|
|
scope,
|
|
filterstr=filterstr,
|
|
attrlist=attrlist,
|
|
attrsonly=attrsonly,
|
|
serverctrls=(serverctrls or [])+[req_ctrl],
|
|
clientctrls=clientctrls,
|
|
timeout=timeout,
|
|
sizelimit=sizelimit
|
|
)
|
|
else:
|
|
break # no more pages available
|
|
|
|
except ldap.SERVER_DOWN,e:
|
|
self.reconnect(self._uri)
|
|
else:
|
|
break
|
|
|
|
|
|
class PagedLDAPObject(ReconnectLDAPObject,PagedResultsSearchObject):
|
|
pass
|
|
|
|
lconn = PagedLDAPObject('ldapi://', retry_max=100, retry_delay=2)
|
|
lconn.sasl_interactive_bind_s("", ldap.sasl.external())
|
|
|
|
get_all_filter = '(|(objectclass=dcobject)(objectclass=organizationalUnit)(objectclass=supannPerson)(objectclass=supannGroupe)(objectclass=supannEntite))'
|
|
pivot_attributes = [('supannPerson', 'uid'),
|
|
('supannGroupe', 'cn'),
|
|
('supannEntite', 'supanncodeentite'),
|
|
('dcObject', 'dc'),
|
|
('organizationalUnit', 'ou')]
|
|
pivot_attrs = ['objectclass', 'uid', 'cn', 'supanncodeentite', 'dc', 'ou']
|
|
|
|
parser = argparse.ArgumentParser(description='Synchronize a supann directory with a meta-directory copy')
|
|
parser.add_argument('ldap_uri', help='URL of the other LDAP to synchronize locally')
|
|
parser.add_argument('ldap_newbasedn', help='new basedn for entries copied locally (ex.: dc=univ-test,ou=meta)')
|
|
parser.add_argument('ldap_basedn', help='basedn of the remote supann directory (ex.: dc=univ-test,dc=fr)')
|
|
parser.add_argument('ldap_binddn', nargs='?', help='bind DN to read the remote directory', default=None)
|
|
parser.add_argument('ldap_bindpwd', nargs='?', help='bind password to read the remote directory')
|
|
parser.add_argument('--quiet', dest='verbose', action='store_false', default=True, help='do not report all modifications')
|
|
parser.add_argument('--fake', action='store_true', default=False, help='do not apply modifications, only simulate them')
|
|
args = parser.parse_args()
|
|
|
|
if args.verbose:
|
|
print 'Synchronizing LDAP directory at', args.ldap_binddn, 'locally.'
|
|
print 'BaseDN:', args.ldap_basedn
|
|
if args.ldap_binddn:
|
|
print 'BindDN:', args.ldap_binddn
|
|
print 'BindPWD:', args.ldap_bindpwd
|
|
|
|
rconn = PagedLDAPObject(args.ldap_uri, retry_max=100, retry_delay=2)
|
|
if args.ldap_binddn:
|
|
rconn.simple_bind_s(args.ldap_binddn, args.ldap_bindpwd)
|
|
|
|
def lower_keys(d):
|
|
return dict((key.lower(), value) for key, value in d.iteritems())
|
|
|
|
def batch_generator(gen, batch_size):
|
|
batch = []
|
|
for result in gen:
|
|
batch.append(result)
|
|
if len(batch) == batch_size:
|
|
yield batch
|
|
batch = []
|
|
if len(batch):
|
|
yield batch
|
|
|
|
def get_pivotattr(dn, entry):
|
|
'''Find a pivot attribute value for an LDAP entry'''
|
|
for objc, attr in pivot_attributes:
|
|
if objc in entry['objectclass']:
|
|
try:
|
|
value = entry[attr]
|
|
except KeyError:
|
|
raise Exception('entry %s is missing pivot attribute %s: %s' % (dn, attr, entry))
|
|
break
|
|
else:
|
|
raise Exception('entry %s has unknown objectclasses %s' % (dn, entry['objectclass']))
|
|
if len(value) != 1:
|
|
raise Exception('error on entry %s: pivot attribute %s must have only one value' % (dn, attr))
|
|
return objc, attr, value[0]
|
|
|
|
def get_found_dns(filters):
|
|
'''Return a mapping from pivot attributes to dns'''
|
|
or_filter = '(|%s)' % ''.join(filters)
|
|
found = {}
|
|
try:
|
|
for dn, entry in lconn.search_s(args.ldap_newbasedn, ldap.SCOPE_SUBTREE, or_filter, attributes):
|
|
entry = lower_keys(entry)
|
|
objc, attr, value = get_pivotattr(dn, entry)
|
|
found[(attr, value)] = dn, entry
|
|
except ldap.NO_SUCH_OBJECT:
|
|
pass
|
|
return found
|
|
|
|
CREATE = 1
|
|
UPDATE = 2
|
|
RENAME = 3
|
|
DELETE = 4
|
|
|
|
def new_dn(old_dn):
|
|
return old_dn[:-len(args.ldap_basedn)] + args.ldap_newbasedn
|
|
|
|
def to_dict_of_set(d):
|
|
return {k: set(v) for k, v in d.iteritems()}
|
|
|
|
# Create & Update
|
|
batched = 0
|
|
batch = []
|
|
seen_dns = set()
|
|
renames, creates, updates, deletes = 0, 0, 0, 0
|
|
for results in batch_generator(rconn.paged_search_ext_s(
|
|
args.ldap_basedn,
|
|
ldap.SCOPE_SUBTREE,
|
|
get_all_filter,
|
|
attributes), 200):
|
|
new_dns = dict()
|
|
filters = []
|
|
for dn, entry in results:
|
|
entry = lower_keys(entry)
|
|
objc, attr, value = get_pivotattr(dn, entry)
|
|
new_dns[(attr, value)] = dn, entry
|
|
filter_tpl = '(&(objectclass=%%s)(%s=%%s))' % attr
|
|
filters.append(filter_format(
|
|
filter_tpl, (objc, value)))
|
|
found_dns = get_found_dns(filters)
|
|
actions = []
|
|
for k, (dn, entry) in new_dns.iteritems():
|
|
target_dn = new_dn(dn)
|
|
seen_dns.add(target_dn)
|
|
if k in found_dns:
|
|
existing_dn, existing_entry = found_dns[k]
|
|
if existing_dn != target_dn:
|
|
actions.append((RENAME, found_dns[k], target_dn))
|
|
renames += 1
|
|
if to_dict_of_set(existing_entry) != to_dict_of_set(entry):
|
|
actions.append((UPDATE, target_dn, entry))
|
|
updates += 1
|
|
else:
|
|
actions.append((CREATE, target_dn, entry))
|
|
creates += 1
|
|
|
|
try:
|
|
for dn, entry in lconn.paged_search_ext_s(
|
|
args.ldap_newbasedn,
|
|
ldap.SCOPE_SUBTREE,
|
|
get_all_filter):
|
|
if dn not in seen_dns:
|
|
actions.append((DELETE, dn))
|
|
deletes += 1
|
|
except ldap.NO_SUCH_OBJECT:
|
|
pass
|
|
|
|
printing = {
|
|
CREATE: ('- Create', 1),
|
|
UPDATE: ('- Update', 1),
|
|
RENAME: ('- Rename', 1, 'to', 2),
|
|
DELETE: ('- Delete', 1),
|
|
}
|
|
|
|
|
|
|
|
def print_action(action):
|
|
for do in printing[action[0]]:
|
|
if isinstance(do, int):
|
|
print action[do],
|
|
else:
|
|
print do,
|
|
print
|
|
|
|
def apply_action(action):
|
|
t = action[0]
|
|
if t == CREATE:
|
|
dn, entry = action[1:]
|
|
lconn.add(dn, ldap.modlist.addModlist(entry))
|
|
if t == RENAME:
|
|
old_dn, new_dn = action[1:]
|
|
lconn.modrdn(old_dn, new_dn)
|
|
if t == UPDATE:
|
|
dn, entry = action[1:]
|
|
modlist = []
|
|
for key, values in entry.iteritems():
|
|
modlist.append((ldap.MOD_REPLACE, key, values))
|
|
lconn.modify(dn, modlist)
|
|
if t == DELETE:
|
|
lconn.delete(action[1])
|
|
|
|
actions.sort(key=lambda x: (x[0] == DELETE, len(x[1])))
|
|
if args.verbose:
|
|
print 'Actions:'
|
|
for action in actions:
|
|
if args.verbose:
|
|
print_action(action)
|
|
if not args.fake:
|
|
apply_action(action)
|
|
else:
|
|
print 'Fake, doing nothing'
|
|
|
|
if args.verbose:
|
|
print 'Waiting for completion..',
|
|
i = len(actions)
|
|
while i:
|
|
lconn.result()
|
|
i -= 1
|
|
print ' done'
|