#!/usr/bin/env python # Copyright (C) 2011 Laurentian University # Dan Scott # # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. """ Synchronize Evergreen user accounts with an LDAP directory via OpenSRF LDAP and OpenSRF authentication information is stored in a separate Python file (credentials.py) and imported to avoid storing credentials in the VCS. 1. Pull a list of new LDAP records since the last sync from the LDAP directory using the filter (createTimestamp>=time). 2. For each new LDAP record, check to see if the record exists in Evergreen (matching on ident_value, usrname, email). If not, create a new account with barcode. 3. Push the new barcode back into the LDAP directory. Needs to be able to find credentials.py with the following content: LDAP_HOST = 'ldap://192.168.1.106' LDAP_DN = 'cn=ldap_usr' LDAP_PW = 'password' OSRF_HTTP = 'http' IDL_URL = '/reports/fm_IDL.xml' GATEWAY_URL = '/osrf-gateway-v1' OSRF_HOST = 'localhost' OSRF_USER = 'admin' OSRF_PW = 'demo123' OSRF_WORK_OU = 'herb' """ import os import sys import ldap import datetime import argparse import oils.event import oils.utils.idl import oils.utils.utils import osrf.gateway import osrf.json import osrf.net_obj import tempfile import urllib2 try: import credentials except: sys.exit(__doc__) class User: """ Holds data mapped from LDAP schema to Evergreen attributes Less formal than an actual 'au' object """ def __init__(self, raw_cn, raw_atts): """ Map core LDAP schema attributes to Evergreen user bits Easier to replace hard-coded mappings with calls to functions """ self.cname = raw_cn self.ldap_atts = raw_atts self.cn = self._simple_map('cn') self.lang_pref = self._simple_map('preferredLanguage') if 'mail' not in self.ldap_atts: if self.lang_pref and self.lang_pref == 'f': self.email = self.cn + '@laurentienne.ca' else: self.email = self.cn + '@laurentian.ca' print >> sys.stderr, '"mail" not found for %s, using %s' % (self.cn, self.email) # return None else: self.email = (self._simple_map('mail') or '').lower() # Strip leading/ending whitespace; LDAP data can be dirty # Using email for username deliberately here self.usrname = self.email self.family_name = self._simple_map('sn') self.ident_type, self.ident_value = self.get_identity() self.home_ou = self.get_home_ou() self.first_given_name = self._simple_map('givenName') if not self.first_given_name: self.first_given_name = 'LDAP_NULL' print >> sys.stderr, 'No givenName for %s' % (self.usrname) # Randomized password, assuming user will use "Forgot password" # function to set their own password self.passwd = oils.utils.utils.md5sum(os.urandom(10)) self.profile = self.get_profile() self.expire_date = self.get_expiry_date() self.barcode = self._simple_map('lulLibraryBarcode') def _simple_map(self, ldap_attribute): """ Convenience method for mapping a given LDAP attribute """ if ldap_attribute in self.ldap_atts: return self.ldap_atts[ldap_attribute][0].strip() return None def get_expiry_date(self): """ Map LDAP record to Evergreen expiry dates """ expiry_date = '%d-09-30' % (datetime.date.today().year + 1) # Faculty and staff get a long time if self.profile == 11 or self.profile == 14: expiry_date = '%d-09-30' % (datetime.date.today().year + 8) return expiry_date def get_identity(self): """ Map LDAP record to Evergreen identity type and value """ if 'lulColleagueId' not in self.ldap_atts: print >> sys.stderr, 'No lulColleagueId for %s' % (self.ldap_atts) return 2, 'NO_COLLEAGUE_ID' ident_value = self._simple_map('lulColleagueId') if ident_value is None: print >> sys.stderr, 'No Datatel number for %s (%s)' % ( self.usrname ) else: ident_value = ident_value.lower() if len(ident_value) != 7: print >> sys.stderr, 'Datatel number not 7 chars for %s (%s)' % ( self.usrname, ident_value ) if len(ident_value) == 6: ident_value = '0%s' % ident_value elif len(ident_value) == 5: ident_value = '00%s' % ident_value return 2, ident_value def get_profile(self): """ Map LDAP record to Evergreen profile """ if 'lulStudentLevel' in self.ldap_atts: affiliation = self._simple_map('lulStudentLevel').lower() elif 'lulPrimaryAffiliation' in self.ldap_atts: affiliation = self._simple_map('lulPrimaryAffiliation').lower() else: affiliation = r'\N' profile_map = { 'ug': 113, 'student': 113, 'gr': 112, 'al': 114, 'alumni': 114, 'faculty': 111, 'staff': 115, 'thorneloe': 115, 'thornloe': 115, 'proxy': None, 'retired': None, 'affiliate': 115 } if affiliation in profile_map: return profile_map[affiliation] else: print >> sys.stderr, "Affiliation '%s' not mapped to a profile " \ "for user %s" % (affiliation, self.ldap_atts) return None def get_home_ou(self): """ Map LDAP record to Evergreen home library """ if 'laurentian.ca' or 'laurentienne.ca' in self.email: return 103 elif 'huntingtonu.ca' in self.email: return 104 elif 'usudbury.ca' in self.email: return 107 # Default to Laurentian return 103 class AuthException(Exception): """ Exceptions for authentication events """ def __init__(self, msg=''): """ Initialize the authentication exception """ Exception.__init__(self) self.msg = msg def __str__(self): """ Stringify the authentication exception """ return 'AuthException: %s' % self.msg def load_idl(): """ Loads the fieldmapper IDL, registering class hints for the defined objects We use a temporary file to store the IDL each time load_idl() is invoked to ensure that the IDL is in sync with the target server. One could a HEAD request to do some smarter caching, perhaps. """ parser = oils.utils.idl.IDLParser() idlfile = tempfile.TemporaryFile() # Get the fm_IDL.xml file from the server try: idl = urllib2.urlopen('%s://%s/%s' % (credentials.OSRF_HTTP, ARGS.eg_host, credentials.IDL_URL) ) idlfile.write(idl.read()) # rewind to the beginning of the file idlfile.seek(0) except urllib2.URLError, exc: print("Could not open URL to read IDL: %s", exc.code) except IOError, exc: print("Could not write IDL to file: %s", exc.code) # parse the IDL parser.set_IDL(idlfile) parser.parse_IDL() def osrf_login(username, password, workstation=None): """ Login to the server and get back an authtoken """ __authtoken = None try: seed = osrf_request( 'open-ils.auth', 'open-ils.auth.authenticate.init', username).send() except Exception, exc: print exc # generate the hashed password password = oils.utils.utils.md5sum(seed + oils.utils.utils.md5sum(password)) result = osrf_request( 'open-ils.auth', 'open-ils.auth.authenticate.complete', { 'workstation' : workstation, 'username' : username, 'password' : password, 'type' : 'staff' }).send() evt = oils.event.Event.parse_event(result) if evt and not evt.success: raise AuthException(evt.text_code) __authtoken = result['payload']['authtoken'] return __authtoken def osrf_request(service, method, *args): """ Make a JSON request to the OpenSRF gateway This is as simple as it gets. Atomic requests will require a bit more effort. """ req = osrf.gateway.JSONGatewayRequest(service, method, *args) # The gateway URL ensures we're using JSON v1, not v0 req.setPath(credentials.GATEWAY_URL) return req def find_ldap_users(con, ldap_filter, attributes, auth): """ Retrieve personnel accounts from LDAP directory and process'em """ base_dn = 'o=lul' search_scope = ldap.SCOPE_SUBTREE results = [] try: raw_results = con.search_ext_s(base_dn, search_scope, ldap_filter, attributes) for raw_user, raw_atts in raw_results: if raw_atts == []: continue user = User(raw_user, raw_atts) if ARGS.dump_ldap: dump_data(raw_atts) if ARGS.create_users: res = create_evergreen_user(auth, user) if res: update_ldap_barcode(con, user) results.append(res) if ARGS.push_barcode: if user.barcode: continue try: uid = find_evergreen_user(auth, user) except AttributeError: # stub LDAP account; move on continue if not uid: print >> sys.stderr, "User not found in Evergreen: %s" % \ (user.ident_value) continue user.barcode = get_barcode(auth, uid) update_ldap_barcode(con, user) except ldap.LDAPError, exc: print >> sys.stderr, exc return results def get_barcode(auth, uid): """ Retrieve the barcode for a user from Evergreen based on their user ID """ user = osrf_request( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve', auth, int(uid), ['card'] ).send() evt = oils.event.Event.parse_event(user) if evt and not evt.success: raise AuthException(evt.text_code) if not user: return None return user.card().barcode() def find_evergreen_user(auth, user): """ Search for an existing user in Evergreen Returns user ID if found, None if not """ # Custom search method - overrides opt-in visibility, as it suggests search_method = 'open-ils.actor.patron.search.advanced.opt_in_override' limit = 1 sort = None search_depth = 1 include_inactive = True print("Trying to find user: %s %s %s" % (user.ident_value, user.email, user.usrname) ) by_id = osrf_request( 'open-ils.actor', search_method, auth, {'ident_value': {'value': user.ident_value, 'group': 0}}, limit, sort, include_inactive, search_depth ).send() if by_id and len(by_id): return by_id[0] by_email = osrf_request( 'open-ils.actor', search_method, auth, {'email': {'value': user.email}}, limit, sort, include_inactive, search_depth ).send() if by_email and len(by_email): return by_email[0] by_usrname = osrf_request( 'open-ils.actor', search_method, auth, {'usrname': {'value': user.usrname}}, limit, sort, include_inactive, search_depth ).send() if by_usrname and len(by_usrname): return by_usrname[0] return None def retrieve_evergreen_user(auth, uid): """ Update account for an existing user in Evergreen Sets active flag, updates expiry date """ egau = osrf_request( 'open-ils.actor', 'open-ils.actor.user.retrieve', auth, uid ).send() if not egau: print sys.stderr >> "Damn it" return egau def create_evergreen_user(auth, user): """ Map LDAP record to Evergreen user """ if not user: return if user.profile is None: print >> sys.stderr, "No profile set for %s" % user.usrname return if user.barcode is not None: return found = find_evergreen_user(auth, user) egau = osrf.net_obj.new_object_from_hint('au') if found: print("Found: %s" % user.usrname) egau = retrieve_evergreen_user(auth, found) egau.ischanged(True) egau.active(True) else: egau.isnew(True) egau.usrname(user.usrname) egau.profile(user.profile) egau.email(user.email) egau.family_name(user.family_name) egau.first_given_name(user.first_given_name) egau.ident_type(user.ident_type) egau.ident_value(user.ident_value) egau.home_ou(user.home_ou) egau.passwd(user.passwd) egau.expire_date(user.expire_date) # Workaround open-ils.actor.patron.update bug egau.addresses([]) egau.cards([]) # Create or update the user try: usr = osrf_request( 'open-ils.actor', 'open-ils.actor.patron.update', auth, egau ).send() except Exception, exc: print >> sys.stderr, exc return None evt = oils.event.Event.parse_event(usr) if evt and not evt.success: print >> sys.stderr, "Create Evergreen user failed for %s: %s" % ( AuthException(evt.text_code), user.usrname) return None if found: # Do not generate a barcode for the user return # Generate a barcode for the user try: barcode = osrf_request( 'open-ils.actor', 'open-ils.actor.generate_patron_barcode', auth, usr.id(), '000070' ).send() except Exception, exc: print >> sys.stderr, exc return None evt = oils.event.Event.parse_event(barcode) if evt and not evt.success: print >> sys.stderr, "Create barcode failed for %s: %s" % ( AuthException(evt.text_code), user.usrname) return None user.barcode = barcode['evergreen.actor_update_barcode'] create_stat_cats(egau, user) print("Created: %s with barcode %s" % (egau.usrname(), user.barcode)) return egau.usrname(), user.barcode def update_ldap_barcode(con, user): """ Updates the LDAP directory with the new barcode for a given user We need to retrieve the qualified CN for the ldap.modify_s operation, first, so take the first match we get. Reckless, I know. """ try: mod_attrs = [(ldap.MOD_REPLACE, 'lulLibraryBarcode', [user.barcode])] con.modify_s(user.cname, mod_attrs) except Exception, exc: print >> sys.stderr, exc return False return True def create_stat_cats(eg_user, ldap_user): """ Map statistical categories """ if ldap_user.lang_pref: pass # XXX Now we should generate stat cats eh? def dump_data(result_data): """ Simple dump of all data received """ print() for key in result_data: print(key, result_data[key]) def ldap_query(con, auth): """ Process LDAP users created since a given date """ ldap_filter = None attributes = [ 'lulLibraryBarcode', 'createTimestamp', 'lulAffiliation', 'lulStudentLevel', 'lulPrimaryAffiliation', 'cn', 'mail', 'givenName', 'sn', 'lulColleagueId', 'preferredLanguage', 'lulModifyTimestamp' ] if (ARGS.query_date): ldap_filter = '(&%s(lulPrimaryAffiliation=*)(createTimestamp>=%s))' % ( '(objectclass=lulEduPerson)', ARGS.query_date ) elif (ARGS.query_cn): ldap_filter = '(&%s(cn=%s))' % ( '', ARGS.query_cn ) elif (ARGS.query_sn): ldap_filter = '(&%s(sn=%s))' % ( '(objectclass=lulEduPerson)', ARGS.query_sn ) elif (ARGS.query_id): ldap_filter = '(&%s(lulColleagueId=%s))' % ( '(objectclass=lulEduPerson)', ARGS.query_id ) if not ldap_filter: return return find_ldap_users(con, ldap_filter, attributes, auth) def parse_args(): """ Parse the command line options for the script """ parser = argparse.ArgumentParser() parser.add_argument('-d', '--dump-ldap', action='store_true', help='Dump the LDAP results to STDOUT' ) parser.add_argument('-c', '--create-users', action='store_true', help='Create new users in Evergreen' ) parser.add_argument('-b', '--push-barcode', action='store_true', help='Push barcode to LDAP' ) parser.add_argument('--query-cn', help='Search LDAP for a specific user by cn attribute' ) parser.add_argument('--query-sn', help='Search LDAP for a specific user by sn attribute' ) parser.add_argument('--query-id', help='Search LDAP for a specific user by id attribute' ) parser.add_argument('--query-date', help='Search LDAP for users created since (YYYYMMDDHHMMSSZ)' ) parser.add_argument('--find-eg-user', help='Find Evergreen user by user name' ) parser.add_argument('-U', '--eg-user', nargs='?', help='Evergreen user name', default=credentials.OSRF_USER ) parser.add_argument('-P', '--eg-password', nargs='?', help='Evergreen password', default=credentials.OSRF_PW ) parser.add_argument('-W', '--eg-workstation', nargs='?', help='Name of the Evergreen workstation', default=credentials.OSRF_WORK_OU ) parser.add_argument('-H', '--eg-host', nargs='?', help='Hostname of the Evergreen gateway', default=credentials.OSRF_HOST ) parser.add_argument('-u', '--ldap-user', nargs='?', help='LDAP user (DN)', default=credentials.LDAP_DN ) parser.add_argument('-p', '--ldap-password', nargs='?', help='LDAP password', default=credentials.LDAP_PW ) parser.add_argument('-s', '--ldap-server', nargs='?', help='LDAP server name or IP address', default=credentials.LDAP_HOST ) args = parser.parse_args() return args def main(args=None): """ Set up connections and run code """ results = [] global ARGS global AUTHTOKEN ARGS = parse_args() # override parsed args with anything that was passed in if args: ARGS = args # Set the host for our requests osrf.gateway.GatewayRequest.setDefaultHost(ARGS.eg_host) # Pull all of our object definitions together load_idl() # Log in and get an authtoken AUTHTOKEN = osrf_login(ARGS.eg_user, ARGS.eg_password, ARGS.eg_workstation) if ARGS.find_eg_user: class QuickUser: "A quick and dirty user class" pass user = QuickUser() user.ident_value = ARGS.find_eg_user user.email = ARGS.find_eg_user user.usrname = ARGS.find_eg_user uid = find_evergreen_user(AUTHTOKEN, user) if uid: print "Found Evergreen user: %s" % (uid) sys.exit() try: con = ldap.initialize(ARGS.ldap_server) con.set_option(ldap.OPT_REFERRALS, 0) con.simple_bind_s(ARGS.ldap_user, ARGS.ldap_password) results = ldap_query(con, AUTHTOKEN) except ldap.LDAPError, exc: print >> sys.stderr, "Could not connect: " + exc.message['info'] if type(exc.message) == dict and exc.message.has_key('desc'): print >> sys.stderr, exc.message['desc'] else: print >> sys.stderr, exc sys.exit() finally: con.unbind_ext_s() return results # UDATA = { # 'mail': ['dan@example.com'], # 'givenName': ['Dan'], # 'sn': ['Scott'], # 'lulColleagueId': ['0123456'], # 'lulStudentLevel': ['GR'], # } # create_evergreen_user(AUTHTOKEN, UDATA) if __name__ == '__main__': import doctest doctest.testmod() ARGS = None AUTHTOKEN = None main() # vim: et:ts=4:sw=4:tw=78: