ldap-ui 0.10.1__py3-none-any.whl → 0.10.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ldap_ui/__init__.py +1 -1
- ldap_ui/__main__.py +7 -6
- ldap_ui/entities.py +8 -20
- ldap_ui/ldap_api.py +39 -56
- ldap_ui/settings.py +3 -10
- ldap_ui/statics/assets/index-BxbE5Fxl.js +19 -0
- ldap_ui/statics/assets/index-BxbE5Fxl.js.gz +0 -0
- ldap_ui/statics/assets/{index-CusJ2HRh.css → index-sA8CL-9V.css} +1 -1
- ldap_ui/statics/assets/index-sA8CL-9V.css.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.10.1.dist-info → ldap_ui-0.10.3.dist-info}/METADATA +1 -1
- ldap_ui-0.10.3.dist-info/RECORD +25 -0
- ldap_ui/statics/assets/index-BxCLA1wZ.js +0 -19
- ldap_ui/statics/assets/index-BxCLA1wZ.js.gz +0 -0
- ldap_ui/statics/assets/index-CusJ2HRh.css.gz +0 -0
- ldap_ui-0.10.1.dist-info/RECORD +0 -25
- {ldap_ui-0.10.1.dist-info → ldap_ui-0.10.3.dist-info}/WHEEL +0 -0
- {ldap_ui-0.10.1.dist-info → ldap_ui-0.10.3.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.10.1.dist-info → ldap_ui-0.10.3.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.10.1.dist-info → ldap_ui-0.10.3.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.10.
|
|
1
|
+
__version__ = "0.10.3"
|
ldap_ui/__main__.py
CHANGED
|
@@ -57,6 +57,11 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
57
57
|
help="Log level.",
|
|
58
58
|
show_default=True,
|
|
59
59
|
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--reload",
|
|
62
|
+
is_flag=True,
|
|
63
|
+
help="Watch for changes and reload?",
|
|
64
|
+
)
|
|
60
65
|
@click.option(
|
|
61
66
|
"--version",
|
|
62
67
|
is_flag=True,
|
|
@@ -65,7 +70,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
65
70
|
is_eager=True,
|
|
66
71
|
help="Display the current version and exit.",
|
|
67
72
|
)
|
|
68
|
-
def main(base_dn, host, port, ldap_url, log_level):
|
|
73
|
+
def main(base_dn, host, port, ldap_url, log_level, reload):
|
|
69
74
|
logging.basicConfig(level=LOG_LEVELS[log_level])
|
|
70
75
|
rootHandler = logging.getLogger().handlers[0]
|
|
71
76
|
rootHandler.setFormatter(ColourizedFormatter(fmt="%(levelprefix)s %(message)s"))
|
|
@@ -76,11 +81,7 @@ def main(base_dn, host, port, ldap_url, log_level):
|
|
|
76
81
|
if ldap_url is not None:
|
|
77
82
|
settings.LDAP_URL = ldap_url
|
|
78
83
|
|
|
79
|
-
uvicorn.run(
|
|
80
|
-
"ldap_ui.app:app",
|
|
81
|
-
host=host,
|
|
82
|
-
port=port,
|
|
83
|
-
)
|
|
84
|
+
uvicorn.run("ldap_ui.app:app", host=host, port=port, reload=reload)
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
if __name__ == "__main__":
|
ldap_ui/entities.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"Data types for ReST endpoints"
|
|
2
2
|
|
|
3
|
-
from pydantic import BaseModel
|
|
3
|
+
from pydantic import BaseModel
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class TreeItem(BaseModel):
|
|
@@ -12,32 +12,20 @@ class TreeItem(BaseModel):
|
|
|
12
12
|
level: int
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
class Meta(BaseModel):
|
|
16
|
-
"Attribute classification for an entry"
|
|
17
|
-
|
|
18
|
-
dn: str
|
|
19
|
-
required: list[str]
|
|
20
|
-
aux: list[str]
|
|
21
|
-
binary: list[str]
|
|
22
|
-
autoFilled: list[str]
|
|
23
|
-
isNew: bool = False
|
|
24
|
-
|
|
25
|
-
|
|
26
15
|
Attributes = dict[str, list[str]]
|
|
27
16
|
|
|
17
|
+
AttributeNames = list[str] # Names of modified attributes
|
|
18
|
+
|
|
28
19
|
|
|
29
20
|
class Entry(BaseModel):
|
|
30
21
|
"Directory entry"
|
|
31
22
|
|
|
23
|
+
dn: str
|
|
32
24
|
attrs: Attributes
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class ChangedAttributes(BaseModel):
|
|
38
|
-
"List of modified attributes"
|
|
39
|
-
|
|
40
|
-
changed: list[str]
|
|
25
|
+
binary: AttributeNames
|
|
26
|
+
autoFilled: AttributeNames
|
|
27
|
+
changed: AttributeNames
|
|
28
|
+
isNew: bool = False
|
|
41
29
|
|
|
42
30
|
|
|
43
31
|
class ChangePasswordRequest(BaseModel):
|
ldap_ui/ldap_api.py
CHANGED
|
@@ -19,12 +19,12 @@ from fastapi import (
|
|
|
19
19
|
Body,
|
|
20
20
|
Depends,
|
|
21
21
|
File,
|
|
22
|
+
Header,
|
|
22
23
|
HTTPException,
|
|
23
24
|
Response,
|
|
24
25
|
UploadFile,
|
|
25
26
|
)
|
|
26
27
|
from fastapi.responses import PlainTextResponse
|
|
27
|
-
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
28
28
|
from ldap import (
|
|
29
29
|
INVALID_CREDENTIALS, # type: ignore
|
|
30
30
|
SCOPE_BASE, # type: ignore
|
|
@@ -34,15 +34,14 @@ from ldap import (
|
|
|
34
34
|
from ldap.ldapobject import LDAPObject
|
|
35
35
|
from ldap.modlist import addModlist, modifyModlist
|
|
36
36
|
from ldap.schema import SubSchema
|
|
37
|
-
from ldap.schema.models import AttributeType, LDAPSyntax
|
|
37
|
+
from ldap.schema.models import AttributeType, LDAPSyntax
|
|
38
38
|
|
|
39
39
|
from . import settings
|
|
40
40
|
from .entities import (
|
|
41
|
+
AttributeNames,
|
|
41
42
|
Attributes,
|
|
42
|
-
ChangedAttributes,
|
|
43
43
|
ChangePasswordRequest,
|
|
44
44
|
Entry,
|
|
45
|
-
Meta,
|
|
46
45
|
Range,
|
|
47
46
|
SearchResult,
|
|
48
47
|
TreeItem,
|
|
@@ -58,7 +57,6 @@ from .ldap_helpers import (
|
|
|
58
57
|
results,
|
|
59
58
|
unique,
|
|
60
59
|
)
|
|
61
|
-
from .schema import ObjectClass as OC
|
|
62
60
|
from .schema import Schema, frontend_schema
|
|
63
61
|
|
|
64
62
|
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT)
|
|
@@ -96,8 +94,8 @@ async def get_root_dse(connection: LDAPObject):
|
|
|
96
94
|
|
|
97
95
|
|
|
98
96
|
async def authenticated(
|
|
99
|
-
credentials: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
|
|
100
97
|
connection: Annotated[LDAPObject, Depends(ldap_connect)],
|
|
98
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
101
99
|
) -> LDAPObject:
|
|
102
100
|
"Authenticate against the directory"
|
|
103
101
|
|
|
@@ -109,11 +107,11 @@ async def authenticated(
|
|
|
109
107
|
password = settings.GET_BIND_PASSWORD()
|
|
110
108
|
|
|
111
109
|
# Search for basic auth user
|
|
112
|
-
if not dn:
|
|
113
|
-
password =
|
|
114
|
-
dn = settings.GET_BIND_PATTERN(
|
|
115
|
-
|
|
116
|
-
)
|
|
110
|
+
if not dn and authorization:
|
|
111
|
+
username, password = get_basic_credentials(authorization)
|
|
112
|
+
dn = settings.GET_BIND_PATTERN(username) or await anonymous_user_search(
|
|
113
|
+
connection, username
|
|
114
|
+
)
|
|
117
115
|
|
|
118
116
|
if dn: # Log in
|
|
119
117
|
await empty(connection, connection.simple_bind(dn, password))
|
|
@@ -122,6 +120,13 @@ async def authenticated(
|
|
|
122
120
|
raise INVALID_CREDENTIALS([{"desc": f"Invalid credentials for DN: {dn}"}])
|
|
123
121
|
|
|
124
122
|
|
|
123
|
+
def get_basic_credentials(authorization: str) -> list[str]:
|
|
124
|
+
scheme, credentials = authorization.split(maxsplit=1)
|
|
125
|
+
if scheme.lower() != "basic":
|
|
126
|
+
raise INVALID_CREDENTIALS()
|
|
127
|
+
return base64.b64decode(credentials).decode().split(":", maxsplit=1)
|
|
128
|
+
|
|
129
|
+
|
|
125
130
|
AuthenticatedConnection = Annotated[LDAPObject, Depends(authenticated)]
|
|
126
131
|
|
|
127
132
|
|
|
@@ -188,50 +193,25 @@ async def get_entry(dn: str, connection: AuthenticatedConnection) -> Entry:
|
|
|
188
193
|
def _entry(entry: LdapEntry, schema: SubSchema) -> Entry:
|
|
189
194
|
"Decode an LDAP entry for transmission"
|
|
190
195
|
|
|
191
|
-
|
|
192
|
-
|
|
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 = [
|
|
213
|
-
oc.names[0] # type: ignore
|
|
214
|
-
for oc in map(lambda o: schema.get_obj(ObjectClass, o), object_classes)
|
|
215
|
-
if oc.kind == OC.Kind.structural # type: ignore
|
|
216
|
-
]
|
|
217
|
-
aux = set(
|
|
218
|
-
schema.get_obj(ObjectClass, a).names[0] # type: ignore
|
|
219
|
-
for a in schema.get_applicable_aux_classes(structural[0])
|
|
196
|
+
binary = sorted(
|
|
197
|
+
set(attr for attr in entry.attrs if _is_binary(entry, attr, schema))
|
|
220
198
|
)
|
|
221
|
-
|
|
222
|
-
|
|
199
|
+
return Entry(
|
|
200
|
+
attrs={
|
|
201
|
+
k: ["*****"] # 23 suppress userPassword
|
|
202
|
+
if k == "userPassword"
|
|
203
|
+
else [base64.b64encode(val).decode() for val in entry.attrs[k]]
|
|
204
|
+
if k in binary
|
|
205
|
+
else entry.attr(k)
|
|
206
|
+
for k in sorted(entry.attrs)
|
|
207
|
+
},
|
|
223
208
|
dn=entry.dn,
|
|
224
|
-
|
|
225
|
-
aux=sorted(aux - object_classes),
|
|
226
|
-
binary=sorted(_binary_attributes(entry, schema)),
|
|
209
|
+
binary=binary,
|
|
227
210
|
autoFilled=[],
|
|
211
|
+
changed=[],
|
|
228
212
|
)
|
|
229
213
|
|
|
230
214
|
|
|
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
215
|
def _is_binary(entry: LdapEntry, attr: str, schema: SubSchema) -> bool:
|
|
236
216
|
"Guess whether an attribute has binary content"
|
|
237
217
|
|
|
@@ -272,7 +252,7 @@ async def delete_entry(dn: str, connection: AuthenticatedConnection) -> None:
|
|
|
272
252
|
@api.post("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="post_entry")
|
|
273
253
|
async def post_entry(
|
|
274
254
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
275
|
-
) ->
|
|
255
|
+
) -> AttributeNames:
|
|
276
256
|
entry = await get_entry_by_dn(connection, dn)
|
|
277
257
|
schema = await get_schema(connection)
|
|
278
258
|
|
|
@@ -280,16 +260,19 @@ async def post_entry(
|
|
|
280
260
|
attr: _nonempty_byte_strings(attributes, attr)
|
|
281
261
|
for attr in attributes
|
|
282
262
|
if attr not in PASSWORDS
|
|
283
|
-
and
|
|
284
|
-
|
|
285
|
-
|
|
263
|
+
and (
|
|
264
|
+
attr not in entry.attrs
|
|
265
|
+
or not _is_binary(
|
|
266
|
+
entry, attr, schema
|
|
267
|
+
) # FIXME Handle binary attributes properly
|
|
268
|
+
)
|
|
286
269
|
}
|
|
287
270
|
|
|
288
271
|
actual = {attr: v for attr, v in entry.attrs.items() if attr in expected}
|
|
289
272
|
modlist = modifyModlist(actual, expected)
|
|
290
273
|
if modlist: # Apply changes and send changed keys back
|
|
291
274
|
await empty(connection, connection.modify(dn, modlist))
|
|
292
|
-
return
|
|
275
|
+
return list(sorted(set(m[1] for m in modlist)))
|
|
293
276
|
|
|
294
277
|
|
|
295
278
|
def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
|
|
@@ -299,7 +282,7 @@ def _nonempty_byte_strings(attributes: Attributes, attr: str) -> list[bytes]:
|
|
|
299
282
|
@api.put("/entry/{dn:path}", tags=[Tag.EDITING], operation_id="put_entry")
|
|
300
283
|
async def put_entry(
|
|
301
284
|
dn: str, attributes: Attributes, connection: AuthenticatedConnection
|
|
302
|
-
) ->
|
|
285
|
+
) -> AttributeNames:
|
|
303
286
|
modlist = addModlist(
|
|
304
287
|
{
|
|
305
288
|
attr: _nonempty_byte_strings(attributes, attr)
|
|
@@ -309,7 +292,7 @@ async def put_entry(
|
|
|
309
292
|
)
|
|
310
293
|
if modlist:
|
|
311
294
|
await empty(connection, connection.add(dn, modlist))
|
|
312
|
-
return
|
|
295
|
+
return ["dn"] # Dummy
|
|
313
296
|
|
|
314
297
|
|
|
315
298
|
@api.post(
|
ldap_ui/settings.py
CHANGED
|
@@ -30,17 +30,11 @@ BASE_DN = config("BASE_DN", default=None)
|
|
|
30
30
|
SCHEMA_DN = config("SCHEMA_DN", default=None)
|
|
31
31
|
|
|
32
32
|
USE_TLS = config(
|
|
33
|
-
"USE_TLS",
|
|
34
|
-
cast=lambda x: bool(x),
|
|
35
|
-
default=LDAP_URL.startswith("ldaps://"),
|
|
33
|
+
"USE_TLS", cast=lambda x: bool(x), default=LDAP_URL.startswith("ldaps://")
|
|
36
34
|
)
|
|
37
35
|
|
|
38
36
|
# DANGEROUS: Disable TLS host name verification.
|
|
39
|
-
INSECURE_TLS = config(
|
|
40
|
-
"INSECURE_TLS",
|
|
41
|
-
cast=lambda x: bool(x),
|
|
42
|
-
default=False,
|
|
43
|
-
)
|
|
37
|
+
INSECURE_TLS = config("INSECURE_TLS", cast=lambda x: bool(x), default=False)
|
|
44
38
|
|
|
45
39
|
#
|
|
46
40
|
# Binding
|
|
@@ -54,8 +48,7 @@ def GET_BIND_DN() -> Optional[str]:
|
|
|
54
48
|
the UI will NOT ask for a login.
|
|
55
49
|
You need to secure it otherwise!
|
|
56
50
|
"""
|
|
57
|
-
|
|
58
|
-
return config("BIND_DN")
|
|
51
|
+
return config("BIND_DN", default=None)
|
|
59
52
|
|
|
60
53
|
|
|
61
54
|
def GET_BIND_PATTERN(username) -> Optional[str]:
|