]> git.evergreen-ils.org Git - contrib/Conifer.git/blob - tools/patron-load/ldap_osrf_sync
Capture more than just " /" from 245$h
[contrib/Conifer.git] / tools / patron-load / ldap_osrf_sync
1 #!/usr/bin/env python
2
3 # Copyright (C) 2011 Laurentian University
4 # Dan Scott <dscott@laurentian.ca>
5 #
6
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.
11
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.
16
17 """
18 Synchronize Evergreen user accounts with an LDAP directory via OpenSRF 
19
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.
22
23 1. Pull a list of new LDAP records since the last sync from the LDAP
24    directory using the filter (createTimestamp>=time).
25
26 2. For each new LDAP record, check to see if the record exists in Evergreen
27    (matching on ident_value, usrname, email).
28    
29    If not, create a new account with barcode.
30
31 3. Push the new barcode back into the LDAP directory.
32
33 Needs to be able to find credentials.py with the following content:
34
35 LDAP_HOST = 'ldap://192.168.1.106'
36 LDAP_DN = 'cn=ldap_usr'
37 LDAP_PW = 'password'
38
39 OSRF_HTTP = 'http'
40 IDL_URL = '/reports/fm_IDL.xml'
41 GATEWAY_URL = '/osrf-gateway-v1'
42
43 OSRF_HOST = 'localhost'
44 OSRF_USER = 'admin'
45 OSRF_PW = 'demo123'
46 OSRF_WORK_OU = 'herb'
47 """
48
49 import os
50 import sys
51 import ldap
52 import datetime
53 import argparse
54
55 import oils.event
56 import oils.utils.idl
57 import oils.utils.utils
58 import osrf.gateway
59 import osrf.json
60 import osrf.net_obj
61 import tempfile
62 import urllib2
63
64 try:
65     import credentials
66 except:
67     sys.exit(__doc__)
68
69 class User:
70     """
71     Holds data mapped from LDAP schema to Evergreen attributes
72
73     Less formal than an actual 'au' object
74     """
75
76     def __init__(self, raw_cn, raw_atts):
77         """
78         Map core LDAP schema attributes to Evergreen user bits
79
80         Easier to replace hard-coded mappings with calls to functions
81         """
82
83         self.cname = raw_cn
84         self.ldap_atts = raw_atts
85
86         self.cn = self._simple_map('cn').lower()
87         self.lang_pref = self._simple_map('preferredLanguage')
88
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'
92             else:
93                 self.email = self.cn + '@laurentian.ca'
94             print >> sys.stderr, '"mail" not found for %s, using %s' % (self.cn, self.email)
95             # return None
96         else:
97             self.email = (self._simple_map('mail') or '').lower()
98
99         # Strip leading/ending whitespace; LDAP data can be dirty
100
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)
110
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))
114
115         self.profile = self.get_profile()
116         self.expire_date = self.get_expiry_date()
117         self.barcode = self._simple_map('lulLibraryBarcode')
118
119     def _simple_map(self, ldap_attribute):
120         """
121         Convenience method for mapping a given LDAP attribute
122         """
123
124         if ldap_attribute in self.ldap_atts:
125             return self.ldap_atts[ldap_attribute][0].strip()
126
127         return None
128
129     def get_expiry_date(self):
130         """
131         Map LDAP record to Evergreen expiry dates
132         """
133
134         expiry_date = '%d-09-30' % (datetime.date.today().year + 1)
135         exp_att = self._simple_map('loginExpirationTime')
136
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
141         elif exp_att:
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)
149
150         return expiry_date
151
152     def get_identity(self):
153         """
154         Map LDAP record to Evergreen identity type and value
155         """
156
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'
160
161         ident_value = self._simple_map('lulColleagueId')
162         if ident_value is None:
163             print >> sys.stderr, 'No Datatel number for %s (%s)' % (
164                 self.usrname
165             )
166         else:
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
171                 )
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
176
177         return 2, ident_value
178
179     def get_profile(self):
180         """
181         Map LDAP record to Evergreen profile
182         """
183
184         profile_map = {
185             'ug': 113,
186             'student': 113,
187             'gr': 112,
188             'al': 127,
189             'guest': 114,
190             'alumni': 127,
191             'faculty': 111,
192             'staff': 115,
193             'thorneloe': 115,
194             'thornloe': 115,
195             'proxy': None,
196             'retired': None,
197             'affiliate': 115,
198             'visitor': 113
199         }
200
201         if 'alumni' in self.cname.lower():
202             # we have a winner!
203             affiliation = 'alumni'
204             return profile_map[affiliation]
205
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]
212
213         if 'alumni' in self.cname.lower():
214             # we have a winner!
215             affiliation = 'alumni'
216         elif 'empl' in self.cname.lower():
217             affiliation = 'staff'
218         else:
219             affiliation = r'\N'
220
221         # Let's remember the affiliation
222         self.affiliation = affiliation
223
224         if affiliation in profile_map:
225             return profile_map[affiliation]
226         else:
227             print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
228                 "for user %s" % (affiliation, self.ldap_atts)
229         return None
230
231     def get_home_ou(self):
232         """
233         Map LDAP record to Evergreen home library
234         """
235
236         if 'laurentian.ca' in self.email or 'laurentienne.ca' in self.email:
237             return 103
238         elif 'huntingtonu.ca' in self.email:
239             return 104
240         elif 'usudbury.ca' in self.email:
241             return 107
242
243         # Default to Laurentian
244         return 103
245
246 class AuthException(Exception):
247     """
248     Exceptions for authentication events
249     """
250
251     def __init__(self, msg=''):
252         """
253         Initialize the authentication exception
254         """
255         Exception.__init__(self)
256         self.msg = msg
257
258     def __str__(self):
259         """
260         Stringify the authentication exception
261         """
262         return 'AuthException: %s' % self.msg
263
264 def load_idl():
265     """
266     Loads the fieldmapper IDL, registering class hints for the defined objects
267
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,
271     perhaps.
272     """
273     
274     parser = oils.utils.idl.IDLParser()
275     idlfile = tempfile.TemporaryFile()
276
277     # Get the fm_IDL.xml file from the server
278     try:
279         idl = urllib2.urlopen('%s://%s/%s' % 
280             (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
281         )
282         idlfile.write(idl.read())
283         # rewind to the beginning of the file
284         idlfile.seek(0)
285
286     except urllib2.URLError, exc:
287         print("Could not open URL to read IDL: %s", exc)
288
289     except IOError, exc:
290         print("Could not write IDL to file: %s", exc.code)
291
292     # parse the IDL
293     parser.set_IDL(idlfile)
294     parser.parse_IDL()
295
296 def osrf_login(username, password, workstation=None):
297     """
298     Login to the server and get back an authtoken
299     """
300
301     __authtoken = None
302
303     try:
304         seed = osrf_request(
305             'open-ils.auth', 
306             'open-ils.auth.authenticate.init', username).send()
307     except Exception, exc:
308         print exc
309
310     # generate the hashed password
311     password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
312
313     result = osrf_request(
314         'open-ils.auth',
315         'open-ils.auth.authenticate.complete',
316         {   'workstation' : workstation,
317             'username' : username,
318             'password' : password,
319             'type' : 'staff' 
320         }).send()
321
322     evt = oils.event.Event.parse_event(result)
323     if evt and not evt.success:
324         raise AuthException(evt.text_code)
325
326     __authtoken = result['payload']['authtoken']
327     return __authtoken
328
329 def osrf_request(service, method, *args):
330     """
331     Make a JSON request to the OpenSRF gateway
332
333     This is as simple as it gets. Atomic requests will require a bit
334     more effort.
335     """
336
337     req = osrf.gateway.JSONGatewayRequest(service, method, *args)
338
339     # The gateway URL ensures we're using JSON v1, not v0
340     req.setPath(credentials.GATEWAY_URL)
341     return req
342
343 def find_ldap_users(con, ldap_filter, attributes, auth):
344     """
345     Retrieve personnel accounts from LDAP directory and process'em
346     """
347     base_dn = 'o=lul'
348     search_scope = ldap.SCOPE_SUBTREE
349     results = []
350
351     try:
352         raw_results = con.search_ext_s(base_dn, search_scope, ldap_filter, attributes)
353         for raw_user, raw_atts in raw_results:
354             if raw_atts == []:
355                 continue
356
357             user = User(raw_user, raw_atts)
358             if ARGS.dump_ldap:
359                 dump_data(raw_atts)
360                 print("dn = '%s'" % (raw_user))
361             if user.ident_value == 'NO_COLLEAGUE_ID':
362                 print >> sys.stderr, "No colleague ID: skipping"
363                 continue
364             if ARGS.create_users:
365                 res = create_evergreen_user(auth, user)
366                 if res:
367                     update_ldap_barcode(con, user)
368                     results.append(res)
369             if ARGS.push_barcode:
370                 if user.barcode:
371                     continue
372
373                 try:
374                     uid = find_evergreen_user(auth, user)
375                 except AttributeError:
376                     # stub LDAP account; move on
377                     continue
378                 if not uid:
379                     print >> sys.stderr, "User not found in Evergreen: %s" % \
380                         (user.ident_value)
381                     continue
382                 user.barcode = get_barcode(auth, uid)
383                 update_ldap_barcode(con, user)
384
385     except ldap.LDAPError, exc:
386         print >> sys.stderr, exc
387
388     return results
389
390 def get_barcode(auth, uid):
391     """
392     Retrieve the barcode for a user from Evergreen based on their user ID
393     """
394     user = osrf_request(
395         'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
396         auth, int(uid), ['card']
397     ).send()
398     evt = oils.event.Event.parse_event(user)
399     if evt and not evt.success:
400         raise AuthException(evt.text_code)
401  
402     if not user:
403         return None
404
405     return user.card().barcode()
406
407 def find_evergreen_user(auth, user):
408     """
409     Search for an existing user in Evergreen
410
411     Returns user ID if found, None if not
412     """
413
414     # Custom search method - overrides opt-in visibility, as it suggests
415     search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
416     limit = 1
417     sort = None
418     search_depth = 1
419     include_inactive = True
420
421     print("Trying to find user: %s %s %s" % 
422         (user.ident_value, user.email, user.usrname)
423     )
424
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
429     ).send()
430
431     if by_id and len(by_id):
432         return by_id[0]
433
434     by_email = osrf_request(
435         'open-ils.actor', search_method,
436         auth, {'email': {'value': user.email}}, limit,
437         sort, include_inactive, search_depth
438     ).send()
439
440     if by_email and len(by_email):
441         return by_email[0] 
442
443     by_usrname = osrf_request(
444         'open-ils.actor', search_method,
445         auth, {'usrname': {'value': user.usrname}}, limit,
446         sort, include_inactive, search_depth
447     ).send()
448
449     if by_usrname and len(by_usrname):
450         return by_usrname[0]
451
452     return None
453
454 def retrieve_evergreen_user(auth, uid):
455     """
456     Update account for an existing user in Evergreen
457
458     Sets active flag, updates expiry date
459     """
460
461     egau = osrf_request(
462         'open-ils.actor', 'open-ils.actor.user.retrieve', auth, uid
463     ).send()
464
465     if not egau:
466         print sys.stderr >> "Damn it"
467
468     return egau
469
470
471 def create_evergreen_user(auth, user):
472     """
473     Map LDAP record to Evergreen user
474     """
475
476     if not user:
477         return
478
479     if user.profile is None:
480         print >> sys.stderr, "No profile set for %s" % user.usrname
481         return
482
483     found = find_evergreen_user(auth, user)
484
485     egau = osrf.net_obj.new_object_from_hint('au')
486
487     if found:
488         print("Found: %s" % user.usrname)
489         egau = retrieve_evergreen_user(auth, found)
490         egau.ischanged(True)
491         egau.active(True)
492     else:
493         egau.isnew(True)
494
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)
504
505     # Workaround open-ils.actor.patron.update bug
506     egau.addresses([])
507     egau.cards([])
508
509     # Don't reset the Conifer-specific password
510     if not found:
511         egau.passwd(user.passwd)
512
513     # Create or update the user
514     try:
515         usr = osrf_request(
516             'open-ils.actor', 'open-ils.actor.patron.update', auth, egau
517         ).send()
518     except Exception, exc:
519         print >> sys.stderr, exc
520         return None
521
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)
526         return None
527
528     if found:
529         # Do not generate a barcode for the user
530         return
531
532     # Generate a barcode for the user
533     try:
534         barcode = osrf_request(
535             'open-ils.actor', 'open-ils.actor.generate_patron_barcode', 
536             auth, usr.id(), '000070'
537         ).send()
538     except Exception, exc:
539         print >> sys.stderr, exc
540         return None
541
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)
546         return None
547  
548     user.barcode = barcode['evergreen.actor_update_barcode']
549
550     create_stat_cats(egau, user)
551
552     print("Created: %s with barcode %s" % (egau.usrname(), user.barcode))
553     
554     return egau.usrname(), user.barcode
555
556 def update_ldap_barcode(con, user):
557     """
558     Updates the LDAP directory with the new barcode for a given user
559
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.
562     """
563
564     try:
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
569         return False
570
571     return True
572
573 def create_stat_cats(eg_user, ldap_user):
574     """
575     Map statistical categories
576     """
577
578     if ldap_user.lang_pref:
579         pass
580
581     # XXX Now we should generate stat cats eh?
582
583
584 def dump_data(result_data):
585     """
586     Simple dump of all data received
587     """
588
589     print()
590     for key in result_data:
591         print(key, result_data[key])
592
593 def ldap_query(con, auth):
594     """
595     Process LDAP users created since a given date
596     """
597
598     ldap_filter = None
599
600     attributes = [
601         'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
602         'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
603         'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
604         'lulModifyTimestamp', 'loginExpirationTime'
605     ]
606
607     if (ARGS.query_date):
608         ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
609             '(objectclass=lulEduPerson)', ARGS.query_date
610         )
611     elif (ARGS.query_cn):
612         ldap_filter = '(&%s(cn=%s))' % (
613             '', ARGS.query_cn
614         )
615     elif (ARGS.query_sn):
616         ldap_filter = '(&%s(sn=%s))' % (
617             '(objectclass=lulEduPerson)', ARGS.query_sn
618         )
619     elif (ARGS.query_id):
620         ldap_filter = '(&%s(lulColleagueId=%s)(!(lulStudentLevel=APP)))' % (
621             '(objectclass=lulEduPerson)', ARGS.query_id
622         )
623     if (ARGS.query_end):
624         ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s)(createTimestamp<=%s))' % (
625             '(objectclass=lulEduPerson)', ARGS.query_date, ARGS.query_end
626         )
627     if not ldap_filter:
628         return 
629     return find_ldap_users(con, ldap_filter, attributes, auth)
630
631 def parse_args():
632     """
633     Parse the command line options for the script
634     """
635     parser = argparse.ArgumentParser()
636     parser.add_argument('-d', '--dump-ldap', action='store_true',
637         help='Dump the LDAP results to STDOUT'
638     )
639     parser.add_argument('-c', '--create-users', action='store_true',
640         help='Create new users in Evergreen'
641     )
642     parser.add_argument('-b', '--push-barcode', action='store_true',
643         help='Push barcode to LDAP'
644     )
645     parser.add_argument('--query-cn',
646         help='Search LDAP for a specific user by cn attribute'
647     )
648     parser.add_argument('--query-sn',
649         help='Search LDAP for a specific user by sn attribute'
650     )
651     parser.add_argument('--query-id',
652         help='Search LDAP for a specific user by id attribute'
653     )
654     parser.add_argument('--query-date',
655         help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
656     )
657     parser.add_argument('--query-end',
658         help='Search LDAP for users created before (YYYYMMDDHHMMSSZ)'
659     )
660     parser.add_argument('--find-eg-user',
661         help='Find Evergreen user by user name'
662     )
663     parser.add_argument('-U', '--eg-user', nargs='?',
664         help='Evergreen user name', default=credentials.OSRF_USER
665     )
666     parser.add_argument('-P', '--eg-password', nargs='?',
667         help='Evergreen password', default=credentials.OSRF_PW
668     )
669     parser.add_argument('-W', '--eg-workstation', nargs='?',
670         help='Name of the Evergreen workstation',
671         default=credentials.OSRF_WORK_OU
672     )
673     parser.add_argument('-H', '--eg-host', nargs='?',
674         help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
675     )
676     parser.add_argument('-u', '--ldap-user', nargs='?',
677         help='LDAP user (DN)', default=credentials.LDAP_DN
678     )
679     parser.add_argument('-p', '--ldap-password', nargs='?',
680         help='LDAP password', default=credentials.LDAP_PW
681     )
682     parser.add_argument('-s', '--ldap-server', nargs='?',
683         help='LDAP server name or IP address', default=credentials.LDAP_HOST
684     )
685     args = parser.parse_args()
686     return args
687
688 def main(args=None):
689     """
690     Set up connections and run code
691     """
692
693     results = []
694     global ARGS
695     global AUTHTOKEN
696     ARGS = parse_args()
697
698     # override parsed args with anything that was passed in
699     if args:
700         ARGS = args
701
702     # Set the host for our requests
703     osrf.gateway.GatewayRequest.setDefaultHost('%s://%s' % (credentials.OSRF_HTTP, ARGS.eg_host))
704
705     # Pull all of our object definitions together
706     load_idl()
707
708     # Log in and get an authtoken
709     AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
710
711     if ARGS.find_eg_user:
712         class QuickUser:
713             "A quick and dirty user class"
714             pass
715         user = QuickUser()
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)
720         if uid:
721             print "Found Evergreen user: %s" % (uid)
722         sys.exit()
723
724     try:
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)
729
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']
735         else:
736             print >> sys.stderr, exc
737         sys.exit()
738     finally:
739         con.unbind_ext_s()
740
741     return results
742
743 #    UDATA = {
744 #        'mail': ['dan@example.com'],
745 #        'givenName':  ['Dan'],
746 #        'sn':  ['Scott'],
747 #        'lulColleagueId':  ['0123456'],
748 #        'lulStudentLevel':  ['GR'],
749 #    }
750 #    create_evergreen_user(AUTHTOKEN, UDATA)
751
752
753 if __name__ == '__main__':
754     import doctest
755     doctest.testmod()
756
757     ARGS = None
758     AUTHTOKEN = None
759
760     main()
761 # vim: et:ts=4:sw=4:tw=78: