ldap-ui 0.9.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.
@@ -0,0 +1,112 @@
1
+ """
2
+ Utilities for asynchronous LDAP operations.
3
+
4
+ HTTP endpoints typically trigger asynchronous LDAP requests
5
+ which return an ID for the operation being performed.
6
+ Results are then gathered in non-blocking mode.
7
+
8
+ Some shorthands are provided for common usages
9
+ like retrieving a unique result or waiting for an
10
+ operation to complete without results.
11
+ """
12
+
13
+ import contextlib
14
+ from typing import AsyncGenerator, Generator, Tuple
15
+
16
+ import ldap
17
+ from anyio import sleep
18
+ from ldap.ldapobject import LDAPObject
19
+ from starlette.exceptions import HTTPException
20
+
21
+ from . import settings
22
+
23
+ __all__ = (
24
+ "empty",
25
+ "get_entry_by_dn",
26
+ "ldap_connect",
27
+ "result",
28
+ "unique",
29
+ "WITH_OPERATIONAL_ATTRS",
30
+ )
31
+
32
+
33
+ # Constant to add technical attributes in LDAP search results
34
+ WITH_OPERATIONAL_ATTRS = ("*", "+")
35
+
36
+
37
+ @contextlib.contextmanager
38
+ def ldap_connect() -> Generator[LDAPObject, None, None]:
39
+ "Open an LDAP connection"
40
+
41
+ url = settings.LDAP_URL
42
+ connection = ldap.initialize(url)
43
+
44
+ # #43 TLS, see https://stackoverflow.com/a/8795694
45
+ if settings.USE_TLS or settings.INSECURE_TLS:
46
+ cert_level = (
47
+ ldap.OPT_X_TLS_NEVER if settings.INSECURE_TLS else ldap.OPT_X_TLS_DEMAND
48
+ )
49
+
50
+ connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, cert_level)
51
+ # See https://stackoverflow.com/a/38136255
52
+ connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
53
+ if not url.startswith("ldaps://"):
54
+ connection.start_tls_s()
55
+ yield connection
56
+ connection.unbind_s()
57
+
58
+
59
+ async def result(
60
+ connection: LDAPObject, msgid: int
61
+ ) -> AsyncGenerator[Tuple[str, dict[str, list[bytes]]], None]:
62
+ "Stream LDAP result entries without blocking other tasks"
63
+
64
+ while True:
65
+ r_type, r_data = connection.result(msgid=msgid, all=0, timeout=0)
66
+ if r_type is None: # Throttle to 100 results / second
67
+ await sleep(0.01)
68
+ elif r_data == []: # Operation completed
69
+ break
70
+ else:
71
+ yield r_data[0]
72
+
73
+
74
+ async def unique(
75
+ connection: LDAPObject,
76
+ msgid: int,
77
+ ) -> Tuple[str, dict[str, list[bytes]]]:
78
+ "Asynchronously collect a unique result"
79
+
80
+ res = None
81
+ async for r in result(connection, msgid):
82
+ if res is None:
83
+ res = r
84
+ else:
85
+ connection.abandon(msgid)
86
+ raise HTTPException(500, "Non-unique result")
87
+ if res is None:
88
+ raise HTTPException(404, "Empty search result")
89
+ return res
90
+
91
+
92
+ async def empty(
93
+ connection: LDAPObject,
94
+ msgid: int,
95
+ ) -> None:
96
+ "Asynchronously wait for an empty result"
97
+
98
+ async for r in result(connection, msgid):
99
+ connection.abandon(msgid)
100
+ raise HTTPException(500, "Unexpected result")
101
+
102
+
103
+ async def get_entry_by_dn(
104
+ connection: LDAPObject,
105
+ dn: str,
106
+ ) -> Tuple[str, dict[str, list[bytes]]]:
107
+ "Asynchronously retrieve an LDAP entry by its DN"
108
+
109
+ try:
110
+ return await unique(connection, connection.search(dn, ldap.SCOPE_BASE))
111
+ except ldap.NO_SUCH_OBJECT:
112
+ raise HTTPException(404, f"DN not found: {dn}")
ldap_ui/schema.py ADDED
@@ -0,0 +1,130 @@
1
+ """
2
+ JSON-serializable representation of the LDAP directory schema.
3
+
4
+ It is used by the frontend for consistency checks, and
5
+ to determine how individual attributes should be presented
6
+ to the user.
7
+ """
8
+
9
+ from typing import Any, Generator
10
+
11
+ from ldap.schema import SubSchema
12
+ from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
13
+
14
+ __all__ = ("frontend_schema",)
15
+
16
+
17
+ # Object class constants
18
+ SCHEMA_OC_KIND = {
19
+ 0: "structural",
20
+ 1: "abstract",
21
+ 2: "auxiliary",
22
+ }
23
+
24
+ # Attribute usage constants
25
+ SCHEMA_ATTR_USAGE = {
26
+ 0: "userApplications",
27
+ 1: "directoryOperation",
28
+ 2: "distributedOperation",
29
+ 3: "dSAOperation",
30
+ }
31
+
32
+
33
+ def element(obj) -> dict:
34
+ "Basic information about an schema element"
35
+ name = obj.names[0]
36
+ return {
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),
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
+ }
73
+ )
74
+ return r
75
+
76
+
77
+ def syntax_dict(obj) -> dict:
78
+ "Information about an attribute syntax"
79
+ return {
80
+ "oid": obj.oid,
81
+ "desc": obj.desc,
82
+ "not_human_readable": bool(obj.not_human_readable),
83
+ }
84
+
85
+
86
+ def lowercase_dict(attr: str, items) -> dict:
87
+ "Create an dictionary with lowercased keys extracted from a given attribute"
88
+ return {obj[attr].lower(): obj for obj in items}
89
+
90
+
91
+ def extract_type(
92
+ sub_schema: SubSchema, schema_class: Any
93
+ ) -> Generator[Any, None, None]:
94
+ "Get non-obsolete objects from the schema for a type"
95
+
96
+ for oid in sub_schema.listall(schema_class):
97
+ obj = sub_schema.get_obj(schema_class, oid)
98
+ if schema_class is LDAPSyntax or not obj.obsolete:
99
+ yield obj
100
+
101
+
102
+ # See: https://www.python-ldap.org/en/latest/reference/ldap-schema.html
103
+ def frontend_schema(sub_schema: SubSchema) -> dict[Any]:
104
+ "Dump an LDAP SubSchema"
105
+
106
+ return dict(
107
+ attributes=lowercase_dict(
108
+ "name",
109
+ sorted(
110
+ map(
111
+ attribute_dict,
112
+ extract_type(sub_schema, AttributeType),
113
+ ),
114
+ key=lambda x: x["name"],
115
+ ),
116
+ ),
117
+ objectClasses=lowercase_dict(
118
+ "name",
119
+ sorted(
120
+ map(
121
+ object_class_dict,
122
+ extract_type(sub_schema, ObjectClass),
123
+ ),
124
+ key=lambda x: x["name"],
125
+ ),
126
+ ),
127
+ syntaxes=lowercase_dict(
128
+ "oid", map(syntax_dict, extract_type(sub_schema, LDAPSyntax))
129
+ ),
130
+ )
ldap_ui/settings.py ADDED
@@ -0,0 +1,85 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from starlette.config import Config
5
+
6
+ config = Config(".env")
7
+
8
+ # App settings
9
+ DEBUG = config("DEBUG", cast=lambda x: bool(x), default=False)
10
+ PREFERRED_URL_SCHEME = "https"
11
+ SECRET_KEY = os.urandom(16)
12
+
13
+ #
14
+ # LDAP settings
15
+ #
16
+ LDAP_URL = config("LDAP_URL", default="ldap:///")
17
+ BASE_DN = config("BASE_DN") # Always required
18
+
19
+ USE_TLS = config(
20
+ "USE_TLS",
21
+ cast=lambda x: bool(x),
22
+ default=LDAP_URL.startswith("ldaps://"),
23
+ )
24
+ INSECURE_TLS = config("INSECURE_TLS", cast=lambda x: bool(x), default=False)
25
+
26
+ SCHEMA_DN = config("SCHEMA_DN", default="cn=subschema")
27
+
28
+
29
+ #
30
+ # Binding
31
+ #
32
+ def GET_BIND_DN(username) -> Optional[str]:
33
+ "Try to determine the login DN from the environment and request"
34
+
35
+ # Use a hard-wired DN from the environment.
36
+ # If this is set and a GET_BIND_PASSWORD returns something,
37
+ # the UI will NOT ask for a login.
38
+ # You need to secure it otherwise!
39
+ if config("BIND_DN", default=None):
40
+ return config("BIND_DN")
41
+
42
+ # Optional user DN pattern string for authentication,
43
+ # e.g. "uid=%s,ou=people,dc=example,dc=com".
44
+ # This can be used to authenticate with directories
45
+ # that do not allow anonymous users to search.
46
+ elif config("BIND_PATTERN", default=None) and username:
47
+ return config("BIND_PATTERN") % username
48
+
49
+
50
+ def GET_BIND_DN_FILTER(username) -> str:
51
+ "Produce a LDAP search filter for the login DN"
52
+ return SEARCH_PATTERNS[0] % username
53
+
54
+
55
+ def GET_BIND_PASSWORD() -> Optional[str]:
56
+ "Try to determine the login password from the environment or request"
57
+
58
+ pw = config("BIND_PASSWORD", default=None)
59
+ if pw is not None:
60
+ return pw
61
+
62
+ pw_file = config("BIND_PASSWORD_FILE", default=None)
63
+ if pw_file is not None:
64
+ with open(pw_file) as file:
65
+ return file.read().rstrip("\n")
66
+
67
+
68
+ #
69
+ # Search
70
+ #
71
+
72
+ # Attribute to search for user names
73
+ LOGIN_ATTR = config("LOGIN_ATTR", default="uid")
74
+
75
+ # Search users by a number of common attributes
76
+ SEARCH_PATTERNS = (
77
+ "(%s=%%s)" % LOGIN_ATTR,
78
+ "(cn=%s*)",
79
+ "(gn=%s*)",
80
+ "(sn=%s*)",
81
+ )
82
+ SEARCH_QUERY_MIN = config(
83
+ "SEARCH_QUERY_MIN", cast=int, default=2
84
+ ) # Minimum length of query term
85
+ SEARCH_MAX = config("SEARCH_MAX", cast=int, default=50) # Maximum number of results