ldap-ui 0.9.1__py3-none-any.whl → 0.9.3__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,14 +1 @@
1
- import uvicorn
2
-
3
- from . import settings
4
-
5
- __version__ = "0.9.1"
6
-
7
-
8
- def run():
9
- uvicorn.run(
10
- "ldap_ui.app:app",
11
- log_level="info",
12
- host="127.0.0.1" if settings.DEBUG else None,
13
- port=5000,
14
- )
1
+ __version__ = "0.9.3"
ldap_ui/__main__.py CHANGED
@@ -1,4 +1,69 @@
1
- from ldap_ui import run
1
+ import click
2
+ import uvicorn
3
+ from uvicorn.main import LEVEL_CHOICES
4
+
5
+ import ldap_ui
6
+
7
+ from . import settings
8
+
9
+
10
+ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
11
+ if value:
12
+ click.echo(ldap_ui.__version__)
13
+ ctx.exit()
14
+
15
+
16
+ @click.command()
17
+ @click.option(
18
+ "-b",
19
+ "--base-dn",
20
+ type=str,
21
+ default=settings.BASE_DN,
22
+ help="LDAP base DN. Required unless the BASE_DN environment variable is set.",
23
+ )
24
+ @click.option(
25
+ "-h",
26
+ "--host",
27
+ type=str,
28
+ default="127.0.0.1",
29
+ help="Bind socket to this host.",
30
+ show_default=True,
31
+ )
32
+ @click.option(
33
+ "-p",
34
+ "--port",
35
+ type=int,
36
+ default=5000,
37
+ help="Bind socket to this port. If 0, an available port will be picked.",
38
+ show_default=True,
39
+ )
40
+ @click.option(
41
+ "-l",
42
+ "--log-level",
43
+ type=LEVEL_CHOICES,
44
+ default=None,
45
+ help="Log level. [default: info]",
46
+ show_default=True,
47
+ )
48
+ @click.option(
49
+ "--version",
50
+ is_flag=True,
51
+ callback=print_version,
52
+ expose_value=False,
53
+ is_eager=True,
54
+ help="Display the current version and exit.",
55
+ )
56
+ def main(base_dn, host, port, log_level):
57
+ if base_dn:
58
+ settings.BASE_DN = base_dn
59
+
60
+ uvicorn.run(
61
+ "ldap_ui.app:app",
62
+ log_level=log_level,
63
+ host=host,
64
+ port=port,
65
+ )
66
+
2
67
 
3
68
  if __name__ == "__main__":
4
- run()
69
+ main()
ldap_ui/app.py CHANGED
@@ -11,11 +11,13 @@ No sessions, no cookies, nothing else.
11
11
  import base64
12
12
  import binascii
13
13
  import contextlib
14
+ import logging
15
+ import sys
14
16
  from typing import AsyncGenerator
15
17
 
16
18
  import ldap
17
- import uvicorn
18
19
  from ldap.schema import SubSchema
20
+ from pydantic import ValidationError
19
21
  from starlette.applications import Starlette
20
22
  from starlette.authentication import (
21
23
  AuthCredentials,
@@ -37,6 +39,12 @@ from . import settings
37
39
  from .ldap_api import api
38
40
  from .ldap_helpers import WITH_OPERATIONAL_ATTRS, empty, ldap_connect, unique
39
41
 
42
+ LOG = logging.getLogger("ldap-ui")
43
+
44
+ if settings.BASE_DN is None:
45
+ LOG.critical("An LDAP base DN is required!")
46
+ sys.exit(1)
47
+
40
48
  # Force authentication
41
49
  UNAUTHORIZED = Response(
42
50
  "Invalid credentials",
@@ -144,7 +152,7 @@ class CacheBustingMiddleware(BaseHTTPMiddleware):
144
152
  return response
145
153
 
146
154
 
147
- async def http_exception(_request: Request, exc: HTTPException):
155
+ async def http_exception(_request: Request, exc: HTTPException) -> Response:
148
156
  "Send error responses"
149
157
  assert exc.status_code >= 400
150
158
  return PlainTextResponse(
@@ -154,11 +162,17 @@ async def http_exception(_request: Request, exc: HTTPException):
154
162
  )
155
163
 
156
164
 
157
- async def forbidden(_request: Request, exc: ldap.LDAPError):
165
+ async def forbidden(_request: Request, exc: ldap.LDAPError) -> Response:
158
166
  "HTTP 403 Forbidden"
159
167
  return PlainTextResponse(ldap_exception_message(exc), status_code=403)
160
168
 
161
169
 
170
+ async def http_422(_request: Request, e: ValidationError) -> Response:
171
+ "HTTP 422 Unprocessable Entity"
172
+ LOG.exception("Invalid request body", exc_info=e)
173
+ return Response(repr(e), status_code=422)
174
+
175
+
162
176
  @contextlib.asynccontextmanager
163
177
  async def lifespan(app):
164
178
  with ldap_connect() as connection:
@@ -181,6 +195,7 @@ app = Starlette(
181
195
  exception_handlers={
182
196
  HTTPException: http_exception,
183
197
  ldap.INSUFFICIENT_ACCESS: forbidden,
198
+ ValidationError: http_422,
184
199
  },
185
200
  lifespan=lifespan,
186
201
  middleware=(
ldap_ui/ldap_api.py CHANGED
@@ -9,7 +9,7 @@ Asynchronous LDAP operations are used as much as possible.
9
9
 
10
10
  import base64
11
11
  import io
12
- from typing import Any, Optional, Tuple
12
+ from typing import Any, Optional, Tuple, Union
13
13
 
14
14
  import ldap
15
15
  import ldif
@@ -17,6 +17,7 @@ from ldap.ldapobject import LDAPObject
17
17
  from ldap.modlist import addModlist, modifyModlist
18
18
  from ldap.schema import SubSchema
19
19
  from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
20
+ from pydantic import BaseModel, Field, TypeAdapter
20
21
  from starlette.datastructures import UploadFile
21
22
  from starlette.exceptions import HTTPException
22
23
  from starlette.requests import Request
@@ -144,6 +145,9 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> dict[str, Any]:
144
145
  }
145
146
 
146
147
 
148
+ Entry = TypeAdapter(dict[str, list[bytes]])
149
+
150
+
147
151
  @api.route("/entry/{dn}", methods=("GET", "POST", "DELETE", "PUT"))
148
152
  async def entry(request: Request) -> Response:
149
153
  "Edit directory entries"
@@ -164,9 +168,9 @@ async def entry(request: Request) -> Response:
164
168
  return NO_CONTENT
165
169
 
166
170
  # Copy JSON payload into a dictionary of non-empty byte strings
167
- json = await request.json()
171
+ json = Entry.validate_json(await request.body())
168
172
  req = {
169
- k: [s.encode() for s in filter(None, v)]
173
+ k: [s for s in filter(None, v)]
170
174
  for k, v in json.items()
171
175
  if k not in PHOTOS and (k not in PASSWORDS or request.method == "PUT")
172
176
  }
@@ -277,8 +281,14 @@ async def ldifUpload(
277
281
  "Import LDIF"
278
282
 
279
283
  reader = LDIFReader(await request.body(), request.state.ldap)
280
- reader.parse()
281
- return NO_CONTENT
284
+ try:
285
+ reader.parse()
286
+ return NO_CONTENT
287
+ except ValueError as e:
288
+ return Response(e.args[0], status_code=422)
289
+
290
+
291
+ Rdn = TypeAdapter(str)
282
292
 
283
293
 
284
294
  @api.route("/rename/{dn}", methods=("POST",))
@@ -286,16 +296,22 @@ async def rename(request: Request) -> JSONResponse:
286
296
  "Rename an entry"
287
297
 
288
298
  dn = request.path_params["dn"]
289
- rdn = await request.json()
299
+ rdn = Rdn.validate_json(await request.body())
290
300
  connection = request.state.ldap
291
301
  await empty(connection, connection.rename(dn, rdn, delold=0))
292
302
  return NO_CONTENT
293
303
 
294
304
 
295
- def _cn(entry: dict) -> Optional[str]:
296
- "Try to extract a CN"
297
- if "cn" in entry and entry["cn"]:
298
- return entry["cn"][0].decode()
305
+ class ChangePasswordRequest(BaseModel):
306
+ old: str
307
+ new1: str
308
+
309
+
310
+ class CheckPasswordRequest(BaseModel):
311
+ check: str = Field(min_length=1)
312
+
313
+
314
+ PasswordRequest = TypeAdapter(Union[ChangePasswordRequest, CheckPasswordRequest])
299
315
 
300
316
 
301
317
  @api.route("/entry/password/{dn}", methods=("POST",))
@@ -303,22 +319,22 @@ async def passwd(request: Request) -> JSONResponse:
303
319
  "Update passwords"
304
320
 
305
321
  dn = request.path_params["dn"]
306
- args = await request.json()
322
+ args = PasswordRequest.validate_json(await request.body())
307
323
 
308
- if "check" in args:
324
+ if type(args) is CheckPasswordRequest:
309
325
  with ldap_connect() as con:
310
326
  try:
311
- con.simple_bind_s(dn, args["check"])
327
+ con.simple_bind_s(dn, args.check)
312
328
  return JSONResponse(True)
313
329
  except ldap.INVALID_CREDENTIALS:
314
330
  return JSONResponse(False)
315
331
 
316
- if "old" in args and "new1" in args:
332
+ else:
317
333
  connection = request.state.ldap
318
- if args["new1"]:
334
+ if args.new1:
319
335
  await empty(
320
336
  connection,
321
- connection.passwd(dn, args.get("old") or None, args["new1"]),
337
+ connection.passwd(dn, args.old or None, args.new1),
322
338
  )
323
339
  _dn, attrs = await get_entry_by_dn(connection, dn)
324
340
  return JSONResponse(attrs["userPassword"][0].decode())
@@ -328,6 +344,12 @@ async def passwd(request: Request) -> JSONResponse:
328
344
  return JSONResponse(None)
329
345
 
330
346
 
347
+ def _cn(entry: dict) -> Optional[str]:
348
+ "Try to extract a CN"
349
+ if "cn" in entry and entry["cn"]:
350
+ return entry["cn"][0].decode()
351
+
352
+
331
353
  @api.route("/search/{query:path}")
332
354
  async def search(request: Request) -> JSONResponse:
333
355
  "Search the directory"
ldap_ui/settings.py CHANGED
@@ -14,7 +14,7 @@ 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") # Always required
17
+ BASE_DN = config("BASE_DN", default=None)
18
18
 
19
19
  USE_TLS = config(
20
20
  "USE_TLS",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ldap-ui
3
- Version: 0.9.1
3
+ Version: 0.9.3
4
4
  Summary: A fast and versatile LDAP editor
5
5
  Author: dnknth
6
6
  License: MIT License
@@ -12,9 +12,10 @@ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Programming Language :: Python :: 3 :: Only
14
14
  Classifier: Topic :: Software Development :: Libraries
15
- Requires-Python: >=3.8
15
+ Requires-Python: >=3.7
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE.txt
18
+ Requires-Dist: pydantic
18
19
  Requires-Dist: python-ldap
19
20
  Requires-Dist: python-multipart
20
21
  Requires-Dist: starlette
@@ -85,7 +86,23 @@ python3 -m venv --system-site-packages venv
85
86
  pip3 install ldap-ui
86
87
  ```
87
88
 
88
- Possibly after a shell `rehash`, it is available as `ldap-ui`.
89
+ Possibly after a shell `rehash`, it is available as `ldap-ui`:
90
+
91
+ ```text
92
+ Usage: ldap-ui [OPTIONS]
93
+
94
+ Options:
95
+ -b, --base-dn TEXT LDAP base DN. Required unless the BASE_DN
96
+ environment variable is set.
97
+ -h, --host TEXT Bind socket to this host. [default:
98
+ 127.0.0.1]
99
+ -p, --port INTEGER Bind socket to this port. If 0, an available
100
+ port will be picked. [default: 5000]
101
+ -l, --log-level [critical|error|warning|info|debug|trace]
102
+ Log level. [default: info]
103
+ --version Display the current version and exit.
104
+ --help Show this message and exit.
105
+ ```
89
106
 
90
107
  ## Development
91
108
 
@@ -1,10 +1,10 @@
1
- ldap_ui/__init__.py,sha256=qWn38sHkooVeCw9daRP9jbAi2NLrMetwUD8NaCWPrv4,224
2
- ldap_ui/__main__.py,sha256=J0Hc3IiwRAjNKeov7tCLgpZJwT2iUHcmXb4UmazRqU4,62
3
- ldap_ui/app.py,sha256=68__BoGHFJU8HePoTKyYTM8B305s_dYnlKb8e9yWm8I,6312
4
- ldap_ui/ldap_api.py,sha256=ecK8K-7-tkoA2df2dbQT8y61I70r7NukE3BQFNN1BsE,12776
1
+ ldap_ui/__init__.py,sha256=xKd3pzbczuMsdB08eLAOqZDUd_q1IRxwZ_ccAFL4c4A,22
2
+ ldap_ui/__main__.py,sha256=RzzkUuwqEk9bPG9pPaBZHA6EvFhXFKI89cF7jdX9RPw,1345
3
+ ldap_ui/app.py,sha256=2gy0khq8whbrxJbA-jNo2Ss7oiW-XxQKWM6Jbxp5d7I,6759
4
+ ldap_ui/ldap_api.py,sha256=l6Wnjzm8ycaY-xXT2JNuHTTvLEsOEXGJnJhDdD72N5Q,13267
5
5
  ldap_ui/ldap_helpers.py,sha256=DRpKtqEX_OvYJBuvzTNi0CcZAu446wwUiOezlkBAxrQ,3045
6
6
  ldap_ui/schema.py,sha256=apbdLK_WpED0IzmrdktWTu4ESz8GfdOoJuRicFC84YY,3327
7
- ldap_ui/settings.py,sha256=qEN2fechOCZ935VAa47cPimQruEq7h-iq9d7ascDtZc,2265
7
+ ldap_ui/settings.py,sha256=PmugPMAmFmNa3bM4XKsur89ELORZ103DxrP7ZbsuopY,2260
8
8
  ldap_ui/statics/favicon.ico,sha256=_PMMM_C1ER5cpJTXZcRgISR4igj44kA4u8Trl-Ko3L0,4286
9
9
  ldap_ui/statics/index.html,sha256=twUC90MNTfNFpQLiLIwUuU3H6RRDiPY92BnMRgIGRWU,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-CA45Sb-q.js,sha256=qejoldzV9wbUVar1ljURYwyTb2DYn3HQ
16
16
  ldap_ui/statics/assets/index-CA45Sb-q.js.gz,sha256=1yJTHdcLZLU2d6DkPKbmNEFiOlPo7vIWXYyg_fidSdE,43710
17
17
  ldap_ui/statics/assets/index-DlTKbnmq.css,sha256=Rpthz_HvUybqmodfPCnOXFsSwGd7v8hhh-p-duAzf1E,48119
18
18
  ldap_ui/statics/assets/index-DlTKbnmq.css.gz,sha256=Ctq3hMh_BBVt9zsh9CYlHpVtDDHanicyGPAdcGXIFXw,11532
19
- ldap_ui-0.9.1.dist-info/LICENSE.txt,sha256=UpJ0sDIqHxbOtzy1EG4bCHs9R_99ODxxPDK4NZ0g3I0,1042
20
- ldap_ui-0.9.1.dist-info/METADATA,sha256=uENv_PqySjK3XhZ0pF9kf7FiQ--WDHeHDD0a1--1YWc,6824
21
- ldap_ui-0.9.1.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
22
- ldap_ui-0.9.1.dist-info/entry_points.txt,sha256=rJuKzXUTziBohZ3-tt4-WbD2DOO1LHfzwnDm2HdCg84,40
23
- ldap_ui-0.9.1.dist-info/top_level.txt,sha256=t9Agyig1nDdJuQvx_UVuk1n28pgswc1BIYw8E6pWado,8
24
- ldap_ui-0.9.1.dist-info/RECORD,,
19
+ ldap_ui-0.9.3.dist-info/LICENSE.txt,sha256=UpJ0sDIqHxbOtzy1EG4bCHs9R_99ODxxPDK4NZ0g3I0,1042
20
+ ldap_ui-0.9.3.dist-info/METADATA,sha256=tDswFFkkDqk_wc40dnfq455_lWXmUmaGFntAgNcbiQc,7557
21
+ ldap_ui-0.9.3.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
22
+ ldap_ui-0.9.3.dist-info/entry_points.txt,sha256=TGxMkXYeZP5m5NjZxWmgzITYWhSdj2mR_GGUYmHhGws,50
23
+ ldap_ui-0.9.3.dist-info/top_level.txt,sha256=t9Agyig1nDdJuQvx_UVuk1n28pgswc1BIYw8E6pWado,8
24
+ ldap_ui-0.9.3.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ldap-ui = ldap_ui.__main__:main
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- ldap-ui = ldap_ui:run