Skip to content

Commit 9fead2a

Browse files
committed
Merge branch 'ldap-pool' into 'main'
Add support for pooled LDAP connections See merge request yaal/canaille!338
2 parents d41c0f6 + 44f2db8 commit 9fead2a

9 files changed

Lines changed: 186 additions & 66 deletions

File tree

CHANGES.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
Added
55
^^^^^
6-
- Add SQL database configuration parameters.
6+
- SQL database configuration parameters.
7+
- Pooled LDAP connection support.
78

89
[0.2.4] - 2026-04-08
910
--------------------

canaille/backends/ldap/backend.py

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from ldap.controls.ppolicy import PasswordPolicyControl
1212
from ldap.controls.ppolicy import PasswordPolicyError
1313
from ldap.controls.readentry import PostReadControl
14+
from ldappool import ConnectionManager
15+
from ldappool import StateConnector
1416

1517
from canaille.app import models
1618
from canaille.app.configuration import CheckResult
@@ -27,6 +29,17 @@
2729
from .utils import python_attrs_to_ldap
2830

2931

32+
def _make_connector_cls(network_timeout):
33+
"""Create a StateConnector subclass that sets OPT_NETWORK_TIMEOUT."""
34+
35+
class _Connector(StateConnector):
36+
def __init__(self, *args, **kwargs):
37+
super().__init__(*args, **kwargs)
38+
self.set_option(ldap.OPT_NETWORK_TIMEOUT, network_timeout)
39+
40+
return _Connector
41+
42+
3043
@contextmanager
3144
def ldap_connection(config):
3245
conn = ldap.initialize(config["CANAILLE_LDAP"]["URI"])
@@ -89,10 +102,22 @@ def default(self, obj):
89102

90103
class LDAPBackend(Backend):
91104
json_encoder = LDAPModelEncoder
105+
pool = None
92106

93107
def __init__(self, config):
94108
super().__init__(config)
95-
self._connection = None
109+
ldap_config = self.config["CANAILLE_LDAP"]
110+
LDAPBackend.pool = ConnectionManager(
111+
uri=ldap_config["URI"],
112+
bind=ldap_config["BIND_DN"],
113+
passwd=ldap_config["BIND_PW"],
114+
size=ldap_config["POOL_SIZE"],
115+
max_lifetime=ldap_config["POOL_MAX_LIFETIME"],
116+
retry_max=ldap_config["POOL_RETRY_MAX"],
117+
retry_delay=ldap_config["POOL_RETRY_DELAY"],
118+
timeout=ldap_config["TIMEOUT"],
119+
connector_cls=_make_connector_cls(ldap_config["TIMEOUT"]),
120+
)
96121

97122
def init_app(self, app, init_backend=None):
98123
super().init_app(app, init_backend)
@@ -119,43 +144,25 @@ def setup_schemas(cls, config):
119144
os.path.dirname(__file__) + "/schemas/oauth2-openldap.ldif",
120145
)
121146

122-
@property
147+
@contextmanager
123148
def connection(self):
124-
if self._connection:
125-
return self._connection
126-
149+
"""Get a connection from the pool."""
127150
try:
128-
self._connection = ldap.initialize(self.config["CANAILLE_LDAP"]["URI"])
129-
self._connection.set_option(
130-
ldap.OPT_NETWORK_TIMEOUT,
131-
self.config["CANAILLE_LDAP"]["TIMEOUT"],
132-
)
133-
self._connection.simple_bind_s(
134-
self.config["CANAILLE_LDAP"]["BIND_DN"],
135-
self.config["CANAILLE_LDAP"]["BIND_PW"],
136-
)
137-
151+
with self.pool.connection() as conn:
152+
yield conn
138153
except ldap.SERVER_DOWN as exc:
139154
message = _("Could not connect to the LDAP server '{uri}'").format(
140155
uri=self.config["CANAILLE_LDAP"]["URI"]
141156
)
142157
logging.error(message)
143158
raise ConfigurationException(message) from exc
144-
145159
except ldap.INVALID_CREDENTIALS as exc:
146160
message = _("LDAP authentication failed with user '{user}'").format(
147161
user=self.config["CANAILLE_LDAP"]["BIND_DN"]
148162
)
149163
logging.error(message)
150164
raise ConfigurationException(message) from exc
151165

152-
return self._connection
153-
154-
def teardown(self) -> None:
155-
if self._connection: # pragma: no branch
156-
self._connection.unbind_s()
157-
self._connection = None
158-
159166
@classmethod
160167
def check_network_config(cls, config):
161168
from canaille.app import models
@@ -274,12 +281,12 @@ def gettext(x):
274281
return result, message
275282

276283
def set_user_password(self, user, password) -> None:
277-
conn = self.connection
278-
conn.passwd_s(
279-
user.dn,
280-
None,
281-
password.encode("utf-8"),
282-
)
284+
with self.connection() as conn:
285+
conn.passwd_s(
286+
user.dn,
287+
None,
288+
password.encode("utf-8"),
289+
)
283290

284291
def do_query(self, model, dn=None, filter=None, *args, **kwargs):
285292
from .ldapobjectquery import LDAPObjectQuery
@@ -325,9 +332,10 @@ def do_query(self, model, dn=None, filter=None, *args, **kwargs):
325332
ldapfilter = f"(&{class_filter}{arg_filter}{filter})"
326333
base = base or f"{model.base},{model.root_dn}"
327334
try:
328-
result = self.connection.search_s(
329-
base, ldap.SCOPE_SUBTREE, ldapfilter or None, ["+", "*"]
330-
)
335+
with self.connection() as conn:
336+
result = conn.search_s(
337+
base, ldap.SCOPE_SUBTREE, ldapfilter or None, ["+", "*"]
338+
)
331339
except ldap.NO_SUCH_OBJECT:
332340
result = []
333341
return LDAPObjectQuery(model, result)
@@ -475,9 +483,10 @@ def do_save(self, instance) -> None:
475483
(ldap.MOD_REPLACE, name, values)
476484
for name, values in formatted_changes.items()
477485
]
478-
_, _, _, [result] = self.connection.modify_ext_s(
479-
instance.dn, modlist, serverctrls=[read_post_control]
480-
)
486+
with self.connection() as conn:
487+
_, _, _, [result] = conn.modify_ext_s(
488+
instance.dn, modlist, serverctrls=[read_post_control]
489+
)
481490

482491
# Object does not exist yet in the LDAP database
483492
else:
@@ -488,24 +497,25 @@ def do_save(self, instance) -> None:
488497
}
489498
formatted_changes = python_attrs_to_ldap(changes, null_allowed=False)
490499
modlist = [(name, values) for name, values in formatted_changes.items()]
491-
_, _, _, [result] = self.connection.add_ext_s(
492-
instance.dn, modlist, serverctrls=[read_post_control]
493-
)
500+
with self.connection() as conn:
501+
_, _, _, [result] = conn.add_ext_s(
502+
instance.dn, modlist, serverctrls=[read_post_control]
503+
)
494504

495505
instance.exists = True
496506
instance.state = {**result.entry, **instance.changes}
497507
instance.changes = {}
498508

499509
def do_delete(self, instance) -> None:
500510
try:
501-
self.connection.delete_s(instance.dn)
511+
with self.connection() as conn:
512+
conn.delete_s(instance.dn)
502513
except ldap.NO_SUCH_OBJECT:
503514
pass
504515

505516
def do_reload(self, instance) -> None:
506-
result = self.connection.search_s(
507-
instance.dn, ldap.SCOPE_SUBTREE, None, ["+", "*"]
508-
)
517+
with self.connection() as conn:
518+
result = conn.search_s(instance.dn, ldap.SCOPE_SUBTREE, None, ["+", "*"])
509519
instance.changes = {}
510520
instance.state = result[0][1]
511521

canaille/backends/ldap/configuration.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,29 @@ class LDAPSettings(BaseModel):
5252

5353
GROUP_NAME_ATTRIBUTE: str = "cn"
5454
"""The attribute to use to identify a group."""
55+
56+
POOL_SIZE: int = 10
57+
"""The number of connections to keep in the pool.
58+
59+
See the ``size`` parameter of :class:`ldappool.ConnectionManager`.
60+
"""
61+
62+
POOL_MAX_LIFETIME: int = 600
63+
"""Maximum lifetime of a connection in seconds.
64+
65+
Connections older than this are automatically closed and replaced.
66+
Set to ``0`` to disable lifetime-based recycling.
67+
See the ``max_lifetime`` parameter of :class:`ldappool.ConnectionManager`.
68+
"""
69+
70+
POOL_RETRY_MAX: int = 3
71+
"""Number of retry attempts when a connection fails.
72+
73+
See the ``retry_max`` parameter of :class:`ldappool.ConnectionManager`.
74+
"""
75+
76+
POOL_RETRY_DELAY: float = 0.1
77+
"""Delay in seconds between connection retry attempts.
78+
79+
See the ``retry_delay`` parameter of :class:`ldappool.ConnectionManager`.
80+
"""

canaille/backends/ldap/ldapobject.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ def must(cls):
164164

165165
@classmethod
166166
def install(cls) -> None:
167-
conn = LDAPBackend.instance.connection
168167
cls.ldap_object_classes()
169168
cls.ldap_object_attributes()
170169

@@ -174,13 +173,14 @@ def install(cls) -> None:
174173
dn = f"{organizationalUnit}{acc},{cls.root_dn}"
175174
acc = f",{organizationalUnit}"
176175
try:
177-
conn.add_s(
178-
dn,
179-
[
180-
("objectClass", [b"organizationalUnit"]),
181-
("ou", [v.encode("utf-8")]),
182-
],
183-
)
176+
with LDAPBackend.instance.connection() as conn:
177+
conn.add_s(
178+
dn,
179+
[
180+
("objectClass", [b"organizationalUnit"]),
181+
("ou", [v.encode("utf-8")]),
182+
],
183+
)
184184
except ldap.ALREADY_EXISTS:
185185
pass
186186

@@ -189,11 +189,10 @@ def ldap_object_classes(cls, force=False):
189189
if cls._object_class_by_name and not force:
190190
return cls._object_class_by_name
191191

192-
conn = LDAPBackend.instance.connection
193-
194-
res = conn.search_s(
195-
"cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"]
196-
)
192+
with LDAPBackend.instance.connection() as conn:
193+
res = conn.search_s(
194+
"cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"]
195+
)
197196
subschema_entry = res[0]
198197
subschema_subentry = ldap.cidict.cidict(subschema_entry[1])
199198
subschema = ldap.schema.SubSchema(subschema_subentry)
@@ -211,11 +210,10 @@ def ldap_object_attributes(cls, force=False):
211210
if cls._attribute_type_by_name and not force:
212211
return cls._attribute_type_by_name
213212

214-
conn = LDAPBackend.instance.connection
215-
216-
res = conn.search_s(
217-
"cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"]
218-
)
213+
with LDAPBackend.instance.connection() as conn:
214+
res = conn.search_s(
215+
"cn=subschema", ldap.SCOPE_BASE, "(objectclass=*)", ["*", "+"]
216+
)
219217
subschema_entry = res[0]
220218
subschema_subentry = ldap.cidict.cidict(subschema_entry[1])
221219
subschema = ldap.schema.SubSchema(subschema_subentry)

canaille/backends/ldap/models/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ def get_password_hash(self):
5656

5757
def match_filter(self, filter):
5858
if isinstance(filter, str):
59-
conn = LDAPBackend.instance.connection
60-
return self.dn and conn.search_s(self.dn, ldap.SCOPE_SUBTREE, filter)
59+
with LDAPBackend.instance.connection() as conn:
60+
return self.dn and conn.search_s(self.dn, ldap.SCOPE_SUBTREE, filter)
6161

6262
return super().match_filter(filter)
6363

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,9 @@ devserver = [
205205
release = [
206206
"pyinstaller>=6.11.1",
207207
]
208+
ldap = [
209+
"ldappool>=3.0.0",
210+
]
208211

209212
[project.scripts]
210213
canaille = "canaille.commands:cli"

tests/app/fixtures/current-app-config.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,28 @@ GROUP_BASE = "ou=groups,dc=example,dc=org"
668668
# The attribute to use to identify a group.
669669
# GROUP_NAME_ATTRIBUTE = "cn"
670670

671+
# The number of connections to keep in the pool.
672+
#
673+
# See the size parameter of ldappool.ConnectionManager.
674+
# POOL_SIZE = 10
675+
676+
# Maximum lifetime of a connection in seconds.
677+
#
678+
# Connections older than this are automatically closed and replaced. Set to 0 to
679+
# disable lifetime-based recycling. See the max_lifetime parameter of
680+
# ldappool.ConnectionManager.
681+
# POOL_MAX_LIFETIME = 600
682+
683+
# Number of retry attempts when a connection fails.
684+
#
685+
# See the retry_max parameter of ldappool.ConnectionManager.
686+
# POOL_RETRY_MAX = 3
687+
688+
# Delay in seconds between connection retry attempts.
689+
#
690+
# See the retry_delay parameter of ldappool.ConnectionManager.
691+
# POOL_RETRY_DELAY = 0.1
692+
671693
[CANAILLE_OIDC]
672694
# Whether the Single Sign-On feature and the OpenID Connect API is enabled.
673695
# ENABLE_OIDC = true

tests/app/fixtures/default-config.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,28 @@ GROUP_BASE = "ou=groups,dc=example,dc=org"
579579
# The attribute to use to identify a group.
580580
# GROUP_NAME_ATTRIBUTE = "cn"
581581

582+
# The number of connections to keep in the pool.
583+
#
584+
# See the size parameter of ldappool.ConnectionManager.
585+
# POOL_SIZE = 10
586+
587+
# Maximum lifetime of a connection in seconds.
588+
#
589+
# Connections older than this are automatically closed and replaced. Set to 0 to
590+
# disable lifetime-based recycling. See the max_lifetime parameter of
591+
# ldappool.ConnectionManager.
592+
# POOL_MAX_LIFETIME = 600
593+
594+
# Number of retry attempts when a connection fails.
595+
#
596+
# See the retry_max parameter of ldappool.ConnectionManager.
597+
# POOL_RETRY_MAX = 3
598+
599+
# Delay in seconds between connection retry attempts.
600+
#
601+
# See the retry_delay parameter of ldappool.ConnectionManager.
602+
# POOL_RETRY_DELAY = 0.1
603+
582604
[CANAILLE_OIDC]
583605
# Whether the Single Sign-On feature and the OpenID Connect API is enabled.
584606
# ENABLE_OIDC = true

0 commit comments

Comments
 (0)