ldap-ui 0.9.5__py3-none-any.whl → 0.9.7__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 +11 -3
- ldap_ui/app.py +25 -35
- ldap_ui/ldap_api.py +60 -26
- ldap_ui/ldap_helpers.py +8 -4
- ldap_ui/schema.py +109 -79
- ldap_ui/settings.py +6 -1
- ldap_ui/statics/assets/{index-CA45Sb-q.js → index-CFsg5uEH.js} +1 -1
- ldap_ui/statics/assets/{index-CA45Sb-q.js.gz → index-CFsg5uEH.js.gz} +0 -0
- ldap_ui/statics/assets/{index-DlTKbnmq.css → index-Cw9TEv0d.css} +1 -1
- ldap_ui/statics/assets/index-Cw9TEv0d.css.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.9.5.dist-info → ldap_ui-0.9.7.dist-info}/METADATA +1 -1
- ldap_ui-0.9.7.dist-info/RECORD +24 -0
- {ldap_ui-0.9.5.dist-info → ldap_ui-0.9.7.dist-info}/WHEEL +1 -1
- ldap_ui/statics/assets/index-DlTKbnmq.css.gz +0 -0
- ldap_ui-0.9.5.dist-info/RECORD +0 -24
- {ldap_ui-0.9.5.dist-info → ldap_ui-0.9.7.dist-info}/LICENSE.txt +0 -0
- {ldap_ui-0.9.5.dist-info → ldap_ui-0.9.7.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.9.5.dist-info → ldap_ui-0.9.7.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.9.
|
|
1
|
+
__version__ = "0.9.7"
|
ldap_ui/__main__.py
CHANGED
|
@@ -23,7 +23,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
23
23
|
"--base-dn",
|
|
24
24
|
type=str,
|
|
25
25
|
default=settings.BASE_DN,
|
|
26
|
-
help="LDAP base DN.
|
|
26
|
+
help="LDAP base DN (required). [default: BASE_DN environment variable]",
|
|
27
27
|
)
|
|
28
28
|
@click.option(
|
|
29
29
|
"-h",
|
|
@@ -41,6 +41,12 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
41
41
|
help="Bind socket to this port. If 0, an available port will be picked.",
|
|
42
42
|
show_default=True,
|
|
43
43
|
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"-u",
|
|
46
|
+
"--ldap-url",
|
|
47
|
+
type=str,
|
|
48
|
+
help="LDAP directory connection URL. [default: LDAP_URL environment variable or 'ldap:///']",
|
|
49
|
+
)
|
|
44
50
|
@click.option(
|
|
45
51
|
"-l",
|
|
46
52
|
"--log-level",
|
|
@@ -57,7 +63,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
|
|
|
57
63
|
is_eager=True,
|
|
58
64
|
help="Display the current version and exit.",
|
|
59
65
|
)
|
|
60
|
-
def main(base_dn, host, port, log_level):
|
|
66
|
+
def main(base_dn, host, port, ldap_url, log_level):
|
|
61
67
|
logging.basicConfig(level=LOG_LEVELS[log_level])
|
|
62
68
|
rootHandler = logging.getLogger().handlers[0]
|
|
63
69
|
rootHandler.setFormatter(ColourizedFormatter(fmt="%(levelprefix)s %(message)s"))
|
|
@@ -65,9 +71,11 @@ def main(base_dn, host, port, log_level):
|
|
|
65
71
|
if base_dn is not None:
|
|
66
72
|
settings.BASE_DN = base_dn
|
|
67
73
|
|
|
74
|
+
if ldap_url is not None:
|
|
75
|
+
settings.LDAP_URL = ldap_url
|
|
76
|
+
|
|
68
77
|
uvicorn.run(
|
|
69
78
|
"ldap_ui.app:app",
|
|
70
|
-
log_level=logging.INFO,
|
|
71
79
|
host=host,
|
|
72
80
|
port=port,
|
|
73
81
|
)
|
ldap_ui/app.py
CHANGED
|
@@ -10,13 +10,12 @@ No sessions, no cookies, nothing else.
|
|
|
10
10
|
|
|
11
11
|
import base64
|
|
12
12
|
import binascii
|
|
13
|
-
import contextlib
|
|
14
13
|
import logging
|
|
15
14
|
import sys
|
|
15
|
+
from http import HTTPStatus
|
|
16
16
|
from typing import AsyncGenerator
|
|
17
17
|
|
|
18
18
|
import ldap
|
|
19
|
-
from ldap.schema import SubSchema
|
|
20
19
|
from pydantic import ValidationError
|
|
21
20
|
from starlette.applications import Starlette
|
|
22
21
|
from starlette.authentication import (
|
|
@@ -37,7 +36,7 @@ from starlette.staticfiles import StaticFiles
|
|
|
37
36
|
|
|
38
37
|
from . import settings
|
|
39
38
|
from .ldap_api import api
|
|
40
|
-
from .ldap_helpers import
|
|
39
|
+
from .ldap_helpers import empty, ldap_connect, unique
|
|
41
40
|
|
|
42
41
|
LOG = logging.getLogger("ldap-ui")
|
|
43
42
|
|
|
@@ -49,8 +48,8 @@ LOG.debug("Base DN: %s", settings.BASE_DN)
|
|
|
49
48
|
|
|
50
49
|
# Force authentication
|
|
51
50
|
UNAUTHORIZED = Response(
|
|
52
|
-
|
|
53
|
-
status_code=
|
|
51
|
+
HTTPStatus.UNAUTHORIZED.phrase,
|
|
52
|
+
status_code=HTTPStatus.UNAUTHORIZED.value,
|
|
54
53
|
headers={"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'},
|
|
55
54
|
)
|
|
56
55
|
|
|
@@ -71,15 +70,20 @@ class LdapConnectionMiddleware(BaseHTTPMiddleware):
|
|
|
71
70
|
|
|
72
71
|
# Search for basic auth user
|
|
73
72
|
if type(request.user) is LdapUser:
|
|
74
|
-
dn, _attrs = await unique(
|
|
75
|
-
connection,
|
|
76
|
-
connection.search(
|
|
77
|
-
settings.BASE_DN,
|
|
78
|
-
ldap.SCOPE_SUBTREE,
|
|
79
|
-
settings.GET_BIND_DN_FILTER(request.user.username),
|
|
80
|
-
),
|
|
81
|
-
)
|
|
82
73
|
password = request.user.password
|
|
74
|
+
dn = settings.GET_BIND_PATTERN(request.user.username)
|
|
75
|
+
if dn is None:
|
|
76
|
+
try:
|
|
77
|
+
dn, _attrs = await unique(
|
|
78
|
+
connection,
|
|
79
|
+
connection.search(
|
|
80
|
+
settings.BASE_DN,
|
|
81
|
+
ldap.SCOPE_SUBTREE,
|
|
82
|
+
settings.GET_BIND_DN_FILTER(request.user.username),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
except HTTPException:
|
|
86
|
+
pass
|
|
83
87
|
|
|
84
88
|
# Hard-wired credentials
|
|
85
89
|
if dn is None:
|
|
@@ -99,10 +103,10 @@ class LdapConnectionMiddleware(BaseHTTPMiddleware):
|
|
|
99
103
|
|
|
100
104
|
except ldap.LDAPError as err:
|
|
101
105
|
msg = ldap_exception_message(err)
|
|
102
|
-
LOG.error(msg)
|
|
106
|
+
LOG.error(msg, exc_info=err)
|
|
103
107
|
return PlainTextResponse(
|
|
104
108
|
msg,
|
|
105
|
-
status_code=
|
|
109
|
+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
106
110
|
)
|
|
107
111
|
|
|
108
112
|
|
|
@@ -172,29 +176,16 @@ async def http_exception(_request: Request, exc: HTTPException) -> Response:
|
|
|
172
176
|
|
|
173
177
|
async def forbidden(_request: Request, exc: ldap.LDAPError) -> Response:
|
|
174
178
|
"HTTP 403 Forbidden"
|
|
175
|
-
return PlainTextResponse(
|
|
179
|
+
return PlainTextResponse(
|
|
180
|
+
ldap_exception_message(exc),
|
|
181
|
+
status_code=HTTPStatus.FORBIDDEN.value,
|
|
182
|
+
)
|
|
176
183
|
|
|
177
184
|
|
|
178
185
|
async def http_422(_request: Request, e: ValidationError) -> Response:
|
|
179
186
|
"HTTP 422 Unprocessable Entity"
|
|
180
187
|
LOG.warn("Invalid request body", exc_info=e)
|
|
181
|
-
return Response(repr(e), status_code=
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
@contextlib.asynccontextmanager
|
|
185
|
-
async def lifespan(app):
|
|
186
|
-
with ldap_connect() as connection:
|
|
187
|
-
# See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
|
|
188
|
-
_dn, sub_schema = await unique(
|
|
189
|
-
connection,
|
|
190
|
-
connection.search(
|
|
191
|
-
settings.SCHEMA_DN,
|
|
192
|
-
ldap.SCOPE_BASE,
|
|
193
|
-
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
194
|
-
),
|
|
195
|
-
)
|
|
196
|
-
app.state.schema = SubSchema(sub_schema, check_uniqueness=2)
|
|
197
|
-
yield
|
|
188
|
+
return Response(repr(e), status_code=HTTPStatus.UNPROCESSABLE_ENTITY.value)
|
|
198
189
|
|
|
199
190
|
|
|
200
191
|
# Main ASGI entry
|
|
@@ -205,7 +196,6 @@ app = Starlette(
|
|
|
205
196
|
ldap.INSUFFICIENT_ACCESS: forbidden,
|
|
206
197
|
ValidationError: http_422,
|
|
207
198
|
},
|
|
208
|
-
lifespan=lifespan,
|
|
209
199
|
middleware=(
|
|
210
200
|
Middleware(AuthenticationMiddleware, backend=BasicAuthBackend()),
|
|
211
201
|
Middleware(LdapConnectionMiddleware),
|
|
@@ -213,7 +203,7 @@ app = Starlette(
|
|
|
213
203
|
Middleware(GZipMiddleware, minimum_size=512, compresslevel=6),
|
|
214
204
|
),
|
|
215
205
|
routes=[
|
|
216
|
-
Mount("/api",
|
|
206
|
+
Mount("/api", app=api),
|
|
217
207
|
Mount("/", StaticFiles(packages=["ldap_ui"], html=True)),
|
|
218
208
|
],
|
|
219
209
|
)
|
ldap_ui/ldap_api.py
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
ReST endpoints for LDAP access.
|
|
3
3
|
|
|
4
4
|
Directory operations are accessible to the frontend
|
|
5
|
-
through a hand-knit API, responses are usually converted to JSON.
|
|
5
|
+
through 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
|
+
from http import HTTPStatus
|
|
12
13
|
from typing import Any, Optional, Tuple, Union
|
|
13
14
|
|
|
14
15
|
import ldap
|
|
@@ -31,13 +32,15 @@ from .ldap_helpers import (
|
|
|
31
32
|
get_entry_by_dn,
|
|
32
33
|
ldap_connect,
|
|
33
34
|
result,
|
|
35
|
+
unique,
|
|
34
36
|
)
|
|
37
|
+
from .schema import ObjectClass as OC
|
|
35
38
|
from .schema import frontend_schema
|
|
36
39
|
|
|
37
40
|
__all__ = ("api",)
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
NO_CONTENT = Response(status_code=
|
|
43
|
+
NO_CONTENT = Response(status_code=HTTPStatus.NO_CONTENT.value)
|
|
41
44
|
|
|
42
45
|
# Special fields
|
|
43
46
|
PHOTOS = ("jpegPhoto", "thumbnailPhoto")
|
|
@@ -87,7 +90,20 @@ async def _tree(request: Request, basedn: str, scope: int) -> list[dict[str, Any
|
|
|
87
90
|
]
|
|
88
91
|
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
class Meta(BaseModel):
|
|
94
|
+
dn: str
|
|
95
|
+
required: list[str]
|
|
96
|
+
aux: list[str]
|
|
97
|
+
binary: list[str]
|
|
98
|
+
autoFilled: list[str]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Entry(BaseModel):
|
|
102
|
+
attrs: dict[str, list[str]]
|
|
103
|
+
meta: Meta
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _entry(schema: SubSchema, res: Tuple[str, Any]) -> Entry:
|
|
91
107
|
"Prepare an LDAP entry for transmission"
|
|
92
108
|
|
|
93
109
|
dn, attrs = res
|
|
@@ -96,7 +112,7 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> dict[str, Any]:
|
|
|
96
112
|
soc = [
|
|
97
113
|
oc.names[0]
|
|
98
114
|
for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
|
|
99
|
-
if oc.kind ==
|
|
115
|
+
if oc.kind == OC.Kind.structural.value
|
|
100
116
|
]
|
|
101
117
|
aux = set(
|
|
102
118
|
schema.get_obj(ObjectClass, a).names[0]
|
|
@@ -126,26 +142,22 @@ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> dict[str, Any]:
|
|
|
126
142
|
if syntax.not_human_readable:
|
|
127
143
|
binary.add(attr)
|
|
128
144
|
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
k: [
|
|
132
|
-
base64.b64encode(val).decode() if k in binary else val.decode()
|
|
133
|
-
for val in values
|
|
134
|
-
]
|
|
145
|
+
return Entry(
|
|
146
|
+
attrs={
|
|
147
|
+
k: [base64.b64encode(val) if k in binary else val for val in values]
|
|
135
148
|
for k, values in attrs.items()
|
|
136
149
|
},
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
150
|
+
meta=Meta(
|
|
151
|
+
dn=dn,
|
|
152
|
+
required=[schema.get_obj(AttributeType, a).names[0] for a in must_attrs],
|
|
153
|
+
aux=sorted(aux - ocs),
|
|
154
|
+
binary=sorted(binary),
|
|
155
|
+
autoFilled=[],
|
|
156
|
+
),
|
|
157
|
+
)
|
|
146
158
|
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
Attributes = TypeAdapter(dict[str, list[bytes]])
|
|
149
161
|
|
|
150
162
|
|
|
151
163
|
@api.route("/entry/{dn}", methods=("GET", "POST", "DELETE", "PUT"))
|
|
@@ -157,7 +169,9 @@ async def entry(request: Request) -> Response:
|
|
|
157
169
|
|
|
158
170
|
if request.method == "GET":
|
|
159
171
|
return JSONResponse(
|
|
160
|
-
_entry(
|
|
172
|
+
_entry(
|
|
173
|
+
request.app.state.schema, await get_entry_by_dn(connection, dn)
|
|
174
|
+
).model_dump()
|
|
161
175
|
)
|
|
162
176
|
|
|
163
177
|
if request.method == "DELETE":
|
|
@@ -168,7 +182,7 @@ async def entry(request: Request) -> Response:
|
|
|
168
182
|
return NO_CONTENT
|
|
169
183
|
|
|
170
184
|
# Copy JSON payload into a dictionary of non-empty byte strings
|
|
171
|
-
json =
|
|
185
|
+
json = Attributes.validate_json(await request.body())
|
|
172
186
|
req = {
|
|
173
187
|
k: [s for s in filter(None, v)]
|
|
174
188
|
for k, v in json.items()
|
|
@@ -206,7 +220,9 @@ async def blob(request: Request) -> Response:
|
|
|
206
220
|
|
|
207
221
|
if request.method == "GET":
|
|
208
222
|
if attr not in attrs or len(attrs[attr]) <= index:
|
|
209
|
-
raise HTTPException(
|
|
223
|
+
raise HTTPException(
|
|
224
|
+
HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
|
|
225
|
+
)
|
|
210
226
|
|
|
211
227
|
return Response(
|
|
212
228
|
attrs[attr][index],
|
|
@@ -234,7 +250,9 @@ async def blob(request: Request) -> Response:
|
|
|
234
250
|
|
|
235
251
|
if request.method == "DELETE":
|
|
236
252
|
if attr not in attrs or len(attrs[attr]) <= index:
|
|
237
|
-
raise HTTPException(
|
|
253
|
+
raise HTTPException(
|
|
254
|
+
HTTPStatus.NOT_FOUND.value, f"Attribute {attr} not found for DN {dn}"
|
|
255
|
+
)
|
|
238
256
|
await empty(connection, connection.modify(dn, [(1, attr, None)]))
|
|
239
257
|
data = attrs[attr][:index] + attrs[attr][index + 1 :]
|
|
240
258
|
if data:
|
|
@@ -420,7 +438,9 @@ async def attribute_range(request: Request) -> JSONResponse:
|
|
|
420
438
|
)
|
|
421
439
|
|
|
422
440
|
if not values:
|
|
423
|
-
raise HTTPException(
|
|
441
|
+
raise HTTPException(
|
|
442
|
+
HTTPStatus.NOT_FOUND.value, f"No values found for attribute {attribute}"
|
|
443
|
+
)
|
|
424
444
|
|
|
425
445
|
minimum, maximum = min(values), max(values)
|
|
426
446
|
return JSONResponse(
|
|
@@ -435,4 +455,18 @@ async def attribute_range(request: Request) -> JSONResponse:
|
|
|
435
455
|
@api.route("/schema")
|
|
436
456
|
async def json_schema(request: Request) -> JSONResponse:
|
|
437
457
|
"Dump the LDAP schema as JSON"
|
|
438
|
-
|
|
458
|
+
if getattr(request.app.state, "schema", None) is None:
|
|
459
|
+
connection = request.state.ldap
|
|
460
|
+
# See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
|
|
461
|
+
_dn, sub_schema = await unique(
|
|
462
|
+
connection,
|
|
463
|
+
connection.search(
|
|
464
|
+
settings.SCHEMA_DN,
|
|
465
|
+
ldap.SCOPE_BASE,
|
|
466
|
+
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
request.app.state.schema = SubSchema(sub_schema, check_uniqueness=2)
|
|
470
|
+
|
|
471
|
+
schema = frontend_schema(request.app.state.schema)
|
|
472
|
+
return JSONResponse(schema.model_dump())
|
ldap_ui/ldap_helpers.py
CHANGED
|
@@ -11,6 +11,7 @@ operation to complete without results.
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import contextlib
|
|
14
|
+
from http import HTTPStatus
|
|
14
15
|
from typing import AsyncGenerator, Generator, Tuple
|
|
15
16
|
|
|
16
17
|
import ldap
|
|
@@ -83,9 +84,12 @@ async def unique(
|
|
|
83
84
|
res = r
|
|
84
85
|
else:
|
|
85
86
|
connection.abandon(msgid)
|
|
86
|
-
raise HTTPException(
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
89
|
+
"Non-unique result",
|
|
90
|
+
)
|
|
87
91
|
if res is None:
|
|
88
|
-
raise HTTPException(
|
|
92
|
+
raise HTTPException(HTTPStatus.NOT_FOUND.value, "Empty search result")
|
|
89
93
|
return res
|
|
90
94
|
|
|
91
95
|
|
|
@@ -97,7 +101,7 @@ async def empty(
|
|
|
97
101
|
|
|
98
102
|
async for r in result(connection, msgid):
|
|
99
103
|
connection.abandon(msgid)
|
|
100
|
-
raise HTTPException(
|
|
104
|
+
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR.value, "Unexpected result")
|
|
101
105
|
|
|
102
106
|
|
|
103
107
|
async def get_entry_by_dn(
|
|
@@ -109,4 +113,4 @@ async def get_entry_by_dn(
|
|
|
109
113
|
try:
|
|
110
114
|
return await unique(connection, connection.search(dn, ldap.SCOPE_BASE))
|
|
111
115
|
except ldap.NO_SUCH_OBJECT:
|
|
112
|
-
raise HTTPException(
|
|
116
|
+
raise HTTPException(HTTPStatus.NOT_FOUND.value, f"DN not found: {dn}")
|
ldap_ui/schema.py
CHANGED
|
@@ -6,125 +6,155 @@ to determine how individual attributes should be presented
|
|
|
6
6
|
to the user.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
from typing import Generator, Optional, Type, TypeVar, Union
|
|
10
11
|
|
|
11
12
|
from ldap.schema import SubSchema
|
|
12
|
-
from ldap.schema.models import AttributeType,
|
|
13
|
+
from ldap.schema.models import AttributeType, SchemaElement
|
|
14
|
+
from ldap.schema.models import LDAPSyntax as LDAPSyntaxType
|
|
15
|
+
from ldap.schema.models import ObjectClass as ObjectClassType
|
|
16
|
+
from pydantic import BaseModel, Field, field_serializer
|
|
13
17
|
|
|
14
|
-
__all__ = ("frontend_schema",)
|
|
18
|
+
__all__ = ("frontend_schema", "Attribute", "ObjectClass")
|
|
15
19
|
|
|
20
|
+
T = TypeVar("T", bound=SchemaElement)
|
|
16
21
|
|
|
17
|
-
# Object class constants
|
|
18
|
-
SCHEMA_OC_KIND = {
|
|
19
|
-
0: "structural",
|
|
20
|
-
1: "abstract",
|
|
21
|
-
2: "auxiliary",
|
|
22
|
-
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
0: "userApplications",
|
|
27
|
-
1: "directoryOperation",
|
|
28
|
-
2: "distributedOperation",
|
|
29
|
-
3: "dSAOperation",
|
|
30
|
-
}
|
|
23
|
+
class Element(BaseModel):
|
|
24
|
+
"Common attributes od schema elements"
|
|
31
25
|
|
|
26
|
+
oid: str
|
|
27
|
+
name: str
|
|
28
|
+
names: list[str] = Field(min_length=1)
|
|
29
|
+
desc: Optional[str]
|
|
30
|
+
obsolete: bool
|
|
31
|
+
sup: list[str] # TODO check
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
|
|
34
|
+
def element(obj: Union[AttributeType, ObjectClassType]) -> Element:
|
|
35
35
|
name = obj.names[0]
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def object_class_dict(obj) -> dict:
|
|
47
|
-
"Additional information about an object class"
|
|
48
|
-
r = element(obj)
|
|
49
|
-
r.update(
|
|
50
|
-
{
|
|
51
|
-
"may": sorted(obj.may),
|
|
52
|
-
"must": sorted(obj.must),
|
|
53
|
-
"kind": SCHEMA_OC_KIND[obj.kind],
|
|
54
|
-
}
|
|
55
|
-
)
|
|
56
|
-
return r
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def attribute_dict(obj) -> dict:
|
|
60
|
-
"Additional information about an attribute"
|
|
61
|
-
r = element(obj)
|
|
62
|
-
r.update(
|
|
63
|
-
{
|
|
64
|
-
"single_value": bool(obj.single_value),
|
|
65
|
-
"no_user_mod": bool(obj.no_user_mod),
|
|
66
|
-
"usage": SCHEMA_ATTR_USAGE[obj.usage],
|
|
67
|
-
# FIXME avoid null values below
|
|
68
|
-
"equality": obj.equality,
|
|
69
|
-
"syntax": obj.syntax,
|
|
70
|
-
"substr": obj.substr,
|
|
71
|
-
"ordering": obj.ordering,
|
|
72
|
-
}
|
|
36
|
+
return Element(
|
|
37
|
+
oid=obj.oid,
|
|
38
|
+
name=name[:1].lower() + name[1:],
|
|
39
|
+
names=obj.names,
|
|
40
|
+
desc=obj.desc,
|
|
41
|
+
obsolete=bool(obj.obsolete),
|
|
42
|
+
sup=sorted(obj.sup),
|
|
73
43
|
)
|
|
74
|
-
return r
|
|
75
44
|
|
|
76
45
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
46
|
+
class ObjectClass(Element):
|
|
47
|
+
class Kind(IntEnum):
|
|
48
|
+
structural = 0
|
|
49
|
+
abstract = 1
|
|
50
|
+
auxiliary = 2
|
|
51
|
+
|
|
52
|
+
may: list[str]
|
|
53
|
+
must: list[str]
|
|
54
|
+
kind: Kind
|
|
55
|
+
|
|
56
|
+
@field_serializer("kind")
|
|
57
|
+
def serialize_kind(self, kind: Kind, _info) -> str:
|
|
58
|
+
return kind.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Attribute(Element):
|
|
62
|
+
class Usage(IntEnum):
|
|
63
|
+
userApplications = 0
|
|
64
|
+
directoryOperation = 1
|
|
65
|
+
distributedOperation = 2
|
|
66
|
+
dSAOperation = 3
|
|
84
67
|
|
|
68
|
+
single_value: bool
|
|
69
|
+
no_user_mod: bool
|
|
70
|
+
usage: Usage
|
|
71
|
+
equality: Optional[str]
|
|
72
|
+
syntax: Optional[str]
|
|
73
|
+
substr: Optional[str]
|
|
74
|
+
ordering: Optional[str]
|
|
85
75
|
|
|
86
|
-
|
|
76
|
+
@field_serializer("usage")
|
|
77
|
+
def serialize_kind(self, usage: Usage, _info) -> str:
|
|
78
|
+
return usage.name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Syntax(BaseModel):
|
|
82
|
+
oid: str
|
|
83
|
+
desc: str
|
|
84
|
+
not_human_readable: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def lowercase_dict(attr: str, items: list[T]) -> dict[str, T]:
|
|
87
88
|
"Create an dictionary with lowercased keys extracted from a given attribute"
|
|
88
|
-
return {obj
|
|
89
|
+
return {getattr(obj, attr).lower(): obj for obj in items}
|
|
89
90
|
|
|
90
91
|
|
|
91
92
|
def extract_type(
|
|
92
|
-
sub_schema: SubSchema, schema_class:
|
|
93
|
-
) -> Generator[
|
|
93
|
+
sub_schema: SubSchema, schema_class: Type[T]
|
|
94
|
+
) -> Generator[T, None, None]:
|
|
94
95
|
"Get non-obsolete objects from the schema for a type"
|
|
95
96
|
|
|
96
97
|
for oid in sub_schema.listall(schema_class):
|
|
97
98
|
obj = sub_schema.get_obj(schema_class, oid)
|
|
98
|
-
if schema_class is
|
|
99
|
+
if schema_class is LDAPSyntaxType or not obj.obsolete:
|
|
99
100
|
yield obj
|
|
100
101
|
|
|
101
102
|
|
|
103
|
+
class Schema(BaseModel):
|
|
104
|
+
attributes: dict[str, Attribute]
|
|
105
|
+
objectClasses: dict[str, ObjectClass]
|
|
106
|
+
syntaxes: dict[str, Syntax]
|
|
107
|
+
|
|
108
|
+
|
|
102
109
|
# See: https://www.python-ldap.org/en/latest/reference/ldap-schema.html
|
|
103
|
-
def frontend_schema(sub_schema: SubSchema) ->
|
|
110
|
+
def frontend_schema(sub_schema: SubSchema) -> Schema:
|
|
104
111
|
"Dump an LDAP SubSchema"
|
|
105
112
|
|
|
106
|
-
return
|
|
113
|
+
return Schema(
|
|
107
114
|
attributes=lowercase_dict(
|
|
108
115
|
"name",
|
|
109
116
|
sorted(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
(
|
|
118
|
+
Attribute(
|
|
119
|
+
single_value=bool(attr.single_value),
|
|
120
|
+
no_user_mod=bool(attr.no_user_mod),
|
|
121
|
+
usage=Attribute.Usage(attr.usage),
|
|
122
|
+
# FIXME avoid null values below
|
|
123
|
+
equality=attr.equality,
|
|
124
|
+
syntax=attr.syntax,
|
|
125
|
+
substr=attr.substr,
|
|
126
|
+
ordering=attr.ordering,
|
|
127
|
+
**element(attr).model_dump(),
|
|
128
|
+
)
|
|
129
|
+
for attr in extract_type(sub_schema, AttributeType)
|
|
113
130
|
),
|
|
114
|
-
key=lambda x: x
|
|
131
|
+
key=lambda x: x.name,
|
|
115
132
|
),
|
|
116
133
|
),
|
|
117
134
|
objectClasses=lowercase_dict(
|
|
118
135
|
"name",
|
|
119
136
|
sorted(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
(
|
|
138
|
+
ObjectClass(
|
|
139
|
+
may=sorted(oc.may),
|
|
140
|
+
must=sorted(oc.must),
|
|
141
|
+
kind=ObjectClass.Kind(oc.kind),
|
|
142
|
+
**element(oc).model_dump(),
|
|
143
|
+
)
|
|
144
|
+
for oc in extract_type(sub_schema, ObjectClassType)
|
|
123
145
|
),
|
|
124
|
-
key=lambda x: x
|
|
146
|
+
key=lambda x: x.name,
|
|
125
147
|
),
|
|
126
148
|
),
|
|
127
149
|
syntaxes=lowercase_dict(
|
|
128
|
-
"oid",
|
|
150
|
+
"oid",
|
|
151
|
+
[
|
|
152
|
+
Syntax(
|
|
153
|
+
oid=stx.oid,
|
|
154
|
+
desc=stx.desc,
|
|
155
|
+
not_human_readable=bool(stx.not_human_readable),
|
|
156
|
+
)
|
|
157
|
+
for stx in extract_type(sub_schema, LDAPSyntaxType)
|
|
158
|
+
],
|
|
129
159
|
),
|
|
130
160
|
)
|
ldap_ui/settings.py
CHANGED
|
@@ -39,11 +39,16 @@ def GET_BIND_DN(username) -> Optional[str]:
|
|
|
39
39
|
if config("BIND_DN", default=None):
|
|
40
40
|
return config("BIND_DN")
|
|
41
41
|
|
|
42
|
+
return GET_BIND_PATTERN(username)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def GET_BIND_PATTERN(username) -> Optional[str]:
|
|
46
|
+
"Determine the bind pattern from the environment and request"
|
|
42
47
|
# Optional user DN pattern string for authentication,
|
|
43
48
|
# e.g. "uid=%s,ou=people,dc=example,dc=com".
|
|
44
49
|
# This can be used to authenticate with directories
|
|
45
50
|
# that do not allow anonymous users to search.
|
|
46
|
-
|
|
51
|
+
if config("BIND_PATTERN", default=None) and username:
|
|
47
52
|
return config("BIND_PATTERN") % username
|
|
48
53
|
|
|
49
54
|
|