3 # Copyright (C) 2011 Laurentian University
4 # Dan Scott <dscott@laurentian.ca>
7 # This program is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU General Public License
9 # as published by the Free Software Foundation; either version 2
10 # of the License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
18 Synchronize Evergreen user accounts with an LDAP directory via OpenSRF
20 LDAP and OpenSRF authentication information is stored in a separate Python
21 file (credentials.py) and imported to avoid storing credentials in the VCS.
23 1. Pull a list of new LDAP records since the last sync from the LDAP
24 directory using the filter (createTimestamp>=time).
26 2. For each new LDAP record, check to see if the record exists in Evergreen
27 (matching on ident_value, usrname, email).
29 If not, create a new account with barcode.
31 3. Push the new barcode back into the LDAP directory.
33 Needs to be able to find credentials.py with the following content:
35 LDAP_HOST = 'ldap://192.168.1.106'
36 LDAP_DN = 'cn=ldap_usr'
40 IDL_URL = '/reports/fm_IDL.xml'
41 GATEWAY_URL = '/osrf-gateway-v1'
43 OSRF_HOST = 'localhost'
57 import oils.utils.utils
68 Holds data mapped from LDAP schema to Evergreen attributes
70 Less formal than an actual 'au' object
73 def __init__(self, raw_cn, raw_atts):
75 Map core LDAP schema attributes to Evergreen user bits
77 Easier to replace hard-coded mappings with calls to functions
81 self.ldap_atts = raw_atts
83 self.cn = self._simple_map('cn')
84 self.lang_pref = self._simple_map('preferredLanguage')
86 if 'mail' not in self.ldap_atts:
87 if self.lang_pref and self.lang_pref == 'f':
88 self.email = self.cn + '@laurentienne.ca'
90 self.email = self.cn + '@laurentian.ca'
91 print >> sys.stderr, '"mail" not found for %s, using %s' % (self.cn, self.email)
94 self.email = (self._simple_map('mail') or '').lower()
96 # Strip leading/ending whitespace; LDAP data can be dirty
98 # Using email for username deliberately here
99 self.usrname = self.email
100 self.family_name = self._simple_map('sn')
101 self.ident_type, self.ident_value = self.get_identity()
102 self.home_ou = self.get_home_ou()
103 self.first_given_name = self._simple_map('givenName')
104 if not self.first_given_name:
105 self.first_given_name = 'LDAP_NULL'
106 print >> sys.stderr, 'No givenName for %s' % (self.usrname)
108 # Randomized password, assuming user will use "Forgot password"
109 # function to set their own password
110 self.passwd = oils.utils.utils.md5sum(os.urandom(10))
112 self.profile = self.get_profile()
113 self.expire_date = self.get_expiry_date()
114 self.barcode = self._simple_map('lulLibraryBarcode')
116 def _simple_map(self, ldap_attribute):
118 Convenience method for mapping a given LDAP attribute
121 if ldap_attribute in self.ldap_atts:
122 return self.ldap_atts[ldap_attribute][0].strip()
126 def get_expiry_date(self):
128 Map LDAP record to Evergreen expiry dates
131 expiry_date = '%d-09-30' % (datetime.date.today().year + 1)
132 # Faculty and staff get a long time
133 if self.profile == 11 or self.profile == 14:
134 expiry_date = '%d-09-30' % (datetime.date.today().year + 8)
138 def get_identity(self):
140 Map LDAP record to Evergreen identity type and value
143 if 'lulColleagueId' not in self.ldap_atts:
144 print >> sys.stderr, 'No lulColleagueId for %s' % (self.ldap_atts)
145 return 2, 'NO_COLLEAGUE_ID'
147 ident_value = self._simple_map('lulColleagueId')
148 if ident_value is None:
149 print >> sys.stderr, 'No Datatel number for %s (%s)' % (
153 ident_value = ident_value.lower()
154 if len(ident_value) != 7:
155 print >> sys.stderr, 'Datatel number not 7 chars for %s (%s)' % (
156 self.usrname, ident_value
158 if len(ident_value) == 6:
159 ident_value = '0%s' % ident_value
160 elif len(ident_value) == 5:
161 ident_value = '00%s' % ident_value
163 return 2, ident_value
165 def get_profile(self):
167 Map LDAP record to Evergreen profile
170 if 'lulStudentLevel' in self.ldap_atts:
171 affiliation = self._simple_map('lulStudentLevel').lower()
172 elif 'lulPrimaryAffiliation' in self.ldap_atts:
173 affiliation = self._simple_map('lulPrimaryAffiliation').lower()
192 if affiliation in profile_map:
193 return profile_map[affiliation]
195 print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
196 "for user %s" % (affiliation, self.ldap_atts)
199 def get_home_ou(self):
201 Map LDAP record to Evergreen home library
204 if 'laurentian.ca' or 'laurentienne.ca' in self.email:
206 elif 'huntingtonu.ca' in self.email:
208 elif 'usudbury.ca' in self.email:
211 # Default to Laurentian
214 class AuthException(Exception):
216 Exceptions for authentication events
219 def __init__(self, msg=''):
221 Initialize the authentication exception
223 Exception.__init__(self)
228 Stringify the authentication exception
230 return 'AuthException: %s' % self.msg
234 Loads the fieldmapper IDL, registering class hints for the defined objects
236 We use a temporary file to store the IDL each time load_idl()
237 is invoked to ensure that the IDL is in sync with the target
238 server. One could a HEAD request to do some smarter caching,
242 parser = oils.utils.idl.IDLParser()
243 idlfile = tempfile.TemporaryFile()
245 # Get the fm_IDL.xml file from the server
247 idl = urllib2.urlopen('%s://%s/%s' %
248 (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
250 idlfile.write(idl.read())
251 # rewind to the beginning of the file
254 except urllib2.URLError, exc:
255 print("Could not open URL to read IDL: %s", exc.code)
258 print("Could not write IDL to file: %s", exc.code)
261 parser.set_IDL(idlfile)
264 def osrf_login(username, password, workstation=None):
266 Login to the server and get back an authtoken
274 'open-ils.auth.authenticate.init', username).send()
275 except Exception, exc:
278 # generate the hashed password
279 password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
281 result = osrf_request(
283 'open-ils.auth.authenticate.complete',
284 { 'workstation' : workstation,
285 'username' : username,
286 'password' : password,
290 evt = oils.event.Event.parse_event(result)
291 if evt and not evt.success:
292 raise AuthException(evt.text_code)
294 __authtoken = result['payload']['authtoken']
297 def osrf_request(service, method, *args):
299 Make a JSON request to the OpenSRF gateway
301 This is as simple as it gets. Atomic requests will require a bit
305 req = osrf.gateway.JSONGatewayRequest(service, method, *args)
307 # The gateway URL ensures we're using JSON v1, not v0
308 req.setPath(credentials.GATEWAY_URL)
311 def find_ldap_users(con, ldap_filter, attributes, auth):
313 Retrieve personnel accounts from LDAP directory and process'em
316 search_scope = ldap.SCOPE_SUBTREE
320 raw_results = con.search_ext_s(base_dn, search_scope, ldap_filter, attributes)
321 for raw_user, raw_atts in raw_results:
325 user = User(raw_user, raw_atts)
328 if ARGS.create_users:
329 res = create_evergreen_user(auth, user)
331 update_ldap_barcode(con, user)
333 if ARGS.push_barcode:
338 uid = find_evergreen_user(auth, user)
339 except AttributeError:
340 # stub LDAP account; move on
343 print >> sys.stderr, "User not found in Evergreen: %s" % \
346 user.barcode = get_barcode(auth, uid)
347 update_ldap_barcode(con, user)
349 except ldap.LDAPError, exc:
350 print >> sys.stderr, exc
354 def get_barcode(auth, uid):
356 Retrieve the barcode for a user from Evergreen based on their user ID
359 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
360 auth, int(uid), ['card']
362 evt = oils.event.Event.parse_event(user)
363 if evt and not evt.success:
364 raise AuthException(evt.text_code)
369 return user.card().barcode()
371 def find_evergreen_user(auth, user):
373 Search for an existing user in Evergreen
375 Returns user ID if found, None if not
378 # Custom search method - overrides opt-in visibility, as it suggests
379 search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
383 include_inactive = True
385 print("Trying to find user: %s %s %s" %
386 (user.ident_value, user.email, user.usrname)
389 by_id = osrf_request(
390 'open-ils.actor', search_method,
391 auth, {'ident_value': {'value': user.ident_value, 'group': 0}}, limit,
392 sort, include_inactive, search_depth
395 if by_id and len(by_id):
398 by_email = osrf_request(
399 'open-ils.actor', search_method,
400 auth, {'email': {'value': user.email}}, limit,
401 sort, include_inactive, search_depth
404 if by_email and len(by_email):
407 by_usrname = osrf_request(
408 'open-ils.actor', search_method,
409 auth, {'usrname': {'value': user.usrname}}, limit,
410 sort, include_inactive, search_depth
413 if by_usrname and len(by_usrname):
418 def retrieve_evergreen_user(auth, uid):
420 Update account for an existing user in Evergreen
422 Sets active flag, updates expiry date
426 'open-ils.actor', 'open-ils.actor.user.retrieve', auth, uid
430 print sys.stderr >> "Damn it"
435 def create_evergreen_user(auth, user):
437 Map LDAP record to Evergreen user
443 if user.profile is None:
444 print >> sys.stderr, "No profile set for %s" % user.usrname
447 if user.barcode is not None:
450 found = find_evergreen_user(auth, user)
452 egau = osrf.net_obj.new_object_from_hint('au')
455 print("Found: %s" % user.usrname)
456 egau = retrieve_evergreen_user(auth, found)
462 egau.usrname(user.usrname)
463 egau.profile(user.profile)
464 egau.email(user.email)
465 egau.family_name(user.family_name)
466 egau.first_given_name(user.first_given_name)
467 egau.ident_type(user.ident_type)
468 egau.ident_value(user.ident_value)
469 egau.home_ou(user.home_ou)
470 egau.passwd(user.passwd)
471 egau.expire_date(user.expire_date)
473 # Workaround open-ils.actor.patron.update bug
477 # Create or update the user
480 'open-ils.actor', 'open-ils.actor.patron.update', auth, egau
482 except Exception, exc:
483 print >> sys.stderr, exc
486 evt = oils.event.Event.parse_event(usr)
487 if evt and not evt.success:
488 print >> sys.stderr, "Create Evergreen user failed for %s: %s" % (
489 AuthException(evt.text_code), user.usrname)
493 # Do not generate a barcode for the user
496 # Generate a barcode for the user
498 barcode = osrf_request(
499 'open-ils.actor', 'open-ils.actor.generate_patron_barcode',
500 auth, usr.id(), '000070'
502 except Exception, exc:
503 print >> sys.stderr, exc
506 evt = oils.event.Event.parse_event(barcode)
507 if evt and not evt.success:
508 print >> sys.stderr, "Create barcode failed for %s: %s" % (
509 AuthException(evt.text_code), user.usrname)
512 user.barcode = barcode['evergreen.actor_update_barcode']
514 create_stat_cats(egau, user)
516 print("Created: %s with barcode %s" % (egau.usrname(), user.barcode))
518 return egau.usrname(), user.barcode
520 def update_ldap_barcode(con, user):
522 Updates the LDAP directory with the new barcode for a given user
524 We need to retrieve the qualified CN for the ldap.modify_s operation,
525 first, so take the first match we get. Reckless, I know.
529 mod_attrs = [(ldap.MOD_REPLACE, 'lulLibraryBarcode', [user.barcode])]
530 con.modify_s(user.cname, mod_attrs)
531 except Exception, exc:
532 print >> sys.stderr, exc
537 def create_stat_cats(eg_user, ldap_user):
539 Map statistical categories
542 if ldap_user.lang_pref:
545 # XXX Now we should generate stat cats eh?
548 def dump_data(result_data):
550 Simple dump of all data received
554 for key in result_data:
555 print(key, result_data[key])
557 def ldap_query(con, auth):
559 Process LDAP users created since a given date
565 'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
566 'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
567 'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
571 if (ARGS.query_date):
572 ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
573 '(objectclass=lulEduPerson)', ARGS.query_date
575 elif (ARGS.query_cn):
576 ldap_filter = '(&%s(cn=%s))' % (
579 elif (ARGS.query_sn):
580 ldap_filter = '(&%s(sn=%s))' % (
581 '(objectclass=lulEduPerson)', ARGS.query_sn
583 elif (ARGS.query_id):
584 ldap_filter = '(&%s(lulColleagueId=%s))' % (
585 '(objectclass=lulEduPerson)', ARGS.query_id
589 return find_ldap_users(con, ldap_filter, attributes, auth)
593 Parse the command line options for the script
595 parser = argparse.ArgumentParser()
596 parser.add_argument('-d', '--dump-ldap', action='store_true',
597 help='Dump the LDAP results to STDOUT'
599 parser.add_argument('-c', '--create-users', action='store_true',
600 help='Create new users in Evergreen'
602 parser.add_argument('-b', '--push-barcode', action='store_true',
603 help='Push barcode to LDAP'
605 parser.add_argument('--query-cn',
606 help='Search LDAP for a specific user by cn attribute'
608 parser.add_argument('--query-sn',
609 help='Search LDAP for a specific user by sn attribute'
611 parser.add_argument('--query-id',
612 help='Search LDAP for a specific user by id attribute'
614 parser.add_argument('--query-date',
615 help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
617 parser.add_argument('--find-eg-user',
618 help='Find Evergreen user by user name'
620 parser.add_argument('-U', '--eg-user', nargs='?',
621 help='Evergreen user name', default=credentials.OSRF_USER
623 parser.add_argument('-P', '--eg-password', nargs='?',
624 help='Evergreen password', default=credentials.OSRF_PW
626 parser.add_argument('-W', '--eg-workstation', nargs='?',
627 help='Name of the Evergreen workstation',
628 default=credentials.OSRF_WORK_OU
630 parser.add_argument('-H', '--eg-host', nargs='?',
631 help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
633 parser.add_argument('-u', '--ldap-user', nargs='?',
634 help='LDAP user (DN)', default=credentials.LDAP_DN
636 parser.add_argument('-p', '--ldap-password', nargs='?',
637 help='LDAP password', default=credentials.LDAP_PW
639 parser.add_argument('-s', '--ldap-server', nargs='?',
640 help='LDAP server name or IP address', default=credentials.LDAP_HOST
642 args = parser.parse_args()
647 Set up connections and run code
655 # override parsed args with anything that was passed in
659 # Set the host for our requests
660 osrf.gateway.GatewayRequest.setDefaultHost(ARGS.eg_host)
662 # Pull all of our object definitions together
665 # Log in and get an authtoken
666 AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
668 if ARGS.find_eg_user:
670 "A quick and dirty user class"
673 user.ident_value = ARGS.find_eg_user
674 user.email = ARGS.find_eg_user
675 user.usrname = ARGS.find_eg_user
676 uid = find_evergreen_user(AUTHTOKEN, user)
678 print "Found Evergreen user: %s" % (uid)
682 con = ldap.initialize(ARGS.ldap_server)
683 con.set_option(ldap.OPT_REFERRALS, 0)
684 con.simple_bind_s(ARGS.ldap_user, ARGS.ldap_password)
685 results = ldap_query(con, AUTHTOKEN)
687 except ldap.LDAPError, exc:
688 print >> sys.stderr, "Could not connect: " + exc.message['info']
689 if type(exc.message) == dict and exc.message.has_key('desc'):
690 print >> sys.stderr, exc.message['desc']
692 print >> sys.stderr, exc
700 # 'mail': ['dan@example.com'],
701 # 'givenName': ['Dan'],
703 # 'lulColleagueId': ['0123456'],
704 # 'lulStudentLevel': ['GR'],
706 # create_evergreen_user(AUTHTOKEN, UDATA)
709 if __name__ == '__main__':
717 # vim: et:ts=4:sw=4:tw=78: