server/sources/ldapuser.py
changeset 257 4c7d3af7e94d
child 938 a69188963ccb
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/server/sources/ldapuser.py	Mon Dec 22 17:34:15 2008 +0100
@@ -0,0 +1,685 @@
+"""cubicweb ldap user source
+
+this source is for now limited to a read-only EUser source
+
+:organization: Logilab
+:copyright: 2003-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+
+
+Part of the code is coming form Zope's LDAPUserFolder
+
+Copyright (c) 2004 Jens Vagelpohl.
+All Rights Reserved.
+
+This software is subject to the provisions of the Zope Public License,
+Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+FOR A PARTICULAR PURPOSE.
+"""
+
+from mx.DateTime import now, DateTimeDelta
+
+from logilab.common.textutils import get_csv
+from rql.nodes import Relation, VariableRef, Constant, Function
+
+import ldap
+from ldap.ldapobject import ReconnectLDAPObject
+from ldap.filter import filter_format, escape_filter_chars
+from ldapurl import LDAPUrl
+
+from cubicweb.common import AuthenticationError, UnknownEid, RepositoryError
+from cubicweb.server.sources import AbstractSource, TrFunc, GlobTrFunc, ConnectionWrapper
+from cubicweb.server.utils import cartesian_product
+
+# search scopes
+BASE = ldap.SCOPE_BASE
+ONELEVEL = ldap.SCOPE_ONELEVEL
+SUBTREE = ldap.SCOPE_SUBTREE
+
+# XXX only for edition ??
+## password encryption possibilities
+#ENCRYPTIONS = ('SHA', 'CRYPT', 'MD5', 'CLEAR') # , 'SSHA'
+
+# mode identifier : (port, protocol)
+MODES = {
+    0: (389, 'ldap'),
+    1: (636, 'ldaps'),
+    2: (0,   'ldapi'),
+    }
+
+class TimedCache(dict):
+    def __init__(self, ttlm, ttls=0):
+        # time to live in minutes
+        self.ttl = DateTimeDelta(0, 0, ttlm, ttls)
+        
+    def __setitem__(self, key, value):
+        dict.__setitem__(self, key, (now(), value))
+        
+    def __getitem__(self, key):
+        return dict.__getitem__(self, key)[1]
+    
+    def clear_expired(self):
+        now_ = now()
+        ttl = self.ttl
+        for key, (timestamp, value) in self.items():
+            if now_ - timestamp > ttl:
+                del self[key]
+                
+class LDAPUserSource(AbstractSource):
+    """LDAP read-only EUser source"""
+    support_entities = {'EUser': False} 
+
+    port = None
+    
+    cnx_mode = 0
+    cnx_dn = ''
+    cnx_pwd = ''
+    
+    options = (
+        ('host',
+         {'type' : 'string',
+          'default': 'ldap',
+          'help': 'ldap host',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+        ('user-base-dn',
+         {'type' : 'string',
+          'default': 'ou=People,dc=logilab,dc=fr',
+          'help': 'base DN to lookup for users',
+          'group': 'ldap-source', 'inputlevel': 0,
+          }),
+        ('user-scope',
+         {'type' : 'choice',
+          'default': 'ONELEVEL',
+          'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
+          'help': 'user search scope',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+        ('user-classes',
+         {'type' : 'csv',
+          'default': ('top', 'posixAccount'),
+          'help': 'classes of user',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+        ('user-login-attr',
+         {'type' : 'string',
+          'default': 'uid',
+          'help': 'attribute used as login on authentication',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+        ('user-default-group',
+         {'type' : 'csv',
+          'default': ('users',),
+          'help': 'name of a group in which ldap users will be by default. \
+You can set multiple groups by separating them by a comma.',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+        ('user-attrs-map',
+         {'type' : 'named',
+          'default': {'uid': 'login', 'gecos': 'email'},
+          'help': 'map from ldap user attributes to cubicweb attributes',
+          'group': 'ldap-source', 'inputlevel': 1,
+          }),
+
+        ('synchronization-interval',
+         {'type' : 'int',
+          'default': 24*60*60,
+          'help': 'interval between synchronization with the ldap \
+directory (default to once a day).',
+          'group': 'ldap-source', 'inputlevel': 2,
+          }),
+        ('cache-life-time',
+         {'type' : 'int',
+          'default': 2*60,
+          'help': 'life time of query cache in minutes (default to two hours).',
+          'group': 'ldap-source', 'inputlevel': 2,
+          }),
+        
+    )
+            
+    def __init__(self, repo, appschema, source_config, *args, **kwargs):
+        AbstractSource.__init__(self, repo, appschema, source_config,
+                                *args, **kwargs)
+        self.host = source_config['host']
+        self.user_base_dn = source_config['user-base-dn']
+        self.user_base_scope = globals()[source_config['user-scope']]
+        self.user_classes = get_csv(source_config['user-classes'])
+        self.user_login_attr = source_config['user-login-attr']
+        self.user_default_groups = get_csv(source_config['user-default-group'])
+        self.user_attrs = dict(v.split(':', 1) for v in get_csv(source_config['user-attrs-map']))
+        self.user_rev_attrs = {'eid': 'dn'}
+        for ldapattr, cwattr in self.user_attrs.items():
+            self.user_rev_attrs[cwattr] = ldapattr
+        self.base_filters = [filter_format('(%s=%s)', ('objectClass', o))
+                              for o in self.user_classes]
+        self._conn = None
+        self._cache = {}
+        ttlm = int(source_config.get('cache-life-type', 2*60))
+        self._query_cache = TimedCache(ttlm)
+        self._interval = int(source_config.get('synchronization-interval',
+                                               24*60*60))
+
+    def reset_caches(self):
+        """method called during test to reset potential source caches"""
+        self._query_cache = TimedCache(2*60)
+
+    def init(self):
+        """method called by the repository once ready to handle request"""
+        self.repo.looping_task(self._interval, self.synchronize) 
+        self.repo.looping_task(self._query_cache.ttl.seconds/10, self._query_cache.clear_expired) 
+
+    def synchronize(self):
+        """synchronize content known by this repository with content in the
+        external repository
+        """
+        self.info('synchronizing ldap source %s', self.uri)
+        session = self.repo.internal_session()
+        try:
+            cursor = session.system_sql("SELECT eid, extid FROM entities WHERE "
+                                        "source='%s'" % self.uri)
+            for eid, extid in cursor.fetchall():
+                # if no result found, _search automatically delete entity information
+                res = self._search(session, extid, BASE)
+                if res: 
+                    ldapemailaddr = res[0].get(self.user_rev_attrs['email'])
+                    if ldapemailaddr:
+                        rset = session.execute('EmailAddress X,A WHERE '
+                                               'U use_email X, U eid %(u)s',
+                                               {'u': eid})
+                        ldapemailaddr = unicode(ldapemailaddr)
+                        for emaileid, emailaddr in rset:
+                            if emailaddr == ldapemailaddr:
+                                break
+                        else:
+                            self.info('updating email address of user %s to %s',
+                                      extid, ldapemailaddr)
+                            if rset:
+                                session.execute('SET X address %(addr)s WHERE '
+                                                'U primary_email X, U eid %(u)s',
+                                                {'addr': ldapemailaddr, 'u': eid})
+                            else:
+                                # no email found, create it
+                                _insert_email(session, ldapemailaddr, eid)
+        finally:
+            session.commit()
+            session.close()
+            
+    def get_connection(self):
+        """open and return a connection to the source"""
+        if self._conn is None:
+            self._connect()
+        return ConnectionWrapper(self._conn)
+    
+    def authenticate(self, session, login, password):
+        """return EUser eid for the given login/password if this account is
+        defined in this source, else raise `AuthenticationError`
+
+        two queries are needed since passwords are stored crypted, so we have
+        to fetch the salt first
+        """
+        assert login, 'no login!'
+        searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))]
+        searchfilter.extend([filter_format('(%s=%s)', ('objectClass', o))
+                             for o in self.user_classes])
+        searchstr = '(&%s)' % ''.join(searchfilter)
+        # first search the user
+        try:
+            user = self._search(session, self.user_base_dn,
+                                self.user_base_scope, searchstr)[0]
+        except IndexError:
+            # no such user
+            raise AuthenticationError()
+        # check password by establishing a (unused) connection
+        try:
+            self._connect(user['dn'], password)
+        except:
+            # Something went wrong, most likely bad credentials
+            raise AuthenticationError()
+        return self.extid2eid(user['dn'], 'EUser', session)
+
+    def ldap_name(self, var):
+        if var.stinfo['relations']:
+            relname = iter(var.stinfo['relations']).next().r_type
+            return self.user_rev_attrs.get(relname)
+        return None
+        
+    def prepare_columns(self, mainvars, rqlst):
+        """return two list describin how to build the final results
+        from the result of an ldap search (ie a list of dictionnary)
+        """
+        columns = []
+        global_transforms = []
+        for i, term in enumerate(rqlst.selection):
+            if isinstance(term, Constant):
+                columns.append(term)
+                continue
+            if isinstance(term, Function): # LOWER, UPPER, COUNT...
+                var = term.get_nodes(VariableRef)[0]
+                var = var.variable
+                try:
+                    mainvar = var.stinfo['attrvar'].name
+                except AttributeError: # no attrvar set
+                    mainvar = var.name
+                assert mainvar in mainvars
+                trname = term.name
+                ldapname = self.ldap_name(var)
+                if trname in ('COUNT', 'MIN', 'MAX', 'SUM'):
+                    global_transforms.append(GlobTrFunc(trname, i, ldapname))
+                    columns.append((mainvar, ldapname))
+                    continue
+                if trname in ('LOWER', 'UPPER'):
+                    columns.append((mainvar, TrFunc(trname, i, ldapname)))
+                    continue
+                raise NotImplementedError('no support for %s function' % trname)
+            if term.name in mainvars:
+                columns.append((term.name, 'dn'))
+                continue
+            var = term.variable
+            mainvar = var.stinfo['attrvar'].name
+            columns.append((mainvar, self.ldap_name(var)))
+            #else:
+            #    # probably a bug in rql splitting if we arrive here
+            #    raise NotImplementedError
+        return columns, global_transforms
+    
+    def syntax_tree_search(self, session, union,
+                           args=None, cachekey=None, varmap=None, debug=0):
+        """return result from this source for a rql query (actually from a rql 
+        syntax tree and a solution dictionary mapping each used variable to a 
+        possible type). If cachekey is given, the query necessary to fetch the
+        results (but not the results themselves) may be cached using this key.
+        """
+        # XXX not handled : transform/aggregat function, join on multiple users...
+        assert len(union.children) == 1, 'union not supported'
+        rqlst = union.children[0]
+        assert not rqlst.with_, 'subquery not supported'
+        rqlkey = rqlst.as_string(kwargs=args)
+        try:
+            results = self._query_cache[rqlkey]
+        except KeyError:
+            results = self.rqlst_search(session, rqlst, args)
+            self._query_cache[rqlkey] = results
+        return results
+
+    def rqlst_search(self, session, rqlst, args):
+        mainvars = []
+        for varname in rqlst.defined_vars:
+            for sol in rqlst.solutions:
+                if sol[varname] == 'EUser':
+                    mainvars.append(varname)
+                    break
+        assert mainvars
+        columns, globtransforms = self.prepare_columns(mainvars, rqlst)
+        eidfilters = []
+        allresults = []
+        generator = RQL2LDAPFilter(self, session, args, mainvars)
+        for mainvar in mainvars:
+            # handle restriction
+            try:
+                eidfilters_, ldapfilter = generator.generate(rqlst, mainvar)
+            except GotDN, ex:
+                assert ex.dn, 'no dn!'
+                try:
+                    res = [self._cache[ex.dn]]
+                except KeyError:
+                    res = self._search(session, ex.dn, BASE)
+            except UnknownEid, ex:
+                # raised when we are looking for the dn of an eid which is not
+                # coming from this source
+                res = []
+            else:
+                eidfilters += eidfilters_
+                res = self._search(session, self.user_base_dn,
+                                   self.user_base_scope, ldapfilter)
+            allresults.append(res)
+        # 1. get eid for each dn and filter according to that eid if necessary
+        for i, res in enumerate(allresults):
+            filteredres = []
+            for resdict in res:
+                # get sure the entity exists in the system table
+                eid = self.extid2eid(resdict['dn'], 'EUser', session)
+                for eidfilter in eidfilters:
+                    if not eidfilter(eid):
+                        break
+                else:
+                    resdict['eid'] = eid
+                    filteredres.append(resdict)
+            allresults[i] = filteredres
+        # 2. merge result for each "mainvar": cartesian product
+        allresults = cartesian_product(allresults)
+        # 3. build final result according to column definition
+        result = []
+        for rawline in allresults:
+            rawline = dict(zip(mainvars, rawline))
+            line = []
+            for varname, ldapname in columns:
+                if ldapname is None:
+                    value = None # no mapping available
+                elif ldapname == 'dn':
+                    value = rawline[varname]['eid']
+                elif isinstance(ldapname, Constant):
+                    if ldapname.type == 'Substitute':
+                        value = args[ldapname.value]
+                    else:
+                        value = ldapname.value
+                elif isinstance(ldapname, TrFunc):
+                    value = ldapname.apply(rawline[varname])
+                else:
+                    value = rawline[varname].get(ldapname)
+                line.append(value)
+            result.append(line)
+        for trfunc in globtransforms:
+            result = trfunc.apply(result)
+        #print '--> ldap result', result
+        return result
+                
+    
+    def _connect(self, userdn=None, userpwd=None):
+        port, protocol = MODES[self.cnx_mode]
+        if protocol == 'ldapi':
+            hostport = self.host
+        else:
+            hostport = '%s:%s' % (self.host, self.port or port)
+        self.info('connecting %s://%s as %s', protocol, hostport,
+                  userdn or 'anonymous')
+        url = LDAPUrl(urlscheme=protocol, hostport=hostport)
+        conn = ReconnectLDAPObject(url.initializeUrl())
+        # Set the protocol version - version 3 is preferred
+        try:
+            conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
+        except ldap.LDAPError: # Invalid protocol version, fall back safely
+            conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2)
+        # Deny auto-chasing of referrals to be safe, we handle them instead
+        #try:
+        #    connection.set_option(ldap.OPT_REFERRALS, 0)
+        #except ldap.LDAPError: # Cannot set referrals, so do nothing
+        #    pass
+        #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout)
+        #conn.timeout = op_timeout
+        # Now bind with the credentials given. Let exceptions propagate out.
+        if userdn is None:
+            assert self._conn is None
+            self._conn = conn
+            userdn = self.cnx_dn
+            userpwd = self.cnx_pwd
+        conn.simple_bind_s(userdn, userpwd)
+        return conn
+
+    def _search(self, session, base, scope,
+                searchstr='(objectClass=*)', attrs=()):
+        """make an ldap query"""
+        cnx = session.pool.connection(self.uri).cnx
+        try:
+            res = cnx.search_s(base, scope, searchstr, attrs)
+        except ldap.PARTIAL_RESULTS:
+            res = cnx.result(all=0)[1]
+        except ldap.NO_SUCH_OBJECT:
+            eid = self.extid2eid(base, 'EUser', session, insert=False)
+            if eid:
+                self.warning('deleting ldap user with eid %s and dn %s',
+                             eid, base)
+                self.repo.delete_info(session, eid)
+                self._cache.pop(base, None)
+            return []
+##         except ldap.REFERRAL, e:
+##             cnx = self.handle_referral(e)
+##             try:
+##                 res = cnx.search_s(base, scope, searchstr, attrs)
+##             except ldap.PARTIAL_RESULTS:
+##                 res_type, res = cnx.result(all=0)
+        result = []
+        for rec_dn, rec_dict in res:
+            # When used against Active Directory, "rec_dict" may not be
+            # be a dictionary in some cases (instead, it can be a list)
+            # An example of a useless "res" entry that can be ignored
+            # from AD is
+            # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL'])
+            # This appears to be some sort of internal referral, but
+            # we can't handle it, so we need to skip over it.
+            try:
+                items =  rec_dict.items()
+            except AttributeError:
+                # 'items' not found on rec_dict, skip
+                continue
+            for key, value in items: # XXX syt: huuum ?
+                if not isinstance(value, str):
+                    try:
+                        for i in range(len(value)):
+                            value[i] = unicode(value[i], 'utf8')
+                    except:
+                        pass
+                if isinstance(value, list) and len(value) == 1:
+                    rec_dict[key] = value = value[0]
+            rec_dict['dn'] = rec_dn
+            self._cache[rec_dn] = rec_dict
+            result.append(rec_dict)
+        #print '--->', result
+        return result
+    
+    def before_entity_insertion(self, session, lid, etype, eid):
+        """called by the repository when an eid has been attributed for an
+        entity stored here but the entity has not been inserted in the system
+        table yet.
+        
+        This method must return the an Entity instance representation of this
+        entity.
+        """
+        entity = super(LDAPUserSource, self).before_entity_insertion(session, lid, etype, eid)
+        res = self._search(session, lid, BASE)[0]
+        for attr in entity.e_schema.indexable_attributes():
+            entity[attr] = res[self.user_rev_attrs[attr]]
+        return entity
+    
+    def after_entity_insertion(self, session, dn, entity):
+        """called by the repository after an entity stored here has been
+        inserted in the system table.
+        """
+        super(LDAPUserSource, self).after_entity_insertion(session, dn, entity)
+        for group in self.user_default_groups:
+            session.execute('SET X in_group G WHERE X eid %(x)s, G name %(group)s',
+                            {'x': entity.eid, 'group': group}, 'x')
+        # search for existant email first
+        try:
+            emailaddr = self._cache[dn][self.user_rev_attrs['email']]
+        except KeyError:
+            return
+        rset = session.execute('EmailAddress X WHERE X address %(addr)s',
+                               {'addr': emailaddr})
+        if rset:
+            session.execute('SET U primary_email X WHERE U eid %(u)s, X eid %(x)s',
+                            {'x': rset[0][0], 'u': entity.eid}, 'u')
+        else:
+            # not found, create it
+            _insert_email(session, emailaddr, entity.eid)
+
+    def update_entity(self, session, entity):
+        """replace an entity in the source"""
+        raise RepositoryError('this source is read only')
+
+    def delete_entity(self, session, etype, eid):
+        """delete an entity from the source"""
+        raise RepositoryError('this source is read only')
+
+def _insert_email(session, emailaddr, ueid):
+    session.execute('INSERT EmailAddress X: X address %(addr)s, U primary_email X '
+                    'WHERE U eid %(x)s', {'addr': emailaddr, 'x': ueid}, 'x')
+    
+class GotDN(Exception):
+    """exception used when a dn localizing the searched user has been found"""
+    def __init__(self, dn):
+        self.dn = dn
+
+        
+class RQL2LDAPFilter(object):
+    """generate an LDAP filter for a rql query"""
+    def __init__(self, source, session, args=None, mainvars=()):
+        self.source = source
+        self._ldap_attrs = source.user_rev_attrs
+        self._base_filters = source.base_filters
+        self._session = session
+        if args is None:
+            args = {}
+        self._args = args
+        self.mainvars = mainvars
+        
+    def generate(self, selection, mainvarname):
+        self._filters = res = self._base_filters[:]
+        self._mainvarname = mainvarname
+        self._eidfilters = []
+        self._done_not = set()
+        restriction = selection.where
+        if isinstance(restriction, Relation):
+            # only a single relation, need to append result here (no AND/OR)
+            filter = restriction.accept(self)
+            if filter is not None:
+                res.append(filter)
+        elif restriction:
+            restriction.accept(self)
+        if len(res) > 1:
+            return self._eidfilters, '(&%s)' % ''.join(res)
+        return self._eidfilters, res[0]
+    
+    def visit_and(self, et):
+        """generate filter for a AND subtree"""
+        for c in et.children:
+            part = c.accept(self)
+            if part:
+                self._filters.append(part)
+
+    def visit_or(self, ou):
+        """generate filter for a OR subtree"""
+        res = []
+        for c in ou.children:
+            part = c.accept(self)
+            if part:
+                res.append(part)
+        if res:
+            if len(res) > 1:
+                part = '(|%s)' % ''.join(res)
+            else:
+                part = res[0]
+            self._filters.append(part)
+
+    def visit_not(self, node):
+        """generate filter for a OR subtree"""
+        part = node.children[0].accept(self)
+        if part:
+            self._filters.append('(!(%s))'% part)
+
+    def visit_relation(self, relation):
+        """generate filter for a relation"""
+        rtype = relation.r_type
+        # don't care of type constraint statement (i.e. relation_type = 'is')
+        if rtype == 'is':
+            return ''
+        lhs, rhs = relation.get_parts()
+        # attribute relation
+        if self.source.schema.rschema(rtype).is_final():
+            # dunno what to do here, don't pretend anything else
+            if lhs.name != self._mainvarname:
+                if lhs.name in self.mainvars:
+                    # XXX check we don't have variable as rhs
+                    return
+                raise NotImplementedError
+            rhs_vars = rhs.get_nodes(VariableRef)
+            if rhs_vars:
+                if len(rhs_vars) > 1:
+                    raise NotImplementedError
+                # selected variable, nothing to do here
+                return
+            # no variables in the RHS
+            if isinstance(rhs.children[0], Function):
+                res = rhs.children[0].accept(self)
+            elif rtype != 'has_text':
+                res = self._visit_attribute_relation(relation)
+            else:
+                raise NotImplementedError(relation)
+        # regular relation XXX todo: in_group
+        else:
+            raise NotImplementedError(relation)
+        return res
+        
+    def _visit_attribute_relation(self, relation):
+        """generate filter for an attribute relation"""
+        lhs, rhs = relation.get_parts()
+        lhsvar = lhs.variable
+        if relation.r_type == 'eid':
+            # XXX hack
+            # skip comparison sign
+            eid = int(rhs.children[0].accept(self))
+            if relation.neged(strict=True):
+                self._done_not.add(relation.parent)
+                self._eidfilters.append(lambda x: not x == eid)
+                return
+            if rhs.operator != '=':
+                filter = {'>': lambda x: x > eid,
+                          '>=': lambda x: x >= eid,
+                          '<': lambda x: x < eid,
+                          '<=': lambda x: x <= eid,
+                          }[rhs.operator]
+                self._eidfilters.append(filter)
+                return
+            dn = self.source.eid2extid(eid, self._session)
+            raise GotDN(dn)
+        try:
+            filter = '(%s%s)' % (self._ldap_attrs[relation.r_type],
+                                 rhs.accept(self))
+        except KeyError:
+            assert relation.r_type == 'password' # 2.38 migration
+            raise UnknownEid # trick to return no result
+        return filter
+
+    def visit_comparison(self, cmp):
+        """generate filter for a comparaison"""
+        return '%s%s'% (cmp.operator, cmp.children[0].accept(self))            
+
+    def visit_mathexpression(self, mexpr):
+        """generate filter for a mathematic expression"""
+        raise NotImplementedError
+        
+    def visit_function(self, function):
+        """generate filter name for a function"""
+        if function.name == 'IN':
+            return self.visit_in(function)
+        raise NotImplementedError
+        
+    def visit_in(self, function):
+        grandpapa = function.parent.parent
+        ldapattr = self._ldap_attrs[grandpapa.r_type]
+        res = []
+        for c in function.children:
+            part = c.accept(self)
+            if part:
+                res.append(part)
+        if res:
+            if len(res) > 1:
+                part = '(|%s)' % ''.join('(%s=%s)' % (ldapattr, v) for v in res)
+            else:
+                part = '(%s=%s)' % (ldapattr, res[0])
+        return part
+        
+    def visit_constant(self, constant):
+        """generate filter name for a constant"""
+        value = constant.value
+        if constant.type is None:
+            raise NotImplementedError
+        if constant.type == 'Date':
+            raise NotImplementedError
+            #value = self.keyword_map[value]()
+        elif constant.type == 'Substitute':
+            value = self._args[constant.value]
+        else:
+            value = constant.value
+        if isinstance(value, unicode):
+            value = value.encode('utf8')
+        else:
+            value = str(value)
+        return escape_filter_chars(value)
+        
+    def visit_variableref(self, variableref):
+        """get the sql name for a variable reference"""
+        pass
+