This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new 0cbfa7a  Add an example LDAP query test interface for admins
0cbfa7a is described below

commit 0cbfa7afd6ff3b9ae02532f7619f56bae101b6c6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue May 20 20:46:48 2025 +0100

    Add an example LDAP query test interface for admins
---
 atr/blueprints/admin/admin.py                   | 123 +++++++++++++++++++++++-
 atr/blueprints/admin/templates/ldap-lookup.html |  93 ++++++++++++++++++
 atr/config.py                                   |  13 +++
 poetry.lock                                     |  85 +++++++++++++++-
 pyproject.toml                                  |   1 +
 5 files changed, 312 insertions(+), 3 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 2c5dc5b..839c625 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -27,8 +27,10 @@ import aiofiles.os
 import aioshutil
 import asfquart
 import asfquart.base as base
-import asfquart.session as session
+import asfquart.session
 import httpx
+import ldap3
+import ldap3.utils.conv as conv
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
@@ -42,6 +44,10 @@ import atr.util as util
 
 _LOGGER: Final = logging.getLogger(__name__)
 
+LDAP_ATTRIBUTES: Final[list[str]] = ["uid", "cn", "mail", "asf-altEmail", 
"displayName"]
+LDAP_SEARCH_BASE: Final[str] = "ou=people,dc=apache,dc=org"
+LDAP_SERVER_HOST: Final[str] = "ldap-eu.apache.org"
+
 
 class DeleteReleaseForm(util.QuartFormTyped):
     """Form for deleting releases."""
@@ -58,6 +64,18 @@ class DeleteReleaseForm(util.QuartFormTyped):
     submit = wtforms.SubmitField("Delete selected releases permanently")
 
 
+class LdapLookupForm(util.QuartFormTyped):
+    uid = wtforms.StringField(
+        "ASF UID (optional)",
+        render_kw={"placeholder": "Enter ASF UID, e.g. johnsmith"},
+    )
+    email = wtforms.StringField(
+        "Email address (optional)",
+        render_kw={"placeholder": "Enter email address, e.g. 
[email protected]"},
+    )
+    submit = wtforms.SubmitField("Lookup")
+
+
 @admin.BLUEPRINT.route("/data")
 @admin.BLUEPRINT.route("/data/<model>")
 async def admin_data(model: str = "Committee") -> str:
@@ -298,7 +316,7 @@ async def admin_toggle_admin_view_page() -> str:
 async def admin_toggle_view() -> response.Response:
     await util.validate_empty_form()
 
-    web_session = await session.read()
+    web_session = await asfquart.session.read()
     if web_session is None:
         # For the type checker
         # We should pass this as an argument, then it's guaranteed
@@ -322,6 +340,107 @@ async def admin_toggle_view() -> response.Response:
     return quart.redirect(referrer or quart.url_for("admin.admin_data"))
 
 
[email protected]("/ldap/", methods=["GET"])
+async def ldap() -> str:
+    form = await LdapLookupForm.create_form(data=quart.request.args)
+    results: list[dict[str, str | list[str]]] = []
+    error_message: str | None = None
+    server_info_for_debug: str | None = None
+    detailed_error_info: str | None = None
+    asf_id_for_template: str | None = None
+
+    web_session = await asfquart.session.read()
+    if web_session and web_session.uid:
+        asf_id_for_template = web_session.uid
+
+    uid_query = form.uid.data
+    email_query = form.email.data
+
+    ldap_querying: bool = bool((quart.request.method == "GET") and (uid_query 
or email_query))
+    if ldap_querying:
+        bind_dn = quart.current_app.config.get("LDAP_BIND_DN")
+        bind_password = quart.current_app.config.get("LDAP_BIND_PASSWORD")
+
+        results, error_message, server_info_for_debug, detailed_error_info = 
await _ldap_lookup_perform_search(
+            uid_query, email_query, bind_dn, bind_password
+        )
+
+    return await quart.render_template(
+        "ldap-lookup.html",
+        form=form,
+        results=results,
+        error_message=error_message,
+        asf_id=asf_id_for_template,
+        ldap_query_performed=ldap_querying,
+        server_info_for_debug=server_info_for_debug,
+        detailed_error_info=detailed_error_info,
+    )
+
+
+async def _ldap_lookup_perform_search(
+    uid_query: str | None,
+    email_query: str | None,
+    bind_dn_from_config: str | None,
+    bind_password_from_config: str | None,
+) -> tuple[list[dict[str, str | list[str]]], str | None, str | None, str | 
None]:
+    results_list: list[dict[str, str | list[str]]] = []
+    err_msg: str | None = None
+    srv_info: str | None = None
+    detail_err: str | None = None
+
+    try:
+        server = ldap3.Server(LDAP_SERVER_HOST, use_ssl=True, 
get_info=ldap3.ALL)
+        srv_info = repr(server)
+
+        if bind_dn_from_config and bind_password_from_config:
+            conn = ldap3.Connection(
+                server, user=bind_dn_from_config, 
password=bind_password_from_config, auto_bind=True
+            )
+        else:
+            conn = ldap3.Connection(server, auto_bind=True)
+
+        filters: list[str] = []
+        if uid_query:
+            filters.append(f"(uid={conv.escape_filter_chars(uid_query)})")
+
+        if email_query:
+            escaped_email = conv.escape_filter_chars(email_query)
+            if email_query.endswith("@apache.org"):
+                filters.append(f"(mail={escaped_email})")
+            else:
+                filters.append(f"(asf-altEmail={escaped_email})")
+
+        if not filters:
+            err_msg = "Please provide a UID or an email address to search."
+        else:
+            search_filter = f"(&{''.join(filters)})" if (len(filters) > 1) 
else filters[0]
+            conn.search(
+                search_base=LDAP_SEARCH_BASE,
+                search_filter=search_filter,
+                attributes=LDAP_ATTRIBUTES,
+            )
+            for entry in conn.entries:
+                result_item: dict[str, str | list[str]] = {"dn": 
entry.entry_dn}
+                result_item.update(entry.entry_attributes_as_dict)
+                results_list.append(result_item)
+
+            if (not results_list) and (not err_msg):
+                err_msg = "No results found for the given criteria."
+        if conn.bound:
+            conn.unbind()
+    # except exceptions.LDAPSocketOpenError as e:
+    #     err_msg = f"LDAP Socket Open Error: {e!s}"
+    #     detail_err = f"Details: {e.args}"
+    # except exceptions.LDAPException as e:
+    #     err_msg = f"LDAP Error: {e!s}"
+    #     detail_err = f"Details: {e.args}"
+    except Exception as e:
+        err_msg = f"An unexpected error occurred: {e!s}"
+        detail_err = f"Details: {e.args}"
+
+    return results_list, err_msg, srv_info, detail_err
+
+
 
@admin.BLUEPRINT.route("/ongoing-tasks/<project_name>/<version_name>/<revision>")
 async def ongoing_tasks(project_name: str, version_name: str, revision: str) 
-> quart.wrappers.response.Response:
     try:
diff --git a/atr/blueprints/admin/templates/ldap-lookup.html 
b/atr/blueprints/admin/templates/ldap-lookup.html
new file mode 100644
index 0000000..a7e2813
--- /dev/null
+++ b/atr/blueprints/admin/templates/ldap-lookup.html
@@ -0,0 +1,93 @@
+{% extends "layouts/base-admin.html" %}
+
+{% block title %}
+  LDAP lookup ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Perform a lookup in the ASF LDAP directory.
+{% endblock description %}
+
+{% block content %}
+  <h1>LDAP lookup</h1>
+  <p>Query the ASF LDAP directory for user information.</p>
+
+  <div class="card mb-4">
+    <div class="card-header">
+      <h5 class="mb-0">Search criteria</h5>
+    </div>
+    <div class="card-body">
+      <form method="get" action="{{ url_for('admin.ldap') }}">
+        {{ form.csrf_token }}
+        <div class="mb-3">
+          {{ form.uid.label(class="form-label") }}
+          {{ form.uid(class="form-control") }}
+          {% if form.uid.errors %}<div class="invalid-feedback d-block">{{ 
form.uid.errors|join(", ") }}</div>{% endif %}
+        </div>
+        <div class="mb-3">
+          {{ form.email.label(class="form-label") }}
+          {{ form.email(class="form-control") }}
+          {% if form.email.errors %}<div class="invalid-feedback d-block">{{ 
form.email.errors|join(", ") }}</div>{% endif %}
+        </div>
+        {{ form.submit(class="btn btn-primary") }}
+      </form>
+    </div>
+  </div>
+
+  {% if ldap_query_performed %}
+    <div class="card">
+      <div class="card-header">
+        <h5 class="mb-0">Lookup results</h5>
+      </div>
+      <div class="card-body">
+        {% if error_message %}
+          <div class="alert alert-danger" role="alert">
+            <p class="fw-bold">{{ error_message }}</p>
+            {% if server_info_for_debug %}
+              <p class="mb-1">Attempted server configuration:</p>
+              <pre class="small bg-light p-2 rounded"><code>{{ 
server_info_for_debug }}</code></pre>
+            {% endif %}
+            {% if detailed_error_info %}
+              <p class="mb-1 mt-2">Detailed error information:</p>
+              <pre class="small bg-light p-2 rounded"><code>{{ 
detailed_error_info }}</code></pre>
+            {% endif %}
+          </div>
+        {% elif results %}
+          <table class="table table-striped table-hover">
+            <thead>
+              <tr>
+                <th>DN</th>
+                <th>UID</th>
+                <th>CN</th>
+                <th>Mail</th>
+                <th>Alt Email</th>
+                <th>Display Name</th>
+              </tr>
+            </thead>
+            <tbody>
+              {% for result in results %}
+                <tr>
+                  <td>{{ result.get('dn', 'N/A') }}</td>
+                  <td>{{ result.get('uid', ['N/A'])[0] }}</td>
+                  <td>{{ result.get('cn', ['N/A'])[0] }}</td>
+                  <td>{{ result.get('mail', ['N/A'])[0] }}</td>
+                  <td>
+                    {% set alt_emails = result.get("asf-altEmail") %}
+                    {% if alt_emails %}
+                      {{ alt_emails|join(", ") }}
+                    {% else %}
+                      N/A
+                    {% endif %}
+                  </td>
+                  <td>{{ result.get('displayName', ['N/A'])[0] }}</td>
+                </tr>
+              {% endfor %}
+            </tbody>
+          </table>
+        {% else %}
+          <div class="alert alert-info" role="alert">No results found for the 
given criteria.</div>
+        {% endif %}
+      </div>
+    </div>
+  {% endif %}
+{% endblock content %}
diff --git a/atr/config.py b/atr/config.py
index a462348..6cd84e9 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -26,12 +26,25 @@ _MB: Final = 1024 * 1024
 _GB: Final = 1024 * _MB
 
 
+def _config_secrets(key: str, state_dir: str, default: str | None = None, 
cast: type = str) -> str | None:
+    secrets_path = os.path.join(state_dir, "secrets.ini")
+    try:
+        repo_ini = decouple.RepositoryIni(secrets_path)
+        config_obj = decouple.Config(repo_ini)
+        return config_obj.get(key, default=default, cast=cast)
+    except FileNotFoundError:
+        return decouple.config(key, default=default, cast=cast)
+
+
 class AppConfig:
     APP_HOST = decouple.config("APP_HOST", default="localhost")
     SSH_HOST = decouple.config("SSH_HOST", default="0.0.0.0")
     SSH_PORT = decouple.config("SSH_PORT", default=2222, cast=int)
     PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
     STATE_DIR = decouple.config("STATE_DIR", 
default=os.path.join(PROJECT_ROOT, "state"))
+    LDAP_BIND_DN = _config_secrets("LDAP_BIND_DN", STATE_DIR, default=None, 
cast=str)
+    LDAP_BIND_PASSWORD = _config_secrets("LDAP_BIND_PASSWORD", STATE_DIR, 
default=None, cast=str)
+
     DEBUG = False
     TEMPLATES_AUTO_RELOAD = False
     USE_BLOCKBUSTER = False
diff --git a/poetry.lock b/poetry.lock
index 2652a5c..b9f6af2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1332,6 +1332,26 @@ files = [
 [package.extras]
 dev = ["build (==1.2.2.post1)", "coverage (==7.5.4) ; python_version < 
\"3.9\"", "coverage (==7.8.0) ; python_version >= \"3.9\"", "mypy (==1.14.1) ; 
python_version < \"3.9\"", "mypy (==1.15.0) ; python_version >= \"3.9\"", "pip 
(==25.0.1)", "pylint (==3.2.7) ; python_version < \"3.9\"", "pylint (==3.3.6) ; 
python_version >= \"3.9\"", "ruff (==0.11.2)", "twine (==6.1.0)", "uv 
(==0.6.11)"]
 
+[[package]]
+name = "ldap3"
+version = "2.10.2rc2"
+description = "A pure Python LDAPv3 client library strictly conforming to RFC 
4510"
+optional = false
+python-versions = ">=3.4"
+groups = ["main"]
+files = [
+    {file = "ldap3-2.10.2rc2-py3-none-any.whl", hash = 
"sha256:6986d0484c926daca557b4ad1dc08d3a9a776cf949591d485b6346a83efc0e69"},
+    {file = "ldap3-2.10.2rc2.tar.gz", hash = 
"sha256:0c4304f6d86ef2a5600e7f2258fa833049b77d812ec7de7da15e50531c852ec0"},
+]
+
+[package.dependencies]
+pyasn1 = ">=0.4.6"
+pycryptodomex = "*"
+
+[package.extras]
+gssapi = ["gssapi"]
+winkerberos = ["winkerberos"]
+
 [[package]]
 name = "mako"
 version = "1.3.10"
@@ -1909,6 +1929,18 @@ files = [
     {file = "propcache-0.3.1.tar.gz", hash = 
"sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"},
 ]
 
+[[package]]
+name = "pyasn1"
+version = "0.6.1"
+description = "Pure-Python implementation of ASN.1 types and DER/BER/CER 
codecs (X.208)"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "pyasn1-0.6.1-py3-none-any.whl", hash = 
"sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
+    {file = "pyasn1-0.6.1.tar.gz", hash = 
"sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
+]
+
 [[package]]
 name = "pycparser"
 version = "2.22"
@@ -1921,6 +1953,57 @@ files = [
     {file = "pycparser-2.22.tar.gz", hash = 
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
 ]
 
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+description = "Cryptographic library for Python"
+optional = false
+python-versions = 
"!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["main"]
+files = [
+    {file = "pycryptodomex-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = 
"sha256:add243d204e125f189819db65eed55e6b4713f70a7e9576c043178656529cec7"},
+    {file = "pycryptodomex-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = 
"sha256:1c6d919fc8429e5cb228ba8c0d4d03d202a560b421c14867a65f6042990adc8e"},
+    {file = "pycryptodomex-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = 
"sha256:1c3a65ad441746b250d781910d26b7ed0a396733c6f2dbc3327bd7051ec8a541"},
+    {file = "pycryptodomex-3.23.0-cp27-cp27m-win32.whl", hash = 
"sha256:47f6d318fe864d02d5e59a20a18834819596c4ed1d3c917801b22b92b3ffa648"},
+    {file = "pycryptodomex-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = 
"sha256:d9825410197a97685d6a1fa2a86196430b01877d64458a20e95d4fd00d739a08"},
+    {file = "pycryptodomex-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash 
= "sha256:267a3038f87a8565bd834317dbf053a02055915acf353bf42ededb9edaf72010"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", 
hash = 
"sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash 
= "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d"},
+    {file = 
"pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
 hash = 
"sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa"},
+    {file = 
"pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
 hash = 
"sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8"},
+    {file = 
"pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
 hash = 
"sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", 
hash = 
"sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = 
"sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash 
= "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = 
"sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = 
"sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708"},
+    {file = "pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = 
"sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash 
= "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = 
"sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545"},
+    {file = 
"pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
 hash = 
"sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587"},
+    {file = 
"pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
 hash = 
"sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c"},
+    {file = 
"pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
 hash = 
"sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = 
"sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = 
"sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = 
"sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = 
"sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = 
"sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9"},
+    {file = "pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = 
"sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51"},
+    {file = "pycryptodomex-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash 
= "sha256:febec69c0291efd056c65691b6d9a339f8b4bc43c6635b8699471248fe897fea"},
+    {file = "pycryptodomex-3.23.0-pp27-pypy_73-win32.whl", hash = 
"sha256:c84b239a1f4ec62e9c789aafe0543f0594f0acd90c8d9e15bcece3efe55eca66"},
+    {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", 
hash = 
"sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5"},
+    {file = 
"pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
 hash = 
"sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798"},
+    {file = 
"pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
 hash = 
"sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f"},
+    {file = 
"pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
 hash = 
"sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea"},
+    {file = "pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = 
"sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe"},
+    {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", 
hash = 
"sha256:7de1e40a41a5d7f1ac42b6569b10bcdded34339950945948529067d8426d2785"},
+    {file = 
"pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
 hash = 
"sha256:bffc92138d75664b6d543984db7893a628559b9e78658563b0395e2a5fb47ed9"},
+    {file = 
"pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
 hash = 
"sha256:df027262368334552db2c0ce39706b3fb32022d1dce34673d0f9422df004b96a"},
+    {file = 
"pycryptodomex-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl",
 hash = 
"sha256:4e79f1aaff5a3a374e92eb462fa9e598585452135012e2945f96874ca6eeb1ff"},
+    {file = "pycryptodomex-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = 
"sha256:27e13c80ac9a0a1d050ef0a7e0a18cc04c8850101ec891815b6c5a0375e8a245"},
+    {file = "pycryptodomex-3.23.0.tar.gz", hash = 
"sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da"},
+]
+
 [[package]]
 name = "pydantic"
 version = "2.11.4"
@@ -2996,4 +3079,4 @@ propcache = ">=0.2.1"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"6ff06f78389576f0ae1fdda7a7547d7fa6d32cca37acf7eacecaf52108e7e20f"
+content-hash = 
"2915c8c242f599b301912a5b4c5f8f4f6d82156a3e69b27404de109618d935ea"
diff --git a/pyproject.toml b/pyproject.toml
index 8117ff3..bfec183 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ dependencies = [
   "greenlet>=3.1.1,<4.0.0",
   "httpx~=0.27",
   "hypercorn~=0.17",
+  "ldap3 (==2.10.2rc2)",
   "python-decouple~=3.8",
   "python-gnupg~=0.5",
   "quart-schema[pydantic]~=0.21",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to