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]