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 +1 -14
- ldap_ui/__main__.py +67 -2
- ldap_ui/app.py +18 -3
- ldap_ui/ldap_api.py +38 -16
- ldap_ui/settings.py +1 -1
- {ldap_ui-0.9.1.dist-info → ldap_ui-0.9.3.dist-info}/METADATA +20 -3
- {ldap_ui-0.9.1.dist-info → ldap_ui-0.9.3.dist-info}/RECORD +11 -11
- ldap_ui-0.9.3.dist-info/entry_points.txt +2 -0
- ldap_ui-0.9.1.dist-info/entry_points.txt +0 -2
- {ldap_ui-0.9.1.dist-info → ldap_ui-0.9.3.dist-info}/LICENSE.txt +0 -0
- {ldap_ui-0.9.1.dist-info → ldap_ui-0.9.3.dist-info}/WHEEL +0 -0
- {ldap_ui-0.9.1.dist-info → ldap_ui-0.9.3.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
ldap_ui/__main__.py
CHANGED
|
@@ -1,4 +1,69 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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.
|
|
171
|
+
json = Entry.validate_json(await request.body())
|
|
168
172
|
req = {
|
|
169
|
-
k: [s
|
|
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
|
-
|
|
281
|
-
|
|
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.
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
322
|
+
args = PasswordRequest.validate_json(await request.body())
|
|
307
323
|
|
|
308
|
-
if
|
|
324
|
+
if type(args) is CheckPasswordRequest:
|
|
309
325
|
with ldap_connect() as con:
|
|
310
326
|
try:
|
|
311
|
-
con.simple_bind_s(dn, args
|
|
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
|
-
|
|
332
|
+
else:
|
|
317
333
|
connection = request.state.ldap
|
|
318
|
-
if args
|
|
334
|
+
if args.new1:
|
|
319
335
|
await empty(
|
|
320
336
|
connection,
|
|
321
|
-
connection.passwd(dn, args.
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: ldap-ui
|
|
3
|
-
Version: 0.9.
|
|
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.
|
|
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=
|
|
2
|
-
ldap_ui/__main__.py,sha256=
|
|
3
|
-
ldap_ui/app.py,sha256=
|
|
4
|
-
ldap_ui/ldap_api.py,sha256=
|
|
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=
|
|
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.
|
|
20
|
-
ldap_ui-0.9.
|
|
21
|
-
ldap_ui-0.9.
|
|
22
|
-
ldap_ui-0.9.
|
|
23
|
-
ldap_ui-0.9.
|
|
24
|
-
ldap_ui-0.9.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|