ldap-ui 0.9.9__py3-none-any.whl → 0.9.10__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 -1
- ldap_ui/app.py +48 -21
- ldap_ui/ldap_api.py +110 -63
- ldap_ui/ldap_helpers.py +17 -11
- ldap_ui/schema.py +6 -6
- ldap_ui/settings.py +1 -1
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/METADATA +1 -1
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/RECORD +12 -12
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/LICENSE.txt +0 -0
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/WHEEL +0 -0
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.9.9.dist-info → ldap_ui-0.9.10.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.9.
|
|
1
|
+
__version__ = "0.9.10"
|
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
|
|
17
|
-
|
|
18
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
130
|
+
except INVALID_CREDENTIALS:
|
|
97
131
|
pass
|
|
98
132
|
|
|
99
|
-
except
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
80
|
+
base_level = len(basedn.split(","))
|
|
81
|
+
scope = SCOPE_ONELEVEL
|
|
69
82
|
if basedn == "base":
|
|
70
|
-
scope =
|
|
83
|
+
scope = SCOPE_BASE
|
|
71
84
|
basedn = settings.BASE_DN
|
|
72
85
|
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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: [
|
|
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=
|
|
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
|
|
179
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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=
|
|
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=
|
|
313
|
-
async def rename(request: Request) ->
|
|
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=
|
|
336
|
-
async def passwd(request: Request) ->
|
|
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
|
|
389
|
+
except INVALID_CREDENTIALS:
|
|
348
390
|
return JSONResponse(False)
|
|
349
391
|
|
|
350
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
57
|
+
connection.set_option(OPT_X_TLS_REQUIRE_CERT, cert_level)
|
|
52
58
|
# See https://stackoverflow.com/a/38136255
|
|
53
|
-
connection.set_option(
|
|
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[
|
|
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,
|
|
115
|
-
except
|
|
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,
|
|
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
|
|
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"
|
|
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:
|
|
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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
ldap_ui/__init__.py,sha256=
|
|
1
|
+
ldap_ui/__init__.py,sha256=5nY2lKMmQwtU8FXTQ2Qpv9EUNfy2UJF9cHFr82n7ARw,23
|
|
2
2
|
ldap_ui/__main__.py,sha256=s2jFbC2y2LpvcTY8yXOFVisKXSFG079hc9IVgrJ49vY,1849
|
|
3
|
-
ldap_ui/app.py,sha256=
|
|
4
|
-
ldap_ui/ldap_api.py,sha256=
|
|
5
|
-
ldap_ui/ldap_helpers.py,sha256=
|
|
6
|
-
ldap_ui/schema.py,sha256=
|
|
7
|
-
ldap_ui/settings.py,sha256=
|
|
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=fN5QtB9Sv3UYF3tJX6M1yKClMSxvA332z2FckAonM14,2466
|
|
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.
|
|
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.10.dist-info/LICENSE.txt,sha256=UpJ0sDIqHxbOtzy1EG4bCHs9R_99ODxxPDK4NZ0g3I0,1042
|
|
20
|
+
ldap_ui-0.9.10.dist-info/METADATA,sha256=4A3XwREZ64eKsx7Vnlrlj37i0FobwDWmDkKl8coTawE,7558
|
|
21
|
+
ldap_ui-0.9.10.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
22
|
+
ldap_ui-0.9.10.dist-info/entry_points.txt,sha256=TGxMkXYeZP5m5NjZxWmgzITYWhSdj2mR_GGUYmHhGws,50
|
|
23
|
+
ldap_ui-0.9.10.dist-info/top_level.txt,sha256=t9Agyig1nDdJuQvx_UVuk1n28pgswc1BIYw8E6pWado,8
|
|
24
|
+
ldap_ui-0.9.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|