""" ldapsession.py - higher-level class for handling LDAP connections (c) by Michael Stroeder This module is distributed under the terms of the GPL (GNU GENERAL PUBLIC LICENSE) Version 2 (see http://www.gnu.org/copyleft/gpl.html) $Id: ldapsession.py,v 1.264 2007/06/25 13:21:06 michael Exp $ """ __version__ = '0.11.1' import sys,time,types,ldap,ldap.cidict,ldaputil.base try: import ldap.sasl except ImportError: pass from ldap.ldapobject import ReconnectLDAPObject START_TLS_NO = 0 START_TLS_TRY = 1 START_TLS_REQUIRED = 2 CONTROL_DONOTREPLICATE = '1.3.18.0.2.10.23' # IBM Directory Server CONTROL_DONTUSECOPY = '1.3.6.1.4.1.4203.666.5.15' CONTROL_LDUP_SUBENTRIES = '1.3.6.1.4.1.7628.5.101.1' # draft-ietf-ldup-subentry-07.txt CONTROL_MANAGEDSAIT = '2.16.840.1.113730.3.4.2' # RFC 3296 CONTROL_RELAXRULES = '1.3.6.1.4.1.4203.666.5.12' # draft-zeilenga-ldap-relax-00.txt CONTROL_SERVERADMINISTRATION = '1.3.18.0.2.10.15' # IBM Directory Server CONTROL_SUBENTRIES = '1.3.6.1.4.1.4203.1.10.1' # RFC 3672 CONTROL_TREEDELETE = '1.2.840.113556.1.4.805' # draft-armijo-ldap-treedelete-02.txt # Used attributes from RootDSE ROOTDSE_ATTRS = [ 'altServer', 'namingContexts', 'ogSupportedProfile', 'subschemaSubentry', 'supportedControl', 'supportedFeatures', 'supportedLDAPVersion', 'supportedSASLMechanisms', 'vendorName', 'vendorVersion', # 'informational' attributes of OpenLDAP 'auditContext', 'configContext', 'monitorContext', # 'informational' attributes of Active Directory 'configurationNamingContext', 'defaultNamingContext', 'defaultRnrDN', 'dnsHostName', 'schemaNamingContext', 'supportedCapabilities', 'supportedLDAPPolicies', # 'informational' attributes of IBM Directory Server 'ibm-configurationnamingcontext', ] READ_CACHE_EXPIRE = 120 class LDAPObject(ReconnectLDAPObject): def __init__( self,uri, trace_level=0,trace_file=None,trace_stack_limit=5 ): self._serverctrls = { '__all__':[], # all LDAP operations '__read__':[], # compare_ext,search_ext '__write__':[], # add_ext,delete_ext,modify_ext,rename 'abandon_ext':[], 'add_ext':[], 'compare_ext':[], 'delete_ext':[], 'modify_ext':[], 'rename':[], 'search_ext':[], 'unbind_ext':[], } return ReconnectLDAPObject.__init__( self,uri,trace_level,trace_file,trace_stack_limit,retry_max=4,retry_delay=20.0) def _get_server_ctrls(self,method): all_s_ctrls = {} for c in self._serverctrls[method]: all_s_ctrls[c.controlType] = c return all_s_ctrls def add_server_control(self,method,lc): _s_ctrls = self._get_server_ctrls(method) _s_ctrls[lc.controlType] = lc self._serverctrls[method] = _s_ctrls.values() def del_server_control(self,method,control_type): _s_ctrls = self._get_server_ctrls(method) try: del _s_ctrls[control_type] except KeyError: pass self._serverctrls[method] = _s_ctrls.values() def manage_dsa_it(self,enable,critical=0): if enable: self.add_server_control( '__all__', ldap.controls.LDAPControl(CONTROL_MANAGEDSAIT,critical,None), ) else: self.del_server_control('__all__',CONTROL_MANAGEDSAIT) def manage_dit(self,enable,critical=0): if enable: self.add_server_control( '__write__', ldap.controls.LDAPControl(CONTROL_RELAXRULES,critical,None), ) else: self.del_server_control('__write__',CONTROL_RELAXRULES) def abandon_ext(self,msgid,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.abandon_ext( self,msgid, serverctrls or self._serverctrls['__all__']+self._serverctrls['abandon_ext'], clientctrls ) def add_ext(self,dn,modlist,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.add_ext( self,dn,modlist, serverctrls or self._serverctrls['__all__']+self._serverctrls['__write__']+self._serverctrls['add_ext'], clientctrls ) def compare_ext(self,dn,attr,value,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.compare_ext( self,dn,attr,value, serverctrls or self._serverctrls['__all__']+self._serverctrls['__read__']+self._serverctrls['compare_ext'], clientctrls ) def delete_ext(self,dn,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.delete_ext( self,dn, serverctrls or self._serverctrls['__all__']+self._serverctrls['__write__']+self._serverctrls['delete_ext'], clientctrls ) def modify_ext(self,dn,modlist,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.modify_ext(self,dn,modlist, serverctrls or self._serverctrls['__all__']+self._serverctrls['__write__']+self._serverctrls['modify_ext'], clientctrls ) def rename(self,dn,newrdn,newsuperior=None,delold=1,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.rename(self,dn,newrdn,newsuperior,delold, serverctrls or self._serverctrls['__all__']+self._serverctrls['__write__']+self._serverctrls['rename'], clientctrls ) def search_ext(self,base,scope,filterstr='(objectClass=*)',attrlist=None,attrsonly=0,serverctrls=None,clientctrls=None,timeout=-1,sizelimit=0): return ReconnectLDAPObject.search_ext( self,base,scope,filterstr,attrlist,attrsonly, serverctrls or self._serverctrls['__all__']+self._serverctrls['__read__']+self._serverctrls['search_ext'], clientctrls,timeout,sizelimit) def unbind_ext(self,serverctrls=None,clientctrls=None): return ReconnectLDAPObject.unbind_ext(self, serverctrls or self._serverctrls['__all__']+self._serverctrls['unbind_ext'], clientctrls ) class LDAPSession: """ Class for handling LDAP connection objects """ def __init__( self,on_behalf=None,traceLevel=0,traceFile=None ): """Initialize a LDAPSession object""" # Set to not connected self.uri = None self.namingContextsDict = {} self.setDN(u'') self._traceLevel = traceLevel self._traceFile = traceFile or sys.stdout # Character set/encoding of data stored on this particular host self.charset = 'utf-8' # This is a dictionary for storing arbitrary objects # tied to a LDAP session self.rootDSE = {} self.read_cache = {} self.schema_dn_cache = {} self.schema_cache = {} # Default timeout 60 seconds self.timeout = 60 # Capable of returning only attribute types with a search call self.onlyAttrTypes = 1 # Supports feature described in draft-zeilenga-ldap-opattrs self.supportsAllOpAttr = 0 # IP adress, host name or other free form information # of proxy client self.onBehalf = on_behalf def _supportedLDAPVersion(self): """ Try to determine the highest supported protocol version by trying to bind anonymously """ # Set protocol version to LDAPv3 self.l.set_option(ldap.OPT_PROTOCOL_VERSION,ldap.VERSION3) # first try LDAPv3 bind try: # Try to bind to provoke error reponse if protocol # version is not supported self.l.simple_bind_s('','') except (ldap.INVALID_CREDENTIALS,ldap.INAPPROPRIATE_AUTH): self.who = None except ldap.PROTOCOL_ERROR: # Drop connection completely self.l.unbind_s() ; del self.l # Reconnect to host self.l = LDAPObject(self.uri,self._traceLevel,self._traceFile) # Switch to new connection to LDAPv2 self.l.set_option(ldap.OPT_PROTOCOL_VERSION,ldap.VERSION2) self.l.set_option(ldap.OPT_NETWORK_TIMEOUT,self.timeout) self.l.simple_bind_s('','') self.who = None except (ldap.INSUFFICIENT_ACCESS),e: self.who = None except ldap.LDAPError,e: self.who = None raise e else: self.who = None return # _supportedLDAPVersion() def setTLSOptions( self, # Options for verifying SSL server certificate tls_cacertdir='',tls_cacertfile='', # Options for deploying client certificates for strong authentication tls_certfile='',tls_keyfile='', ): if ldap.TLS_AVAIL: # Only set the options if python-ldap was built with TLS support for ldap_opt,ldap_opt_value in ( (ldap.OPT_X_TLS_CACERTDIR,tls_cacertdir), (ldap.OPT_X_TLS_CACERTFILE,tls_cacertfile), (ldap.OPT_X_TLS_CERTFILE,tls_certfile), (ldap.OPT_X_TLS_KEYFILE,tls_keyfile), ): if ldap_opt_value: ldap.set_option(ldap_opt,ldap_opt_value) return # setTLSOptions() def startTLSExtOp(self,startTLSOption=START_TLS_TRY): """StartTLS if possible and requested""" if startTLSOption: self.l.set_option(ldap.OPT_PROTOCOL_VERSION,ldap.VERSION3) self.l.set_option(ldap.OPT_X_TLS,startTLSOption) try: self.l.start_tls_s() except (ldap.SERVER_DOWN,ldap.CONNECT_ERROR): self.secureConn = 0 raise except AttributeError,e: self.secureConn = 0 raise ldap.SERVER_DOWN( { 'desc':str(e), 'info':'python-ldap installation is lacking StartTLS support' } ) else: self.secureConn = 1 else: self.secureConn = 0 return # startTLSExtOp() def _initialize(self,uri_list): while uri_list: uri = uri_list[0].strip().encode('ascii') # Try connecting to LDAP host try: self.l = LDAPObject(uri,self._traceLevel,self._traceFile) self.uri = uri self.l.set_option(ldap.OPT_NETWORK_TIMEOUT,self.timeout) self._supportedLDAPVersion() except ldap.SERVER_DOWN: # Remove current host from list self.unbind() uri_list.pop(0) if uri_list: # Try next host continue else: raise else: break def open( self,uri,timeout,startTLS, # Options for verifying SSL server certificate tls_cacertdir='',tls_cacertfile='', # Options for deploying client certificates for strong authentication tls_certfile='',tls_keyfile='', ): """ Open a LDAP connection with separate DNS lookup uri Either a (Unicode) string or a list of strings containing LDAP URLs of host(s) to connect to. If host is a list connecting is tried until a connect to a host in the list was successful. """ assert uri,ValueError("No host string or list specified for %s.open()." % ( self.__class__.__name__ )) assert type(uri) in [types.StringType,types.UnicodeType,types.ListType], \ TypeError("Parameter uri must be either list of strings or single string.") self.timeout = timeout if type(uri) in [types.StringType,types.UnicodeType]: uri_list = [uri] elif type(uri)==types.ListType: uri_list = uri self.setTLSOptions(tls_cacertdir,tls_cacertfile,tls_certfile,tls_keyfile) self._initialize(uri_list) if self.uri.lower().startswith('ldap:'): # Start TLS extended operation self.startTLSExtOp(startTLS) elif self.uri.lower().startswith('ldaps:') or self.uri.lower().startswith('ldapi:'): self.secureConn = 1 return # open() def unbind(self): """Close LDAP connection object if necessary""" if hasattr(self,'l'): try: self.l.unbind_s() del self.l except ldap.LDAPError: pass except AttributeError: pass self.uri = None # delete the LDAP connection URI # Flush schema cache self.schema_dn_cache = {} self.schema_cache = {} # Flush old data from cache self.flushCache() return # unbind() def getUmichConfig(self): """ Try to read entry cn=config attribute database from UMich LDAPv2 server derivate """ try: ldap_result = self.readEntry( 'cn=config',['database'], ) except ( ldap.NO_SUCH_OBJECT, ldap.PARTIAL_RESULTS, ldap.UNDEFINED_TYPE, ldap.INAPPROPRIATE_MATCHING, ldap.INSUFFICIENT_ACCESS, ldap.OPERATIONS_ERROR ): result = [] else: if ldap_result: result = [] l = ldap_result[0][1].get('database',[]) for d in l: try: dbtype,basedn = d.split(' : ',1) except ValueError: pass else: result.extend([ dn.strip() for dn in basedn.split(' : ') ]) else: result = [] return [ unicode(i,self.charset) for i in result ] def _forgetRootDSEAttrs(self): """Forget all old RootDSE values""" self.rootDSE = {} self.supportsAllOpAttr = 0 self.namingContextsDict = {} def getNamingContexts(self): return self.namingContextsDict.keys() def _updateNamingContexts(self,dn_list): for dn in dn_list: dn = ldaputil.base.normalize_dn(dn) self.namingContextsDict[dn] = None return # _updateNamingContexts() def _setRootDSEAttrs(self): """Derive some class attributes from rootDSE attributes""" self.namingContextsDict = ldap.cidict.cidict() self._updateNamingContexts(self.rootDSE.get('namingContexts',[])) for rootdse_naming_attrtype in ['configContext','monitorContext']: try: self._updateNamingContexts([self.rootDSE[rootdse_naming_attrtype][0]]) except KeyError: pass self.supportsAllOpAttr = \ ('1.3.6.1.4.1.4203.1.5.1' in self.rootDSE.get('supportedFeatures',[])) or \ ('OpenLDAProotDSE' in self.rootDSE.get('objectClass',[])) schema_dn_list = self.rootDSE.get('subschemaSubEntry',[]) # Speed up sub schema sub entry retrieval by pre-filling cache # with what is likely the sub schema for whole DIT if schema_dn_list and schema_dn_list[0]!=None: self.schema_dn_cache[u''] = unicode(schema_dn_list[0],self.charset) self.retrieveSubSchema(u'',None) else: self.schema_dn_cache[u''] = None return # _setRootDSEAttrs() def getRootDSE(self): """Retrieve attributes from Root DSE""" self._forgetRootDSEAttrs() try: ldap_result = self.readEntry('',ROOTDSE_ATTRS+['objectClass']) except (ldap.NO_SUCH_OBJECT, ldap.INSUFFICIENT_ACCESS, ldap.PARTIAL_RESULTS, ldap.UNDEFINED_TYPE, ldap.INAPPROPRIATE_MATCHING, ldap.OPERATIONS_ERROR, ldap.UNWILLING_TO_PERFORM, ldap.INVALID_CREDENTIALS, ldap.INAPPROPRIATE_AUTH): pass else: # Copy special rootDSE attributes to object attributes self.rootDSE=ldap.cidict.cidict((ldap_result or [('',{})])[0][1]) self._setRootDSEAttrs() # If no or an empty rootDSE we try to read the old Umich slapd config entry if not self.rootDSE: self._updateNamingContexts(self.getUmichConfig()) return # getRootDSE() def getSearchRoot(self,dn): """ Returns the namingContexts value matching best the distinguished name given in dn """ return ldaputil.base.match_dnlist(dn,self.getNamingContexts()) def isLeafEntry(self,dn): """Returns 1 if the node is a leaf entry, 0 otherwise""" return not self.subOrdinates(dn)[0] def subOrdinates(self,dn): """Returns tuple (hasSubordinates,numSubordinates,numAllSubordinates)""" # List of operational attributes suitable to determine non-leafs subordinate_attrs = ('hasSubordinates','subordinateCount', 'numSubordinates','numAllSubordinates', 'msDS-Approx-Immed-Subordinates') # First try to read operational attributes from entry itself # which might indicate whether there are subordinate entries result_ldap = self.l.search_ext_s( dn.encode(self.charset), ldap.SCOPE_BASE,'(objectClass=*)', subordinate_attrs, timeout=self.timeout ) hasSubordinates = None; numSubordinates = None; numAllSubordinates = None if result_ldap: entry = ldap.cidict.cidict(result_ldap[0][1]) for a in ('subordinateCount','numSubordinates','msDS-Approx-Immed-Subordinates'): try: numSubordinates = int(entry[a][0]) except KeyError: pass else: break try: numAllSubordinates = int(entry['numAllSubordinates'][0]) except KeyError: pass try: hasSubordinates = entry['hasSubordinates'][0].upper()=='TRUE' except KeyError: if numSubordinates!=None or numAllSubordinates!=None: hasSubordinates = (numSubordinates or numAllSubordinates or 0)>0 else: hasSubordinates = None if hasSubordinates is None: # Explicitly search for subordinate entries ldap_msgid = self.l.search_ext( dn.encode(self.charset), ldap.SCOPE_ONELEVEL,'(objectClass=*)', ['objectClass'],self.onlyAttrTypes,timeout=self.timeout,sizelimit=1 ) result_ldap = (None,None) while result_ldap==(None,None): result_ldap = self.l.result(ldap_msgid,0,self.timeout) self.l.abandon(ldap_msgid) hasSubordinates = len(result_ldap)>0 return (hasSubordinates,numSubordinates,numAllSubordinates) def getObjectClasses(self,dn): """Get a list of object classes associated with an entry""" try: search_result = self.readEntry(dn,['objectClass','structuralObjectClass']) except ldap.NO_SUCH_ATTRIBUTE: search_result = self.readEntry(dn,['objectClass']) if not search_result: raise ldap.NO_SUCH_OBJECT entry = ldap.cidict.cidict(search_result[0][1]) objectClass = entry.get('objectClass',[]) structuralObjectClass_values = entry.get('structuralObjectClass',[None]) # Attribute structuralObjectClass is supposed to be SINGLE-VALUE # but some broken LDAPv3 server implementations return all the sup classes if len(structuralObjectClass_values)==1: structuralObjectClass = structuralObjectClass_values[0] else: structuralObjectClass = None return objectClass,structuralObjectClass # getObjectClasses() def searchSubSchemaEntryDN(self,dn): """Determine DN of sub schema sub entry for current part of DIT""" if self.schema_dn_cache.has_key(dn): # grab DN of sub schema sub entry from schema DN cache subschemasubentry_dn = self.schema_dn_cache[dn] else: # Search the DN of sub schema sub entry subschemasubentry_dn = self.l.search_subschemasubentry_s(dn.encode('utf-8')) if subschemasubentry_dn is None: subschemasubentry_dn = self.l.search_subschemasubentry_s('') # Store DN of sub schema sub entry in schema DN cache self.schema_dn_cache[dn] = subschemasubentry_dn return subschemasubentry_dn def retrieveSubSchema(self,dn,default): """Retrieve parsed sub schema sub entry for current part of DIT""" if self.l.protocol_version return default schema return default elif self.schema_cache.has_key(subschemasubentry_dn): # Return parsed schema from cache sub_schema = self.schema_cache[subschemasubentry_dn] else: # Read the sub schema sub entry try: subschemasubentry = self.l.read_subschemasubentry_s( subschemasubentry_dn.encode(self.charset),ldap.schema.SCHEMA_ATTRS ) except ldap.LDAPError: sub_schema = None else: if subschemasubentry is None: sub_schema = None else: # Parse the schema sub_schema = ldap.schema.SubSchema(subschemasubentry) # Store parsed schema in schema cache self.schema_cache[subschemasubentry_dn] = sub_schema # Determine what to return return sub_schema or default def getAttributeTypes(self,dn,attrs=None,brute_force=0,opAttrs=1): """Get a list of attributes present in an entry""" attrs = attrs or [] if not dn: request_attrs = ldap.cidict.strlist_union(attrs,ROOTDSE_ATTRS) else: request_attrs = attrs # Do the wildcard search first search_result = self.readEntry( dn,{0:None,1:['*','+']}[opAttrs and self.supportsAllOpAttr], self.onlyAttrTypes ) if search_result: result = search_result[0][1].keys() else: result = [] if request_attrs: try: # Explicitly search for certain attributes search_result = self.readEntry( dn,request_attrs,self.onlyAttrTypes ) except ldap.NO_SUCH_ATTRIBUTE: if brute_force: for attr in request_attrs: try: # Explicitly search attribute-wise which is pretty hefty! search_result = self.readEntry( dn,[attr],self.onlyAttrTypes ) except ldap.NO_SUCH_ATTRIBUTE: pass else: result.extend(search_result[0][1].keys()) else: if search_result: result = ldap.cidict.strlist_union(result,search_result[0][1].keys()) return result # getAttributeTypes() def readEntry( self,dn,attrtype_list=None,only_attrtypes=0, search_filter='(objectClass=*)',no_cache=0 ): """Read a single entry""" if attrtype_list==['*']: attrtype_list = None acid = ','.join(attrtype_list or ['__']) if not no_cache and \ self.read_cache.has_key(dn) and \ self.read_cache[dn].has_key(acid): timestamp,read_cache_result = self.read_cache[dn][acid] if timestamp+READ_CACHE_EXPIRE>time.time(): return read_cache_result else: del self.read_cache[dn][acid] # Read single entry from LDAP server search_result = self.l.search_st( dn.encode(self.charset),ldap.SCOPE_BASE,search_filter, attrtype_list,only_attrtypes,self.timeout ) if search_result: # Create DN-level cache dictionary if not self.read_cache.has_key(dn): self.read_cache[dn]={} # Store the read entry in the time-stamped read_cache self.read_cache[dn][acid] = (time.time(),search_result) return search_result def existingEntry(self,dn,suppress_referrals=0): """Returns 1 if entry exists, 0 if NO_SUCH_OBJECT was raised.""" try: self.readEntry(dn,[]) except ldap.INSUFFICIENT_ACCESS: return 1 except ldap.NO_SUCH_OBJECT: return 0 except ldap.PARTIAL_RESULTS: if suppress_referrals: return 0 else: raise else: return 1 def flushCache(self): """Flushes all LDAP cache data""" self.read_cache = {} self.schema_dn_cache = {} self.schema_cache = {} def uncacheEntry(self,dn): """Removes all cached items of entry from cache""" try: del self.read_cache[dn] except KeyError: pass def addEntry(self,dn,modlist): """Add single entry""" self.l.add_s(dn.encode(self.charset),modlist) return def modifyEntry(self,dn,modlist): """Modify single entry""" if modlist: self.uncacheEntry(dn) self.l.modify_s(dn.encode(self.charset),modlist) return # modifyEntry() def renameEntry(self,dn,new_rdn,new_superior=None,delold=1): """Rename an entry""" self.uncacheEntry(dn) old_superior_str = ldaputil.base.ParentDN(ldaputil.base.normalize_dn(dn)) if new_superior!=None: if old_superior_str==ldaputil.base.normalize_dn(new_superior): new_superior_str = None else: new_superior_str = new_superior.encode(self.charset) self.l.rename_s( dn.encode(self.charset),new_rdn.encode(self.charset), new_superior_str,delold ) return u','.join([new_rdn,new_superior or old_superior_str]) # renameEntry() def deleteEntry(self,dn): """Delete single entry""" self.uncacheEntry(dn) self.l.delete_s(dn.encode(self.charset)) return # deleteEntry() def setDN(self,dn): """ Set currently used DN. """ if type(dn)==types.StringType: dn = unicode(dn,self.charset) dn=ldaputil.base.normalize_dn(dn) self._dn = dn self.currentSearchRoot = self.getSearchRoot(self._dn) def bind( self, who,cred,sasl_mech,sasl_authzid,sasl_realm, filtertemplate='(uid=%s)', loginSearchRoot='' ): """ Send BindRequest to LDAP server """ uri = self.uri self.unbind() self._initialize([uri]) if sasl_mech: # Call SASL bind sasl_auth = ldap.sasl.sasl( { ldap.sasl.CB_AUTHNAME:who, ldap.sasl.CB_PASS:cred, ldap.sasl.CB_USER:sasl_authzid, ldap.sasl.CB_GETREALM:sasl_realm, }, sasl_mech ) try: self.l.sasl_interactive_bind_s("",sasl_auth,serverctrls=[]) except AttributeError: raise ldap.LDAPError('SASL not supported by local installation.') else: try: whoami = unicode(self.l.whoami_s(),self.charset) except (ldap.LDAPError,AttributeError): self.who = '[%s]%s' % (sasl_mech,who) else: if whoami.startswith('dn:'): self.who = whoami[3:] else: self.who = whoami elif not who or not cred: # Anonymous bind self.l.simple_bind_s('','') self.who = None else: # Search bind DN by "user name" for simple bind if filtertemplate and who and not ldaputil.base.is_dn(who): who = unicode(ldaputil.base.SmartLogin( self.l, who.encode(self.charset), loginSearchRoot, filtertemplate.encode(self.charset), attrnamesonly=self.onlyAttrTypes, timeout=self.timeout ),self.charset) # Call simple bind self.l.simple_bind_s(who.encode(self.charset),cred.encode(self.charset)) self.who = who # Access to root DSE might have changed after binding # as another entity self.getRootDSE() return def valid(self): """return 1 if connection is valid""" return hasattr(self,'l') def __repr__(self): if hasattr(self,'l'): connection_str = (' LDAPv%d' % (self.l.protocol_version)) else: connection_str = '' return '' % ( connection_str, ','.join( [ '%s:%s' % (a,repr(getattr(self,a))) for a in ['uri','who','dn','onBehalf','startedTLS'] if hasattr(self,a) ] ) )