sobjects/ldapparser.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2011-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    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/>.
       
    18 """cubicweb ldap feed source
       
    19 
       
    20 unlike ldapuser source, this source is copy based and will import ldap content
       
    21 (beside passwords for authentication) into the system source.
       
    22 """
       
    23 from six.moves import map, filter
       
    24 
       
    25 from logilab.common.decorators import cached, cachedproperty
       
    26 from logilab.common.shellutils import generate_password
       
    27 
       
    28 from cubicweb import Binary, ConfigurationError
       
    29 from cubicweb.server.utils import crypt_password
       
    30 from cubicweb.server.sources import datafeed
       
    31 from cubicweb.dataimport import stores, importer
       
    32 
       
    33 
       
    34 class UserMetaGenerator(stores.MetaGenerator):
       
    35     """Specific metadata generator, used to see newly created user into their initial state.
       
    36     """
       
    37     @cached
       
    38     def base_etype_dicts(self, entity):
       
    39         entity, rels = super(UserMetaGenerator, self).base_etype_dicts(entity)
       
    40         if entity.cw_etype == 'CWUser':
       
    41             wf_state = self._cnx.execute('Any S WHERE ET default_workflow WF, ET name %(etype)s, '
       
    42                                          'WF initial_state S', {'etype': entity.cw_etype}).one()
       
    43             rels['in_state'] = wf_state.eid
       
    44         return entity, rels
       
    45 
       
    46 
       
    47 class DataFeedLDAPAdapter(datafeed.DataFeedParser):
       
    48     __regid__ = 'ldapfeed'
       
    49     # attributes that may appears in source user_attrs dict which are not
       
    50     # attributes of the cw user
       
    51     non_attribute_keys = set(('email', 'eid', 'member', 'modification_date'))
       
    52 
       
    53     @cachedproperty
       
    54     def searchfilterstr(self):
       
    55         """ ldap search string, including user-filter """
       
    56         return '(&%s)' % ''.join(self.source.base_filters)
       
    57 
       
    58     @cachedproperty
       
    59     def searchgroupfilterstr(self):
       
    60         """ ldap search string, including user-filter """
       
    61         return '(&%s)' % ''.join(self.source.group_base_filters)
       
    62 
       
    63     @cachedproperty
       
    64     def user_source_entities_by_extid(self):
       
    65         source = self.source
       
    66         if source.user_base_dn.strip():
       
    67             attrs = list(map(str, source.user_attrs.keys()))
       
    68             return dict((userdict['dn'].encode('ascii'), userdict)
       
    69                         for userdict in source._search(self._cw,
       
    70                                                        source.user_base_dn,
       
    71                                                        source.user_base_scope,
       
    72                                                        self.searchfilterstr,
       
    73                                                        attrs))
       
    74         return {}
       
    75 
       
    76     @cachedproperty
       
    77     def group_source_entities_by_extid(self):
       
    78         source = self.source
       
    79         if source.group_base_dn.strip():
       
    80             attrs = list(map(str, ['modifyTimestamp'] + list(source.group_attrs.keys())))
       
    81             return dict((groupdict['dn'].encode('ascii'), groupdict)
       
    82                         for groupdict in source._search(self._cw,
       
    83                                                         source.group_base_dn,
       
    84                                                         source.group_base_scope,
       
    85                                                         self.searchgroupfilterstr,
       
    86                                                         attrs))
       
    87         return {}
       
    88 
       
    89     def process(self, url, raise_on_error=False):
       
    90         """IDataFeedParser main entry point"""
       
    91         self.debug('processing ldapfeed source %s %s', self.source, self.searchfilterstr)
       
    92         self._group_members = {}
       
    93         eeimporter = self.build_importer(raise_on_error)
       
    94         for name in self.source.user_default_groups:
       
    95             geid = self._get_group(name)
       
    96             eeimporter.extid2eid[geid] = geid
       
    97         entities = self.extentities_generator()
       
    98         set_cwuri = importer.use_extid_as_cwuri(eeimporter.extid2eid)
       
    99         eeimporter.import_entities(set_cwuri(entities))
       
   100         self.stats['created'] = eeimporter.created
       
   101         self.stats['updated'] = eeimporter.updated
       
   102         # handle in_group relation
       
   103         for group, members in self._group_members.items():
       
   104             self._cw.execute('DELETE U in_group G WHERE G name %(g)s', {'g': group})
       
   105             if members:
       
   106                 members = ["'%s'" % e for e in members]
       
   107                 rql = 'SET U in_group G WHERE G name %%(g)s, U login IN (%s)' % ','.join(members)
       
   108                 self._cw.execute(rql, {'g': group})
       
   109         # ensure updated users are activated
       
   110         for eid in eeimporter.updated:
       
   111             entity = self._cw.entity_from_eid(eid)
       
   112             if entity.cw_etype == 'CWUser':
       
   113                 self.ensure_activated(entity)
       
   114         # manually set primary email if necessary, it's not handled automatically since hooks are
       
   115         # deactivated
       
   116         self._cw.execute('SET X primary_email E WHERE NOT X primary_email E, X use_email E, '
       
   117                          'X cw_source S, S eid %(s)s, X in_state ST, TS name "activated"',
       
   118                          {'s': self.source.eid})
       
   119 
       
   120     def build_importer(self, raise_on_error):
       
   121         """Instantiate and configure an importer"""
       
   122         etypes = ('CWUser', 'EmailAddress', 'CWGroup')
       
   123         extid2eid = dict((self.source.decode_extid(x), y) for x, y in
       
   124                 self._cw.system_sql('select extid, eid from entities where asource = %(s)s', {'s': self.source.uri}))
       
   125         existing_relations = {}
       
   126         for rtype in ('in_group', 'use_email', 'owned_by'):
       
   127             rql = 'Any S,O WHERE S {} O, S cw_source SO, SO eid %(s)s'.format(rtype)
       
   128             rset = self._cw.execute(rql, {'s': self.source.eid})
       
   129             existing_relations[rtype] = set(tuple(x) for x in rset)
       
   130         return importer.ExtEntitiesImporter(self._cw.vreg.schema, self.build_store(),
       
   131                                             extid2eid=extid2eid,
       
   132                                             existing_relations=existing_relations,
       
   133                                             etypes_order_hint=etypes,
       
   134                                             import_log=self.import_log,
       
   135                                             raise_on_error=raise_on_error)
       
   136 
       
   137     def build_store(self):
       
   138         """Instantiate and configure a store"""
       
   139         metagenerator = UserMetaGenerator(self._cw, source=self.source)
       
   140         return stores.NoHookRQLObjectStore(self._cw, metagenerator)
       
   141 
       
   142     def extentities_generator(self):
       
   143         self.debug('processing ldapfeed source %s %s', self.source, self.searchgroupfilterstr)
       
   144         # generate users and email addresses
       
   145         for userdict in self.user_source_entities_by_extid.values():
       
   146             attrs = self.ldap2cwattrs(userdict, 'CWUser')
       
   147             pwd = attrs.get('upassword')
       
   148             if not pwd:
       
   149                 # generate a dumb password if not fetched from ldap (see
       
   150                 # userPassword)
       
   151                 pwd = crypt_password(generate_password())
       
   152                 attrs['upassword'] = set([Binary(pwd)])
       
   153             extuser = importer.ExtEntity('CWUser', userdict['dn'].encode('ascii'), attrs)
       
   154             extuser.values['owned_by'] = set([extuser.extid])
       
   155             for extemail in self._process_email(extuser, userdict):
       
   156                 yield extemail
       
   157             groups = list(filter(None, [self._get_group(name)
       
   158                                         for name in self.source.user_default_groups]))
       
   159             if groups:
       
   160                 extuser.values['in_group'] = groups
       
   161             yield extuser
       
   162         # generate groups
       
   163         for groupdict in self.group_source_entities_by_extid.values():
       
   164             attrs = self.ldap2cwattrs(groupdict, 'CWGroup')
       
   165             extgroup = importer.ExtEntity('CWGroup', groupdict['dn'].encode('ascii'), attrs)
       
   166             yield extgroup
       
   167             # record group membership for later insertion
       
   168             members = groupdict.get(self.source.group_rev_attrs['member'], ())
       
   169             self._group_members[attrs['name']] = members
       
   170 
       
   171     def _process_email(self, extuser, userdict):
       
   172         try:
       
   173             emailaddrs = userdict.pop(self.source.user_rev_attrs['email'])
       
   174         except KeyError:
       
   175             return  # no email for that user, nothing to do
       
   176         if not isinstance(emailaddrs, list):
       
   177             emailaddrs = [emailaddrs]
       
   178         for emailaddr in emailaddrs:
       
   179             # search for existing email first, may be coming from another source
       
   180             rset = self._cw.execute('EmailAddress X WHERE X address %(addr)s',
       
   181                                     {'addr': emailaddr})
       
   182             emailextid = (userdict['dn'] + '@@' + emailaddr).encode('ascii')
       
   183             if not rset:
       
   184                 # not found, create it. first forge an external id
       
   185                 extuser.values.setdefault('use_email', []).append(emailextid)
       
   186                 yield importer.ExtEntity('EmailAddress', emailextid, dict(address=[emailaddr]))
       
   187             elif self.sourceuris:
       
   188                 # pop from sourceuris anyway, else email may be removed by the
       
   189                 # source once import is finished
       
   190                 self.sourceuris.pop(emailextid, None)
       
   191             # XXX else check use_email relation?
       
   192 
       
   193     def handle_deletion(self, config, cnx, myuris):
       
   194         if config['delete-entities']:
       
   195             super(DataFeedLDAPAdapter, self).handle_deletion(config, cnx, myuris)
       
   196             return
       
   197         if myuris:
       
   198             for extid, (eid, etype) in myuris.items():
       
   199                 if etype != 'CWUser' or not self.is_deleted(extid, etype, eid):
       
   200                     continue
       
   201                 self.info('deactivate user %s', eid)
       
   202                 wf = cnx.entity_from_eid(eid).cw_adapt_to('IWorkflowable')
       
   203                 wf.fire_transition_if_possible('deactivate')
       
   204         cnx.commit()
       
   205 
       
   206     def ensure_activated(self, entity):
       
   207         if entity.cw_etype == 'CWUser':
       
   208             wf = entity.cw_adapt_to('IWorkflowable')
       
   209             if wf.state == 'deactivated':
       
   210                 wf.fire_transition('activate')
       
   211                 self.info('user %s reactivated', entity.login)
       
   212 
       
   213     def ldap2cwattrs(self, sdict, etype):
       
   214         """Transform dictionary of LDAP attributes to CW.
       
   215 
       
   216         etype must be CWUser or CWGroup
       
   217         """
       
   218         assert etype in ('CWUser', 'CWGroup'), etype
       
   219         tdict = {}
       
   220         if etype == 'CWUser':
       
   221             items = self.source.user_attrs.items()
       
   222         elif etype == 'CWGroup':
       
   223             items = self.source.group_attrs.items()
       
   224         for sattr, tattr in items:
       
   225             if tattr not in self.non_attribute_keys:
       
   226                 try:
       
   227                     value = sdict[sattr]
       
   228                 except KeyError:
       
   229                     raise ConfigurationError(
       
   230                         'source attribute %s has not been found in the source, '
       
   231                         'please check the %s-attrs-map field and the permissions of '
       
   232                         'the LDAP binding user' % (sattr, etype[2:].lower()))
       
   233                 if not isinstance(value, list):
       
   234                     value = [value]
       
   235                 tdict[tattr] = value
       
   236         return tdict
       
   237 
       
   238     def is_deleted(self, extidplus, etype, eid):
       
   239         try:
       
   240             extid = extidplus.rsplit(b'@@', 1)[0]
       
   241         except ValueError:
       
   242             # for some reason extids here tend to come in both forms, e.g:
       
   243             # dn, dn@@Babar
       
   244             extid = extidplus
       
   245         return extid not in self.user_source_entities_by_extid
       
   246 
       
   247     @cached
       
   248     def _get_group(self, name):
       
   249         try:
       
   250             return self._cw.execute('Any X WHERE X is CWGroup, X name %(name)s',
       
   251                                     {'name': name})[0][0]
       
   252         except IndexError:
       
   253             self.error('group %r referenced by source configuration %r does not exist',
       
   254                        name, self.source.uri)
       
   255             return None