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 c9fdfb8 Allow wildcard LDAP queries for admins, and support LDAP
cache for key upload
c9fdfb8 is described below
commit c9fdfb8bb7ba6dc4905c594de69968364456d2ba
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jun 16 20:50:39 2025 +0100
Allow wildcard LDAP queries for admins, and support LDAP cache for key
upload
---
atr/blueprints/admin/admin.py | 8 +++-
atr/blueprints/admin/templates/ldap-lookup.html | 49 ++++++++++++++++++-------
atr/db/__init__.py | 19 ++++++++++
atr/db/interaction.py | 48 +++++++++++++++++++++---
atr/ldap.py | 10 ++++-
atr/util.py | 20 +++++++---
6 files changed, 126 insertions(+), 28 deletions(-)
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 9698726..14f769e 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -21,6 +21,7 @@ import logging
import os
import pathlib
import statistics
+import time
from collections.abc import Callable, Mapping
from typing import Any, Final
@@ -88,7 +89,7 @@ class DeleteReleaseForm(util.QuartFormTyped):
class LdapLookupForm(util.QuartFormTyped):
uid = wtforms.StringField(
"ASF UID (optional)",
- render_kw={"placeholder": "Enter ASF UID, e.g. johnsmith"},
+ render_kw={"placeholder": "Enter ASF UID, e.g. johnsmith, or * for
all"},
)
email = wtforms.StringField(
"Email address (optional)",
@@ -546,13 +547,17 @@ async def ldap_search() -> str:
bind_dn = quart.current_app.config.get("LDAP_BIND_DN")
bind_password = quart.current_app.config.get("LDAP_BIND_PASSWORD")
+ start = time.perf_counter_ns()
ldap_params = ldap.SearchParameters(
uid_query=uid_query,
email_query=email_query,
bind_dn_from_config=bind_dn,
bind_password_from_config=bind_password,
+ email_only=True,
)
await asyncio.to_thread(ldap.search, ldap_params)
+ end = time.perf_counter_ns()
+ _LOGGER.info("LDAP search took %d ms", (end - start) / 1000000)
return await template.render(
"ldap-lookup.html",
@@ -560,6 +565,7 @@ async def ldap_search() -> str:
ldap_params=ldap_params,
asf_id=asf_id_for_template,
ldap_query_performed=ldap_params is not None,
+ uid_query=uid_query,
)
diff --git a/atr/blueprints/admin/templates/ldap-lookup.html
b/atr/blueprints/admin/templates/ldap-lookup.html
index 91c1e89..cbecd9a 100644
--- a/atr/blueprints/admin/templates/ldap-lookup.html
+++ b/atr/blueprints/admin/templates/ldap-lookup.html
@@ -53,30 +53,51 @@
{% endif %}
</div>
{% elif ldap_params and ldap_params.results_list %}
- {% for result in ldap_params.results_list %}
- <table class="table table-sm table-striped">
+ {% if uid_query == '*' %}
+ <table class="table table-sm table-striped table-bordered">
<thead>
<tr>
- <th class="w-25">Attribute</th>
- <th>Value</th>
+ <th>UID</th>
+ <th>Full Name</th>
+ <th>Email</th>
</tr>
</thead>
<tbody>
- {% for key, value in result.items()|sort %}
+ {% for result in ldap_params.results_list %}
<tr>
- <td><strong>{{ key }}</strong></td>
- <td>
- {% if (value is iterable) and (value is not string) %}
- {{ value|join(", ") }}
- {% else %}
- {{ value }}
- {% endif %}
- </td>
+ <td>{{ result.get('uid', [''])[0] }}</td>
+ <td>{{ result.get('cn', [''])[0] }}</td>
+ <td>{{ result.get('mail', [''])[0] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
- {% endfor %}
+ {% else %}
+ {% for result in ldap_params.results_list %}
+ <table class="table table-sm table-striped">
+ <thead>
+ <tr>
+ <th class="w-25">Attribute</th>
+ <th>Value</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for key, value in result.items()|sort %}
+ <tr>
+ <td><strong>{{ key }}</strong></td>
+ <td>
+ {% if (value is iterable) and (value is not string) %}
+ {{ value|join(", ") }}
+ {% else %}
+ {{ value }}
+ {% endif %}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ {% endfor %}
+ {% endif %}
{% else %}
<div class="alert alert-info" role="alert">No results found for the
given criteria.</div>
{% endif %}
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 0e48e90..1af7501 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -34,6 +34,7 @@ import sqlmodel.sql.expression as expression
import atr.config as config
import atr.db.models as models
+import atr.schema as schema
import atr.util as util
if TYPE_CHECKING:
@@ -115,6 +116,24 @@ class Query(Generic[T]):
result = await self.session.execute(self.query)
return result.scalars().all()
+ async def bulk_upsert(self, items: list[schema.Strict], log_query: bool =
False) -> None:
+ if not items:
+ return
+
+ self.log_query("bulk_upsert", log_query)
+ model_class = self.query.column_descriptions[0]["type"]
+ stmt =
sqlalchemy.dialects.sqlite.insert(model_class).values([item.model_dump() for
item in items])
+ # TODO: The primary key might not be the index element
+ # For example, we might have a unique constraint on other columns
+ primary_keys = [key.name for key in
sqlalchemy.inspect(model_class).primary_key]
+ update_cols = {
+ col.name: getattr(stmt.excluded, col.name)
+ for col in sqlalchemy.inspect(model_class).c
+ if col.name not in primary_keys
+ }
+ stmt = stmt.on_conflict_do_update(index_elements=primary_keys,
set_=update_cols)
+ await self.session.execute(stmt)
+
# async def execute(self) -> sqlalchemy.Result[tuple[T]]:
# return await self.session.execute(self.query)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 7360a37..e4a4e1a 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -68,7 +68,9 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
yield str(temp_dir)
-async def key_user_add(asf_uid: str | None, public_key: str,
selected_committees: list[str]) -> list[dict]:
+async def key_user_add(
+ asf_uid: str | None, public_key: str, selected_committees: list[str],
ldap_data: dict[str, str] | None = None
+) -> list[dict]:
if not public_key:
raise PublicKeyError("Public key is required")
@@ -77,7 +79,7 @@ async def key_user_add(asf_uid: str | None, public_key: str,
selected_committees
added_keys = []
for key in keys:
- asf_uid = await util.asf_uid_from_uids(key.get("uids", []))
+ asf_uid = await util.asf_uid_from_uids(key.get("uids", []),
ldap_data=ldap_data)
# Store key in database
async with db.session() as data:
added = await key_user_session_add(asf_uid, public_key, key,
selected_committees, data)
@@ -279,7 +281,10 @@ async def unfinished_releases(asfuid: str) -> dict[str,
list[models.Release]]:
async def upload_keys(
- user_committees: list[str], keys_text: str, selected_committees: list[str]
+ user_committees: list[str],
+ keys_text: str,
+ selected_committees: list[str],
+ ldap_data: dict[str, str] | None = None,
) -> tuple[list[dict], int, int, list[str]]:
key_blocks = util.parse_key_blocks(keys_text)
if not key_blocks:
@@ -294,7 +299,36 @@ async def upload_keys(
submitted_committees = selected_committees[:]
# Process each key block
- results = await _upload_process_key_blocks(key_blocks, selected_committees)
+ results = await _upload_process_key_blocks(key_blocks,
selected_committees, ldap_data=ldap_data)
+ # if not results:
+ # raise InteractionError("No keys were added")
+
+ success_count = sum(1 for result in results if result["status"] ==
"success")
+ error_count = len(results) - success_count
+
+ return results, success_count, error_count, submitted_committees
+
+
+async def upload_keys_bytes(
+ user_committees: list[str],
+ keys_bytes: bytes,
+ selected_committees: list[str],
+ ldap_data: dict[str, str] | None = None,
+) -> tuple[list[dict], int, int, list[str]]:
+ key_blocks = util.parse_key_blocks_bytes(keys_bytes)
+ if not key_blocks:
+ raise InteractionError("No valid GPG keys found in the uploaded file")
+
+ # Ensure that the selected committees are ones of which the user is
actually a member
+ invalid_committees = [committee for committee in selected_committees if
(committee not in user_committees)]
+ if invalid_committees:
+ raise InteractionError(f"Invalid committee selection: {',
'.join(invalid_committees)}")
+
+ # TODO: Do we modify this? Store a copy just in case, for the template to
use
+ submitted_committees = selected_committees[:]
+
+ # Process each key block
+ results = await _upload_process_key_blocks(key_blocks,
selected_committees, ldap_data=ldap_data)
# if not results:
# raise InteractionError("No keys were added")
@@ -397,14 +431,16 @@ async def _successes_errors_warnings(
info.errors.setdefault(pathlib.Path(primary_rel_path),
[]).append(error)
-async def _upload_process_key_blocks(key_blocks: list[str],
selected_committees: list[str]) -> list[dict]:
+async def _upload_process_key_blocks(
+ key_blocks: list[str], selected_committees: list[str], ldap_data:
dict[str, str] | None = None
+) -> list[dict]:
"""Process GPG key blocks and add them to the user's account."""
results: list[dict] = []
# Process each key block
for i, key_block in enumerate(key_blocks):
try:
- added_keys = await key_user_add(None, key_block,
selected_committees)
+ added_keys = await key_user_add(None, key_block,
selected_committees, ldap_data=ldap_data)
for key_info in added_keys:
key_info["status"] = key_info.get("status", "success")
key_info["email"] = key_info.get("email", "Unknown")
diff --git a/atr/ldap.py b/atr/ldap.py
index 2e9b1cc..127bc60 100644
--- a/atr/ldap.py
+++ b/atr/ldap.py
@@ -39,6 +39,7 @@ class SearchParameters:
srv_info: str | None = None
detail_err: str | None = None
connection: ldap3.Connection | None = None
+ email_only: bool = False
def parse_dn(dn_string: str) -> dict[str, list[str]]:
@@ -83,7 +84,10 @@ def _search_core(params: SearchParameters) -> None:
filters: list[str] = []
if params.uid_query:
- filters.append(f"(uid={conv.escape_filter_chars(params.uid_query)})")
+ if params.uid_query == "*":
+ filters.append("(uid=*)")
+ else:
+
filters.append(f"(uid={conv.escape_filter_chars(params.uid_query)})")
if params.email_query:
escaped_email = conv.escape_filter_chars(params.email_query)
@@ -102,10 +106,12 @@ def _search_core(params: SearchParameters) -> None:
params.err_msg = "LDAP Connection object not established or auto_bind
failed."
return
+ email_attributes = ["uid", "mail", "asf-altEmail", "asf-committer-email"]
+ attributes = email_attributes if params.email_only else
ldap3.ALL_ATTRIBUTES
params.connection.search(
search_base=LDAP_SEARCH_BASE,
search_filter=search_filter,
- attributes=ldap3.ALL_ATTRIBUTES,
+ attributes=attributes,
)
for entry in params.connection.entries:
result_item: dict[str, str | list[str]] = {"dn": entry.entry_dn}
diff --git a/atr/util.py b/atr/util.py
index d96f380..39cba67 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -152,7 +152,9 @@ def asf_uid_from_email(email: str) -> str | None:
return ldap_uid_val[0] if isinstance(ldap_uid_val, list) else ldap_uid_val
-async def asf_uid_from_uids(uids: list[str]) -> str | None:
+async def asf_uid_from_uids(
+ uids: list[str], use_ldap: bool = True, ldap_data: dict[str, str] | None =
None
+) -> str | None:
# Determine ASF UID if not provided
emails = []
for uid_str in uids:
@@ -162,10 +164,18 @@ async def asf_uid_from_uids(uids: list[str]) -> str |
None:
return email.removesuffix("@apache.org")
emails.append(email)
# We did not find a direct @apache.org email address
- # Therefore, search LDAP
- for email in emails:
- if asf_uid := await asyncio.to_thread(asf_uid_from_email, email):
- return asf_uid
+ # Therefore, search LDAP data, either cached or directly
+ if ldap_data is not None:
+ # Use cached LDAP data
+ for email in emails:
+ if email in ldap_data:
+ return ldap_data[email]
+ return None
+ if use_ldap:
+ # Search LDAP directly
+ for email in emails:
+ if asf_uid := await asyncio.to_thread(asf_uid_from_email, email):
+ return asf_uid
return None
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]