ldap-ui 0.10.0__py3-none-any.whl → 0.10.1__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
@@ -50,20 +49,18 @@ from .entities import (
50
49
  )
51
50
  from .ldap_helpers import (
52
51
  WITH_OPERATIONAL_ATTRS,
53
- BinaryAttributes,
52
+ LdapEntry,
54
53
  anonymous_user_search,
55
54
  empty,
56
55
  get_entry_by_dn,
57
56
  get_schema,
58
57
  ldap_connect,
59
- result,
58
+ results,
59
+ unique,
60
60
  )
61
61
  from .schema import ObjectClass as OC
62
62
  from .schema import Schema, frontend_schema
63
63
 
64
- __all__ = ("api",)
65
-
66
-
67
64
  NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
68
65
 
69
66
  # Special fields
@@ -74,17 +71,39 @@ PASSWORDS = ("userPassword",)
74
71
  OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
75
72
  INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
76
73
 
77
- LOG = logging.getLogger("ldap-api")
78
-
79
74
  api = APIRouter(prefix="/api")
80
75
 
81
76
 
77
+ async def get_root_dse(connection: LDAPObject):
78
+ "Auto-detect base DN and LDAP schema from root DSE"
79
+ result = await unique(
80
+ connection,
81
+ connection.search(
82
+ "",
83
+ SCOPE_BASE,
84
+ attrlist=WITH_OPERATIONAL_ATTRS,
85
+ ),
86
+ )
87
+ if not settings.BASE_DN:
88
+ base_dns = result.attr("namingContexts")
89
+ assert len(base_dns) == 1, f"No unique base DN: {base_dns}"
90
+ settings.BASE_DN = base_dns[0]
91
+
92
+ if not settings.SCHEMA_DN:
93
+ schema_dns = result.attr("subschemaSubentry")
94
+ assert schema_dns, "Cannot determine LDAP schema"
95
+ settings.SCHEMA_DN = schema_dns[0]
96
+
97
+
82
98
  async def authenticated(
83
99
  credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
84
100
  connection: Annotated[LDAPObject, Depends(ldap_connect)],
85
101
  ) -> LDAPObject:
86
102
  "Authenticate against the directory"
87
103
 
104
+ if not settings.BASE_DN or not settings.SCHEMA_DN:
105
+ await get_root_dse(connection)
106
+
88
107
  # Hard-wired credentials
89
108
  dn = settings.GET_BIND_DN()
90
109
  password = settings.GET_BIND_PASSWORD()
@@ -112,118 +131,121 @@ class Tag(StrEnum):
112
131
  NAVIGATION = "Navigation"
113
132
 
114
133
 
134
+ @api.get(
135
+ "/tree/base",
136
+ tags=[Tag.NAVIGATION],
137
+ operation_id="get_base_entry",
138
+ include_in_schema=False, # Overlaps with next endpoint
139
+ )
140
+ async def get_base_entry(connection: AuthenticatedConnection) -> list[TreeItem]:
141
+ "Get the directory base entry"
142
+
143
+ assert settings.BASE_DN, "An LDAP base DN is required!"
144
+ result = await unique(
145
+ connection,
146
+ connection.search(
147
+ settings.BASE_DN, SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
148
+ ),
149
+ )
150
+ return [_tree_item(result, settings.BASE_DN)]
151
+
152
+
115
153
  @api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
116
154
  async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
117
- "List directory entries"
155
+ "List directory entries below a DN"
118
156
 
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
125
-
126
- entries = result(
127
- connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
128
- )
129
157
  return [
130
- _tree_item(dn, attrs, base_level, await get_schema(connection))
131
- async for dn, attrs in entries
158
+ _tree_item(entry, basedn)
159
+ async for entry in results(
160
+ connection,
161
+ connection.search(basedn, SCOPE_ONELEVEL, attrlist=WITH_OPERATIONAL_ATTRS),
162
+ )
132
163
  ]
133
164
 
134
165
 
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
- )
166
+ def _tree_item(entry: LdapEntry, base_dn: str) -> TreeItem:
167
+ return TreeItem(
168
+ dn=entry.dn,
169
+ structuralObjectClass=entry.attr("structuralObjectClass")[0],
170
+ hasSubordinates=entry.hasSubordinates,
171
+ level=_level(entry.dn) - _level(base_dn),
148
172
  )
149
173
 
150
- 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,
174
+
175
+ def _level(dn: str) -> int:
176
+ return len(dn.split(","))
177
+
178
+
179
+ @api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
180
+ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
181
+ "Retrieve a directory entry by DN"
182
+ return _entry(
183
+ await get_entry_by_dn(connection, dn),
184
+ await get_schema(connection),
157
185
  )
158
186
 
159
187
 
160
- def _entry(res: tuple[str, BinaryAttributes], schema: SubSchema) -> Entry:
161
- "Prepare an LDAP entry for transmission"
188
+ def _entry(entry: LdapEntry, schema: SubSchema) -> Entry:
189
+ "Decode an LDAP entry for transmission"
162
190
 
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 = [
191
+ meta = _meta(entry, schema)
192
+ attrs = {
193
+ k: ["*****"] # 23 suppress userPassword
194
+ if k == "userPassword"
195
+ else [base64.b64encode(val).decode() for val in entry.attrs[k]]
196
+ if k in meta.binary
197
+ else entry.attr(k)
198
+ for k in sorted(entry.attrs)
199
+ }
200
+ return Entry(attrs=attrs, meta=meta)
201
+
202
+
203
+ def _meta(entry: LdapEntry, schema: SubSchema) -> Meta:
204
+ "Classify entry attributes"
205
+
206
+ object_classes = set(entry.attr("objectClass"))
207
+ must_attrs, _may_attrs = schema.attribute_types(object_classes)
208
+ required = [
209
+ schema.get_obj(AttributeType, a).names[0] # type: ignore
210
+ for a in must_attrs
211
+ ]
212
+ structural = [
167
213
  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
214
+ for oc in map(lambda o: schema.get_obj(ObjectClass, o), object_classes)
215
+ if oc.kind == OC.Kind.structural # type: ignore
170
216
  ]
171
217
  aux = set(
172
218
  schema.get_obj(ObjectClass, a).names[0] # type: ignore
173
- for a in schema.get_applicable_aux_classes(soc[0])
219
+ for a in schema.get_applicable_aux_classes(structural[0])
174
220
  )
175
221
 
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
-
199
- return Entry(
200
- 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()
206
- },
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
- ),
222
+ return Meta(
223
+ dn=entry.dn,
224
+ required=required,
225
+ aux=sorted(aux - object_classes),
226
+ binary=sorted(_binary_attributes(entry, schema)),
227
+ autoFilled=[],
217
228
  )
218
229
 
219
230
 
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
- )
231
+ def _binary_attributes(entry: LdapEntry, schema: SubSchema) -> set[str]:
232
+ return set(attr for attr in entry.attrs if _is_binary(entry, attr, schema))
233
+
234
+
235
+ def _is_binary(entry: LdapEntry, attr: str, schema: SubSchema) -> bool:
236
+ "Guess whether an attribute has binary content"
237
+
238
+ # Octet strings are not used consistently in schemata.
239
+ # Try to decode as text and treat as binary on failure
240
+ attr_type = schema.get_obj(AttributeType, attr)
241
+ if not attr_type.syntax or attr_type.syntax == OCTET_STRING: # type: ignore
242
+ try:
243
+ return any(not val.isprintable() for val in entry.attr(attr))
244
+ except UnicodeDecodeError:
245
+ return True
246
+
247
+ # Check human-readable flag
248
+ return schema.get_obj(LDAPSyntax, attr_type.syntax).not_human_readable # type: ignore
227
249
 
228
250
 
229
251
  @api.delete(
@@ -235,8 +257,8 @@ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
235
257
  async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
236
258
  for entry_dn in sorted(
237
259
  [
238
- dn
239
- async for dn, _attrs in result(
260
+ entry.dn
261
+ async for entry in results(
240
262
  connection,
241
263
  connection.search(dn, SCOPE_SUBTREE),
242
264
  )
@@ -251,31 +273,38 @@ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
251
273
  async def post_entry(
252
274
  dn: str, attributes: Attributes, connection: AuthenticatedConnection
253
275
  ) -> 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
261
276
  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)
277
+ schema = await get_schema(connection)
264
278
 
279
+ expected = {
280
+ attr: _nonempty_byte_strings(attributes, attr)
281
+ for attr in attributes
282
+ if attr not in PASSWORDS
283
+ and not _is_binary(
284
+ entry, attr, schema
285
+ ) # FIXME Handle binary attributes properly
286
+ }
287
+
288
+ actual = {attr: v for attr, v in entry.attrs.items() if attr in expected}
289
+ modlist = modifyModlist(actual, expected)
265
290
  if modlist: # Apply changes and send changed keys back
266
291
  await empty(connection, connection.modify(dn, modlist))
267
292
  return ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
268
293
 
269
294
 
295
+ def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
296
+ return [s.encode() for s in filter(None, attributes[attr])]
297
+
298
+
270
299
  @api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
271
300
  async def put_entry(
272
301
  dn: str, attributes: Attributes, connection: AuthenticatedConnection
273
302
  ) -> ChangedAttributes:
274
303
  modlist = addModlist(
275
304
  {
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
305
+ attr: _nonempty_byte_strings(attributes, attr)
306
+ for attr in attributes
307
+ if attr not in PHOTOS
279
308
  }
280
309
  )
281
310
  if modlist:
@@ -307,15 +336,15 @@ async def get_blob(
307
336
  ) -> Response:
308
337
  "Retrieve a binary attribute"
309
338
 
310
- _dn, attrs = await get_entry_by_dn(connection, dn)
339
+ entry = await get_entry_by_dn(connection, dn)
311
340
 
312
- if attr not in attrs or len(attrs[attr]) <= index:
341
+ if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
313
342
  raise HTTPException(
314
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
343
+ HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
315
344
  )
316
345
 
317
346
  return Response(
318
- attrs[attr][index],
347
+ entry.attrs[attr][index],
319
348
  media_type="application/octet-stream",
320
349
  headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
321
350
  )
@@ -335,12 +364,14 @@ async def put_blob(
335
364
  connection: AuthenticatedConnection,
336
365
  ) -> None:
337
366
  "Upload a binary attribute"
338
- _dn, attrs = await get_entry_by_dn(connection, dn)
367
+ entry = await get_entry_by_dn(connection, dn)
339
368
  data = await blob.read(cast(int, blob.size))
340
- if attr in attrs:
369
+ if attr in entry.attrs:
341
370
  await empty(
342
371
  connection,
343
- connection.modify(dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]),
372
+ connection.modify(
373
+ dn, [(1, attr, None), (0, attr, entry.attrs[attr] + [data])]
374
+ ),
344
375
  )
345
376
  else:
346
377
  await empty(connection, connection.modify(dn, [(0, attr, [data])]))
@@ -356,13 +387,13 @@ async def delete_blob(
356
387
  attr: str, index: int, dn: str, connection: AuthenticatedConnection
357
388
  ) -> None:
358
389
  "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:
390
+ entry = await get_entry_by_dn(connection, dn)
391
+ if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
361
392
  raise HTTPException(
362
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
393
+ HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
363
394
  )
364
395
  await empty(connection, connection.modify(dn, [(1, attr, None)]))
365
- data = attrs[attr][:index] + attrs[attr][index + 1 :]
396
+ data = entry.attrs[attr][:index] + entry.attrs[attr][index + 1 :]
366
397
  if data:
367
398
  await empty(connection, connection.modify(dn, [(0, attr, data)]))
368
399
 
@@ -386,29 +417,21 @@ async def check_password(
386
417
  "/change-password/{dn:path}",
387
418
  tags=[Tag.EDITING],
388
419
  operation_id="post_change_password",
420
+ status_code=HTTPStatus.NO_CONTENT,
389
421
  )
390
422
  async def change_password(
391
423
  dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
392
- ) -> str | None:
424
+ ) -> None:
393
425
  "Update passwords"
394
426
  if args.new1:
395
427
  await empty(
396
428
  connection,
397
429
  connection.passwd(dn, args.old or None, args.new1),
398
430
  )
399
- _dn, attrs = await get_entry_by_dn(connection, dn)
400
- attrs["userPassword"][0].decode()
401
-
402
431
  else:
403
432
  await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
404
433
 
405
434
 
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
435
  @api.get(
413
436
  "/ldif/{dn:path}",
414
437
  include_in_schema=False, # Used as a link target, no API call
@@ -419,8 +442,8 @@ async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
419
442
  out = io.StringIO()
420
443
  writer = ldif.LDIFWriter(out)
421
444
 
422
- async for dn, attrs in result(connection, connection.search(dn, SCOPE_SUBTREE)):
423
- writer.unparse(dn, attrs)
445
+ async for entry in results(connection, connection.search(dn, SCOPE_SUBTREE)):
446
+ writer.unparse(dn, entry.attrs)
424
447
 
425
448
  file_name = dn.split(",")[0].split("=")[1]
426
449
  return PlainTextResponse(
@@ -440,18 +463,22 @@ class LDIFReader(ldif.LDIFParser):
440
463
  self.count += 1
441
464
 
442
465
 
443
- @api.post("/ldif", tags=[Tag.EDITING], operation_id="post_ldif")
466
+ @api.post(
467
+ "/ldif",
468
+ tags=[Tag.EDITING],
469
+ operation_id="post_ldif",
470
+ status_code=HTTPStatus.NO_CONTENT,
471
+ )
444
472
  async def upload_ldif(
445
473
  ldif: Annotated[str, Body()], connection: AuthenticatedConnection
446
- ) -> Response:
474
+ ) -> None:
447
475
  "Import LDIF"
448
476
 
449
477
  reader = LDIFReader(ldif.encode(), connection)
450
478
  try:
451
479
  reader.parse()
452
- return NO_CONTENT
453
480
  except ValueError as e:
454
- return Response(e.args[0], HTTPStatus.UNPROCESSABLE_ENTITY)
481
+ raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, e.args[0])
455
482
 
456
483
 
457
484
  @api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
@@ -469,10 +496,15 @@ async def search(query: str, connection: AuthenticatedConnection) -> list[Search
469
496
 
470
497
  # Collect results
471
498
  res = []
472
- async for dn, attrs in result(
499
+ async for entry in results(
473
500
  connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
474
501
  ):
475
- res.append(SearchResult(dn=dn, name=_cn(attrs) or dn))
502
+ res.append(
503
+ SearchResult(
504
+ dn=entry.dn,
505
+ name=entry.attr("cn")[0] if "cn" in entry.attrs else entry.dn,
506
+ )
507
+ )
476
508
  if len(res) >= settings.SEARCH_MAX:
477
509
  break
478
510
  return res
@@ -490,15 +522,16 @@ async def list_subtree(
490
522
  ) -> list[TreeItem]:
491
523
  "List the subtree below a DN"
492
524
 
493
- start = len(root_dn.split(","))
494
525
  return sorted(
495
526
  [
496
- _tree_item(dn, attrs, start, await get_schema(connection))
497
- async for dn, attrs in result(
527
+ _tree_item(entry, root_dn)
528
+ async for entry in results(
498
529
  connection,
499
- connection.search(root_dn, SCOPE_SUBTREE),
530
+ connection.search(
531
+ root_dn, SCOPE_SUBTREE, attrlist=WITH_OPERATIONAL_ATTRS
532
+ ),
500
533
  )
501
- if root_dn != dn
534
+ if root_dn != entry.dn
502
535
  ],
503
536
  key=lambda item: tuple(reversed(item.dn.lower().split(","))),
504
537
  )
@@ -513,8 +546,8 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
513
546
 
514
547
  values = set(
515
548
  [
516
- int(attrs[attribute][0])
517
- async for dn, attrs in result(
549
+ int(entry.attrs[attribute][0])
550
+ async for entry in results(
518
551
  connection,
519
552
  connection.search(
520
553
  settings.BASE_DN,
@@ -529,7 +562,7 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
529
562
 
530
563
  if not values:
531
564
  raise HTTPException(
532
- HTTPStatus.NOT_FOUND.value, f"No values found for attribute {attribute}"
565
+ HTTPStatus.NOT_FOUND, f"No values found for attribute {attribute}"
533
566
  )
534
567
 
535
568
  minimum, maximum = min(values), max(values)
@@ -549,4 +582,5 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
549
582
  )
550
583
  async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
551
584
  "Dump the LDAP schema as JSON"
585
+ assert settings.SCHEMA_DN, "An LDAP schema DN is required!"
552
586
  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
ldap_ui/schema.py CHANGED
@@ -15,8 +15,6 @@ from ldap.schema.models import LDAPSyntax as LDAPSyntaxType
15
15
  from ldap.schema.models import ObjectClass as ObjectClassType
16
16
  from pydantic import BaseModel, Field, field_serializer
17
17
 
18
- __all__ = ("frontend_schema", "Attribute", "ObjectClass")
19
-
20
18
  T = TypeVar("T")
21
19
 
22
20