ldap-ui 0.9.14__py3-none-any.whl → 0.10.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.
ldap_ui/ldap_api.py CHANGED
@@ -9,43 +9,62 @@ Asynchronous LDAP operations are used as much as possible.
9
9
 
10
10
  import base64
11
11
  import io
12
+ import logging
13
+ from enum import StrEnum
12
14
  from http import HTTPStatus
13
- from typing import Any, Optional, Tuple, Union, cast
15
+ from typing import Annotated, cast
14
16
 
15
17
  import ldif
18
+ from fastapi import (
19
+ APIRouter,
20
+ Body,
21
+ Depends,
22
+ File,
23
+ HTTPException,
24
+ Response,
25
+ UploadFile,
26
+ )
27
+ from fastapi.responses import PlainTextResponse
28
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
16
29
  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]
30
+ INVALID_CREDENTIALS, # type: ignore
31
+ SCOPE_BASE, # type: ignore
32
+ SCOPE_ONELEVEL, # type: ignore
33
+ SCOPE_SUBTREE, # type: ignore
21
34
  )
22
35
  from ldap.ldapobject import LDAPObject
23
36
  from ldap.modlist import addModlist, modifyModlist
24
37
  from ldap.schema import SubSchema
25
38
  from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
26
- from pydantic import BaseModel, Field, TypeAdapter
27
- from starlette.datastructures import UploadFile
28
- from starlette.exceptions import HTTPException
29
- from starlette.requests import Request
30
- from starlette.responses import JSONResponse, PlainTextResponse, Response
31
- from starlette.routing import Router
32
39
 
33
40
  from . import settings
41
+ from .entities import (
42
+ Attributes,
43
+ ChangedAttributes,
44
+ ChangePasswordRequest,
45
+ Entry,
46
+ Meta,
47
+ Range,
48
+ SearchResult,
49
+ TreeItem,
50
+ )
34
51
  from .ldap_helpers import (
35
52
  WITH_OPERATIONAL_ATTRS,
53
+ BinaryAttributes,
54
+ anonymous_user_search,
36
55
  empty,
37
56
  get_entry_by_dn,
57
+ get_schema,
38
58
  ldap_connect,
39
59
  result,
40
- unique,
41
60
  )
42
61
  from .schema import ObjectClass as OC
43
- from .schema import frontend_schema
62
+ from .schema import Schema, frontend_schema
44
63
 
45
64
  __all__ = ("api",)
46
65
 
47
66
 
48
- NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT.value)
67
+ NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
49
68
 
50
69
  # Special fields
51
70
  PHOTOS = ("jpegPhoto", "thumbnailPhoto")
@@ -55,53 +74,71 @@ PASSWORDS = ("userPassword",)
55
74
  OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
56
75
  INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
57
76
 
58
- # Starlette router to decorate endpoints
59
- api = Router()
77
+ LOG = logging.getLogger("ldap-api")
60
78
 
79
+ api = APIRouter(prefix="/api")
61
80
 
62
- @api.route("/whoami")
63
- async def whoami(request: Request) -> JSONResponse:
64
- "DN of the current user"
65
- return JSONResponse(request.state.ldap.whoami_s().replace("dn:", ""))
81
+
82
+ async def authenticated(
83
+ credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
84
+ connection: Annotated[LDAPObject, Depends(ldap_connect)],
85
+ ) -> LDAPObject:
86
+ "Authenticate against the directory"
87
+
88
+ # Hard-wired credentials
89
+ dn = settings.GET_BIND_DN()
90
+ password = settings.GET_BIND_PASSWORD()
91
+
92
+ # Search for basic auth user
93
+ if not dn:
94
+ password = credentials.password
95
+ dn = settings.GET_BIND_PATTERN(
96
+ credentials.username
97
+ ) or await anonymous_user_search(connection, credentials.username)
98
+
99
+ if dn: # Log in
100
+ await empty(connection, connection.simple_bind(dn, password))
101
+ return connection
102
+
103
+ raise INVALID_CREDENTIALS([{"desc": f"Invalid credentials for DN: {dn}"}])
66
104
 
67
105
 
68
- class TreeItem(BaseModel):
69
- dn: str
70
- structuralObjectClass: str
71
- hasSubordinates: bool
72
- level: int
106
+ AuthenticatedConnection = Annotated[LDAPObject, Depends(authenticated)]
73
107
 
74
108
 
75
- @api.route("/tree/{basedn:path}")
76
- async def tree(request: Request) -> JSONResponse:
109
+ class Tag(StrEnum):
110
+ EDITING = "Editing"
111
+ MISC = "Misc"
112
+ NAVIGATION = "Navigation"
113
+
114
+
115
+ @api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
116
+ async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
77
117
  "List directory entries"
78
118
 
79
- basedn = request.path_params["basedn"]
80
119
  base_level = len(basedn.split(","))
81
120
  scope = SCOPE_ONELEVEL
82
121
  if basedn == "base":
83
122
  scope = SCOPE_BASE
123
+ assert settings.BASE_DN is not None
84
124
  basedn = settings.BASE_DN
85
125
 
86
- connection = request.state.ldap
87
126
  entries = result(
88
127
  connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
89
128
  )
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
- )
129
+ return [
130
+ _tree_item(dn, attrs, base_level, await get_schema(connection))
131
+ async for dn, attrs in entries
132
+ ]
96
133
 
97
134
 
98
135
  def _tree_item(
99
- dn: str, attrs: dict[str, Any], level: int, schema: SubSchema
136
+ dn: str, attrs: BinaryAttributes, level: int, schema: SubSchema
100
137
  ) -> TreeItem:
101
138
  structuralObjectClass = next(
102
139
  iter(
103
140
  filter(
104
- lambda oc: oc.kind == OC.Kind.structural.value, # pyright: ignore[reportOptionalMemberAccess]
141
+ lambda oc: oc.kind == OC.Kind.structural.value, # type: ignore
105
142
  map(
106
143
  lambda o: schema.get_obj(ObjectClass, o.decode()),
107
144
  attrs["objectClass"],
@@ -120,32 +157,19 @@ def _tree_item(
120
157
  )
121
158
 
122
159
 
123
- class Meta(BaseModel):
124
- dn: str
125
- required: list[str]
126
- aux: list[str]
127
- binary: list[str]
128
- autoFilled: list[str]
129
-
130
-
131
- class Entry(BaseModel):
132
- attrs: dict[str, list[str]]
133
- meta: Meta
134
-
135
-
136
- def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
160
+ def _entry(res: tuple[str, BinaryAttributes], schema: SubSchema) -> Entry:
137
161
  "Prepare an LDAP entry for transmission"
138
162
 
139
163
  dn, attrs = res
140
164
  ocs = set([oc.decode() for oc in attrs["objectClass"]])
141
165
  must_attrs, _may_attrs = schema.attribute_types(ocs)
142
166
  soc = [
143
- oc.names[0] # pyright: ignore[reportOptionalMemberAccess]
167
+ oc.names[0] # type: ignore
144
168
  for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
145
- if oc.kind == OC.Kind.structural.value # pyright: ignore[reportOptionalMemberAccess]
169
+ if oc.kind == OC.Kind.structural.value # type: ignore
146
170
  ]
147
171
  aux = set(
148
- schema.get_obj(ObjectClass, a).names[0] # pyright: ignore[reportOptionalMemberAccess]
172
+ schema.get_obj(ObjectClass, a).names[0] # type: ignore
149
173
  for a in schema.get_applicable_aux_classes(soc[0])
150
174
  )
151
175
 
@@ -160,7 +184,7 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
160
184
 
161
185
  # Octet strings are not used consistently.
162
186
  # Try to decode as text and treat as binary on failure
163
- if not obj.syntax or obj.syntax == OCTET_STRING: # pyright: ignore[reportOptionalMemberAccess]
187
+ if not obj.syntax or obj.syntax == OCTET_STRING: # type: ignore
164
188
  try:
165
189
  for val in attrs[attr]:
166
190
  assert val.decode().isprintable()
@@ -168,20 +192,24 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
168
192
  binary.add(attr)
169
193
 
170
194
  else: # Check human-readable flag in schema
171
- syntax = schema.get_obj(LDAPSyntax, obj.syntax) # pyright: ignore[reportOptionalMemberAccess]
172
- if syntax.not_human_readable: # pyright: ignore[reportOptionalMemberAccess]
195
+ syntax = schema.get_obj(LDAPSyntax, obj.syntax) # type: ignore
196
+ if syntax.not_human_readable: # type: ignore
173
197
  binary.add(attr)
174
198
 
175
199
  return Entry(
176
200
  attrs={
177
201
  k: [
178
- base64.b64encode(val).decode() if k in binary else val for val in values
202
+ base64.b64encode(val).decode() if k in binary else val.decode()
203
+ for val in values
179
204
  ]
180
205
  for k, values in attrs.items()
181
206
  },
182
207
  meta=Meta(
183
208
  dn=dn,
184
- required=[schema.get_obj(AttributeType, a).names[0] for a in must_attrs], # pyright: ignore[reportOptionalMemberAccess]
209
+ required=[
210
+ schema.get_obj(AttributeType, a).names[0] # type: ignore
211
+ for a in must_attrs
212
+ ],
185
213
  aux=sorted(aux - ocs),
186
214
  binary=sorted(binary),
187
215
  autoFilled=[],
@@ -189,129 +217,207 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
189
217
  )
190
218
 
191
219
 
192
- Attributes = TypeAdapter(dict[str, list[bytes]])
193
-
194
-
195
- @api.route("/entry/{dn:path}", methods=["GET", "POST", "DELETE", "PUT"])
196
- async def entry(request: Request) -> Response:
197
- "Edit directory entries"
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
+ )
198
227
 
199
- dn = request.path_params["dn"]
200
- connection = request.state.ldap
201
228
 
202
- if request.method == "GET":
203
- return JSONResponse(
204
- _entry(
205
- request.app.state.schema, await get_entry_by_dn(connection, dn)
206
- ).model_dump()
207
- )
229
+ @api.delete(
230
+ "/entry/{dn:path}",
231
+ status_code=HTTPStatus.NO_CONTENT,
232
+ tags=[Tag.EDITING],
233
+ operation_id="delete_entry",
234
+ )
235
+ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
236
+ for entry_dn in sorted(
237
+ [
238
+ dn
239
+ async for dn, _attrs in result(
240
+ connection,
241
+ connection.search(dn, SCOPE_SUBTREE),
242
+ )
243
+ ],
244
+ key=len,
245
+ reverse=True,
246
+ ):
247
+ await empty(connection, connection.delete(entry_dn))
208
248
 
209
- if request.method == "DELETE":
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,
220
- ):
221
- await empty(connection, connection.delete(entry_dn))
222
- return NO_CONTENT
223
249
 
224
- # Copy JSON payload into a dictionary of non-empty byte strings
225
- json = Attributes.validate_json(await request.body())
250
+ @api.post("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="post_entry")
251
+ async def post_entry(
252
+ dn: str, attributes: Attributes, connection: AuthenticatedConnection
253
+ ) -> ChangedAttributes:
226
254
  req = {
227
- k: [s for s in filter(None, v)]
228
- for k, v in json.items()
229
- if k not in PHOTOS and (k not in PASSWORDS or request.method == "PUT")
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
230
258
  }
231
259
 
232
- if request.method == "POST":
233
- # Get previous values from directory
234
- res = await get_entry_by_dn(connection, dn)
235
- mods = {k: v for k, v in res[1].items() if k in req}
236
- modlist = modifyModlist(mods, req)
260
+ # Get previous values from directory
261
+ 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)
237
264
 
238
- if modlist: # Apply changes and send changed keys back
239
- await empty(connection, connection.modify(dn, modlist))
240
- return JSONResponse({"changed": sorted(set(m[1] for m in modlist))})
265
+ if modlist: # Apply changes and send changed keys back
266
+ await empty(connection, connection.modify(dn, modlist))
267
+ return ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
241
268
 
242
- if request.method == "PUT":
243
- # Create new object
244
- modlist = addModlist(req)
245
- if modlist:
246
- await empty(connection, connection.add(dn, modlist))
247
- return JSONResponse({"changed": ["dn"]}) # Dummy
248
269
 
249
- raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED)
270
+ @api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
271
+ async def put_entry(
272
+ dn: str, attributes: Attributes, connection: AuthenticatedConnection
273
+ ) -> ChangedAttributes:
274
+ modlist = addModlist(
275
+ {
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
279
+ }
280
+ )
281
+ if modlist:
282
+ await empty(connection, connection.add(dn, modlist))
283
+ return ChangedAttributes(changed=["dn"]) # Dummy
250
284
 
251
285
 
252
- @api.route("/blob/{attr}/{index:int}/{dn:path}", methods=["GET", "DELETE", "PUT"])
253
- async def blob(request: Request) -> Response:
254
- "Handle binary attributes"
286
+ @api.post(
287
+ "/rename/{dn:path}",
288
+ status_code=HTTPStatus.NO_CONTENT,
289
+ tags=[Tag.EDITING],
290
+ operation_id="post_rename_entry",
291
+ )
292
+ async def rename_entry(
293
+ dn: str, rdn: Annotated[str, Body()], connection: AuthenticatedConnection
294
+ ) -> None:
295
+ "Rename an entry"
296
+ await empty(connection, connection.rename(dn, rdn, delold=0))
255
297
 
256
- attr = request.path_params["attr"]
257
- index = request.path_params["index"]
258
- dn = request.path_params["dn"]
259
- connection = request.state.ldap
298
+
299
+ @api.get(
300
+ "/blob/{attr}/{index}/{dn:path}",
301
+ tags=[Tag.EDITING],
302
+ operation_id="get_blob",
303
+ include_in_schema=False, # Not used in UI, images are transferred inline
304
+ )
305
+ async def get_blob(
306
+ attr: str, index: int, dn: str, connection: AuthenticatedConnection
307
+ ) -> Response:
308
+ "Retrieve a binary attribute"
260
309
 
261
310
  _dn, attrs = await get_entry_by_dn(connection, dn)
262
311
 
263
- if request.method == "GET":
264
- if attr not in attrs or len(attrs[attr]) <= index:
265
- raise HTTPException(
266
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
267
- )
312
+ if attr not in attrs or len(attrs[attr]) <= index:
313
+ raise HTTPException(
314
+ HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
315
+ )
316
+
317
+ return Response(
318
+ attrs[attr][index],
319
+ media_type="application/octet-stream",
320
+ headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
321
+ )
268
322
 
269
- return Response(
270
- attrs[attr][index],
271
- media_type="application/octet-stream",
272
- headers={
273
- "Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'
274
- },
323
+
324
+ @api.put(
325
+ "/blob/{attr}/{index}/{dn:path}",
326
+ status_code=HTTPStatus.NO_CONTENT,
327
+ tags=[Tag.EDITING],
328
+ operation_id="put_blob",
329
+ )
330
+ async def put_blob(
331
+ attr: str,
332
+ index: int,
333
+ dn: str,
334
+ blob: Annotated[UploadFile, File()],
335
+ connection: AuthenticatedConnection,
336
+ ) -> None:
337
+ "Upload a binary attribute"
338
+ _dn, attrs = await get_entry_by_dn(connection, dn)
339
+ data = await blob.read(cast(int, blob.size))
340
+ if attr in attrs:
341
+ await empty(
342
+ connection,
343
+ connection.modify(dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]),
275
344
  )
345
+ else:
346
+ await empty(connection, connection.modify(dn, [(0, attr, [data])]))
276
347
 
277
- if request.method == "PUT":
278
- async with request.form() as form_data:
279
- blob = form_data["blob"]
280
- if type(blob) is UploadFile:
281
- data = await blob.read(cast(int, blob.size))
282
- if attr in attrs:
283
- await empty(
284
- connection,
285
- connection.modify(
286
- dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]
287
- ),
288
- )
289
- else:
290
- await empty(connection, connection.modify(dn, [(0, attr, [data])]))
291
- return NO_CONTENT
292
348
 
293
- if request.method == "DELETE":
294
- if attr not in attrs or len(attrs[attr]) <= index:
295
- raise HTTPException(
296
- HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
297
- )
298
- await empty(connection, connection.modify(dn, [(1, attr, None)]))
299
- data = attrs[attr][:index] + attrs[attr][index + 1 :]
300
- if data:
301
- await empty(connection, connection.modify(dn, [(0, attr, data)]))
302
- return NO_CONTENT
349
+ @api.delete(
350
+ "/blob/{attr}/{index}/{dn:path}",
351
+ status_code=HTTPStatus.NO_CONTENT,
352
+ tags=[Tag.EDITING],
353
+ operation_id="delete_blob",
354
+ )
355
+ async def delete_blob(
356
+ attr: str, index: int, dn: str, connection: AuthenticatedConnection
357
+ ) -> None:
358
+ "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:
361
+ raise HTTPException(
362
+ HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
363
+ )
364
+ await empty(connection, connection.modify(dn, [(1, attr, None)]))
365
+ data = attrs[attr][:index] + attrs[attr][index + 1 :]
366
+ if data:
367
+ await empty(connection, connection.modify(dn, [(0, attr, data)]))
303
368
 
304
- raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED)
305
369
 
370
+ @api.post(
371
+ "/check-password/{dn:path}", tags=[Tag.EDITING], operation_id="post_check_password"
372
+ )
373
+ async def check_password(
374
+ dn: str, check: Annotated[str, Body()], connection: AuthenticatedConnection
375
+ ) -> bool:
376
+ "Verify a password"
377
+
378
+ try:
379
+ connection.simple_bind_s(dn, check)
380
+ return True
381
+ except INVALID_CREDENTIALS:
382
+ return False
306
383
 
307
- @api.route("/ldif/{dn:path}")
308
- async def ldifDump(request: Request) -> PlainTextResponse:
384
+
385
+ @api.post(
386
+ "/change-password/{dn:path}",
387
+ tags=[Tag.EDITING],
388
+ operation_id="post_change_password",
389
+ )
390
+ async def change_password(
391
+ dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
392
+ ) -> str | None:
393
+ "Update passwords"
394
+ if args.new1:
395
+ await empty(
396
+ connection,
397
+ connection.passwd(dn, args.old or None, args.new1),
398
+ )
399
+ _dn, attrs = await get_entry_by_dn(connection, dn)
400
+ attrs["userPassword"][0].decode()
401
+
402
+ else:
403
+ await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
404
+
405
+
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
+ @api.get(
413
+ "/ldif/{dn:path}",
414
+ include_in_schema=False, # Used as a link target, no API call
415
+ )
416
+ async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
309
417
  "Dump an entry as LDIF"
310
418
 
311
- dn = request.path_params["dn"]
312
419
  out = io.StringIO()
313
420
  writer = ldif.LDIFWriter(out)
314
- connection = request.state.ldap
315
421
 
316
422
  async for dn, attrs in result(connection, connection.search(dn, SCOPE_SUBTREE)):
317
423
  writer.unparse(dn, attrs)
@@ -329,96 +435,31 @@ class LDIFReader(ldif.LDIFParser):
329
435
  self.count = 0
330
436
  self.con = con
331
437
 
332
- def handle(self, dn: str, entry: dict[str, Any]):
438
+ def handle(self, dn: str, entry: Attributes):
333
439
  self.con.add_s(dn, addModlist(entry))
334
440
  self.count += 1
335
441
 
336
442
 
337
- @api.route("/ldif", methods=["POST"])
338
- async def ldifUpload(
339
- request: Request,
443
+ @api.post("/ldif", tags=[Tag.EDITING], operation_id="post_ldif")
444
+ async def upload_ldif(
445
+ ldif: Annotated[str, Body()], connection: AuthenticatedConnection
340
446
  ) -> Response:
341
447
  "Import LDIF"
342
448
 
343
- reader = LDIFReader(await request.body(), request.state.ldap)
449
+ reader = LDIFReader(ldif.encode(), connection)
344
450
  try:
345
451
  reader.parse()
346
452
  return NO_CONTENT
347
453
  except ValueError as e:
348
- return Response(e.args[0], status_code=422)
349
-
454
+ return Response(e.args[0], HTTPStatus.UNPROCESSABLE_ENTITY)
350
455
 
351
- Rdn = TypeAdapter(str)
352
-
353
-
354
- @api.route("/rename/{dn:path}", methods=["POST"])
355
- async def rename(request: Request) -> Response:
356
- "Rename an entry"
357
-
358
- dn = request.path_params["dn"]
359
- rdn = Rdn.validate_json(await request.body())
360
- connection = request.state.ldap
361
- await empty(connection, connection.rename(dn, rdn, delold=0))
362
- return NO_CONTENT
363
-
364
-
365
- class ChangePasswordRequest(BaseModel):
366
- old: str
367
- new1: str
368
-
369
-
370
- class CheckPasswordRequest(BaseModel):
371
- check: str = Field(min_length=1)
372
-
373
-
374
- PasswordRequest = TypeAdapter(Union[ChangePasswordRequest, CheckPasswordRequest])
375
-
376
-
377
- @api.route("/password/{dn:path}", methods=["POST"])
378
- async def passwd(request: Request) -> Response:
379
- "Update passwords"
380
456
 
381
- dn = request.path_params["dn"]
382
- args = PasswordRequest.validate_json(await request.body())
383
-
384
- if type(args) is CheckPasswordRequest:
385
- with ldap_connect() as con:
386
- try:
387
- con.simple_bind_s(dn, args.check)
388
- return JSONResponse(True)
389
- except INVALID_CREDENTIALS:
390
- return JSONResponse(False)
391
-
392
- elif type(args) is ChangePasswordRequest:
393
- connection = request.state.ldap
394
- if args.new1:
395
- await empty(
396
- connection,
397
- connection.passwd(dn, args.old or None, args.new1),
398
- )
399
- _dn, attrs = await get_entry_by_dn(connection, dn)
400
- return JSONResponse(attrs["userPassword"][0].decode())
401
-
402
- else:
403
- await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
404
- return NO_CONTENT
405
-
406
- raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY)
407
-
408
-
409
- def _cn(entry: dict) -> Optional[str]:
410
- "Try to extract a CN"
411
- if "cn" in entry and entry["cn"]:
412
- return entry["cn"][0].decode()
413
-
414
-
415
- @api.route("/search/{query:path}")
416
- async def search(request: Request) -> JSONResponse:
457
+ @api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
458
+ async def search(query: str, connection: AuthenticatedConnection) -> list[SearchResult]:
417
459
  "Search the directory"
418
460
 
419
- query = request.path_params["query"]
420
461
  if len(query) < settings.SEARCH_QUERY_MIN:
421
- return JSONResponse([])
462
+ return []
422
463
 
423
464
  if "=" in query: # Search specific attributes
424
465
  if "(" not in query:
@@ -428,45 +469,47 @@ async def search(request: Request) -> JSONResponse:
428
469
 
429
470
  # Collect results
430
471
  res = []
431
- connection = request.state.ldap
432
472
  async for dn, attrs in result(
433
473
  connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
434
474
  ):
435
- res.append({"dn": dn, "name": _cn(attrs) or dn})
475
+ res.append(SearchResult(dn=dn, name=_cn(attrs) or dn))
436
476
  if len(res) >= settings.SEARCH_MAX:
437
477
  break
438
- return JSONResponse(res)
478
+ return res
479
+
480
+
481
+ @api.get("/whoami", tags=[Tag.MISC], operation_id="get_who_am_i")
482
+ async def whoami(connection: AuthenticatedConnection) -> str:
483
+ "DN of the current user"
484
+ return connection.whoami_s().replace("dn:", "")
439
485
 
440
486
 
441
- @api.route("/subtree/{dn:path}")
442
- async def subtree(request: Request) -> JSONResponse:
487
+ @api.get("/subtree/{root_dn:path}", tags=[Tag.MISC], operation_id="get_subtree")
488
+ async def list_subtree(
489
+ root_dn: str, connection: AuthenticatedConnection
490
+ ) -> list[TreeItem]:
443
491
  "List the subtree below a DN"
444
492
 
445
- root_dn = request.path_params["dn"]
446
493
  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
- )
494
+ return sorted(
495
+ [
496
+ _tree_item(dn, attrs, start, await get_schema(connection))
497
+ async for dn, attrs in result(
498
+ connection,
499
+ connection.search(root_dn, SCOPE_SUBTREE),
500
+ )
501
+ if root_dn != dn
502
+ ],
503
+ key=lambda item: tuple(reversed(item.dn.lower().split(","))),
460
504
  )
461
505
 
462
506
 
463
- @api.route("/range/{attribute}")
464
- async def attribute_range(request: Request) -> JSONResponse:
507
+ @api.get("/range/{attribute}", tags=[Tag.MISC], operation_id="get_range")
508
+ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -> Range:
465
509
  "List all values for a numeric attribute of an objectClass like uidNumber or gidNumber"
466
510
 
467
- attribute = request.path_params["attribute"]
468
- connection = request.state.ldap
469
- obj = request.app.state.schema.get_obj(AttributeType, attribute)
511
+ schema = await get_schema(connection)
512
+ obj = schema.get_obj(AttributeType, attribute)
470
513
 
471
514
  values = set(
472
515
  [
@@ -490,30 +533,20 @@ async def attribute_range(request: Request) -> JSONResponse:
490
533
  )
491
534
 
492
535
  minimum, maximum = min(values), max(values)
493
- return JSONResponse(
494
- {
495
- "min": minimum,
496
- "max": maximum,
497
- "next": min(set(range(minimum, maximum + 2)) - values),
498
- }
536
+ return Range(
537
+ min=minimum,
538
+ max=maximum,
539
+ next=min(set(range(minimum, maximum + 2)) - values),
499
540
  )
500
541
 
501
542
 
502
- @api.route("/schema")
503
- async def json_schema(request: Request) -> JSONResponse:
543
+ @api.get(
544
+ "/schema",
545
+ tags=[Tag.MISC],
546
+ operation_id="get_schema",
547
+ response_model_exclude_none=True,
548
+ response_model_exclude_unset=True,
549
+ )
550
+ async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
504
551
  "Dump the LDAP schema as JSON"
505
- if getattr(request.app.state, "schema", None) is None:
506
- connection = request.state.ldap
507
- # See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
508
- _dn, sub_schema = await unique(
509
- connection,
510
- connection.search(
511
- settings.SCHEMA_DN,
512
- SCOPE_BASE,
513
- attrlist=WITH_OPERATIONAL_ATTRS,
514
- ),
515
- )
516
- request.app.state.schema = SubSchema(sub_schema, check_uniqueness=2)
517
-
518
- schema = frontend_schema(request.app.state.schema)
519
- return JSONResponse(schema.model_dump())
552
+ return frontend_schema(await get_schema(connection))