ldap-ui 0.10.0__py3-none-any.whl → 0.10.2__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 +35 -77
- ldap_ui/entities.py +8 -20
- ldap_ui/ldap_api.py +170 -160
- ldap_ui/ldap_helpers.py +40 -23
- ldap_ui/schema.py +0 -2
- ldap_ui/statics/assets/index-0D2GdpZj.js +19 -0
- ldap_ui/statics/assets/index-0D2GdpZj.js.gz +0 -0
- ldap_ui/statics/assets/{index-4LadlPh6.css → index-CAWiQJyn.css} +1 -1
- ldap_ui/statics/assets/index-CAWiQJyn.css.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.2.dist-info}/METADATA +1 -1
- ldap_ui-0.10.2.dist-info/RECORD +25 -0
- ldap_ui/statics/assets/index-4LadlPh6.css.gz +0 -0
- ldap_ui/statics/assets/index-CVDJWmXN.js +0 -19
- ldap_ui/statics/assets/index-CVDJWmXN.js.gz +0 -0
- ldap_ui-0.10.0.dist-info/RECORD +0 -25
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.2.dist-info}/WHEEL +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.2.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.2.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.2.dist-info}/top_level.txt +0 -0
ldap_ui/ldap_api.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
2
|
ReST endpoints for LDAP access.
|
|
3
3
|
|
|
4
|
-
Directory operations are
|
|
5
|
-
|
|
4
|
+
Directory operations are exposed to the frontend
|
|
5
|
+
by a hand-knit ReST API, responses are usually converted to JSON.
|
|
6
6
|
|
|
7
7
|
Asynchronous LDAP operations are used as much as possible.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import io
|
|
12
|
-
import logging
|
|
13
12
|
from enum import StrEnum
|
|
14
13
|
from http import HTTPStatus
|
|
15
14
|
from typing import Annotated, cast
|
|
@@ -35,35 +34,31 @@ from ldap import (
|
|
|
35
34
|
from ldap.ldapobject import LDAPObject
|
|
36
35
|
from ldap.modlist import addModlist, modifyModlist
|
|
37
36
|
from ldap.schema import SubSchema
|
|
38
|
-
from ldap.schema.models import AttributeType, LDAPSyntax
|
|
37
|
+
from ldap.schema.models import AttributeType, LDAPSyntax
|
|
39
38
|
|
|
40
39
|
from . import settings
|
|
41
40
|
from .entities import (
|
|
41
|
+
AttributeNames,
|
|
42
42
|
Attributes,
|
|
43
|
-
ChangedAttributes,
|
|
44
43
|
ChangePasswordRequest,
|
|
45
44
|
Entry,
|
|
46
|
-
Meta,
|
|
47
45
|
Range,
|
|
48
46
|
SearchResult,
|
|
49
47
|
TreeItem,
|
|
50
48
|
)
|
|
51
49
|
from .ldap_helpers import (
|
|
52
50
|
WITH_OPERATIONAL_ATTRS,
|
|
53
|
-
|
|
51
|
+
LdapEntry,
|
|
54
52
|
anonymous_user_search,
|
|
55
53
|
empty,
|
|
56
54
|
get_entry_by_dn,
|
|
57
55
|
get_schema,
|
|
58
56
|
ldap_connect,
|
|
59
|
-
|
|
57
|
+
results,
|
|
58
|
+
unique,
|
|
60
59
|
)
|
|
61
|
-
from .schema import ObjectClass as OC
|
|
62
60
|
from .schema import Schema, frontend_schema
|
|
63
61
|
|
|
64
|
-
__all__ = ("api",)
|
|
65
|
-
|
|
66
|
-
|
|
67
62
|
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
|
|
68
63
|
|
|
69
64
|
# Special fields
|
|
@@ -74,17 +69,39 @@ PASSWORDS = ("userPassword",)
|
|
|
74
69
|
OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
|
|
75
70
|
INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
|
|
76
71
|
|
|
77
|
-
LOG = logging.getLogger("ldap-api")
|
|
78
|
-
|
|
79
72
|
api = APIRouter(prefix="/api")
|
|
80
73
|
|
|
81
74
|
|
|
75
|
+
async def get_root_dse(connection: LDAPObject):
|
|
76
|
+
"Auto-detect base DN and LDAP schema from root DSE"
|
|
77
|
+
result = await unique(
|
|
78
|
+
connection,
|
|
79
|
+
connection.search(
|
|
80
|
+
"",
|
|
81
|
+
SCOPE_BASE,
|
|
82
|
+
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
if not settings.BASE_DN:
|
|
86
|
+
base_dns = result.attr("namingContexts")
|
|
87
|
+
assert len(base_dns) == 1, f"No unique base DN: {base_dns}"
|
|
88
|
+
settings.BASE_DN = base_dns[0]
|
|
89
|
+
|
|
90
|
+
if not settings.SCHEMA_DN:
|
|
91
|
+
schema_dns = result.attr("subschemaSubentry")
|
|
92
|
+
assert schema_dns, "Cannot determine LDAP schema"
|
|
93
|
+
settings.SCHEMA_DN = schema_dns[0]
|
|
94
|
+
|
|
95
|
+
|
|
82
96
|
async def authenticated(
|
|
83
97
|
credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
|
|
84
98
|
connection: Annotated[LDAPObject, Depends(ldap_connect)],
|
|
85
99
|
) -> LDAPObject:
|
|
86
100
|
"Authenticate against the directory"
|
|
87
101
|
|
|
102
|
+
if not settings.BASE_DN or not settings.SCHEMA_DN:
|
|
103
|
+
await get_root_dse(connection)
|
|
104
|
+
|
|
88
105
|
# Hard-wired credentials
|
|
89
106
|
dn = settings.GET_BIND_DN()
|
|
90
107
|
password = settings.GET_BIND_PASSWORD()
|
|
@@ -112,118 +129,96 @@ class Tag(StrEnum):
|
|
|
112
129
|
NAVIGATION = "Navigation"
|
|
113
130
|
|
|
114
131
|
|
|
132
|
+
@api.get(
|
|
133
|
+
"/tree/base",
|
|
134
|
+
tags=[Tag.NAVIGATION],
|
|
135
|
+
operation_id="get_base_entry",
|
|
136
|
+
include_in_schema=False, # Overlaps with next endpoint
|
|
137
|
+
)
|
|
138
|
+
async def get_base_entry(connection: AuthenticatedConnection) -> list[TreeItem]:
|
|
139
|
+
"Get the directory base entry"
|
|
140
|
+
|
|
141
|
+
assert settings.BASE_DN, "An LDAP base DN is required!"
|
|
142
|
+
result = await unique(
|
|
143
|
+
connection,
|
|
144
|
+
connection.search(
|
|
145
|
+
settings.BASE_DN, SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
return [_tree_item(result, settings.BASE_DN)]
|
|
149
|
+
|
|
150
|
+
|
|
115
151
|
@api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
|
|
116
152
|
async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
|
|
117
|
-
"List directory entries"
|
|
118
|
-
|
|
119
|
-
base_level = len(basedn.split(","))
|
|
120
|
-
scope = SCOPE_ONELEVEL
|
|
121
|
-
if basedn == "base":
|
|
122
|
-
scope = SCOPE_BASE
|
|
123
|
-
assert settings.BASE_DN is not None
|
|
124
|
-
basedn = settings.BASE_DN
|
|
153
|
+
"List directory entries below a DN"
|
|
125
154
|
|
|
126
|
-
entries = result(
|
|
127
|
-
connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
|
|
128
|
-
)
|
|
129
155
|
return [
|
|
130
|
-
_tree_item(
|
|
131
|
-
async for
|
|
156
|
+
_tree_item(entry, basedn)
|
|
157
|
+
async for entry in results(
|
|
158
|
+
connection,
|
|
159
|
+
connection.search(basedn, SCOPE_ONELEVEL, attrlist=WITH_OPERATIONAL_ATTRS),
|
|
160
|
+
)
|
|
132
161
|
]
|
|
133
162
|
|
|
134
163
|
|
|
135
|
-
def _tree_item(
|
|
136
|
-
dn: str, attrs: BinaryAttributes, level: int, schema: SubSchema
|
|
137
|
-
) -> TreeItem:
|
|
138
|
-
structuralObjectClass = next(
|
|
139
|
-
iter(
|
|
140
|
-
filter(
|
|
141
|
-
lambda oc: oc.kind == OC.Kind.structural.value, # type: ignore
|
|
142
|
-
map(
|
|
143
|
-
lambda o: schema.get_obj(ObjectClass, o.decode()),
|
|
144
|
-
attrs["objectClass"],
|
|
145
|
-
),
|
|
146
|
-
)
|
|
147
|
-
)
|
|
148
|
-
)
|
|
149
|
-
|
|
164
|
+
def _tree_item(entry: LdapEntry, base_dn: str) -> TreeItem:
|
|
150
165
|
return TreeItem(
|
|
151
|
-
dn=dn,
|
|
152
|
-
structuralObjectClass=structuralObjectClass
|
|
153
|
-
hasSubordinates=
|
|
154
|
-
|
|
155
|
-
else bool(attrs.get("numSubordinates")),
|
|
156
|
-
level=len(dn.split(",")) - level,
|
|
166
|
+
dn=entry.dn,
|
|
167
|
+
structuralObjectClass=entry.attr("structuralObjectClass")[0],
|
|
168
|
+
hasSubordinates=entry.hasSubordinates,
|
|
169
|
+
level=_level(entry.dn) - _level(base_dn),
|
|
157
170
|
)
|
|
158
171
|
|
|
159
172
|
|
|
160
|
-
def
|
|
161
|
-
|
|
173
|
+
def _level(dn: str) -> int:
|
|
174
|
+
return len(dn.split(","))
|
|
162
175
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
]
|
|
171
|
-
aux = set(
|
|
172
|
-
schema.get_obj(ObjectClass, a).names[0] # type: ignore
|
|
173
|
-
for a in schema.get_applicable_aux_classes(soc[0])
|
|
176
|
+
|
|
177
|
+
@api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
|
|
178
|
+
async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
179
|
+
"Retrieve a directory entry by DN"
|
|
180
|
+
return _entry(
|
|
181
|
+
await get_entry_by_dn(connection, dn),
|
|
182
|
+
await get_schema(connection),
|
|
174
183
|
)
|
|
175
184
|
|
|
176
|
-
# 23 suppress userPassword
|
|
177
|
-
if "userPassword" in attrs:
|
|
178
|
-
attrs["userPassword"] = [b"*****"]
|
|
179
|
-
|
|
180
|
-
# Filter out binary attributes
|
|
181
|
-
binary = set()
|
|
182
|
-
for attr in attrs:
|
|
183
|
-
obj = schema.get_obj(AttributeType, attr)
|
|
184
|
-
|
|
185
|
-
# Octet strings are not used consistently.
|
|
186
|
-
# Try to decode as text and treat as binary on failure
|
|
187
|
-
if not obj.syntax or obj.syntax == OCTET_STRING: # type: ignore
|
|
188
|
-
try:
|
|
189
|
-
for val in attrs[attr]:
|
|
190
|
-
assert val.decode().isprintable()
|
|
191
|
-
except: # noqa: E722
|
|
192
|
-
binary.add(attr)
|
|
193
|
-
|
|
194
|
-
else: # Check human-readable flag in schema
|
|
195
|
-
syntax = schema.get_obj(LDAPSyntax, obj.syntax) # type: ignore
|
|
196
|
-
if syntax.not_human_readable: # type: ignore
|
|
197
|
-
binary.add(attr)
|
|
198
185
|
|
|
186
|
+
def _entry(entry: LdapEntry, schema: SubSchema) -> Entry:
|
|
187
|
+
"Decode an LDAP entry for transmission"
|
|
188
|
+
|
|
189
|
+
binary = sorted(
|
|
190
|
+
set(attr for attr in entry.attrs if _is_binary(entry, attr, schema))
|
|
191
|
+
)
|
|
199
192
|
return Entry(
|
|
200
193
|
attrs={
|
|
201
|
-
k: [
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
k: ["*****"] # 23 suppress userPassword
|
|
195
|
+
if k == "userPassword"
|
|
196
|
+
else [base64.b64encode(val).decode() for val in entry.attrs[k]]
|
|
197
|
+
if k in binary
|
|
198
|
+
else entry.attr(k)
|
|
199
|
+
for k in sorted(entry.attrs)
|
|
206
200
|
},
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
for a in must_attrs
|
|
212
|
-
],
|
|
213
|
-
aux=sorted(aux - ocs),
|
|
214
|
-
binary=sorted(binary),
|
|
215
|
-
autoFilled=[],
|
|
216
|
-
),
|
|
201
|
+
dn=entry.dn,
|
|
202
|
+
binary=binary,
|
|
203
|
+
autoFilled=[],
|
|
204
|
+
changed=[],
|
|
217
205
|
)
|
|
218
206
|
|
|
219
207
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
208
|
+
def _is_binary(entry: LdapEntry, attr: str, schema: SubSchema) -> bool:
|
|
209
|
+
"Guess whether an attribute has binary content"
|
|
210
|
+
|
|
211
|
+
# Octet strings are not used consistently in schemata.
|
|
212
|
+
# Try to decode as text and treat as binary on failure
|
|
213
|
+
attr_type = schema.get_obj(AttributeType, attr)
|
|
214
|
+
if not attr_type.syntax or attr_type.syntax == OCTET_STRING: # type: ignore
|
|
215
|
+
try:
|
|
216
|
+
return any(not val.isprintable() for val in entry.attr(attr))
|
|
217
|
+
except UnicodeDecodeError:
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
# Check human-readable flag
|
|
221
|
+
return schema.get_obj(LDAPSyntax, attr_type.syntax).not_human_readable # type: ignore
|
|
227
222
|
|
|
228
223
|
|
|
229
224
|
@api.delete(
|
|
@@ -235,8 +230,8 @@ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
|
235
230
|
async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
236
231
|
for entry_dn in sorted(
|
|
237
232
|
[
|
|
238
|
-
dn
|
|
239
|
-
async for
|
|
233
|
+
entry.dn
|
|
234
|
+
async for entry in results(
|
|
240
235
|
connection,
|
|
241
236
|
connection.search(dn, SCOPE_SUBTREE),
|
|
242
237
|
)
|
|
@@ -250,37 +245,47 @@ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
|
250
245
|
@api.post("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="post_entry")
|
|
251
246
|
async def post_entry(
|
|
252
247
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
253
|
-
) ->
|
|
254
|
-
req = {
|
|
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
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
# Get previous values from directory
|
|
248
|
+
) -> AttributeNames:
|
|
261
249
|
entry = await get_entry_by_dn(connection, dn)
|
|
262
|
-
|
|
263
|
-
modlist = modifyModlist(mods, req)
|
|
250
|
+
schema = await get_schema(connection)
|
|
264
251
|
|
|
252
|
+
expected = {
|
|
253
|
+
attr: _nonempty_byte_strings(attributes, attr)
|
|
254
|
+
for attr in attributes
|
|
255
|
+
if attr not in PASSWORDS
|
|
256
|
+
and (
|
|
257
|
+
attr not in entry.attrs
|
|
258
|
+
or not _is_binary(
|
|
259
|
+
entry, attr, schema
|
|
260
|
+
) # FIXME Handle binary attributes properly
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
actual = {attr: v for attr, v in entry.attrs.items() if attr in expected}
|
|
265
|
+
modlist = modifyModlist(actual, expected)
|
|
265
266
|
if modlist: # Apply changes and send changed keys back
|
|
266
267
|
await empty(connection, connection.modify(dn, modlist))
|
|
267
|
-
return
|
|
268
|
+
return list(sorted(set(m[1] for m in modlist)))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
|
|
272
|
+
return [s.encode() for s in filter(None, attributes[attr])]
|
|
268
273
|
|
|
269
274
|
|
|
270
275
|
@api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
|
|
271
276
|
async def put_entry(
|
|
272
277
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
273
|
-
) ->
|
|
278
|
+
) -> AttributeNames:
|
|
274
279
|
modlist = addModlist(
|
|
275
280
|
{
|
|
276
|
-
|
|
277
|
-
for
|
|
278
|
-
if
|
|
281
|
+
attr: _nonempty_byte_strings(attributes, attr)
|
|
282
|
+
for attr in attributes
|
|
283
|
+
if attr not in PHOTOS
|
|
279
284
|
}
|
|
280
285
|
)
|
|
281
286
|
if modlist:
|
|
282
287
|
await empty(connection, connection.add(dn, modlist))
|
|
283
|
-
return
|
|
288
|
+
return ["dn"] # Dummy
|
|
284
289
|
|
|
285
290
|
|
|
286
291
|
@api.post(
|
|
@@ -307,15 +312,15 @@ async def get_blob(
|
|
|
307
312
|
) -> Response:
|
|
308
313
|
"Retrieve a binary attribute"
|
|
309
314
|
|
|
310
|
-
|
|
315
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
311
316
|
|
|
312
|
-
if attr not in attrs or len(attrs[attr]) <= index:
|
|
317
|
+
if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
|
|
313
318
|
raise HTTPException(
|
|
314
|
-
HTTPStatus.NOT_FOUND
|
|
319
|
+
HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
|
|
315
320
|
)
|
|
316
321
|
|
|
317
322
|
return Response(
|
|
318
|
-
attrs[attr][index],
|
|
323
|
+
entry.attrs[attr][index],
|
|
319
324
|
media_type="application/octet-stream",
|
|
320
325
|
headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
|
|
321
326
|
)
|
|
@@ -335,12 +340,14 @@ async def put_blob(
|
|
|
335
340
|
connection: AuthenticatedConnection,
|
|
336
341
|
) -> None:
|
|
337
342
|
"Upload a binary attribute"
|
|
338
|
-
|
|
343
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
339
344
|
data = await blob.read(cast(int, blob.size))
|
|
340
|
-
if attr in attrs:
|
|
345
|
+
if attr in entry.attrs:
|
|
341
346
|
await empty(
|
|
342
347
|
connection,
|
|
343
|
-
connection.modify(
|
|
348
|
+
connection.modify(
|
|
349
|
+
dn, [(1, attr, None), (0, attr, entry.attrs[attr] + [data])]
|
|
350
|
+
),
|
|
344
351
|
)
|
|
345
352
|
else:
|
|
346
353
|
await empty(connection, connection.modify(dn, [(0, attr, [data])]))
|
|
@@ -356,13 +363,13 @@ async def delete_blob(
|
|
|
356
363
|
attr: str, index: int, dn: str, connection: AuthenticatedConnection
|
|
357
364
|
) -> None:
|
|
358
365
|
"Remove a binary attribute"
|
|
359
|
-
|
|
360
|
-
if attr not in attrs or len(attrs[attr]) <= index:
|
|
366
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
367
|
+
if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
|
|
361
368
|
raise HTTPException(
|
|
362
|
-
HTTPStatus.NOT_FOUND
|
|
369
|
+
HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
|
|
363
370
|
)
|
|
364
371
|
await empty(connection, connection.modify(dn, [(1, attr, None)]))
|
|
365
|
-
data = attrs[attr][:index] + attrs[attr][index + 1 :]
|
|
372
|
+
data = entry.attrs[attr][:index] + entry.attrs[attr][index + 1 :]
|
|
366
373
|
if data:
|
|
367
374
|
await empty(connection, connection.modify(dn, [(0, attr, data)]))
|
|
368
375
|
|
|
@@ -386,29 +393,21 @@ async def check_password(
|
|
|
386
393
|
"/change-password/{dn:path}",
|
|
387
394
|
tags=[Tag.EDITING],
|
|
388
395
|
operation_id="post_change_password",
|
|
396
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
389
397
|
)
|
|
390
398
|
async def change_password(
|
|
391
399
|
dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
|
|
392
|
-
) ->
|
|
400
|
+
) -> None:
|
|
393
401
|
"Update passwords"
|
|
394
402
|
if args.new1:
|
|
395
403
|
await empty(
|
|
396
404
|
connection,
|
|
397
405
|
connection.passwd(dn, args.old or None, args.new1),
|
|
398
406
|
)
|
|
399
|
-
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
400
|
-
attrs["userPassword"][0].decode()
|
|
401
|
-
|
|
402
407
|
else:
|
|
403
408
|
await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
|
|
404
409
|
|
|
405
410
|
|
|
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
411
|
@api.get(
|
|
413
412
|
"/ldif/{dn:path}",
|
|
414
413
|
include_in_schema=False, # Used as a link target, no API call
|
|
@@ -419,8 +418,8 @@ async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
|
|
|
419
418
|
out = io.StringIO()
|
|
420
419
|
writer = ldif.LDIFWriter(out)
|
|
421
420
|
|
|
422
|
-
async for
|
|
423
|
-
writer.unparse(dn, attrs)
|
|
421
|
+
async for entry in results(connection, connection.search(dn, SCOPE_SUBTREE)):
|
|
422
|
+
writer.unparse(dn, entry.attrs)
|
|
424
423
|
|
|
425
424
|
file_name = dn.split(",")[0].split("=")[1]
|
|
426
425
|
return PlainTextResponse(
|
|
@@ -440,18 +439,22 @@ class LDIFReader(ldif.LDIFParser):
|
|
|
440
439
|
self.count += 1
|
|
441
440
|
|
|
442
441
|
|
|
443
|
-
@api.post(
|
|
442
|
+
@api.post(
|
|
443
|
+
"/ldif",
|
|
444
|
+
tags=[Tag.EDITING],
|
|
445
|
+
operation_id="post_ldif",
|
|
446
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
447
|
+
)
|
|
444
448
|
async def upload_ldif(
|
|
445
449
|
ldif: Annotated[str, Body()], connection: AuthenticatedConnection
|
|
446
|
-
) ->
|
|
450
|
+
) -> None:
|
|
447
451
|
"Import LDIF"
|
|
448
452
|
|
|
449
453
|
reader = LDIFReader(ldif.encode(), connection)
|
|
450
454
|
try:
|
|
451
455
|
reader.parse()
|
|
452
|
-
return NO_CONTENT
|
|
453
456
|
except ValueError as e:
|
|
454
|
-
|
|
457
|
+
raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, e.args[0])
|
|
455
458
|
|
|
456
459
|
|
|
457
460
|
@api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
|
|
@@ -469,10 +472,15 @@ async def search(query: str, connection: AuthenticatedConnection) -> list[Search
|
|
|
469
472
|
|
|
470
473
|
# Collect results
|
|
471
474
|
res = []
|
|
472
|
-
async for
|
|
475
|
+
async for entry in results(
|
|
473
476
|
connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
|
|
474
477
|
):
|
|
475
|
-
res.append(
|
|
478
|
+
res.append(
|
|
479
|
+
SearchResult(
|
|
480
|
+
dn=entry.dn,
|
|
481
|
+
name=entry.attr("cn")[0] if "cn" in entry.attrs else entry.dn,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
476
484
|
if len(res) >= settings.SEARCH_MAX:
|
|
477
485
|
break
|
|
478
486
|
return res
|
|
@@ -490,15 +498,16 @@ async def list_subtree(
|
|
|
490
498
|
) -> list[TreeItem]:
|
|
491
499
|
"List the subtree below a DN"
|
|
492
500
|
|
|
493
|
-
start = len(root_dn.split(","))
|
|
494
501
|
return sorted(
|
|
495
502
|
[
|
|
496
|
-
_tree_item(
|
|
497
|
-
async for
|
|
503
|
+
_tree_item(entry, root_dn)
|
|
504
|
+
async for entry in results(
|
|
498
505
|
connection,
|
|
499
|
-
connection.search(
|
|
506
|
+
connection.search(
|
|
507
|
+
root_dn, SCOPE_SUBTREE, attrlist=WITH_OPERATIONAL_ATTRS
|
|
508
|
+
),
|
|
500
509
|
)
|
|
501
|
-
if root_dn != dn
|
|
510
|
+
if root_dn != entry.dn
|
|
502
511
|
],
|
|
503
512
|
key=lambda item: tuple(reversed(item.dn.lower().split(","))),
|
|
504
513
|
)
|
|
@@ -513,8 +522,8 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
513
522
|
|
|
514
523
|
values = set(
|
|
515
524
|
[
|
|
516
|
-
int(attrs[attribute][0])
|
|
517
|
-
async for
|
|
525
|
+
int(entry.attrs[attribute][0])
|
|
526
|
+
async for entry in results(
|
|
518
527
|
connection,
|
|
519
528
|
connection.search(
|
|
520
529
|
settings.BASE_DN,
|
|
@@ -529,7 +538,7 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
529
538
|
|
|
530
539
|
if not values:
|
|
531
540
|
raise HTTPException(
|
|
532
|
-
HTTPStatus.NOT_FOUND
|
|
541
|
+
HTTPStatus.NOT_FOUND, f"No values found for attribute {attribute}"
|
|
533
542
|
)
|
|
534
543
|
|
|
535
544
|
minimum, maximum = min(values), max(values)
|
|
@@ -549,4 +558,5 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
549
558
|
)
|
|
550
559
|
async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
|
|
551
560
|
"Dump the LDAP schema as JSON"
|
|
561
|
+
assert settings.SCHEMA_DN, "An LDAP schema DN is required!"
|
|
552
562
|
return frontend_schema(await get_schema(connection))
|
ldap_ui/ldap_helpers.py
CHANGED
|
@@ -10,6 +10,7 @@ like retrieving a unique result or waiting for an
|
|
|
10
10
|
operation to complete without results.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
from dataclasses import dataclass
|
|
13
14
|
from http import HTTPStatus
|
|
14
15
|
from typing import AsyncGenerator, Generator
|
|
15
16
|
|
|
@@ -32,7 +33,23 @@ from . import settings
|
|
|
32
33
|
# Constant to add technical attributes in LDAP search results
|
|
33
34
|
WITH_OPERATIONAL_ATTRS = ("*", "+")
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LdapEntry:
|
|
39
|
+
dn: str
|
|
40
|
+
attrs: dict[str, list[bytes]]
|
|
41
|
+
|
|
42
|
+
def attr(self, name: str) -> list[str]:
|
|
43
|
+
return [v.decode() for v in self.attrs[name]]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def hasSubordinates(self):
|
|
47
|
+
return (
|
|
48
|
+
self.attr("hasSubordinates") == ["TRUE"]
|
|
49
|
+
if "hasSubordinates" in self.attrs
|
|
50
|
+
else bool(self.attrs.get("numSubordinates"))
|
|
51
|
+
)
|
|
52
|
+
|
|
36
53
|
|
|
37
54
|
sub_schema: SubSchema | None = None
|
|
38
55
|
|
|
@@ -58,24 +75,24 @@ def ldap_connect() -> Generator[LDAPObject, None, None]:
|
|
|
58
75
|
|
|
59
76
|
async def anonymous_user_search(connection: LDAPObject, username: str) -> str | None:
|
|
60
77
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
return (
|
|
79
|
+
await unique(
|
|
80
|
+
connection,
|
|
81
|
+
connection.search(
|
|
82
|
+
settings.BASE_DN,
|
|
83
|
+
SCOPE_SUBTREE,
|
|
84
|
+
settings.GET_BIND_DN_FILTER(username),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
).dn
|
|
71
88
|
|
|
72
89
|
except HTTPException:
|
|
73
90
|
pass # No unique result
|
|
74
91
|
|
|
75
92
|
|
|
76
|
-
async def
|
|
93
|
+
async def results(
|
|
77
94
|
connection: LDAPObject, msgid: int
|
|
78
|
-
) -> AsyncGenerator[
|
|
95
|
+
) -> AsyncGenerator[LdapEntry, None]:
|
|
79
96
|
"Stream LDAP result entries without blocking other tasks"
|
|
80
97
|
|
|
81
98
|
while True:
|
|
@@ -85,27 +102,27 @@ async def result(
|
|
|
85
102
|
elif r_data == []: # Operation completed
|
|
86
103
|
break
|
|
87
104
|
else:
|
|
88
|
-
yield r_data[0] # type: ignore
|
|
105
|
+
yield LdapEntry(*r_data[0]) # type: ignore
|
|
89
106
|
|
|
90
107
|
|
|
91
108
|
async def unique(
|
|
92
109
|
connection: LDAPObject,
|
|
93
110
|
msgid: int,
|
|
94
|
-
) ->
|
|
111
|
+
) -> LdapEntry:
|
|
95
112
|
"Asynchronously collect a unique result"
|
|
96
113
|
|
|
97
114
|
res = None
|
|
98
|
-
async for r in
|
|
115
|
+
async for r in results(connection, msgid):
|
|
99
116
|
if res is None:
|
|
100
117
|
res = r
|
|
101
118
|
else:
|
|
102
119
|
connection.abandon(msgid)
|
|
103
120
|
raise HTTPException(
|
|
104
|
-
HTTPStatus.INTERNAL_SERVER_ERROR
|
|
121
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
105
122
|
"Non-unique result",
|
|
106
123
|
)
|
|
107
124
|
if res is None:
|
|
108
|
-
raise HTTPException(HTTPStatus.NOT_FOUND
|
|
125
|
+
raise HTTPException(HTTPStatus.NOT_FOUND, "Empty search result")
|
|
109
126
|
return res
|
|
110
127
|
|
|
111
128
|
|
|
@@ -115,15 +132,15 @@ async def empty(
|
|
|
115
132
|
) -> None:
|
|
116
133
|
"Asynchronously wait for an empty result"
|
|
117
134
|
|
|
118
|
-
async for r in
|
|
135
|
+
async for r in results(connection, msgid):
|
|
119
136
|
connection.abandon(msgid)
|
|
120
|
-
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR
|
|
137
|
+
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected result")
|
|
121
138
|
|
|
122
139
|
|
|
123
140
|
async def get_entry_by_dn(
|
|
124
141
|
connection: LDAPObject,
|
|
125
142
|
dn: str,
|
|
126
|
-
) ->
|
|
143
|
+
) -> LdapEntry:
|
|
127
144
|
"Asynchronously retrieve an LDAP entry by its DN"
|
|
128
145
|
|
|
129
146
|
return await unique(connection, connection.search(dn, SCOPE_BASE))
|
|
@@ -133,7 +150,7 @@ async def get_schema(connection: LDAPObject) -> SubSchema:
|
|
|
133
150
|
global sub_schema
|
|
134
151
|
# See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
|
|
135
152
|
if sub_schema is None:
|
|
136
|
-
|
|
153
|
+
result = await unique(
|
|
137
154
|
connection,
|
|
138
155
|
connection.search(
|
|
139
156
|
settings.SCHEMA_DN,
|
|
@@ -141,5 +158,5 @@ async def get_schema(connection: LDAPObject) -> SubSchema:
|
|
|
141
158
|
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
142
159
|
),
|
|
143
160
|
)
|
|
144
|
-
sub_schema = SubSchema(
|
|
161
|
+
sub_schema = SubSchema(result.attrs, check_uniqueness=2)
|
|
145
162
|
return sub_schema
|