ldap-ui 0.9.15__py3-none-any.whl → 0.10.0__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 +84 -191
- ldap_ui/entities.py +62 -0
- ldap_ui/ldap_api.py +309 -276
- ldap_ui/ldap_helpers.py +51 -28
- ldap_ui/schema.py +8 -8
- ldap_ui/statics/assets/{index-qocMa2qY.css → index-4LadlPh6.css} +1 -1
- ldap_ui/statics/assets/index-4LadlPh6.css.gz +0 -0
- ldap_ui/statics/assets/index-CVDJWmXN.js +19 -0
- ldap_ui/statics/assets/index-CVDJWmXN.js.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/METADATA +8 -9
- ldap_ui-0.10.0.dist-info/RECORD +25 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/WHEEL +1 -1
- ldap_ui/statics/assets/index-CZWuB-hf.js +0 -18
- ldap_ui/statics/assets/index-CZWuB-hf.js.gz +0 -0
- ldap_ui/statics/assets/index-qocMa2qY.css.gz +0 -0
- ldap_ui-0.9.15.dist-info/RECORD +0 -24
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/top_level.txt +0 -0
ldap_ui/ldap_api.py
CHANGED
|
@@ -9,43 +9,62 @@ Asynchronous LDAP operations are used as much as possible.
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import io
|
|
12
|
+
import logging
|
|
13
|
+
from enum import StrEnum
|
|
12
14
|
from http import HTTPStatus
|
|
13
|
-
from typing import
|
|
15
|
+
from typing import Annotated, cast
|
|
14
16
|
|
|
15
17
|
import ldif
|
|
18
|
+
from fastapi import (
|
|
19
|
+
APIRouter,
|
|
20
|
+
Body,
|
|
21
|
+
Depends,
|
|
22
|
+
File,
|
|
23
|
+
HTTPException,
|
|
24
|
+
Response,
|
|
25
|
+
UploadFile,
|
|
26
|
+
)
|
|
27
|
+
from fastapi.responses import PlainTextResponse
|
|
28
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
16
29
|
from ldap import (
|
|
17
|
-
INVALID_CREDENTIALS, #
|
|
18
|
-
SCOPE_BASE, #
|
|
19
|
-
SCOPE_ONELEVEL, #
|
|
20
|
-
SCOPE_SUBTREE, #
|
|
30
|
+
INVALID_CREDENTIALS, # type: ignore
|
|
31
|
+
SCOPE_BASE, # type: ignore
|
|
32
|
+
SCOPE_ONELEVEL, # type: ignore
|
|
33
|
+
SCOPE_SUBTREE, # type: ignore
|
|
21
34
|
)
|
|
22
35
|
from ldap.ldapobject import LDAPObject
|
|
23
36
|
from ldap.modlist import addModlist, modifyModlist
|
|
24
37
|
from ldap.schema import SubSchema
|
|
25
38
|
from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
|
|
26
|
-
from pydantic import BaseModel, Field, TypeAdapter
|
|
27
|
-
from starlette.datastructures import UploadFile
|
|
28
|
-
from starlette.exceptions import HTTPException
|
|
29
|
-
from starlette.requests import Request
|
|
30
|
-
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
|
31
|
-
from starlette.routing import Router
|
|
32
39
|
|
|
33
40
|
from . import settings
|
|
41
|
+
from .entities import (
|
|
42
|
+
Attributes,
|
|
43
|
+
ChangedAttributes,
|
|
44
|
+
ChangePasswordRequest,
|
|
45
|
+
Entry,
|
|
46
|
+
Meta,
|
|
47
|
+
Range,
|
|
48
|
+
SearchResult,
|
|
49
|
+
TreeItem,
|
|
50
|
+
)
|
|
34
51
|
from .ldap_helpers import (
|
|
35
52
|
WITH_OPERATIONAL_ATTRS,
|
|
53
|
+
BinaryAttributes,
|
|
54
|
+
anonymous_user_search,
|
|
36
55
|
empty,
|
|
37
56
|
get_entry_by_dn,
|
|
57
|
+
get_schema,
|
|
38
58
|
ldap_connect,
|
|
39
59
|
result,
|
|
40
|
-
unique,
|
|
41
60
|
)
|
|
42
61
|
from .schema import ObjectClass as OC
|
|
43
|
-
from .schema import frontend_schema
|
|
62
|
+
from .schema import Schema, frontend_schema
|
|
44
63
|
|
|
45
64
|
__all__ = ("api",)
|
|
46
65
|
|
|
47
66
|
|
|
48
|
-
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT
|
|
67
|
+
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
|
|
49
68
|
|
|
50
69
|
# Special fields
|
|
51
70
|
PHOTOS = ("jpegPhoto", "thumbnailPhoto")
|
|
@@ -55,53 +74,71 @@ PASSWORDS = ("userPassword",)
|
|
|
55
74
|
OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
|
|
56
75
|
INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
|
|
57
76
|
|
|
58
|
-
|
|
59
|
-
api = Router()
|
|
77
|
+
LOG = logging.getLogger("ldap-api")
|
|
60
78
|
|
|
79
|
+
api = APIRouter(prefix="/api")
|
|
61
80
|
|
|
62
|
-
|
|
63
|
-
async def
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
|
|
82
|
+
async def authenticated(
|
|
83
|
+
credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
|
|
84
|
+
connection: Annotated[LDAPObject, Depends(ldap_connect)],
|
|
85
|
+
) -> LDAPObject:
|
|
86
|
+
"Authenticate against the directory"
|
|
87
|
+
|
|
88
|
+
# Hard-wired credentials
|
|
89
|
+
dn = settings.GET_BIND_DN()
|
|
90
|
+
password = settings.GET_BIND_PASSWORD()
|
|
91
|
+
|
|
92
|
+
# Search for basic auth user
|
|
93
|
+
if not dn:
|
|
94
|
+
password = credentials.password
|
|
95
|
+
dn = settings.GET_BIND_PATTERN(
|
|
96
|
+
credentials.username
|
|
97
|
+
) or await anonymous_user_search(connection, credentials.username)
|
|
98
|
+
|
|
99
|
+
if dn: # Log in
|
|
100
|
+
await empty(connection, connection.simple_bind(dn, password))
|
|
101
|
+
return connection
|
|
102
|
+
|
|
103
|
+
raise INVALID_CREDENTIALS([{"desc": f"Invalid credentials for DN: {dn}"}])
|
|
66
104
|
|
|
67
105
|
|
|
68
|
-
|
|
69
|
-
dn: str
|
|
70
|
-
structuralObjectClass: str
|
|
71
|
-
hasSubordinates: bool
|
|
72
|
-
level: int
|
|
106
|
+
AuthenticatedConnection = Annotated[LDAPObject, Depends(authenticated)]
|
|
73
107
|
|
|
74
108
|
|
|
75
|
-
|
|
76
|
-
|
|
109
|
+
class Tag(StrEnum):
|
|
110
|
+
EDITING = "Editing"
|
|
111
|
+
MISC = "Misc"
|
|
112
|
+
NAVIGATION = "Navigation"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@api.get("/tree/{basedn:path}", tags=[Tag.NAVIGATION], operation_id="get_tree")
|
|
116
|
+
async def get_tree(basedn: str, connection: AuthenticatedConnection) -> list[TreeItem]:
|
|
77
117
|
"List directory entries"
|
|
78
118
|
|
|
79
|
-
basedn = request.path_params["basedn"]
|
|
80
119
|
base_level = len(basedn.split(","))
|
|
81
120
|
scope = SCOPE_ONELEVEL
|
|
82
121
|
if basedn == "base":
|
|
83
122
|
scope = SCOPE_BASE
|
|
123
|
+
assert settings.BASE_DN is not None
|
|
84
124
|
basedn = settings.BASE_DN
|
|
85
125
|
|
|
86
|
-
connection = request.state.ldap
|
|
87
126
|
entries = result(
|
|
88
127
|
connection, connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS)
|
|
89
128
|
)
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
]
|
|
95
|
-
)
|
|
129
|
+
return [
|
|
130
|
+
_tree_item(dn, attrs, base_level, await get_schema(connection))
|
|
131
|
+
async for dn, attrs in entries
|
|
132
|
+
]
|
|
96
133
|
|
|
97
134
|
|
|
98
135
|
def _tree_item(
|
|
99
|
-
dn: str, attrs:
|
|
136
|
+
dn: str, attrs: BinaryAttributes, level: int, schema: SubSchema
|
|
100
137
|
) -> TreeItem:
|
|
101
138
|
structuralObjectClass = next(
|
|
102
139
|
iter(
|
|
103
140
|
filter(
|
|
104
|
-
lambda oc: oc.kind == OC.Kind.structural.value, #
|
|
141
|
+
lambda oc: oc.kind == OC.Kind.structural.value, # type: ignore
|
|
105
142
|
map(
|
|
106
143
|
lambda o: schema.get_obj(ObjectClass, o.decode()),
|
|
107
144
|
attrs["objectClass"],
|
|
@@ -120,32 +157,19 @@ def _tree_item(
|
|
|
120
157
|
)
|
|
121
158
|
|
|
122
159
|
|
|
123
|
-
|
|
124
|
-
dn: str
|
|
125
|
-
required: list[str]
|
|
126
|
-
aux: list[str]
|
|
127
|
-
binary: list[str]
|
|
128
|
-
autoFilled: list[str]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
class Entry(BaseModel):
|
|
132
|
-
attrs: dict[str, list[str]]
|
|
133
|
-
meta: Meta
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
|
|
160
|
+
def _entry(res: tuple[str, BinaryAttributes], schema: SubSchema) -> Entry:
|
|
137
161
|
"Prepare an LDAP entry for transmission"
|
|
138
162
|
|
|
139
163
|
dn, attrs = res
|
|
140
164
|
ocs = set([oc.decode() for oc in attrs["objectClass"]])
|
|
141
165
|
must_attrs, _may_attrs = schema.attribute_types(ocs)
|
|
142
166
|
soc = [
|
|
143
|
-
oc.names[0] #
|
|
167
|
+
oc.names[0] # type: ignore
|
|
144
168
|
for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
|
|
145
|
-
if oc.kind == OC.Kind.structural.value #
|
|
169
|
+
if oc.kind == OC.Kind.structural.value # type: ignore
|
|
146
170
|
]
|
|
147
171
|
aux = set(
|
|
148
|
-
schema.get_obj(ObjectClass, a).names[0] #
|
|
172
|
+
schema.get_obj(ObjectClass, a).names[0] # type: ignore
|
|
149
173
|
for a in schema.get_applicable_aux_classes(soc[0])
|
|
150
174
|
)
|
|
151
175
|
|
|
@@ -160,7 +184,7 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
|
|
|
160
184
|
|
|
161
185
|
# Octet strings are not used consistently.
|
|
162
186
|
# Try to decode as text and treat as binary on failure
|
|
163
|
-
if not obj.syntax or obj.syntax == OCTET_STRING: #
|
|
187
|
+
if not obj.syntax or obj.syntax == OCTET_STRING: # type: ignore
|
|
164
188
|
try:
|
|
165
189
|
for val in attrs[attr]:
|
|
166
190
|
assert val.decode().isprintable()
|
|
@@ -168,20 +192,24 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
|
|
|
168
192
|
binary.add(attr)
|
|
169
193
|
|
|
170
194
|
else: # Check human-readable flag in schema
|
|
171
|
-
syntax = schema.get_obj(LDAPSyntax, obj.syntax) #
|
|
172
|
-
if syntax.not_human_readable: #
|
|
195
|
+
syntax = schema.get_obj(LDAPSyntax, obj.syntax) # type: ignore
|
|
196
|
+
if syntax.not_human_readable: # type: ignore
|
|
173
197
|
binary.add(attr)
|
|
174
198
|
|
|
175
199
|
return Entry(
|
|
176
200
|
attrs={
|
|
177
201
|
k: [
|
|
178
|
-
base64.b64encode(val).decode() if k in binary else val
|
|
202
|
+
base64.b64encode(val).decode() if k in binary else val.decode()
|
|
203
|
+
for val in values
|
|
179
204
|
]
|
|
180
205
|
for k, values in attrs.items()
|
|
181
206
|
},
|
|
182
207
|
meta=Meta(
|
|
183
208
|
dn=dn,
|
|
184
|
-
required=[
|
|
209
|
+
required=[
|
|
210
|
+
schema.get_obj(AttributeType, a).names[0] # type: ignore
|
|
211
|
+
for a in must_attrs
|
|
212
|
+
],
|
|
185
213
|
aux=sorted(aux - ocs),
|
|
186
214
|
binary=sorted(binary),
|
|
187
215
|
autoFilled=[],
|
|
@@ -189,129 +217,207 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
|
|
|
189
217
|
)
|
|
190
218
|
|
|
191
219
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
220
|
+
@api.get("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="get_entry")
|
|
221
|
+
async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
222
|
+
"Retrieve a directory entry by DN"
|
|
223
|
+
return _entry(
|
|
224
|
+
await get_entry_by_dn(connection, dn),
|
|
225
|
+
await get_schema(connection),
|
|
226
|
+
)
|
|
198
227
|
|
|
199
|
-
dn = request.path_params["dn"]
|
|
200
|
-
connection = request.state.ldap
|
|
201
228
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
229
|
+
@api.delete(
|
|
230
|
+
"/entry/{dn:path}",
|
|
231
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
232
|
+
tags=[Tag.EDITING],
|
|
233
|
+
operation_id="delete_entry",
|
|
234
|
+
)
|
|
235
|
+
async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
236
|
+
for entry_dn in sorted(
|
|
237
|
+
[
|
|
238
|
+
dn
|
|
239
|
+
async for dn, _attrs in result(
|
|
240
|
+
connection,
|
|
241
|
+
connection.search(dn, SCOPE_SUBTREE),
|
|
242
|
+
)
|
|
243
|
+
],
|
|
244
|
+
key=len,
|
|
245
|
+
reverse=True,
|
|
246
|
+
):
|
|
247
|
+
await empty(connection, connection.delete(entry_dn))
|
|
208
248
|
|
|
209
|
-
if request.method == "DELETE":
|
|
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,
|
|
220
|
-
):
|
|
221
|
-
await empty(connection, connection.delete(entry_dn))
|
|
222
|
-
return NO_CONTENT
|
|
223
249
|
|
|
224
|
-
|
|
225
|
-
|
|
250
|
+
@api.post("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="post_entry")
|
|
251
|
+
async def post_entry(
|
|
252
|
+
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
253
|
+
) -> ChangedAttributes:
|
|
226
254
|
req = {
|
|
227
|
-
k: [s for s in filter(None, v)]
|
|
228
|
-
for k, v in
|
|
229
|
-
if k not in PHOTOS and
|
|
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
|
|
230
258
|
}
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
modlist = modifyModlist(mods, req)
|
|
260
|
+
# Get previous values from directory
|
|
261
|
+
entry = await get_entry_by_dn(connection, dn)
|
|
262
|
+
mods = {k: v for k, v in entry[1].items() if k in req}
|
|
263
|
+
modlist = modifyModlist(mods, req)
|
|
237
264
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
265
|
+
if modlist: # Apply changes and send changed keys back
|
|
266
|
+
await empty(connection, connection.modify(dn, modlist))
|
|
267
|
+
return ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
|
|
241
268
|
|
|
242
|
-
if request.method == "PUT":
|
|
243
|
-
# Create new object
|
|
244
|
-
modlist = addModlist(req)
|
|
245
|
-
if modlist:
|
|
246
|
-
await empty(connection, connection.add(dn, modlist))
|
|
247
|
-
return JSONResponse({"changed": ["dn"]}) # Dummy
|
|
248
269
|
|
|
249
|
-
|
|
270
|
+
@api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
|
|
271
|
+
async def put_entry(
|
|
272
|
+
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
273
|
+
) -> ChangedAttributes:
|
|
274
|
+
modlist = addModlist(
|
|
275
|
+
{
|
|
276
|
+
k: [s.encode() for s in filter(None, v)] # Omit empty byte strings
|
|
277
|
+
for k, v in attributes.items()
|
|
278
|
+
if k not in PHOTOS
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
if modlist:
|
|
282
|
+
await empty(connection, connection.add(dn, modlist))
|
|
283
|
+
return ChangedAttributes(changed=["dn"]) # Dummy
|
|
250
284
|
|
|
251
285
|
|
|
252
|
-
@api.
|
|
253
|
-
|
|
254
|
-
|
|
286
|
+
@api.post(
|
|
287
|
+
"/rename/{dn:path}",
|
|
288
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
289
|
+
tags=[Tag.EDITING],
|
|
290
|
+
operation_id="post_rename_entry",
|
|
291
|
+
)
|
|
292
|
+
async def rename_entry(
|
|
293
|
+
dn: str, rdn: Annotated[str, Body()], connection: AuthenticatedConnection
|
|
294
|
+
) -> None:
|
|
295
|
+
"Rename an entry"
|
|
296
|
+
await empty(connection, connection.rename(dn, rdn, delold=0))
|
|
255
297
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
298
|
+
|
|
299
|
+
@api.get(
|
|
300
|
+
"/blob/{attr}/{index}/{dn:path}",
|
|
301
|
+
tags=[Tag.EDITING],
|
|
302
|
+
operation_id="get_blob",
|
|
303
|
+
include_in_schema=False, # Not used in UI, images are transferred inline
|
|
304
|
+
)
|
|
305
|
+
async def get_blob(
|
|
306
|
+
attr: str, index: int, dn: str, connection: AuthenticatedConnection
|
|
307
|
+
) -> Response:
|
|
308
|
+
"Retrieve a binary attribute"
|
|
260
309
|
|
|
261
310
|
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
262
311
|
|
|
263
|
-
if
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
312
|
+
if attr not in attrs or len(attrs[attr]) <= index:
|
|
313
|
+
raise HTTPException(
|
|
314
|
+
HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return Response(
|
|
318
|
+
attrs[attr][index],
|
|
319
|
+
media_type="application/octet-stream",
|
|
320
|
+
headers={"Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'},
|
|
321
|
+
)
|
|
268
322
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
323
|
+
|
|
324
|
+
@api.put(
|
|
325
|
+
"/blob/{attr}/{index}/{dn:path}",
|
|
326
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
327
|
+
tags=[Tag.EDITING],
|
|
328
|
+
operation_id="put_blob",
|
|
329
|
+
)
|
|
330
|
+
async def put_blob(
|
|
331
|
+
attr: str,
|
|
332
|
+
index: int,
|
|
333
|
+
dn: str,
|
|
334
|
+
blob: Annotated[UploadFile, File()],
|
|
335
|
+
connection: AuthenticatedConnection,
|
|
336
|
+
) -> None:
|
|
337
|
+
"Upload a binary attribute"
|
|
338
|
+
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
339
|
+
data = await blob.read(cast(int, blob.size))
|
|
340
|
+
if attr in attrs:
|
|
341
|
+
await empty(
|
|
342
|
+
connection,
|
|
343
|
+
connection.modify(dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]),
|
|
275
344
|
)
|
|
345
|
+
else:
|
|
346
|
+
await empty(connection, connection.modify(dn, [(0, attr, [data])]))
|
|
276
347
|
|
|
277
|
-
if request.method == "PUT":
|
|
278
|
-
async with request.form() as form_data:
|
|
279
|
-
blob = form_data["blob"]
|
|
280
|
-
if type(blob) is UploadFile:
|
|
281
|
-
data = await blob.read(cast(int, blob.size))
|
|
282
|
-
if attr in attrs:
|
|
283
|
-
await empty(
|
|
284
|
-
connection,
|
|
285
|
-
connection.modify(
|
|
286
|
-
dn, [(1, attr, None), (0, attr, attrs[attr] + [data])]
|
|
287
|
-
),
|
|
288
|
-
)
|
|
289
|
-
else:
|
|
290
|
-
await empty(connection, connection.modify(dn, [(0, attr, [data])]))
|
|
291
|
-
return NO_CONTENT
|
|
292
348
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
349
|
+
@api.delete(
|
|
350
|
+
"/blob/{attr}/{index}/{dn:path}",
|
|
351
|
+
status_code=HTTPStatus.NO_CONTENT,
|
|
352
|
+
tags=[Tag.EDITING],
|
|
353
|
+
operation_id="delete_blob",
|
|
354
|
+
)
|
|
355
|
+
async def delete_blob(
|
|
356
|
+
attr: str, index: int, dn: str, connection: AuthenticatedConnection
|
|
357
|
+
) -> None:
|
|
358
|
+
"Remove a binary attribute"
|
|
359
|
+
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
360
|
+
if attr not in attrs or len(attrs[attr]) <= index:
|
|
361
|
+
raise HTTPException(
|
|
362
|
+
HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
|
|
363
|
+
)
|
|
364
|
+
await empty(connection, connection.modify(dn, [(1, attr, None)]))
|
|
365
|
+
data = attrs[attr][:index] + attrs[attr][index + 1 :]
|
|
366
|
+
if data:
|
|
367
|
+
await empty(connection, connection.modify(dn, [(0, attr, data)]))
|
|
303
368
|
|
|
304
|
-
raise HTTPException(HTTPStatus.METHOD_NOT_ALLOWED)
|
|
305
369
|
|
|
370
|
+
@api.post(
|
|
371
|
+
"/check-password/{dn:path}", tags=[Tag.EDITING], operation_id="post_check_password"
|
|
372
|
+
)
|
|
373
|
+
async def check_password(
|
|
374
|
+
dn: str, check: Annotated[str, Body()], connection: AuthenticatedConnection
|
|
375
|
+
) -> bool:
|
|
376
|
+
"Verify a password"
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
connection.simple_bind_s(dn, check)
|
|
380
|
+
return True
|
|
381
|
+
except INVALID_CREDENTIALS:
|
|
382
|
+
return False
|
|
306
383
|
|
|
307
|
-
|
|
308
|
-
|
|
384
|
+
|
|
385
|
+
@api.post(
|
|
386
|
+
"/change-password/{dn:path}",
|
|
387
|
+
tags=[Tag.EDITING],
|
|
388
|
+
operation_id="post_change_password",
|
|
389
|
+
)
|
|
390
|
+
async def change_password(
|
|
391
|
+
dn: str, args: ChangePasswordRequest, connection: AuthenticatedConnection
|
|
392
|
+
) -> str | None:
|
|
393
|
+
"Update passwords"
|
|
394
|
+
if args.new1:
|
|
395
|
+
await empty(
|
|
396
|
+
connection,
|
|
397
|
+
connection.passwd(dn, args.old or None, args.new1),
|
|
398
|
+
)
|
|
399
|
+
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
400
|
+
attrs["userPassword"][0].decode()
|
|
401
|
+
|
|
402
|
+
else:
|
|
403
|
+
await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
|
|
404
|
+
|
|
405
|
+
|
|
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
|
+
@api.get(
|
|
413
|
+
"/ldif/{dn:path}",
|
|
414
|
+
include_in_schema=False, # Used as a link target, no API call
|
|
415
|
+
)
|
|
416
|
+
async def export_ldif(dn: str, connection: AuthenticatedConnection) -> Response:
|
|
309
417
|
"Dump an entry as LDIF"
|
|
310
418
|
|
|
311
|
-
dn = request.path_params["dn"]
|
|
312
419
|
out = io.StringIO()
|
|
313
420
|
writer = ldif.LDIFWriter(out)
|
|
314
|
-
connection = request.state.ldap
|
|
315
421
|
|
|
316
422
|
async for dn, attrs in result(connection, connection.search(dn, SCOPE_SUBTREE)):
|
|
317
423
|
writer.unparse(dn, attrs)
|
|
@@ -329,96 +435,31 @@ class LDIFReader(ldif.LDIFParser):
|
|
|
329
435
|
self.count = 0
|
|
330
436
|
self.con = con
|
|
331
437
|
|
|
332
|
-
def handle(self, dn: str, entry:
|
|
438
|
+
def handle(self, dn: str, entry: Attributes):
|
|
333
439
|
self.con.add_s(dn, addModlist(entry))
|
|
334
440
|
self.count += 1
|
|
335
441
|
|
|
336
442
|
|
|
337
|
-
@api.
|
|
338
|
-
async def
|
|
339
|
-
|
|
443
|
+
@api.post("/ldif", tags=[Tag.EDITING], operation_id="post_ldif")
|
|
444
|
+
async def upload_ldif(
|
|
445
|
+
ldif: Annotated[str, Body()], connection: AuthenticatedConnection
|
|
340
446
|
) -> Response:
|
|
341
447
|
"Import LDIF"
|
|
342
448
|
|
|
343
|
-
reader = LDIFReader(
|
|
449
|
+
reader = LDIFReader(ldif.encode(), connection)
|
|
344
450
|
try:
|
|
345
451
|
reader.parse()
|
|
346
452
|
return NO_CONTENT
|
|
347
453
|
except ValueError as e:
|
|
348
|
-
return Response(e.args[0],
|
|
349
|
-
|
|
454
|
+
return Response(e.args[0], HTTPStatus.UNPROCESSABLE_ENTITY)
|
|
350
455
|
|
|
351
|
-
Rdn = TypeAdapter(str)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
@api.route("/rename/{dn:path}", methods=["POST"])
|
|
355
|
-
async def rename(request: Request) -> Response:
|
|
356
|
-
"Rename an entry"
|
|
357
|
-
|
|
358
|
-
dn = request.path_params["dn"]
|
|
359
|
-
rdn = Rdn.validate_json(await request.body())
|
|
360
|
-
connection = request.state.ldap
|
|
361
|
-
await empty(connection, connection.rename(dn, rdn, delold=0))
|
|
362
|
-
return NO_CONTENT
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
class ChangePasswordRequest(BaseModel):
|
|
366
|
-
old: str
|
|
367
|
-
new1: str
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
class CheckPasswordRequest(BaseModel):
|
|
371
|
-
check: str = Field(min_length=1)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
PasswordRequest = TypeAdapter(Union[ChangePasswordRequest, CheckPasswordRequest])
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
@api.route("/password/{dn:path}", methods=["POST"])
|
|
378
|
-
async def passwd(request: Request) -> Response:
|
|
379
|
-
"Update passwords"
|
|
380
456
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if type(args) is CheckPasswordRequest:
|
|
385
|
-
with ldap_connect() as con:
|
|
386
|
-
try:
|
|
387
|
-
con.simple_bind_s(dn, args.check)
|
|
388
|
-
return JSONResponse(True)
|
|
389
|
-
except INVALID_CREDENTIALS:
|
|
390
|
-
return JSONResponse(False)
|
|
391
|
-
|
|
392
|
-
elif type(args) is ChangePasswordRequest:
|
|
393
|
-
connection = request.state.ldap
|
|
394
|
-
if args.new1:
|
|
395
|
-
await empty(
|
|
396
|
-
connection,
|
|
397
|
-
connection.passwd(dn, args.old or None, args.new1),
|
|
398
|
-
)
|
|
399
|
-
_dn, attrs = await get_entry_by_dn(connection, dn)
|
|
400
|
-
return JSONResponse(attrs["userPassword"][0].decode())
|
|
401
|
-
|
|
402
|
-
else:
|
|
403
|
-
await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
|
|
404
|
-
return NO_CONTENT
|
|
405
|
-
|
|
406
|
-
raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def _cn(entry: dict) -> Optional[str]:
|
|
410
|
-
"Try to extract a CN"
|
|
411
|
-
if "cn" in entry and entry["cn"]:
|
|
412
|
-
return entry["cn"][0].decode()
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
@api.route("/search/{query:path}")
|
|
416
|
-
async def search(request: Request) -> JSONResponse:
|
|
457
|
+
@api.get("/search/{query:path}", tags=[Tag.NAVIGATION], operation_id="search")
|
|
458
|
+
async def search(query: str, connection: AuthenticatedConnection) -> list[SearchResult]:
|
|
417
459
|
"Search the directory"
|
|
418
460
|
|
|
419
|
-
query = request.path_params["query"]
|
|
420
461
|
if len(query) < settings.SEARCH_QUERY_MIN:
|
|
421
|
-
return
|
|
462
|
+
return []
|
|
422
463
|
|
|
423
464
|
if "=" in query: # Search specific attributes
|
|
424
465
|
if "(" not in query:
|
|
@@ -428,45 +469,47 @@ async def search(request: Request) -> JSONResponse:
|
|
|
428
469
|
|
|
429
470
|
# Collect results
|
|
430
471
|
res = []
|
|
431
|
-
connection = request.state.ldap
|
|
432
472
|
async for dn, attrs in result(
|
|
433
473
|
connection, connection.search(settings.BASE_DN, SCOPE_SUBTREE, query)
|
|
434
474
|
):
|
|
435
|
-
res.append(
|
|
475
|
+
res.append(SearchResult(dn=dn, name=_cn(attrs) or dn))
|
|
436
476
|
if len(res) >= settings.SEARCH_MAX:
|
|
437
477
|
break
|
|
438
|
-
return
|
|
478
|
+
return res
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@api.get("/whoami", tags=[Tag.MISC], operation_id="get_who_am_i")
|
|
482
|
+
async def whoami(connection: AuthenticatedConnection) -> str:
|
|
483
|
+
"DN of the current user"
|
|
484
|
+
return connection.whoami_s().replace("dn:", "")
|
|
439
485
|
|
|
440
486
|
|
|
441
|
-
@api.
|
|
442
|
-
async def
|
|
487
|
+
@api.get("/subtree/{root_dn:path}", tags=[Tag.MISC], operation_id="get_subtree")
|
|
488
|
+
async def list_subtree(
|
|
489
|
+
root_dn: str, connection: AuthenticatedConnection
|
|
490
|
+
) -> list[TreeItem]:
|
|
443
491
|
"List the subtree below a DN"
|
|
444
492
|
|
|
445
|
-
root_dn = request.path_params["dn"]
|
|
446
493
|
start = len(root_dn.split(","))
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
],
|
|
458
|
-
key=lambda item: tuple(reversed(item["dn"].lower().split(","))),
|
|
459
|
-
)
|
|
494
|
+
return sorted(
|
|
495
|
+
[
|
|
496
|
+
_tree_item(dn, attrs, start, await get_schema(connection))
|
|
497
|
+
async for dn, attrs in result(
|
|
498
|
+
connection,
|
|
499
|
+
connection.search(root_dn, SCOPE_SUBTREE),
|
|
500
|
+
)
|
|
501
|
+
if root_dn != dn
|
|
502
|
+
],
|
|
503
|
+
key=lambda item: tuple(reversed(item.dn.lower().split(","))),
|
|
460
504
|
)
|
|
461
505
|
|
|
462
506
|
|
|
463
|
-
@api.
|
|
464
|
-
async def attribute_range(
|
|
507
|
+
@api.get("/range/{attribute}", tags=[Tag.MISC], operation_id="get_range")
|
|
508
|
+
async def attribute_range(attribute: str, connection: AuthenticatedConnection) -> Range:
|
|
465
509
|
"List all values for a numeric attribute of an objectClass like uidNumber or gidNumber"
|
|
466
510
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
obj = request.app.state.schema.get_obj(AttributeType, attribute)
|
|
511
|
+
schema = await get_schema(connection)
|
|
512
|
+
obj = schema.get_obj(AttributeType, attribute)
|
|
470
513
|
|
|
471
514
|
values = set(
|
|
472
515
|
[
|
|
@@ -490,30 +533,20 @@ async def attribute_range(request: Request) -> JSONResponse:
|
|
|
490
533
|
)
|
|
491
534
|
|
|
492
535
|
minimum, maximum = min(values), max(values)
|
|
493
|
-
return
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
"next": min(set(range(minimum, maximum + 2)) - values),
|
|
498
|
-
}
|
|
536
|
+
return Range(
|
|
537
|
+
min=minimum,
|
|
538
|
+
max=maximum,
|
|
539
|
+
next=min(set(range(minimum, maximum + 2)) - values),
|
|
499
540
|
)
|
|
500
541
|
|
|
501
542
|
|
|
502
|
-
@api.
|
|
503
|
-
|
|
543
|
+
@api.get(
|
|
544
|
+
"/schema",
|
|
545
|
+
tags=[Tag.MISC],
|
|
546
|
+
operation_id="get_schema",
|
|
547
|
+
response_model_exclude_none=True,
|
|
548
|
+
response_model_exclude_unset=True,
|
|
549
|
+
)
|
|
550
|
+
async def ldap_schema(connection: AuthenticatedConnection) -> Schema:
|
|
504
551
|
"Dump the LDAP schema as JSON"
|
|
505
|
-
|
|
506
|
-
connection = request.state.ldap
|
|
507
|
-
# See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
|
|
508
|
-
_dn, sub_schema = await unique(
|
|
509
|
-
connection,
|
|
510
|
-
connection.search(
|
|
511
|
-
settings.SCHEMA_DN,
|
|
512
|
-
SCOPE_BASE,
|
|
513
|
-
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
514
|
-
),
|
|
515
|
-
)
|
|
516
|
-
request.app.state.schema = SubSchema(sub_schema, check_uniqueness=2)
|
|
517
|
-
|
|
518
|
-
schema = frontend_schema(request.app.state.schema)
|
|
519
|
-
return JSONResponse(schema.model_dump())
|
|
552
|
+
return frontend_schema(await get_schema(connection))
|