Do a better job of tolerating missing credentials.py
[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 import credentials
65
66 class User:
67     """
68     Holds data mapped from LDAP schema to Evergreen attributes
69
70     Less formal than an actual 'au' object
71     """
72
73     def __init__(self, raw_cn, raw_atts):
74         """
75         Map core LDAP schema attributes to Evergreen user bits
76
77         Easier to replace hard-coded mappings with calls to functions
78         """
79
80         self.cname = raw_cn
81         self.ldap_atts = raw_atts
82
83         self.cn = self._simple_map('cn')
84         self.lang_pref = self._simple_map('preferredLanguage')
85
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'
89             else:
90                 self.email = self.cn + '@laurentian.ca'
91             print >> sys.stderr, '"mail" not found for %s, using %s' % (self.cn, self.email)
92             # return None
93         else:
94             self.email = (self._simple_map('mail') or '').lower()
95
96         # Strip leading/ending whitespace; LDAP data can be dirty
97
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)
107
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))
111
112         self.profile = self.get_profile()
113         self.expire_date = self.get_expiry_date()
114         self.barcode = self._simple_map('lulLibraryBarcode')
115
116     def _simple_map(self, ldap_attribute):
117         """
118         Convenience method for mapping a given LDAP attribute
119         """
120
121         if ldap_attribute in self.ldap_atts:
122             return self.ldap_atts[ldap_attribute][0].strip()
123
124         return None
125
126     def get_expiry_date(self):
127         """
128         Map LDAP record to Evergreen expiry dates
129         """
130
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)
135
136         return expiry_date
137
138     def get_identity(self):
139         """
140         Map LDAP record to Evergreen identity type and value
141         """
142
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'
146
147         ident_value = self._simple_map('lulColleagueId')
148         if ident_value is None:
149             print >> sys.stderr, 'No Datatel number for %s (%s)' % (
150                 self.usrname
151             )
152         else:
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
157                 )
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
162
163         return 2, ident_value
164
165     def get_profile(self):
166         """
167         Map LDAP record to Evergreen profile
168         """
169
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()
174         else:
175             affiliation = r'\N'
176
177         profile_map = {
178             'ug': 113,
179             'student': 113,
180             'gr': 112,
181             'al': 114,
182             'alumni': 114,
183             'faculty': 111,
184             'staff': 115,
185             'thorneloe': 115,
186             'thornloe': 115,
187             'proxy': None,
188             'retired': None,
189             'affiliate': 115
190         }
191
192         if affiliation in profile_map:
193             return profile_map[affiliation]
194         else:
195             print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
196                 "for user %s" % (affiliation, self.ldap_atts)
197         return None
198
199     def get_home_ou(self):
200         """
201         Map LDAP record to Evergreen home library
202         """
203
204         if 'laurentian.ca' or 'laurentienne.ca' in self.email:
205             return 103
206         elif 'huntingtonu.ca' in self.email:
207             return 104
208         elif 'usudbury.ca' in self.email:
209             return 107
210
211         # Default to Laurentian
212         return 103
213
214 class AuthException(Exception):
215     """
216     Exceptions for authentication events
217     """
218
219     def __init__(self, msg=''):
220         """
221         Initialize the authentication exception
222         """
223         Exception.__init__(self)
224         self.msg = msg
225
226     def __str__(self):
227         """
228         Stringify the authentication exception
229         """
230         return 'AuthException: %s' % self.msg
231
232 def load_idl():
233     """
234     Loads the fieldmapper IDL, registering class hints for the defined objects
235
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,
239     perhaps.
240     """
241     
242     parser = oils.utils.idl.IDLParser()
243     idlfile = tempfile.TemporaryFile()
244
245     # Get the fm_IDL.xml file from the server
246     try:
247         idl = urllib2.urlopen('%s://%s/%s' % 
248             (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
249         )
250         idlfile.write(idl.read())
251         # rewind to the beginning of the file
252         idlfile.seek(0)
253
254     except urllib2.URLError, exc:
255         print("Could not open URL to read IDL: %s", exc.code)
256
257     except IOError, exc:
258         print("Could not write IDL to file: %s", exc.code)
259
260     # parse the IDL
261     parser.set_IDL(idlfile)
262     parser.parse_IDL()
263
264 def osrf_login(username, password, workstation=None):
265     """
266     Login to the server and get back an authtoken
267     """
268
269     __authtoken = None
270
271     try:
272         seed = osrf_request(
273             'open-ils.auth', 
274             'open-ils.auth.authenticate.init', username).send()
275     except Exception, exc:
276         print exc
277
278     # generate the hashed password
279     password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
280
281     result = osrf_request(
282         'open-ils.auth',
283         'open-ils.auth.authenticate.complete',
284         {   'workstation' : workstation,
285             'username' : username,
286             'password' : password,
287             'type' : 'staff' 
288         }).send()
289
290     evt = oils.event.Event.parse_event(result)
291     if evt and not evt.success:
292         raise AuthException(evt.text_code)
293
294     __authtoken = result['payload']['authtoken']
295     return __authtoken
296
297 def osrf_request(service, method, *args):
298     """
299     Make a JSON request to the OpenSRF gateway
300
301     This is as simple as it gets. Atomic requests will require a bit
302     more effort.
303     """
304
305     req = osrf.gateway.JSONGatewayRequest(service, method, *args)
306
307     # The gateway URL ensures we're using JSON v1, not v0
308     req.setPath(credentials.GATEWAY_URL)
309     return req
310
311 def find_ldap_users(con, ldap_filter, attributes, auth):
312     """
313     Retrieve personnel accounts from LDAP directory and process'em
314     """
315     base_dn = 'o=lul'
316     search_scope = ldap.SCOPE_SUBTREE
317     results = []
318
319     try:
320         raw_results = con.search_ext_s(base_dn, search_scope, ldap_filter, attributes)
321         for raw_user, raw_atts in raw_results:
322             if raw_atts == []:
323                 continue
324
325             user = User(raw_user, raw_atts)
326             if ARGS.dump_ldap:
327                 dump_data(raw_atts)
328             if ARGS.create_users:
329                 res = create_evergreen_user(auth, user)
330                 if res:
331                     update_ldap_barcode(con, user)
332                     results.append(res)
333             if ARGS.push_barcode:
334                 if user.barcode:
335                     continue
336
337                 try:
338                     uid = find_evergreen_user(auth, user)
339                 except AttributeError:
340                     # stub LDAP account; move on
341                     continue
342                 if not uid:
343                     print >> sys.stderr, "User not found in Evergreen: %s" % \
344                         (user.ident_value)
345                     continue
346                 user.barcode = get_barcode(auth, uid)
347                 update_ldap_barcode(con, user)
348
349     except ldap.LDAPError, exc:
350         print >> sys.stderr, exc
351
352     return results
353
354 def get_barcode(auth, uid):
355     """
356     Retrieve the barcode for a user from Evergreen based on their user ID
357     """
358     user = osrf_request(
359         'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
360         auth, int(uid), ['card']
361     ).send()
362     evt = oils.event.Event.parse_event(user)
363     if evt and not evt.success:
364         raise AuthException(evt.text_code)
365  
366     if not user:
367         return None
368
369     return user.card().barcode()
370
371 def find_evergreen_user(auth, user):
372     """
373     Search for an existing user in Evergreen
374
375     Returns user ID if found, None if not
376     """
377
378     # Custom search method - overrides opt-in visibility, as it suggests
379     search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
380     limit = 1
381     sort = None
382     search_depth = 1
383     include_inactive = True
384
385     print("Trying to find user: %s %s %s" % 
386         (user.ident_value, user.email, user.usrname)
387     )
388
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
393     ).send()
394
395     if by_id and len(by_id):
396         return by_id[0]
397
398     by_email = osrf_request(
399         'open-ils.actor', search_method,
400         auth, {'email': {'value': user.email}}, limit,
401         sort, include_inactive, search_depth
402     ).send()
403
404     if by_email and len(by_email):
405         return by_email[0] 
406
407     by_usrname = osrf_request(
408         'open-ils.actor', search_method,
409         auth, {'usrname': {'value': user.usrname}}, limit,
410         sort, include_inactive, search_depth
411     ).send()
412
413     if by_usrname and len(by_usrname):
414         return by_usrname[0]
415
416     return None
417
418 def retrieve_evergreen_user(auth, uid):
419     """
420     Update account for an existing user in Evergreen
421
422     Sets active flag, updates expiry date
423     """
424
425     egau = osrf_request(
426         'open-ils.actor', 'open-ils.actor.user.retrieve', auth, uid
427     ).send()
428
429     if not egau:
430         print sys.stderr >> "Damn it"
431
432     return egau
433
434
435 def create_evergreen_user(auth, user):
436     """
437     Map LDAP record to Evergreen user
438     """
439
440     if not user:
441         return
442
443     if user.profile is None:
444         print >> sys.stderr, "No profile set for %s" % user.usrname
445         return
446
447     if user.barcode is not None:
448         return
449
450     found = find_evergreen_user(auth, user)
451
452     egau = osrf.net_obj.new_object_from_hint('au')
453
454     if found:
455         print("Found: %s" % user.usrname)
456         egau = retrieve_evergreen_user(auth, found)
457         egau.ischanged(True)
458         egau.active(True)
459     else:
460         egau.isnew(True)
461
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)
472
473     # Workaround open-ils.actor.patron.update bug
474     egau.addresses([])
475     egau.cards([])
476
477     # Create or update the user
478     try:
479         usr = osrf_request(
480             'open-ils.actor', 'open-ils.actor.patron.update', auth, egau
481         ).send()
482     except Exception, exc:
483         print >> sys.stderr, exc
484         return None
485
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)
490         return None
491
492     if found:
493         # Do not generate a barcode for the user
494         return
495
496     # Generate a barcode for the user
497     try:
498         barcode = osrf_request(
499             'open-ils.actor', 'open-ils.actor.generate_patron_barcode', 
500             auth, usr.id(), '000070'
501         ).send()
502     except Exception, exc:
503         print >> sys.stderr, exc
504         return None
505
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)
510         return None
511  
512     user.barcode = barcode['evergreen.actor_update_barcode']
513
514     create_stat_cats(egau, user)
515
516     print("Created: %s with barcode %s" % (egau.usrname(), user.barcode))
517     
518     return egau.usrname(), user.barcode
519
520 def update_ldap_barcode(con, user):
521     """
522     Updates the LDAP directory with the new barcode for a given user
523
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.
526     """
527
528     try:
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
533         return False
534
535     return True
536
537 def create_stat_cats(eg_user, ldap_user):
538     """
539     Map statistical categories
540     """
541
542     if ldap_user.lang_pref:
543         pass
544
545     # XXX Now we should generate stat cats eh?
546
547
548 def dump_data(result_data):
549     """
550     Simple dump of all data received
551     """
552
553     print()
554     for key in result_data:
555         print(key, result_data[key])
556
557 def ldap_query(con, auth):
558     """
559     Process LDAP users created since a given date
560     """
561
562     ldap_filter = None
563
564     attributes = [
565         'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
566         'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
567         'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
568         'lulModifyTimestamp'
569     ]
570
571     if (ARGS.query_date):
572         ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
573             '(objectclass=lulEduPerson)', ARGS.query_date
574         )
575     elif (ARGS.query_cn):
576         ldap_filter = '(&%s(cn=%s))' % (
577             '', ARGS.query_cn
578         )
579     elif (ARGS.query_sn):
580         ldap_filter = '(&%s(sn=%s))' % (
581             '(objectclass=lulEduPerson)', ARGS.query_sn
582         )
583     elif (ARGS.query_id):
584         ldap_filter = '(&%s(lulColleagueId=%s))' % (
585             '(objectclass=lulEduPerson)', ARGS.query_id
586         )
587     if not ldap_filter:
588         return 
589     return find_ldap_users(con, ldap_filter, attributes, auth)
590
591 def parse_args():
592     """
593     Parse the command line options for the script
594     """
595     parser = argparse.ArgumentParser()
596     parser.add_argument('-d', '--dump-ldap', action='store_true',
597         help='Dump the LDAP results to STDOUT'
598     )
599     parser.add_argument('-c', '--create-users', action='store_true',
600         help='Create new users in Evergreen'
601     )
602     parser.add_argument('-b', '--push-barcode', action='store_true',
603         help='Push barcode to LDAP'
604     )
605     parser.add_argument('--query-cn',
606         help='Search LDAP for a specific user by cn attribute'
607     )
608     parser.add_argument('--query-sn',
609         help='Search LDAP for a specific user by sn attribute'
610     )
611     parser.add_argument('--query-id',
612         help='Search LDAP for a specific user by id attribute'
613     )
614     parser.add_argument('--query-date',
615         help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
616     )
617     parser.add_argument('--find-eg-user',
618         help='Find Evergreen user by user name'
619     )
620     parser.add_argument('-U', '--eg-user', nargs='?',
621         help='Evergreen user name', default=credentials.OSRF_USER
622     )
623     parser.add_argument('-P', '--eg-password', nargs='?',
624         help='Evergreen password', default=credentials.OSRF_PW
625     )
626     parser.add_argument('-W', '--eg-workstation', nargs='?',
627         help='Name of the Evergreen workstation',
628         default=credentials.OSRF_WORK_OU
629     )
630     parser.add_argument('-H', '--eg-host', nargs='?',
631         help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
632     )
633     parser.add_argument('-u', '--ldap-user', nargs='?',
634         help='LDAP user (DN)', default=credentials.LDAP_DN
635     )
636     parser.add_argument('-p', '--ldap-password', nargs='?',
637         help='LDAP password', default=credentials.LDAP_PW
638     )
639     parser.add_argument('-s', '--ldap-server', nargs='?',
640         help='LDAP server name or IP address', default=credentials.LDAP_HOST
641     )
642     args = parser.parse_args()
643     return args
644
645 def main(args=None):
646     """
647     Set up connections and run code
648     """
649
650     results = []
651     global ARGS
652     global AUTHTOKEN
653     ARGS = parse_args()
654
655     # override parsed args with anything that was passed in
656     if args:
657         ARGS = args
658
659     # Set the host for our requests
660     osrf.gateway.GatewayRequest.setDefaultHost(ARGS.eg_host)
661
662     # Pull all of our object definitions together
663     load_idl()
664
665     # Log in and get an authtoken
666     AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
667
668     if ARGS.find_eg_user:
669         class QuickUser:
670             "A quick and dirty user class"
671             pass
672         user = QuickUser()
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)
677         if uid:
678             print "Found Evergreen user: %s" % (uid)
679         sys.exit()
680
681     try:
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)
686
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']
691         else:
692             print >> sys.stderr, exc
693         sys.exit()
694     finally:
695         con.unbind_ext_s()
696
697     return results
698
699 #    UDATA = {
700 #        'mail': ['dan@example.com'],
701 #        'givenName':  ['Dan'],
702 #        'sn':  ['Scott'],
703 #        'lulColleagueId':  ['0123456'],
704 #        'lulStudentLevel':  ['GR'],
705 #    }
706 #    create_evergreen_user(AUTHTOKEN, UDATA)
707
708
709 if __name__ == '__main__':
710     import doctest
711     doctest.testmod()
712
713     ARGS = None
714     AUTHTOKEN = None
715
716     main()
717 # vim: et:ts=4:sw=4:tw=78: