# -*- coding: utf-8 -*-
"""
ldapsession.py - higher-level class for handling LDAP connections
(c) by Michael Stroeder <michael@stroeder.com>

This module is distributed under the terms of the
GPL (GNU GENERAL PUBLIC LICENSE) Version 2
(see http://www.gnu.org/copyleft/gpl.html)
"""

import sys,socket,time,types,codecs,ldap,ldap.cidict,ldap.resiter,ldaputil.base,ldaputil.schema

try:
  import ldap.sasl
except ImportError:
  pass

from ldap.ldapobject import ReconnectLDAPObject
from ldapurl import LDAPUrl,LDAPUrlExtension
from ldap.schema.models import DITStructureRule
from ldaputil.base import escape_ldap_filter_chars
from ldaputil.extldapurl import ExtendedLDAPUrl
from ldaputil.controls import PreReadControl,PostReadControl,SearchNoOpControl

from ldap.controls import ValueLessRequestControl,AssertionControl
# Authorization Identity Request and Response Controls (see RFC 3829)
try:
  from ldap.controls import AuthorizationIdentityRequestControl,AuthorizationIdentityResponseControl
except ImportError:
  AuthorizationIdentityRequestControl = AuthorizationIdentityResponseControl = None
from ldap.controls.ppolicy import PasswordPolicyControl
from ldap.controls.sessiontrack import SessionTrackingControl,SESSION_TRACKING_FORMAT_OID_USERNAME

try:
  from ldap.schema.subentry import SubschemaError
except:
  SubschemaError = None


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.1.22' # RFC 6171
CONTROL_LDUP_SUBENTRIES = '1.3.6.1.4.1.7628.5.101.1' # draft-ietf-ldup-subentry-07.txt
CONTROL_SUBENTRIES = '1.3.6.1.4.1.4203.1.10.1' # RFC 3672
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
CONTROL_SERVERADMINISTRATION = '1.3.18.0.2.10.15' # IBM Directory Server
CONTROL_TREEDELETE = '1.2.840.113556.1.4.805' # draft-armijo-ldap-treedelete

PASSWORD_SASL_MECHS = set(('CRAM-MD5','PLAIN','DIGEST-MD5','NTLM','OTP','SCRAM-SHA-1'))

# Used attributes from RootDSE
ROOTDSE_ATTRS = (
  'objectClass',
  'altServer',
  'namingContexts',
  'ogSupportedProfile',
  'subschemaSubentry',
  'supportedControl',
  'supportedExtension',
  'supportedFeatures',
  'supportedLDAPVersion',
  'supportedSASLMechanisms',
  'supportedAuthPasswordSchemes', # RFC 3112
  'vendorName',
  'vendorVersion',
  # 'informational' attributes of OpenDS/OpenDJ
  'ds-private-naming-contexts',
  # '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',
  # see draft-good-ldap-changelog
  'changelog',
)


# Attributes to be read from user's entry
USER_ENTRY_ATTRIBUTES = (
  '*',
  'uid',
  'uidNumber',
  'gidNumber',
  'cn',
  'displayName',
  'sAMAccountName',
  'userPrincipalName',
  'employeeNumber',
  'employeeID',
  'preferredLanguage',
  'objectClass',
  'pwdExpire',
  'pwdLastSet',
  'badPasswordTime',
  'badPwdCount',
  'lastLogin',
  'shadowLastChange',
  'sambaPwdLastSet',
)


READ_CACHE_EXPIRE = 120

LDAPLimitErrors = (ldap.TIMEOUT,ldap.TIMELIMIT_EXCEEDED,ldap.SIZELIMIT_EXCEEDED,ldap.ADMINLIMIT_EXCEEDED)

COUNT_TIMEOUT = 5.0


class MyLDAPObject(ReconnectLDAPObject,ldap.resiter.ResultProcessor):

  cache_time = 6.0

  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':[],
    }
    self.flush_cache()
    ReconnectLDAPObject.__init__(self,uri,trace_level,trace_file,trace_stack_limit,retry_max=20,retry_delay=3.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 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 simple_bind(self,who='',cred='',serverctrls=None,clientctrls=None):
    self.flush_cache()
    return ReconnectLDAPObject.simple_bind(self,who,cred,serverctrls,clientctrls)

  def bind(self,who,cred,method=ldap.AUTH_SIMPLE):
    self.flush_cache()
    return ReconnectLDAPObject.bind(self,who,cred,method)

  def sasl_interactive_bind_s(self,who,auth,serverctrls=None,clientctrls=None,sasl_flags=ldap.SASL_QUIET):
    self.flush_cache()
    return ReconnectLDAPObject.sasl_interactive_bind_s(self,who,auth,serverctrls,clientctrls,sasl_flags)

  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 search_ext_s(self,base,scope,filterstr='(objectClass=*)',attrlist=None,attrsonly=0,serverctrls=None,clientctrls=None,timeout=-1,sizelimit=0,nocache=0):
    current_time = time.time()
    cache_key_args = (base,scope,filterstr,tuple(attrlist or []),attrsonly,tuple(serverctrls or []),tuple(clientctrls or []),timeout,sizelimit)
    try:
      result_cache_time,result = self._cache[cache_key_args]
    except KeyError:
      result = None
    else:
      if nocache or current_time>result_cache_time:
        del self._cache[cache_key_args]
        result = None
      else:
        self._cache_hits+=1
    if result is None:
      self._cache_misses+=1
      result = ReconnectLDAPObject.search_ext_s(self,base,scope,filterstr,attrlist,attrsonly,serverctrls,clientctrls,timeout,sizelimit)
      self._cache[cache_key_args] = (current_time+self.cache_time,result)
    return result # search_ext_s()

  def noop_search_st(self,base,scope=ldap.SCOPE_SUBTREE,filterstr='(objectClass=*)',timeout=-1):
    try:
      msg_id = self.search_ext(
        base,
        scope,
        filterstr=filterstr,
        attrlist=['1.1'],
        timeout=timeout,
        serverctrls=[SearchNoOpControl()],
      )
      _,_,_,search_response_ctrls = self.result3(msg_id,all=1,timeout=timeout)
    except LDAPLimitErrors,e:
      self.abandon(msg_id)
      raise e
    else:
      noop_srch_ctrl = [
        c
        for c in search_response_ctrls
        if c.controlType==SearchNoOpControl.controlType
      ]
      if noop_srch_ctrl:
        return noop_srch_ctrl[0].numSearchResults,noop_srch_ctrl[0].numSearchContinuations
      else:
        return (None,None)

  def get_cache_hit_ratio(self):
    """
    Returns percentage of cache hit ratio
    """
    try:
      return float(self._cache_hits)/float(self._cache_hits+self._cache_misses)*100
    except ZeroDivisionError:
      return None

  def unbind_ext(self,serverctrls=None,clientctrls=None):
    return ReconnectLDAPObject.unbind_ext(self,
      (serverctrls or [])+self._serverctrls['**all**']+self._serverctrls['unbind_ext'],
      clientctrls
    )

  def flush_cache(self):
    self._cache_misses = 0
    self._cache_hits = 0
    self._cache = {}

  def uncache_entry(self,dn):
    dn = dn.lower()
    for cache_key_args,cached_results in self._cache.items():
      if cache_key_args[0].lower()==dn:
        del self._cache[cache_key_args]
      else:
        for cached_dn,cached_entry in cached_results[1]:
          if not cached_dn is None and cached_dn.lower()==dn:
            del self._cache[cache_key_args]
            break # stop looping through results


class LDAPSessionException(ldap.LDAPError):
  """
  Base exception class raised within this module
  """
  def __str__(self):
    return self.args[0]['desc']

class PasswordPolicyException(LDAPSessionException):

  def __init__(self,who=None,desc=None):
    self.who = who
    self.desc = desc

  def __str__(self):
    return self.desc

class PWD_CHANGE_AFTER_RESET(PasswordPolicyException):
  pass

class PWD_EXPIRATION_WARNING(PasswordPolicyException):
  def __init__(self,who=None,desc=None,timeBeforeExpiration=None):
    PasswordPolicyException.__init__(self,who,desc)
    self.timeBeforeExpiration = timeBeforeExpiration

class PWD_EXPIRED(PasswordPolicyException):
  def __init__(self,who=None,desc=None,graceAuthNsRemaining=None):
    PasswordPolicyException.__init__(self,who,desc)
    self.graceAuthNsRemaining = graceAuthNsRemaining

class USERNAME_NOT_FOUND(LDAPSessionException):
  """
  Simple exception class raised when searchBindDN() does not
  find any entry matching search
  """

class USERNAME_NOT_UNIQUE(LDAPSessionException):
  """
  Simple exception class raised when searchBindDN() does not
  find more than one entry matching search
  """


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.namingContexts = None
    self._auditContextCache = ldap.cidict.cidict()
    self._traceLevel = traceLevel
    self._traceFile = traceFile or sys.stdout
    # Character set/encoding of data stored on this particular host
    self.charset = 'utf-8'
    conn_codec = codecs.lookup(self.charset)
    self.uc_encode,self.uc_decode = conn_codec[0],conn_codec[1]
    # This is a dictionary for storing arbitrary objects
    # tied to a LDAP session
    self.rootDSE = ldap.cidict.cidict()
    self.secureConn = 0
    self.saslAuth = None
    self.startTLSOption = 0
    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
    self.connStartTime = None
    self.setDN(u'')
    return # __init__()

  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 not self.uri.lower().startswith('ldapi:') and 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_REQUIRE_CERT,ldap.OPT_X_TLS_DEMAND),
        (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:
          self.l.set_option(ldap_opt,ldap_opt_value)
      self.l.set_option(ldap.OPT_X_TLS_NEWCTX,0)
    return # setTLSOptions()

  def startTLSExtOp(self,startTLSOption):
    """StartTLS if possible and requested"""
    self.secureConn = 0
    self.startTLSOption = 0
    if startTLSOption:
      try:
        self.l.start_tls_s()
      except AttributeError,e:
        raise ldap.SERVER_DOWN({
          'desc':str(e),
          'info':'python-ldap installation is lacking StartTLS support'
        })
      except (
        ldap.UNAVAILABLE,
        ldap.CONNECT_ERROR,
        ldap.PROTOCOL_ERROR,
        ldap.INSUFFICIENT_ACCESS,
        ldap.SERVER_DOWN,    # Rely on ReconnectLDAPObject to catch up later
      ),e:
        if startTLSOption>1:
          self.unbind()
          raise e
      else:
        self.startTLSOption = 2
        self.secureConn = 1
    return # startTLSExtOp()

  def _initialize(self,
    uri_list,
    # Options for verifying SSL server certificate
    tls_cacertdir='',tls_cacertfile='',
    # Options for deploying client certificates for strong authentication
    tls_certfile='',tls_keyfile='',
  ):
    while uri_list:
      uri = uri_list[0].strip().encode('ascii')
      # Try connecting to LDAP host
      try:
        self.l = MyLDAPObject(
          uri,
          trace_level=self._traceLevel,
          trace_file=self._traceFile,
        )
        self.uri = uri
        self.setTLSOptions(tls_cacertdir,tls_cacertfile,tls_certfile,tls_keyfile)
        self.l.set_option(ldap.OPT_NETWORK_TIMEOUT,self.timeout)
        self.who = None
      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,env,enableSessionTracking,
    # 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._initialize(uri_list,tls_cacertdir,tls_cacertfile,tls_certfile,tls_keyfile)
    if enableSessionTracking:
      session_tracking_ctrl = SessionTrackingControl(
        self.onBehalf,
        ':'.join((
          env.get('HTTP_HOST',env.get('SERVER_NAME',socket.getfqdn())),
          env['SERVER_PORT'],
        )),
        SESSION_TRACKING_FORMAT_OID_USERNAME,
        hex(hash(self.l)),
      )
      self.l.add_server_control('**all**',session_tracking_ctrl)
    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
    self.connStartTime = time.time()
    return # open()

  def unbind(self):
    """Close LDAP connection object if necessary"""
    try:
      self.l.unbind_s()
      del self.l
    except ldap.LDAPError:
      pass
    except AttributeError:
      pass
    self.uri = None # delete the LDAP connection URI
    # Flush old data from cache
    self.flushCache()
    return # unbind()

  def _forgetRootDSEAttrs(self):
    """Forget all old RootDSE values"""
    self.rootDSE = ldap.cidict.cidict()
    self.supportsAllOpAttr = 0
    self.namingContexts = None
    self._auditContextCache = ldap.cidict.cidict()

  def _setRootDSEAttrs(self):
    """Derive some class attributes from rootDSE attributes"""
    self.namingContexts = set([])
    self.namingContexts.update([
      unicode({'\x00':''}.get(v,v),self.charset)
      for v in self.rootDSE.get('namingContexts',[])
    ])
    for rootdse_naming_attrtype in ('configContext','monitorContext','ds-private-naming-contexts'):
      self.namingContexts.update([
        unicode(v,self.charset) for v in self.rootDSE.get(rootdse_naming_attrtype,[])
      ])
    for attr_type in (
      'supportedLDAPVersion','supportedControl','supportedExtension',
      'supportedFeatures','supportedSASLMechanisms',
    ):
      setattr(self,attr_type,frozenset(self.rootDSE.get(attr_type,[])))
    for attr_type in ('vendorName','vendorVersion'):
      setattr(self,attr_type,self.rootDSE.get(attr_type,[None])[0])
    self.supportsAllOpAttr = \
      ('1.3.6.1.4.1.4203.1.5.1' in self.supportedFeatures) or \
      ('OpenLDAProotDSE' in self.rootDSE.get('objectClass',[]))
    # Speed up sub schema sub entry retrieval by pre-filling cache
    # with what is likely the sub schema for whole DIT
    try:
      schema_dn = self.rootDSE['subschemaSubEntry'][0]
    except KeyError:
      self.schema_dn_cache[u''] = None
    else:
      self.schema_dn_cache[u''] = unicode(schema_dn,self.charset)
      self.retrieveSubSchema(u'',None)
    return # _setRootDSEAttrs()

  def getRootDSE(self):
    """Retrieve attributes from Root DSE"""
    self._forgetRootDSEAttrs()
    self.rootDSE = ldap.cidict.cidict()
    try:
      ldap_result = self.readEntry('',ROOTDSE_ATTRS)
    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.CONSTRAINT_VIOLATION,
            ldap.INAPPROPRIATE_AUTH,
            ldap.CONFIDENTIALITY_REQUIRED),e:
      pass
    else:
      # Copy special rootDSE attributes to object attributes
      for attr_type,attr_values in (ldap_result or [('',{})])[0][1].items():
        self.rootDSE[attr_type] = attr_values
    self._setRootDSEAttrs()
    return # getRootDSE()

  def getSearchRoot(self,dn):
    """
    Returns the namingContexts value matching best the
    distinguished name given in dn
    """
    if self.namingContexts is None and hasattr(self,'l'):
      self.getRootDSE()
    return ldaputil.base.match_dnlist(dn,self.namingContexts)

  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
    ldap_result = self.l.search_ext_s(
      self.uc_encode(dn)[0],
      ldap.SCOPE_BASE,'(objectClass=*)',
      subordinate_attrs,
      timeout=self.timeout
    )
    hasSubordinates = None; numSubordinates = None; numAllSubordinates = None
    if ldap_result:
      entry = ldap.cidict.cidict(ldap_result[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:
        if numSubordinates!=None:
          ldap_result = self.l.search_ext_s(
            self.uc_encode(dn)[0],
            ldap.SCOPE_SUBTREE,
            '(numSubordinates>=1)',
            attrlist=['numSubordinates'],
            timeout=COUNT_TIMEOUT
          )
          numAllSubordinates = 0
          for ldap_dn,ldap_entry in ldap_result:
            numAllSubordinates += int(ldap_entry['numSubordinates'][0])
      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(
        self.uc_encode(dn)[0],
        ldap.SCOPE_ONELEVEL,'(objectClass=*)',
        ['objectClass'],self.onlyAttrTypes,timeout=self.timeout,sizelimit=1
      )
      ldap_result = (None,None)
      while ldap_result==(None,None):
        ldap_result = self.l.result(ldap_msgid,0,self.timeout)
      self.l.abandon(ldap_msgid)
      hasSubordinates = len(ldap_result)>0

    if SearchNoOpControl.controlType in self.rootDSE.get('supportedControl',[]):
      if not numSubordinates:
        try:
          numSubordinates,_ = self.l.noop_search_st(
            self.uc_encode(dn)[0],
            ldap.SCOPE_ONELEVEL,
            timeout=COUNT_TIMEOUT,
          )
        except LDAPLimitErrors,e:
          pass
      if not numAllSubordinates:
        try:
          numAllSubordinates,_ = self.l.noop_search_st(
            self.uc_encode(dn)[0],
            ldap.SCOPE_SUBTREE,
            timeout=COUNT_TIMEOUT,
          )
        except LDAPLimitErrors,e:
          pass

    return (hasSubordinates,numSubordinates,numAllSubordinates)

  def getObjectClasses(self,dn):
    """
    Returns a 2-tuple with the list of object classes associated with an entry
    and the structural object class
    """
    try:
      search_result = self.readEntry(dn,['objectClass','structuralObjectClass'])
    except ldap.NO_SUCH_ATTRIBUTE:
      search_result = self.readEntry(dn,['objectClass'])
    except (ldap.INSUFFICIENT_ACCESS,ldap.UNWILLING_TO_PERFORM),e:
      return [],None
    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
      try:
        subschemasubentry_dn = self.l.search_subschemasubentry_s(self.uc_encode(dn)[0])
      except ldap.LDAPError:
        subschemasubentry_dn = None
      if subschemasubentry_dn is None:
        try:
          subschemasubentry_dn = self.l.search_subschemasubentry_s('')
        except ldap.LDAPError:
          subschemasubentry_dn = None
      # 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<ldap.VERSION3 or dn is None:
      # LDAPv2 gets back default schema
      return default
    else:
      subschemasubentry_dn = self.searchSubSchemaEntryDN(dn)
      if subschemasubentry_dn is None:
        # No sub schema sub entry found => 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(
            self.uc_encode(subschemasubentry_dn)[0],
            ldap.schema.SCHEMA_ATTRS
          )
        except ldap.LDAPError:
          sub_schema = None
        else:
          if subschemasubentry is None:
            sub_schema = None
          else:
            # Parse the schema
            try:
              sub_schema = ldaputil.schema.SubSchema(subschemasubentry,self.uc_encode(subschemasubentry_dn)[0])
            except SubschemaError:
              sub_schema = None
        # 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=False,
    server_ctrls=None,
  ):
    """Read a single entry"""
    if attrtype_list==['*']:
      attrtype_list = None
    # Read single entry from LDAP server
    search_result = self.l.search_ext_s(
      self.uc_encode(dn)[0],
      ldap.SCOPE_BASE,
      search_filter,
      attrlist=attrtype_list,
      attrsonly=only_attrtypes,
      timeout=self.timeout,
      nocache=no_cache,
      serverctrls=server_ctrls,
    )
    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 True
    except ldap.NO_SUCH_OBJECT:
      return False
    except ldap.PARTIAL_RESULTS:
      if suppress_referrals:
        return False
      else:
        raise
    else:
      return True

  def flushCache(self):
    """Flushes all LDAP cache data"""
    self.schema_dn_cache = {}
    self.schema_cache = {}
    try:
      self.l.flush_cache()
    except AttributeError:
      pass

  def modifyEntry(self,dn,modlist,serverctrls=None,assertion_filter=None):
    """Modify single entry"""
    serverctrls = serverctrls or []
    if AssertionControl.controlType in self.supportedControl and \
       not 'OpenLDAProotDSE' in self.rootDSE['objectClass'] and \
       assertion_filter:
        serverctrls.append(AssertionControl(False,assertion_filter))
    if modlist:
      self.l.uncache_entry(self.uc_encode(dn)[0])
      self.l.modify_ext_s(
        self.uc_encode(dn)[0],
        modlist,
        serverctrls=serverctrls
      )
    return # modifyEntry()

  def renameEntry(self,dn,new_rdn,new_superior=None,delold=1):
    """Rename an entry"""
    self.l.uncache_entry(dn.encode(self.charset))
    if not new_superior is None:
      self.l.uncache_entry(new_superior.encode(self.charset))
    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 = self.uc_encode(new_superior)[0]
    rename_serverctrls = []
    if PreReadControl.controlType in self.supportedControl:
      rename_serverctrls.append(PreReadControl(criticality=False,attrList=['entryUUID']))
    if PostReadControl.controlType in self.supportedControl:
      rename_serverctrls.append(PostReadControl(criticality=False,attrList=['entryUUID']))
    rename_serverctrls = rename_serverctrls or None
    # Send ModRDNRequest
    rename_msg_id = self.l.rename(
      self.uc_encode(dn)[0],
      self.uc_encode(new_rdn)[0],
      new_superior_str,
      delold,
      serverctrls=rename_serverctrls
    )
    # Receive result
    _,_,_,rename_resp_ctrls = self.l.result3(rename_msg_id)
    # Try to extract Read Entry controls from response
    prec_ctrls = dict([
      (c.controlType,c)
      for c in rename_resp_ctrls or []
      if c.controlType in (PreReadControl.controlType,PostReadControl.controlType)
    ])
    if prec_ctrls:
      new_dn = self.uc_decode(prec_ctrls[PostReadControl.controlType].dn)[0]
      try:
        entry_uuid = self.uc_decode(prec_ctrls[PreReadControl.controlType].entry['entryUUID'][0])[0]
      except (IndexError,KeyError),e:
        entry_uuid = None
    else:
      new_dn = u','.join([new_rdn,new_superior or old_superior_str])
      entry_uuid = None
    return new_dn,entry_uuid # renameEntry()

  def deleteEntry(self,dn,serverctrls=None):
    """Delete single entry"""
    self.l.uncache_entry(dn.encode(self.charset))
    self.l.delete_ext_s(self.uc_encode(dn)[0],serverctrls=serverctrls)
    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(dn)
    return # setDN()

  def getAuditContext(self,search_root_dn):
    try:
      audit_context_dn = self._auditContextCache[search_root_dn]
    except KeyError:
      try:
        result = self.readEntry(search_root_dn,['auditContext'])
      except AttributeError:
        audit_context_dn = None
      except ldap.LDAPError:
        audit_context_dn = None
        self._auditContextCache[search_root_dn] = audit_context_dn
      else:
        if result:
          try:
            audit_context_dn = unicode(ldap.cidict.cidict(result[0][1])['auditContext'][0],self.charset)
          except KeyError:
            audit_context_dn = None
        else:
          audit_context_dn = None
        self._auditContextCache[search_root_dn] = audit_context_dn
    return audit_context_dn # getAuditContext()

  def searchBindDN(
    self,
    username='',              # User name or complete bind DN (UTF-8 encoded)
    searchroot='',            # search root for user entry search
    filtertemplate='(uid=%s)',# template string for LDAP filter
    scope=ldap.SCOPE_SUBTREE, # search scope
    attrnamesonly=1,          # retrieve only attribute names when searching
    timeout=-1
  ):
    """
    Search for a bind DN.

    If username is a valid DN it's used as bind-DN without further action.
    Otherwise filtertemplate is used to construct a LDAP search filter
    containing username instead of %s.
    The calling application has to handle all possible exceptions:
    ldap.NO_SUCH_OBJECT, ldap.FILTER_ERROR, ldapbase.USERNAME_NOT_UNIQUE
    ldap.INVALID_CREDENTIALS, ldap.INAPPROPRIATE_AUTH
    """
    if not username:
      return u''
    elif ldaputil.base.is_dn(username):
      return ldaputil.base.normalize_dn(username)
    else:
      username_filter_escaped = escape_ldap_filter_chars(username)
      searchfilter = filtertemplate.replace('%s',username_filter_escaped)
      if searchroot is None:
        searchroot = u''
      # Try to find a unique entry with filtertemplate
      try:
        result = self.l.search_ext_s(
          self.uc_encode(searchroot)[0],
          scope,
          self.uc_encode(searchfilter)[0],
          attrlist=['objectclass'],
          attrsonly=attrnamesonly,
          timeout=self.timeout,
          sizelimit=2
        )
      except ldap.NO_SUCH_OBJECT:
        raise USERNAME_NOT_FOUND({'desc':'Smart login did not find a matching user entry.'})
      else:
        # Ignore search continuations in search result list
        result = [r for r in result if r[0]!=None]
        if not result:
          raise USERNAME_NOT_FOUND({'desc':'Smart login did not find a matching user entry.'})
        elif len(result)!=1:
          raise USERNAME_NOT_UNIQUE({'desc':'More than one matching user entries.'})
        else:
          return ldaputil.base.normalize_dn(unicode(result[0][0],self.charset))

  def bind(
    self,
    who,cred,sasl_mech,sasl_authzid,sasl_realm,
    binddn_filtertemplate=u'(uid=%s)',
    whoami_filtertemplate=u'(uid=%s)',
    loginSearchRoot=''
  ):
    """
    Send BindRequest to LDAP server
    """
    # Flush old data from cache
    self.flushCache()
    uri = self.uri
    # Drop the bind call sent before stored in ReconnectLDAPObject's class attribute
    self.l._last_bind = None
    # Force reconnecting in ReconnectLDAPObject
    self.l.reconnect(uri)
    # Prepare extended controls attached to bind request
    bind_server_ctrls = []
    # Authorization Identity Request and Response Controls (RFC 3829)
#    if AuthorizationIdentityRequestControl:
#      bind_server_ctrls.append(AuthorizationIdentityRequestControl(0))
    # Password Policy Control (draft-behera-ldap-password-policy)
    bind_server_ctrls.append(PasswordPolicyControl())

    if sasl_mech:

      # SASL bind
      #-------------------------------

      sasl_auth = ldap.sasl.sasl(
        {
          ldap.sasl.CB_AUTHNAME:(who or u'').encode(self.charset),
          ldap.sasl.CB_PASS:(cred or u'').encode(self.charset),
          ldap.sasl.CB_USER:(sasl_authzid or u'').encode(self.charset),
          ldap.sasl.CB_GETREALM:(sasl_realm or u'').encode(self.charset),
        },
        sasl_mech
      )
      if ldap.SASL_AVAIL:
        self.l.sasl_interactive_bind_s("",sasl_auth,serverctrls=bind_server_ctrls)
        self.saslAuth = sasl_auth
        # Don't store the password
        try:
          del self.saslAuth.cb_value_dict[ldap.sasl.CB_PASS]
        except KeyError:
          pass
      else:
        raise ldap.LDAPError('SASL not supported by local installation.')

      bind_srv_ctrls = []

    else:

      # Simple bind
      #-------------------------------

      self.saslAuth = None
      if not who or not cred:
        # Anonymous bind
        who = None; cred = None
      else:
        # Search bind DN by "user name" for simple bind
        if binddn_filtertemplate and who and not ldaputil.base.is_dn(who):
          who = self.searchBindDN(
            who,
            loginSearchRoot or unicode(self.rootDSE.get('defaultNamingContext',[''])[0],self.charset) or u'',
            binddn_filtertemplate,
            attrnamesonly=self.onlyAttrTypes,
          )

      # Call simple bind
      try:
        _,_,_,bind_srv_ctrls = self.l.simple_bind_s(
          self.uc_encode(who or u'')[0],
          self.uc_encode(cred or u'')[0],
          serverctrls=bind_server_ctrls
        )
      except ldap.LDAPError,e:
        # Explicitly fall back to anonymous bind before re-raising exception
        self.l.simple_bind_s('','')
        self.who = None
        raise e
      else:
        self.who = who

    if AuthorizationIdentityResponseControl and bind_srv_ctrls:
      authz_identity_ctrls = [
        c for c in bind_srv_ctrls if c.controlType==AuthorizationIdentityResponseControl.controlType
      ]
      if authz_identity_ctrls:
        authz_id = authz_identity_ctrls[0].authzId.decode(self.charset)
        if authz_id.startswith('dn:'):
          self.who = authz_id[3:]
        else:
          self.who = authz_id

    # Determine identity by sending LDAPv3 Who Am I? extended operation
    try:
      whoami = unicode(self.l.whoami_s(),self.charset)
    except ldap.LDAPError,e:
      if who:
        self.who = u'u:%s' % (who)
      else:
        self.who = None
    else:
      if whoami.startswith('dn:'):
        self.who = whoami[3:]
      else:
        self.who = whoami

    # Access to root DSE might have changed after binding
    # as another entity
    self.getRootDSE()

    # Try to look up the user entry's DN in case self.who is still not a DN
    if whoami_filtertemplate and \
       self.who and \
       not ldaputil.base.is_dn(self.who):
      if self.saslAuth and self.saslAuth.mech in ('EXTERNAL','GSSAPI'):
        # For SASL mechs EXTERNAL and GSSAPI the user did not enter a SASL username
        # => try to determine it through OpenLDAP's libldap
        try:
          # Check whether python-ldap has support for ldap.OPT_X_SASL_USERNAME
          ldap.OPT_X_SASL_USERNAME
        except AttributeError:
          # Constant ldap.OPT_X_SASL_USERNAME not available in python-ldap => ignore
          pass
        else:
          # Ask libldap for SASL username for later LDAP search
          who = unicode(self.l.get_option(ldap.OPT_X_SASL_USERNAME),self.charset)

      # Search for a user entry which matches the username known so far
      try:
        self.who = self.searchBindDN(
          who,
          loginSearchRoot or unicode(self.rootDSE.get('defaultNamingContext',[''])[0],self.charset) or u'',
          whoami_filtertemplate,
          attrnamesonly=self.onlyAttrTypes,
        )
      except (ldap.LDAPError,USERNAME_NOT_FOUND,USERNAME_NOT_UNIQUE):
        pass

    # Read the user's entry if self.who is a DN to get name and preferences
    if self.who and ldaputil.base.is_dn(self.who):
      try:
        userEntryDN,self.userEntry = self.readEntry(
          self.who,
          attrtype_list=USER_ENTRY_ATTRIBUTES,
          search_filter='(objectClass=*)',
          no_cache=1
        )[0]
      except (ldap.LDAPError,IndexError):
        self.userEntry = {}
      else:
        self.who = userEntryDN.decode(self.charset)
    else:
      self.userEntry = {}

    # Extract the password policy response control and raise exceptions
    if bind_srv_ctrls:
      ppolicy_ctrls = [
        c for c in bind_srv_ctrls if c.controlType==PasswordPolicyControl.controlType
      ]
      if ppolicy_ctrls and len(ppolicy_ctrls)==1:
        ppolicy_ctrl = ppolicy_ctrls[0]
        if ppolicy_ctrl.error==2:
          raise PWD_CHANGE_AFTER_RESET(
            who=self.who.encode(self.charset),
            desc='Password change is needed after reset!',
          )
        elif ppolicy_ctrl.timeBeforeExpiration!=None:
          raise PWD_EXPIRATION_WARNING(
            who=self.who.encode(self.charset),
            desc='Password will expire in %d seconds!' % (ppolicy_ctrl.timeBeforeExpiration),
            timeBeforeExpiration=ppolicy_ctrl.timeBeforeExpiration,
          )
        elif ppolicy_ctrl.graceAuthNsRemaining!=None:
          raise PWD_EXPIRED(
            who=self.who.encode(self.charset),
            desc='Password expired! %d grace logins left.' % (ppolicy_ctrl.graceAuthNsRemaining),
            graceAuthNsRemaining=ppolicy_ctrl.graceAuthNsRemaining,
          )

    return # bind()

  def getGoverningStructureRule(self,dn,schema):
    """
    Determine the governing structure rule for the entry specified with dn
    in the subschema specified in argument schema
    """
    governing_structure_rule = None
    try:
      search_result = self.readEntry(
        dn,
        [
          'objectClass','structuralObjectClass','governingStructureRule',
          'subschemaSubentry','administrativeRole'
        ]
      )
    except ldap.NO_SUCH_OBJECT:
      # Probably we reached root of current naming context
      pass
    else:
      if search_result:
        entry = ldaputil.schema.Entry(schema,dn,search_result[0][1])
        try:
          # Try to directly read the governing structure rule ID from operational attribute in entry
          governing_structure_rule = entry['governingStructureRule'][0]
        except KeyError:
          possible_dit_structure_rules = {}.fromkeys((
            entry.get_possible_dit_structure_rules(self.uc_encode(dn)[0]) or []
          ))
          parent_dn = ldaputil.base.ParentDN(dn)
          administrative_roles = entry.get('administrativeRole',[])
          if 'subschemaAdminSpecificArea' in administrative_roles or \
             not parent_dn:
            # If the current entry is a subschema administrative point all
            # DIT structure rule with a SUP clause have to be sorted out
            for dit_structure_rule_id in possible_dit_structure_rules.keys():
              dit_structure_rule_obj = schema.get_obj(DITStructureRule,dit_structure_rule_id)
              if dit_structure_rule_obj.sup:
                del possible_dit_structure_rules[dit_structure_rule_id]
          dit_structure_rules = possible_dit_structure_rules.keys()
          if not dit_structure_rules:
            return None
          elif len(dit_structure_rules)==1:
            governing_structure_rule = dit_structure_rules[0]
          else:
            # More than one possible DIT structure rule found
            if parent_dn:
              parent_governing_structure_rule = self.getGoverningStructureRule(parent_dn,schema)
              if not parent_governing_structure_rule is None:
                subord_structural_rules,subord_structural_oc_names = schema.get_subord_structural_oc_names(parent_governing_structure_rule)
                dit_structure_rules = list(set(subord_structural_rules).intersection(dit_structure_rules))
                if len(dit_structure_rules)==1:
                  governing_structure_rule = dit_structure_rules[0]
    return governing_structure_rule # getGoverningStructureRule()

  def ldapUrl(self,dn):
    if self.uri:
      lu = ExtendedLDAPUrl(ldapUrl=self.uri.encode('ascii'))
      lu.dn = dn.encode(self.charset)
      if self.startTLSOption:
        lu.x_startTLS = str(START_TLS_REQUIRED * (self.startTLSOption>0))
      if self.saslAuth:
        lu.saslMech = self.saslAuth.mech.encode('ascii')
        if self.saslAuth.mech in PASSWORD_SASL_MECHS:
          lu.who = self.saslAuth.cb_value_dict.get(ldap.sasl.CB_AUTHNAME,u'').encode(self.charset) or None
      else:
        lu.who = (self.who or u'').encode(self.charset) or None
      return lu # ConnLDAPUrl()
    else:
      return None

  def __repr__(self):
    try:
      connection_str = (' LDAPv%d' % (self.l.protocol_version))
    except AttributeError:
      connection_str = ''
    return '<LDAPSession%s:%s>' % (
      connection_str,
      ','.join(
        [
          '%s:%s' % (a,repr(getattr(self,a)))
          for a in ['uri','who','dn','onBehalf','startedTLS']
          if hasattr(self,a)
        ]
      )
    )
