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 b8c14e7  Add a route for admins to check the consistency of public 
signing keys
b8c14e7 is described below

commit b8c14e738a5174b0258bc9fc9602e497b6f83d0a
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jun 18 16:53:59 2025 +0100

    Add a route for admins to check the consistency of public signing keys
---
 atr/blueprints/admin/admin.py   | 43 +++++++++++++++++++++++++++++++++++
 atr/db/interaction.py           | 50 ++++++++++++++++++++++++++---------------
 atr/templates/keys-details.html |  2 +-
 atr/util.py                     | 33 +++++++++++++++++++++++++++
 scripts/keys_import.py          | 25 ++-------------------
 5 files changed, 111 insertions(+), 42 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index e2179a7..efb9119 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -358,6 +358,29 @@ async def admin_env() -> quart.wrappers.response.Response:
     return quart.Response("\n".join(env_vars), mimetype="text/plain")
 
 
[email protected]("/keys/check", methods=["GET", "POST"])
+async def admin_keys_check() -> quart.Response:
+    """Check public signing key details."""
+    if quart.request.method != "POST":
+        empty_form = await util.EmptyForm.create_form()
+        return quart.Response(
+            f"""
+<form method="post">
+  <button type="submit">Check public signing key details</button>
+  {empty_form.hidden_tag()}
+</form>
+""",
+            mimetype="text/html",
+        )
+
+    try:
+        result = await _check_keys()
+        return quart.Response(result, mimetype="text/plain")
+    except Exception as e:
+        _LOGGER.exception("Exception during key check:")
+        return quart.Response(f"Exception during key check: {e!s}", 
mimetype="text/plain")
+
+
 @admin.BLUEPRINT.route("/keys/regenerate-all", methods=["GET", "POST"])
 async def admin_keys_regenerate_all() -> quart.Response:
     """Regenerate the KEYS file for all committees."""
@@ -609,6 +632,26 @@ async def ongoing_tasks(project_name: str, version_name: 
str, revision: str) ->
         return quart.Response("", mimetype="text/plain")
 
 
+async def _check_keys() -> str:
+    email_to_uid = await util.email_to_uid_map()
+    bad_keys = []
+    async with db.session() as data:
+        keys = await data.public_signing_key().all()
+        for key in keys:
+            uids = []
+            if key.primary_declared_uid:
+                uids.append(key.primary_declared_uid)
+            if key.secondary_declared_uids:
+                uids.extend(key.secondary_declared_uids)
+            asf_uid = await util.asf_uid_from_uids(uids, 
ldap_data=email_to_uid)
+            if asf_uid != key.apache_uid:
+                bad_keys.append(f"{key.fingerprint} detected: {asf_uid}, key: 
{key.apache_uid}")
+    message = f"Checked {len(keys)} keys"
+    if bad_keys:
+        message += f"\nFound {len(bad_keys)} bad keys:\n{'\n'.join(bad_keys)}"
+    return message
+
+
 async def _delete_release_data(release_name: str) -> None:
     """Handle the deletion of database records and filesystem data for a 
release."""
     async with db.session() as data:
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 21485fb..e4cbf77 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -73,6 +73,7 @@ async def key_user_add(
     public_key: str,
     selected_committees: list[str],
     ldap_data: dict[str, str] | None = None,
+    update_existing: bool = False,
 ) -> list[dict]:
     if not public_key:
         raise PublicKeyError("Public key is required")
@@ -93,7 +94,9 @@ async def key_user_add(
             raise InteractionError(f"Key {key.get('fingerprint', '').upper()} 
is not associated with your ASF account")
         async with db.session() as data:
             # Store the key in the database
-            added = await key_user_session_add(asf_uid, public_key, key, 
selected_committees, data)
+            added = await key_user_session_add(
+                asf_uid, public_key, key, selected_committees, data, 
update_existing=update_existing
+            )
             if added:
                 added_keys.append(added)
             else:
@@ -107,6 +110,7 @@ async def key_user_session_add(
     key: dict,
     selected_committees: list[str],
     data: db.Session,
+    update_existing: bool = False,
 ) -> dict | None:
     # TODO: Check if key already exists
     # psk_statement = 
select(PublicSigningKey).where(PublicSigningKey.apache_uid == session.uid)
@@ -138,24 +142,26 @@ async def key_user_session_add(
         existing = await data.public_signing_key(fingerprint=fingerprint).get()
         # TODO: This can race
         if existing:
-            # TODO: Always update?
+            update = update_existing
             # If the new key has a latest self signature
             if latest_self_signature is not None:
                 # And the self signature is newer, update it
                 if (existing.latest_self_signature is None) or 
(existing.latest_self_signature < latest_self_signature):
-                    existing.fingerprint = fingerprint
-                    existing.algorithm = int(key["algo"])
-                    existing.length = int(key.get("length", "0"))
-                    existing.created = created
-                    existing.latest_self_signature = latest_self_signature
-                    existing.expires = expires
-                    existing.primary_declared_uid = uids[0] if uids else None
-                    existing.secondary_declared_uids = uids[1:]
-                    existing.apache_uid = asf_uid
-                    existing.ascii_armored_key = public_key
-                    logging.info(f"Found existing key {fingerprint.upper()}, 
updating associations")
-                else:
-                    logging.info(f"Found existing key {fingerprint.upper()}, 
no update needed")
+                    update = True
+            if update:
+                existing.fingerprint = fingerprint
+                existing.algorithm = int(key["algo"])
+                existing.length = int(key.get("length", "0"))
+                existing.created = created
+                existing.latest_self_signature = latest_self_signature
+                existing.expires = expires
+                existing.primary_declared_uid = uids[0] if uids else None
+                existing.secondary_declared_uids = uids[1:]
+                existing.apache_uid = asf_uid
+                existing.ascii_armored_key = public_key
+                logging.info(f"Found existing key {fingerprint.upper()}, 
updating associations")
+            else:
+                logging.info(f"Found existing key {fingerprint.upper()}, no 
update needed")
             key_record = existing
         else:
             # Key doesn't exist, create it
@@ -326,6 +332,7 @@ async def upload_keys_bytes(
     keys_bytes: bytes,
     selected_committees: list[str],
     ldap_data: dict[str, str] | None = None,
+    update_existing: bool = False,
 ) -> tuple[list[dict], int, int, list[str]]:
     key_blocks = util.parse_key_blocks_bytes(keys_bytes)
     if not key_blocks:
@@ -340,7 +347,9 @@ async def upload_keys_bytes(
     submitted_committees = selected_committees[:]
 
     # Process each key block
-    results = await _upload_process_key_blocks(key_blocks, 
selected_committees, ldap_data=ldap_data)
+    results = await _upload_process_key_blocks(
+        key_blocks, selected_committees, ldap_data=ldap_data, 
update_existing=update_existing
+    )
     # if not results:
     #     raise InteractionError("No keys were added")
 
@@ -446,7 +455,10 @@ async def _successes_errors_warnings(
 
 
 async def _upload_process_key_blocks(
-    key_blocks: list[str], selected_committees: list[str], ldap_data: 
dict[str, str] | None = None
+    key_blocks: list[str],
+    selected_committees: list[str],
+    ldap_data: dict[str, str] | None = None,
+    update_existing: bool = False,
 ) -> list[dict]:
     """Process OpenPGP key blocks and add them to the user's account."""
     results: list[dict] = []
@@ -454,7 +466,9 @@ async def _upload_process_key_blocks(
     # Process each key block
     for i, key_block in enumerate(key_blocks):
         try:
-            added_keys = await key_user_add(None, key_block, 
selected_committees, ldap_data=ldap_data)
+            added_keys = await key_user_add(
+                None, key_block, selected_committees, ldap_data=ldap_data, 
update_existing=update_existing
+            )
             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/templates/keys-details.html b/atr/templates/keys-details.html
index 08bce30..f7840d9 100644
--- a/atr/templates/keys-details.html
+++ b/atr/templates/keys-details.html
@@ -103,6 +103,6 @@
     </tbody>
   </table>
   <h2>ASCII armored key</h2>
-  <pre class="mt-3 border border-2 p-3">{{ key.ascii_armored_key }}</pre>
+  <pre class="mt-3 border border-2 p-3">{{ key.ascii_armored_key if 
isinstance(key.ascii_armored_key, str) else 
key.ascii_armored_key.decode('utf-8') }}</pre>
   {# TODO: Add download button for the ASCII armored key #}
 {% endblock content %}
diff --git a/atr/util.py b/atr/util.py
index 477c767..f25f9e9 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -280,6 +280,39 @@ def email_from_uid(uid: str) -> str | None:
     return None
 
 
+async def email_to_uid_map() -> dict[str, str]:
+    def get(entry: dict, prop: str) -> str | None:
+        if prop in entry:
+            values = entry[prop]
+            if values:
+                return values[0]
+        return None
+
+    # Get all email addresses in LDAP
+    conf = config.AppConfig()
+    bind_dn = conf.LDAP_BIND_DN
+    bind_password = conf.LDAP_BIND_PASSWORD
+    ldap_params = ldap.SearchParameters(
+        uid_query="*",
+        bind_dn_from_config=bind_dn,
+        bind_password_from_config=bind_password,
+        email_only=True,
+    )
+    await asyncio.to_thread(ldap.search, ldap_params)
+
+    # Map the LDAP addresses to Apache UIDs
+    email_to_uid = {}
+    for entry in ldap_params.results_list:
+        uid = entry.get("uid", [""])[0]
+        if mail := get(entry, "mail"):
+            email_to_uid[mail] = uid
+        if alt_email := get(entry, "asf-altEmail"):
+            email_to_uid[alt_email] = uid
+        if committer_email := get(entry, "asf-committer-email"):
+            email_to_uid[committer_email] = uid
+    return email_to_uid
+
+
 async def file_sha3(path: str) -> str:
     """Compute SHA3-256 hash of a file."""
     sha3 = hashlib.sha3_256()
diff --git a/scripts/keys_import.py b/scripts/keys_import.py
index 7e3f73c..c2db36d 100644
--- a/scripts/keys_import.py
+++ b/scripts/keys_import.py
@@ -13,7 +13,6 @@ sys.path.append(".")
 import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
-import atr.ldap as ldap
 import atr.util as util
 
 
@@ -57,30 +56,10 @@ async def keys_import(conf: config.AppConfig) -> None:
 
     # Get all email addresses in LDAP
     # We'll discard them when we're finished
-    conf = config.AppConfig()
-    bind_dn = conf.LDAP_BIND_DN
-    bind_password = conf.LDAP_BIND_PASSWORD
-    ldap_params = ldap.SearchParameters(
-        uid_query="*",
-        bind_dn_from_config=bind_dn,
-        bind_password_from_config=bind_password,
-        email_only=True,
-    )
     start = time.perf_counter_ns()
-    await asyncio.to_thread(ldap.search, ldap_params)
+    email_to_uid = await util.email_to_uid_map()
     end = time.perf_counter_ns()
     write(f"LDAP search took {(end - start) / 1000000} ms")
-
-    # Map the LDAP addresses to Apache UIDs
-    email_to_uid = {}
-    for entry in ldap_params.results_list:
-        uid = entry.get("uid", [""])[0]
-        if mail := get(entry, "mail"):
-            email_to_uid[mail] = uid
-        if alt_email := get(entry, "asf-altEmail"):
-            email_to_uid[alt_email] = uid
-        if committer_email := get(entry, "asf-committer-email"):
-            email_to_uid[committer_email] = uid
     write(f"Email addresses from LDAP: {len(email_to_uid)}")
 
     # Open an ATR database connection
@@ -104,7 +83,7 @@ async def keys_import(conf: config.AppConfig) -> None:
             # Then we could use the bulk upsert query method
             try:
                 _result, yes, no, _committees = await 
interaction.upload_keys_bytes(
-                    [committee_name], content, [committee_name], 
ldap_data=email_to_uid
+                    [committee_name], content, [committee_name], 
ldap_data=email_to_uid, update_existing=True
                 )
             except Exception as e:
                 write(f"{committee_name} error: {e}")


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

Reply via email to