ldap-ui 0.10.0__py3-none-any.whl → 0.10.2__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.
ldap_ui/ldap_api.py CHANGED
@@ -1,15 +1,14 @@
1
1
  """
2
2
  ReST endpoints for LDAP access.
3
3
 
4
- Directory operations are accessible to the frontend
5
- through a hand-knit ReST API, responses are usually converted to JSON.
4
+ Directory operations are exposed to the frontend
5
+ by a hand-knit ReST API, responses are usually converted to JSON.
6
6
 
7
7
  Asynchronous LDAP operations are used as much as possible.
8
8
  """
9
9
 
10
10
  import base64
11
11
  import io
12
- import logging
13
12
  from enum import StrEnum
14
13
  from http import HTTPStatus
15
14
  from typing import Annotated, cast
@@ -35,35 +34,31 @@ from ldap import (
35
34
  from ldap.ldapobject import LDAPObject
36
35
  from ldap.modlist import addModlist, modifyModlist
37
36
  from ldap.schema import SubSchema
38
- from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
37
+ from ldap.schema.models import AttributeType, LDAPSyntax
39
38
 
40
39
  from . import settings
41
40
  from .entities import (
41
+ AttributeNames,
42
42
  Attributes,
43
- ChangedAttributes,
44
43
  ChangePasswordRequest,
45
44
  Entry,
46
- Meta,
47
45
  Range,
48
46
  SearchResult,
49
47
  TreeItem,
50
48
  )
51
49
  from .ldap_helpers import (
52
50
  WITH_OPERATIONAL_ATTRS,
53
- BinaryAttributes,
51
+ LdapEntry,
54
52
  anonymous_user_search,
55
53
  empty,
56
54
  get_entry_by_dn,
57
55
  get_schema,
58
56
  ldap_connect,
59
- result,
57
+ results,
58
+ unique,
60
59
  )
61
- from .schema import ObjectClass as OC
62
60
  from .schema import Schema, frontend_schema
63
61
 
64
- __all__ = ("api",)
65
-
66
-
67
62
  NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
68
63
 
69
64
  # Special fields
@@ -74,17 +69,39 @@ PASSWORDS = ("userPassword",)
74
69
  OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
75
70
  INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
76
71
 
77
- LOG = logging.getLogger("ldap-api")
78
-
79
72
  api = APIRouter(prefix="/api")
80
73
 
81
74
 
75
+ async def get_root_dse(connection: LDAPObject):
76
+ "Auto-detect base DN and LDAP schema from root DSE"
77
+ result = await unique(
78
+ connection,
79
+ connection.search(
80
+ "",
81
+ SCOPE_BASE,
82
+ attrlist=WITH_OPERATIONAL_ATTRS,
83
+ ),
84
+ )
85
+ if not settings.BASE_DN:
86
+ base_dns = result.attr("namingContexts")
87
+ assert len(base_dns) == 1, f"No unique base DN: {base_dns}"
88
+ settings.BASE_DN = base_dns[0]
89
+
90
+ if not settings.SCHEMA_DN:
91
+ schema_dns = result.attr("subschemaSubentry")
92
+ assert schema_dns, "Cannot determine LDAP schema"
93
+ settings.SCHEMA_DN = schema_dns[0]
94
+
95
+
82
96
  async def authenticated(
83
97
  credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
84
98
  connection: Annotated[LDAPObject, Depends(ldap_connect)],
85
99
  ) -> LDAPObject:
86
100
  "Authenticate against the directory"
87
101
 
102
+ if not settings.BASE_DN or not settings.SCHEMA_DN:
103
+ await get_root_dse(connection)
104
+
88
105
  # Hard-wired credentials
89
106
  dn = settings.GET_BIND_DN()
90
107
  password = settings.GET_BIND_PASSWORD()
@@ -112,118 +129,96 @@ class Tag(StrEnum):
112
129
  NAVIGATION = "Navigation"
113
130
 
114
131
 
132
+ @api.get(
133
+ "/tree/base",
134
+ tags=[Tag.NAVIGATION],
135
+ operation_id="get_base_entry",
136
+ include_in_schema=False, # Overlaps with next endpoint
137
+ )
138
+ async def get_base_entry(connection: AuthenticatedConnection) -> list[TreeItem]:
139
+ "Get the directory base entry"
140
+
141
+ assert settings.BASE_DN, "An LDAP base DN is required!"
142
+ result = await unique(
143
+ connection,
144
+ connection.search(
145
+ settings.BASE_DN, SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
146
+ ),
147
+ )
148
+ return [_tree_item(result, settings.BASE_DN)]
149
+
150
+
115
151
  @api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
116
152
  async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
117
- "List directory entries"
118
-
119
- base_level = len(basedn.split(","))
120
- scope = SCOPE_ONELEVEL
121
- if basedn == "base":
122
- scope = SCOPE_BASE
123
- assert settings.BASE_DN is not None
124
- basedn = settings.BASE_DN
153
+ "List directory entries below a DN"
125
154
 
126
- entries = result(
127
- connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
128
- )
129
155
  return [
130
- _tree_item(dn, attrs, base_level, await get_schema(connection))
131
- async for dn, attrs in entries
156
+ _tree_item(entry, basedn)
157
+ async for entry in results(
158
+ connection,
159
+ connection.search(basedn, SCOPE_ONELEVEL, attrlist=WITH_OPERATIONAL_ATTRS),
160
+ )
132
161
  ]
133
162
 
134
163
 
135
- def _tree_item(
136
- dn: str, attrs: BinaryAttributes, level: int, schema: SubSchema
137
- ) -> TreeItem:
138
- structuralObjectClass = next(
139
- iter(
140
- filter(
141
- lambda oc: oc.kind == OC.Kind.structural.value, # type: ignore
142
- map(
143
- lambda o: schema.get_obj(ObjectClass, o.decode()),
144
- attrs["objectClass"],
145
- ),
146
- )
147
- )
148
- )
149
-
164
+ def _tree_item(entry: LdapEntry, base_dn: str) -> TreeItem:
150
165
  return TreeItem(
151
- dn=dn,
152
- structuralObjectClass=structuralObjectClass.names[0],
153
- hasSubordinates=attrs["hasSubordinates"][0] == b"TRUE"
154
- if "hasSubordinates" in attrs
155
- else bool(attrs.get("numSubordinates")),
156
- level=len(dn.split(",")) - level,
166
+ dn=entry.dn,
167
+ structuralObjectClass=entry.attr("structuralObjectClass")[0],
168
+ hasSubordinates=entry.hasSubordinates,
169
+ level=_level(entry.dn) - _level(base_dn),
157
170
  )
158
171
 
159
172
 
160
- def _entry(res: tuple[str, BinaryAttributes], schema: SubSchema) -> Entry:
161
- "Prepare an LDAP entry for transmission"
173
+ def _level(dn: str) -> int:
174
+ return len(dn.split(","))
162
175
 
163
- dn, attrs = res
164
- ocs = set([oc.decode() for oc in attrs["objectClass"]])
165
- must_attrs, _may_attrs = schema.attribute_types(ocs)
166
- soc = [
167
- oc.names[0] # type: ignore
168
- for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
169
- if oc.kind == OC.Kind.structural.value # type: ignore
170
- ]
171
- aux = set(
172
- schema.get_obj(ObjectClass, a).names[0] # type: ignore
173
- for a in schema.get_applicable_aux_classes(soc[0])
176
+
177
+ @api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
178
+ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
179
+ "Retrieve a directory entry by DN"
180
+ return _entry(
181
+ await get_entry_by_dn(connection, dn),
182
+ await get_schema(connection),
174
183
  )
175
184
 
176
- # 23 suppress userPassword
177
- if "userPassword" in attrs:
178
- attrs["userPassword"] = [b"*****"]
179
-
180
- # Filter out binary attributes
181
- binary = set()
182
- for attr in attrs:
183
- obj = schema.get_obj(AttributeType, attr)
184
-
185
- # Octet strings are not used consistently.
186
- # Try to decode as text and treat as binary on failure
187
- if not obj.syntax or obj.syntax == OCTET_STRING: # type: ignore
188
- try:
189
- for val in attrs[attr]:
190
- assert val.decode().isprintable()
191
- except: # noqa: E722
192
- binary.add(attr)
193
-
194
- else: # Check human-readable flag in schema
195
- syntax = schema.get_obj(LDAPSyntax, obj.syntax) # type: ignore
196
- if syntax.not_human_readable: # type: ignore
197
- binary.add(attr)
198
185
 
186
+ def _entry(entry: LdapEntry, schema: SubSchema) -> Entry:
187
+ "Decode an LDAP entry for transmission"
188
+
189
+ binary = sorted(
190
+ set(attr for attr in entry.attrs if _is_binary(entry, attr, schema))
191
+ )
199
192
  return Entry(
200
193
  attrs={
201
- k: [
202
- base64.b64encode(val).decode() if k in binary else val.decode()
203
- for val in values
204
- ]
205
- for k, values in attrs.items()
194
+ k: ["*****"] # 23 suppress userPassword
195
+ if k == "userPassword"
196
+ else [base64.b64encode(val).decode() for val in entry.attrs[k]]
197
+ if k in binary
198
+ else entry.attr(k)
199
+ for k in sorted(entry.attrs)
206
200
  },
207
- meta=Meta(
208
- dn=dn,
209
- required=[
210
- schema.get_obj(AttributeType, a).names[0] # type: ignore
211
- for a in must_attrs
212
- ],
213
- aux=sorted(aux - ocs),
214
- binary=sorted(binary),
215
- autoFilled=[],
216
- ),
201
+ dn=entry.dn,
202
+ binary=binary,
203
+ autoFilled=[],
204
+ changed=[],
217
205
  )
218
206
 
219
207
 
220
- @api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
221
- async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
222
- "Retrieve a directory entry by DN"
223
- return _entry(
224
- await get_entry_by_dn(connection, dn),
225
- await get_schema(connection),
226
- )
208
+ def _is_binary(entry: LdapEntry, attr: str, schema: SubSchema) -> bool:
209
+ "Guess whether an attribute has binary content"
210
+
211
+ # Octet strings are not used consistently in schemata.
212
+ # Try to decode as text and treat as binary on failure
213
+ attr_type = schema.get_obj(AttributeType, attr)
214
+ if not attr_type.syntax or attr_type.syntax == OCTET_STRING: # type: ignore
215
+ try:
216
+ return any(not val.isprintable() for val in entry.attr(attr))
217
+ except UnicodeDecodeError:
218
+ return True
219
+
220
+ # Check human-readable flag
221
+ return schema.get_obj(LDAPSyntax, attr_type.syntax).not_human_readable # type: ignore
227
222
 
228
223
 
229
224
  @api.delete(
@@ -235,8 +230,8 @@ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
235
230
  async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
236
231
  for entry_dn in sorted(
237
232
  [
238
- dn
239
- async for dn, _attrs in result(
233
+ entry.dn
234
+ async for entry in results(
240
235
  connection,
241
236
  connection.search(dn, SCOPE_SUBTREE),
242
237
  )
@@ -250,37 +245,47 @@ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
250
245
  @api.post("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="post_entry")
251
246
  async def post_entry(
252
247
  dn: str, attributes: Attributes, connection: AuthenticatedConnection
253
- ) -> ChangedAttributes:
254
- req = {
255
- k: [s.encode() for s in filter(None, v)] # Omit empty byte strings
256
- for k, v in attributes.items()
257
- if k not in PHOTOS and k not in PASSWORDS
258
- }
259
-
260
- # Get previous values from directory
248
+ ) -> AttributeNames:
261
249
  entry = await get_entry_by_dn(connection, dn)
262
- mods = {k: v for k, v in entry[1].items() if k in req}
263
- modlist = modifyModlist(mods, req)
250
+ schema = await get_schema(connection)
264
251
 
252
+ expected = {
253
+ attr: _nonempty_byte_strings(attributes, attr)
254
+ for attr in attributes
255
+ if attr not in PASSWORDS
256
+ and (
257
+ attr not in entry.attrs
258
+ or not _is_binary(
259
+ entry, attr, schema
260
+ ) # FIXME Handle binary attributes properly
261
+ )
262
+ }
263
+
264
+ actual = {attr: v for attr, v in entry.attrs.items() if attr in expected}
265
+ modlist = modifyModlist(actual, expected)
265
266
  if modlist: # Apply changes and send changed keys back
266
267
  await empty(connection, connection.modify(dn, modlist))
267
- return ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
268
+ return list(sorted(set(m[1] for m in modlist)))
269
+
270
+
271
+ def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
272
+ return [s.encode() for s in filter(None, attributes[attr])]
268
273
 
269
274
 
270
275
  @api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
271
276
  async def put_entry(
272
277
  dn: str, attributes: Attributes, connection: AuthenticatedConnection
273
- ) -> ChangedAttributes:
278
+ ) -> AttributeNames:
274
279
  modlist = addModlist(
275
280
  {
276
- k: [s.encode() for s in filter(None, v)] # Omit empty byte strings
277
- for k, v in attributes.items()
278
- if k not in PHOTOS
281
+ attr: _nonempty_byte_strings(attributes, attr)
282
+ for attr in attributes
283
+ if attr not in PHOTOS
279
284
  }
280
285
  )
281
286
  if modlist:
282
287
  await empty(connection, connection.add(dn, modlist))
283
- return ChangedAttributes(changed=["dn"]) # Dummy
288
+ return ["dn"] # Dummy
284
289
 
285
290
 
286
291
  @api.post(
@@ -307,15 +312,15 @@ async def get_blob(
307
312
  ) -> Response:
308
313
  "Retrieve a binary attribute"
309
314
 
310
- _dn, attrs = await get_entry_by_dn(connection, dn)
315
+ entry = await get_entry_by_dn(connection, dn)
311
316
 
312
- if attr not in attrs or len(attrs[attr]) <= index:
317
+ if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
313
318
  raise HTTPException(
314
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
319
+ HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
315
320
  )
316
321
 
317
322
  return Response(
318
- attrs[attr][index],
323
+ entry.attrs[attr][index],
319
324
  media_type="application/octet-stream",
320
325
  headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
321
326
  )
@@ -335,12 +340,14 @@ async def put_blob(
335
340
  connection: AuthenticatedConnection,
336
341
  ) -> None:
337
342
  "Upload a binary attribute"
338
- _dn, attrs = await get_entry_by_dn(connection, dn)
343
+ entry = await get_entry_by_dn(connection, dn)
339
344
  data = await blob.read(cast(int, blob.size))
340
- if attr in attrs:
345
+ if attr in entry.attrs:
341
346
  await empty(
342
347
  connection,
343
- connection.modify(dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]),
348
+ connection.modify(
349
+ dn, [(1, attr, None), (0, attr, entry.attrs[attr] + [data])]
350
+ ),
344
351
  )
345
352
  else:
346
353
  await empty(connection, connection.modify(dn, [(0, attr, [data])]))
@@ -356,13 +363,13 @@ async def delete_blob(
356
363
  attr: str, index: int, dn: str, connection: AuthenticatedConnection
357
364
  ) -> None:
358
365
  "Remove a binary attribute"
359
- _dn, attrs = await get_entry_by_dn(connection, dn)
360
- if attr not in attrs or len(attrs[attr]) <= index:
366
+ entry = await get_entry_by_dn(connection, dn)
367
+ if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
361
368
  raise HTTPException(
362
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
369
+ HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
363
370
  )
364
371
  await empty(connection, connection.modify(dn, [(1, attr, None)]))
365
- data = attrs[attr][:index] + attrs[attr][index + 1 :]
372
+ data = entry.attrs[attr][:index] + entry.attrs[attr][index + 1 :]
366
373
  if data:
367
374
  await empty(connection, connection.modify(dn, [(0, attr, data)]))
368
375
 
@@ -386,29 +393,21 @@ async def check_password(
386
393
  "/change-password/{dn:path}",
387
394
  tags=[Tag.EDITING],
388
395
  operation_id="post_change_password",
396
+ status_code=HTTPStatus.NO_CONTENT,
389
397
  )
390
398
  async def change_password(
391
399
  dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
392
- ) -> str | None:
400
+ ) -> None:
393
401
  "Update passwords"
394
402
  if args.new1:
395
403
  await empty(
396
404
  connection,
397
405
  connection.passwd(dn, args.old or None, args.new1),
398
406
  )
399
- _dn, attrs = await get_entry_by_dn(connection, dn)
400
- attrs["userPassword"][0].decode()
401
-
402
407
  else:
403
408
  await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
404
409
 
405
410
 
406
- def _cn(entry: dict) -> str | None:
407
- "Try to extract a CN"
408
- if "cn" in entry and entry["cn"]:
409
- return entry["cn"][0].decode()
410
-
411
-
412
411
  @api.get(
413
412
  "/ldif/{dn:path}",
414
413
  include_in_schema=False, # Used as a link target, no API call
@@ -419,8 +418,8 @@ async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
419
418
  out = io.StringIO()
420
419
  writer = ldif.LDIFWriter(out)
421
420
 
422
- async for dn, attrs in result(connection, connection.search(dn, SCOPE_SUBTREE)):
423
- writer.unparse(dn, attrs)
421
+ async for entry in results(connection, connection.search(dn, SCOPE_SUBTREE)):
422
+ writer.unparse(dn, entry.attrs)
424
423
 
425
424
  file_name = dn.split(",")[0].split("=")[1]
426
425
  return PlainTextResponse(
@@ -440,18 +439,22 @@ class LDIFReader(ldif.LDIFParser):
440
439
  self.count += 1
441
440
 
442
441
 
443
- @api.post("/ldif", tags=[Tag.EDITING], operation_id="post_ldif")
442
+ @api.post(
443
+ "/ldif",
444
+ tags=[Tag.EDITING],
445
+ operation_id="post_ldif",
446
+ status_code=HTTPStatus.NO_CONTENT,
447
+ )
444
448
  async def upload_ldif(
445
449
  ldif: Annotated[str, Body()], connection: AuthenticatedConnection
446
- ) -> Response:
450
+ ) -> None:
447
451
  "Import LDIF"
448
452
 
449
453
  reader = LDIFReader(ldif.encode(), connection)
450
454
  try:
451
455
  reader.parse()
452
- return NO_CONTENT
453
456
  except ValueError as e:
454
- return Response(e.args[0], HTTPStatus.UNPROCESSABLE_ENTITY)
457
+ raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, e.args[0])
455
458
 
456
459
 
457
460
  @api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
@@ -469,10 +472,15 @@ async def search(query: str, connection: AuthenticatedConnection) -> list[Search
469
472
 
470
473
  # Collect results
471
474
  res = []
472
- async for dn, attrs in result(
475
+ async for entry in results(
473
476
  connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
474
477
  ):
475
- res.append(SearchResult(dn=dn, name=_cn(attrs) or dn))
478
+ res.append(
479
+ SearchResult(
480
+ dn=entry.dn,
481
+ name=entry.attr("cn")[0] if "cn" in entry.attrs else entry.dn,
482
+ )
483
+ )
476
484
  if len(res) >= settings.SEARCH_MAX:
477
485
  break
478
486
  return res
@@ -490,15 +498,16 @@ async def list_subtree(
490
498
  ) -> list[TreeItem]:
491
499
  "List the subtree below a DN"
492
500
 
493
- start = len(root_dn.split(","))
494
501
  return sorted(
495
502
  [
496
- _tree_item(dn, attrs, start, await get_schema(connection))
497
- async for dn, attrs in result(
503
+ _tree_item(entry, root_dn)
504
+ async for entry in results(
498
505
  connection,
499
- connection.search(root_dn, SCOPE_SUBTREE),
506
+ connection.search(
507
+ root_dn, SCOPE_SUBTREE, attrlist=WITH_OPERATIONAL_ATTRS
508
+ ),
500
509
  )
501
- if root_dn != dn
510
+ if root_dn != entry.dn
502
511
  ],
503
512
  key=lambda item: tuple(reversed(item.dn.lower().split(","))),
504
513
  )
@@ -513,8 +522,8 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
513
522
 
514
523
  values = set(
515
524
  [
516
- int(attrs[attribute][0])
517
- async for dn, attrs in result(
525
+ int(entry.attrs[attribute][0])
526
+ async for entry in results(
518
527
  connection,
519
528
  connection.search(
520
529
  settings.BASE_DN,
@@ -529,7 +538,7 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
529
538
 
530
539
  if not values:
531
540
  raise HTTPException(
532
- HTTPStatus.NOT_FOUND.value, f"No values found for attribute {attribute}"
541
+ HTTPStatus.NOT_FOUND, f"No values found for attribute {attribute}"
533
542
  )
534
543
 
535
544
  minimum, maximum = min(values), max(values)
@@ -549,4 +558,5 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
549
558
  )
550
559
  async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
551
560
  "Dump the LDAP schema as JSON"
561
+ assert settings.SCHEMA_DN, "An LDAP schema DN is required!"
552
562
  return frontend_schema(await get_schema(connection))
ldap_ui/ldap_helpers.py CHANGED
@@ -10,6 +10,7 @@ like retrieving a unique result or waiting for an
10
10
  operation to complete without results.
11
11
  """
12
12
 
13
+ from dataclasses import dataclass
13
14
  from http import HTTPStatus
14
15
  from typing import AsyncGenerator, Generator
15
16
 
@@ -32,7 +33,23 @@ from . import settings
32
33
  # Constant to add technical attributes in LDAP search results
33
34
  WITH_OPERATIONAL_ATTRS = ("*", "+")
34
35
 
35
- BinaryAttributes = dict[str, list[bytes]]
36
+
37
+ @dataclass(frozen=True)
38
+ class LdapEntry:
39
+ dn: str
40
+ attrs: dict[str, list[bytes]]
41
+
42
+ def attr(self, name: str) -> list[str]:
43
+ return [v.decode() for v in self.attrs[name]]
44
+
45
+ @property
46
+ def hasSubordinates(self):
47
+ return (
48
+ self.attr("hasSubordinates") == ["TRUE"]
49
+ if "hasSubordinates" in self.attrs
50
+ else bool(self.attrs.get("numSubordinates"))
51
+ )
52
+
36
53
 
37
54
  sub_schema: SubSchema | None = None
38
55
 
@@ -58,24 +75,24 @@ def ldap_connect() -> Generator[LDAPObject, None, None]:
58
75
 
59
76
  async def anonymous_user_search(connection: LDAPObject, username: str) -> str | None:
60
77
  try:
61
- # No BIND_PATTERN, try anonymous search
62
- dn, _attrs = await unique(
63
- connection,
64
- connection.search(
65
- settings.BASE_DN,
66
- SCOPE_SUBTREE,
67
- settings.GET_BIND_DN_FILTER(username),
68
- ),
69
- )
70
- return dn
78
+ return (
79
+ await unique(
80
+ connection,
81
+ connection.search(
82
+ settings.BASE_DN,
83
+ SCOPE_SUBTREE,
84
+ settings.GET_BIND_DN_FILTER(username),
85
+ ),
86
+ )
87
+ ).dn
71
88
 
72
89
  except HTTPException:
73
90
  pass # No unique result
74
91
 
75
92
 
76
- async def result(
93
+ async def results(
77
94
  connection: LDAPObject, msgid: int
78
- ) -> AsyncGenerator[tuple[str, BinaryAttributes], None]:
95
+ ) -> AsyncGenerator[LdapEntry, None]:
79
96
  "Stream LDAP result entries without blocking other tasks"
80
97
 
81
98
  while True:
@@ -85,27 +102,27 @@ async def result(
85
102
  elif r_data == []: # Operation completed
86
103
  break
87
104
  else:
88
- yield r_data[0] # type: ignore
105
+ yield LdapEntry(*r_data[0]) # type: ignore
89
106
 
90
107
 
91
108
  async def unique(
92
109
  connection: LDAPObject,
93
110
  msgid: int,
94
- ) -> tuple[str, BinaryAttributes]:
111
+ ) -> LdapEntry:
95
112
  "Asynchronously collect a unique result"
96
113
 
97
114
  res = None
98
- async for r in result(connection, msgid):
115
+ async for r in results(connection, msgid):
99
116
  if res is None:
100
117
  res = r
101
118
  else:
102
119
  connection.abandon(msgid)
103
120
  raise HTTPException(
104
- HTTPStatus.INTERNAL_SERVER_ERROR.value,
121
+ HTTPStatus.INTERNAL_SERVER_ERROR,
105
122
  "Non-unique result",
106
123
  )
107
124
  if res is None:
108
- raise HTTPException(HTTPStatus.NOT_FOUND.value, "Empty search result")
125
+ raise HTTPException(HTTPStatus.NOT_FOUND, "Empty search result")
109
126
  return res
110
127
 
111
128
 
@@ -115,15 +132,15 @@ async def empty(
115
132
  ) -> None:
116
133
  "Asynchronously wait for an empty result"
117
134
 
118
- async for r in result(connection, msgid):
135
+ async for r in results(connection, msgid):
119
136
  connection.abandon(msgid)
120
- raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR.value, "Unexpected result")
137
+ raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected result")
121
138
 
122
139
 
123
140
  async def get_entry_by_dn(
124
141
  connection: LDAPObject,
125
142
  dn: str,
126
- ) -> tuple[str, BinaryAttributes]:
143
+ ) -> LdapEntry:
127
144
  "Asynchronously retrieve an LDAP entry by its DN"
128
145
 
129
146
  return await unique(connection, connection.search(dn, SCOPE_BASE))
@@ -133,7 +150,7 @@ async def get_schema(connection: LDAPObject) -> SubSchema:
133
150
  global sub_schema
134
151
  # See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
135
152
  if sub_schema is None:
136
- _dn, schema = await unique(
153
+ result = await unique(
137
154
  connection,
138
155
  connection.search(
139
156
  settings.SCHEMA_DN,
@@ -141,5 +158,5 @@ async def get_schema(connection: LDAPObject) -> SubSchema:
141
158
  attrlist=WITH_OPERATIONAL_ATTRS,
142
159
  ),
143
160
  )
144
- sub_schema = SubSchema(schema, check_uniqueness=2)
161
+ sub_schema = SubSchema(result.attrs, check_uniqueness=2)
145
162
  return sub_schema