First commit
authorThierry Florac <tflorac@ulthar.net>
Sat, 28 Feb 2015 15:20:14 +0100
changeset 0 94ee60dd51e1
child 1 91b1aa9ced92
First commit
.hgignore
LICENSE
MANIFEST.in
bootstrap.py
buildout.cfg
docs/HISTORY.txt
docs/README.txt
setup.py
src/pyams_ldap.egg-info/PKG-INFO
src/pyams_ldap.egg-info/SOURCES.txt
src/pyams_ldap.egg-info/dependency_links.txt
src/pyams_ldap.egg-info/entry_points.txt
src/pyams_ldap.egg-info/namespace_packages.txt
src/pyams_ldap.egg-info/not-zip-safe
src/pyams_ldap.egg-info/requires.txt
src/pyams_ldap.egg-info/top_level.txt
src/pyams_ldap/__init__.py
src/pyams_ldap/configure.zcml
src/pyams_ldap/doctests/README.txt
src/pyams_ldap/interfaces/__init__.py
src/pyams_ldap/locales/fr/LC_MESSAGES/pyams_ldap.po
src/pyams_ldap/locales/pyams_ldap.pot
src/pyams_ldap/plugin.py
src/pyams_ldap/query.py
src/pyams_ldap/tests/__init__.py
src/pyams_ldap/tests/test_utilsdocs.py
src/pyams_ldap/tests/test_utilsdocstrings.py
src/pyams_ldap/zmi/__init__.py
src/pyams_ldap/zmi/plugin.py
src/pyams_ldap/zmi/templates/ldap-attributes.pt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,19 @@
+
+syntax: regexp
+^develop-eggs$
+syntax: regexp
+^parts$
+syntax: regexp
+^bin$
+syntax: regexp
+^\.installed\.cfg$
+syntax: regexp
+^\.settings$
+syntax: regexp
+^build$
+syntax: regexp
+^dist$
+syntax: regexp
+^\.idea$
+syntax: regexp
+.*\.pyc$
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,42 @@
+Zope Public License (ZPL) Version 2.1
+=====================================
+
+A copyright notice accompanies this license document that identifies
+the copyright holders.
+
+This license has been certified as open source. It has also been designated
+as GPL compatible by the Free Software Foundation (FSF).
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+   1. Redistributions in source code must retain the accompanying copyright
+      notice, this list of conditions, and the following disclaimer.
+   2. Redistributions in binary form must reproduce the accompanying copyright
+      notice, this list of conditions, and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+   3. Names of the copyright holders must not be used to endorse or promote
+      products derived from this software without prior written permission
+      from the copyright holders.
+   4. The right to distribute this software or to use it for any purpose does
+      not give you the right to use Servicemarks (sm) or Trademarks (tm) of the
+      copyright holders. Use of them is covered by separate agreement with the
+      copyright holders.
+   5. If any files are modified, you must cause the modified files to carry
+      prominent notices stating that you changed the files and the date of any
+      change.
+
+
+Disclaimer
+==========
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
+EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MANIFEST.in	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,5 @@
+include *.txt
+recursive-include docs *
+recursive-include src *
+global-exclude *.pyc
+global-exclude *.*~
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bootstrap.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,178 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Foundation and Contributors.
+# 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.
+#
+##############################################################################
+"""Bootstrap a buildout-based project
+
+Simply run this script in a directory containing a buildout.cfg.
+The script accepts buildout command-line options, so you can
+use the -c option to specify an alternate configuration file.
+"""
+
+import os
+import shutil
+import sys
+import tempfile
+
+from optparse import OptionParser
+
+tmpeggs = tempfile.mkdtemp()
+
+usage = '''\
+[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options]
+
+Bootstraps a buildout-based project.
+
+Simply run this script in a directory containing a buildout.cfg, using the
+Python that you want bin/buildout to use.
+
+Note that by using --find-links to point to local resources, you can keep 
+this script from going over the network.
+'''
+
+parser = OptionParser(usage=usage)
+parser.add_option("-v", "--version", help="use a specific zc.buildout version")
+
+parser.add_option("-t", "--accept-buildout-test-releases",
+                  dest='accept_buildout_test_releases',
+                  action="store_true", default=False,
+                  help=("Normally, if you do not specify a --version, the "
+                        "bootstrap script and buildout gets the newest "
+                        "*final* versions of zc.buildout and its recipes and "
+                        "extensions for you.  If you use this flag, "
+                        "bootstrap and buildout will get the newest releases "
+                        "even if they are alphas or betas."))
+parser.add_option("-c", "--config-file",
+                  help=("Specify the path to the buildout configuration "
+                        "file to be used."))
+parser.add_option("-f", "--find-links",
+                  help=("Specify a URL to search for buildout releases"))
+parser.add_option("--allow-site-packages",
+                  action="store_true", default=False,
+                  help=("Let bootstrap.py use existing site packages"))
+
+
+options, args = parser.parse_args()
+
+######################################################################
+# load/install setuptools
+
+try:
+    if options.allow_site_packages:
+        import setuptools
+        import pkg_resources
+    from urllib.request import urlopen
+except ImportError:
+    from urllib2 import urlopen
+
+ez = {}
+exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez)
+
+if not options.allow_site_packages:
+    # ez_setup imports site, which adds site packages
+    # this will remove them from the path to ensure that incompatible versions 
+    # of setuptools are not in the path
+    import site
+    # inside a virtualenv, there is no 'getsitepackages'. 
+    # We can't remove these reliably
+    if hasattr(site, 'getsitepackages'):
+        for sitepackage_path in site.getsitepackages():
+            sys.path[:] = [x for x in sys.path if sitepackage_path not in x]
+
+setup_args = dict(to_dir=tmpeggs, download_delay=0)
+ez['use_setuptools'](**setup_args)
+import setuptools
+import pkg_resources
+
+# This does not (always?) update the default working set.  We will
+# do it.
+for path in sys.path:
+    if path not in pkg_resources.working_set.entries:
+        pkg_resources.working_set.add_entry(path)
+
+######################################################################
+# Install buildout
+
+ws = pkg_resources.working_set
+
+cmd = [sys.executable, '-c',
+       'from setuptools.command.easy_install import main; main()',
+       '-mZqNxd', tmpeggs]
+
+find_links = os.environ.get(
+    'bootstrap-testing-find-links',
+    options.find_links or
+    ('http://downloads.buildout.org/'
+     if options.accept_buildout_test_releases else None)
+    )
+if find_links:
+    cmd.extend(['-f', find_links])
+
+setuptools_path = ws.find(
+    pkg_resources.Requirement.parse('setuptools')).location
+
+requirement = 'zc.buildout'
+version = options.version
+if version is None and not options.accept_buildout_test_releases:
+    # Figure out the most recent final version of zc.buildout.
+    import setuptools.package_index
+    _final_parts = '*final-', '*final'
+
+    def _final_version(parsed_version):
+        for part in parsed_version:
+            if (part[:1] == '*') and (part not in _final_parts):
+                return False
+        return True
+    index = setuptools.package_index.PackageIndex(
+        search_path=[setuptools_path])
+    if find_links:
+        index.add_find_links((find_links,))
+    req = pkg_resources.Requirement.parse(requirement)
+    if index.obtain(req) is not None:
+        best = []
+        bestv = None
+        for dist in index[req.project_name]:
+            distv = dist.parsed_version
+            if _final_version(distv):
+                if bestv is None or distv > bestv:
+                    best = [dist]
+                    bestv = distv
+                elif distv == bestv:
+                    best.append(dist)
+        if best:
+            best.sort()
+            version = best[-1].version
+if version:
+    requirement = '=='.join((requirement, version))
+cmd.append(requirement)
+
+import subprocess
+if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0:
+    raise Exception(
+        "Failed to execute command:\n%s" % repr(cmd)[1:-1])
+
+######################################################################
+# Import and run buildout
+
+ws.add_entry(tmpeggs)
+ws.require(requirement)
+import zc.buildout.buildout
+
+if not [a for a in args if '=' not in a]:
+    args.append('bootstrap')
+
+# if -c was provided, we push it back into args for buildout' main function
+if options.config_file is not None:
+    args[0:0] = ['-c', options.config_file]
+
+zc.buildout.buildout.main(args)
+shutil.rmtree(tmpeggs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/buildout.cfg	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,67 @@
+[buildout]
+eggs-directory = /var/local/env/pyams/eggs
+
+socket-timeout = 3
+show-picked-versions = true
+newest = false
+
+allow-hosts =
+    bitbucket.org
+    *.python.org
+    *.sourceforge.net
+    github.com
+
+#extends = http://download.ztfy.org/webapp/ztfy.webapp.dev.cfg
+versions = versions
+newest = false
+#allow-picked-versions = false
+
+src = src
+develop = .
+          ../pyams_form
+          ../pyams_pagelet
+          ../pyams_security
+          ../pyams_skin
+          ../pyams_template
+          ../pyams_utils
+          ../pyams_viewlet
+          ../ext/lingua
+
+parts =
+    package
+    i18n
+    pyflakes
+    test
+
+[package]
+recipe = zc.recipe.egg
+eggs =
+    pyams_ldap
+    pyramid
+    zope.component
+    zope.interface
+
+[i18n]
+recipe = zc.recipe.egg
+eggs =
+    babel
+    lingua
+
+[pyflakes]
+recipe = zc.recipe.egg
+eggs = pyflakes
+scripts = pyflakes
+entry-points = pyflakes=pyflakes.scripts.pyflakes:main
+initialization = if not sys.argv[1:]: sys.argv[1:] = ["${buildout:src}"]
+
+[pyflakesrun]
+recipe = collective.recipe.cmd
+on_install = true
+cmds = ${buildout:develop}/bin/${pyflakes:scripts}
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = pyams_ldap [test]
+
+[versions]
+pyams_base = 0.1.0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,71 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+"""
+This module contains pyams_ package
+"""
+import os
+from setuptools import setup, find_packages
+
+DOCS = os.path.join(os.path.dirname(__file__),
+                    'docs')
+
+README = os.path.join(DOCS, 'README.txt')
+HISTORY = os.path.join(DOCS, 'HISTORY.txt')
+
+version = '0.1.0'
+long_description = open(README).read() + '\n\n' + open(HISTORY).read()
+
+tests_require = []
+
+setup(name='pyams_ldap',
+      version=version,
+      description="PyAMS interfaces and classes for LDAP authentication",
+      long_description=long_description,
+      classifiers=[
+          "License :: OSI Approved :: Zope Public License",
+          "Development Status :: 4 - Beta",
+          "Programming Language :: Python",
+          "Framework :: Pyramid",
+          "Topic :: Software Development :: Libraries :: Python Modules",
+      ],
+      keywords='Pyramid PyAMS LDAP',
+      author='Thierry Florac',
+      author_email='tflorac@ulthar.net',
+      url='http://hg.ztfy.org/pyams/pyams_ldap',
+      license='ZPL',
+      packages=find_packages('src'),
+      package_dir={'': 'src'},
+      namespace_packages=[],
+      include_package_data=True,
+      package_data={'': ['*.zcml', '*.txt', '*.pt', '*.pot', '*.po', '*.mo', '*.png', '*.gif', '*.jpeg', '*.jpg', '*.css', '*.js']},
+      zip_safe=False,
+      # uncomment this to be able to run tests with setup.py
+      test_suite="pyams_.tests.test_utilsdocs.test_suite",
+      tests_require=tests_require,
+      extras_require=dict(test=tests_require),
+      install_requires=[
+          'setuptools',
+          # -*- Extra requirements: -*-
+          'pyams_pagelet',
+          'pyams_security',
+          'pyams_skin',
+          'pyams_utils',
+          'pyams_viewlet',
+          'pyramid',
+          'zope.component',
+          'zope.interface',
+      ],
+      entry_points="""
+      # -*- Entry points: -*-
+      """,
+      )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/PKG-INFO	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,18 @@
+Metadata-Version: 1.1
+Name: pyams-ldap
+Version: 0.1.0
+Summary: PyAMS interfaces and classes for LDAP authentication
+Home-page: http://hg.ztfy.org/pyams/pyams_ldap
+Author: Thierry Florac
+Author-email: tflorac@ulthar.net
+License: ZPL
+Description: 
+        
+        
+Keywords: Pyramid PyAMS LDAP
+Platform: UNKNOWN
+Classifier: License :: OSI Approved :: Zope Public License
+Classifier: Development Status :: 4 - Beta
+Classifier: Programming Language :: Python
+Classifier: Framework :: Pyramid
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/SOURCES.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,24 @@
+MANIFEST.in
+setup.py
+docs/HISTORY.txt
+docs/README.txt
+src/pyams_ldap/__init__.py
+src/pyams_ldap/configure.zcml
+src/pyams_ldap/plugin.py
+src/pyams_ldap/query.py
+src/pyams_ldap.egg-info/PKG-INFO
+src/pyams_ldap.egg-info/SOURCES.txt
+src/pyams_ldap.egg-info/dependency_links.txt
+src/pyams_ldap.egg-info/entry_points.txt
+src/pyams_ldap.egg-info/namespace_packages.txt
+src/pyams_ldap.egg-info/not-zip-safe
+src/pyams_ldap.egg-info/requires.txt
+src/pyams_ldap.egg-info/top_level.txt
+src/pyams_ldap/doctests/README.txt
+src/pyams_ldap/interfaces/__init__.py
+src/pyams_ldap/tests/__init__.py
+src/pyams_ldap/tests/test_utilsdocs.py
+src/pyams_ldap/tests/test_utilsdocstrings.py
+src/pyams_ldap/zmi/__init__.py
+src/pyams_ldap/zmi/plugin.py
+src/pyams_ldap/zmi/templates/ldap-attributes.pt
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/dependency_links.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/entry_points.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,3 @@
+
+      # -*- Entry points: -*-
+      
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/namespace_packages.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/not-zip-safe	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/requires.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,11 @@
+setuptools
+pyams_pagelet
+pyams_security
+pyams_skin
+pyams_utils
+pyams_viewlet
+pyramid
+zope.component
+zope.interface
+
+[test]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap.egg-info/top_level.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,1 @@
+pyams_ldap
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/__init__.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,35 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+from pyramid.i18n import TranslationStringFactory
+_ = TranslationStringFactory('pyams_ldap')
+
+
+def includeme(config):
+    """Pyramid include"""
+
+    # add translations
+    config.add_translation_dirs('pyams_ldap:locales')
+
+    # load registry components
+    try:
+        import pyams_zmi
+    except ImportError:
+        config.scan(ignore='pyams_ldap.zmi')
+    else:
+        config.scan()
+
+    if hasattr(config, 'load_zcml'):
+        config.load_zcml('configure.zcml')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/configure.zcml	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,7 @@
+<configure
+	xmlns="http://pylonshq.com/pyramid">
+
+	<include package="pyramid_zcml" />
+
+
+</configure>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/doctests/README.txt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,3 @@
+==================
+pyams_ldap package
+==================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/interfaces/__init__.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,157 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+from ldap3 import SEARCH_SCOPE_BASE_OBJECT, SEARCH_SCOPE_SINGLE_LEVEL, SEARCH_SCOPE_WHOLE_SUBTREE
+
+# import interfaces
+from pyams_security.interfaces import IAuthenticationPlugin, IDirectorySearchPlugin
+
+# import packages
+from zope.schema import TextLine, Bool, Int, Choice
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+from pyams_ldap import _
+
+
+SEARCH_SCOPES = {SEARCH_SCOPE_BASE_OBJECT: _("Base object"),
+                 SEARCH_SCOPE_SINGLE_LEVEL: _("Single level"),
+                 SEARCH_SCOPE_WHOLE_SUBTREE: _("Whole subtree")}
+
+SEARCH_SCOPES_VOCABULARY = SimpleVocabulary([SimpleTerm(v, title=t) for v, t in SEARCH_SCOPES.items()])
+
+
+class ILDAPPlugin(IAuthenticationPlugin, IDirectorySearchPlugin):
+    """LDAP authentication plug-in interface"""
+
+    server_uri = TextLine(title=_("LDAP server URI"),
+                          description=_("Full URI (including protocol) of LDAP server"),
+                          default="ldap://localhost:389",
+                          required=True)
+
+    bind_dn = TextLine(title=_("Bind DN"),
+                       description=_("DN used for LDAP bind; keep empty for anonymous"),
+                       required=False)
+
+    bind_password = TextLine(title=_("Bind password"),
+                             description=_("Password used for LDAP bind"),
+                             required=False)
+
+    use_tls = Bool(title=_("Use TLS?"),
+                   required=True,
+                   default=False)
+
+    use_pool = Bool(title=_("Use connections pool?"),
+                    required=True,
+                    default=True)
+
+    pool_size = Int(title=_("Pool size"),
+                    required=False,
+                    default=10)
+
+    pool_lifetime = Int(title=_("Pool lifetime"),
+                        description=_("Duration, in seconds, of pool lifetime"),
+                        required=False)
+
+    base_dn = TextLine(title=_("Base DN"),
+                       description=_("LDAP base DN"),
+                       required=True)
+
+    search_scope = Choice(title=_("Search scope"),
+                          vocabulary=SEARCH_SCOPES_VOCABULARY,
+                          default=SEARCH_SCOPE_WHOLE_SUBTREE,
+                          required=True)
+
+    login_attribute = TextLine(title=_("Login attribute"),
+                               description=_("LDAP attribute used as user login"),
+                               required=True,
+                               default='uid')
+
+    login_query = TextLine(title=_("Login query"),
+                           description=_("Query template used to authenticate user "
+                                         "(based on login attribute called 'login')"),
+                           required=True,
+                           default='(uid={login})')
+
+    uid_attribute = TextLine(title=_("UID attribute"),
+                             description=_("LDAP attribute used as principal identifier"),
+                             required=True,
+                             default='dn')
+
+    uid_query = TextLine(title=_("UID query"),
+                         description=_("Query template used to get principal information "
+                                       "(based on UID attribute called 'login')"),
+                         required=True,
+                         default="(objectClass=*)")
+
+    title_format = TextLine(title=_("Title format"),
+                            description=_("Principal's title format string"),
+                            required=True,
+                            default='{givenName[0]} {sn[0]}')
+
+    groups_base_dn = TextLine(title=_("Groups base DN"),
+                              description=_("Base DN used to search LDAP groups; keep empty to "
+                                            "disable groups usage"),
+                              required=False)
+
+    groups_search_scope = Choice(title=_("Groups search scope"),
+                                 vocabulary=SEARCH_SCOPES_VOCABULARY,
+                                 default=SEARCH_SCOPE_WHOLE_SUBTREE,
+                                 required=False)
+
+    groups_query = TextLine(title=_("Groups query"),
+                            description=_("Query template used to get principal groups "
+                                          "(based on DN and UID attributes called 'dn' "
+                                          "and 'login')"),
+                            required=False,
+                            default="(&(objectClass=groupOfUniqueNames)(uniqueMember={dn}))")
+
+    group_prefix = TextLine(title=_("Group prefix"),
+                            description=_("Prefix used to identify groups"),
+                            required=False,
+                            default='group')
+
+    group_uid_attribute = TextLine(title=_("Group UID attribute"),
+                                   description=_("LDAP attribute used as group identifier"),
+                                   required=False,
+                                   default='dn')
+
+    group_title_format = TextLine(title=_("Group title format"),
+                                  description=_("Principal's title format string"),
+                                  required=True,
+                                  default='{cn[0]}')
+
+    users_select_query = TextLine(title=_("Users select query"),
+                                  description=_("Query template used to select users"),
+                                  required=True,
+                                  default='(|(givenName={query}*)(sn={query}*))')
+
+    users_search_query = TextLine(title=_("Users search query"),
+                                  description=_("Query template used to search users"),
+                                  required=True,
+                                  default='(|(givenName={query}*)(sn={query}*))')
+
+    groups_select_query = TextLine(title=_("Groups select query"),
+                                   description=_("Query template used to select groups"),
+                                   required=True,
+                                   default='(cn=*{query}*)')
+
+    groups_search_query = TextLine(title=_("Groups search query"),
+                                   description=_("Query template used to search groups"),
+                                   required=True,
+                                   default='(cn=*{query}*)')
+
+    def get_connection(self):
+        """Get LDAP connection"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/locales/fr/LC_MESSAGES/pyams_ldap.po	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,264 @@
+#
+# French translations for PACKAGE package
+# This file is distributed under the same license as the PACKAGE package.
+# Thierry Florac <tflorac@ulthar.net>, 2015.
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-02-28 09:16+0100\n"
+"PO-Revision-Date: 2015-02-28 09:17+0100\n"
+"Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
+"Language-Team: French\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: src/pyams_ldap/zmi/plugin.py:68
+msgid "Add LDAP users folder..."
+msgstr "Ajouter un dossier LDAP..."
+
+#: src/pyams_ldap/zmi/plugin.py:84
+msgid "System security manager"
+msgstr "Gestionnaire de sécurité"
+
+#: src/pyams_ldap/zmi/plugin.py:85
+msgid "Add LDAP users folder plug-in"
+msgstr "Ajout d'un dossier d'accès LDAP"
+
+#: src/pyams_ldap/zmi/plugin.py:118 src/pyams_ldap/zmi/plugin.py:206
+msgid "Connection"
+msgstr "Connexion"
+
+#: src/pyams_ldap/zmi/plugin.py:130 src/pyams_ldap/zmi/plugin.py:218
+msgid "Users schema"
+msgstr "Schéma des utilisateurs"
+
+#: src/pyams_ldap/zmi/plugin.py:142 src/pyams_ldap/zmi/plugin.py:230
+msgid "Groups schema"
+msgstr "Schéma des groupes"
+
+#: src/pyams_ldap/zmi/plugin.py:154 src/pyams_ldap/zmi/plugin.py:242
+msgid "Search settings"
+msgstr "Recherches"
+
+#: src/pyams_ldap/zmi/plugin.py:171
+msgid "Edit LDAP users folder plug-in properties"
+msgstr "Modification des propriétés d'un dossier d'accès LDAP"
+
+#: src/pyams_ldap/zmi/plugin.py:272
+msgid "Search users and groups"
+msgstr "Utilisateurs et groupes"
+
+#: src/pyams_ldap/zmi/plugin.py:281
+msgid "Search results"
+msgstr "Résultats de la recherche"
+
+#: src/pyams_ldap/zmi/plugin.py:309
+msgid "Common name"
+msgstr "Nom courant"
+
+#: src/pyams_ldap/zmi/plugin.py:319
+msgid "E-mail"
+msgstr "Adresse email"
+
+#: src/pyams_ldap/zmi/plugin.py:338
+#, python-format
+msgid "Display LDAP entry: {dn}"
+msgstr "Entrée LDAP : {dn}"
+
+#: src/pyams_ldap/interfaces/__init__.py:29
+msgid "Base object"
+msgstr "Objet de base"
+
+#: src/pyams_ldap/interfaces/__init__.py:30
+msgid "Single level"
+msgstr "Un niveau"
+
+#: src/pyams_ldap/interfaces/__init__.py:31
+msgid "Whole subtree"
+msgstr "Sous-arbre complet"
+
+#: src/pyams_ldap/interfaces/__init__.py:39
+msgid "LDAP server URI"
+msgstr "URI du serveur"
+
+#: src/pyams_ldap/interfaces/__init__.py:40
+msgid "Full URI (including protocol) of LDAP server"
+msgstr "URI complète (y compris le protocole) d'accès au serveur LDAP"
+
+#: src/pyams_ldap/interfaces/__init__.py:44
+msgid "Bind DN"
+msgstr "DN de connexion"
+
+#: src/pyams_ldap/interfaces/__init__.py:45
+msgid "DN used for LDAP bind; keep empty for anonymous"
+msgstr "DN utilisé pour la connexion LDAP ; laissez vide pour une connexion anonyme"
+
+#: src/pyams_ldap/interfaces/__init__.py:48
+msgid "Bind password"
+msgstr "Mot de passe"
+
+#: src/pyams_ldap/interfaces/__init__.py:49
+msgid "Password used for LDAP bind"
+msgstr "Mot de passe utilisé pour la connexion LDAP"
+
+#: src/pyams_ldap/interfaces/__init__.py:52
+msgid "Use TLS?"
+msgstr "Utiliser TLS ?"
+
+#: src/pyams_ldap/interfaces/__init__.py:56
+msgid "Use connections pool?"
+msgstr "Pool de connexions ?"
+
+#: src/pyams_ldap/interfaces/__init__.py:60
+msgid "Pool size"
+msgstr "Taille du pool"
+
+#: src/pyams_ldap/interfaces/__init__.py:64
+msgid "Pool lifetime"
+msgstr "Durée de vie du pool"
+
+#: src/pyams_ldap/interfaces/__init__.py:65
+msgid "Duration, in seconds, of pool lifetime"
+msgstr "En secondes"
+
+#: src/pyams_ldap/interfaces/__init__.py:68
+msgid "Base DN"
+msgstr "DN de base"
+
+#: src/pyams_ldap/interfaces/__init__.py:69
+msgid "LDAP base DN"
+msgstr "DN de base pour la recherche des utilisateurs"
+
+#: src/pyams_ldap/interfaces/__init__.py:72
+msgid "Search scope"
+msgstr "Portée de la recherche"
+
+#: src/pyams_ldap/interfaces/__init__.py:77
+msgid "Login attribute"
+msgstr "Attribut de connexion"
+
+#: src/pyams_ldap/interfaces/__init__.py:78
+msgid "LDAP attribute used as user login"
+msgstr "Nom de l'attribut LDAP utilisé lors de la connexion"
+
+#: src/pyams_ldap/interfaces/__init__.py:82
+msgid "Login query"
+msgstr "Requête de connexion"
+
+#: src/pyams_ldap/interfaces/__init__.py:83
+msgid ""
+"Query template used to authenticate user (based on login attribute called "
+"'login')"
+msgstr ""
+"Modèle de la requête utilisée lors de la connexion d'un utilisateur ; "
+"la variable 'login' correspond à la saisie de l'utilisateur"
+
+#: src/pyams_ldap/interfaces/__init__.py:88
+msgid "UID attribute"
+msgstr "Attribut UID"
+
+#: src/pyams_ldap/interfaces/__init__.py:89
+msgid "LDAP attribute used as principal identifier"
+msgstr "Attribut LDAP unique utilisé pour l'identification d'un utilisateur"
+
+#: src/pyams_ldap/interfaces/__init__.py:93
+msgid "UID query"
+msgstr "Requête d'UID"
+
+#: src/pyams_ldap/interfaces/__init__.py:94
+msgid ""
+"Query template used to get principal information (based on UID attribute "
+"called 'login')"
+msgstr ""
+"Modèle de la requête utilisée pour rechercher les informations relatives à un "
+"utilisateur à partir de son UID (variable 'login')"
+
+#: src/pyams_ldap/interfaces/__init__.py:99
+msgid "Title format"
+msgstr "Format du nom"
+
+#: src/pyams_ldap/interfaces/__init__.py:100
+#: src/pyams_ldap/interfaces/__init__.py:132
+msgid "Principal's title format string"
+msgstr "Chaîne de formatage du nom"
+
+#: src/pyams_ldap/interfaces/__init__.py:104
+msgid "Groups base DN"
+msgstr "DN de base"
+
+#: src/pyams_ldap/interfaces/__init__.py:105
+msgid "Base DN used to search LDAP groups; keep empty to disable groups usage"
+msgstr "DN de base pour la recherche des groupes ; laissez le vide pour ne "
+"pas activer la gestion des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:109
+msgid "Groups search scope"
+msgstr "Portée de la recherche"
+
+#: src/pyams_ldap/interfaces/__init__.py:114
+msgid "Groups query"
+msgstr "Recherche des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:115
+msgid ""
+"Query template used to get principal groups (based on DN and UID attributes "
+"called 'dn' and 'login')"
+msgstr ""
+"Modèle de requête utilisée pour extraire la liste des groupes d'un utilisateur "
+"(à partir de ses attributes appelés 'dn' et 'uid')"
+
+#: src/pyams_ldap/interfaces/__init__.py:121
+msgid "Group prefix"
+msgstr "Préfixe des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:122
+msgid "Prefix used to identify groups"
+msgstr "Préfixe utilisé pour identifier les groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:126
+msgid "Group UID attribute"
+msgstr "Attribut UID"
+
+#: src/pyams_ldap/interfaces/__init__.py:127
+msgid "LDAP attribute used as group identifier"
+msgstr "Attribut LDAP utilisé pour identifier les groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:131
+msgid "Group title format"
+msgstr "Format du nom"
+
+#: src/pyams_ldap/interfaces/__init__.py:136
+msgid "Users select query"
+msgstr "Sélection des utilisateurs"
+
+#: src/pyams_ldap/interfaces/__init__.py:137
+msgid "Query template used to select users"
+msgstr "Modèle de la requête utilisée pour la sélection des utilisateurs"
+
+#: src/pyams_ldap/interfaces/__init__.py:141
+msgid "Users search query"
+msgstr "Recherche des utilisateurs"
+
+#: src/pyams_ldap/interfaces/__init__.py:142
+msgid "Query template used to search users"
+msgstr "Modèle de la requête utilisée pour la recherche des utilisateurs"
+
+#: src/pyams_ldap/interfaces/__init__.py:146
+msgid "Groups select query"
+msgstr "Sélection des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:147
+msgid "Query template used to select groups"
+msgstr "Modèle de la requête utilisée pour la sélection des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:151
+msgid "Groups search query"
+msgstr "Recherche des groupes"
+
+#: src/pyams_ldap/interfaces/__init__.py:152
+msgid "Query template used to search groups"
+msgstr "Modèle de la requête utilisée pour la recherche des groupes"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/locales/pyams_ldap.pot	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,257 @@
+# 
+# SOME DESCRIPTIVE TITLE
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2015.
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-02-28 09:16+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+
+#: ./src/pyams_ldap/zmi/plugin.py:68
+msgid "Add LDAP users folder..."
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:84
+msgid "System security manager"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:85
+msgid "Add LDAP users folder plug-in"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:118 ./src/pyams_ldap/zmi/plugin.py:206
+msgid "Connection"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:130 ./src/pyams_ldap/zmi/plugin.py:218
+msgid "Users schema"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:142 ./src/pyams_ldap/zmi/plugin.py:230
+msgid "Groups schema"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:154 ./src/pyams_ldap/zmi/plugin.py:242
+msgid "Search settings"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:171
+msgid "Edit LDAP users folder plug-in properties"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:272
+msgid "Search users and groups"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:281
+msgid "Search results"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:309
+msgid "Common name"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:319
+msgid "E-mail"
+msgstr ""
+
+#: ./src/pyams_ldap/zmi/plugin.py:338
+#, python-format
+msgid "Display LDAP entry: {dn}"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:29
+msgid "Base object"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:30
+msgid "Single level"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:31
+msgid "Whole subtree"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:39
+msgid "LDAP server URI"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:40
+msgid "Full URI (including protocol) of LDAP server"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:44
+msgid "Bind DN"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:45
+msgid "DN used for LDAP bind; keep empty for anonymous"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:48
+msgid "Bind password"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:49
+msgid "Password used for LDAP bind"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:52
+msgid "Use TLS?"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:56
+msgid "Use connections pool?"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:60
+msgid "Pool size"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:64
+msgid "Pool lifetime"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:65
+msgid "Duration, in seconds, of pool lifetime"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:68
+msgid "Base DN"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:69
+msgid "LDAP base DN"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:72
+msgid "Search scope"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:77
+msgid "Login attribute"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:78
+msgid "LDAP attribute used as user login"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:82
+msgid "Login query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:83
+msgid ""
+"Query template used to authenticate user (based on login attribute called "
+"'login')"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:88
+msgid "UID attribute"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:89
+msgid "LDAP attribute used as principal identifier"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:93
+msgid "UID query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:94
+msgid ""
+"Query template used to get principal information (based on UID attribute "
+"called 'login')"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:99
+msgid "Title format"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:100
+#: ./src/pyams_ldap/interfaces/__init__.py:132
+msgid "Principal's title format string"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:104
+msgid "Groups base DN"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:105
+msgid "Base DN used to search LDAP groups; keep empty to disable groups usage"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:109
+msgid "Groups search scope"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:114
+msgid "Groups query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:115
+msgid ""
+"Query template used to get principal groups (based on DN and UID attributes "
+"called 'dn' and 'login')"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:121
+msgid "Group prefix"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:122
+msgid "Prefix used to identify groups"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:126
+msgid "Group UID attribute"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:127
+msgid "LDAP attribute used as group identifier"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:131
+msgid "Group title format"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:136
+msgid "Users select query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:137
+msgid "Query template used to select users"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:141
+msgid "Users search query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:142
+msgid "Query template used to search users"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:146
+msgid "Groups select query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:147
+msgid "Query template used to select groups"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:151
+msgid "Groups search query"
+msgstr ""
+
+#: ./src/pyams_ldap/interfaces/__init__.py:152
+msgid "Query template used to search groups"
+msgstr ""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/plugin.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,330 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import ldap3
+import logging
+logger = logging.getLogger('pyams_ldap')
+
+import re
+
+# import interfaces
+from pyams_ldap.interfaces import ILDAPPlugin
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from beaker.cache import cache_region
+from persistent import Persistent
+from pyams_ldap.query import LDAPQuery
+from pyams_security.principal import PrincipalInfo
+from pyams_utils.registry import query_utility
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+managers = {}
+
+
+FORMAT_ATTRIBUTES = re.compile("\{(\w+)\[?\d*\]?\}")
+
+
+class ConnectionManager(object):
+    """LDAP connections manager"""
+
+    def __init__(self, plugin):
+        self.server = ldap3.Server(plugin.host,
+                                   port=plugin.port,
+                                   use_ssl=plugin.use_ssl,
+                                   tls=plugin.use_tls)
+        self.bind_dn = plugin.bind_dn
+        self.password = plugin.bind_password
+        if plugin.use_pool:
+            self.strategy = ldap3.STRATEGY_REUSABLE_THREADED
+            self.pool_name = 'pyams_ldap:{prefix}'.format(prefix=plugin.prefix)
+            self.pool_size = plugin.pool_size
+            self.pool_lifetime = plugin.pool_lifetime
+        else:
+            self.strategy = ldap3.STRATEGY_ASYNC_THREADED
+            self.pool_name = None
+            self.pool_size = None
+            self.pool_lifetime = None
+
+    def get_connection(self, user=None, password=None):
+        if user:
+            conn = ldap3.Connection(self.server,
+                                    user=user, password=password,
+                                    client_strategy=ldap3.STRATEGY_SYNC,
+                                    auto_bind=True, lazy=False, read_only=True)
+        else:
+            conn = ldap3.Connection(self.server,
+                                    user=self.bind_dn, password=self.password,
+                                    client_strategy=self.strategy,
+                                    pool_name=self.pool_name,
+                                    pool_size=self.pool_size,
+                                    pool_lifetime=self.pool_lifetime,
+                                    auto_bind=True, lazy=False, read_only=True)
+        return conn
+
+@implementer(ILDAPPlugin)
+class LDAPPlugin(Persistent, Contained):
+    """LDAP authentication plug-in"""
+
+    prefix = FieldProperty(ILDAPPlugin['prefix'])
+    title = FieldProperty(ILDAPPlugin['title'])
+    enabled = FieldProperty(ILDAPPlugin['enabled'])
+
+    _scheme = None
+    _host = None
+    _port = None
+    _use_ssl = False
+
+    _server_uri = FieldProperty(ILDAPPlugin['server_uri'])
+    bind_dn = FieldProperty(ILDAPPlugin['bind_dn'])
+    bind_password = FieldProperty(ILDAPPlugin['bind_password'])
+    use_tls = FieldProperty(ILDAPPlugin['use_tls'])
+    use_pool = FieldProperty(ILDAPPlugin['use_pool'])
+    pool_size = FieldProperty(ILDAPPlugin['pool_size'])
+    pool_lifetime = FieldProperty(ILDAPPlugin['pool_lifetime'])
+    base_dn = FieldProperty(ILDAPPlugin['base_dn'])
+    search_scope = FieldProperty(ILDAPPlugin['search_scope'])
+    login_attribute = FieldProperty(ILDAPPlugin['login_attribute'])
+    login_query = FieldProperty(ILDAPPlugin['login_query'])
+    uid_attribute = FieldProperty(ILDAPPlugin['uid_attribute'])
+    uid_query = FieldProperty(ILDAPPlugin['uid_query'])
+    title_format = FieldProperty(ILDAPPlugin['title_format'])
+    groups_base_dn = FieldProperty(ILDAPPlugin['groups_base_dn'])
+    groups_search_scope = FieldProperty(ILDAPPlugin['groups_search_scope'])
+    groups_query = FieldProperty(ILDAPPlugin['groups_query'])
+    group_prefix = FieldProperty(ILDAPPlugin['group_prefix'])
+    group_uid_attribute = FieldProperty(ILDAPPlugin['group_uid_attribute'])
+    group_title_format = FieldProperty(ILDAPPlugin['group_title_format'])
+
+    users_select_query = FieldProperty(ILDAPPlugin['users_select_query'])
+    users_search_query = FieldProperty(ILDAPPlugin['users_search_query'])
+    groups_select_query = FieldProperty(ILDAPPlugin['groups_select_query'])
+    groups_search_query = FieldProperty(ILDAPPlugin['groups_search_query'])
+
+    @property
+    def server_uri(self):
+        return self._server_uri
+
+    @server_uri.setter
+    def server_uri(self, value):
+        self._server_uri = value
+        try:
+            scheme, host = value.split('://', 1)
+        except ValueError:
+            scheme = 'ldap'
+            host = value
+        self._use_ssl = scheme == 'ldaps'
+        self._scheme = scheme
+        try:
+            host, port = host.split(':', 1)
+            port = int(port)
+        except ValueError:
+            port = 636 if self._use_ssl else 389
+        self._host = host
+        self._port = port
+
+    @property
+    def scheme(self):
+        return self._scheme
+
+    @property
+    def host(self):
+        return self._host
+
+    @property
+    def port(self):
+        return self._port
+
+    @property
+    def use_ssl(self):
+        return self._use_ssl
+
+    def _get_id(self):
+        intids = query_utility(IIntIds)
+        return intids.register(self)
+
+    def clear(self):
+        self_id = self._get_id()
+        if self_id in managers:
+            del managers[self_id]
+
+    def get_connection(self, user=None, password=None):
+        self_id = self._get_id()
+        if self_id not in managers:
+            managers[self_id] = ConnectionManager(self)
+        return managers[self_id].get_connection(user, password)
+
+    def authenticate(self, credentials, request):
+        if not self.enabled:
+            return None
+        attrs = credentials.attributes
+        login = attrs.get('login')
+        password = attrs.get('password')
+        conn = self.get_connection()
+        search = LDAPQuery(self.base_dn, self.login_query, self.search_scope, (self.login_attribute,
+                                                                               self.uid_attribute))
+        result = search.execute(conn, login=login, password=password)
+        if not result or len(result) > 1:
+            return None
+        result = result[0]
+        login_dn = result[0]
+        try:
+            login_conn = self.get_connection(user=login_dn, password=password)
+            login_conn.unbind()
+        except ldap3.LDAPException:
+            logger.debug("LDAP authentication exception with login %r", login, exc_info=True)
+            return None
+        else:
+            if self.uid_attribute == 'dn':
+                return "{prefix}:{dn}".format(prefix=self.prefix,
+                                              dn=login_dn)
+            else:
+                attrs = result[1]
+                if self.login_attribute in attrs:
+                    return "{prefix}:{attr}".format(prefix=self.prefix,
+                                                    attr=attrs[self.uid_attribute][0])
+
+    def _get_group(self, group_id):
+        if not self.enabled:
+            return None
+
+    def get_principal(self, principal_id):
+        if not self.enabled:
+            return None
+        if not principal_id.startswith(self.prefix + ':'):
+            return None
+        prefix, login = principal_id.split(':', 1)
+        conn = self.get_connection()
+        if login.startswith(self.group_prefix + ':'):
+            group_prefix, group_id = login.split(':', 1)
+            attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format)
+            if self.group_uid_attribute == 'dn':
+                search = LDAPQuery(group_id, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes)
+            else:
+                search = LDAPQuery(self.base_dn, self.uid_query, self.search_scope, attributes)
+            result = search.execute(conn, login=group_id)
+            if not result or len(result) > 1:
+                return None
+            group_dn, attrs = result[0]
+            return PrincipalInfo(id='{prefix}:{group_prefix}:{group_id}'.format(prefix=self.prefix,
+                                                                                group_prefix=self.group_prefix,
+                                                                                group_id=group_id),
+                                 title=self.group_title_format.format(**attrs),
+                                 dn=group_dn)
+        else:
+            attributes = FORMAT_ATTRIBUTES.findall(self.title_format)
+            if self.uid_attribute == 'dn':
+                search = LDAPQuery(login, '(objectClass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, attributes)
+            else:
+                search = LDAPQuery(self.base_dn, self.uid_query, self.search_scope, attributes)
+            result = search.execute(conn, login=login)
+            if not result or len(result) > 1:
+                return None
+            user_dn, attrs = result[0]
+            return PrincipalInfo(id='{prefix}:{login}'.format(prefix=self.prefix,
+                                                              login=login),
+                                 title=self.title_format.format(**attrs),
+                                 dn=user_dn)
+
+    def _get_groups(self, principal):
+        if not self.groups_base_dn:
+            raise StopIteration
+        principal_dn = principal.attributes.get('dn')
+        if principal_dn is None:
+            raise StopIteration
+        conn = self.get_connection()
+        attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format)
+        search = LDAPQuery(self.groups_base_dn, self.groups_query, self.groups_search_scope, attributes)
+        for group_dn, group_attrs in search.execute(conn, dn=principal_dn):
+            if self.group_uid_attribute == 'dn':
+                yield '{prefix}:{group_prefix}:{dn}'.format(prefix=self.prefix,
+                                                            group_prefix=self.group_prefix,
+                                                            dn=group_dn)
+            else:
+                yield '{prefix}:{group_prefix}:{attr}'.format(prefix=self.prefix,
+                                                              group_prefix=self.group_prefix,
+                                                              attr=group_attrs[self.group_uid_attribute])
+
+    @cache_region('short')
+    def get_all_principals(self, principal_id):
+        if not self.enabled:
+            return set()
+        principal = self.get_principal(principal_id)
+        if principal is not None:
+            result = {principal_id}
+            if self.groups_query:
+                result |= set(self._get_groups(principal))
+            return result
+        return set()
+
+    def find_principals(self, query):
+        if not self.enabled:
+            raise StopIteration
+        if not query:
+            return None
+        conn = self.get_connection()
+        # users search
+        attributes = FORMAT_ATTRIBUTES.findall(self.title_format) + [self.uid_attribute, ]
+        search = LDAPQuery(self.base_dn, self.users_select_query, self.search_scope, attributes)
+        for user_dn, user_attrs in search.execute(conn, query=query):
+            if self.uid_attribute == 'dn':
+                yield PrincipalInfo(id='{prefix}:{dn}'.format(prefix=self.prefix,
+                                                              dn=user_dn),
+                                    title=self.title_format.format(**user_attrs),
+                                    dn=user_dn)
+            else:
+                yield PrincipalInfo(id='{prefix}:{attr}'.format(prefix=self.prefix,
+                                                                attr=user_attrs[self.uid_attribute][0]),
+                                    title=self.title_format.format(**user_attrs),
+                                    dn=user_dn)
+        # groups search
+        if self.groups_base_dn:
+            attributes = FORMAT_ATTRIBUTES.findall(self.group_title_format) + [self.group_uid_attribute, ]
+            search = LDAPQuery(self.groups_base_dn, self.groups_select_query, self.groups_search_scope, attributes)
+            for group_dn, group_attrs in search.execute(conn, query=query):
+                if self.group_uid_attribute == 'dn':
+                    yield PrincipalInfo(id='{prefix}:{group_prefix}:{dn}'.format(prefix=self.prefix,
+                                                                                 group_prefix=self.group_prefix,
+                                                                                 dn=group_dn),
+                                        title=self.group_title_format.format(**group_attrs),
+                                        dn=group_dn)
+                else:
+                    yield PrincipalInfo(id='{prefix}:{group_prefix}:{attr}'.format(prefix=self.prefix,
+                                                                                   group_prefix=self.group_prefix,
+                                                                                   attr=group_attrs[
+                                                                                       self.group_uid_attribute][0]),
+                                        title=self.group_title_format.format(**group_attrs),
+                                        dn=group_dn)
+
+    def get_search_results(self, data):
+        # LDAP search results are made of tuples containing DN and all
+        # entries attributes
+        query = data.get('query')
+        if not query:
+            return ()
+        conn = self.get_connection()
+        # users search
+        search = LDAPQuery(self.base_dn, self.users_search_query, self.search_scope, ldap3.ALL_ATTRIBUTES)
+        for user_dn, user_attrs in search.execute(conn, query=query):
+            yield user_dn, user_attrs
+        # groups search
+        if self.groups_base_dn:
+            search = LDAPQuery(self.groups_base_dn, self.groups_search_query, self.groups_search_scope, ldap3.ALL_ATTRIBUTES)
+            for group_dn, group_attrs in search.execute(conn, query=query):
+                yield group_dn, group_attrs
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/query.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,54 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import logging
+logger = logging.getLogger('PyAMS (ldap)')
+
+# import interfaces
+
+# import packages
+
+
+class LDAPQuery(object):
+    """Object representing an LDAP query"""
+
+    def __init__(self, base_dn, filter_tmpl, scope, attributes):
+        self.base_dn = base_dn
+        self.filter_tmpl = filter_tmpl
+        self.scope = scope
+        self.attributes = attributes
+
+    def __str__(self):
+        return ('base_dn={base_dn}, filter_tmpl={filter_tmpl}, '
+                'scope={scope}, attributes={attributes}'.format(**self.__dict__))
+
+    def execute(self, conn, **kwargs):
+        key = (self.base_dn.format(**kwargs),
+               self.filter_tmpl.format(**kwargs))
+        logger.debug(">>> executing LDAP query: {0} (base {1}) <<< {1}".format(self.filter_tmpl,
+                                                                               self.base_dn,
+                                                                               str(kwargs.items())))
+        ret = conn.search(search_scope=self.scope,
+                          attributes=self.attributes,
+                          *key)
+        result, ret = conn.get_response(ret)
+        if result is None:
+            result = []
+        else:
+            result = [(r['dn'], r['attributes']) for r in result
+                      if 'dn' in r]
+        logger.debug("<<< LDAP result = {0}".format(str(result)))
+        return result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/tests/__init__.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/tests/test_utilsdocs.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,59 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+"""
+Generic Test case for pyams_ldap doctest
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.dirname(__file__)
+
+def doc_suite(test_dir, setUp=None, tearDown=None, globs=None):
+    """Returns a test suite, based on doctests found in /doctest."""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    doctest_dir = os.path.join(package_dir, 'doctests')
+
+    # filtering files on extension
+    docs = [os.path.join(doctest_dir, doc) for doc in
+            os.listdir(doctest_dir) if doc.endswith('.txt')]
+
+    for test in docs:
+        suite.append(doctest.DocFileSuite(test, optionflags=flags,
+                                          globs=globs, setUp=setUp,
+                                          tearDown=tearDown,
+                                          module_relative=False))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/tests/test_utilsdocstrings.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,62 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+"""
+Generic Test case for pyams_ldap doc strings
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.abspath(os.path.dirname(__file__))
+
+def doc_suite(test_dir, globs=None):
+    """Returns a test suite, based on doc tests strings found in /*.py"""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    # filtering files on extension
+    docs = [doc for doc in
+            os.listdir(package_dir) if doc.endswith('.py')]
+    docs = [doc for doc in docs if not doc.startswith('__')]
+
+    for test in docs:
+        fd = open(os.path.join(package_dir, test))
+        content = fd.read()
+        fd.close()
+        if '>>> ' not in content:
+            continue
+        test = test.replace('.py', '')
+        location = 'pyams_ldap.%s' % test
+        suite.append(doctest.DocTestSuite(location, optionflags=flags,
+                                          globs=globs))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/zmi/__init__.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/zmi/plugin.py	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,370 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# 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 pyramid.view import view_config
+from z3c.form.interfaces import DISPLAY_MODE
+from z3c.table.column import GetAttrColumn
+from z3c.table.interfaces import IColumn
+from zope.component.interfaces import ISite
+from pyams_form.form import AJAXAddForm, AJAXEditForm, InnerEditForm, InnerAddForm
+from pyams_form.interfaces.form import IInnerTabForm, IWidgetsSuffixViewletsManager
+from pyams_form.search import SearchView, SearchResultsView
+from pyams_ldap.interfaces import ILDAPPlugin
+from pyams_ldap.plugin import LDAPPlugin
+from pyams_ldap.query import LDAPQuery
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_security.interfaces import ISecurityManager, IPlugin
+from pyams_security.zmi.interfaces import ISecurityManagerToolbarAddingMenu
+from pyams_security.zmi.utility import SecurityManagerPluginsTable
+from pyams_skin.interfaces import IPageHeader
+from pyams_skin.layer import IPyAMSLayer
+from pyams_skin.skin import apply_skin
+from pyams_skin.table import I18nColumn
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.registry import query_utility
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config, Viewlet
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm, AdminDialogDisplayForm
+from pyams_zmi.interfaces import IAdminView
+from pyams_zmi.layer import IAdminLayer
+from pyams_zmi.view import AdminView
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import base64
+import ldap3
+
+# import interfaces
+
+# import packages
+from z3c.form import field
+from zope.interface import implementer, Interface
+
+from pyams_ldap import _
+
+
+#
+# LDAP users folder views
+#
+
+@viewlet_config(name='add-ldap-folder.menu', context=ISite, layer=IAdminLayer,
+                view=SecurityManagerPluginsTable, manager=ISecurityManagerToolbarAddingMenu,
+                permission='system.manage', weight=60)
+class LDAPPluginAddMenu(ToolbarMenuItem):
+    """LDAP users folder add menu"""
+
+    label = _("Add LDAP users folder...")
+    label_css_class = 'fa fa-fw fa-sitemap'
+    url = 'add-ldap-folder.html'
+    modal_target = True
+
+
+class ILDAPForm(Interface):
+    """LDAP form"""
+
+
+@pagelet_config(name='add-ldap-folder.html', context=ISite, layer=IPyAMSLayer,
+                permission='system.manage')
+@implementer(ILDAPForm)
+class LDAPPluginAddForm(AdminDialogAddForm):
+    """LDAP users folder plug-in add form"""
+
+    title = _("System security manager")
+    legend = _("Add LDAP users folder plug-in")
+    icon_css_class = 'fa fa-fw fa-sitemap'
+
+    fields = field.Fields(IPlugin).omit('__name__', '__parent__')
+    ajax_handler = 'add-ldap-folder.json'
+    edit_permission = 'system.manage'
+
+    def create(self, data):
+        return LDAPPlugin()
+
+    def add(self, plugin):
+        context = query_utility(ISecurityManager)
+        context[plugin.prefix] = plugin
+
+    def nextURL(self):
+        return absolute_url(self.context, self.request, 'security-manager.html')
+
+
+@view_config(name='add-ldap-folder.json', context=ISite, request_type=IPyAMSLayer,
+             permission='system.manage', renderer='json', xhr=True)
+class LDAPPluginAJAXAddForm(AJAXAddForm, LDAPPluginAddForm):
+    """LDAP users folder plug-in add form, AJAX handler"""
+
+
+#
+# LDAP add form tabs
+#
+
+@adapter_config(name='connection', context=(ISite, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginConnectionAddForm(InnerAddForm):
+    """LDAP plug-in add form connection"""
+
+    id = 'ldap_connection_form'
+    tabLabel = _("Connection")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('server_uri', 'bind_dn', 'bind_password', 'use_tls',
+                                              'use_pool', 'pool_size', 'pool_lifetime')
+    weight = 1
+
+
+@adapter_config(name='users', context=(ISite, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginUsersAddForm(InnerAddForm):
+    """LDAP plug-in add form users schema"""
+
+    id = 'ldap_users_form'
+    tabLabel = _("Users schema")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('base_dn', 'search_scope', 'login_attribute', 'login_query',
+                                              'uid_attribute', 'uid_query', 'title_format')
+    weight = 2
+
+
+@adapter_config(name='groups', context=(ISite, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginGroupsAddForm(InnerAddForm):
+    """LDAP plug-in add form groups schema"""
+
+    id = 'ldap_groups_form'
+    tabLabel = _("Groups schema")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'groups_query',
+                                              'group_prefix', 'group_uid_attribute', 'group_title_format')
+    weight = 3
+
+
+@adapter_config(name='search', context=(ISite, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginSearchAddForm(InnerAddForm):
+    """LDAP plug-in add form search settings"""
+
+    id = 'ldap_search_form'
+    tabLabel = _("Search settings")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('users_select_query', 'users_search_query',
+                                              'groups_select_query', 'groups_search_query')
+    weight = 4
+
+
+@pagelet_config(name='properties.html', context=ILDAPPlugin, layer=IPyAMSLayer,
+                permission='system.view')
+@implementer(ILDAPForm)
+class LDAPPluginEditForm(AdminDialogEditForm):
+    """LDAP users folder plug-in edit form"""
+
+    @property
+    def title(self):
+        return self.context.title
+
+    legend = _("Edit LDAP users folder plug-in properties")
+    icon_css_class = 'fa fa-fw fa-sitemap'
+
+    fields = field.Fields(IPlugin).omit('__parent__', '__name__')
+
+    ajax_handler = 'properties.json'
+    edit_permission = 'system.manage'
+
+    def updateWidgets(self, prefix=None):
+        super(LDAPPluginEditForm, self).updateWidgets()
+        self.widgets['prefix'].mode = DISPLAY_MODE
+
+    def update_content(self, content, data):
+        changes = super(LDAPPluginEditForm, self).update_content(content, data)
+        if changes:
+            self.context.clear()
+        return changes
+
+
+@view_config(name='properties.json', context=ILDAPPlugin, request_type=IPyAMSLayer,
+             permission='system.manage', renderer='json', xhr=True)
+@implementer(IAdminView)
+class LDAPPluginAJAXEditForm(AJAXEditForm, LDAPPluginEditForm):
+    """LDAP users folder plug-in edit form, AJAX handler"""
+
+
+#
+# LDAP edit form tabs
+#
+
+@adapter_config(name='connection', context=(ILDAPPlugin, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginConnectionEditForm(InnerEditForm):
+    """LDAP plug-in connection edit form"""
+
+    id = 'ldap_connection_form'
+    tabLabel = _("Connection")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('server_uri', 'bind_dn', 'bind_password', 'use_tls',
+                                              'use_pool', 'pool_size', 'pool_lifetime')
+    weight = 1
+
+
+@adapter_config(name='users', context=(ILDAPPlugin, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginUsersEditForm(InnerEditForm):
+    """LDAP plug-in users schema edit form"""
+
+    id = 'ldap_users_form'
+    tabLabel = _("Users schema")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('base_dn', 'search_scope', 'login_attribute', 'login_query',
+                                              'uid_attribute', 'uid_query', 'title_format')
+    weight = 2
+
+
+@adapter_config(name='groups', context=(ILDAPPlugin, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginGroupsEditForm(InnerEditForm):
+    """LDAP plug-in groups schema edit form"""
+
+    id = 'ldap_groups_form'
+    tabLabel = _("Groups schema")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('groups_base_dn', 'groups_search_scope', 'groups_query',
+                                              'group_prefix', 'group_uid_attribute', 'group_title_format')
+    weight = 3
+
+
+@adapter_config(name='search', context=(ILDAPPlugin, IAdminLayer, ILDAPForm), provides=IInnerTabForm)
+class LDAPPluginSearchEditForm(InnerEditForm):
+    """LDAP plug-in search settings"""
+
+    id = 'ldap_search_form'
+    tabLabel = _("Search settings")
+    legend = None
+    fields = field.Fields(ILDAPPlugin).select('users_select_query', 'users_search_query',
+                                              'groups_select_query', 'groups_search_query')
+
+    label_css_class = 'control-label col-md-4'
+    input_css_class = 'col-md-8'
+    weight = 4
+
+
+#
+# Users folder search views
+#
+
+@pagelet_config(name='search.html', context=ILDAPPlugin, layer=IPyAMSLayer, permission='system.view')
+class LDAPPluginSearchView(AdminView, SearchView):
+    """LDAP users folder search view"""
+
+    def __init__(self, context, request):
+        super(LDAPPluginSearchView, self).__init__(context, request)
+
+
+@adapter_config(context=(ILDAPPlugin, IAdminLayer, LDAPPluginSearchView), provides=IPageHeader)
+class LDAPPluginSearchViewHeaderAdapter(ContextRequestViewAdapter):
+    """LDAP users folder search view header adapter"""
+
+    back_url = '#security-manager.html'
+    icon_class = 'fa fa-fw fa-sitemap'
+
+    @property
+    def title(self):
+        return self.context.title
+
+    subtitle = _("Search users and groups")
+
+
+@view_config(name='search-results.html', context=ILDAPPlugin, request_type=IPyAMSLayer,
+             permission='system.view')
+class LDAPPluginSearchResultsView(AdminView, SearchResultsView):
+    """LDAP users folder search results view table"""
+
+    id = 'ldap_folder_search_table'
+    title = _("Search results")
+    cssClasses = {'table': 'table table-bordered table-striped table-hover table-tight datatable'}
+
+    @property
+    def data_attributes(self):
+        return {'tr': {'data-ams-element-name': lambda x: x[0],
+                       'data-ams-url': lambda x: '{url}?dn={dn}'.format(url=absolute_url(self.context, self.request,
+                                                                                         'user-properties.html'),
+                                                                        dn=x[0]),
+                       'data-toggle': 'modal'}}
+
+    def __init__(self, context, request):
+        super(LDAPPluginSearchResultsView, self).__init__(context, request)
+        apply_skin(self.request, 'PyAMS admin skin')
+
+
+class LDAPColumn(I18nColumn, GetAttrColumn):
+    """Base LDAP column"""
+
+    def getValue(self, obj):
+        return ', '.join(obj[1].get(self.attrName, ()))
+
+
+@adapter_config(name='name', context=(ILDAPPlugin, IAdminLayer, LDAPPluginSearchResultsView),
+                provides=IColumn)
+class LDAPCnColumn(LDAPColumn):
+    """CN column"""
+
+    _header = _("Common name")
+    attrName = 'cn'
+    weight = 5
+
+
+@adapter_config(name='mail', context=(ILDAPPlugin, IAdminLayer, LDAPPluginSearchResultsView),
+                provides=IColumn)
+class LDAPMailColumn(LDAPColumn):
+    """Mail column"""
+
+    _header = _("E-mail")
+    attrName = 'mail'
+    weight = 20
+
+
+#
+# LDAP principal display form
+#
+
+@pagelet_config(name='user-properties.html', context=ILDAPPlugin, layer=IPyAMSLayer)
+class LDAPPrincipalDisplayForm(AdminDialogDisplayForm):
+    """LDAP principal display form"""
+
+    @property
+    def title(self):
+        return self.context.title
+
+    @property
+    def legend(self):
+        return self.request.localizer.translate(_("Display LDAP entry: {dn}")).format(dn=self.request.params['dn'])
+
+    icon_class = 'fa fa-fw fa-sitemap'
+
+    fields = field.Fields(Interface)
+
+
+@viewlet_config(name='ldap-attributes', layer=IAdminLayer, manager=IWidgetsSuffixViewletsManager,
+                view=LDAPPrincipalDisplayForm)
+@template_config(template='templates/ldap-attributes.pt')
+class LDAPPrincipalAttributesViewlet(Viewlet):
+    """LDAP principal attributes"""
+
+    br = '<br />'
+
+    @property
+    def attributes(self):
+        plugin = self.context
+        conn = plugin.get_connection()
+        dn = self.request.params.get('dn')
+        query = LDAPQuery(dn, '(objectclass=*)', ldap3.SEARCH_SCOPE_BASE_OBJECT, ldap3.ALL_ATTRIBUTES)
+        result = query.execute(conn)
+        if not result or len(result) > 1:
+            return ()
+        dn, attributes = result[0]
+        if 'jpegPhoto' in attributes:
+            attributes['jpegPhoto'] = ['<img src="data:image/jpeg;base64,{0}" />'.
+                                       format(base64.encodebytes(attributes['jpegPhoto'][0]).decode()), ]
+        result = sorted(attributes.items(), key=lambda x: x[0])
+        return result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_ldap/zmi/templates/ldap-attributes.pt	Sat Feb 28 15:20:14 2015 +0100
@@ -0,0 +1,10 @@
+<table class="table table-bordered table-striped table-hover table-tight datatable">
+	<tr>
+		<th>Attribute name</th>
+		<th>Value</th>
+	</tr>
+	<tr tal:repeat="attr view.attributes">
+		<td tal:content="attr[0]"></td>
+		<td tal:content="structure view.br.join(sorted(attr[1]))"></td>
+	</tr>
+</table>