ldap-ui 0.9.9__py3-none-any.whl → 0.9.11__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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.9.9"
1
+ __version__ = "0.9.11"
ldap_ui/app.py CHANGED
@@ -13,9 +13,16 @@ import binascii
13
13
  import logging
14
14
  import sys
15
15
  from http import HTTPStatus
16
- from typing import AsyncGenerator, Optional
17
-
18
- import ldap
16
+ from typing import Optional
17
+
18
+ from ldap import (
19
+ INSUFFICIENT_ACCESS, # pyright: ignore[reportAttributeAccessIssue]
20
+ INVALID_CREDENTIALS, # pyright: ignore[reportAttributeAccessIssue]
21
+ SCOPE_BASE, # pyright: ignore[reportAttributeAccessIssue]
22
+ SCOPE_SUBTREE, # pyright: ignore[reportAttributeAccessIssue]
23
+ UNWILLING_TO_PERFORM, # pyright: ignore[reportAttributeAccessIssue]
24
+ LDAPError, # pyright: ignore[reportAttributeAccessIssue]
25
+ )
19
26
  from ldap.ldapobject import LDAPObject
20
27
  from pydantic import ValidationError
21
28
  from starlette.applications import Starlette
@@ -28,7 +35,7 @@ from starlette.authentication import (
28
35
  from starlette.exceptions import HTTPException
29
36
  from starlette.middleware import Middleware
30
37
  from starlette.middleware.authentication import AuthenticationMiddleware
31
- from starlette.middleware.base import BaseHTTPMiddleware
38
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
32
39
  from starlette.middleware.gzip import GZipMiddleware
33
40
  from starlette.requests import HTTPConnection, Request
34
41
  from starlette.responses import Response
@@ -37,15 +44,42 @@ from starlette.staticfiles import StaticFiles
37
44
 
38
45
  from . import settings
39
46
  from .ldap_api import api
40
- from .ldap_helpers import empty, ldap_connect, unique
47
+ from .ldap_helpers import WITH_OPERATIONAL_ATTRS, empty, ldap_connect, unique
41
48
 
42
49
  LOG = logging.getLogger("ldap-ui")
43
50
 
51
+
52
+ def ldap_exception_message(exc: LDAPError) -> str:
53
+ args = exc.args[0]
54
+ if "info" in args:
55
+ return args.get("info", "") + ": " + args.get("desc", "")
56
+ return args.get("desc", "")
57
+
58
+
59
+ if not settings.BASE_DN or not settings.SCHEMA_DN:
60
+ # Try auto-detection from root DSE
61
+ try:
62
+ with ldap_connect() as connection:
63
+ _dn, attrs = connection.search_s( # pyright: ignore[reportAssignmentType, reportOptionalSubscript]
64
+ "", SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
65
+ )[0]
66
+ base_dns = attrs.get("namingContexts", [])
67
+ if len(base_dns) == 1:
68
+ settings.BASE_DN = settings.BASE_DN or base_dns[0].decode()
69
+ else:
70
+ LOG.warning("No unique base DN: %s", base_dns)
71
+ schema_dns = attrs.get("subschemaSubentry", [])
72
+ settings.SCHEMA_DN = settings.SCHEMA_DN or schema_dns[0].decode()
73
+ except LDAPError as err:
74
+ LOG.error(ldap_exception_message(err), exc_info=err)
75
+
44
76
  if not settings.BASE_DN:
45
77
  LOG.critical("An LDAP base DN is required!")
46
78
  sys.exit(1)
47
79
 
48
- LOG.debug("Base DN: %s", settings.BASE_DN)
80
+ if not settings.SCHEMA_DN:
81
+ LOG.critical("An LDAP schema DN is required!")
82
+ sys.exit(1)
49
83
 
50
84
 
51
85
  async def anonymous_user_search(connection: LDAPObject, username: str) -> Optional[str]:
@@ -55,7 +89,7 @@ async def anonymous_user_search(connection: LDAPObject, username: str) -> Option
55
89
  connection,
56
90
  connection.search(
57
91
  settings.BASE_DN,
58
- ldap.SCOPE_SUBTREE,
92
+ SCOPE_SUBTREE,
59
93
  settings.GET_BIND_DN_FILTER(username),
60
94
  ),
61
95
  )
@@ -67,7 +101,7 @@ async def anonymous_user_search(connection: LDAPObject, username: str) -> Option
67
101
 
68
102
  class LdapConnectionMiddleware(BaseHTTPMiddleware):
69
103
  async def dispatch(
70
- self, request: Request, call_next: AsyncGenerator[Request, Response]
104
+ self, request: Request, call_next: RequestResponseEndpoint
71
105
  ) -> Response:
72
106
  "Add an authenticated LDAP connection to the request"
73
107
 
@@ -93,23 +127,23 @@ class LdapConnectionMiddleware(BaseHTTPMiddleware):
93
127
  request.state.ldap = connection
94
128
  return await call_next(request)
95
129
 
96
- except ldap.INVALID_CREDENTIALS:
130
+ except INVALID_CREDENTIALS:
97
131
  pass
98
132
 
99
- except ldap.INSUFFICIENT_ACCESS as err:
133
+ except INSUFFICIENT_ACCESS as err:
100
134
  return Response(
101
135
  ldap_exception_message(err),
102
136
  status_code=HTTPStatus.FORBIDDEN.value,
103
137
  )
104
138
 
105
- except ldap.UNWILLING_TO_PERFORM:
139
+ except UNWILLING_TO_PERFORM:
106
140
  LOG.warning("Need BIND_DN or BIND_PATTERN to authenticate")
107
141
  return Response(
108
142
  HTTPStatus.FORBIDDEN.phrase,
109
143
  status_code=HTTPStatus.FORBIDDEN.value,
110
144
  )
111
145
 
112
- except ldap.LDAPError as err:
146
+ except LDAPError as err:
113
147
  LOG.error(ldap_exception_message(err), exc_info=err)
114
148
  return Response(
115
149
  ldap_exception_message(err),
@@ -126,13 +160,6 @@ class LdapConnectionMiddleware(BaseHTTPMiddleware):
126
160
  )
127
161
 
128
162
 
129
- def ldap_exception_message(exc: ldap.LDAPError) -> str:
130
- args = exc.args[0]
131
- if "info" in args:
132
- return args.get("info", "") + ": " + args.get("desc", "")
133
- return args.get("desc", "")
134
-
135
-
136
163
  class LdapUser(SimpleUser):
137
164
  "LDAP credentials"
138
165
 
@@ -166,7 +193,7 @@ class CacheBustingMiddleware(BaseHTTPMiddleware):
166
193
  "Forbid caching of API responses"
167
194
 
168
195
  async def dispatch(
169
- self, request: Request, call_next: AsyncGenerator[Request, Response]
196
+ self, request: Request, call_next: RequestResponseEndpoint
170
197
  ) -> Response:
171
198
  response = await call_next(request)
172
199
  if request.url.path.startswith("/api"):
@@ -195,7 +222,7 @@ async def http_422(_request: Request, e: ValidationError) -> Response:
195
222
  # Main ASGI entry
196
223
  app = Starlette(
197
224
  debug=settings.DEBUG,
198
- exception_handlers={
225
+ exception_handlers={ # pyright: ignore[reportArgumentType]
199
226
  HTTPException: http_exception,
200
227
  ValidationError: http_422,
201
228
  },
ldap_ui/ldap_api.py CHANGED
@@ -10,10 +10,15 @@ Asynchronous LDAP operations are used as much as possible.
10
10
  import base64
11
11
  import io
12
12
  from http import HTTPStatus
13
- from typing import Any, Optional, Tuple, Union
13
+ from typing import Any, Optional, Tuple, Union, cast
14
14
 
15
- import ldap
16
15
  import ldif
16
+ from ldap import (
17
+ INVALID_CREDENTIALS, # pyright: ignore[reportAttributeAccessIssue]
18
+ SCOPE_BASE, # pyright: ignore[reportAttributeAccessIssue]
19
+ SCOPE_ONELEVEL, # pyright: ignore[reportAttributeAccessIssue]
20
+ SCOPE_SUBTREE, # pyright: ignore[reportAttributeAccessIssue]
21
+ )
17
22
  from ldap.ldapobject import LDAPObject
18
23
  from ldap.modlist import addModlist, modifyModlist
19
24
  from ldap.schema import SubSchema
@@ -60,34 +65,59 @@ async def whoami(request: Request) -> JSONResponse:
60
65
  return JSONResponse(request.state.ldap.whoami_s().replace("dn:", ""))
61
66
 
62
67
 
68
+ class TreeItem(BaseModel):
69
+ dn: str
70
+ structuralObjectClass: str
71
+ hasSubordinates: bool
72
+ level: int
73
+
74
+
63
75
  @api.route("/tree/{basedn}")
64
76
  async def tree(request: Request) -> JSONResponse:
65
77
  "List directory entries"
66
78
 
67
79
  basedn = request.path_params["basedn"]
68
- scope = ldap.SCOPE_ONELEVEL
80
+ base_level = len(basedn.split(","))
81
+ scope = SCOPE_ONELEVEL
69
82
  if basedn == "base":
70
- scope = ldap.SCOPE_BASE
83
+ scope = SCOPE_BASE
71
84
  basedn = settings.BASE_DN
72
85
 
73
- return JSONResponse(await _tree(request, basedn, scope))
74
-
86
+ connection = request.state.ldap
87
+ entries = result(
88
+ connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
89
+ )
90
+ return JSONResponse(
91
+ [
92
+ _tree_item(dn, attrs, base_level, request.app.state.schema).model_dump()
93
+ async for dn, attrs in entries
94
+ ]
95
+ )
75
96
 
76
- async def _tree(request: Request, basedn: str, scope: int) -> list[dict[str, Any]]:
77
- "Get all nodes below a DN (including the DN) within the given scope"
78
97
 
79
- connection = request.state.ldap
80
- return [
81
- {
82
- "dn": dn,
83
- "structuralObjectClass": attrs["structuralObjectClass"][0].decode(),
84
- "hasSubordinates": b"TRUE" == attrs["hasSubordinates"][0],
85
- }
86
- async for dn, attrs in result(
87
- connection,
88
- connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS),
98
+ def _tree_item(
99
+ dn: str, attrs: dict[str, Any], level: int, schema: SubSchema
100
+ ) -> TreeItem:
101
+ structuralObjectClass = next(
102
+ iter(
103
+ filter(
104
+ lambda oc: oc.kind == OC.Kind.structural.value, # pyright: ignore[reportOptionalMemberAccess]
105
+ map(
106
+ lambda o: schema.get_obj(ObjectClass, o.decode()),
107
+ attrs["objectClass"],
108
+ ),
109
+ )
89
110
  )
90
- ]
111
+ )
112
+
113
+ return TreeItem(
114
+ dn=dn,
115
+ structuralObjectClass=structuralObjectClass.names[0],
116
+ hasSubordinates=attrs["hasSubordinates"][0] == b"TRUE"
117
+ if "hasSubordinates" in attrs
118
+ else bool(attrs.get("numSubordinates")),
119
+ level=len(dn.split(",")) - level,
120
+ )
91
121
 
92
122
 
93
123
  class Meta(BaseModel):
@@ -110,12 +140,12 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
110
140
  ocs = set([oc.decode() for oc in attrs["objectClass"]])
111
141
  must_attrs, _may_attrs = schema.attribute_types(ocs)
112
142
  soc = [
113
- oc.names[0]
143
+ oc.names[0] # pyright: ignore[reportOptionalMemberAccess]
114
144
  for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
115
- if oc.kind == OC.Kind.structural.value
145
+ if oc.kind == OC.Kind.structural.value # pyright: ignore[reportOptionalMemberAccess]
116
146
  ]
117
147
  aux = set(
118
- schema.get_obj(ObjectClass, a).names[0]
148
+ schema.get_obj(ObjectClass, a).names[0] # pyright: ignore[reportOptionalMemberAccess]
119
149
  for a in schema.get_applicable_aux_classes(soc[0])
120
150
  )
121
151
 
@@ -130,7 +160,7 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
130
160
 
131
161
  # Octet strings are not used consistently.
132
162
  # Try to decode as text and treat as binary on failure
133
- if not obj.syntax or obj.syntax == OCTET_STRING:
163
+ if not obj.syntax or obj.syntax == OCTET_STRING: # pyright: ignore[reportOptionalMemberAccess]
134
164
  try:
135
165
  for val in attrs[attr]:
136
166
  assert val.decode().isprintable()
@@ -138,18 +168,20 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
138
168
  binary.add(attr)
139
169
 
140
170
  else: # Check human-readable flag in schema
141
- syntax = schema.get_obj(LDAPSyntax, obj.syntax)
142
- if syntax.not_human_readable:
171
+ syntax = schema.get_obj(LDAPSyntax, obj.syntax) # pyright: ignore[reportOptionalMemberAccess]
172
+ if syntax.not_human_readable: # pyright: ignore[reportOptionalMemberAccess]
143
173
  binary.add(attr)
144
174
 
145
175
  return Entry(
146
176
  attrs={
147
- k: [base64.b64encode(val) if k in binary else val for val in values]
177
+ k: [
178
+ base64.b64encode(val).decode() if k in binary else val for val in values
179
+ ]
148
180
  for k, values in attrs.items()
149
181
  },
150
182
  meta=Meta(
151
183
  dn=dn,
152
- required=[schema.get_obj(AttributeType, a).names[0] for a in must_attrs],
184
+ required=[schema.get_obj(AttributeType, a).names[0] for a in must_attrs], # pyright: ignore[reportOptionalMemberAccess]
153
185
  aux=sorted(aux - ocs),
154
186
  binary=sorted(binary),
155
187
  autoFilled=[],
@@ -160,7 +192,7 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
160
192
  Attributes = TypeAdapter(dict[str, list[bytes]])
161
193
 
162
194
 
163
- @api.route("/entry/{dn}", methods=("GET", "POST", "DELETE", "PUT"))
195
+ @api.route("/entry/{dn}", methods=["GET", "POST", "DELETE", "PUT"])
164
196
  async def entry(request: Request) -> Response:
165
197
  "Edit directory entries"
166
198
 
@@ -175,10 +207,18 @@ async def entry(request: Request) -> Response:
175
207
  )
176
208
 
177
209
  if request.method == "DELETE":
178
- for entry in reversed(
179
- sorted(await _tree(request, dn, ldap.SCOPE_SUBTREE), key=_dn_order)
210
+ for entry_dn in sorted(
211
+ [
212
+ dn
213
+ async for dn, _attrs in result(
214
+ connection,
215
+ connection.search(dn, SCOPE_SUBTREE),
216
+ )
217
+ ],
218
+ key=len,
219
+ reverse=True,
180
220
  ):
181
- await empty(connection, connection.delete(entry["dn"]))
221
+ await empty(connection, connection.delete(entry_dn))
182
222
  return NO_CONTENT
183
223
 
184
224
  # Copy JSON payload into a dictionary of non-empty byte strings
@@ -206,8 +246,10 @@ async def entry(request: Request) -> Response:
206
246
  await empty(connection, connection.add(dn, modlist))
207
247
  return JSONResponse({"changed": ["dn"]}) # Dummy
208
248
 
249
+ raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED)
209
250
 
210
- @api.route("/blob/{attr}/{index:int}/{dn}", methods=("GET", "DELETE", "PUT"))
251
+
252
+ @api.route("/blob/{attr}/{index:int}/{dn}", methods=["GET", "DELETE", "PUT"])
211
253
  async def blob(request: Request) -> Response:
212
254
  "Handle binary attributes"
213
255
 
@@ -236,16 +278,16 @@ async def blob(request: Request) -> Response:
236
278
  async with request.form() as form_data:
237
279
  blob = form_data["blob"]
238
280
  if type(blob) is UploadFile:
239
- data = await blob.read(blob.size)
281
+ data = await blob.read(cast(int, blob.size))
240
282
  if attr in attrs:
241
283
  await empty(
242
284
  connection,
243
285
  connection.modify(
244
- dn, [(1, attr, None), (0, attr, data + attrs[attr])]
286
+ dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]
245
287
  ),
246
288
  )
247
289
  else:
248
- await empty(connection, connection.modify(dn, [(0, attr, data)]))
290
+ await empty(connection, connection.modify(dn, [(0, attr, [data])]))
249
291
  return NO_CONTENT
250
292
 
251
293
  if request.method == "DELETE":
@@ -259,6 +301,8 @@ async def blob(request: Request) -> Response:
259
301
  await empty(connection, connection.modify(dn, [(0, attr, data)]))
260
302
  return NO_CONTENT
261
303
 
304
+ raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED)
305
+
262
306
 
263
307
  @api.route("/ldif/{dn}")
264
308
  async def ldifDump(request: Request) -> PlainTextResponse:
@@ -269,9 +313,7 @@ async def ldifDump(request: Request) -> PlainTextResponse:
269
313
  writer = ldif.LDIFWriter(out)
270
314
  connection = request.state.ldap
271
315
 
272
- async for dn, attrs in result(
273
- connection, connection.search(dn, ldap.SCOPE_SUBTREE)
274
- ):
316
+ async for dn, attrs in result(connection, connection.search(dn, SCOPE_SUBTREE)):
275
317
  writer.unparse(dn, attrs)
276
318
 
277
319
  file_name = dn.split(",")[0].split("=")[1]
@@ -282,7 +324,7 @@ async def ldifDump(request: Request) -> PlainTextResponse:
282
324
 
283
325
 
284
326
  class LDIFReader(ldif.LDIFParser):
285
- def __init__(self, input: str, con: LDAPObject):
327
+ def __init__(self, input: bytes, con: LDAPObject):
286
328
  ldif.LDIFParser.__init__(self, io.BytesIO(input))
287
329
  self.count = 0
288
330
  self.con = con
@@ -292,7 +334,7 @@ class LDIFReader(ldif.LDIFParser):
292
334
  self.count += 1
293
335
 
294
336
 
295
- @api.route("/ldif", methods=("POST",))
337
+ @api.route("/ldif", methods=["POST"])
296
338
  async def ldifUpload(
297
339
  request: Request,
298
340
  ) -> Response:
@@ -309,8 +351,8 @@ async def ldifUpload(
309
351
  Rdn = TypeAdapter(str)
310
352
 
311
353
 
312
- @api.route("/rename/{dn}", methods=("POST",))
313
- async def rename(request: Request) -> JSONResponse:
354
+ @api.route("/rename/{dn}", methods=["POST"])
355
+ async def rename(request: Request) -> Response:
314
356
  "Rename an entry"
315
357
 
316
358
  dn = request.path_params["dn"]
@@ -332,8 +374,8 @@ class CheckPasswordRequest(BaseModel):
332
374
  PasswordRequest = TypeAdapter(Union[ChangePasswordRequest, CheckPasswordRequest])
333
375
 
334
376
 
335
- @api.route("/entry/password/{dn}", methods=("POST",))
336
- async def passwd(request: Request) -> JSONResponse:
377
+ @api.route("/entry/password/{dn}", methods=["POST"])
378
+ async def passwd(request: Request) -> Response:
337
379
  "Update passwords"
338
380
 
339
381
  dn = request.path_params["dn"]
@@ -344,10 +386,10 @@ async def passwd(request: Request) -> JSONResponse:
344
386
  try:
345
387
  con.simple_bind_s(dn, args.check)
346
388
  return JSONResponse(True)
347
- except ldap.INVALID_CREDENTIALS:
389
+ except INVALID_CREDENTIALS:
348
390
  return JSONResponse(False)
349
391
 
350
- else:
392
+ elif type(args) is ChangePasswordRequest:
351
393
  connection = request.state.ldap
352
394
  if args.new1:
353
395
  await empty(
@@ -359,7 +401,9 @@ async def passwd(request: Request) -> JSONResponse:
359
401
 
360
402
  else:
361
403
  await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
362
- return JSONResponse(None)
404
+ return NO_CONTENT
405
+
406
+ raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY)
363
407
 
364
408
 
365
409
  def _cn(entry: dict) -> Optional[str]:
@@ -386,7 +430,7 @@ async def search(request: Request) -> JSONResponse:
386
430
  res = []
387
431
  connection = request.state.ldap
388
432
  async for dn, attrs in result(
389
- connection, connection.search(settings.BASE_DN, ldap.SCOPE_SUBTREE, query)
433
+ connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
390
434
  ):
391
435
  res.append({"dn": dn, "name": _cn(attrs) or dn})
392
436
  if len(res) >= settings.SEARCH_MAX:
@@ -394,23 +438,26 @@ async def search(request: Request) -> JSONResponse:
394
438
  return JSONResponse(res)
395
439
 
396
440
 
397
- def _dn_order(node):
398
- "Reverse DN parts for tree ordering"
399
- return tuple(reversed(node["dn"].lower().split(",")))
400
-
401
-
402
441
  @api.route("/subtree/{dn}")
403
442
  async def subtree(request: Request) -> JSONResponse:
404
443
  "List the subtree below a DN"
405
444
 
406
- dn = request.path_params["dn"]
407
- result, start = [], len(dn.split(","))
408
- for node in sorted(await _tree(request, dn, ldap.SCOPE_SUBTREE), key=_dn_order):
409
- if node["dn"] == dn:
410
- continue
411
- node["level"] = len(node["dn"].split(",")) - start
412
- result.append(node)
413
- return JSONResponse(result)
445
+ root_dn = request.path_params["dn"]
446
+ start = len(root_dn.split(","))
447
+ connection = request.state.ldap
448
+ return JSONResponse(
449
+ sorted(
450
+ [
451
+ _tree_item(dn, attrs, start, request.app.state.schema).model_dump()
452
+ async for dn, attrs in result(
453
+ connection,
454
+ connection.search(root_dn, SCOPE_SUBTREE),
455
+ )
456
+ if root_dn != dn
457
+ ],
458
+ key=lambda item: tuple(reversed(item["dn"].lower().split(","))),
459
+ )
460
+ )
414
461
 
415
462
 
416
463
  @api.route("/range/{attribute}")
@@ -428,7 +475,7 @@ async def attribute_range(request: Request) -> JSONResponse:
428
475
  connection,
429
476
  connection.search(
430
477
  settings.BASE_DN,
431
- ldap.SCOPE_SUBTREE,
478
+ SCOPE_SUBTREE,
432
479
  f"({attribute}=*)",
433
480
  attrlist=(attribute,),
434
481
  ),
@@ -462,7 +509,7 @@ async def json_schema(request: Request) -> JSONResponse:
462
509
  connection,
463
510
  connection.search(
464
511
  settings.SCHEMA_DN,
465
- ldap.SCOPE_BASE,
512
+ SCOPE_BASE,
466
513
  attrlist=WITH_OPERATIONAL_ATTRS,
467
514
  ),
468
515
  )
ldap_ui/ldap_helpers.py CHANGED
@@ -14,8 +14,16 @@ import contextlib
14
14
  from http import HTTPStatus
15
15
  from typing import AsyncGenerator, Generator, Tuple
16
16
 
17
- import ldap
18
17
  from anyio import sleep
18
+ from ldap import (
19
+ NO_SUCH_OBJECT, # pyright: ignore[reportAttributeAccessIssue]
20
+ OPT_X_TLS_DEMAND, # pyright: ignore[reportAttributeAccessIssue]
21
+ OPT_X_TLS_NEVER, # pyright: ignore[reportAttributeAccessIssue]
22
+ OPT_X_TLS_NEWCTX, # pyright: ignore[reportAttributeAccessIssue]
23
+ OPT_X_TLS_REQUIRE_CERT, # pyright: ignore[reportAttributeAccessIssue]
24
+ SCOPE_BASE, # pyright: ignore[reportAttributeAccessIssue]
25
+ initialize,
26
+ )
19
27
  from ldap.ldapobject import LDAPObject
20
28
  from starlette.exceptions import HTTPException
21
29
 
@@ -40,17 +48,15 @@ def ldap_connect() -> Generator[LDAPObject, None, None]:
40
48
  "Open an LDAP connection"
41
49
 
42
50
  url = settings.LDAP_URL
43
- connection = ldap.initialize(url)
51
+ connection = initialize(url)
44
52
 
45
53
  # #43 TLS, see https://stackoverflow.com/a/8795694
46
54
  if settings.USE_TLS or settings.INSECURE_TLS:
47
- cert_level = (
48
- ldap.OPT_X_TLS_NEVER if settings.INSECURE_TLS else ldap.OPT_X_TLS_DEMAND
49
- )
55
+ cert_level = OPT_X_TLS_NEVER if settings.INSECURE_TLS else OPT_X_TLS_DEMAND
50
56
 
51
- connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, cert_level)
57
+ connection.set_option(OPT_X_TLS_REQUIRE_CERT, cert_level)
52
58
  # See https://stackoverflow.com/a/38136255
53
- connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
59
+ connection.set_option(OPT_X_TLS_NEWCTX, 0)
54
60
  if not url.startswith("ldaps://"):
55
61
  connection.start_tls_s()
56
62
  yield connection
@@ -59,7 +65,7 @@ def ldap_connect() -> Generator[LDAPObject, None, None]:
59
65
 
60
66
  async def result(
61
67
  connection: LDAPObject, msgid: int
62
- ) -> AsyncGenerator[Tuple[str, dict[str, list[bytes]]], None]:
68
+ ) -> AsyncGenerator[tuple[str, dict[str, list[bytes]]], None]:
63
69
  "Stream LDAP result entries without blocking other tasks"
64
70
 
65
71
  while True:
@@ -69,7 +75,7 @@ async def result(
69
75
  elif r_data == []: # Operation completed
70
76
  break
71
77
  else:
72
- yield r_data[0]
78
+ yield r_data[0] # pyright: ignore[reportOptionalSubscript, reportReturnType]
73
79
 
74
80
 
75
81
  async def unique(
@@ -111,6 +117,6 @@ async def get_entry_by_dn(
111
117
  "Asynchronously retrieve an LDAP entry by its DN"
112
118
 
113
119
  try:
114
- return await unique(connection, connection.search(dn, ldap.SCOPE_BASE))
115
- except ldap.NO_SUCH_OBJECT:
120
+ return await unique(connection, connection.search(dn, SCOPE_BASE))
121
+ except NO_SUCH_OBJECT:
116
122
  raise HTTPException(HTTPStatus.NOT_FOUND.value, f"DN not found: {dn}")
ldap_ui/schema.py CHANGED
@@ -7,17 +7,17 @@ to the user.
7
7
  """
8
8
 
9
9
  from enum import IntEnum
10
- from typing import Generator, Optional, Type, TypeVar, Union
10
+ from typing import Generator, Optional, TypeVar, Union, cast
11
11
 
12
12
  from ldap.schema import SubSchema
13
- from ldap.schema.models import AttributeType, SchemaElement
13
+ from ldap.schema.models import AttributeType
14
14
  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
18
  __all__ = ("frontend_schema", "Attribute", "ObjectClass")
19
19
 
20
- T = TypeVar("T", bound=SchemaElement)
20
+ T = TypeVar("T")
21
21
 
22
22
 
23
23
  class Element(BaseModel):
@@ -90,14 +90,14 @@ def lowercase_dict(attr: str, items: list[T]) -> dict[str, T]:
90
90
 
91
91
 
92
92
  def extract_type(
93
- sub_schema: SubSchema, schema_class: Type[T]
93
+ sub_schema: SubSchema, schema_class: type[T]
94
94
  ) -> Generator[T, None, None]:
95
95
  "Get non-obsolete objects from the schema for a type"
96
96
 
97
97
  for oid in sub_schema.listall(schema_class):
98
98
  obj = sub_schema.get_obj(schema_class, oid)
99
- if schema_class is LDAPSyntaxType or not obj.obsolete:
100
- yield obj
99
+ if schema_class is LDAPSyntaxType or not obj.obsolete: # pyright: ignore[reportOptionalMemberAccess]
100
+ yield cast(T, obj)
101
101
 
102
102
 
103
103
  class Schema(BaseModel):
ldap_ui/settings.py CHANGED
@@ -14,7 +14,20 @@ SECRET_KEY = os.urandom(16)
14
14
  # LDAP settings
15
15
  #
16
16
  LDAP_URL = config("LDAP_URL", default="ldap:///")
17
- BASE_DN = config("BASE_DN", default=None) # Required
17
+
18
+ # Directory base DN.
19
+ # If unset, auto-detection from the root DSE is attempted.
20
+ # This works under the following conditions:
21
+ # - The root DSE is readable with anonymous binding
22
+ # - `namingContexts` contains exactly one entry
23
+ # Otherwise, manual configuration is required.
24
+ BASE_DN = config("BASE_DN", default=None)
25
+
26
+ # DN to obtain the directory schema.
27
+ # If unset, auto-detection from the root DSE is attempted.
28
+ # This works if root DSE is readable with anonymous binding.
29
+ # Otherwise, manual configuration is required.
30
+ SCHEMA_DN = config("SCHEMA_DN", default=None)
18
31
 
19
32
  USE_TLS = config(
20
33
  "USE_TLS",
@@ -29,11 +42,6 @@ INSECURE_TLS = config(
29
42
  default=False,
30
43
  )
31
44
 
32
- # OpenLdap default DN to obtain the schema.
33
- # Change as needed for other directories.
34
- SCHEMA_DN = config("SCHEMA_DN", default="cn=subschema")
35
-
36
-
37
45
  #
38
46
  # Binding
39
47
  #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ldap-ui
3
- Version: 0.9.9
3
+ Version: 0.9.11
4
4
  Summary: A fast and versatile LDAP editor
5
5
  Author: dnknth
6
6
  License: MIT License
@@ -44,14 +44,22 @@ The app always requires authentication, even if the directory permits anonymous
44
44
 
45
45
  ### Environment variables
46
46
 
47
- LDAP access is controlled by these environment variables, possibly from a `.env` file:
47
+ LDAP access is controlled by the following optional environment variables, possibly from a `.env` file:
48
48
 
49
- * `LDAP_URL` (optional): Connection URL, defaults to `ldap:///`.
50
- * `BASE_DN` (required): Search base, e.g. `dc=example,dc=org`.
51
- * `LOGIN_ATTR` (optional): User name attribute, defaults to `uid`.
49
+ * `LDAP_URL`: Connection URL, defaults to `ldap:///`.
50
+ * `BASE_DN`: Search base, e.g. `dc=example,dc=org`.
51
+ * `SCHEMA_DN`: # DN to obtain the directory schema, e.g. `cn=subSchema`.
52
+ * `LOGIN_ATTR`: User name attribute, defaults to `uid`.
52
53
 
53
- * `USE_TLS` (optional): Enable TLS, defaults to true for `ldaps` connections. Set it to a non-empty string to force `STARTTLS` on `ldap` connections.
54
- * `INSECURE_TLS` (optional): Do not require a valid server TLS certificate, defaults to false, implies `USE_TLS`.
54
+ * `USE_TLS`: Enable TLS, defaults to true for `ldaps` connections. Set it to a non-empty string to force `STARTTLS` on `ldap` connections.
55
+ * `INSECURE_TLS`: Do not require a valid server TLS certificate, defaults to false, implies `USE_TLS`.
56
+
57
+ if `BASE_DN` or `SCHEMA_DN` are not provided explicitly, auto-detection from the root DSE is attempted.
58
+ For this to work, the root DSE must be readable anonymously, e.g. with the following ACL line for OpenLDAP:
59
+
60
+ ```text
61
+ access to dn.base="" by * read
62
+ ```
55
63
 
56
64
  For finer-grained control, see [settings.py](settings.py).
57
65
 
@@ -61,8 +69,7 @@ For the impatient: Run it with
61
69
 
62
70
  ```shell
63
71
  docker run -p 127.0.0.1:5000:5000 \
64
- -e LDAP_URL=ldap://your.ldap.server/ \
65
- -e BASE_DN=dc=example,dc=org dnknth/ldap-ui
72
+ -e LDAP_URL=ldap://your.openldap.server/
66
73
  ```
67
74
 
68
75
  For the even more impatient: Start a demo with
@@ -143,7 +150,8 @@ Additionally, arbitrary attributes can be searched with an LDAP filter specifica
143
150
 
144
151
  ### Caveats
145
152
 
146
- * The software works with [OpenLdap](http://www.openldap.org) using simple bind. Other directories have not been tested, and SASL authentication schemes are presently not supported.
153
+ * The software works with [OpenLdap](http://www.openldap.org) using simple bind. Other directories have not been tested much, although [389 DS](https://www.port389.org) works to some extent.
154
+ * SASL authentication schemes are presently not supported.
147
155
  * Passwords are transmitted as plain text. The LDAP server is expected to hash them (OpenLdap 2.4 does). I strongly recommend to expose the app through a TLS-enabled web server.
148
156
  * HTTP *Basic Authentication* is triggered unless the `AUTHORIZATION` request variable is already set by some upstream HTTP server.
149
157
 
@@ -1,10 +1,10 @@
1
- ldap_ui/__init__.py,sha256=nhsA3KKA-CXSYpbzuChuLyxpDepY_-JffnUNClcYEaU,22
1
+ ldap_ui/__init__.py,sha256=hCWvJmnndbpxCyOQ7z-g5qleaxwixXNqkmkxuORqf1I,23
2
2
  ldap_ui/__main__.py,sha256=s2jFbC2y2LpvcTY8yXOFVisKXSFG079hc9IVgrJ49vY,1849
3
- ldap_ui/app.py,sha256=wmsRa2Ufzhb7BEVWoavh6rv5NTyOBegDE4n8NAG02SQ,6859
4
- ldap_ui/ldap_api.py,sha256=tGFC8wWnnfpwYiP560K5cGElzEm5mgDmrVg_YDPIT08,14206
5
- ldap_ui/ldap_helpers.py,sha256=KTgvwKH8ZNiO1Ccy8TVt8Rr9Q8D2ft334wQJMfBj6Ek,3236
6
- ldap_ui/schema.py,sha256=gdfqIpRRgMj4CtrqFrzQcmfduJOyceOWqLD58dGFezE,4495
7
- ldap_ui/settings.py,sha256=wvAvxhULIDAocpwVqHutSDsEtNxcg_uazuzIpgBY5-A,2476
3
+ ldap_ui/app.py,sha256=eLRed3iVyrE56CeYBmE0nW09LKh_3Ztc1_ZON37dv8Q,8161
4
+ ldap_ui/ldap_api.py,sha256=j8llIyXkd51g-MDHtN-9XyUvVS8Z_wvQb9Z7uTMyoNU,15897
5
+ ldap_ui/ldap_helpers.py,sha256=1Sq2hwndwzETb3cPpCoHBF8r-JmAaWh87-Pl2inZRy8,3675
6
+ ldap_ui/schema.py,sha256=LNIHTlkcJYPdtZ0RZ9a_-KejVGWCGuMwtDDD8tSaprY,4515
7
+ ldap_ui/settings.py,sha256=UjCB24epLLUF0ECLb5MulfHPNGjEG57ZS2HXVFJ_k3Y,2844
8
8
  ldap_ui/statics/favicon.ico,sha256=_PMMM_C1ER5cpJTXZcRgISR4igj44kA4u8Trl-Ko3L0,4286
9
9
  ldap_ui/statics/index.html,sha256=_QF-25WH6wEK2MfhAmccRRlzpbk8btozMhhct9ro-do,827
10
10
  ldap_ui/statics/assets/fontawesome-webfont-B-jkhYfk.woff2,sha256=Kt78vAQefRj88tQXh53FoJmXqmTWdbejxLbOM9oT8_4,77160
@@ -16,9 +16,9 @@ ldap_ui/statics/assets/index-BOlMrt1N.js,sha256=GpM_tl2FLHwau7eFtlh82sN3x_YhjemR
16
16
  ldap_ui/statics/assets/index-BOlMrt1N.js.gz,sha256=8LOcgG-YTp4c0kCIw9QzQzM59a_PlRy7eBOhTnHsmvY,43711
17
17
  ldap_ui/statics/assets/index-Cw9TEv0d.css,sha256=sa0JhzpsjJhP3Bi2nJpG6Shn3yKI9hl_7I9kVY5E3Zs,48119
18
18
  ldap_ui/statics/assets/index-Cw9TEv0d.css.gz,sha256=qE_XQEa7HH54vGvQR78l5eeTcXVWmiqU_d7Go80X_S0,11533
19
- ldap_ui-0.9.9.dist-info/LICENSE.txt,sha256=UpJ0sDIqHxbOtzy1EG4bCHs9R_99ODxxPDK4NZ0g3I0,1042
20
- ldap_ui-0.9.9.dist-info/METADATA,sha256=vtXeS4unz0_7g5HFyhuhhefAghVkeZEbhepBekUOD6U,7557
21
- ldap_ui-0.9.9.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
22
- ldap_ui-0.9.9.dist-info/entry_points.txt,sha256=TGxMkXYeZP5m5NjZxWmgzITYWhSdj2mR_GGUYmHhGws,50
23
- ldap_ui-0.9.9.dist-info/top_level.txt,sha256=t9Agyig1nDdJuQvx_UVuk1n28pgswc1BIYw8E6pWado,8
24
- ldap_ui-0.9.9.dist-info/RECORD,,
19
+ ldap_ui-0.9.11.dist-info/LICENSE.txt,sha256=UpJ0sDIqHxbOtzy1EG4bCHs9R_99ODxxPDK4NZ0g3I0,1042
20
+ ldap_ui-0.9.11.dist-info/METADATA,sha256=iCnt7S7SOTENEPXYzIG5zlU_LjQP503DIHhzV31EFvQ,7872
21
+ ldap_ui-0.9.11.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
22
+ ldap_ui-0.9.11.dist-info/entry_points.txt,sha256=TGxMkXYeZP5m5NjZxWmgzITYWhSdj2mR_GGUYmHhGws,50
23
+ ldap_ui-0.9.11.dist-info/top_level.txt,sha256=t9Agyig1nDdJuQvx_UVuk1n28pgswc1BIYw8E6pWado,8
24
+ ldap_ui-0.9.11.dist-info/RECORD,,