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_ldap):
75 Map core LDAP schema attributes to Evergreen user bits
77 Easier to replace hard-coded mappings with calls to functions
80 self.cname = raw_ldap[0]
81 self.ldap_atts = raw_ldap[1]
83 if 'mail' not in self.ldap_atts:
84 print >> sys.stderr, 'mail not found for %s' % self.cname
87 # Strip leading/ending whitespace; LDAP data can be dirty
89 # Using email for username deliberately here
90 self.usrname = (self._simple_map('mail') or '').lower()
91 self.email = (self._simple_map('mail') or '').lower()
92 self.family_name = self._simple_map('sn')
93 self.ident_type, self.ident_value = self.get_identity()
94 self.home_ou = self.get_home_ou()
95 self.first_given_name = self._simple_map('givenName')
96 if not self.first_given_name:
97 self.first_given_name = 'LDAP_NULL'
98 print >> sys.stderr, 'No givenName for %s' % (self.usrname)
99 self.lang_pref = self._simple_map('preferredLanguage')
101 # Randomized password, assuming user will use "Forgot password"
102 # function to set their own password
103 self.passwd = oils.utils.utils.md5sum(os.urandom(10))
105 self.profile = self.get_profile()
106 self.expire_date = self.get_expiry_date()
107 self.barcode = self._simple_map('lulLibraryBarcode')
109 def _simple_map(self, ldap_attribute):
111 Convenience method for mapping a given LDAP attribute
114 if ldap_attribute in self.ldap_atts:
115 return self.ldap_atts[ldap_attribute][0].strip()
119 def get_expiry_date(self):
121 Map LDAP record to Evergreen expiry dates
124 expiry_date = '%d-09-30' % (datetime.date.today().year + 1)
125 # Faculty and staff get a long time
126 if self.profile == 11 or self.profile == 14:
127 expiry_date = '%d-09-30' % (datetime.date.today().year + 8)
131 def get_identity(self):
133 Map LDAP record to Evergreen identity type and value
136 if 'lulColleagueId' not in self.ldap_atts:
137 print >> sys.stderr, 'No lulColleagueId for %s' % (self.ldap_atts)
138 return 2, 'NO_COLLEAGUE_ID'
140 ident_value = self._simple_map('lulColleagueId')
141 if ident_value is None:
142 print >> sys.stderr, 'No Datatel number for %s (%s)' % (
146 ident_value = ident_value.lower()
147 if len(ident_value) != 7:
148 print >> sys.stderr, 'Datatel number not 7 chars for %s (%s)' % (
149 self.usrname, ident_value
151 if len(ident_value) == 6:
152 ident_value = '0%s' % ident_value
153 elif len(ident_value) == 5:
154 ident_value = '00%s' % ident_value
156 return 2, ident_value
158 def get_profile(self):
160 Map LDAP record to Evergreen profile
163 if 'lulStudentLevel' in self.ldap_atts:
164 affiliation = self._simple_map('lulStudentLevel').lower()
165 elif 'lulPrimaryAffiliation' in self.ldap_atts:
166 affiliation = self._simple_map('lulPrimaryAffiliation').lower()
185 if affiliation in profile_map:
186 return profile_map[affiliation]
188 print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
189 "for user %s" % (affiliation, self.ldap_atts)
192 def get_home_ou(self):
194 Map LDAP record to Evergreen home library
197 if 'laurentian.ca' or 'laurentienne.ca' in self.email:
199 elif 'huntingtonu.ca' in self.email:
201 elif 'usudbury.ca' in self.email:
204 # Default to Laurentian
207 class AuthException(Exception):
209 Exceptions for authentication events
212 def __init__(self, msg=''):
214 Initialize the authentication exception
216 Exception.__init__(self)
221 Stringify the authentication exception
223 return 'AuthException: %s' % self.msg
227 Loads the fieldmapper IDL, registering class hints for the defined objects
229 We use a temporary file to store the IDL each time load_idl()
230 is invoked to ensure that the IDL is in sync with the target
231 server. One could a HEAD request to do some smarter caching,
235 parser = oils.utils.idl.IDLParser()
236 idlfile = tempfile.TemporaryFile()
238 # Get the fm_IDL.xml file from the server
240 idl = urllib2.urlopen('%s://%s/%s' %
241 (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
243 idlfile.write(idl.read())
244 # rewind to the beginning of the file
247 except urllib2.URLError, exc:
248 print("Could not open URL to read IDL: %s", exc.code)
251 print("Could not write IDL to file: %s", exc.code)
254 parser.set_IDL(idlfile)
257 def osrf_login(username, password, workstation=None):
259 Login to the server and get back an authtoken
267 'open-ils.auth.authenticate.init', username).send()
268 except Exception, exc:
271 # generate the hashed password
272 password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
274 result = osrf_request(
276 'open-ils.auth.authenticate.complete',
277 { 'workstation' : workstation,
278 'username' : username,
279 'password' : password,
283 evt = oils.event.Event.parse_event(result)
284 if evt and not evt.success:
285 raise AuthException(evt.text_code)
287 __authtoken = result['payload']['authtoken']
290 def osrf_request(service, method, *args):
292 Make a JSON request to the OpenSRF gateway
294 This is as simple as it gets. Atomic requests will require a bit
298 req = osrf.gateway.JSONGatewayRequest(service, method, *args)
300 # The gateway URL ensures we're using JSON v1, not v0
301 req.setPath(credentials.GATEWAY_URL)
304 def find_ldap_users(con, ldap_filter, attributes, auth):
306 Retrieve personnel accounts from LDAP directory and process'em
309 search_scope = ldap.SCOPE_SUBTREE
312 result_id = con.search(base_dn, search_scope, ldap_filter, attributes)
314 result_type, result_data = con.result(result_id, 0)
315 if result_data == []:
318 user = User(result_data[0])
320 dump_data(result_data)
321 if ARGS.create_users:
322 res = create_evergreen_user(auth, user)
324 update_ldap_barcode(con, user)
325 if ARGS.push_barcode:
330 uid = find_evergreen_user(auth, user)
331 except AttributeError:
332 # stub LDAP account; move on
335 print >> sys.stderr, "User not found in Evergreen: %s" % \
338 user.barcode = get_barcode(auth, uid)
339 update_ldap_barcode(con, user)
341 except ldap.LDAPError, exc:
342 print >> sys.stderr, exc
344 def get_barcode(auth, uid):
346 Retrieve the barcode for a user from Evergreen based on their user ID
349 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
350 auth, int(uid), ['card']
352 evt = oils.event.Event.parse_event(user)
353 if evt and not evt.success:
354 raise AuthException(evt.text_code)
359 return user.card().barcode()
361 def find_evergreen_user(auth, user):
363 Search for an existing user in Evergreen
365 Returns user ID if found, None if not
368 # Custom search method - overrides opt-in visibility, as it suggests
369 search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
373 include_inactive = True
375 print("Trying to find user: %s %s %s" %
376 (user.ident_value, user.email, user.usrname)
379 by_id = osrf_request(
380 'open-ils.actor', search_method,
381 auth, {'ident_value': {'value': user.ident_value, 'group': 0}}, limit,
382 sort, include_inactive, search_depth
385 if by_id and len(by_id):
388 by_email = osrf_request(
389 'open-ils.actor', search_method,
390 auth, {'email': {'value': user.email}}, limit,
391 sort, include_inactive, search_depth
394 if by_email and len(by_email):
397 by_usrname = osrf_request(
398 'open-ils.actor', search_method,
399 auth, {'usrname': {'value': user.usrname}}, limit,
400 sort, include_inactive, search_depth
403 if by_usrname and len(by_usrname):
408 def create_evergreen_user(auth, user):
410 Map LDAP record to Evergreen user
416 if user.profile is None:
417 print >> sys.stderr, "No profile set for %s" % user.usrname
420 if user.barcode is not None:
423 found = find_evergreen_user(auth, user)
425 print("Found: %s" % user.usrname)
428 newau = osrf.net_obj.new_object_from_hint('au')
431 newau.usrname(user.usrname)
432 newau.profile(user.profile)
433 newau.email(user.email)
434 newau.family_name(user.family_name)
435 newau.first_given_name(user.first_given_name)
436 newau.ident_type(user.ident_type)
437 newau.ident_value(user.ident_value)
438 newau.home_ou(user.home_ou)
439 newau.passwd(user.passwd)
440 newau.expire_date(user.expire_date)
442 # Workaround open-ils.actor.patron.update bug
449 'open-ils.actor', 'open-ils.actor.patron.update', auth, newau
451 except Exception, exc:
452 print >> sys.stderr, exc
455 evt = oils.event.Event.parse_event(usr)
456 if evt and not evt.success:
457 print >> sys.stderr, "Create Evergreen user failed for %s: %s" % (
458 AuthException(evt.text_code), user.usrname)
461 # Generate a barcode for the user
463 barcode = osrf_request(
464 'open-ils.actor', 'open-ils.actor.generate_patron_barcode',
467 except Exception, exc:
468 print >> sys.stderr, exc
471 evt = oils.event.Event.parse_event(barcode)
472 if evt and not evt.success:
473 print >> sys.stderr, "Create barcode failed for %s: %s" % (
474 AuthException(evt.text_code), user.usrname)
477 user.barcode = barcode['evergreen.lu_update_barcode']
479 create_stat_cats(newau, user)
481 print("Created: %s with barcode %s" % (newau.usrname(), user.barcode))
484 def update_ldap_barcode(con, user):
486 Updates the LDAP directory with the new barcode for a given user
488 We need to retrieve the qualified CN for the ldap.modify_s operation,
489 first, so take the first match we get. Reckless, I know.
493 mod_attrs = [(ldap.MOD_REPLACE, 'lulLibraryBarcode', [user.barcode])]
494 con.modify_s(user.cname, mod_attrs)
495 except Exception, exc:
496 print >> sys.stderr, exc
501 def create_stat_cats(eg_user, ldap_user):
503 Map statistical categories
506 if ldap_user.lang_pref:
509 # XXX Now we should generate stat cats eh?
512 def dump_data(result_data):
514 Simple dump of all data received
518 print(result_data[0][0])
519 for key in result_data[0][1]:
520 print(key, result_data[0][1][key])
522 def ldap_query(con, auth):
524 Process LDAP users created since a given date
527 'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
528 'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
529 'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
533 if (ARGS.query_date):
534 ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
535 '(objectclass=lulEduPerson)', ARGS.query_date
537 elif (ARGS.query_cn):
538 ldap_filter = '(&%s(cn=%s))' % (
541 elif (ARGS.query_sn):
542 ldap_filter = '(&%s(sn=%s))' % (
543 '(objectclass=lulEduPerson)', ARGS.query_sn
545 elif (ARGS.query_id):
546 ldap_filter = '(&%s(lulColleagueId=%s))' % (
547 '(objectclass=lulEduPerson)', ARGS.query_id
551 find_ldap_users(con, ldap_filter, attributes, auth)
555 Parse the command line options for the script
557 parser = argparse.ArgumentParser()
558 parser.add_argument('-d', '--dump-ldap', action='store_true',
559 help='Dump the LDAP results to STDOUT'
561 parser.add_argument('-c', '--create-users', action='store_true',
562 help='Create new users in Evergreen'
564 parser.add_argument('-b', '--push-barcode', action='store_true',
565 help='Push barcode to LDAP'
567 parser.add_argument('--query-cn',
568 help='Search LDAP for a specific user by cn attribute'
570 parser.add_argument('--query-sn',
571 help='Search LDAP for a specific user by sn attribute'
573 parser.add_argument('--query-id',
574 help='Search LDAP for a specific user by id attribute'
576 parser.add_argument('--query-date',
577 help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
579 parser.add_argument('-U', '--eg-user', nargs='?',
580 help='Evergreen user name', default=credentials.OSRF_USER
582 parser.add_argument('-P', '--eg-password', nargs='?',
583 help='Evergreen password', default=credentials.OSRF_PW
585 parser.add_argument('-W', '--eg-workstation', nargs='?',
586 help='Name of the Evergreen workstation',
587 default=credentials.OSRF_WORK_OU
589 parser.add_argument('-H', '--eg-host', nargs='?',
590 help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
592 parser.add_argument('-u', '--ldap-user', nargs='?',
593 help='LDAP user (DN)', default=credentials.LDAP_DN
595 parser.add_argument('-p', '--ldap-password', nargs='?',
596 help='LDAP password', default=credentials.LDAP_PW
598 parser.add_argument('-s', '--ldap-server', nargs='?',
599 help='LDAP server name or IP address', default=credentials.LDAP_HOST
601 args = parser.parse_args()
606 Set up connections and run code
613 # Set the host for our requests
614 osrf.gateway.GatewayRequest.setDefaultHost(ARGS.eg_host)
616 # Pull all of our object definitions together
619 # Log in and get an authtoken
620 AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
623 con = ldap.initialize(ARGS.ldap_server)
624 con.set_option(ldap.OPT_REFERRALS, 0)
625 con.simple_bind_s(ARGS.ldap_user, ARGS.ldap_password)
626 ldap_query(con, AUTHTOKEN)
628 except ldap.LDAPError, exc:
629 print >> sys.stderr, "Could not connect: " + exc.message['info']
630 if type(exc.message) == dict and exc.message.has_key('desc'):
631 print >> sys.stderr, exc.message['desc']
633 print >> sys.stderr, exc
639 # 'mail': ['dan@example.com'],
640 # 'givenName': ['Dan'],
642 # 'lulColleagueId': ['0123456'],
643 # 'lulStudentLevel': ['GR'],
645 # create_evergreen_user(AUTHTOKEN, UDATA)
648 if __name__ == '__main__':
656 # vim: et:ts=4:sw=4:tw=78: