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 dcc7ba2  Perform an LDAP search for keys without an ASF email UID
dcc7ba2 is described below

commit dcc7ba274dfaa78bfa435d25d41ad2f522adef9e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed May 21 16:19:45 2025 +0100

    Perform an LDAP search for keys without an ASF email UID
---
 atr/blueprints/admin/admin.py                   |  96 ++-------------------
 atr/blueprints/admin/templates/ldap-lookup.html |   2 +-
 atr/db/interaction.py                           |  23 ++++-
 atr/ldap.py                                     | 106 ++++++++++++++++++++++++
 4 files changed, 132 insertions(+), 95 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 65ebcc2..bf40c98 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -17,7 +17,6 @@
 
 import asyncio
 import collections
-import dataclasses
 import logging
 import os
 import pathlib
@@ -31,8 +30,6 @@ import asfquart
 import asfquart.base as base
 import asfquart.session
 import httpx
-import ldap3
-import ldap3.utils.conv as conv
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
@@ -42,14 +39,11 @@ import atr.datasources.apache as apache
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.db.models as models
+import atr.ldap as ldap
 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."""
@@ -78,20 +72,6 @@ class LdapLookupForm(util.QuartFormTyped):
     submit = wtforms.SubmitField("Lookup")
 
 
-# We use a dataclass to support ldap3.Connection objects
[email protected]
-class LdapSearchParams:
-    uid_query: str | None = None
-    email_query: str | None = None
-    bind_dn_from_config: str | None = None
-    bind_password_from_config: str | None = None
-    results_list: list[dict[str, str | list[str]]] = 
dataclasses.field(default_factory=list)
-    err_msg: str | None = None
-    srv_info: str | None = None
-    detail_err: str | None = None
-    connection: ldap3.Connection | None = None
-
-
 @admin.BLUEPRINT.route("/data")
 @admin.BLUEPRINT.route("/data/<model>")
 async def admin_data(model: str = "Committee") -> str:
@@ -357,7 +337,7 @@ async def admin_toggle_view() -> response.Response:
 
 
 @admin.BLUEPRINT.route("/ldap/", methods=["GET"])
-async def ldap() -> str:
+async def ldap_search() -> str:
     form = await LdapLookupForm.create_form(data=quart.request.args)
     asf_id_for_template: str | None = None
 
@@ -368,18 +348,18 @@ async def ldap() -> str:
     uid_query = form.uid.data
     email_query = form.email.data
 
-    ldap_params: LdapSearchParams | None = None
+    ldap_params: ldap.SearchParameters | None = None
     if (quart.request.method == "GET") and (uid_query or email_query):
         bind_dn = quart.current_app.config.get("LDAP_BIND_DN")
         bind_password = quart.current_app.config.get("LDAP_BIND_PASSWORD")
 
-        ldap_params = LdapSearchParams(
+        ldap_params = ldap.SearchParameters(
             uid_query=uid_query,
             email_query=email_query,
             bind_dn_from_config=bind_dn,
             bind_password_from_config=bind_password,
         )
-        await asyncio.to_thread(_ldap_lookup_perform_search, ldap_params)
+        await asyncio.to_thread(ldap.search, ldap_params)
 
     return await quart.render_template(
         "ldap-lookup.html",
@@ -390,72 +370,6 @@ async def ldap() -> str:
     )
 
 
-def _ldap_lookup_perform_search(params: LdapSearchParams) -> None:
-    try:
-        _ldap_lookup_perform_search_inner(params)
-    except Exception as e:
-        params.err_msg = f"An unexpected error occurred: {e!s}"
-        params.detail_err = f"Details: {e.args}"
-    finally:
-        if params.connection and params.connection.bound:
-            try:
-                params.connection.unbind()
-            except Exception:
-                ...
-
-
-def _ldap_lookup_perform_search_inner(params: LdapSearchParams) -> None:
-    params.results_list = []
-    params.err_msg = None
-    params.srv_info = None
-    params.detail_err = None
-    params.connection = None
-
-    server = ldap3.Server(LDAP_SERVER_HOST, use_ssl=True, get_info=ldap3.ALL)
-    params.srv_info = repr(server)
-
-    if params.bind_dn_from_config and params.bind_password_from_config:
-        params.connection = ldap3.Connection(
-            server, user=params.bind_dn_from_config, 
password=params.bind_password_from_config, auto_bind=True
-        )
-    else:
-        params.connection = ldap3.Connection(server, auto_bind=True)
-
-    filters: list[str] = []
-    if params.uid_query:
-        filters.append(f"(uid={conv.escape_filter_chars(params.uid_query)})")
-
-    if params.email_query:
-        escaped_email = conv.escape_filter_chars(params.email_query)
-        if params.email_query.endswith("@apache.org"):
-            filters.append(f"(mail={escaped_email})")
-        else:
-            filters.append(f"(asf-altEmail={escaped_email})")
-
-    if not filters:
-        params.err_msg = "Please provide a UID or an email address to search."
-        return
-
-    search_filter = f"(&{''.join(filters)})" if (len(filters) > 1) else 
filters[0]
-
-    if not params.connection:
-        params.err_msg = "LDAP Connection object not established or auto_bind 
failed."
-        return
-
-    params.connection.search(
-        search_base=LDAP_SEARCH_BASE,
-        search_filter=search_filter,
-        attributes=LDAP_ATTRIBUTES,
-    )
-    for entry in params.connection.entries:
-        result_item: dict[str, str | list[str]] = {"dn": entry.entry_dn}
-        result_item.update(entry.entry_attributes_as_dict)
-        params.results_list.append(result_item)
-
-    if (not params.results_list) and (not params.err_msg):
-        params.err_msg = "No results found for the given criteria."
-
-
 
@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
index f51c401..b9cd56b 100644
--- a/atr/blueprints/admin/templates/ldap-lookup.html
+++ b/atr/blueprints/admin/templates/ldap-lookup.html
@@ -17,7 +17,7 @@
       <h5 class="mb-0">Search criteria</h5>
     </div>
     <div class="card-body">
-      <form method="get" action="{{ url_for('admin.ldap') }}">
+      <form method="get" action="{{ url_for('admin.ldap_search') }}">
         {{ form.csrf_token }}
         <div class="mb-3">
           {{ form.uid.label(class="form-label") }}
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index e557597..b3a25be 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -31,6 +31,7 @@ import sqlmodel
 import atr.analysis as analysis
 import atr.db as db
 import atr.db.models as models
+import atr.ldap as ldap
 import atr.schema as schema
 import atr.user as user
 import atr.util as util
@@ -62,13 +63,15 @@ async def key_user_add(asf_uid: str | None, public_key: 
str, selected_committees
 
     # Determine ASF UID if not provided
     if asf_uid is None:
-        for uid in key["uids"]:
-            match = re.search(r"([A-Za-z0-9]+)@apache.org", uid)
-            if match:
+        for uid_str in key["uids"]:
+            if match := re.search(r"([A-Za-z0-9]+)@apache.org", uid_str):
                 asf_uid = match.group(1).lower()
                 break
         else:
             _LOGGER.warning(f"key_user_add called with no ASF UID found in key 
UIDs: {key.get('uids')}")
+            for uid_str in key.get("uids", []):
+                if asf_uid := await asyncio.to_thread(_asf_uid_from_uid_str, 
uid_str):
+                    break
     if asf_uid is None:
         # We place this here to make it easier on the type checkers
         raise RuntimeError("No Apache UID found in the key UIDs")
@@ -288,6 +291,20 @@ async def unfinished_releases(asfuid: str) -> dict[str, 
list[models.Release]]:
     return releases
 
 
+def _asf_uid_from_uid_str(uid_str: str) -> str | None:
+    if not (email_match := re.search(r"<([^>]+)>", uid_str)):
+        return None
+    email = email_match.group(1)
+    if email.endswith("@apache.org"):
+        return None
+    ldap_params = ldap.SearchParameters(email_query=email)
+    ldap.search(ldap_params)
+    if not (ldap_params.results_list and ("uid" in 
ldap_params.results_list[0])):
+        return None
+    ldap_uid_val = ldap_params.results_list[0]["uid"]
+    return ldap_uid_val[0] if isinstance(ldap_uid_val, list) else ldap_uid_val
+
+
 async def _key_user_add_validate_key_properties(public_key: str) -> 
tuple[dict, str]:
     """Validate GPG key string, import it, and return its properties and 
fingerprint."""
     import gnupg
diff --git a/atr/ldap.py b/atr/ldap.py
new file mode 100644
index 0000000..05985a3
--- /dev/null
+++ b/atr/ldap.py
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import dataclasses
+from typing import Final
+
+import ldap3
+import ldap3.utils.conv as conv
+
+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"
+
+
+# We use a dataclass to support ldap3.Connection objects
[email protected]
+class SearchParameters:
+    uid_query: str | None = None
+    email_query: str | None = None
+    bind_dn_from_config: str | None = None
+    bind_password_from_config: str | None = None
+    results_list: list[dict[str, str | list[str]]] = 
dataclasses.field(default_factory=list)
+    err_msg: str | None = None
+    srv_info: str | None = None
+    detail_err: str | None = None
+    connection: ldap3.Connection | None = None
+
+
+def search(params: SearchParameters) -> None:
+    try:
+        _search_core(params)
+    except Exception as e:
+        params.err_msg = f"An unexpected error occurred: {e!s}"
+        params.detail_err = f"Details: {e.args}"
+    finally:
+        if params.connection and params.connection.bound:
+            try:
+                params.connection.unbind()
+            except Exception:
+                ...
+
+
+def _search_core(params: SearchParameters) -> None:
+    params.results_list = []
+    params.err_msg = None
+    params.srv_info = None
+    params.detail_err = None
+    params.connection = None
+
+    server = ldap3.Server(LDAP_SERVER_HOST, use_ssl=True, get_info=ldap3.ALL)
+    params.srv_info = repr(server)
+
+    if params.bind_dn_from_config and params.bind_password_from_config:
+        params.connection = ldap3.Connection(
+            server, user=params.bind_dn_from_config, 
password=params.bind_password_from_config, auto_bind=True
+        )
+    else:
+        params.connection = ldap3.Connection(server, auto_bind=True)
+
+    filters: list[str] = []
+    if params.uid_query:
+        filters.append(f"(uid={conv.escape_filter_chars(params.uid_query)})")
+
+    if params.email_query:
+        escaped_email = conv.escape_filter_chars(params.email_query)
+        if params.email_query.endswith("@apache.org"):
+            filters.append(f"(mail={escaped_email})")
+        else:
+            filters.append(f"(asf-altEmail={escaped_email})")
+
+    if not filters:
+        params.err_msg = "Please provide a UID or an email address to search."
+        return
+
+    search_filter = f"(&{''.join(filters)})" if (len(filters) > 1) else 
filters[0]
+
+    if not params.connection:
+        params.err_msg = "LDAP Connection object not established or auto_bind 
failed."
+        return
+
+    params.connection.search(
+        search_base=LDAP_SEARCH_BASE,
+        search_filter=search_filter,
+        attributes=LDAP_ATTRIBUTES,
+    )
+    for entry in params.connection.entries:
+        result_item: dict[str, str | list[str]] = {"dn": entry.entry_dn}
+        result_item.update(entry.entry_attributes_as_dict)
+        params.results_list.append(result_item)
+
+    if (not params.results_list) and (not params.err_msg):
+        params.err_msg = "No results found for the given criteria."


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

Reply via email to