Adding support for ldap connection pooling.

Using ldappool library to establish connection pooling.
Connection pooling is disabled by default.
Pooling specific configuration parameters are added in ldap section.
Added pool test using existing FakeLdap as connector class.
Added pool specific ldap live test. These tests are executed similar to
existing ldap live test.
Addressed async search_s and result3 API issues mentioned in review.
Added separate connection pool for end user auth bind done by keystone
identity ldap driver logic to avoid saturation of pool by these kind of
binds and limiting pool effectiveness for other ldap operations.
Rebased with lastest master and addressed doc comments.

Change-Id: If516a0d308a7f3be88df5583a30739a935076173
Closes-Bug: #1320997
bp: ldap-connection-pooling
DocImpact
This commit is contained in:
Arun Kant 2014-05-23 15:25:38 -07:00
parent 686597b52a
commit ea689ff78f
13 changed files with 1040 additions and 63 deletions

View File

@ -1603,3 +1603,67 @@ section::
user_allow_create = False
user_allow_update = False
user_allow_delete = False
Connection Pooling
------------------
Various LDAP backends in keystone use a common LDAP module to interact with
LDAP data. By default, a new connection is established for LDAP operations.
This can become highly expensive when TLS support is enabled which is a likely
configuraton in enterprise setup. Re-using of connectors from a connection
pool drastically reduces overhead of initiating a new connection for every
LDAP operation.
Keystone now provides connection pool support via configuration. This change
will keep LDAP connectors alive and re-use for subsequent LDAP operations. A
connection lifespan is going to be configurable with other pooling specific
attributes. The change is made in LDAP handler layer logic which is primarily
responsible for LDAP connection and shared common operations.
In LDAP identity driver, Keystone authenticates end user by LDAP bind with user
DN and provided password. These kind of auth binds can fill up the pool pretty
quickly so a separate pool is provided for those end user auth bind calls.
If a deployment does not want to use pool for those binds, then it can disable
pooling selectively by ``use_auth_pool`` as false. If a deployment wants to
use pool for those auth binds, then ``use_auth_pool`` needs to be true. For
auth pool, a different pool size (``auth_pool_size``) and connection lifetime
(``auth_pool_connection_lifetime``) can be specified. With enabled auth pool,
its connection lifetime should be kept short so that pool frequently re-binds
the connection with provided creds and works reliably in end user password
change case. When ``use_pool`` is false (disabled), then auth pool
configuration is also not used.
Connection pool configuration is added in ``[ldap]`` configuration section::
[ldap]
# Enable LDAP connection pooling. (boolean value)
use_pool=false
# Connection pool size. (integer value)
pool_size=10
# Maximum count of reconnect trials. (integer value)
pool_retry_max=3
# Time span in seconds to wait between two reconnect trials.
# (floating point value)
pool_retry_delay=0.1
# Connector timeout in seconds. Value -1 indicates indefinite wait for
# response. (integer value)
pool_connection_timeout=-1
# Connection lifetime in seconds. (integer value)
pool_connection_lifetime=600
# Enable LDAP connection pooling for end user authentication. If use_pool
# is disabled, then this setting is meaningless and is not used at all.
# (boolean value)
use_auth_pool=false
# End user auth connection pool size. (integer value)
auth_pool_size=100
# End user auth connection lifetime in seconds. (integer value)
auth_pool_connection_lifetime=60

View File

@ -354,9 +354,11 @@ and set environment variables ``KEYSTONE_IDENTITY_BACKEND=ldap`` and
``KEYSTONE_CLEAR_LDAP=yes`` in your ``localrc`` file.
The unit tests can be run against a live server with
``keystone/tests/test_ldap_livetest.py``. The default password is ``test`` but if you have
installed devstack with a different LDAP password, modify the file
``keystone/tests/backend_liveldap.conf`` to reflect your password.
``keystone/tests/test_ldap_livetest.py`` and
``keystone/tests/test_ldap_pool_livetest.py``. The default password is ``test``
but if you have installed devstack with a different LDAP password, modify the
file ``keystone/tests/config_files/backend_liveldap.conf`` and
``keystone/tests/config_files/backend_pool_liveldap.conf`` to reflect your password.
.. NOTE::
To run the live tests you need to set the environment variable ``ENABLE_LDAP_LIVE_TEST``

View File

@ -1149,6 +1149,38 @@
# (string value)
#tls_req_cert=demand
# Enable LDAP connection pooling. (boolean value)
#use_pool=false
# Connection pool size. (integer value)
#pool_size=10
# Maximum count of reconnect trials. (integer value)
#pool_retry_max=3
# Time span in seconds to wait between two reconnect trials.
# (floating point value)
#pool_retry_delay=0.1
# Connector timeout in seconds. Value -1 indicates indefinite
# wait for response. (integer value)
#pool_connection_timeout=-1
# Connection lifetime in seconds. (integer value)
#pool_connection_lifetime=600
# Enable LDAP connection pooling for end user authentication.
# If use_pool is disabled, then this setting is meaningless
# and is not used at all. (boolean value)
#use_auth_pool=false
# End user auth connection pool size. (integer value)
#auth_pool_size=100
# End user auth connection lifetime in seconds. (integer
# value)
#auth_pool_connection_lifetime=60
[matchmaker_ring]

View File

@ -706,6 +706,28 @@ FILE_OPTIONS = {
cfg.StrOpt('tls_req_cert', default='demand',
help='Valid options for tls_req_cert are demand, never, '
'and allow.'),
cfg.BoolOpt('use_pool', default=False,
help='Enable LDAP connection pooling.'),
cfg.IntOpt('pool_size', default=10,
help='Connection pool size.'),
cfg.IntOpt('pool_retry_max', default=3,
help='Maximum count of reconnect trials.'),
cfg.FloatOpt('pool_retry_delay', default=0.1,
help='Time span in seconds to wait between two '
'reconnect trials.'),
cfg.IntOpt('pool_connection_timeout', default=-1,
help='Connector timeout in seconds. Value -1 indicates '
'indefinite wait for response.'),
cfg.IntOpt('pool_connection_lifetime', default=600,
help='Connection lifetime in seconds.'),
cfg.BoolOpt('use_auth_pool', default=False,
help='Enable LDAP connection pooling for end user '
'authentication. If use_pool is disabled, then this '
'setting is meaningless and is not used at all.'),
cfg.IntOpt('auth_pool_size', default=100,
help='End user auth connection pool size.'),
cfg.IntOpt('auth_pool_connection_lifetime', default=60,
help='End user auth connection lifetime in seconds.'),
],
'auth': [
cfg.ListOpt('methods', default=_DEFAULT_AUTH_METHODS,

View File

@ -17,9 +17,13 @@ import os.path
import re
import codecs
import functools
import ldap
import ldap.filter
import ldappool
import six
import sys
import weakref
from keystone import exception
from keystone.i18n import _
@ -27,7 +31,6 @@ from keystone.openstack.common import log
LOG = log.getLogger(__name__)
LDAP_VALUES = {'TRUE': True, 'FALSE': False}
CONTROL_TREEDELETE = '1.2.840.113556.1.4.805'
LDAP_SCOPES = {'one': ldap.SCOPE_ONELEVEL,
@ -404,7 +407,10 @@ class LDAPHandler(object):
@abc.abstractmethod
def connect(self, url, page_size=0, alias_dereferencing=None,
use_tls=False, tls_cacertfile=None, tls_cacertdir=None,
tls_req_cert='demand', chase_referrals=None, debug_level=None):
tls_req_cert='demand', chase_referrals=None, debug_level=None,
use_pool=None, pool_size=None, pool_retry_max=None,
pool_retry_delay=None, pool_conn_timeout=None,
pool_conn_lifetime=None):
raise exception.NotImplemented() # pragma: no cover
@abc.abstractmethod
@ -472,54 +478,17 @@ class PythonLDAPHandler(LDAPHandler):
def connect(self, url, page_size=0, alias_dereferencing=None,
use_tls=False, tls_cacertfile=None, tls_cacertdir=None,
tls_req_cert='demand', chase_referrals=None, debug_level=None):
LOG.debug("LDAP init: url=%s", url)
LOG.debug('LDAP init: use_tls=%s tls_cacertfile=%s tls_cacertdir=%s '
'tls_req_cert=%s tls_avail=%s',
use_tls, tls_cacertfile, tls_cacertdir,
tls_req_cert, ldap.TLS_AVAIL)
tls_req_cert='demand', chase_referrals=None, debug_level=None,
use_pool=None, pool_size=None, pool_retry_max=None,
pool_retry_delay=None, pool_conn_timeout=None,
pool_conn_lifetime=None):
if debug_level is not None:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, debug_level)
using_ldaps = url.lower().startswith("ldaps")
if use_tls and using_ldaps:
raise AssertionError(_('Invalid TLS / LDAPS combination'))
if use_tls:
if not ldap.TLS_AVAIL:
raise ValueError(_('Invalid LDAP TLS_AVAIL option: %s. TLS '
'not available') % ldap.TLS_AVAIL)
if tls_cacertfile:
# NOTE(topol)
# python ldap TLS does not verify CACERTFILE or CACERTDIR
# so we add some extra simple sanity check verification
# Also, setting these values globally (i.e. on the ldap object)
# works but these values are ignored when setting them on the
# connection
if not os.path.isfile(tls_cacertfile):
raise IOError(_("tls_cacertfile %s not found "
"or is not a file") %
tls_cacertfile)
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, tls_cacertfile)
elif tls_cacertdir:
# NOTE(topol)
# python ldap TLS does not verify CACERTFILE or CACERTDIR
# so we add some extra simple sanity check verification
# Also, setting these values globally (i.e. on the ldap object)
# works but these values are ignored when setting them on the
# connection
if not os.path.isdir(tls_cacertdir):
raise IOError(_("tls_cacertdir %s not found "
"or is not a directory") %
tls_cacertdir)
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, tls_cacertdir)
if tls_req_cert in LDAP_TLS_CERTS.values():
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, tls_req_cert)
else:
LOG.debug("LDAP TLS: invalid TLS_REQUIRE_CERT Option=%s",
tls_req_cert)
_common_ldap_initialization(url=url,
use_tls=use_tls,
tls_cacertfile=tls_cacertfile,
tls_cacertdir=tls_cacertdir,
tls_req_cert=tls_req_cert,
debug_level=debug_level)
self.conn = ldap.initialize(url)
self.conn.protocol_version = ldap.VERSION3
@ -581,6 +550,269 @@ class PythonLDAPHandler(LDAPHandler):
return self.conn.delete_ext_s(dn, serverctrls, clientctrls)
def _common_ldap_initialization(url, use_tls=False, tls_cacertfile=None,
tls_cacertdir=None, tls_req_cert=None,
debug_level=None):
'''Method for common ldap initialization between PythonLDAPHandler and
PooledLDAPHandler.
'''
LOG.debug("LDAP init: url=%s", url)
LOG.debug('LDAP init: use_tls=%s tls_cacertfile=%s tls_cacertdir=%s '
'tls_req_cert=%s tls_avail=%s',
use_tls, tls_cacertfile, tls_cacertdir,
tls_req_cert, ldap.TLS_AVAIL)
if debug_level is not None:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, debug_level)
using_ldaps = url.lower().startswith("ldaps")
if use_tls and using_ldaps:
raise AssertionError(_('Invalid TLS / LDAPS combination'))
if use_tls:
if not ldap.TLS_AVAIL:
raise ValueError(_('Invalid LDAP TLS_AVAIL option: %s. TLS '
'not available') % ldap.TLS_AVAIL)
if tls_cacertfile:
# NOTE(topol)
# python ldap TLS does not verify CACERTFILE or CACERTDIR
# so we add some extra simple sanity check verification
# Also, setting these values globally (i.e. on the ldap object)
# works but these values are ignored when setting them on the
# connection
if not os.path.isfile(tls_cacertfile):
raise IOError(_("tls_cacertfile %s not found "
"or is not a file") %
tls_cacertfile)
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, tls_cacertfile)
elif tls_cacertdir:
# NOTE(topol)
# python ldap TLS does not verify CACERTFILE or CACERTDIR
# so we add some extra simple sanity check verification
# Also, setting these values globally (i.e. on the ldap object)
# works but these values are ignored when setting them on the
# connection
if not os.path.isdir(tls_cacertdir):
raise IOError(_("tls_cacertdir %s not found "
"or is not a directory") %
tls_cacertdir)
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, tls_cacertdir)
if tls_req_cert in LDAP_TLS_CERTS.values():
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, tls_req_cert)
else:
LOG.debug("LDAP TLS: invalid TLS_REQUIRE_CERT Option=%s",
tls_req_cert)
class MsgId(list):
'''Wrapper class to hold connection and msgid.'''
pass
def use_conn_pool(func):
'''Use this only for connection pool specific ldap API.
This adds connection object to decorated API as next argument after self.
'''
def wrapper(self, *args, **kwargs):
# assert isinstance(self, PooledLDAPHandler)
with self._get_pool_connection() as conn:
self._apply_options(conn)
return func(self, conn, *args, **kwargs)
return wrapper
class PooledLDAPHandler(LDAPHandler):
'''Implementation of the LDAPHandler interface which uses pooled
connection manager.
Pool specific configuration is defined in [ldap] section.
All other LDAP configuration is still used from [ldap] section
Keystone LDAP authentication logic authenticates an end user using its DN
and password via LDAP bind to establish supplied password is correct.
This can fill up the pool quickly (as pool re-uses existing connection
based on its bind data) and would not leave space in pool for connection
re-use for other LDAP operations.
Now a separate pool can be established for those requests when related flag
'use_auth_pool' is enabled. That pool can have its own size and
connection lifetime. Other pool attributes are shared between those pools.
If 'use_pool' is disabled, then 'use_auth_pool' does not matter.
If 'use_auth_pool' is not enabled, then connection pooling is not used for
those LDAP operations.
Note, the python-ldap API requires all string values to be UTF-8
encoded. The KeystoneLDAPHandler enforces this prior to invoking
the methods in this class.
'''
# Added here to allow override for testing
Connector = ldappool.StateConnector
auth_pool_prefix = 'auth_pool_'
connection_pools = {} # static connector pool dict
def __init__(self, conn=None, use_auth_pool=False):
super(PooledLDAPHandler, self).__init__(conn=conn)
self.who = ''
self.cred = ''
self.conn_options = {} # connection specific options
self.page_size = None
self.use_auth_pool = use_auth_pool
self.conn_pool = None
def connect(self, url, page_size=0, alias_dereferencing=None,
use_tls=False, tls_cacertfile=None, tls_cacertdir=None,
tls_req_cert='demand', chase_referrals=None, debug_level=None,
use_pool=None, pool_size=None, pool_retry_max=None,
pool_retry_delay=None, pool_conn_timeout=None,
pool_conn_lifetime=None):
_common_ldap_initialization(url=url,
use_tls=use_tls,
tls_cacertfile=tls_cacertfile,
tls_cacertdir=tls_cacertdir,
tls_req_cert=tls_req_cert,
debug_level=debug_level)
self.page_size = page_size
# Following two options are not added in common initialization as they
# need to follow a sequence in PythonLDAPHandler code.
if alias_dereferencing is not None:
self.set_option(ldap.OPT_DEREF, alias_dereferencing)
if chase_referrals is not None:
self.set_option(ldap.OPT_REFERRALS, int(chase_referrals))
if self.use_auth_pool: # separate pool when use_auth_pool enabled
pool_url = self.auth_pool_prefix + url
else:
pool_url = url
try:
self.conn_pool = self.connection_pools[pool_url]
except KeyError:
self.conn_pool = ldappool.ConnectionManager(
url,
size=pool_size,
retry_max=pool_retry_max,
retry_delay=pool_retry_delay,
timeout=pool_conn_timeout,
connector_cls=self.Connector,
use_tls=use_tls,
max_lifetime=pool_conn_lifetime)
self.connection_pools[pool_url] = self.conn_pool
def set_option(self, option, invalue):
self.conn_options[option] = invalue
def get_option(self, option):
value = self.conn_options.get(option)
# if option was not specified explictly, then use connection default
# value for that option if there.
if value is None:
with self._get_pool_connection() as conn:
value = conn.get_option(option)
return value
def _apply_options(self, conn):
# if connection has a lifetime, then it already has options specified
if conn.get_lifetime() > 30:
return
for option, invalue in six.iteritems(self.conn_options):
conn.set_option(option, invalue)
def _get_pool_connection(self):
return self.conn_pool.connection(self.who, self.cred)
def simple_bind_s(self, who='', cred='',
serverctrls=None, clientctrls=None):
'''Not using use_conn_pool decorator here as this API takes cred as
input.
'''
self.who = who
self.cred = cred
with self._get_pool_connection() as conn:
self._apply_options(conn)
def unbind_s(self):
# After connection generator is done `with` statement execution block
# connection is always released via finally block in ldappool.
# So this unbind is a no op.
pass
@use_conn_pool
def add_s(self, conn, dn, modlist):
return conn.add_s(dn, modlist)
@use_conn_pool
def search_s(self, conn, base, scope,
filterstr='(objectClass=*)', attrlist=None, attrsonly=0):
return conn.search_s(base, scope, filterstr, attrlist,
attrsonly)
def search_ext(self, base, scope,
filterstr='(objectClass=*)', attrlist=None, attrsonly=0,
serverctrls=None, clientctrls=None,
timeout=-1, sizelimit=0):
'''This API is asynchoronus API which returns MsgId instance to be used
in result3 call.
To work with result3 API in predicatable manner, same LDAP connection
is needed which provided msgid. So wrapping used connection and msgid
in MsgId class. The connection associated with search_ext is released
once last hard reference to MsgId object is freed. This will happen
when the method is done with returned MsgId usage.
'''
conn_ctxt = self._get_pool_connection()
conn = conn_ctxt.__enter__()
try:
msgid = conn.search_ext(base, scope,
filterstr, attrlist, attrsonly,
serverctrls, clientctrls,
timeout, sizelimit)
except Exception:
conn_ctxt.__exit__(*sys.exc_info())
raise
res = MsgId((conn, msgid))
weakref.ref(res, functools.partial(conn_ctxt.__exit__,
None, None, None))
return res
def result3(self, msgid, all=1, timeout=None,
resp_ctrl_classes=None):
'''This method is used to wait for and return the result of an
operation previously initiated by one of the LDAP asynchronous
operation routines (eg search_ext()) It returned an invocation
identifier (a message id) upon successful initiation of their
operation.
Input msgid is expected to be instance of class MsgId which has LDAP
session/connection used to execute search_ext and message idenfier.
The connection associated with search_ext is released once last hard
reference to MsgId object is freed. This will happen when function
which requested msgId and used it in result3 exits.
'''
conn, msg_id = msgid
return conn.result3(msg_id, all, timeout)
@use_conn_pool
def modify_s(self, conn, dn, modlist):
return conn.modify_s(dn, modlist)
@use_conn_pool
def delete_s(self, conn, dn):
return conn.delete_s(dn)
@use_conn_pool
def delete_ext_s(self, conn, dn, serverctrls=None, clientctrls=None):
return conn.delete_ext_s(dn, serverctrls, clientctrls)
class KeystoneLDAPHandler(LDAPHandler):
'''Convert data types and perform logging.
@ -617,11 +849,21 @@ class KeystoneLDAPHandler(LDAPHandler):
def connect(self, url, page_size=0, alias_dereferencing=None,
use_tls=False, tls_cacertfile=None, tls_cacertdir=None,
tls_req_cert='demand', chase_referrals=None, debug_level=None):
tls_req_cert='demand', chase_referrals=None, debug_level=None,
use_pool=None, pool_size=None,
pool_retry_max=None, pool_retry_delay=None,
pool_conn_timeout=None, pool_conn_lifetime=None):
self.page_size = page_size
return self.conn.connect(url, page_size, alias_dereferencing,
use_tls, tls_cacertfile, tls_cacertdir,
tls_req_cert, chase_referrals,
debug_level=debug_level)
debug_level=debug_level,
use_pool=use_pool,
pool_size=pool_size,
pool_retry_max=pool_retry_max,
pool_retry_delay=pool_retry_delay,
pool_conn_timeout=pool_conn_timeout,
pool_conn_lifetime=pool_conn_lifetime)
def set_option(self, option, invalue):
return self.conn.set_option(option, invalue)
@ -635,7 +877,8 @@ class KeystoneLDAPHandler(LDAPHandler):
who_utf8 = utf8_encode(who)
cred_utf8 = utf8_encode(cred)
return self.conn.simple_bind_s(who_utf8, cred_utf8,
serverctrls, clientctrls)
serverctrls=serverctrls,
clientctrls=clientctrls)
def unbind_s(self):
LOG.debug("LDAP unbind")
@ -798,12 +1041,15 @@ def register_handler(prefix, handler):
_HANDLERS[prefix] = handler
def _get_connection(conn_url):
def _get_connection(conn_url, use_pool=False, use_auth_pool=False):
for prefix, handler in six.iteritems(_HANDLERS):
if conn_url.startswith(prefix):
return handler()
return PythonLDAPHandler()
if use_pool:
return PooledLDAPHandler(use_auth_pool=use_auth_pool)
else:
return PythonLDAPHandler()
def filter_entity(entity_ref):
@ -854,6 +1100,19 @@ class BaseLdap(object):
self.chase_referrals = conf.ldap.chase_referrals
self.debug_level = conf.ldap.debug_level
# LDAP Pool specific attribute
self.use_pool = conf.ldap.use_pool
self.pool_size = conf.ldap.pool_size
self.pool_retry_max = conf.ldap.pool_retry_max
self.pool_retry_delay = conf.ldap.pool_retry_delay
self.pool_conn_timeout = conf.ldap.pool_connection_timeout
self.pool_conn_lifetime = conf.ldap.pool_connection_lifetime
# End user authentication pool specific config attributes
self.use_auth_pool = self.use_pool and conf.ldap.use_auth_pool
self.auth_pool_size = conf.ldap.auth_pool_size
self.auth_pool_conn_lifetime = conf.ldap.auth_pool_connection_lifetime
if self.options_name is not None:
self.suffix = conf.ldap.suffix
if self.suffix is None:
@ -938,8 +1197,20 @@ class BaseLdap(object):
return (self.use_dumb_member
and is_dn_equal(member_dn, self.dumb_member))
def get_connection(self, user=None, password=None):
conn = _get_connection(self.LDAP_URL)
def get_connection(self, user=None, password=None, end_user_auth=False):
use_pool = self.use_pool
pool_size = self.pool_size
pool_conn_lifetime = self.pool_conn_lifetime
if end_user_auth:
if not self.use_auth_pool:
use_pool = False
else:
pool_size = self.auth_pool_size
pool_conn_lifetime = self.auth_pool_conn_lifetime
conn = _get_connection(self.LDAP_URL, use_pool,
use_auth_pool=end_user_auth)
conn = KeystoneLDAPHandler(conn=conn)
@ -951,7 +1222,14 @@ class BaseLdap(object):
tls_cacertdir=self.tls_cacertdir,
tls_req_cert=self.tls_req_cert,
chase_referrals=self.chase_referrals,
debug_level=self.debug_level)
debug_level=self.debug_level,
use_pool=use_pool,
pool_size=pool_size,
pool_retry_max=self.pool_retry_max,
pool_retry_delay=self.pool_retry_delay,
pool_conn_timeout=self.pool_conn_timeout,
pool_conn_lifetime=pool_conn_lifetime
)
if user is None:
user = self.LDAP_USER

View File

@ -64,7 +64,7 @@ class Identity(identity.Driver):
conn = None
try:
conn = self.user.get_connection(user_ref['dn'],
password)
password, end_user_auth=True)
if not conn:
raise AssertionError(_('Invalid user / password'))
except Exception:

View File

@ -0,0 +1,41 @@
[ldap]
url = fakepool://memory
user = cn=Admin
password = password
backend_entities = ['Tenant', 'User', 'UserRoleAssociation', 'Role', 'Group', 'Domain']
suffix = cn=example,cn=com
# Connection pooling specific attributes
# Enable LDAP connection pooling. (boolean value)
use_pool=true
# Connection pool size. (integer value)
pool_size=5
# Maximum count of reconnect trials. (integer value)
pool_retry_max=2
# Time span in seconds to wait between two reconnect trials.
# (floating point value)
pool_retry_delay=0.2
# Connector timeout in seconds. Value -1 indicates indefinite
# wait for response. (integer value)
pool_connection_timeout=-1
# Connection lifetime in seconds.
# (integer value)
pool_connection_lifetime=600
# Enable LDAP connection pooling for end user authentication.
# If use_pool is disabled, then this setting is meaningless
# and is not used at all. (boolean value)
use_auth_pool=true
# End user auth connection pool size. (integer value)
auth_pool_size=50
# End user auth connection lifetime in seconds. (integer
# value)
auth_pool_connection_lifetime=60

View File

@ -0,0 +1,35 @@
[ldap]
url = ldap://localhost
user = cn=Manager,dc=openstack,dc=org
password = test
suffix = dc=openstack,dc=org
group_tree_dn = ou=UserGroups,dc=openstack,dc=org
role_tree_dn = ou=Roles,dc=openstack,dc=org
project_tree_dn = ou=Projects,dc=openstack,dc=org
user_tree_dn = ou=Users,dc=openstack,dc=org
project_enabled_emulation = True
user_enabled_emulation = True
user_mail_attribute = mail
use_dumb_member = True
# Connection pooling specific attributes
# Enable LDAP connection pooling. (boolean value)
use_pool=true
# Connection pool size. (integer value)
pool_size=5
# Connection lifetime in seconds.
# (integer value)
pool_connection_lifetime=60
# Enable LDAP connection pooling for end user authentication.
# If use_pool is disabled, then this setting is meaningless
# and is not used at all. (boolean value)
use_auth_pool=true
# End user auth connection pool size. (integer value)
auth_pool_size=50
# End user auth connection lifetime in seconds. (integer
# value)
auth_pool_connection_lifetime=300

View File

@ -30,6 +30,7 @@ import six
from six import moves
from keystone.common.ldap import core
from keystone import config
from keystone import exception
from keystone.openstack.common import log
@ -45,6 +46,7 @@ SCOPE_NAMES = {
CONTROL_TREEDELETE = '1.2.840.113556.1.4.805'
LOG = log.getLogger(__name__)
CONF = config.CONF
def _internal_attr(attr_name, value_or_values):
@ -200,7 +202,10 @@ class FakeLdap(core.LDAPHandler):
def connect(self, url, page_size=0, alias_dereferencing=None,
use_tls=False, tls_cacertfile=None, tls_cacertdir=None,
tls_req_cert='demand', chase_referrals=None, debug_level=None):
tls_req_cert='demand', chase_referrals=None, debug_level=None,
use_pool=None, pool_size=None, pool_retry_max=None,
pool_retry_delay=None, pool_conn_timeout=None,
pool_conn_lifetime=None):
if url.startswith('fake://memory'):
if url not in FakeShelves:
FakeShelves[url] = FakeShelve()
@ -228,6 +233,13 @@ class FakeLdap(core.LDAPHandler):
self.set_option(ldap.OPT_DEREF, alias_dereferencing)
self.page_size = page_size
self.use_pool = use_pool
self.pool_size = pool_size
self.pool_retry_max = pool_retry_max
self.pool_retry_delay = pool_retry_delay
self.pool_conn_timeout = pool_conn_timeout
self.pool_conn_lifetime = pool_conn_lifetime
def dn(self, dn):
return core.utf8_decode(dn)
@ -239,7 +251,8 @@ class FakeLdap(core.LDAPHandler):
"""This method is ignored, but provided for compatibility."""
if server_fail:
raise ldap.SERVER_DOWN
if who == 'cn=Admin' and cred == 'password':
whos = ['cn=Admin', CONF.ldap.user]
if who in whos and cred in ['password', CONF.ldap.password]:
return
try:
@ -465,3 +478,43 @@ class FakeLdap(core.LDAPHandler):
def result3(self, msgid=ldap.RES_ANY, all=1, timeout=None,
resp_ctrl_classes=None):
raise exception.NotImplemented()
class FakeLdapPool(FakeLdap):
'''Emulate the python-ldap API with pooled connections using existing
FakeLdap logic.
This class is used as connector class in PooledLDAPHandler.
'''
def __init__(self, uri, retry_max=None, retry_delay=None, conn=None):
super(FakeLdapPool, self).__init__(conn=conn)
self.url = uri
self.connected = None
self.conn = self
self._connection_time = 5 # any number greater than 0
def get_lifetime(self):
return self._connection_time
def simple_bind_s(self, who=None, cred=None,
serverctrls=None, clientctrls=None):
if self.url.startswith('fakepool://memory'):
if self.url not in FakeShelves:
FakeShelves[self.url] = FakeShelve()
self.db = FakeShelves[self.url]
else:
self.db = shelve.open(self.url[11:])
if not who:
who = 'cn=Admin'
if not cred:
cred = 'password'
super(FakeLdapPool, self).simple_bind_s(who=who, cred=cred,
serverctrls=serverctrls,
clientctrls=clientctrls)
def unbind_ext_s(self):
'''Added to extend FakeLdap as connector class.'''
pass

View File

@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# Copyright 2012 OpenStack Foundation
# Copyright 2013 IBM Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import ldappool
import mock
from keystone.common.ldap import core as ldap_core
from keystone import config
from keystone.identity.backends import ldap
from keystone import tests
from keystone.tests import fakeldap
from keystone.tests import test_backend_ldap
CONF = config.CONF
class LdapPoolCommonTestMixin(object):
"""LDAP pool specific common tests used here and in live tests."""
def cleanup_pools(self):
ldap_core.PooledLDAPHandler.connection_pools.clear()
def test_handler_with_use_pool_enabled(self):
# by default use_pool and use_auth_pool is enabled in test pool config
user_ref = self.identity_api.get_user(self.user_foo['id'])
self.user_foo.pop('password')
self.assertDictEqual(user_ref, self.user_foo)
handler = ldap_core._get_connection(CONF.ldap.url, use_pool=True)
self.assertIsInstance(handler, ldap_core.PooledLDAPHandler)
@mock.patch.object(ldap_core.KeystoneLDAPHandler, 'connect')
@mock.patch.object(ldap_core.KeystoneLDAPHandler, 'simple_bind_s')
def test_handler_with_use_pool_not_enabled(self, bind_method,
connect_method):
self.config_fixture.config(group='ldap', use_pool=False)
self.config_fixture.config(group='ldap', use_auth_pool=True)
self.cleanup_pools()
user_api = ldap.UserApi(CONF)
handler = user_api.get_connection(user=None, password=None,
end_user_auth=True)
# use_auth_pool flag does not matter when use_pool is False
# still handler is non pool version
self.assertIsInstance(handler.conn, ldap_core.PythonLDAPHandler)
@mock.patch.object(ldap_core.KeystoneLDAPHandler, 'connect')
@mock.patch.object(ldap_core.KeystoneLDAPHandler, 'simple_bind_s')
def test_handler_with_end_user_auth_use_pool_not_enabled(self, bind_method,
connect_method):
# by default use_pool is enabled in test pool config
# now disabling use_auth_pool flag to test handler instance
self.config_fixture.config(group='ldap', use_auth_pool=False)
self.cleanup_pools()
user_api = ldap.UserApi(CONF)
handler = user_api.get_connection(user=None, password=None,
end_user_auth=True)
self.assertIsInstance(handler.conn, ldap_core.PythonLDAPHandler)
# For end_user_auth case, flag should not be false otherwise
# it will use, admin connections ldap pool
handler = user_api.get_connection(user=None, password=None,
end_user_auth=False)
self.assertIsInstance(handler.conn, ldap_core.PooledLDAPHandler)
def test_pool_size_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.pool_size, ldappool_cm.size)
def test_pool_retry_max_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.pool_retry_max, ldappool_cm.retry_max)
def test_pool_retry_delay_set(self):
# just make one identity call to initiate ldap connection if not there
self.identity_api.get_user(self.user_foo['id'])
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.pool_retry_delay, ldappool_cm.retry_delay)
def test_pool_use_tls_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.use_tls, ldappool_cm.use_tls)
def test_pool_timeout_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.pool_connection_timeout,
ldappool_cm.timeout)
def test_pool_use_pool_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.use_pool, ldappool_cm.use_pool)
def test_pool_connection_lifetime_set(self):
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
self.assertEqual(CONF.ldap.pool_connection_lifetime,
ldappool_cm.max_lifetime)
def test_max_connection_error_raised(self):
who = CONF.ldap.user
cred = CONF.ldap.password
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
ldappool_cm.size = 2
# 3rd connection attempt should raise Max connection error
with ldappool_cm.connection(who, cred) as _: # conn1
with ldappool_cm.connection(who, cred) as _: # conn2
try:
with ldappool_cm.connection(who, cred) as _: # conn3
_.unbind_s()
self.fail()
except Exception as ex:
self.assertIsInstance(ex,
ldappool.MaxConnectionReachedError)
ldappool_cm.size = CONF.ldap.pool_size
def test_pool_size_expands_correctly(self):
who = CONF.ldap.user
cred = CONF.ldap.password
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
ldappool_cm.size = 3
def _get_conn():
return ldappool_cm.connection(who, cred)
# Open 3 connections first
with _get_conn() as _: # conn1
self.assertEqual(len(ldappool_cm), 1)
with _get_conn() as _: # conn2
self.assertEqual(len(ldappool_cm), 2)
with _get_conn() as _: # conn2
_.unbind_ext_s()
self.assertEqual(len(ldappool_cm), 3)
# Then open 3 connections again and make sure size does not grow
# over 3
with _get_conn() as _: # conn1
self.assertEqual(len(ldappool_cm), 1)
with _get_conn() as _: # conn2
self.assertEqual(len(ldappool_cm), 2)
with _get_conn() as _: # conn3
_.unbind_ext_s()
self.assertEqual(len(ldappool_cm), 3)
def test_password_change_with_pool(self):
old_password = self.user_sna['password']
self.cleanup_pools()
# authenticate so that connection is added to pool before password
# change
user_ref = self.identity_api.authenticate(
context={},
user_id=self.user_sna['id'],
password=self.user_sna['password'])
self.user_sna.pop('password')
self.user_sna['enabled'] = True
self.assertDictEqual(user_ref, self.user_sna)
new_password = 'new_password'
user_ref['password'] = new_password
self.identity_api.update_user(user_ref['id'], user_ref)
# now authenticate again to make sure new password works with
# conneciton pool
user_ref2 = self.identity_api.authenticate(
context={},
user_id=self.user_sna['id'],
password=new_password)
user_ref.pop('password')
self.assertDictEqual(user_ref, user_ref2)
# Authentication with old password would not work here as there
# is only one connection in pool which get bind again with updated
# password..so no old bind is maintained in this case.
self.assertRaises(AssertionError,
self.identity_api.authenticate,
context={},
user_id=self.user_sna['id'],
password=old_password)
class LdapIdentitySqlAssignment(LdapPoolCommonTestMixin,
test_backend_ldap.LdapIdentitySqlAssignment,
tests.TestCase):
'''Executes existing base class 150+ tests with pooled LDAP handler to make
sure it works without any error.
'''
def setUp(self):
patcher = mock.patch.object(ldap_core.PooledLDAPHandler, 'Connector',
fakeldap.FakeLdapPool)
patcher.start()
self.addCleanup(patcher.stop)
super(LdapIdentitySqlAssignment, self).setUp()
self.addCleanup(self.cleanup_pools)
# storing to local variable to avoid long references
self.conn_pools = ldap_core.PooledLDAPHandler.connection_pools
# super class loads db fixtures which establishes ldap connection
# so adding dummy call to highlight connection pool initialization
# as its not that obvious though its not needed here
self.identity_api.get_user(self.user_foo['id'])
def config_files(self):
config_files = super(LdapIdentitySqlAssignment, self).config_files()
config_files.append(tests.dirs.tests_conf('backend_ldap_pool.conf'))
return config_files
@mock.patch.object(ldap_core, 'utf8_encode')
def test_utf8_encoded_is_used_in_pool(self, mocked_method):
def side_effect(arg):
return arg
mocked_method.side_effect = side_effect
self.identity_api.get_user(self.user_foo['id'])
mocked_method.assert_any_call(CONF.ldap.user)
mocked_method.assert_any_call(CONF.ldap.password)

View File

@ -0,0 +1,207 @@
# Copyright 2012 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import ldappool
import uuid
from keystone.common.ldap import core as ldap_core
from keystone import config
from keystone.identity.backends import ldap
from keystone import tests
from keystone.tests import fakeldap
from keystone.tests import test_backend_ldap_pool
from keystone.tests import test_ldap_livetest
CONF = config.CONF
class LiveLDAPPoolIdentity(test_backend_ldap_pool.LdapPoolCommonTestMixin,
test_ldap_livetest.LiveLDAPIdentity):
"""Executes existing LDAP live test with pooled LDAP handler to make
sure it works without any error.
Also executes common pool specific tests via Mixin class.
"""
def setUp(self):
super(LiveLDAPPoolIdentity, self).setUp()
self.addCleanup(self.cleanup_pools)
# storing to local variable to avoid long references
self.conn_pools = ldap_core.PooledLDAPHandler.connection_pools
def config_files(self):
config_files = super(LiveLDAPPoolIdentity, self).config_files()
config_files.append(tests.dirs.
tests_conf('backend_pool_liveldap.conf'))
return config_files
def config_overrides(self):
super(LiveLDAPPoolIdentity, self).config_overrides()
self.config_fixture.config(
group='identity',
driver='keystone.identity.backends.ldap.Identity')
def test_assert_connector_used_not_fake_ldap_pool(self):
handler = ldap_core._get_connection(CONF.ldap.url, use_pool=True)
self.assertNotEqual(type(handler.Connector),
type(fakeldap.FakeLdapPool))
self.assertEqual(type(handler.Connector),
type(ldappool.StateConnector))
def test_async_search_and_result3(self):
self.config_fixture.config(group='ldap', page_size=1)
self.test_user_enable_attribute_mask()
def test_pool_size_expands_correctly(self):
who = CONF.ldap.user
cred = CONF.ldap.password
# get related connection manager instance
ldappool_cm = self.conn_pools[CONF.ldap.url]
def _get_conn():
return ldappool_cm.connection(who, cred)
with _get_conn() as c1: # 1
self.assertEqual(len(ldappool_cm), 1)
self.assertTrue(c1.connected, True)
self.assertTrue(c1.active, True)
with _get_conn() as c2: # conn2
self.assertEqual(len(ldappool_cm), 2)
self.assertTrue(c2.connected)
self.assertTrue(c2.active)
self.assertEqual(len(ldappool_cm), 2)
# c2 went out of context, its connected but not active
self.assertTrue(c2.connected)
self.assertFalse(c2.active)
with _get_conn() as c3: # conn3
self.assertEqual(len(ldappool_cm), 2)
self.assertTrue(c3.connected)
self.assertTrue(c3.active)
self.assertTrue(c3 is c2) # same connection is reused
self.assertTrue(c2.active)
with _get_conn() as c4: # conn4
self.assertEqual(len(ldappool_cm), 3)
self.assertTrue(c4.connected)
self.assertTrue(c4.active)
def test_password_change_with_auth_pool_disabled(self):
self.config_fixture.config(group='ldap', use_auth_pool=False)
old_password = self.user_sna['password']
self.test_password_change_with_pool()
self.assertRaises(AssertionError,
self.identity_api.authenticate,
context={},
user_id=self.user_sna['id'],
password=old_password)
def _create_user_and_authenticate(self, password):
user_dict = {
'domain_id': CONF.identity.default_domain_id,
'name': uuid.uuid4().hex,
'password': password}
user = self.identity_api.create_user(user_dict)
self.identity_api.authenticate(
context={},
user_id=user['id'],
password=password)
return self.identity_api.get_user(user['id'])
def _get_auth_conn_pool_cm(self):
pool_url = ldap_core.PooledLDAPHandler.auth_pool_prefix + CONF.ldap.url
return self.conn_pools[pool_url]
def _do_password_change_for_one_user(self, password, new_password):
self.config_fixture.config(group='ldap', use_auth_pool=True)
self.cleanup_pools()
self.load_backends()
user1 = self._create_user_and_authenticate(password)
auth_cm = self._get_auth_conn_pool_cm()
self.assertEqual(len(auth_cm), 1)
user2 = self._create_user_and_authenticate(password)
self.assertEqual(len(auth_cm), 1)
user3 = self._create_user_and_authenticate(password)
self.assertEqual(len(auth_cm), 1)
user4 = self._create_user_and_authenticate(password)
self.assertEqual(len(auth_cm), 1)
user5 = self._create_user_and_authenticate(password)
self.assertEqual(len(auth_cm), 1)
# connection pool size remains 1 even for different user ldap bind
# as there is only one active connection at a time
user_api = ldap.UserApi(CONF)
u1_dn = user_api._id_to_dn_string(user1['id'])
u2_dn = user_api._id_to_dn_string(user2['id'])
u3_dn = user_api._id_to_dn_string(user3['id'])
u4_dn = user_api._id_to_dn_string(user4['id'])
u5_dn = user_api._id_to_dn_string(user5['id'])
# now create multiple active connections for end user auth case which
# will force to keep them in pool. After that, modify one of user
# password. Need to make sure that user connection is in middle
# of pool list.
auth_cm = self._get_auth_conn_pool_cm()
with auth_cm.connection(u1_dn, password) as _:
with auth_cm.connection(u2_dn, password) as _:
with auth_cm.connection(u3_dn, password) as _:
with auth_cm.connection(u4_dn, password) as _:
with auth_cm.connection(u5_dn, password) as _:
self.assertEqual(len(auth_cm), 5)
_.unbind_s()
user3['password'] = new_password
self.identity_api.update_user(user3['id'], user3)
return user3
def test_password_change_with_auth_pool_enabled_long_lifetime(self):
self.config_fixture.config(group='ldap',
auth_pool_connection_lifetime=600)
old_password = 'my_password'
new_password = 'new_password'
user = self._do_password_change_for_one_user(old_password,
new_password)
user.pop('password')
# with long connection lifetime auth_pool can bind to old password
# successfully which is not desired if password change is frequent
# use case in a deployment.
# This can happen in multiple concurrent connections case only.
user_ref = self.identity_api.authenticate(
context={}, user_id=user['id'], password=old_password)
self.assertDictEqual(user_ref, user)
def test_password_change_with_auth_pool_enabled_no_lifetime(self):
self.config_fixture.config(group='ldap',
auth_pool_connection_lifetime=0)
old_password = 'my_password'
new_password = 'new_password'
user = self._do_password_change_for_one_user(old_password,
new_password)
# now as connection lifetime is zero, so authentication
# with old password will always fail.
self.assertRaises(AssertionError,
self.identity_api.authenticate,
context={}, user_id=user['id'],
password=old_password)

View File

@ -16,6 +16,7 @@ pymongo>=2.5
# python-ldap does not install on py3
# authenticate against an existing LDAP server
# python-ldap==2.3.13
# ldappool>=1.0
# Testing
# computes code coverage percentages

View File

@ -13,6 +13,7 @@ pymongo>=2.5
# Optional backend: LDAP
# authenticate against an existing LDAP server
python-ldap==2.3.13
ldappool>=1.0
# Testing
# computes code coverage percentages