16 # You should have received a copy of the GNU Lesser General Public License along |
16 # You should have received a copy of the GNU Lesser General Public License along |
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
18 """cubicweb ldap user source |
18 """cubicweb ldap user source |
19 |
19 |
20 this source is for now limited to a read-only CWUser source |
20 this source is for now limited to a read-only CWUser source |
21 |
|
22 Part of the code is coming form Zope's LDAPUserFolder |
|
23 |
|
24 Copyright (c) 2004 Jens Vagelpohl. |
|
25 All Rights Reserved. |
|
26 |
|
27 This software is subject to the provisions of the Zope Public License, |
|
28 Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. |
|
29 THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED |
|
30 WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
|
31 WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS |
|
32 FOR A PARTICULAR PURPOSE. |
|
33 """ |
21 """ |
34 from __future__ import division |
22 from __future__ import division |
35 from base64 import b64decode |
23 from base64 import b64decode |
36 |
24 |
37 import ldap |
25 import ldap |
38 from ldap.ldapobject import ReconnectLDAPObject |
26 from ldap.filter import escape_filter_chars |
39 from ldap.filter import filter_format, escape_filter_chars |
|
40 from ldapurl import LDAPUrl |
|
41 |
27 |
42 from rql.nodes import Relation, VariableRef, Constant, Function |
28 from rql.nodes import Relation, VariableRef, Constant, Function |
43 |
29 |
44 from cubicweb import AuthenticationError, UnknownEid, RepositoryError |
30 from cubicweb import UnknownEid, RepositoryError |
|
31 from cubicweb.server import ldaputils |
45 from cubicweb.server.utils import cartesian_product |
32 from cubicweb.server.utils import cartesian_product |
46 from cubicweb.server.sources import (AbstractSource, TrFunc, GlobTrFunc, |
33 from cubicweb.server.sources import (AbstractSource, TrFunc, GlobTrFunc, |
47 ConnectionWrapper, TimedCache) |
34 TimedCache) |
48 |
35 |
49 # search scopes |
36 # search scopes |
50 BASE = ldap.SCOPE_BASE |
37 BASE = ldap.SCOPE_BASE |
51 ONELEVEL = ldap.SCOPE_ONELEVEL |
38 ONELEVEL = ldap.SCOPE_ONELEVEL |
52 SUBTREE = ldap.SCOPE_SUBTREE |
39 SUBTREE = ldap.SCOPE_SUBTREE |
56 'ldaps': 636, |
43 'ldaps': 636, |
57 'ldapi': None, |
44 'ldapi': None, |
58 } |
45 } |
59 |
46 |
60 |
47 |
61 class LDAPUserSource(AbstractSource): |
48 class LDAPUserSource(ldaputils.LDAPSourceMixIn, AbstractSource): |
62 """LDAP read-only CWUser source""" |
49 """LDAP read-only CWUser source""" |
63 support_entities = {'CWUser': False} |
50 support_entities = {'CWUser': False} |
64 |
51 |
65 options = ( |
52 options = ldaputils.LDAPSourceMixIn.options + ( |
66 ('host', |
|
67 {'type' : 'string', |
|
68 'default': 'ldap', |
|
69 'help': 'ldap host. It may contains port information using \ |
|
70 <host>:<port> notation.', |
|
71 'group': 'ldap-source', 'level': 1, |
|
72 }), |
|
73 ('protocol', |
|
74 {'type' : 'choice', |
|
75 'default': 'ldap', |
|
76 'choices': ('ldap', 'ldaps', 'ldapi'), |
|
77 'help': 'ldap protocol (allowed values: ldap, ldaps, ldapi)', |
|
78 'group': 'ldap-source', 'level': 1, |
|
79 }), |
|
80 ('auth-mode', |
|
81 {'type' : 'choice', |
|
82 'default': 'simple', |
|
83 'choices': ('simple', 'cram_md5', 'digest_md5', 'gssapi'), |
|
84 'help': 'authentication mode used to authenticate user to the ldap.', |
|
85 'group': 'ldap-source', 'level': 3, |
|
86 }), |
|
87 ('auth-realm', |
|
88 {'type' : 'string', |
|
89 'default': None, |
|
90 'help': 'realm to use when using gssapi/kerberos authentication.', |
|
91 'group': 'ldap-source', 'level': 3, |
|
92 }), |
|
93 |
|
94 ('data-cnx-dn', |
|
95 {'type' : 'string', |
|
96 'default': '', |
|
97 'help': 'user dn to use to open data connection to the ldap (eg used \ |
|
98 to respond to rql queries). Leave empty for anonymous bind', |
|
99 'group': 'ldap-source', 'level': 1, |
|
100 }), |
|
101 ('data-cnx-password', |
|
102 {'type' : 'string', |
|
103 'default': '', |
|
104 'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.', |
|
105 'group': 'ldap-source', 'level': 1, |
|
106 }), |
|
107 |
|
108 ('user-base-dn', |
|
109 {'type' : 'string', |
|
110 'default': 'ou=People,dc=logilab,dc=fr', |
|
111 'help': 'base DN to lookup for users', |
|
112 'group': 'ldap-source', 'level': 1, |
|
113 }), |
|
114 ('user-scope', |
|
115 {'type' : 'choice', |
|
116 'default': 'ONELEVEL', |
|
117 'choices': ('BASE', 'ONELEVEL', 'SUBTREE'), |
|
118 'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")', |
|
119 'group': 'ldap-source', 'level': 1, |
|
120 }), |
|
121 ('user-classes', |
|
122 {'type' : 'csv', |
|
123 'default': ('top', 'posixAccount'), |
|
124 'help': 'classes of user (with Active Directory, you want to say "user" here)', |
|
125 'group': 'ldap-source', 'level': 1, |
|
126 }), |
|
127 ('user-filter', |
|
128 {'type': 'string', |
|
129 'default': '', |
|
130 'help': 'additional filters to be set in the ldap query to find valid users', |
|
131 'group': 'ldap-source', 'level': 2, |
|
132 }), |
|
133 ('user-login-attr', |
|
134 {'type' : 'string', |
|
135 'default': 'uid', |
|
136 'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)', |
|
137 'group': 'ldap-source', 'level': 1, |
|
138 }), |
|
139 ('user-default-group', |
|
140 {'type' : 'csv', |
|
141 'default': ('users',), |
|
142 'help': 'name of a group in which ldap users will be by default. \ |
|
143 You can set multiple groups by separating them by a comma.', |
|
144 'group': 'ldap-source', 'level': 1, |
|
145 }), |
|
146 ('user-attrs-map', |
|
147 {'type' : 'named', |
|
148 'default': {'uid': 'login', 'gecos': 'email'}, |
|
149 'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)', |
|
150 'group': 'ldap-source', 'level': 1, |
|
151 }), |
|
152 |
53 |
153 ('synchronization-interval', |
54 ('synchronization-interval', |
154 {'type' : 'time', |
55 {'type' : 'time', |
155 'default': '1d', |
56 'default': '1d', |
156 'help': 'interval between synchronization with the ldap \ |
57 'help': 'interval between synchronization with the ldap \ |
166 |
67 |
167 ) |
68 ) |
168 |
69 |
169 def __init__(self, repo, source_config, eid=None): |
70 def __init__(self, repo, source_config, eid=None): |
170 AbstractSource.__init__(self, repo, source_config, eid) |
71 AbstractSource.__init__(self, repo, source_config, eid) |
171 self.update_config(None, self.check_conf_dict(eid, source_config)) |
72 self.update_config(None, self.check_conf_dict(eid, source_config, |
172 self._conn = None |
73 fail_if_unknown=False)) |
|
74 |
|
75 def _entity_update(self, source_entity): |
|
76 # XXX copy from datafeed source |
|
77 if source_entity.url: |
|
78 self.urls = [url.strip() for url in source_entity.url.splitlines() |
|
79 if url.strip()] |
|
80 else: |
|
81 self.urls = [] |
|
82 # /end XXX |
|
83 ldaputils.LDAPSourceMixIn._entity_update(self, source_entity) |
173 |
84 |
174 def update_config(self, source_entity, typedconfig): |
85 def update_config(self, source_entity, typedconfig): |
175 """update configuration from source entity. `typedconfig` is config |
86 """update configuration from source entity. `typedconfig` is config |
176 properly typed with defaults set |
87 properly typed with defaults set |
177 """ |
88 """ |
178 self.host = typedconfig['host'] |
89 ldaputils.LDAPSourceMixIn.update_config(self, source_entity, typedconfig) |
179 self.protocol = typedconfig['protocol'] |
|
180 self.authmode = typedconfig['auth-mode'] |
|
181 self._authenticate = getattr(self, '_auth_%s' % self.authmode) |
|
182 self.cnx_dn = typedconfig['data-cnx-dn'] |
|
183 self.cnx_pwd = typedconfig['data-cnx-password'] |
|
184 self.user_base_dn = str(typedconfig['user-base-dn']) |
|
185 self.user_base_scope = globals()[typedconfig['user-scope']] |
|
186 self.user_login_attr = typedconfig['user-login-attr'] |
|
187 self.user_default_groups = typedconfig['user-default-group'] |
|
188 self.user_attrs = typedconfig['user-attrs-map'] |
|
189 self.user_rev_attrs = {'eid': 'dn'} |
|
190 for ldapattr, cwattr in self.user_attrs.items(): |
|
191 self.user_rev_attrs[cwattr] = ldapattr |
|
192 self.base_filters = [filter_format('(%s=%s)', ('objectClass', o)) |
|
193 for o in typedconfig['user-classes']] |
|
194 if typedconfig['user-filter']: |
|
195 self.base_filters.append(typedconfig['user-filter']) |
|
196 self._interval = typedconfig['synchronization-interval'] |
90 self._interval = typedconfig['synchronization-interval'] |
197 self._cache_ttl = max(71, typedconfig['cache-life-time']) |
91 self._cache_ttl = max(71, typedconfig['cache-life-time']) |
198 self.reset_caches() |
92 self.reset_caches() |
199 self._conn = None |
93 # XXX copy from datafeed source |
|
94 if source_entity is not None: |
|
95 self._entity_update(source_entity) |
|
96 self.config = typedconfig |
|
97 # /end XXX |
200 |
98 |
201 def reset_caches(self): |
99 def reset_caches(self): |
202 """method called during test to reset potential source caches""" |
100 """method called during test to reset potential source caches""" |
203 self._cache = {} |
101 self._cache = {} |
204 self._query_cache = TimedCache(self._cache_ttl) |
102 self._query_cache = TimedCache(self._cache_ttl) |
205 |
103 |
206 def init(self, activated, source_entity): |
104 def init(self, activated, source_entity): |
207 """method called by the repository once ready to handle request""" |
105 """method called by the repository once ready to handle request""" |
208 if activated: |
106 if activated: |
209 self.info('ldap init') |
107 self.info('ldap init') |
|
108 self._entity_update(source_entity) |
210 # set minimum period of 5min 1s (the additional second is to |
109 # set minimum period of 5min 1s (the additional second is to |
211 # minimize resonnance effet) |
110 # minimize resonnance effet) |
212 self.repo.looping_task(max(301, self._interval), self.synchronize) |
111 if self.user_rev_attrs['email']: |
|
112 self.repo.looping_task(max(301, self._interval), self.synchronize) |
213 self.repo.looping_task(self._cache_ttl // 10, |
113 self.repo.looping_task(self._cache_ttl // 10, |
214 self._query_cache.clear_expired) |
114 self._query_cache.clear_expired) |
215 |
115 |
216 def synchronize(self): |
116 def synchronize(self): |
|
117 self.pull_data(self.repo.internal_session()) |
|
118 |
|
119 def pull_data(self, session, force=False, raise_on_error=False): |
217 """synchronize content known by this repository with content in the |
120 """synchronize content known by this repository with content in the |
218 external repository |
121 external repository |
219 """ |
122 """ |
220 self.info('synchronizing ldap source %s', self.uri) |
123 self.info('synchronizing ldap source %s', self.uri) |
221 try: |
124 ldap_emailattr = self.user_rev_attrs['email'] |
222 ldap_emailattr = self.user_rev_attrs['email'] |
125 assert ldap_emailattr |
223 except KeyError: |
|
224 return # no email in ldap, we're done |
|
225 session = self.repo.internal_session() |
126 session = self.repo.internal_session() |
226 execute = session.execute |
127 execute = session.execute |
227 try: |
128 try: |
228 cursor = session.system_sql("SELECT eid, extid FROM entities WHERE " |
129 cursor = session.system_sql("SELECT eid, extid FROM entities WHERE " |
229 "source='%s'" % self.uri) |
130 "source='%s'" % self.uri) |
265 # no email found, create it |
166 # no email found, create it |
266 _insert_email(session, ldapemailaddr, eid) |
167 _insert_email(session, ldapemailaddr, eid) |
267 finally: |
168 finally: |
268 session.commit() |
169 session.commit() |
269 session.close() |
170 session.close() |
270 |
|
271 def get_connection(self): |
|
272 """open and return a connection to the source""" |
|
273 if self._conn is None: |
|
274 try: |
|
275 self._connect() |
|
276 except Exception: |
|
277 self.exception('unable to connect to ldap:') |
|
278 return ConnectionWrapper(self._conn) |
|
279 |
|
280 def authenticate(self, session, login, password=None, **kwargs): |
|
281 """return CWUser eid for the given login/password if this account is |
|
282 defined in this source, else raise `AuthenticationError` |
|
283 |
|
284 two queries are needed since passwords are stored crypted, so we have |
|
285 to fetch the salt first |
|
286 """ |
|
287 self.info('ldap authenticate %s', login) |
|
288 if not password: |
|
289 # On Windows + ADAM this would have succeeded (!!!) |
|
290 # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'. |
|
291 # we really really don't want that |
|
292 raise AuthenticationError() |
|
293 searchfilter = [filter_format('(%s=%s)', (self.user_login_attr, login))] |
|
294 searchfilter.extend(self.base_filters) |
|
295 searchstr = '(&%s)' % ''.join(searchfilter) |
|
296 # first search the user |
|
297 try: |
|
298 user = self._search(session, self.user_base_dn, |
|
299 self.user_base_scope, searchstr)[0] |
|
300 except IndexError: |
|
301 # no such user |
|
302 raise AuthenticationError() |
|
303 # check password by establishing a (unused) connection |
|
304 try: |
|
305 self._connect(user, password) |
|
306 except ldap.LDAPError, ex: |
|
307 # Something went wrong, most likely bad credentials |
|
308 self.info('while trying to authenticate %s: %s', user, ex) |
|
309 raise AuthenticationError() |
|
310 except Exception: |
|
311 self.error('while trying to authenticate %s', user, exc_info=True) |
|
312 raise AuthenticationError() |
|
313 eid = self.repo.extid2eid(self, user['dn'], 'CWUser', session) |
|
314 if eid < 0: |
|
315 # user has been moved away from this source |
|
316 raise AuthenticationError() |
|
317 return eid |
|
318 |
171 |
319 def ldap_name(self, var): |
172 def ldap_name(self, var): |
320 if var.stinfo['relations']: |
173 if var.stinfo['relations']: |
321 relname = iter(var.stinfo['relations']).next().r_type |
174 relname = iter(var.stinfo['relations']).next().r_type |
322 return self.user_rev_attrs.get(relname) |
175 return self.user_rev_attrs.get(relname) |
457 for trfunc in globtransforms: |
310 for trfunc in globtransforms: |
458 result = trfunc.apply(result) |
311 result = trfunc.apply(result) |
459 #print '--> ldap result', result |
312 #print '--> ldap result', result |
460 return result |
313 return result |
461 |
314 |
462 |
315 def _process_ldap_item(self, dn, iterator): |
463 def _connect(self, user=None, userpwd=None): |
316 itemdict = super(LDAPUserSource, self)._process_ldap_item(dn, iterator) |
464 if self.protocol == 'ldapi': |
317 self._cache[dn] = itemdict |
465 hostport = self.host |
318 return itemdict |
466 elif not ':' in self.host: |
319 |
467 hostport = '%s:%s' % (self.host, PROTO_PORT[self.protocol]) |
320 def _process_no_such_object(self, session, dn): |
468 else: |
321 eid = self.repo.extid2eid(self, dn, 'CWUser', session, insert=False) |
469 hostport = self.host |
322 if eid: |
470 self.info('connecting %s://%s as %s', self.protocol, hostport, |
323 self.warning('deleting ldap user with eid %s and dn %s', eid, dn) |
471 user and user['dn'] or 'anonymous') |
324 entity = session.entity_from_eid(eid, 'CWUser') |
472 # don't require server certificate when using ldaps (will |
325 self.repo.delete_info(session, entity, self.uri) |
473 # enable self signed certs) |
326 self.reset_caches() |
474 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) |
|
475 url = LDAPUrl(urlscheme=self.protocol, hostport=hostport) |
|
476 conn = ReconnectLDAPObject(url.initializeUrl()) |
|
477 # Set the protocol version - version 3 is preferred |
|
478 try: |
|
479 conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3) |
|
480 except ldap.LDAPError: # Invalid protocol version, fall back safely |
|
481 conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION2) |
|
482 # Deny auto-chasing of referrals to be safe, we handle them instead |
|
483 #try: |
|
484 # connection.set_option(ldap.OPT_REFERRALS, 0) |
|
485 #except ldap.LDAPError: # Cannot set referrals, so do nothing |
|
486 # pass |
|
487 #conn.set_option(ldap.OPT_NETWORK_TIMEOUT, conn_timeout) |
|
488 #conn.timeout = op_timeout |
|
489 # Now bind with the credentials given. Let exceptions propagate out. |
|
490 if user is None: |
|
491 # no user specified, we want to initialize the 'data' connection, |
|
492 assert self._conn is None |
|
493 self._conn = conn |
|
494 # XXX always use simple bind for data connection |
|
495 if not self.cnx_dn: |
|
496 conn.simple_bind_s(self.cnx_dn, self.cnx_pwd) |
|
497 else: |
|
498 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd) |
|
499 else: |
|
500 # user specified, we want to check user/password, no need to return |
|
501 # the connection which will be thrown out |
|
502 self._authenticate(conn, user, userpwd) |
|
503 return conn |
|
504 |
|
505 def _auth_simple(self, conn, user, userpwd): |
|
506 conn.simple_bind_s(user['dn'], userpwd) |
|
507 |
|
508 def _auth_cram_md5(self, conn, user, userpwd): |
|
509 from ldap import sasl |
|
510 auth_token = sasl.cram_md5(user['dn'], userpwd) |
|
511 conn.sasl_interactive_bind_s('', auth_token) |
|
512 |
|
513 def _auth_digest_md5(self, conn, user, userpwd): |
|
514 from ldap import sasl |
|
515 auth_token = sasl.digest_md5(user['dn'], userpwd) |
|
516 conn.sasl_interactive_bind_s('', auth_token) |
|
517 |
|
518 def _auth_gssapi(self, conn, user, userpwd): |
|
519 # print XXX not proper sasl/gssapi |
|
520 import kerberos |
|
521 if not kerberos.checkPassword(user[self.user_login_attr], userpwd): |
|
522 raise Exception('BAD login / mdp') |
|
523 #from ldap import sasl |
|
524 #conn.sasl_interactive_bind_s('', sasl.gssapi()) |
|
525 |
|
526 def _search(self, session, base, scope, |
|
527 searchstr='(objectClass=*)', attrs=()): |
|
528 """make an ldap query""" |
|
529 self.debug('ldap search %s %s %s %s %s', self.uri, base, scope, |
|
530 searchstr, list(attrs)) |
|
531 # XXX for now, we do not have connections set support for LDAP, so |
|
532 # this is always self._conn |
|
533 cnx = session.cnxset.connection(self.uri).cnx |
|
534 try: |
|
535 res = cnx.search_s(base, scope, searchstr, attrs) |
|
536 except ldap.PARTIAL_RESULTS: |
|
537 res = cnx.result(all=0)[1] |
|
538 except ldap.NO_SUCH_OBJECT: |
|
539 self.info('ldap NO SUCH OBJECT') |
|
540 eid = self.repo.extid2eid(self, base, 'CWUser', session, insert=False) |
|
541 if eid: |
|
542 self.warning('deleting ldap user with eid %s and dn %s', |
|
543 eid, base) |
|
544 entity = session.entity_from_eid(eid, 'CWUser') |
|
545 self.repo.delete_info(session, entity, self.uri) |
|
546 self.reset_caches() |
|
547 return [] |
|
548 # except ldap.REFERRAL, e: |
|
549 # cnx = self.handle_referral(e) |
|
550 # try: |
|
551 # res = cnx.search_s(base, scope, searchstr, attrs) |
|
552 # except ldap.PARTIAL_RESULTS: |
|
553 # res_type, res = cnx.result(all=0) |
|
554 result = [] |
|
555 for rec_dn, rec_dict in res: |
|
556 # When used against Active Directory, "rec_dict" may not be |
|
557 # be a dictionary in some cases (instead, it can be a list) |
|
558 # An example of a useless "res" entry that can be ignored |
|
559 # from AD is |
|
560 # (None, ['ldap://ForestDnsZones.PORTAL.LOCAL/DC=ForestDnsZones,DC=PORTAL,DC=LOCAL']) |
|
561 # This appears to be some sort of internal referral, but |
|
562 # we can't handle it, so we need to skip over it. |
|
563 try: |
|
564 items = rec_dict.items() |
|
565 except AttributeError: |
|
566 # 'items' not found on rec_dict, skip |
|
567 continue |
|
568 for key, value in items: # XXX syt: huuum ? |
|
569 if not isinstance(value, str): |
|
570 try: |
|
571 for i in range(len(value)): |
|
572 value[i] = unicode(value[i], 'utf8') |
|
573 except Exception: |
|
574 pass |
|
575 if isinstance(value, list) and len(value) == 1: |
|
576 rec_dict[key] = value = value[0] |
|
577 rec_dict['dn'] = rec_dn |
|
578 self._cache[rec_dn] = rec_dict |
|
579 result.append(rec_dict) |
|
580 #print '--->', result |
|
581 self.debug('ldap built results %s', len(result)) |
|
582 return result |
|
583 |
327 |
584 def before_entity_insertion(self, session, lid, etype, eid, sourceparams): |
328 def before_entity_insertion(self, session, lid, etype, eid, sourceparams): |
585 """called by the repository when an eid has been attributed for an |
329 """called by the repository when an eid has been attributed for an |
586 entity stored here but the entity has not been inserted in the system |
330 entity stored here but the entity has not been inserted in the system |
587 table yet. |
331 table yet. |
602 inserted in the system table. |
346 inserted in the system table. |
603 """ |
347 """ |
604 self.debug('ldap after entity insertion') |
348 self.debug('ldap after entity insertion') |
605 super(LDAPUserSource, self).after_entity_insertion( |
349 super(LDAPUserSource, self).after_entity_insertion( |
606 session, lid, entity, sourceparams) |
350 session, lid, entity, sourceparams) |
607 dn = lid |
|
608 for group in self.user_default_groups: |
351 for group in self.user_default_groups: |
609 session.execute('SET X in_group G WHERE X eid %(x)s, G name %(group)s', |
352 session.execute('SET X in_group G WHERE X eid %(x)s, G name %(group)s', |
610 {'x': entity.eid, 'group': group}) |
353 {'x': entity.eid, 'group': group}) |
611 # search for existant email first |
354 # search for existant email first |
612 try: |
355 try: |
613 emailaddr = self._cache[dn][self.user_rev_attrs['email']] |
356 # lid = dn |
|
357 emailaddr = self._cache[lid][self.user_rev_attrs['email']] |
614 except KeyError: |
358 except KeyError: |
615 return |
359 return |
616 if isinstance(emailaddr, list): |
360 if isinstance(emailaddr, list): |
617 emailaddr = emailaddr[0] # XXX consider only the first email in the list |
361 emailaddr = emailaddr[0] # XXX consider only the first email in the list |
618 rset = session.execute('EmailAddress X WHERE X address %(addr)s', |
362 rset = session.execute('EmailAddress X WHERE X address %(addr)s', |