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
71 Holds data mapped from LDAP schema to Evergreen attributes
73 Less formal than an actual 'au' object
76 def __init__(self, raw_cn, raw_atts):
78 Map core LDAP schema attributes to Evergreen user bits
80 Easier to replace hard-coded mappings with calls to functions
84 self.ldap_atts = raw_atts
86 self.cn = self._simple_map('cn').lower()
87 self.lang_pref = self._simple_map('preferredLanguage')
89 if 'mail' not in self.ldap_atts:
90 if self.lang_pref and self.lang_pref == 'f':
91 self.email = self.cn + '@laurentienne.ca'
93 self.email = self.cn + '@laurentian.ca'
94 print >> sys.stderr, '"mail" not found for %s, using %s' % (self.cn, self.email)
97 self.email = (self._simple_map('mail') or '').lower()
99 # Strip leading/ending whitespace; LDAP data can be dirty
101 # Using email for username deliberately here
102 self.usrname = self.email
103 self.family_name = self._simple_map('sn')
104 self.ident_type, self.ident_value = self.get_identity()
105 self.home_ou = self.get_home_ou()
106 self.first_given_name = self._simple_map('givenName')
107 if not self.first_given_name:
108 self.first_given_name = 'LDAP_NULL'
109 print >> sys.stderr, 'No givenName for %s' % (self.usrname)
111 # Randomized password, assuming user will use "Forgot password"
112 # function to set their own password
113 self.passwd = oils.utils.utils.md5sum(os.urandom(10))
115 self.profile = self.get_profile()
116 self.expire_date = self.get_expiry_date()
117 self.barcode = self._simple_map('lulLibraryBarcode')
119 def _simple_map(self, ldap_attribute):
121 Convenience method for mapping a given LDAP attribute
124 if ldap_attribute in self.ldap_atts:
125 return self.ldap_atts[ldap_attribute][0].strip()
129 def get_expiry_date(self):
131 Map LDAP record to Evergreen expiry dates
134 expiry_date = '%d-09-30' % (datetime.date.today().year + 1)
135 exp_att = self._simple_map('loginExpirationTime')
137 # Alumni get extended expiry dates
138 if self.profile == 127:
139 expiry_date = '%d-09-30' % (datetime.date.today().year + 5)
140 # Go with the LDAP-set expiry date
142 expiry_date = "%s-%s-%s" % (exp_att[:4], exp_att[4:6], exp_att[6:8])
143 # Faculty and staff get a long time
144 elif self.profile == 111 or self.profile == 115:
145 expiry_date = '%d-09-30' % (datetime.date.today().year + 8)
146 # Expire the visiting students Sept. 30th
147 elif self.affiliation == 'visitor':
148 expiry_date = '%d-09-30' % (datetime.date.today().year + 1)
152 def get_identity(self):
154 Map LDAP record to Evergreen identity type and value
157 if 'lulColleagueId' not in self.ldap_atts:
158 print >> sys.stderr, 'No lulColleagueId for %s' % (self.ldap_atts)
159 return 2, 'NO_COLLEAGUE_ID'
161 ident_value = self._simple_map('lulColleagueId')
162 if ident_value is None:
163 print >> sys.stderr, 'No Datatel number for %s (%s)' % (
167 ident_value = ident_value.lower()
168 if len(ident_value) != 7:
169 print >> sys.stderr, 'Datatel number not 7 chars for %s (%s)' % (
170 self.usrname, ident_value
172 if len(ident_value) == 6:
173 ident_value = '0%s' % ident_value
174 elif len(ident_value) == 5:
175 ident_value = '00%s' % ident_value
177 return 2, ident_value
179 def get_profile(self):
181 Map LDAP record to Evergreen profile
201 if 'alumni' in self.cname.lower():
203 affiliation = 'alumni'
204 return profile_map[affiliation]
206 for att in ('lulPrimaryAffiliation', 'lulStudentLevel', 'lulAffiliation'):
207 if att in self.ldap_atts:
208 affiliation = self._simple_map(att).lower()
209 if affiliation in profile_map:
210 self.affiliation = affiliation
211 return profile_map[affiliation]
213 if 'alumni' in self.cname.lower():
215 affiliation = 'alumni'
216 elif 'empl' in self.cname.lower():
217 affiliation = 'staff'
221 # Let's remember the affiliation
222 self.affiliation = affiliation
224 if affiliation in profile_map:
225 return profile_map[affiliation]
227 print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
228 "for user %s" % (affiliation, self.ldap_atts)
231 def get_home_ou(self):
233 Map LDAP record to Evergreen home library
236 if 'laurentian.ca' in self.email or 'laurentienne.ca' in self.email:
238 elif 'huntingtonu.ca' in self.email:
240 elif 'usudbury.ca' in self.email:
243 # Default to Laurentian
246 class AuthException(Exception):
248 Exceptions for authentication events
251 def __init__(self, msg=''):
253 Initialize the authentication exception
255 Exception.__init__(self)
260 Stringify the authentication exception
262 return 'AuthException: %s' % self.msg
266 Loads the fieldmapper IDL, registering class hints for the defined objects
268 We use a temporary file to store the IDL each time load_idl()
269 is invoked to ensure that the IDL is in sync with the target
270 server. One could a HEAD request to do some smarter caching,
274 parser = oils.utils.idl.IDLParser()
275 idlfile = tempfile.TemporaryFile()
277 # Get the fm_IDL.xml file from the server
279 idl = urllib2.urlopen('%s://%s/%s' %
280 (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
282 idlfile.write(idl.read())
283 # rewind to the beginning of the file
286 except urllib2.URLError, exc:
287 print("Could not open URL to read IDL: %s", exc)
290 print("Could not write IDL to file: %s", exc.code)
293 parser.set_IDL(idlfile)
296 def osrf_login(username, password, workstation=None):
298 Login to the server and get back an authtoken
306 'open-ils.auth.authenticate.init', username).send()
307 except Exception, exc:
310 # generate the hashed password
311 password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
313 result = osrf_request(
315 'open-ils.auth.authenticate.complete',
316 { 'workstation' : workstation,
317 'username' : username,
318 'password' : password,
322 evt = oils.event.Event.parse_event(result)
323 if evt and not evt.success:
324 raise AuthException(evt.text_code)
326 __authtoken = result['payload']['authtoken']
329 def osrf_request(service, method, *args):
331 Make a JSON request to the OpenSRF gateway
333 This is as simple as it gets. Atomic requests will require a bit
337 req = osrf.gateway.JSONGatewayRequest(service, method, *args)
339 # The gateway URL ensures we're using JSON v1, not v0
340 req.setPath(credentials.GATEWAY_URL)
343 def find_ldap_users(con, ldap_filter, attributes, auth):
345 Retrieve personnel accounts from LDAP directory and process'em
348 search_scope = ldap.SCOPE_SUBTREE
352 raw_results = con.search_ext_s(base_dn, search_scope, ldap_filter, attributes)
353 for raw_user, raw_atts in raw_results:
357 user = User(raw_user, raw_atts)
360 print("dn = '%s'" % (raw_user))
361 if user.ident_value == 'NO_COLLEAGUE_ID':
362 print >> sys.stderr, "No colleague ID: skipping"
364 if ARGS.create_users:
365 res = create_evergreen_user(auth, user)
367 update_ldap_barcode(con, user)
369 if ARGS.push_barcode:
374 uid = find_evergreen_user(auth, user)
375 except AttributeError:
376 # stub LDAP account; move on
379 print >> sys.stderr, "User not found in Evergreen: %s" % \
382 user.barcode = get_barcode(auth, uid)
383 update_ldap_barcode(con, user)
385 except ldap.LDAPError, exc:
386 print >> sys.stderr, exc
390 def get_barcode(auth, uid):
392 Retrieve the barcode for a user from Evergreen based on their user ID
395 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
396 auth, int(uid), ['card']
398 evt = oils.event.Event.parse_event(user)
399 if evt and not evt.success:
400 raise AuthException(evt.text_code)
405 return user.card().barcode()
407 def find_evergreen_user(auth, user):
409 Search for an existing user in Evergreen
411 Returns user ID if found, None if not
414 # Custom search method - overrides opt-in visibility, as it suggests
415 search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
419 include_inactive = True
421 print("Trying to find user: %s %s %s" %
422 (user.ident_value, user.email, user.usrname)
425 by_id = osrf_request(
426 'open-ils.actor', search_method,
427 auth, {'ident_value': {'value': user.ident_value, 'group': 0}}, limit,
428 sort, include_inactive, search_depth
431 if by_id and len(by_id):
434 by_email = osrf_request(
435 'open-ils.actor', search_method,
436 auth, {'email': {'value': user.email}}, limit,
437 sort, include_inactive, search_depth
440 if by_email and len(by_email):
443 by_usrname = osrf_request(
444 'open-ils.actor', search_method,
445 auth, {'usrname': {'value': user.usrname}}, limit,
446 sort, include_inactive, search_depth
449 if by_usrname and len(by_usrname):
454 def retrieve_evergreen_user(auth, uid):
456 Update account for an existing user in Evergreen
458 Sets active flag, updates expiry date
462 'open-ils.actor', 'open-ils.actor.user.retrieve', auth, uid
466 print sys.stderr >> "Damn it"
471 def create_evergreen_user(auth, user):
473 Map LDAP record to Evergreen user
479 if user.profile is None:
480 print >> sys.stderr, "No profile set for %s" % user.usrname
483 found = find_evergreen_user(auth, user)
485 egau = osrf.net_obj.new_object_from_hint('au')
488 print("Found: %s" % user.usrname)
489 egau = retrieve_evergreen_user(auth, found)
495 egau.usrname(user.usrname)
496 egau.profile(user.profile)
497 egau.email(user.email)
498 egau.family_name(user.family_name)
499 egau.first_given_name(user.first_given_name)
500 egau.ident_type(user.ident_type)
501 egau.ident_value(user.ident_value)
502 egau.home_ou(user.home_ou)
503 egau.expire_date(user.expire_date)
505 # Workaround open-ils.actor.patron.update bug
509 # Don't reset the Conifer-specific password
511 egau.passwd(user.passwd)
513 # Create or update the user
516 'open-ils.actor', 'open-ils.actor.patron.update', auth, egau
518 except Exception, exc:
519 print >> sys.stderr, exc
522 evt = oils.event.Event.parse_event(usr)
523 if evt and not evt.success:
524 print >> sys.stderr, "Create Evergreen user failed for %s: %s" % (
525 AuthException(evt.text_code), user.usrname)
529 # Do not generate a barcode for the user
532 # Generate a barcode for the user
534 barcode = osrf_request(
535 'open-ils.actor', 'open-ils.actor.generate_patron_barcode',
536 auth, usr.id(), '000070'
538 except Exception, exc:
539 print >> sys.stderr, exc
542 evt = oils.event.Event.parse_event(barcode)
543 if evt and not evt.success:
544 print >> sys.stderr, "Create barcode failed for %s: %s" % (
545 AuthException(evt.text_code), user.usrname)
548 user.barcode = barcode['evergreen.actor_update_barcode']
550 create_stat_cats(egau, user)
552 print("Created: %s with barcode %s" % (egau.usrname(), user.barcode))
554 return egau.usrname(), user.barcode
556 def update_ldap_barcode(con, user):
558 Updates the LDAP directory with the new barcode for a given user
560 We need to retrieve the qualified CN for the ldap.modify_s operation,
561 first, so take the first match we get. Reckless, I know.
565 mod_attrs = [(ldap.MOD_REPLACE, 'lulLibraryBarcode', [user.barcode])]
566 con.modify_s(user.cname, mod_attrs)
567 except Exception, exc:
568 print >> sys.stderr, exc
573 def create_stat_cats(eg_user, ldap_user):
575 Map statistical categories
578 if ldap_user.lang_pref:
581 # XXX Now we should generate stat cats eh?
584 def dump_data(result_data):
586 Simple dump of all data received
590 for key in result_data:
591 print(key, result_data[key])
593 def ldap_query(con, auth):
595 Process LDAP users created since a given date
601 'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
602 'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
603 'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
604 'lulModifyTimestamp', 'loginExpirationTime'
607 if (ARGS.query_date):
608 ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
609 '(objectclass=lulEduPerson)', ARGS.query_date
611 elif (ARGS.query_cn):
612 ldap_filter = '(&%s(cn=%s))' % (
615 elif (ARGS.query_sn):
616 ldap_filter = '(&%s(sn=%s))' % (
617 '(objectclass=lulEduPerson)', ARGS.query_sn
619 elif (ARGS.query_id):
620 ldap_filter = '(&%s(lulColleagueId=%s)(!(lulStudentLevel=APP)))' % (
621 '(objectclass=lulEduPerson)', ARGS.query_id
624 ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s)(createTimestamp<=%s))' % (
625 '(objectclass=lulEduPerson)', ARGS.query_date, ARGS.query_end
629 return find_ldap_users(con, ldap_filter, attributes, auth)
633 Parse the command line options for the script
635 parser = argparse.ArgumentParser()
636 parser.add_argument('-d', '--dump-ldap', action='store_true',
637 help='Dump the LDAP results to STDOUT'
639 parser.add_argument('-c', '--create-users', action='store_true',
640 help='Create new users in Evergreen'
642 parser.add_argument('-b', '--push-barcode', action='store_true',
643 help='Push barcode to LDAP'
645 parser.add_argument('--query-cn',
646 help='Search LDAP for a specific user by cn attribute'
648 parser.add_argument('--query-sn',
649 help='Search LDAP for a specific user by sn attribute'
651 parser.add_argument('--query-id',
652 help='Search LDAP for a specific user by id attribute'
654 parser.add_argument('--query-date',
655 help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
657 parser.add_argument('--query-end',
658 help='Search LDAP for users created before (YYYYMMDDHHMMSSZ)'
660 parser.add_argument('--find-eg-user',
661 help='Find Evergreen user by user name'
663 parser.add_argument('-U', '--eg-user', nargs='?',
664 help='Evergreen user name', default=credentials.OSRF_USER
666 parser.add_argument('-P', '--eg-password', nargs='?',
667 help='Evergreen password', default=credentials.OSRF_PW
669 parser.add_argument('-W', '--eg-workstation', nargs='?',
670 help='Name of the Evergreen workstation',
671 default=credentials.OSRF_WORK_OU
673 parser.add_argument('-H', '--eg-host', nargs='?',
674 help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
676 parser.add_argument('-u', '--ldap-user', nargs='?',
677 help='LDAP user (DN)', default=credentials.LDAP_DN
679 parser.add_argument('-p', '--ldap-password', nargs='?',
680 help='LDAP password', default=credentials.LDAP_PW
682 parser.add_argument('-s', '--ldap-server', nargs='?',
683 help='LDAP server name or IP address', default=credentials.LDAP_HOST
685 args = parser.parse_args()
690 Set up connections and run code
698 # override parsed args with anything that was passed in
702 # Set the host for our requests
703 osrf.gateway.GatewayRequest.setDefaultHost('%s://%s' % (credentials.OSRF_HTTP, ARGS.eg_host))
705 # Pull all of our object definitions together
708 # Log in and get an authtoken
709 AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
711 if ARGS.find_eg_user:
713 "A quick and dirty user class"
716 user.ident_value = ARGS.find_eg_user
717 user.email = ARGS.find_eg_user
718 user.usrname = ARGS.find_eg_user
719 uid = find_evergreen_user(AUTHTOKEN, user)
721 print "Found Evergreen user: %s" % (uid)
725 con = ldap.initialize(ARGS.ldap_server)
726 con.set_option(ldap.OPT_REFERRALS, 0)
727 con.simple_bind_s(ARGS.ldap_user, ARGS.ldap_password)
728 results = ldap_query(con, AUTHTOKEN)
730 except ldap.LDAPError, exc:
731 print >> sys.stderr, "Could not connect: "
732 print >> sys.stderr, exc.message
733 if type(exc.message) == dict and exc.message.has_key('desc'):
734 print >> sys.stderr, exc.message['desc']
736 print >> sys.stderr, exc
744 # 'mail': ['dan@example.com'],
745 # 'givenName': ['Dan'],
747 # 'lulColleagueId': ['0123456'],
748 # 'lulStudentLevel': ['GR'],
750 # create_evergreen_user(AUTHTOKEN, UDATA)
753 if __name__ == '__main__':
761 # vim: et:ts=4:sw=4:tw=78: