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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.10.1"
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, Field
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
- meta: Meta
34
- changed: list[str] = Field(default_factory=list)
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, ObjectClass
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 = credentials.password
114
- dn = settings.GET_BIND_PATTERN(
115
- credentials.username
116
- ) or await anonymous_user_search(connection, credentials.username)
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
- 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 = [
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
- return Meta(
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
- required=required,
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
- ) -> ChangedAttributes:
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 not _is_binary(
284
- entry, attr, schema
285
- ) # FIXME Handle binary attributes properly
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 ChangedAttributes(changed=list(sorted(set(m[1] for m in modlist))))
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
- ) -> ChangedAttributes:
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 ChangedAttributes(changed=["dn"]) # Dummy
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
- if config("BIND_DN", default=None):
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]: