Project

General

Profile

Bug #19168

Erreur d'encodage lors synchro ldap.

Added by Josué Kouka over 2 years ago. Updated over 1 year ago.

Status:
En cours
Priority:
Normal
Category:
-
Target version:
-
Start date:
03 Oct 2017
Due date:
% Done:

0%

Patch proposed:
Yes
Planning:
No

Description

Traceback (most recent call last):
  File "/usr/lib/authentic2/manage.py", line 21, in <module>
    execute_from_command_line(sys.argv[:1] + argv)
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 354, in execute_from_command_line
    utility.execute()
  File "/usr/lib/python2.7/dist-packages/django/core/management/__init__.py", line 346, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/usr/lib/python2.7/dist-packages/hobo/multitenant/management/commands/tenant_command.py", line 52, in run_from_argv
    klass.run_from_argv(args)
  File "/usr/lib/python2.7/dist-packages/django/core/management/base.py", line 394, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/usr/lib/python2.7/dist-packages/hobo/agent/authentic2/apps.py", line 45, in new_execute
    return old_execute(self, *args, **kwargs)
  File "/usr/lib/python2.7/dist-packages/django/core/management/base.py", line 445, in execute
    output = self.handle(*args, **options)
  File "/usr/lib/python2.7/dist-packages/authentic2/management/commands/sync-ldap-users.py", line 14, in handle
    list(LDAPBackend.get_users())
  File "/usr/lib/python2.7/dist-packages/authentic2/backends/ldap_backend.py", line 847, in get_users
    users = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, user_filter, attrlist=attrs)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 552, in search_s
    return self.search_ext_s(base,scope,filterstr,attrlist,attrsonly,None,None,timeout=self.timeout)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 870, in search_ext_s
    return self._apply_method_s(SimpleLDAPObject.search_ext_s,*args,**kwargs)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 812, in _apply_method_s
    return func(self,*args,**kwargs)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 545, in search_ext_s
    msgid = self.search_ext(base,scope,filterstr,attrlist,attrsonly,serverctrls,clientctrls,timeout,sizelimit)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 541, in search_ext
    timeout,sizelimit,
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 99, in _ldap_call
    result = func(*args,**kwargs)
UnicodeEncodeError: 'ascii' codec can't encode character u'\xe9' in position 25: ordinal not in range(128)

https://sentry.entrouvert.org/sentry/recette/issues/2297/

0001-WIP-ldap_backend-fix-encoding-errors-during-user-syn.patch View (1.35 KB) Paul Marillonnet, 06 Oct 2017 05:01 PM

0001-WIP-ldap_backend-fix-encoding-errors-during-user-syn.patch View (5.37 KB) Paul Marillonnet, 11 Oct 2017 07:06 PM

0001-WIP-ldap_backend-fix-encoding-errors-during-user-syn.patch View (6.1 KB) Paul Marillonnet, 17 Oct 2017 04:22 PM

0001-WIP-ldap_backend-fix-encoding-errors-during-user-syn.patch View (5.8 KB) Paul Marillonnet, 27 Oct 2017 05:58 PM

0001-WIP-ldap_backend-fix-encoding-errors-during-user-syn.patch View (7.25 KB) Paul Marillonnet, 30 Oct 2017 05:00 PM


Related issues

Related to Authentic 2 - Bug #23698: LDAP: supporter les accents dans les base DNs (et peut-être aussi les filtres, à vérifier) Fermé 14 May 2018

History

#1 Updated by Josué Kouka over 2 years ago

  • Assignee changed from Josué Kouka to Paul Marillonnet

#2 Updated by Paul Marillonnet over 2 years ago

"LDAPv3 defines UTF-8 based string encoding for DNs in RFC 4514 and python-ldap happily sends that to the server." (dixit le mainteneur de python-ldap)
Il ne manque pas simplement un str.decode('utf-8') quelque part dans le code du backend LDAP ?

Josué est-ce qu'il y a moyen stp que tu retrouves le DN de l'utilisateur qui a provoqué l'erreur ?
Je ne vois rien ici ni sur le rapport Sentry.

#3 Updated by Josué Kouka over 2 years ago

Paul Marillonnet a écrit :

"LDAPv3 defines UTF-8 based string encoding for DNs in RFC 4514 and python-ldap happily sends that to the server." (dixit le mainteneur de python-ldap)
Il ne manque pas simplement un str.decode('utf-8') quelque part dans le code du backend LDAP ?

Josué est-ce qu'il y a moyen stp que tu retrouves le DN de l'utilisateur qui a provoqué l'erreur ?
Je ne vois rien ici ni sur le rapport Sentry.

Tout est dans Sentry u'basedn': u'OU=DSI,OU=DG (Direction générale),OU=Mairie de Dreux,DC=mairiedreux,DC=local'
Un encode('utf-8') basedn dans le cas ou le basedn est une chaine unicode devrait aller je pense.

#4 Updated by Paul Marillonnet over 2 years ago

worksforme

J'ajoute des entrées utilisateurs de DN non-ascii dans un OpenLDAP (le DN apparaît en base 64 lors d'une commande ldapsearch).
La commande sync-ldap-users se passe sans problème, les utilisateurs sont bien ajoutés dans la base authentic2.

#5 Updated by Thomas Noël over 2 years ago

FYI, patch à l'arrache posé par Fred sur la recette authentic/src/authentic2/backends/ldap_backend.py pour dépanner

@@ -319,6 +319,8 @@
         # First get our configuration into a standard format
         for block in blocks:
             cls.update_default(block)
+            if isinstance(block.get('basedn'), unicode):
+                block['basedn'] = block['basedn'].encode('utf-8')
         log.debug('got config %r', blocks)
         return blocks

#6 Updated by Benjamin Dauvergne over 2 years ago

Si au passage vous pouviez parcourir tous les autres références à du contenu de la config (qui pour mémoire est du JSON et donc ça sort des chaînes unicode), il y aussi user_basedn et group_basedn dans ma mémoire qui existent (mais il y a peut-être d'autres setting qui participent à la construction de DNs).

#7 Updated by Paul Marillonnet over 2 years ago

Quelque chose comme ça ?
(Je ne vois que ces 4 éléments, je loupe quelque chose ?)

Est-ce que c'est vraiment dans get_config que cette correction doit avoir lieu ? (Pourquoi pas plutôt directement au bloc le plus local, dans get_users ?)

#8 Updated by Benjamin Dauvergne over 2 years ago

Hmm effectivement je préfèrerai qu'on ne fasse pas ça. parce que d'autres libs LDAP gère correctement l'unicode, et donc si on y passe à un moment ce sera problématique.

#9 Updated by Frédéric Péters over 2 years ago

(pour info comme il y a eu un niveau paquet authentic dans le dépôt de recette ma modification posée manuellement a été écrasée)

#10 Updated by Paul Marillonnet over 2 years ago

Frédéric, Josué m'a dit avoir ré-appliqué ton patch de façon temporaire en attendant un correctif dans la branche master.

Benjamin, est-ce que tu penses qu'il est possible (et préférable) de proposer un patch conservant la même librairie LDAP, en faisant en sorte de ne pas créer de problèmes de compatibilité si jamais on opte pour un changement de librairie ?
Ou bien est-ce qu'il vaut mieux en profiter pour migrer vers une librairie LDAP qui supporte nativement l'unicode ? (comme le fait ldap3, non ?)

#11 Updated by Benjamin Dauvergne over 2 years ago

Non on migre rien dans ce ticket, merci. On repère tous les accès à block['basedn'], comme je suis gentil j'ai fait un grep:

$ git grep -n block.*dn
src/authentic2/backends/ldap_backend.py:354:            user_basedn = block.get('user_basedn') or block['basedn']
src/authentic2/backends/ldap_backend.py:357:                if block['user_dn_template']:
src/authentic2/backends/ldap_backend.py:358:                    template = str(block['user_dn_template'])
src/authentic2/backends/ldap_backend.py:525:        group_base_dn = block.get('group_basedn', block['basedn'])
src/authentic2/backends/ldap_backend.py:843:            user_basedn = block.get('user_basedn') or block['basedn']
src/authentic2/backends/ldap_backend.py:952:                elif block['binddn'] and block['bindpw']:
src/authentic2/backends/ldap_backend.py:953:                    conn.bind_s(block['binddn'], block['bindpw'])
src/authentic2/backends/ldap_backend.py:1075:                            results = conn.search_s(block['basedn'],

et on encode à ces endroits, avec un petit commentaire # python-ldap needs UTF-8 encoded strings.

Possible que des dn soient stockés transitoirement ailleurs (notamment dans la session) faut voir si ça pose problème (les sessions sont stockées en JSON), à la fois en entrée et en sortie (en entrée il me semble que la conversion UTF-8 -> unicode se fait toute seule).

Si on peut avoir un test qui pose des DNs avec des accents c'est cool aussi.

#12 Updated by Frédéric Péters over 2 years ago

Frédéric, Josué m'a dit avoir ré-appliqué ton patch de façon temporaire en attendant un correctif dans la branche master.

Depuis il y a eu un nouveau paquet authentic poussé vers testing.

#13 Updated by Benjamin Dauvergne over 2 years ago

Oui c'est moi désolé, mais bon avec 5 personnes qui interviennent sur ce ticket, il y en a bien un qui va me faire un patch :)

#14 Updated by Paul Marillonnet over 2 years ago

Problème d'environnement de test ce soir, prise de tête, je pose juste un patch "wip" pour indiquer que je suis sur le coup.
Je soumettrai une version fonctionnelle demain.

#15 Updated by Benjamin Dauvergne over 2 years ago

Environnement de test ? Marche pas tox ?

#16 Updated by Benjamin Dauvergne over 2 years ago

(Si c'est pas clair on a un fichier tox.ini à la racine du projet qui permet d'utiliser un outil python nommé tox qui sait construire des virtualenv et lancer les tests, comme ça pas besoin de se souvenir de l'environnement à construire ou de la ligne à la lancer pour tester).

#17 Updated by Benjamin Dauvergne over 2 years ago

Et j'ai ajouté un peu de doc sur le sujet HowDoWeDoTests.

#19 Updated by Paul Marillonnet over 2 years ago

Je m'étais engagé à fournir un patch rapidement là-dessus, mais j'ai encore quelques embrouilles avec les tests.

Je suspecte une erreur non détectée à la création du DN dans la fixture ajoutée.
L'état de mon avancement est dans le patch, je vais continuer à creuser le truc.

#20 Updated by Benjamin Dauvergne over 2 years ago

Est-ce que le test passe ou pas ? Si non, tu peux recopier la trace ici pour y voir clair.

#21 Updated by Paul Marillonnet over 2 years ago

Non, le test ne passe pas.
Je suspectais une erreur lors de l'ajout de la fixture dans l'annuaire, mais ce n'est pas le cas, comme l'attestent les logs de l'annuaire :

59e752b8 conn=1004 fd=16 ACCEPT from PATH=/tmp/a2-provision-slapddK3MYQ/socket (PATH=/tmp/a2-provision-slapddK3MYQ/socket)
59e752b8 conn=1004 op=0 BIND dn="uid=admin,cn=config" method=128
59e752b8 conn=1004 op=0 BIND dn="uid=admin,cn=config" mech=SIMPLE ssf=0
59e752b8 conn=1004 op=0 RESULT tag=97 err=0 text=
59e752b8 conn=1004 op=1 ADD dn="cn=Étienne Michü,o=orga" 
59e752b8 conn=1004 op=1 RESULT tag=105 err=0 text=
59e752b8 conn=1004 op=2 ADD dn="cn=group3,o=orga" 
59e752b8 conn=1004 op=2 RESULT tag=105 err=0 text=
59e752b8 conn=1004 op=3 UNBIND
59e752b8 conn=1004 fd=16 closed

Mais, en dépit du HTTP 200 renvoyé par Authentic, la page HTML contenu dans result correspond à une erreur de login (mire de login, avec champ identifiant prérempli avec la valeur pour laquelle l'authentification n'a pas abouti). Et la base d'utilisateur d'authentic n'est pas provisionnée, User.objects.count() vaut zéro.
La trace en question comme demandée :

============================================ FAILURES =============================================
_______________________________________ test_accents_in_dn ________________________________________

slapd = <ldaptools.slapd.Slapd object at 0x7feae99f0b10>
settings = <pytest_django.fixtures.SettingsWrapper object at 0x7feae4164050>
client = <django.test.client.Client object at 0x7feae4164990>

    @pytest.mark.django_db
    def test_accents_in_dn(slapd, settings, client):
        settings.LDAP_AUTH_SETTINGS = [{
            'url': [slapd.ldap_url],
            'basedn': 'o=entité1',
            'use_tls': False,
        }]
        result = client.post('/login/', {'login-password-submit': '1',
                                         'username': USERNAME_ACCENTS,
                                         'password': PASS}, follow=True)
        assert result.status_code == 200
>       assert u'Étienne Michü' in unicode(str(result), 'utf-8')
\nVary: Cookie, Accept-Langua...iv>\n\n  \n  <script type="text/javascript" src="/static/authentic2/js/js_seconds_until.js"></script>\n\n  </body>\n</html>\n'
\nVary: Cookie, Accept-Langua...iv>\n\n  \n  <script type="text/javascript" src="/static/authentic2/js/js_seconds_until.js"></script>\n\n  </body>\n</html>\n' = unicode('Content-Language: en\r\nContent-Length: 2938\r\nLast-Modified: Wed, 18 Oct 2017 16:31:23 GMT\r\nVary: Cookie, Accept-...  \n  <script type="text/javascript" src="/static/authentic2/js/js_seconds_until.js"></script>\n\n  </body>\n</html>\n', 'utf-8')
E        +    where 'Content-Language: en\r\nContent-Length: 2938\r\nLast-Modified: Wed, 18 Oct 2017 16:31:23 GMT\r\nVary: Cookie, Accept-...  \n  <script type="text/javascript" src="/static/authentic2/js/js_seconds_until.js"></script>\n\n  </body>\n</html>\n' = str(<django.http.response.HttpResponse object at 0x7feae61e5050>)

tests/test_ldap.py:134: AssertionError

#22 Updated by Benjamin Dauvergne over 2 years ago

Ok donc là tu poses un point d'arrêt par exemple dans LdapBackend.authenticte_block() avec:

import pdb
pdb.set_trace()

et tu relances ton test:

tox -e dj18-authentic-sqlite -- tests/test_ldap.py -k accents --pdb --reuse-db

ensuite dans le debugger tu pourrais faire des next et des print pour comprendre ce qui se passe.

Dans le test lui même tu peux utiliser la fixture caplog pour voir les logs.

#23 Updated by Paul Marillonnet over 2 years ago

Ok oui c'est efficace pour voir d'où vient le problème, merci.

Après correction de l'erreur d'incohérence du DN de recherche dans mon précédent pas, j'ai de nouveau une erreur d'encodage.

On dirait qu'une str de filtre LDAP passe au travers des instructions ajoutées ici. J'inspecte la trace :


slapd = <ldaptools.slapd.Slapd object at 0x7f416f8e5410>
settings = <pytest_django.fixtures.SettingsWrapper object at 0x7f416f8e5490>

    @pytest.mark.django_db
    def test_nocreate_mandatory_roles(slapd, settings):
        User = get_user_model()
        settings.LDAP_AUTH_SETTINGS = [{
            'url': [slapd.ldap_url],
            'basedn': 'o=orga',
            'use_tls': False,
            'create_group': True,
            'group_mapping': [
                ('cn=group2,o=orga', ['Group2']),
            ],
            'group_filter': '(&(memberUid={uid})(objectClass=posixGroup))',
            'set_mandatory_roles': ['tech', 'admin'],
            'create_role': False,
        }]

>       users = list(ldap_backend.LDAPBackend.get_users())

tests/test_ldap.py:428: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
src/authentic2/backends/ldap_backend.py:869: in get_users
    yield backend._return_user(user_dn, None, conn, block, data)
src/authentic2/backends/ldap_backend.py:815: in _return_user
    return self._return_django_user(dn, username, password, conn, block, attributes)
src/authentic2/backends/ldap_backend.py:826: in _return_django_user
    self.populate_user(user, dn, username, conn, block, attributes)
src/authentic2/backends/ldap_backend.py:638: in populate_user
    self.populate_user_groups(user, dn, conn, block, attributes)
src/authentic2/backends/ldap_backend.py:559: in populate_user_groups
    group_dns = self.get_ldap_group_dns(user, dn, conn, block, attributes)
src/authentic2/backends/ldap_backend.py:550: in get_ldap_group_dns
    results = conn.search_s(group_base_dn, ldap.SCOPE_SUBTREE, query, [])
/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:599: in search_s
    return self.search_ext_s(base,scope,filterstr,attrlist,attrsonly,None,None,timeout=self.timeout)
/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:998: in search_ext_s
    return self._apply_method_s(SimpleLDAPObject.search_ext_s,*args,**kwargs)
/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:936: in _apply_method_s
    return func(self,*args,**kwargs)
/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:592: in search_ext_s
    msgid = self.search_ext(base,scope,filterstr,attrlist,attrsonly,serverctrls,clientctrls,timeout,sizelimit)
/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:588: in search_ext
    timeout,sizelimit,
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <ldap.ldapobject.ReconnectLDAPObject instance at 0x7f416e9caea8>
func = <built-in method search_ext of LDAP object at 0x7f416d48f260>
args = ('o=orga', 2, '(&(memberUid=étienne.michü)(objectClass=posixGroup))', [], 0, None, ...)
kwargs = {}, diagnostic_message_success = None

    def _ldap_call(self,func,*args,**kwargs):
      """ 
        Wrapper method mainly for serializing calls into OpenLDAP libs
        and trace logs
        """ 
      self._ldap_object_lock.acquire()
      if __debug__:
        if self._trace_level>=1:
          self._trace_file.write('*** %s %s - %s\n%s\n' % (
            repr(self),
            self._uri,
            '.'.join((self.__class__.__name__,func.__name__)),
            pprint.pformat((args,kwargs))
          ))
          if self._trace_level>=9:
            traceback.print_stack(limit=self._trace_stack_limit,file=self._trace_file)
      diagnostic_message_success = None
      try:
        try:
>         result = func(*args,**kwargs)
E         UnicodeEncodeError: 'ascii' codec can't encode character u'\xe9' in position 13: ordinal not in range(128)

/tmp/tox-paul/authentic/coverage-dj18-authentic-sqlite/local/lib/python2.7/site-packages/ldap/ldapobject.py:106: UnicodeEncodeError

J'ajoute quand même le patch WIP, je vais corriger ça.

#24 Updated by Paul Marillonnet over 2 years ago

Compris d'où venait le problème, certaines chaines unicode n'étant pas encodées correctement.

Tous les tests passent, sauf test_get_users, le dernier bulk_create.call_count vaut 0, et non pas 1 comme le voudrait le test.
Je regarde d'où ça peut venir, je pose le patch WIP en attendant.

#25 Updated by Paul Marillonnet over 2 years ago

Je vais déboguer pour voir d'où ça peut venir, mais pour l'instant c'est mystérieux pour moi :

================================================ FAILURES ================================================
_____________________________________________ test_get_users _____________________________________________

slapd = <ldaptools.slapd.Slapd object at 0x7f8b0ad18290>
settings = <pytest_django.fixtures.SettingsWrapper object at 0x7f8b0ad18590>

    @pytest.mark.django_db
    def test_get_users(slapd, settings):
        import django.db.models.base
        from types import MethodType

        User = get_user_model()
        settings.LDAP_AUTH_SETTINGS = [{
            'url': [slapd.ldap_url],
            'basedn': 'o=orga',
            'use_tls': False,
            'create_group': True,
            'group_mapping': [
                ('cn=group2,o=orga', ['Group2']),
            ],
            'group_filter': '(&(memberUid={uid})(objectClass=posixGroup))',
        }]
        save = mock.Mock(wraps=ldap_backend.LDAPUser.save)
        ldap_backend.LDAPUser.save = MethodType(save, None, ldap_backend.LDAPUser)
        bulk_create = mock.Mock(wraps=django.db.models.query.QuerySet.bulk_create)
        django.db.models.query.QuerySet.bulk_create = MethodType(bulk_create, None,
                                                                 django.db.models.query.QuerySet)

        # Provision all users and their groups
        assert User.objects.count() == 0
        users = list(ldap_backend.LDAPBackend.get_users())
        assert len(users) == 102
        assert User.objects.count() == 102
        assert bulk_create.call_count == 101
        assert save.call_count == 306

        # Check that if nothing changed no save() is made
        save.reset_mock()
        bulk_create.reset_mock()
        users = list(ldap_backend.LDAPBackend.get_users())
        assert save.call_count == 0
        assert bulk_create.call_count == 0

        # Check that if we delete 1 user, only this user is created
        save.reset_mock()
        bulk_create.reset_mock()
        User.objects.last().delete()
        assert User.objects.count() == 101
        users = list(ldap_backend.LDAPBackend.get_users())
        assert len(users) == 102
        assert User.objects.count() == 102
        assert save.call_count == 3
>       assert bulk_create.call_count == 1
E       AssertionError: assert 0 == 1
E        +  where 0 = <Mock id='140235158686544'>.call_count

tests/test_ldap.py:389: AssertionError

#26 Updated by Paul Marillonnet over 2 years ago

Il manque quelque chose AMHA. Je cherchais à bidouiller l'attribut side_effect de l'object bulk_create "mocké", mais je vais commencer par lire la doc.

#27 Updated by Benjamin Dauvergne over 2 years ago

Les bulk_create viennt de ce code ci :

 523     def populate_groups_by_mapping(self, user, dn, conn, block, group_dns):
 524         '''Assign group to user based on a mapping from group DNs'''
 525         group_mapping = block['group_mapping']
 526         if not group_mapping:
 527             return
 528         if not user.pk:
 529             user.save()
 530             user._changed = False
 531         groups = user.groups.all()
 532         for dn, group_names in group_mapping:
 533             for group_name in group_names:
 534                 group = self.get_group_by_name(block, group_name)
 535                 if group is None:
 536                     continue
 537                 # Add missing groups
 538                 if dn in group_dns and group not in groups:
 539                     user.groups.add(group)
 540                 # Remove extra groups
 541                 elif dn not in group_dns and group in groups:
 542                     user.groups.remove(group)

Notamment la ligne 539, faut croire qu'on trouve plus le groupe ou qu'il manque ou que je ne sais quoi.

#28 Updated by Paul Marillonnet over 2 years ago

Donc tu utilises bulk_create.call_count pour compter le nombre de groupes créés lors de la dernière opération de synchro, c'est ça ?

#29 Updated by Josué Kouka almost 2 years ago

  • Related to Bug #23698: LDAP: supporter les accents dans les base DNs (et peut-être aussi les filtres, à vérifier) added

#30 Updated by Benjamin Dauvergne almost 2 years ago

Ce ticket est un peu différent du #23698.

#31 Updated by Benjamin Dauvergne over 1 year ago

Bon j'ai bien fait avancer les choses dans le #23698 faudra reprendre par dessus.

Also available in: Atom PDF