ldap-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- .kiro/specs/ldap-cli/design.md +386 -0
- .kiro/specs/ldap-cli/requirements.md +123 -0
- .kiro/specs/ldap-cli/tasks.md +246 -0
- CHANGELOG.md +47 -0
- README.md +145 -0
- docs/reference.md +198 -0
- ldap_cli-0.2.0.dist-info/METADATA +176 -0
- ldap_cli-0.2.0.dist-info/RECORD +18 -0
- ldap_cli-0.2.0.dist-info/WHEEL +4 -0
- ldap_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ldap_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ldapc/__init__.py +7 -0
- ldapc/_version.py +23 -0
- ldapc/cli.py +490 -0
- ldapc/config.py +184 -0
- ldapc/exceptions.py +37 -0
- ldapc/formatter.py +282 -0
- ldapc/ldap_client.py +506 -0
ldapc/ldap_client.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""LDAP client module for ldapc.
|
|
2
|
+
|
|
3
|
+
Provides the LdapClient class for managing LDAP connections and performing
|
|
4
|
+
user and group searches against an LDAP directory server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
13
|
+
from ldap3 import (
|
|
14
|
+
ALL_ATTRIBUTES,
|
|
15
|
+
Connection,
|
|
16
|
+
Server,
|
|
17
|
+
Tls,
|
|
18
|
+
SUBTREE,
|
|
19
|
+
MODIFY_ADD,
|
|
20
|
+
MODIFY_DELETE,
|
|
21
|
+
)
|
|
22
|
+
from ldap3.core.exceptions import (
|
|
23
|
+
LDAPBindError,
|
|
24
|
+
LDAPExceptionError,
|
|
25
|
+
LDAPSocketOpenError,
|
|
26
|
+
LDAPSocketReceiveError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ldapc.exceptions import (
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
ConnectionError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
LOG = logging.getLogger("ldapc")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _escape_filter_value(value: str) -> str:
|
|
38
|
+
"""Escape special characters in an LDAP filter value per RFC 4515.
|
|
39
|
+
|
|
40
|
+
Characters with special meaning in LDAP filters are escaped to their
|
|
41
|
+
hex representation: \\XX.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
value: The raw string to escape.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The escaped string safe for use in LDAP filter assertions.
|
|
48
|
+
"""
|
|
49
|
+
escaped = []
|
|
50
|
+
for char in value:
|
|
51
|
+
if char == "\\":
|
|
52
|
+
escaped.append("\\5c")
|
|
53
|
+
elif char == "*":
|
|
54
|
+
escaped.append("\\2a")
|
|
55
|
+
elif char == "(":
|
|
56
|
+
escaped.append("\\28")
|
|
57
|
+
elif char == ")":
|
|
58
|
+
escaped.append("\\29")
|
|
59
|
+
elif char == "\x00":
|
|
60
|
+
escaped.append("\\00")
|
|
61
|
+
else:
|
|
62
|
+
escaped.append(char)
|
|
63
|
+
return "".join(escaped)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LdapEntry:
|
|
68
|
+
"""A single LDAP directory entry.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
dn: Distinguished name of the entry.
|
|
72
|
+
attributes: Attribute name to list of values mapping.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
dn: str
|
|
76
|
+
attributes: dict[str, list[str]]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LdapClient:
|
|
80
|
+
"""Manages LDAP connections and searches.
|
|
81
|
+
|
|
82
|
+
Establishes a TLS-secured connection to an LDAP server and provides
|
|
83
|
+
methods for searching user and group entries. Supports the context
|
|
84
|
+
manager protocol for automatic resource cleanup.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
with LdapClient(host, username, password) as client:
|
|
88
|
+
users = client.search_users("john", "dc=example,dc=com")
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self, host: str, username: str, password: str, timeout: int = 10,
|
|
93
|
+
skip_ssl_verify: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Establish a TLS connection to the LDAP server.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
host: LDAP server URL (e.g., ldaps://ldap.example.com:636).
|
|
99
|
+
username: Bind DN for authentication.
|
|
100
|
+
password: Bind password.
|
|
101
|
+
timeout: Connection timeout in seconds. Defaults to 10.
|
|
102
|
+
skip_ssl_verify: Skip TLS certificate verification. Defaults to False.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ConnectionError: If the server is unreachable, the connection
|
|
106
|
+
times out, or TLS certificate verification fails.
|
|
107
|
+
AuthenticationError: If the bind credentials are rejected.
|
|
108
|
+
"""
|
|
109
|
+
self._host = host
|
|
110
|
+
self._username = username
|
|
111
|
+
self._connection: Connection | None = None
|
|
112
|
+
|
|
113
|
+
use_ssl = host.lower().startswith("ldaps://")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
LOG.debug("Establishing connection to %s (ssl=%s, timeout=%ds)", host, use_ssl, timeout)
|
|
117
|
+
import ssl
|
|
118
|
+
validate = ssl.CERT_NONE if skip_ssl_verify else ssl.CERT_REQUIRED
|
|
119
|
+
tls = Tls(validate=validate)
|
|
120
|
+
server = Server(host, use_ssl=use_ssl, tls=tls, connect_timeout=timeout)
|
|
121
|
+
conn = Connection(
|
|
122
|
+
server,
|
|
123
|
+
user=username,
|
|
124
|
+
password=password,
|
|
125
|
+
auto_bind=True,
|
|
126
|
+
receive_timeout=timeout,
|
|
127
|
+
)
|
|
128
|
+
except LDAPBindError as exc:
|
|
129
|
+
raise AuthenticationError(
|
|
130
|
+
f"Authentication failed: invalid credentials for {username}"
|
|
131
|
+
) from exc
|
|
132
|
+
except LDAPSocketOpenError as exc:
|
|
133
|
+
error_msg = str(exc).lower()
|
|
134
|
+
if "tls" in error_msg or "certificate" in error_msg or "ssl" in error_msg:
|
|
135
|
+
raise ConnectionError(
|
|
136
|
+
f"TLS certificate verification failed for {host}"
|
|
137
|
+
) from exc
|
|
138
|
+
raise ConnectionError(
|
|
139
|
+
f"Connection failed: unable to reach {host}"
|
|
140
|
+
) from exc
|
|
141
|
+
except LDAPSocketReceiveError as exc:
|
|
142
|
+
raise ConnectionError(
|
|
143
|
+
f"Connection timed out after {timeout} seconds: {host}"
|
|
144
|
+
) from exc
|
|
145
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
146
|
+
error_msg = str(exc).lower()
|
|
147
|
+
if "timeout" in error_msg or "timed out" in error_msg:
|
|
148
|
+
raise ConnectionError(
|
|
149
|
+
f"Connection timed out after {timeout} seconds: {host}"
|
|
150
|
+
) from exc
|
|
151
|
+
if "tls" in error_msg or "certificate" in error_msg or "ssl" in error_msg:
|
|
152
|
+
raise ConnectionError(
|
|
153
|
+
f"TLS certificate verification failed for {host}"
|
|
154
|
+
) from exc
|
|
155
|
+
raise ConnectionError(
|
|
156
|
+
f"Connection failed: unable to reach {host}"
|
|
157
|
+
) from exc
|
|
158
|
+
|
|
159
|
+
self._connection = conn
|
|
160
|
+
LOG.debug("Successfully bound to %s as %s", host, username)
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> Self:
|
|
163
|
+
"""Enter the context manager.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The LdapClient instance.
|
|
167
|
+
"""
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object | None) -> None:
|
|
171
|
+
"""Exit the context manager, closing the connection."""
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
def search_users(self, search_term: str, base_dn: str) -> list[LdapEntry]:
|
|
175
|
+
"""Search for user entries matching cn or uid (case-insensitive substring).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
search_term: The term to search for in cn and uid fields.
|
|
179
|
+
base_dn: The base distinguished name to search under.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
A list of LdapEntry objects matching the search criteria.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ConnectionError: If the search fails due to a connection issue.
|
|
186
|
+
"""
|
|
187
|
+
escaped = _escape_filter_value(search_term)
|
|
188
|
+
search_filter = f"(&(objectClass=inetOrgPerson)(|(cn=*{escaped}*)(uid=*{escaped}*)))"
|
|
189
|
+
return self._search(search_filter, base_dn)
|
|
190
|
+
|
|
191
|
+
def search_groups(self, search_term: str, base_dn: str) -> list[LdapEntry]:
|
|
192
|
+
"""Search for group entries matching cn (case-insensitive substring).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
search_term: The term to search for in the cn field.
|
|
196
|
+
base_dn: The base distinguished name to search under.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
A list of LdapEntry objects matching the search criteria.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
ConnectionError: If the search fails due to a connection issue.
|
|
203
|
+
"""
|
|
204
|
+
escaped = _escape_filter_value(search_term)
|
|
205
|
+
search_filter = f"(&(objectClass=posixGroup)(cn=*{escaped}*))"
|
|
206
|
+
return self._search(search_filter, base_dn)
|
|
207
|
+
|
|
208
|
+
def list_users(self, base_dn: str) -> list[LdapEntry]:
|
|
209
|
+
"""List all user entries.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
base_dn: The base distinguished name to search under.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A list of all user LdapEntry objects.
|
|
216
|
+
"""
|
|
217
|
+
search_filter = "(objectClass=inetOrgPerson)"
|
|
218
|
+
return self._search(search_filter, base_dn)
|
|
219
|
+
|
|
220
|
+
def list_groups(self, base_dn: str) -> list[LdapEntry]:
|
|
221
|
+
"""List all group entries.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
base_dn: The base distinguished name to search under.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A list of all group LdapEntry objects.
|
|
228
|
+
"""
|
|
229
|
+
search_filter = "(objectClass=posixGroup)"
|
|
230
|
+
return self._search(search_filter, base_dn)
|
|
231
|
+
|
|
232
|
+
def get_user(self, name: str, base_dn: str) -> list[LdapEntry]:
|
|
233
|
+
"""Get a specific user by exact uid or cn.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
name: Exact uid or cn to match.
|
|
237
|
+
base_dn: The base distinguished name to search under.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
A list of matching LdapEntry objects (typically 0 or 1).
|
|
241
|
+
"""
|
|
242
|
+
escaped = _escape_filter_value(name)
|
|
243
|
+
search_filter = f"(&(objectClass=inetOrgPerson)(|(uid={escaped})(cn={escaped})))"
|
|
244
|
+
return self._search(search_filter, base_dn)
|
|
245
|
+
|
|
246
|
+
def get_group(self, name: str, base_dn: str) -> list[LdapEntry]:
|
|
247
|
+
"""Get a specific group by exact cn.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
name: Exact cn to match.
|
|
251
|
+
base_dn: The base distinguished name to search under.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
A list of matching LdapEntry objects (typically 0 or 1).
|
|
255
|
+
"""
|
|
256
|
+
escaped = _escape_filter_value(name)
|
|
257
|
+
search_filter = f"(&(objectClass=posixGroup)(cn={escaped}))"
|
|
258
|
+
return self._search(search_filter, base_dn)
|
|
259
|
+
|
|
260
|
+
def add_user(self, name: str, base_dn: str, users_ou: str = "cn=users") -> None:
|
|
261
|
+
"""Add a new user entry to the directory.
|
|
262
|
+
|
|
263
|
+
Creates an inetOrgPerson with uid and cn set to the given name.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: The uid/cn for the new user.
|
|
267
|
+
base_dn: The base DN under which to create the user.
|
|
268
|
+
users_ou: The RDN of the users container. Defaults to "cn=users".
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ConnectionError: If the operation fails.
|
|
272
|
+
"""
|
|
273
|
+
if self._connection is None:
|
|
274
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
275
|
+
|
|
276
|
+
dn = f"uid={name},{users_ou},{base_dn}"
|
|
277
|
+
attributes = {
|
|
278
|
+
"objectClass": ["inetOrgPerson", "top"],
|
|
279
|
+
"uid": name,
|
|
280
|
+
"cn": name,
|
|
281
|
+
"sn": name,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
LOG.debug("Adding user dn=%s", dn)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
self._connection.add(dn, attributes=attributes)
|
|
288
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
289
|
+
raise ConnectionError(
|
|
290
|
+
f"Failed to add user '{name}': {exc}"
|
|
291
|
+
) from exc
|
|
292
|
+
|
|
293
|
+
if not self._connection.result["description"] == "success":
|
|
294
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
295
|
+
raise ConnectionError(f"Failed to add user '{name}': {msg}")
|
|
296
|
+
|
|
297
|
+
def add_group(self, name: str, base_dn: str, groups_ou: str = "cn=groups") -> None:
|
|
298
|
+
"""Add a new group entry to the directory.
|
|
299
|
+
|
|
300
|
+
Creates a posixGroup with cn set to the given name.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
name: The cn for the new group.
|
|
304
|
+
base_dn: The base DN under which to create the group.
|
|
305
|
+
groups_ou: The RDN of the groups container. Defaults to "cn=groups".
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ConnectionError: If the operation fails.
|
|
309
|
+
"""
|
|
310
|
+
if self._connection is None:
|
|
311
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
312
|
+
|
|
313
|
+
dn = f"cn={name},{groups_ou},{base_dn}"
|
|
314
|
+
attributes = {
|
|
315
|
+
"objectClass": ["posixGroup", "top"],
|
|
316
|
+
"cn": name,
|
|
317
|
+
"gidNumber": 0,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
LOG.debug("Adding group dn=%s", dn)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
self._connection.add(dn, attributes=attributes)
|
|
324
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
325
|
+
raise ConnectionError(
|
|
326
|
+
f"Failed to add group '{name}': {exc}"
|
|
327
|
+
) from exc
|
|
328
|
+
|
|
329
|
+
if not self._connection.result["description"] == "success":
|
|
330
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
331
|
+
raise ConnectionError(f"Failed to add group '{name}': {msg}")
|
|
332
|
+
|
|
333
|
+
def delete_user(self, name: str, base_dn: str, users_ou: str = "cn=users") -> None:
|
|
334
|
+
"""Delete a user entry from the directory.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
name: The uid of the user to delete.
|
|
338
|
+
base_dn: The base DN under which the user exists.
|
|
339
|
+
users_ou: The RDN of the users container. Defaults to "cn=users".
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
ConnectionError: If the operation fails.
|
|
343
|
+
"""
|
|
344
|
+
if self._connection is None:
|
|
345
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
346
|
+
|
|
347
|
+
dn = f"uid={name},{users_ou},{base_dn}"
|
|
348
|
+
LOG.debug("Deleting user dn=%s", dn)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
self._connection.delete(dn)
|
|
352
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
353
|
+
raise ConnectionError(
|
|
354
|
+
f"Failed to delete user '{name}': {exc}"
|
|
355
|
+
) from exc
|
|
356
|
+
|
|
357
|
+
if not self._connection.result["description"] == "success":
|
|
358
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
359
|
+
raise ConnectionError(f"Failed to delete user '{name}': {msg}")
|
|
360
|
+
|
|
361
|
+
def delete_group(self, name: str, base_dn: str, groups_ou: str = "cn=groups") -> None:
|
|
362
|
+
"""Delete a group entry from the directory.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
name: The cn of the group to delete.
|
|
366
|
+
base_dn: The base DN under which the group exists.
|
|
367
|
+
groups_ou: The RDN of the groups container. Defaults to "cn=groups".
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ConnectionError: If the operation fails.
|
|
371
|
+
"""
|
|
372
|
+
if self._connection is None:
|
|
373
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
374
|
+
|
|
375
|
+
dn = f"cn={name},{groups_ou},{base_dn}"
|
|
376
|
+
LOG.debug("Deleting group dn=%s", dn)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
self._connection.delete(dn)
|
|
380
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
381
|
+
raise ConnectionError(
|
|
382
|
+
f"Failed to delete group '{name}': {exc}"
|
|
383
|
+
) from exc
|
|
384
|
+
|
|
385
|
+
if not self._connection.result["description"] == "success":
|
|
386
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
387
|
+
raise ConnectionError(f"Failed to delete group '{name}': {msg}")
|
|
388
|
+
|
|
389
|
+
def add_user_to_group(self, user: str, group: str, base_dn: str,
|
|
390
|
+
users_ou: str = "cn=users", groups_ou: str = "cn=groups") -> None:
|
|
391
|
+
"""Add a user to a group's member list.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
user: The uid of the user to add.
|
|
395
|
+
group: The cn of the group.
|
|
396
|
+
base_dn: The base DN.
|
|
397
|
+
users_ou: The RDN of the users container.
|
|
398
|
+
groups_ou: The RDN of the groups container.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
ConnectionError: If the operation fails.
|
|
402
|
+
"""
|
|
403
|
+
if self._connection is None:
|
|
404
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
405
|
+
|
|
406
|
+
group_dn = f"cn={group},{groups_ou},{base_dn}"
|
|
407
|
+
user_dn = f"uid={user},{users_ou},{base_dn}"
|
|
408
|
+
|
|
409
|
+
LOG.debug("Adding %s to group %s", user_dn, group_dn)
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
self._connection.modify(group_dn, {"memberUid": [(MODIFY_ADD, [user])]})
|
|
413
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
414
|
+
raise ConnectionError(
|
|
415
|
+
f"Failed to add user '{user}' to group '{group}': {exc}"
|
|
416
|
+
) from exc
|
|
417
|
+
|
|
418
|
+
if not self._connection.result["description"] == "success":
|
|
419
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
420
|
+
raise ConnectionError(f"Failed to add user '{user}' to group '{group}': {msg}")
|
|
421
|
+
|
|
422
|
+
def remove_user_from_group(self, user: str, group: str, base_dn: str,
|
|
423
|
+
users_ou: str = "cn=users", groups_ou: str = "cn=groups") -> None:
|
|
424
|
+
"""Remove a user from a group's member list.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
user: The uid of the user to remove.
|
|
428
|
+
group: The cn of the group.
|
|
429
|
+
base_dn: The base DN.
|
|
430
|
+
users_ou: The RDN of the users container.
|
|
431
|
+
groups_ou: The RDN of the groups container.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
ConnectionError: If the operation fails.
|
|
435
|
+
"""
|
|
436
|
+
if self._connection is None:
|
|
437
|
+
raise ConnectionError(f"Connection failed: unable to reach {self._host}")
|
|
438
|
+
|
|
439
|
+
group_dn = f"cn={group},{groups_ou},{base_dn}"
|
|
440
|
+
|
|
441
|
+
LOG.debug("Removing %s from group %s", user, group_dn)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
self._connection.modify(group_dn, {"memberUid": [(MODIFY_DELETE, [user])]})
|
|
445
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
446
|
+
raise ConnectionError(
|
|
447
|
+
f"Failed to remove user '{user}' from group '{group}': {exc}"
|
|
448
|
+
) from exc
|
|
449
|
+
|
|
450
|
+
if not self._connection.result["description"] == "success":
|
|
451
|
+
msg = self._connection.result.get("message", self._connection.result.get("description", "unknown error"))
|
|
452
|
+
raise ConnectionError(f"Failed to remove user '{user}' from group '{group}': {msg}")
|
|
453
|
+
|
|
454
|
+
def close(self) -> None:
|
|
455
|
+
"""Unbind and close the LDAP connection.
|
|
456
|
+
|
|
457
|
+
Safe to call multiple times; subsequent calls are no-ops.
|
|
458
|
+
"""
|
|
459
|
+
if self._connection is not None:
|
|
460
|
+
try:
|
|
461
|
+
self._connection.unbind()
|
|
462
|
+
except (LDAPExceptionError, OSError):
|
|
463
|
+
pass
|
|
464
|
+
self._connection = None
|
|
465
|
+
|
|
466
|
+
def _search(self, search_filter: str, base_dn: str) -> list[LdapEntry]:
|
|
467
|
+
"""Execute an LDAP search and return results as LdapEntry objects.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
search_filter: The LDAP filter string.
|
|
471
|
+
base_dn: The base DN to search under.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
A list of LdapEntry objects.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
ConnectionError: If the search fails due to a connection issue.
|
|
478
|
+
"""
|
|
479
|
+
if self._connection is None:
|
|
480
|
+
raise ConnectionError(
|
|
481
|
+
f"Connection failed: unable to reach {self._host}"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
LOG.debug("Searching base_dn=%s filter=%s", base_dn, search_filter)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
self._connection.search(
|
|
488
|
+
search_base=base_dn,
|
|
489
|
+
search_filter=search_filter,
|
|
490
|
+
search_scope=SUBTREE,
|
|
491
|
+
attributes=[ALL_ATTRIBUTES],
|
|
492
|
+
)
|
|
493
|
+
except (LDAPExceptionError, OSError) as exc:
|
|
494
|
+
raise ConnectionError(
|
|
495
|
+
f"Connection failed: unable to reach {self._host}"
|
|
496
|
+
) from exc
|
|
497
|
+
|
|
498
|
+
entries: list[LdapEntry] = []
|
|
499
|
+
for entry in self._connection.entries:
|
|
500
|
+
attrs: dict[str, list[str]] = {}
|
|
501
|
+
for attr_name in entry.entry_attributes:
|
|
502
|
+
values = entry[attr_name].values
|
|
503
|
+
attrs[attr_name] = [str(v) for v in values]
|
|
504
|
+
entries.append(LdapEntry(dn=str(entry.entry_dn), attributes=attrs))
|
|
505
|
+
|
|
506
|
+
return entries
|