ldap-ui 0.10.0__py3-none-any.whl → 0.10.1__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/ldap_api.py +185 -151
- ldap_ui/ldap_helpers.py +40 -23
- ldap_ui/schema.py +0 -2
- ldap_ui/statics/assets/index-BxCLA1wZ.js +19 -0
- ldap_ui/statics/assets/index-BxCLA1wZ.js.gz +0 -0
- ldap_ui/statics/assets/{index-4LadlPh6.css → index-CusJ2HRh.css} +1 -1
- ldap_ui/statics/assets/index-CusJ2HRh.css.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.1.dist-info}/METADATA +1 -1
- ldap_ui-0.10.1.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.1.dist-info}/WHEEL +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.1.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.1.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.10.0.dist-info → ldap_ui-0.10.1.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
|
|
@@ -50,20 +49,18 @@ from .entities import (
|
|
|
50
49
|
)
|
|
51
50
|
from .ldap_helpers import (
|
|
52
51
|
WITH_OPERATIONAL_ATTRS,
|
|
53
|
-
|
|
52
|
+
LdapEntry,
|
|
54
53
|
anonymous_user_search,
|
|
55
54
|
empty,
|
|
56
55
|
get_entry_by_dn,
|
|
57
56
|
get_schema,
|
|
58
57
|
ldap_connect,
|
|
59
|
-
|
|
58
|
+
results,
|
|
59
|
+
unique,
|
|
60
60
|
)
|
|
61
61
|
from .schema import ObjectClass as OC
|
|
62
62
|
from .schema import Schema, frontend_schema
|
|
63
63
|
|
|
64
|
-
__all__ = ("api",)
|
|
65
|
-
|
|
66
|
-
|
|
67
64
|
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
|
|
68
65
|
|
|
69
66
|
# Special fields
|
|
@@ -74,17 +71,39 @@ PASSWORDS = ("userPassword",)
|
|
|
74
71
|
OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
|
|
75
72
|
INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
|
|
76
73
|
|
|
77
|
-
LOG = logging.getLogger("ldap-api")
|
|
78
|
-
|
|
79
74
|
api = APIRouter(prefix="/api")
|
|
80
75
|
|
|
81
76
|
|
|
77
|
+
async def get_root_dse(connection: LDAPObject):
|
|
78
|
+
"Auto-detect base DN and LDAP schema from root DSE"
|
|
79
|
+
result = await unique(
|
|
80
|
+
connection,
|
|
81
|
+
connection.search(
|
|
82
|
+
"",
|
|
83
|
+
SCOPE_BASE,
|
|
84
|
+
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
if not settings.BASE_DN:
|
|
88
|
+
base_dns = result.attr("namingContexts")
|
|
89
|
+
assert len(base_dns) == 1, f"No unique base DN: {base_dns}"
|
|
90
|
+
settings.BASE_DN = base_dns[0]
|
|
91
|
+
|
|
92
|
+
if not settings.SCHEMA_DN:
|
|
93
|
+
schema_dns = result.attr("subschemaSubentry")
|
|
94
|
+
assert schema_dns, "Cannot determine LDAP schema"
|
|
95
|
+
settings.SCHEMA_DN = schema_dns[0]
|
|
96
|
+
|
|
97
|
+
|
|
82
98
|
async def authenticated(
|
|
83
99
|
credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
|
|
84
100
|
connection: Annotated[LDAPObject, Depends(ldap_connect)],
|
|
85
101
|
) -> LDAPObject:
|
|
86
102
|
"Authenticate against the directory"
|
|
87
103
|
|
|
104
|
+
if not settings.BASE_DN or not settings.SCHEMA_DN:
|
|
105
|
+
await get_root_dse(connection)
|
|
106
|
+
|
|
88
107
|
# Hard-wired credentials
|
|
89
108
|
dn = settings.GET_BIND_DN()
|
|
90
109
|
password = settings.GET_BIND_PASSWORD()
|
|
@@ -112,118 +131,121 @@ class Tag(StrEnum):
|
|
|
112
131
|
NAVIGATION = "Navigation"
|
|
113
132
|
|
|
114
133
|
|
|
134
|
+
@api.get(
|
|
135
|
+
"/tree/base",
|
|
136
|
+
tags=[Tag.NAVIGATION],
|
|
137
|
+
operation_id="get_base_entry",
|
|
138
|
+
include_in_schema=False, # Overlaps with next endpoint
|
|
139
|
+
)
|
|
140
|
+
async def get_base_entry(connection: AuthenticatedConnection) -> list[TreeItem]:
|
|
141
|
+
"Get the directory base entry"
|
|
142
|
+
|
|
143
|
+
assert settings.BASE_DN, "An LDAP base DN is required!"
|
|
144
|
+
result = await unique(
|
|
145
|
+
connection,
|
|
146
|
+
connection.search(
|
|
147
|
+
settings.BASE_DN, SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
return [_tree_item(result, settings.BASE_DN)]
|
|
151
|
+
|
|
152
|
+
|
|
115
153
|
@api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
|
|
116
154
|
async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
|
|
117
|
-
"List directory entries"
|
|
155
|
+
"List directory entries below a DN"
|
|
118
156
|
|
|
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
|
|
125
|
-
|
|
126
|
-
entries = result(
|
|
127
|
-
connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
|
|
128
|
-
)
|
|
129
157
|
return [
|
|
130
|
-
_tree_item(
|
|
131
|
-
async for
|
|
158
|
+
_tree_item(entry, basedn)
|
|
159
|
+
async for entry in results(
|
|
160
|
+
connection,
|
|
161
|
+
connection.search(basedn, SCOPE_ONELEVEL, attrlist=WITH_OPERATIONAL_ATTRS),
|
|
162
|
+
)
|
|
132
163
|
]
|
|
133
164
|
|
|
134
165
|
|
|
135
|
-
def _tree_item(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
)
|
|
166
|
+
def _tree_item(entry: LdapEntry, base_dn: str) -> TreeItem:
|
|
167
|
+
return TreeItem(
|
|
168
|
+
dn=entry.dn,
|
|
169
|
+
structuralObjectClass=entry.attr("structuralObjectClass")[0],
|
|
170
|
+
hasSubordinates=entry.hasSubordinates,
|
|
171
|
+
level=_level(entry.dn) - _level(base_dn),
|
|
148
172
|
)
|
|
149
173
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
|
|
175
|
+
def _level(dn: str) -> int:
|
|
176
|
+
return len(dn.split(","))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
|
|
180
|
+
async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
181
|
+
"Retrieve a directory entry by DN"
|
|
182
|
+
return _entry(
|
|
183
|
+
await get_entry_by_dn(connection, dn),
|
|
184
|
+
await get_schema(connection),
|
|
157
185
|
)
|
|
158
186
|
|
|
159
187
|
|
|
160
|
-
def _entry(
|
|
161
|
-
"
|
|
188
|
+
def _entry(entry: LdapEntry, schema: SubSchema) -> Entry:
|
|
189
|
+
"Decode an LDAP entry for transmission"
|
|
162
190
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
191
|
+
meta = _meta(entry, schema)
|
|
192
|
+
attrs = {
|
|
193
|
+
k: ["*****"] # 23 suppress userPassword
|
|
194
|
+
if k == "userPassword"
|
|
195
|
+
else [base64.b64encode(val).decode() for val in entry.attrs[k]]
|
|
196
|
+
if k in meta.binary
|
|
197
|
+
else entry.attr(k)
|
|
198
|
+
for k in sorted(entry.attrs)
|
|
199
|
+
}
|
|
200
|
+
return Entry(attrs=attrs, meta=meta)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _meta(entry: LdapEntry, schema: SubSchema) -> Meta:
|
|
204
|
+
"Classify entry attributes"
|
|
205
|
+
|
|
206
|
+
object_classes = set(entry.attr("objectClass"))
|
|
207
|
+
must_attrs, _may_attrs = schema.attribute_types(object_classes)
|
|
208
|
+
required = [
|
|
209
|
+
schema.get_obj(AttributeType, a).names[0] # type: ignore
|
|
210
|
+
for a in must_attrs
|
|
211
|
+
]
|
|
212
|
+
structural = [
|
|
167
213
|
oc.names[0] # type: ignore
|
|
168
|
-
for oc in map(lambda o: schema.get_obj(ObjectClass, o),
|
|
169
|
-
if oc.kind == OC.Kind.structural
|
|
214
|
+
for oc in map(lambda o: schema.get_obj(ObjectClass, o), object_classes)
|
|
215
|
+
if oc.kind == OC.Kind.structural # type: ignore
|
|
170
216
|
]
|
|
171
217
|
aux = set(
|
|
172
218
|
schema.get_obj(ObjectClass, a).names[0] # type: ignore
|
|
173
|
-
for a in schema.get_applicable_aux_classes(
|
|
219
|
+
for a in schema.get_applicable_aux_classes(structural[0])
|
|
174
220
|
)
|
|
175
221
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
199
|
-
return Entry(
|
|
200
|
-
attrs={
|
|
201
|
-
k: [
|
|
202
|
-
base64.b64encode(val).decode() if k in binary else val.decode()
|
|
203
|
-
for val in values
|
|
204
|
-
]
|
|
205
|
-
for k, values in attrs.items()
|
|
206
|
-
},
|
|
207
|
-
meta=Meta(
|
|
208
|
-
dn=dn,
|
|
209
|
-
required=[
|
|
210
|
-
schema.get_obj(AttributeType, a).names[0] # type: ignore
|
|
211
|
-
for a in must_attrs
|
|
212
|
-
],
|
|
213
|
-
aux=sorted(aux - ocs),
|
|
214
|
-
binary=sorted(binary),
|
|
215
|
-
autoFilled=[],
|
|
216
|
-
),
|
|
222
|
+
return Meta(
|
|
223
|
+
dn=entry.dn,
|
|
224
|
+
required=required,
|
|
225
|
+
aux=sorted(aux - object_classes),
|
|
226
|
+
binary=sorted(_binary_attributes(entry, schema)),
|
|
227
|
+
autoFilled=[],
|
|
217
228
|
)
|
|
218
229
|
|
|
219
230
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
def _binary_attributes(entry: LdapEntry, schema: SubSchema) -> set[str]:
|
|
232
|
+
return set(attr for attr in entry.attrs if _is_binary(entry, attr, schema))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_binary(entry: LdapEntry, attr: str, schema: SubSchema) -> bool:
|
|
236
|
+
"Guess whether an attribute has binary content"
|
|
237
|
+
|
|
238
|
+
# Octet strings are not used consistently in schemata.
|
|
239
|
+
# Try to decode as text and treat as binary on failure
|
|
240
|
+
attr_type = schema.get_obj(AttributeType, attr)
|
|
241
|
+
if not attr_type.syntax or attr_type.syntax == OCTET_STRING: # type: ignore
|
|
242
|
+
try:
|
|
243
|
+
return any(not val.isprintable() for val in entry.attr(attr))
|
|
244
|
+
except UnicodeDecodeError:
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
# Check human-readable flag
|
|
248
|
+
return schema.get_obj(LDAPSyntax, attr_type.syntax).not_human_readable # type: ignore
|
|
227
249
|
|
|
228
250
|
|
|
229
251
|
@api.delete(
|
|
@@ -235,8 +257,8 @@ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
|
235
257
|
async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
236
258
|
for entry_dn in sorted(
|
|
237
259
|
[
|
|
238
|
-
dn
|
|
239
|
-
async for
|
|
260
|
+
entry.dn
|
|
261
|
+
async for entry in results(
|
|
240
262
|
connection,
|
|
241
263
|
connection.search(dn, SCOPE_SUBTREE),
|
|
242
264
|
)
|
|
@@ -251,31 +273,38 @@ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
|
251
273
|
async def post_entry(
|
|
252
274
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
253
275
|
) -> ChangedAttributes:
|
|
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
|
|
261
276
|
entry = await get_entry_by_dn(connection, dn)
|
|
262
|
-
|
|
263
|
-
modlist = modifyModlist(mods, req)
|
|
277
|
+
schema = await get_schema(connection)
|
|
264
278
|
|
|
279
|
+
expected = {
|
|
280
|
+
attr: _nonempty_byte_strings(attributes, attr)
|
|
281
|
+
for attr in attributes
|
|
282
|
+
if attr not in PASSWORDS
|
|
283
|
+
and not _is_binary(
|
|
284
|
+
entry, attr, schema
|
|
285
|
+
) # FIXME Handle binary attributes properly
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
actual = {attr: v for attr, v in entry.attrs.items() if attr in expected}
|
|
289
|
+
modlist = modifyModlist(actual, expected)
|
|
265
290
|
if modlist: # Apply changes and send changed keys back
|
|
266
291
|
await empty(connection, connection.modify(dn, modlist))
|
|
267
292
|
return ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
|
|
268
293
|
|
|
269
294
|
|
|
295
|
+
def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
|
|
296
|
+
return [s.encode() for s in filter(None, attributes[attr])]
|
|
297
|
+
|
|
298
|
+
|
|
270
299
|
@api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
|
|
271
300
|
async def put_entry(
|
|
272
301
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
273
302
|
) -> ChangedAttributes:
|
|
274
303
|
modlist = addModlist(
|
|
275
304
|
{
|
|
276
|
-
|
|
277
|
-
for
|
|
278
|
-
if
|
|
305
|
+
attr: _nonempty_byte_strings(attributes, attr)
|
|
306
|
+
for attr in attributes
|
|
307
|
+
if attr not in PHOTOS
|
|
279
308
|
}
|
|
280
309
|
)
|
|
281
310
|
if modlist:
|
|
@@ -307,15 +336,15 @@ async def get_blob(
|
|
|
307
336
|
) -> Response:
|
|
308
337
|
"Retrieve a binary attribute"
|
|
309
338
|
|
|
310
|
-
|
|
339
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
311
340
|
|
|
312
|
-
if attr not in attrs or len(attrs[attr]) <= index:
|
|
341
|
+
if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
|
|
313
342
|
raise HTTPException(
|
|
314
|
-
HTTPStatus.NOT_FOUND
|
|
343
|
+
HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
|
|
315
344
|
)
|
|
316
345
|
|
|
317
346
|
return Response(
|
|
318
|
-
attrs[attr][index],
|
|
347
|
+
entry.attrs[attr][index],
|
|
319
348
|
media_type="application/octet-stream",
|
|
320
349
|
headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
|
|
321
350
|
)
|
|
@@ -335,12 +364,14 @@ async def put_blob(
|
|
|
335
364
|
connection: AuthenticatedConnection,
|
|
336
365
|
) -> None:
|
|
337
366
|
"Upload a binary attribute"
|
|
338
|
-
|
|
367
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
339
368
|
data = await blob.read(cast(int, blob.size))
|
|
340
|
-
if attr in attrs:
|
|
369
|
+
if attr in entry.attrs:
|
|
341
370
|
await empty(
|
|
342
371
|
connection,
|
|
343
|
-
connection.modify(
|
|
372
|
+
connection.modify(
|
|
373
|
+
dn, [(1, attr, None), (0, attr, entry.attrs[attr] + [data])]
|
|
374
|
+
),
|
|
344
375
|
)
|
|
345
376
|
else:
|
|
346
377
|
await empty(connection, connection.modify(dn, [(0, attr, [data])]))
|
|
@@ -356,13 +387,13 @@ async def delete_blob(
|
|
|
356
387
|
attr: str, index: int, dn: str, connection: AuthenticatedConnection
|
|
357
388
|
) -> None:
|
|
358
389
|
"Remove a binary attribute"
|
|
359
|
-
|
|
360
|
-
if attr not in attrs or len(attrs[attr]) <= index:
|
|
390
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
391
|
+
if attr not in entry.attrs or len(entry.attrs[attr]) <= index:
|
|
361
392
|
raise HTTPException(
|
|
362
|
-
HTTPStatus.NOT_FOUND
|
|
393
|
+
HTTPStatus.NOT_FOUND, f"Attribute {attr} not found for DN {dn}"
|
|
363
394
|
)
|
|
364
395
|
await empty(connection, connection.modify(dn, [(1, attr, None)]))
|
|
365
|
-
data = attrs[attr][:index] + attrs[attr][index + 1 :]
|
|
396
|
+
data = entry.attrs[attr][:index] + entry.attrs[attr][index + 1 :]
|
|
366
397
|
if data:
|
|
367
398
|
await empty(connection, connection.modify(dn, [(0, attr, data)]))
|
|
368
399
|
|
|
@@ -386,29 +417,21 @@ async def check_password(
|
|
|
386
417
|
"/change-password/{dn:path}",
|
|
387
418
|
tags=[Tag.EDITING],
|
|
388
419
|
operation_id="post_change_password",
|
|
420
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
389
421
|
)
|
|
390
422
|
async def change_password(
|
|
391
423
|
dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
|
|
392
|
-
) ->
|
|
424
|
+
) -> None:
|
|
393
425
|
"Update passwords"
|
|
394
426
|
if args.new1:
|
|
395
427
|
await empty(
|
|
396
428
|
connection,
|
|
397
429
|
connection.passwd(dn, args.old or None, args.new1),
|
|
398
430
|
)
|
|
399
|
-
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
400
|
-
attrs["userPassword"][0].decode()
|
|
401
|
-
|
|
402
431
|
else:
|
|
403
432
|
await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
|
|
404
433
|
|
|
405
434
|
|
|
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
435
|
@api.get(
|
|
413
436
|
"/ldif/{dn:path}",
|
|
414
437
|
include_in_schema=False, # Used as a link target, no API call
|
|
@@ -419,8 +442,8 @@ async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
|
|
|
419
442
|
out = io.StringIO()
|
|
420
443
|
writer = ldif.LDIFWriter(out)
|
|
421
444
|
|
|
422
|
-
async for
|
|
423
|
-
writer.unparse(dn, attrs)
|
|
445
|
+
async for entry in results(connection, connection.search(dn, SCOPE_SUBTREE)):
|
|
446
|
+
writer.unparse(dn, entry.attrs)
|
|
424
447
|
|
|
425
448
|
file_name = dn.split(",")[0].split("=")[1]
|
|
426
449
|
return PlainTextResponse(
|
|
@@ -440,18 +463,22 @@ class LDIFReader(ldif.LDIFParser):
|
|
|
440
463
|
self.count += 1
|
|
441
464
|
|
|
442
465
|
|
|
443
|
-
@api.post(
|
|
466
|
+
@api.post(
|
|
467
|
+
"/ldif",
|
|
468
|
+
tags=[Tag.EDITING],
|
|
469
|
+
operation_id="post_ldif",
|
|
470
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
471
|
+
)
|
|
444
472
|
async def upload_ldif(
|
|
445
473
|
ldif: Annotated[str, Body()], connection: AuthenticatedConnection
|
|
446
|
-
) ->
|
|
474
|
+
) -> None:
|
|
447
475
|
"Import LDIF"
|
|
448
476
|
|
|
449
477
|
reader = LDIFReader(ldif.encode(), connection)
|
|
450
478
|
try:
|
|
451
479
|
reader.parse()
|
|
452
|
-
return NO_CONTENT
|
|
453
480
|
except ValueError as e:
|
|
454
|
-
|
|
481
|
+
raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, e.args[0])
|
|
455
482
|
|
|
456
483
|
|
|
457
484
|
@api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
|
|
@@ -469,10 +496,15 @@ async def search(query: str, connection: AuthenticatedConnection) -> list[Search
|
|
|
469
496
|
|
|
470
497
|
# Collect results
|
|
471
498
|
res = []
|
|
472
|
-
async for
|
|
499
|
+
async for entry in results(
|
|
473
500
|
connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
|
|
474
501
|
):
|
|
475
|
-
res.append(
|
|
502
|
+
res.append(
|
|
503
|
+
SearchResult(
|
|
504
|
+
dn=entry.dn,
|
|
505
|
+
name=entry.attr("cn")[0] if "cn" in entry.attrs else entry.dn,
|
|
506
|
+
)
|
|
507
|
+
)
|
|
476
508
|
if len(res) >= settings.SEARCH_MAX:
|
|
477
509
|
break
|
|
478
510
|
return res
|
|
@@ -490,15 +522,16 @@ async def list_subtree(
|
|
|
490
522
|
) -> list[TreeItem]:
|
|
491
523
|
"List the subtree below a DN"
|
|
492
524
|
|
|
493
|
-
start = len(root_dn.split(","))
|
|
494
525
|
return sorted(
|
|
495
526
|
[
|
|
496
|
-
_tree_item(
|
|
497
|
-
async for
|
|
527
|
+
_tree_item(entry, root_dn)
|
|
528
|
+
async for entry in results(
|
|
498
529
|
connection,
|
|
499
|
-
connection.search(
|
|
530
|
+
connection.search(
|
|
531
|
+
root_dn, SCOPE_SUBTREE, attrlist=WITH_OPERATIONAL_ATTRS
|
|
532
|
+
),
|
|
500
533
|
)
|
|
501
|
-
if root_dn != dn
|
|
534
|
+
if root_dn != entry.dn
|
|
502
535
|
],
|
|
503
536
|
key=lambda item: tuple(reversed(item.dn.lower().split(","))),
|
|
504
537
|
)
|
|
@@ -513,8 +546,8 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
513
546
|
|
|
514
547
|
values = set(
|
|
515
548
|
[
|
|
516
|
-
int(attrs[attribute][0])
|
|
517
|
-
async for
|
|
549
|
+
int(entry.attrs[attribute][0])
|
|
550
|
+
async for entry in results(
|
|
518
551
|
connection,
|
|
519
552
|
connection.search(
|
|
520
553
|
settings.BASE_DN,
|
|
@@ -529,7 +562,7 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
529
562
|
|
|
530
563
|
if not values:
|
|
531
564
|
raise HTTPException(
|
|
532
|
-
HTTPStatus.NOT_FOUND
|
|
565
|
+
HTTPStatus.NOT_FOUND, f"No values found for attribute {attribute}"
|
|
533
566
|
)
|
|
534
567
|
|
|
535
568
|
minimum, maximum = min(values), max(values)
|
|
@@ -549,4 +582,5 @@ async def attribute_range(attribute: str, connection: AuthenticatedConnection) -
|
|
|
549
582
|
)
|
|
550
583
|
async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
|
|
551
584
|
"Dump the LDAP schema as JSON"
|
|
585
|
+
assert settings.SCHEMA_DN, "An LDAP schema DN is required!"
|
|
552
586
|
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
|
ldap_ui/schema.py
CHANGED
|
@@ -15,8 +15,6 @@ 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
|
-
__all__ = ("frontend_schema", "Attribute", "ObjectClass")
|
|
19
|
-
|
|
20
18
|
T = TypeVar("T")
|
|
21
19
|
|
|
22
20
|
|