commit d5311440f26814e55f01ed72c38f3aea465fbeba Author: Derek Feichtinger Date: Fri Sep 28 12:12:03 2012 +0200 refactored ldapuserdir code after splitting from sigateway project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56587c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*~ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +sdist + +# Installer logs +pip-log.txt diff --git a/bin/ldapuserdir-ctl b/bin/ldapuserdir-ctl new file mode 100755 index 0000000..4547ea8 --- /dev/null +++ b/bin/ldapuserdir-ctl @@ -0,0 +1,298 @@ +#!/usr/bin/python + +import logging +from ldapuserdir import LdapUserDir +import ldap +import sys +import os +from optparse import OptionParser +import ConfigParser +import getpass + +def read_cfg(filename): + cfg = ConfigParser.ConfigParser() + + try: + mylogger.debug('reading config from %s' % filename) + cfg.read(filename) + config = { + 'serverurl' : cfg.get('Ldap','serverurl'), + 'user_ou' : cfg.get('Ldap','user_ou'), + 'group_ou' : cfg.get('Ldap','group_ou'), + 'default_user_dn' : cfg.get('Ldap','default_user_dn'), + 'default_user_pw' : cfg.get('Ldap','default_user_pw'), + } + except Exception, err: + sys.stderr.write("Error in reading configuration from %s\n" % filename) + sys.stderr.write(str(err)+'\n') + sys.exit(1) + + try: + config['default_group_filter'] = cfg.get('Ldap','default_group_filter') + except: + config['default_group_filter'] = '*' + + return config + +#Defaults + +cfgfile_loc = [os.path.expanduser('~/.ldapuserdir-ctl.cfg'), + '/etc/ldapuserdir-ctl.cfg'] +config = { + 'serverurl' : 'ldaps://xyzdir.example.com:636', + 'user_ou' : 'OU=Users,DC=example.com,DC=ch', + 'group_ou' : 'OU=Groups,DC=example.com,DC=ch', + 'default_user_dn' : 'CN=minpriv_user,OU=Services,DC=example.com,DC=ch', + 'default_user_pw' : 'dummypwd', + 'default_group_filter' : 'svc-ra*' +} + +flag_needprivileges = False +userfilter = '-' + +user_pw = '' +mode = 'list' + +mylogger = logging.getLogger(os.path.basename(sys.argv[0])) +mylogger.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(name)s %(levelname)s: %(message)s') +ch = logging.StreamHandler() +ch.setLevel(logging.WARNING) +ch.setFormatter(formatter) +mylogger.addHandler(ch) + +################################################ +# OPTION PARSING +usage = """usage %prog [options] groupname [usernames] + + Used to inspect or change members of a group in Active Directory + User names can be given as full distinguished names or just as + the short names (in that case they will be extended by the + standard OU extension) + + Examples: + List group members + %prog svc_ra_x06sa + %prog 'svc_ra_*' + + Get group memberships for user mueller + %prog -g mueller + + Add/delete users to/from a group (requires access rights!) + %prog -a svc_ra_x06sa user1 user2 user3 + %prog -d svc_ra_x06sa user1 user2 + + List users matching a pattern + %prog -u 'mueller*' + +The configuration is read from a configuration file (default +locations: """ +usage += ", ".join(cfgfile_loc) + ')\n' +usage += """Configuration file example: + +[Ldap] +# URL for contacting the LDAP server +serverurl = ldaps://d.psi.ch:636 +# base ldap path under which all users are found +user_ou = OU=Users,OU=PSI,DC=d,DC=psi,DC=ch +# base ldap path under which groups are found +group_ou = ou=Groups,ou=PSI,dc=d,dc=psi,dc=ch +# minimally privileged Ldap user and password for running normal +# lookup queries +default_user_dn = CN=linux_ldap,OU=Services,OU=IT,DC=d,DC=psi,DC=ch +default_user_pw = TBVsK5zOfqMyxVmXco7y +# Optional: +# default filter to be used for group searches +default_group_filter = svc-ra* + +""" +parser = OptionParser(usage=usage) +parser.add_option('-a', + action = 'store_true', + dest = 'flag_add', + help = 'add group members', + ) +parser.add_option('-d', + action = 'store_true', + dest = 'flag_del', + help = 'delete group members', + ) +parser.add_option('-c', + action = 'store', + dest = 'cfgfile', + help = 'path of a config file', + default = '' + ) +parser.add_option('-u', + action = 'store', + dest = 'userfilter', + help = 'list all matching ldap users that have defined unix mappings', + ) +parser.add_option('--debug', + action = 'store_true', + dest = 'flag_debug', + help = 'debug mode: log messages at debug level', + ) +parser.add_option('-D', + action = 'store', + dest = 'user_dn', + help = 'DN or CN of ldap user for binding to the AD server (%s)' % config['default_user_dn'], + default = None + ) +parser.add_option('-f', + action = 'store', + dest = 'pwfile', + help = 'path to password file (without this pwd will be prompted for)', + default = '' + ) +parser.add_option('-g', + action = 'store', + dest = 'user_to_group', + help = 'get group memberships for this user', + default = '' + ) +parser.add_option('-v', + action = 'store_true', + dest = 'flag_verbose', + help = 'use more verbose output', + default = False + ) +parser.add_option('--user-ou', + action = 'store', + dest = 'user_ou', + help = 'default OU for users (%s)' % config['user_ou'], + default = None + ) +parser.add_option('--group-ou', + action = 'store', + dest = 'group_ou', + help = 'default OU for groups (%s)' % config['group_ou'], + default = None + ) +parser.add_option('--no-msSFU', + action = 'store_true', + dest = 'flag_nosfu', + help = 'do not restrict to entries with unix (msSFU) mappings', + default = False + ) + +(options, args) = parser.parse_args() + +group = None +usernames = [] +if len(args) > 0: + group = args.pop(0) + usernames = args + +if options.flag_debug: + ch.setLevel(logging.DEBUG) + +cfgfile = None +if(options.cfgfile != ''): + cfgfile = options.cfgfile +else: + for tmp in cfgfile_loc: + if os.path.exists(tmp): + cfgfile = tmp + +if cfgfile == None: + sys.stderr.write('Error: You must provide a config file (default locations ' + + ', '.join(cfgfile_loc) + ' or explicitely)\n') + sys.exit(1) + +config = read_cfg(cfgfile) + +user_dn = config['default_user_dn'] +if options.user_dn: + config['user_dn'] = options.user_dn +if options.group_ou: + config['group_ou'] = options.group_ou +if options.user_ou: + config['user_ou'] = options.user_ou + +flag_verbose = options.flag_verbose +flag_sfu = not options.flag_nosfu +userfilter = options.userfilter + +if options.flag_del: + mode = 'del' + flag_needprivileges = True +if options.flag_add: + mode = 'add' + flag_needprivileges = True +if userfilter: + mode = 'userlist' +if options.user_to_group: + mode = "user_to_group" + user_to_group = options.user_to_group + +if (mode == 'add' or mode == 'del') and len(usernames) == 0: + sys.stderr.write("Error: Not enough arguments\n") + sys.exit(1) + +if ',' not in user_dn: + user_dn = 'CN=' + user_dn + ',' + user_ou + +if flag_needprivileges and user_dn == config['default_user_dn']: + try: + l_unpriv = LdapUserDir(config['serverurl'], + config['default_user_dn'], + config['default_user_pw'], + user_ou = config['user_ou'], + logger = mylogger) + user_dn = l_unpriv.systemuser2dn(os.getlogin()) + if user_dn == '': + sys.stderr.write('Error: Need priviledged user and cannot map your system user "%s" to LDAP DN for binding (you may want to use explicit -D user_dn option?)' % os.getlogin() ) + sys.exit(1) + except ldap.LDAPError, e: + sys.stderr.write('LDAP error: %s\n' % str(e)) + sys.exit(1) + +if user_dn == config['default_user_dn']: + user_pw = config['default_user_pw'] + +if options.pwfile != '': + pwf = open(options.pwfile) + user_pw = pwf.readline().rstrip('\n') + +if user_pw == '': + user_pw = getpass.getpass() + if user_pw == '': + sys.stderr.write('Error: No empty passwd allowed\n') + # Note: AD accepts empty passwds, but will give anonymous access + # So, we want to catch that case + sys.exit(1) + +try: + l = LdapUserDir(config['serverurl'], + user_dn, + user_pw, + config['group_ou'], + config['user_ou'], + sfu = flag_sfu, + logger=mylogger) + + if mode == 'list': + sfilter = config['default_group_filter'] + if group: + sfilter = group + l.list_groups(sfilter) + elif mode == 'userlist': + l.list_users_etcpwd(userfilter, verbose = flag_verbose) + elif mode == 'user_to_group': + sys.stdout.write("\n".join(l.get_groups_for_user(user_to_group)) + + "\n") + elif mode == 'add': + l.add_groupmembers(group, usernames) + elif mode == 'del': + l.del_groupmembers(group, usernames) +except ldap.INVALID_CREDENTIALS, e: + sys.exit(1) +except ldap.LDAPError, e: + sys.stderr.write('Unhandled LDAP error: %s\n' % str(e)) + sys.exit(1) +except: + sys.stderr.write('Unhandled Exception!!!!!!!!\n') + raise + +sys.exit(0) diff --git a/etc/ldapuserdir-ctl.cfg b/etc/ldapuserdir-ctl.cfg new file mode 100644 index 0000000..8e58e91 --- /dev/null +++ b/etc/ldapuserdir-ctl.cfg @@ -0,0 +1,18 @@ +[Ldap] +# URL for contacting the LDAP server +serverurl = ldaps://d.psi.ch:636 + +# base ldap path under which all users are found +user_ou = OU=Users,OU=PSI,DC=d,DC=psi,DC=ch + +# base ldap path under which groups are found +group_ou = ou=Groups,ou=PSI,dc=d,dc=psi,dc=ch + +# minimally privileged Ldap user and password for running normal +# lookup queries +default_user_dn = CN=linux_ldap,OU=Services,OU=IT,DC=d,DC=psi,DC=ch +default_user_pw = secret_pwd + +# Optional +# default filter to be used for group searches +default_group_filter = svc-ra* diff --git a/ldapuserdir/__init__.py b/ldapuserdir/__init__.py new file mode 100644 index 0000000..9367861 --- /dev/null +++ b/ldapuserdir/__init__.py @@ -0,0 +1 @@ +from ldapuserdir import LdapUserDir diff --git a/ldapuserdir/ldapuserdir.py b/ldapuserdir/ldapuserdir.py new file mode 100644 index 0000000..3e4b873 --- /dev/null +++ b/ldapuserdir/ldapuserdir.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python +###################################################################### +# Tool for modifying group memberships in AD +# +# Author: Derek Feichtinger +# +# Version info: $Id: ldapgroupmng.py 65 2012-09-25 15:35:38Z feichtinger $ +###################################################################### + +""" This module provides the LdapUserDir class. It is used to interact +with an LDAP based user directory service +""" + +import ldap +import os +import sys +import re +import logging + +############################################################## +# definitions of the search strings + +# restrict to entries with msSFU mappings +searches_mssfu= dict({ +'get_users' : '(&(objectClass=user)(!(objectClass=computer))(msSFU30UidNumber=*)(msSFU30HomeDirectory=*)(cn=%s))', +'systemuser2dn' : '(&(objectClass=user)(!(objectClass=computer))(msSFU30UidNumber=*)(msSFU30HomeDirectory=*)(cn=%s))', +'get_groups_struct' : '(&(objectClass=Group)(msSFU30GidNumber=*)(cn=%s))', +'get_groups_for_user' : '(&(objectClass=Group)(msSFU30GidNumber=*)(cn=%s)(member=%s))'}) + +# allow entries without msSFU mappings +searches_nomssfu= dict({ +'get_users' : '(&(objectClass=user)(!(objectClass=computer))(cn=%s))', +'systemuser2dn' : '(&(objectClass=user)(!(objectClass=computer))(msSFU30UidNumber=*)(msSFU30HomeDirectory=*)(cn=%s))', +'get_groups_struct' : '(&(objectClass=Group)(cn=%s))', +'get_groups_for_user' : '(&(objectClass=Group)(cn=%s)(member=%s))'}) +############################################################## + +class LdapUserDir(object): + """ A class to interact with a LDAP based user and group directory + """ + def __init__(self, + serverurl, + user_dn, + user_pw, + group_ou = 'ou=example.com', + user_ou = 'ou=example.com', + sfu = True, + logger = None): + self.serverurl = serverurl + self.group_ou = group_ou + self.user_ou = user_ou + + # whether to only search for entries with msSFU mappings + # i.e. with existing unix attributes + if sfu: + self.searches = searches_mssfu + else: + self.searches = searches_nomssfu + + + if logger == None: + self.logger = logging.getLogger('LdapUserDir') + self.logger.setLevel(logging.WARNING) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + ch = logging.StreamHandler() + + ch.setFormatter(formatter) + ch.setLevel(logging.DEBUG) + self.logger.addHandler(ch) + else: + self.logger = logger + + self._ldap = ldap.initialize(self.serverurl, trace_level=0, + trace_file=sys.stderr) + self.logger.debug('binding to: %s\n' % serverurl) + self.logger.debug('binding as user: %s\n' % user_dn) + try: + self._ldap.bind_s(user_dn, user_pw) + except ldap.INVALID_CREDENTIALS, e: + self.logger.error('Authentication failure for dn:"%s"\n' % user_dn) + raise + # need to clean that later + except ldap.LDAPError, e: + raise + + def get_users(self, filter='*', ou=None): + """get the names of all users from the directory service + @param filter A filter expression used for the cn part of the ldap dn + @param ou The organisational unit to be used in the ldap search + @returns A dictionary of the matching users { dn1:list1, ... } + """ + if ou == None: + user_ou = self.user_ou + #try: + r = self._ldap.search_s(user_ou, ldap.SCOPE_SUBTREE, + self.searches['get_users'] % filter) + #except ldap.LDAPError, e: + # print e + # return + return r + + def list_users_etcpwd(self, filter='*', ou=None, verbose = False): + """Print '/etc/pwd' format like information about matching users + @param filter A filter expression used for the cn part of the ldap dn + @param ou The organisational unit to be used in the ldap search + """ + r = self.get_users(filter, ou) + for dn, entry in r: + # MUST fields + try: + print ':'.join([entry['cn'][0], entry['msSFU30UidNumber'][0], + 'IGNORE', + entry['msSFU30GidNumber'][0], + entry['displayName'][0], + entry['msSFU30HomeDirectory'][0], + entry['msSFU30LoginShell'][0], + ]), + except KeyError: + print ':'.join([entry['cn'][0],"","",entry['displayName'][0], + "",""]) + + if verbose: + for k in ['description', 'mail', 'mobile']: + print '[%s:]' % (k,) , + if k in entry: + print entry[k][0], + else: + print 'N.A.', + print + + def systemuser2dn(self, uname): + """Converts a user's system username to the dn of the ldap directory + by performing a search on ldap + @param uname The system username + @returns The DN of the user or '' if no matching record was found + + @exception may throw an ldap.LDAPError exception + """ + #try: + srch = self.searches['systemuser2dn'] % uname + self.logger.debug('systemuser2dn: %s' % srch) + r = self._ldap.search_s(self.user_ou, ldap.SCOPE_SUBTREE, srch) + #except ldap.LDAPError, e: + # print e + if len(r) == 0: + return '' + self.logger.debug('systemuser2dn: dn = %s' % r[0][0]) + return r[0][0] + + def get_groups_struct(self, gfilter='*', ou = None): + """searches for groups + returns the full ldap search result structure for the search + with the optional filter applied to the cn field + @param filter A filter expression used for the cn part of the ldap dn + @param ou The organisational unit to be used in the ldap search + @returns A dictionary of the matching groups { dn1:list1, ... } + """ + if ou == None: + group_ou = self.group_ou + #try: + srch = self.searches['get_groups_struct'] % gfilter + self.logger.debug('get_groups_struct: %s' % srch) + r = self._ldap.search_s(group_ou, ldap.SCOPE_SUBTREE, srch) + #except ldap.LDAPError, e: + # print e + return r + + def get_groups_for_user(self, user, gfilter='*', ou=None, returndn = False): + """Get groups for a particular user from LDAP. + The function will try to determine whether it receives a DN or + a system username that needs to be converted to a DN first. + @param user The user's DN or system name + @param gfilter A filter expression used for the cn part of the ldap dn + @param ou The organisational unit to be used in the ldap search + @param returndn If set True the function will return DN, otherwise CN + @returns list of group names + """ + if ou == None: + group_ou = self.group_ou + if not ',' in user: + dnname = self.systemuser2dn(user) + else: + dnname = user + + srch = self.searches['get_groups_for_user'] % (gfilter, dnname) + + self.logger.debug('get_groups_for_user: %s' % srch) + r = self._ldap.search_s(group_ou, ldap.SCOPE_SUBTREE, srch) + + reslist = [] + for dn, entry in r: + reslist.append(dn) + + if returndn: + return reslist + + cnlist = [] + reg = re.compile(r'^CN=([^,]+),') + for dn in reslist: + m = reg.match(dn) + if m: + cnlist.append(m.group(1)) + else: + raise RuntimeError("Could not match CN in DN (%s)" % dn) + + return cnlist + + def list_groups(self, filter = '*', ou = None): + """Prints a list of groups from the LDAP directory + @param filter A filter expression used for the cn part of the ldap dn + @param ou The organisational unit to be used in the ldap search + """ + r = self.get_groups_struct(filter, ou) + if len(r) == 0: + sys.stderr.write("Error: no groups found (filter: %s)\n" % filter) + return 0 + + for dn, entry in r: + print entry['cn'][0] + if 'member' in entry: + for cn in entry['member']: + print ' member: ', cn + + def _mod_groupmembers(self, ldapmode, dngroup, usernames): + """modifies members of an LDAP group entry + @param ldapmode Either ldap.MOD_ADD, or ldap.MOD_DELETE + @param dngroup DN of the group + @param usernames List of usernames (system names or DNs) + """ + if not ',' in dngroup: + dngroup = ''.join(['cn=', dngroup, ',', self.group_ou]) + dnlist = [] + for name in usernames: + if not ',' in name: + dnname = self.systemuser2dn(name) + if dnname == '': + raise RuntimeError('Error: No such ldap user: %s' % name) + dnlist.append(dnname) + else: + dnlist.append(name) + mod_attrs = [(ldapmode, 'member', dnlist)] + try: + self._ldap.modify_s(dngroup, mod_attrs) + except ldap.ALREADY_EXISTS, e: + self.logger.error('Entry already exists in group %s' % dngroup) + raise + except ldap.LDAPError, e: + self.logger.error('ERROR modifying LDAP: group: %s; user list: %s\n' + % (dngroup, dnlist)) + raise + + def add_groupmembers(self, group, usernames): + """Adds users to an LDAP group + @param dngroup DN of the group + @param usernames List of usernames (system names or DNs) + """ + self._mod_groupmembers(ldap.MOD_ADD, group, usernames) + + def del_groupmembers(self, group, usernames): + """Deletes users from an LDAP group + @param dngroup DN of the group + @param usernames List of usernames (system names or DNs) + """ + self._mod_groupmembers(ldap.MOD_DELETE, group, usernames) + + def __del__(self): + self._ldap.unbind() diff --git a/ldapuserdir/version.py b/ldapuserdir/version.py new file mode 100644 index 0000000..a4e2017 --- /dev/null +++ b/ldapuserdir/version.py @@ -0,0 +1 @@ +__version__ = "0.1" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f943213 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +#from setuptools import setup, find_packages +#from distutils.core import setup + +# we use the distribute framework that has a backward compatible invocation +# to the setuptools +from setuptools import setup, find_packages + +# we get the package version from inside our package, since we then +# can use it also from within the package +execfile('ldapuserdir/version.py') + +setup( + name = "ldapuserdir", + version = __version__, + description = "Client for interacting with a LDAP user/group directory service", + long_description = "Client for listing user and group information and" + + " for managing group memberships", + author = "Derek Feichtinger", + author_email = "derek.feichtinger@psi.ch", + license = "GPL", + # url = + #packages = find_packages('sigateway'), + packages = ['ldapuserdir'], + scripts = ['bin/ldapuserdir-ctl'], + + # following format is (targetdir,[list of files]) + data_files = [('etc',['etc/ldapuserdir-ctl.cfg'])], + + install_requires = ['python-ldap'] +) +