Skip to content

Commit 9cd8364

Browse files
skorandac00kiemon5ter
authored andcommitted
LDAP connection pooling
Use LDAP connection pooling with the connections opened when the microservice is initialized rather than opening a connection during each query.
1 parent 829ff0a commit 9cd8364

File tree

2 files changed

+156
-30
lines changed

2 files changed

+156
-30
lines changed

example/plugins/microservices/ldap_attribute_store.yaml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ config:
1212
mail: mail
1313
employeeNumber: employeenumber
1414
isMemberOf: ismemberof
15+
# LDAP connection pool size
16+
# pool_size: 10
17+
# LDAP connection pool seconds to wait between calls out to server
18+
# to keep the connection alive (uses harmless Abandon(0) call)
19+
# pool_keepalive: 10
1520
ordered_identifier_candidates:
1621
# Ordered list of identifiers to use when constructing the
1722
# search filter to find the user record in LDAP directory.

src/satosa/micro_services/ldap_attribute_store.py

Lines changed: 151 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import ldap3
1515
import urllib
1616

17+
from ldap3.core.exceptions import LDAPException
18+
1719
logger = logging.getLogger(__name__)
1820

1921
class LdapAttributeStoreException(Exception):
@@ -36,29 +38,36 @@ class LdapAttributeStore(satosa.micro_services.base.ResponseMicroService):
3638
# entityID for a per-SP configuration.
3739
#
3840
# The keys are the allowed configuration options. The values
39-
# are a list of [default value, if required or not]. Note that
40-
# required here means required as part of an effective
41+
# are a list of [default value, if required or not, new connection],
42+
# where 'new connection' means whether of not an override for an SP
43+
# of that configuration option causes a separate connection to be
44+
# created for that SP. Required here means required as part of an effective
4145
# configuration during a particular flow, so it applies
4246
# to the default configuration overridden with any per-SP
4347
# configuration details.
4448
config_options = {
45-
'bind_dn' : {'default' : None, 'required' : True},
46-
'bind_password' : {'default' : None, 'required' : True},
47-
'clear_input_attributes' : {'default' : False, 'required' : False},
48-
'ignore' : {'default' : False, 'required' : False},
49-
'ldap_identifier_attribute' : {'default' : None, 'required' : True},
50-
'ldap_url' : {'default' : None, 'required' : True},
51-
'on_ldap_search_result_empty' : {'default' : None, 'required' : False},
52-
'ordered_identifier_candidates' : {'default' : None, 'required' : True},
53-
'search_base' : {'default' : None, 'required' : True},
54-
'search_return_attributes' : {'default' : None, 'required' : True},
55-
'user_id_from_attrs' : {'default' : [], 'required' : False}
49+
'bind_dn' : {'default' : None, 'required' : True, 'connection' : True},
50+
'bind_password' : {'default' : None, 'required' : True, 'connection' : True},
51+
'clear_input_attributes' : {'default' : False, 'required' : False, 'connection' : False},
52+
'ignore' : {'default' : False, 'required' : False, 'connection' : False},
53+
'ldap_identifier_attribute' : {'default' : None, 'required' : True, 'connection' : False},
54+
'ldap_url' : {'default' : None, 'required' : True, 'connection' : True},
55+
'on_ldap_search_result_empty' : {'default' : None, 'required' : False, 'connection' : False},
56+
'ordered_identifier_candidates' : {'default' : None, 'required' : True, 'connection' : False},
57+
'pool_size' : {'default' : 10, 'required' : False, 'connection' : True},
58+
'pool_keepalive' : {'default' : 10, 'required' : False, 'connection' : True},
59+
'search_base' : {'default' : None, 'required' : True, 'connection' : False},
60+
'search_return_attributes' : {'default' : None, 'required' : True, 'connection' : False},
61+
'user_id_from_attrs' : {'default' : [], 'required' : False, 'connection' : False}
5662
}
5763

5864
def __init__(self, config, *args, **kwargs):
5965
super().__init__(*args, **kwargs)
6066
self.config = config
6167
self.logprefix = LdapAttributeStore.logprefix
68+
self._createLdapConnectionPools()
69+
70+
satosa_logging(logger, logging.INFO, "{} LDAP Attribute Store microservice initialized".format(self.logprefix), None)
6271

6372
def _constructFilterValue(self, candidate, data):
6473
"""
@@ -163,6 +172,107 @@ def _copyConfigOptions(self, source, target):
163172
if option in source:
164173
target[option] = source[option]
165174

175+
def _createLdapConnectionPools(self):
176+
"""
177+
Examine the configuration and create a LDAP connection pool
178+
for each unique set of configured LDAP options. The connections
179+
are stored as a dictionary with the SP entityID or 'default'
180+
as the keys and ldap3 Connection instances as the values.
181+
"""
182+
logprefix = self.logprefix
183+
184+
# Initialize dictionary that holds mappings between entityID and
185+
# LDAP connection pools. The special key 'default' holds the default
186+
# connection pool.
187+
connections = {}
188+
189+
# List of connections to create.
190+
connections_to_create = ['default']
191+
192+
# Find the entityID for SP overrides if any.
193+
spEntityIds = self._getSPConfigOverrideEntityIds()
194+
195+
# Determine if the SP configuration requires a separate connection.
196+
ldap_config_options = { key: value['connection'] for key, value in LdapAttributeStore.config_options.items()}
197+
for entityId in spEntityIds:
198+
for option, new_connection in ldap_config_options.items():
199+
if new_connection and option in self.config[entityId]:
200+
if entityId not in connections_to_create:
201+
connections_to_create.append(entityId)
202+
203+
# Create the connections.
204+
for label in connections_to_create:
205+
config = self._getEffectiveConfig(label if label != 'default' else None)
206+
if 'ldap_url' in config:
207+
try:
208+
connection = self._ldapConnectionFactory(config)
209+
except LdapAttributeStoreException as e:
210+
msg = "{} Caught exception creating LDAP connection: {}".format(logprefix, e)
211+
satosa_logging(logger, logging.ERROR, msg, None)
212+
raise
213+
214+
connections[label] = connection
215+
satosa_logging(logger, logging.DEBUG, "{} Created LDAP connection with label '{}'".format(logprefix, label), None)
216+
217+
satosa_logging(logger, logging.INFO, "{} Created {} LDAP connections".format(logprefix,len(connections.keys())), None)
218+
219+
self.connections = connections
220+
221+
def _ldapConnectionFactory(self, config):
222+
"""
223+
Use the input configuration to instantiate and return
224+
a ldap3 Connection object.
225+
"""
226+
logprefix = self.logprefix
227+
228+
ldap_url = config['ldap_url']
229+
bind_dn = config['bind_dn']
230+
bind_password = config['bind_password']
231+
pool_size = config['pool_size']
232+
pool_keepalive = config['pool_keepalive']
233+
234+
server = ldap3.Server(config['ldap_url'])
235+
236+
satosa_logging(logger, logging.DEBUG, "{} Creating a new LDAP connection".format(logprefix), None)
237+
satosa_logging(logger, logging.DEBUG, "{} Using LDAP URL {}".format(logprefix, ldap_url), None)
238+
satosa_logging(logger, logging.DEBUG, "{} Using bind DN {}".format(logprefix, bind_dn), None)
239+
satosa_logging(logger, logging.DEBUG, "{} Using pool size {}".format(logprefix, pool_size), None)
240+
satosa_logging(logger, logging.DEBUG, "{} Using pool keep alive {}".format(logprefix, pool_keepalive), None)
241+
242+
try:
243+
connection = ldap3.Connection(
244+
server,
245+
bind_dn,
246+
bind_password,
247+
auto_bind=True,
248+
client_strategy=ldap3.REUSABLE,
249+
pool_size=pool_size,
250+
pool_keepalive=pool_keepalive
251+
)
252+
253+
except LDAPException as e:
254+
msg = "{} Caught exception when connecting to LDAP server: {}".format(logprefix, e)
255+
satosa_logging(logger, logging.ERROR, msg, None)
256+
raise LdapAttributeStoreException(msg)
257+
258+
satosa_logging(logger, logging.DEBUG, "{} Successfully connected to LDAP server".format(logprefix), None)
259+
260+
return connection
261+
262+
def _getConnection(self, entityID = None):
263+
"""
264+
Return the ldap3 Connection instance for the input SP entityID
265+
or the default if no entityID is input.
266+
"""
267+
label = entityID if not entityID else 'default'
268+
try:
269+
connection = self.connections[label]
270+
except KeyError as e:
271+
msg = "No LDAP connection for {}".format(label)
272+
raise LdapAttributeStoreException(msg)
273+
274+
return connection
275+
166276
def _getEffectiveConfig(self, entityID = None, state = None):
167277
"""
168278
Get the effective configuration for the SP with entityID
@@ -194,6 +304,22 @@ def _getEffectiveConfig(self, entityID = None, state = None):
194304

195305
return effective_config
196306

307+
def _getSPConfigOverrideEntityIds(self):
308+
"""
309+
Get the list of SP entityIDs from the configuration that are
310+
configured as overrides to the default configuration.
311+
"""
312+
entityIds = []
313+
known_config_options = LdapAttributeStore.config_options.keys()
314+
315+
for key in self.config.keys():
316+
if key not in known_config_options:
317+
entityIds.append(key)
318+
319+
entityIds.sort()
320+
321+
return entityIds
322+
197323
def _hideConfigSecrets(self, config):
198324
"""
199325
Make a deep copy of the input config dictionary and
@@ -255,14 +381,7 @@ def process(self, context, data):
255381
record = None
256382

257383
try:
258-
ldap_url = config['ldap_url']
259-
satosa_logging(logger, logging.DEBUG, "{} Using LDAP URL {}".format(logprefix, ldap_url), context.state)
260-
server = ldap3.Server(ldap_url)
261-
262-
bind_dn = config['bind_dn']
263-
satosa_logging(logger, logging.DEBUG, "{} Using bind DN {}".format(logprefix, bind_dn), context.state)
264-
connection = ldap3.Connection(server, bind_dn, config['bind_password'], auto_bind=True)
265-
satosa_logging(logger, logging.DEBUG, "{} Connected to LDAP server".format(logprefix), context.state)
384+
connection = self._getConnection(spEntityID)
266385

267386
for filterVal in filterValues:
268387
if record:
@@ -272,27 +391,29 @@ def process(self, context, data):
272391
satosa_logging(logger, logging.DEBUG, "{} Constructed search filter {}".format(logprefix, search_filter), context.state)
273392

274393
satosa_logging(logger, logging.DEBUG, "{} Querying LDAP server...".format(logprefix), context.state)
275-
connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys())
394+
message_id = connection.search(config['search_base'], search_filter, attributes=config['search_return_attributes'].keys())
395+
responses = connection.get_response(message_id)[0]
276396
satosa_logging(logger, logging.DEBUG, "{} Done querying LDAP server".format(logprefix), context.state)
277-
278-
responses = connection.response
279397
satosa_logging(logger, logging.DEBUG, "{} LDAP server returned {} records".format(logprefix, len(responses)), context.state)
280398

281399
# for now consider only the first record found (if any)
282400
if len(responses) > 0:
283401
if len(responses) > 1:
284-
satosa_logging(logger, logging.WARN, "{} LDAP server returned {} records using search filter value {}".format(logprefix, len(responses), filterVar), context.state)
402+
satosa_logging(logger, logging.WARN, "{} LDAP server returned {} records using search filter value {}".format(logprefix, len(responses), filterVal), context.state)
285403
record = responses[0]
286404
break
405+
except LDAPException as err:
406+
satosa_logging(logger, logging.ERROR, "{} Caught LDAP exception: {}".format(logprefix, err), context.state)
407+
return super().process(context, data)
408+
409+
except LdapAttributeStoreException as err:
410+
satosa_logging(logger, logging.ERROR, "{} Caught LDAP Attribute Store exception: {}".format(logprefix, err), context.state)
411+
return super().process(context, data)
287412

288413
except Exception as err:
289-
satosa_logging(logger, logging.ERROR, "{} Caught exception: {}".format(logprefix, err), context.state)
414+
satosa_logging(logger, logging.ERROR, "{} Caught unhandled exception: {}".format(logprefix, err), context.state)
290415
return super().process(context, data)
291416

292-
else:
293-
satosa_logging(logger, logging.DEBUG, "{} Unbinding and closing connection to LDAP server".format(logprefix), context.state)
294-
connection.unbind()
295-
296417
# Before using a found record, if any, to populate attributes
297418
# clear any attributes incoming to this microservice if so configured.
298419
if config['clear_input_attributes']:

0 commit comments

Comments
 (0)