]> git.evergreen-ils.org Git - contrib/Conifer.git/blob - tools/patron-load/ldap_osrf_sync
More robust identity value handling for LDAP tooling
[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_ldap):
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_ldap[0]
81         self.ldap_atts = raw_ldap[1]
82
83         if 'mail' not in self.ldap_atts:
84             print >> sys.stderr, 'mail not found for %s' % self.cname
85             # return None
86
87         # Strip leading/ending whitespace; LDAP data can be dirty
88
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')
100
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))
104
105         self.profile = self.get_profile()
106         self.expire_date = self.get_expiry_date()
107         self.barcode = self._simple_map('lulLibraryBarcode')
108
109     def _simple_map(self, ldap_attribute):
110         """
111         Convenience method for mapping a given LDAP attribute
112         """
113
114         if ldap_attribute in self.ldap_atts:
115             return self.ldap_atts[ldap_attribute][0].strip()
116
117         return None
118
119     def get_expiry_date(self):
120         """
121         Map LDAP record to Evergreen expiry dates
122         """
123
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)
128
129         return expiry_date
130
131     def get_identity(self):
132         """
133         Map LDAP record to Evergreen identity type and value
134         """
135
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'
139
140         ident_value = self._simple_map('lulColleagueId')
141         if ident_value is None:
142             print >> sys.stderr, 'No Datatel number for %s (%s)' % (
143                 self.usrname
144             )
145         else:
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
150                 )
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
155
156         return 2, ident_value
157
158     def get_profile(self):
159         """
160         Map LDAP record to Evergreen profile
161         """
162
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()
167         else:
168             affiliation = r'\N'
169
170         profile_map = {
171             'ug': 113,
172             'student': 113,
173             'gr': 112,
174             'al': 114,
175             'alumni': 114,
176             'faculty': 111,
177             'staff': 115,
178             'thorneloe': 115,
179             'thornloe': 115,
180             'proxy': None,
181             'retired': None,
182             'affiliate': 115
183         }
184
185         if affiliation in profile_map:
186             return profile_map[affiliation]
187         else:
188             print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \
189                 "for user %s" % (affiliation, self.ldap_atts)
190         return None
191
192     def get_home_ou(self):
193         """
194         Map LDAP record to Evergreen home library
195         """
196
197         if 'laurentian.ca' or 'laurentienne.ca' in self.email:
198             return 103
199         elif 'huntingtonu.ca' in self.email:
200             return 104
201         elif 'usudbury.ca' in self.email:
202             return 107
203
204         # Default to Laurentian
205         return 103
206
207 class AuthException(Exception):
208     """
209     Exceptions for authentication events
210     """
211
212     def __init__(self, msg=''):
213         """
214         Initialize the authentication exception
215         """
216         Exception.__init__(self)
217         self.msg = msg
218
219     def __str__(self):
220         """
221         Stringify the authentication exception
222         """
223         return 'AuthException: %s' % self.msg
224
225 def load_idl():
226     """
227     Loads the fieldmapper IDL, registering class hints for the defined objects
228
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,
232     perhaps.
233     """
234     
235     parser = oils.utils.idl.IDLParser()
236     idlfile = tempfile.TemporaryFile()
237
238     # Get the fm_IDL.xml file from the server
239     try:
240         idl = urllib2.urlopen('%s://%s/%s' % 
241             (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL)
242         )
243         idlfile.write(idl.read())
244         # rewind to the beginning of the file
245         idlfile.seek(0)
246
247     except urllib2.URLError, exc:
248         print("Could not open URL to read IDL: %s", exc.code)
249
250     except IOError, exc:
251         print("Could not write IDL to file: %s", exc.code)
252
253     # parse the IDL
254     parser.set_IDL(idlfile)
255     parser.parse_IDL()
256
257 def osrf_login(username, password, workstation=None):
258     """
259     Login to the server and get back an authtoken
260     """
261
262     __authtoken = None
263
264     try:
265         seed = osrf_request(
266             'open-ils.auth', 
267             'open-ils.auth.authenticate.init', username).send()
268     except Exception, exc:
269         print exc
270
271     # generate the hashed password
272     password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password))
273
274     result = osrf_request(
275         'open-ils.auth',
276         'open-ils.auth.authenticate.complete',
277         {   'workstation' : workstation,
278             'username' : username,
279             'password' : password,
280             'type' : 'staff' 
281         }).send()
282
283     evt = oils.event.Event.parse_event(result)
284     if evt and not evt.success:
285         raise AuthException(evt.text_code)
286
287     __authtoken = result['payload']['authtoken']
288     return __authtoken
289
290 def osrf_request(service, method, *args):
291     """
292     Make a JSON request to the OpenSRF gateway
293
294     This is as simple as it gets. Atomic requests will require a bit
295     more effort.
296     """
297
298     req = osrf.gateway.JSONGatewayRequest(service, method, *args)
299
300     # The gateway URL ensures we're using JSON v1, not v0
301     req.setPath(credentials.GATEWAY_URL)
302     return req
303
304 def find_ldap_users(con, ldap_filter, attributes, auth):
305     """
306     Retrieve personnel accounts from LDAP directory and process'em
307     """
308     base_dn = 'o=lul'
309     search_scope = ldap.SCOPE_SUBTREE
310
311     try:
312         result_id = con.search(base_dn, search_scope, ldap_filter, attributes)
313         while 1:
314             result_type, result_data = con.result(result_id, 0)
315             if result_data == []:
316                 break
317
318             user = User(result_data[0])
319             if ARGS.dump_ldap:
320                 dump_data(result_data)
321             if ARGS.create_users:
322                 res = create_evergreen_user(auth, user)
323                 if res:
324                     update_ldap_barcode(con, user)
325             if ARGS.push_barcode:
326                 if user.barcode:
327                     continue
328
329                 try:
330                     uid = find_evergreen_user(auth, user)
331                 except AttributeError:
332                     # stub LDAP account; move on
333                     continue
334                 if not uid:
335                     print >> sys.stderr, "User not found in Evergreen: %s" % \
336                         (user.ident_value)
337                     continue
338                 user.barcode = get_barcode(auth, uid)
339                 update_ldap_barcode(con, user)
340
341     except ldap.LDAPError, exc:
342         print >> sys.stderr, exc
343
344 def get_barcode(auth, uid):
345     """
346     Retrieve the barcode for a user from Evergreen based on their user ID
347     """
348     user = osrf_request(
349         'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve',
350         auth, int(uid), ['card']
351     ).send()
352     evt = oils.event.Event.parse_event(user)
353     if evt and not evt.success:
354         raise AuthException(evt.text_code)
355  
356     if not user:
357         return None
358
359     return user.card().barcode()
360
361 def find_evergreen_user(auth, user):
362     """
363     Search for an existing user in Evergreen
364
365     Returns user ID if found, None if not
366     """
367
368     # Custom search method - overrides opt-in visibility, as it suggests
369     search_method = 'open-ils.actor.patron.search.advanced.opt_in_override'
370     limit = 1
371     sort = None
372     search_depth = 1
373     include_inactive = True
374
375     print("Trying to find user: %s %s %s" % 
376         (user.ident_value, user.email, user.usrname)
377     )
378
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
383     ).send()
384
385     if by_id and len(by_id):
386         return by_id[0]
387
388     by_email = osrf_request(
389         'open-ils.actor', search_method,
390         auth, {'email': {'value': user.email}}, limit,
391         sort, include_inactive, search_depth
392     ).send()
393
394     if by_email and len(by_email):
395         return by_email[0] 
396
397     by_usrname = osrf_request(
398         'open-ils.actor', search_method,
399         auth, {'usrname': {'value': user.usrname}}, limit,
400         sort, include_inactive, search_depth
401     ).send()
402
403     if by_usrname and len(by_usrname):
404         return by_usrname[0]
405
406     return None
407
408 def create_evergreen_user(auth, user):
409     """
410     Map LDAP record to Evergreen user
411     """
412
413     if not user:
414         return
415
416     if user.profile is None:
417         print >> sys.stderr, "No profile set for %s" % user.usrname
418         return
419
420     if user.barcode is not None:
421         return
422
423     found = find_evergreen_user(auth, user)
424     if found:
425         print("Found: %s" % user.usrname)
426         return
427
428     newau = osrf.net_obj.new_object_from_hint('au')
429
430     newau.isnew(True)
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)
441
442     # Workaround open-ils.actor.patron.update bug
443     newau.addresses([])
444     newau.cards([])
445
446     # Create the user
447     try:
448         usr = osrf_request(
449             'open-ils.actor', 'open-ils.actor.patron.update', auth, newau
450         ).send()
451     except Exception, exc:
452         print >> sys.stderr, exc
453         return None
454
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)
459         return None
460
461     # Generate a barcode for the user
462     try:
463         barcode = osrf_request(
464             'open-ils.actor', 'open-ils.actor.generate_patron_barcode', 
465             auth, usr.id()
466         ).send()
467     except Exception, exc:
468         print >> sys.stderr, exc
469         return None
470
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)
475         return None
476  
477     user.barcode = barcode['evergreen.lu_update_barcode']
478
479     create_stat_cats(newau, user)
480
481     print("Created: %s with barcode %s" % (newau.usrname(), user.barcode))
482     return user.barcode
483
484 def update_ldap_barcode(con, user):
485     """
486     Updates the LDAP directory with the new barcode for a given user
487
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.
490     """
491
492     try:
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
497         return False
498
499     return True
500
501 def create_stat_cats(eg_user, ldap_user):
502     """
503     Map statistical categories
504     """
505
506     if ldap_user.lang_pref:
507         pass
508
509     # XXX Now we should generate stat cats eh?
510
511
512 def dump_data(result_data):
513     """
514     Simple dump of all data received
515     """
516
517     print()
518     print(result_data[0][0])
519     for key in result_data[0][1]:
520         print(key, result_data[0][1][key])
521
522 def ldap_query(con, auth):
523     """
524     Process LDAP users created since a given date
525     """
526     attributes = [
527         'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation',
528         'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail',
529         'givenName', 'sn', 'lulColleagueId', 'preferredLanguage',
530         'lulModifyTimestamp'
531     ]
532
533     if (ARGS.query_date):
534         ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % (
535             '(objectclass=lulEduPerson)', ARGS.query_date
536         )
537     elif (ARGS.query_cn):
538         ldap_filter = '(&%s(cn=%s))' % (
539             '', ARGS.query_cn
540         )
541     elif (ARGS.query_sn):
542         ldap_filter = '(&%s(sn=%s))' % (
543             '(objectclass=lulEduPerson)', ARGS.query_sn
544         )
545     elif (ARGS.query_id):
546         ldap_filter = '(&%s(lulColleagueId=%s))' % (
547             '(objectclass=lulEduPerson)', ARGS.query_id
548         )
549     if not ldap_filter:
550         return 
551     find_ldap_users(con, ldap_filter, attributes, auth)
552
553 def parse_args():
554     """
555     Parse the command line options for the script
556     """
557     parser = argparse.ArgumentParser()
558     parser.add_argument('-d', '--dump-ldap', action='store_true',
559         help='Dump the LDAP results to STDOUT'
560     )
561     parser.add_argument('-c', '--create-users', action='store_true',
562         help='Create new users in Evergreen'
563     )
564     parser.add_argument('-b', '--push-barcode', action='store_true',
565         help='Push barcode to LDAP'
566     )
567     parser.add_argument('--query-cn',
568         help='Search LDAP for a specific user by cn attribute'
569     )
570     parser.add_argument('--query-sn',
571         help='Search LDAP for a specific user by sn attribute'
572     )
573     parser.add_argument('--query-id',
574         help='Search LDAP for a specific user by id attribute'
575     )
576     parser.add_argument('--query-date',
577         help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)'
578     )
579     parser.add_argument('-U', '--eg-user', nargs='?',
580         help='Evergreen user name', default=credentials.OSRF_USER
581     )
582     parser.add_argument('-P', '--eg-password', nargs='?',
583         help='Evergreen password', default=credentials.OSRF_PW
584     )
585     parser.add_argument('-W', '--eg-workstation', nargs='?',
586         help='Name of the Evergreen workstation',
587         default=credentials.OSRF_WORK_OU
588     )
589     parser.add_argument('-H', '--eg-host', nargs='?',
590         help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST
591     )
592     parser.add_argument('-u', '--ldap-user', nargs='?',
593         help='LDAP user (DN)', default=credentials.LDAP_DN
594     )
595     parser.add_argument('-p', '--ldap-password', nargs='?',
596         help='LDAP password', default=credentials.LDAP_PW
597     )
598     parser.add_argument('-s', '--ldap-server', nargs='?',
599         help='LDAP server name or IP address', default=credentials.LDAP_HOST
600     )
601     args = parser.parse_args()
602     return args
603
604 def main():
605     """
606     Set up connections and run code
607     """
608
609     global ARGS
610     global AUTHTOKEN
611     ARGS = parse_args()
612
613     # Set the host for our requests
614     osrf.gateway.GatewayRequest.setDefaultHost(ARGS.eg_host)
615
616     # Pull all of our object definitions together
617     load_idl()
618
619     # Log in and get an authtoken
620     AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation)
621
622     try:
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)
627
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']
632         else:
633             print >> sys.stderr, exc
634         sys.exit()
635     finally:
636         con.unbind()
637
638 #    UDATA = {
639 #        'mail': ['dan@example.com'],
640 #        'givenName':  ['Dan'],
641 #        'sn':  ['Scott'],
642 #        'lulColleagueId':  ['0123456'],
643 #        'lulStudentLevel':  ['GR'],
644 #    }
645 #    create_evergreen_user(AUTHTOKEN, UDATA)
646
647
648 if __name__ == '__main__':
649     import doctest
650     doctest.testmod()
651
652     ARGS = None
653     AUTHTOKEN = None
654
655     main()
656 # vim: et:ts=4:sw=4:tw=78: