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-atr-experiments.git


The following commit(s) were added to refs/heads/main by this push:
     new 49e49d3  Allow the deletion of individual keys
49e49d3 is described below

commit 49e49d3378c129cda670aade57f761f521769caa
Author: Sean B. Palmer <s...@miscoranda.com>
AuthorDate: Wed Feb 19 20:11:49 2025 +0200

    Allow the deletion of individual keys
---
 atr/blueprints/secret/secret.py                    | 124 +++++++++++++--------
 atr/db/models.py                                   |   3 +-
 atr/routes.py                                      |  83 ++++++++------
 atr/templates/candidate-review.html                |  14 +--
 atr/templates/includes/sidebar.html                |   5 -
 atr/templates/keys-review.html                     |  64 +++++++++--
 atr/templates/secret/update-pmcs.html              |  28 +++++
 ...al_schema.py => 1779875a3f38_initial_schema.py} |   6 +-
 8 files changed, 223 insertions(+), 104 deletions(-)

diff --git a/atr/blueprints/secret/secret.py b/atr/blueprints/secret/secret.py
index c6d5bcf..6dae033 100644
--- a/atr/blueprints/secret/secret.py
+++ b/atr/blueprints/secret/secret.py
@@ -18,10 +18,12 @@
 import json
 
 import httpx
-from quart import current_app, render_template, request
+from quart import current_app, flash, redirect, render_template, request, 
url_for
 from sqlmodel import select
+from werkzeug.wrappers.response import Response
 
 from asfquart.base import ASFQuartException
+from asfquart.session import read as session_read
 from atr.db import get_session
 from atr.db.models import (
     PMC,
@@ -87,7 +89,7 @@ async def secret_data(model: str = "PMC") -> str:
 
 
 @blueprint.route("/pmcs/update", methods=["GET", "POST"])
-async def secret_pmcs_update() -> str:
+async def secret_pmcs_update() -> str | Response:
     """Update PMCs from remote, authoritative committee-info.json."""
 
     if request.method == "POST":
@@ -98,55 +100,61 @@ async def secret_pmcs_update() -> str:
                 response.raise_for_status()
                 data = response.json()
             except (httpx.RequestError, json.JSONDecodeError) as e:
-                raise ASFQuartException(f"Failed to fetch committee data: 
{e!s}", errorcode=500)
+                await flash(f"Failed to fetch committee data: {e!s}", "error")
+                return redirect(url_for("secret_blueprint.secret_pmcs_update"))
 
         committees = data.get("committees", {})
         updated_count = 0
 
-        async with get_session() as db_session:
-            async with db_session.begin():
-                for committee_id, info in committees.items():
-                    # Skip non-PMC committees
-                    if not info.get("pmc", False):
-                        continue
-
-                    # Get or create PMC
-                    statement = select(PMC).where(PMC.project_name == 
committee_id)
-                    pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
-                    if not pmc:
-                        pmc = PMC(project_name=committee_id)
-                        db_session.add(pmc)
-
-                    # Update PMC data
-                    roster = info.get("roster", {})
-                    # TODO: Here we say that roster == pmc_members == 
committers
-                    # We ought to do this more accurately instead
-                    pmc.pmc_members = list(roster.keys())
-                    pmc.committers = list(roster.keys())
-
-                    # Mark chairs as release managers
-                    # TODO: Who else is a release manager? How do we know?
-                    chairs = [m["id"] for m in info.get("chairs", [])]
-                    pmc.release_managers = chairs
-
-                    updated_count += 1
-
-                # Add special entry for Tooling PMC
-                # Not clear why, but it's not in the Whimsy data
-                statement = select(PMC).where(PMC.project_name == "tooling")
-                tooling_pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
-                if not tooling_pmc:
-                    tooling_pmc = PMC(project_name="tooling")
-                    db_session.add(tooling_pmc)
-                    updated_count += 1
-
-                # Update Tooling PMC data
-                # Could put this in the "if not tooling_pmc" block, perhaps
-                tooling_pmc.pmc_members = ["wave", "tn", "sbp"]
-                tooling_pmc.committers = ["wave", "tn", "sbp"]
-                tooling_pmc.release_managers = ["wave"]
-
-        return f"Successfully updated {updated_count} PMCs from Whimsy"
+        try:
+            async with get_session() as db_session:
+                async with db_session.begin():
+                    for committee_id, info in committees.items():
+                        # Skip non-PMC committees
+                        if not info.get("pmc", False):
+                            continue
+
+                        # Get or create PMC
+                        statement = select(PMC).where(PMC.project_name == 
committee_id)
+                        pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
+                        if not pmc:
+                            pmc = PMC(project_name=committee_id)
+                            db_session.add(pmc)
+
+                        # Update PMC data
+                        roster = info.get("roster", {})
+                        # TODO: Here we say that roster == pmc_members == 
committers
+                        # We ought to do this more accurately instead
+                        pmc.pmc_members = list(roster.keys())
+                        pmc.committers = list(roster.keys())
+
+                        # Mark chairs as release managers
+                        # TODO: Who else is a release manager? How do we know?
+                        chairs = [m["id"] for m in info.get("chairs", [])]
+                        pmc.release_managers = chairs
+
+                        updated_count += 1
+
+                    # Add special entry for Tooling PMC
+                    # Not clear why, but it's not in the Whimsy data
+                    statement = select(PMC).where(PMC.project_name == 
"tooling")
+                    tooling_pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
+                    if not tooling_pmc:
+                        tooling_pmc = PMC(project_name="tooling")
+                        db_session.add(tooling_pmc)
+                        updated_count += 1
+
+                    # Update Tooling PMC data
+                    # Could put this in the "if not tooling_pmc" block, perhaps
+                    tooling_pmc.pmc_members = ["wave", "tn", "sbp"]
+                    tooling_pmc.committers = ["wave", "tn", "sbp"]
+                    tooling_pmc.release_managers = ["wave"]
+
+            await flash(f"Successfully updated {updated_count} PMCs from 
Whimsy", "success")
+        except Exception as e:
+            await flash(f"Failed to update PMCs: {e!s}", "error")
+
+        return redirect(url_for("secret_blueprint.secret_pmcs_update"))
 
     # For GET requests, show the update form
     return await render_template("secret/update-pmcs.html")
@@ -157,3 +165,25 @@ async def secret_debug_database() -> str:
     """Debug information about the database."""
     pmcs = await get_pmcs()
     return f"Database using {current_app.config['DATA_MODELS_FILE']} has 
{len(pmcs)} PMCs"
+
+
+@blueprint.route("/keys/delete-all")
+async def secret_keys_delete_all() -> str:
+    """Debug endpoint to delete all of a user's keys."""
+    session = await session_read()
+    if session is None:
+        raise ASFQuartException("Not authenticated", errorcode=401)
+
+    async with get_session() as db_session:
+        async with db_session.begin():
+            # Get all keys for the user
+            # TODO: Use session.apache_uid instead of session.uid?
+            statement = 
select(PublicSigningKey).where(PublicSigningKey.apache_uid == session.uid)
+            keys = (await db_session.execute(statement)).scalars().all()
+            count = len(keys)
+
+            # Delete all keys
+            for key in keys:
+                await db_session.delete(key)
+
+        return f"Deleted {count} keys"
diff --git a/atr/db/models.py b/atr/db/models.py
index 639f509..6b801b1 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -135,7 +135,8 @@ class DistributionChannel(SQLModel, table=True):
 
 class Package(SQLModel, table=True):
     # The SHA3-256 hash of the file, used as filename in storage
-    id_sha3: str = Field(primary_key=True)
+    # TODO: We should discuss making this unique
+    artifact_sha3: str = Field(primary_key=True)
     # Original filename from uploader
     filename: str
     # SHA-512 hash of the file
diff --git a/atr/routes.py b/atr/routes.py
index bb4fc12..55127ae 100644
--- a/atr/routes.py
+++ b/atr/routes.py
@@ -31,7 +31,7 @@ from typing import Any, BinaryIO, cast
 import aiofiles
 import aiofiles.os
 import gnupg
-from quart import Request, redirect, render_template, request, url_for
+from quart import Request, flash, redirect, render_template, request, url_for
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -133,14 +133,14 @@ async def release_attach_post(session: ClientSession, 
request: Request) -> Respo
         # Check for duplicate artifact or signature in a single query
         statement = select(Package).where(
             Package.release_key == release_key,
-            (Package.id_sha3 == artifact_sha3) | (Package.signature_sha3 == 
signature_sha3),
+            (Package.artifact_sha3 == artifact_sha3) | (Package.signature_sha3 
== signature_sha3),
         )
         duplicate = (await db_session.execute(statement)).first()
 
         if duplicate:
             package = duplicate[0]
-            # TODO: Perhaps we should call the id_sha3 field artifact_sha3 
instead
-            if package.id_sha3 == artifact_sha3:
+            # TODO: This logic should be improved
+            if package.artifact_sha3 == artifact_sha3:
                 raise ASFQuartException("This release artifact has already 
been uploaded", errorcode=400)
             else:
                 raise ASFQuartException("This signature file has already been 
uploaded", errorcode=400)
@@ -152,7 +152,7 @@ async def release_attach_post(session: ClientSession, 
request: Request) -> Respo
     async with get_session() as db_session:
         async with db_session.begin():
             package = Package(
-                id_sha3=artifact_sha3,
+                artifact_sha3=artifact_sha3,
                 filename=artifact_file.filename,
                 signature_sha3=signature_sha3,
                 sha512=sha512,
@@ -291,7 +291,7 @@ async def root_candidate_create() -> Response | str:
 @APP.route("/candidate/signatures/verify/<release_key>")
 @require(Requirements.committer)
 async def root_candidate_signatures_verify(release_key: str) -> str:
-    """Verify the GPG signatures for all packages in a release candidate."""
+    """Verify the signatures for all packages in a release candidate."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
@@ -325,9 +325,9 @@ async def root_candidate_signatures_verify(release_key: 
str) -> str:
         storage_dir = Path(get_release_storage_dir())
 
         for package in release.packages:
-            result = {"file": package.id_sha3}
+            result = {"file": package.artifact_sha3}
 
-            artifact_path = storage_dir / package.id_sha3
+            artifact_path = storage_dir / package.artifact_sha3
             signature_path = storage_dir / package.signature_sha3
 
             if not artifact_path.exists():
@@ -337,7 +337,7 @@ async def root_candidate_signatures_verify(release_key: 
str) -> str:
             else:
                 # Verify the signature
                 result = await verify_gpg_signature(artifact_path, 
signature_path, ascii_armored_keys)
-                result["file"] = package.id_sha3
+                result["file"] = package.artifact_sha3
 
             verification_results.append(result)
 
@@ -419,7 +419,7 @@ async def root_pmc_list() -> list[dict]:
 @APP.route("/keys/review")
 @require(Requirements.committer)
 async def root_keys_review() -> str:
-    """Show all GPG keys associated with the user's account."""
+    """Show all keys associated with the user's account."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
@@ -430,18 +430,56 @@ async def root_keys_review() -> str:
         statement = 
select(PublicSigningKey).options(pmcs_loader).where(PublicSigningKey.apache_uid 
== session.uid)
         user_keys = (await db_session.execute(statement)).scalars().all()
 
+    status_message = request.args.get("status_message")
+    status_type = request.args.get("status_type")
+
     return await render_template(
         "keys-review.html",
         asf_id=session.uid,
         user_keys=user_keys,
         algorithms=algorithms,
+        status_message=status_message,
+        status_type=status_type,
     )
 
 
+@APP.route("/keys/delete", methods=["POST"])
+@require(Requirements.committer)
+async def root_keys_delete() -> Response:
+    """Delete a public signing key from the user's account."""
+    session = await session_read()
+    if session is None:
+        raise ASFQuartException("Not authenticated", errorcode=401)
+
+    form = await request.form
+    fingerprint = form.get("fingerprint")
+    if not fingerprint:
+        await flash("No key fingerprint provided", "error")
+        return redirect(url_for("root_keys_review"))
+
+    async with get_session() as db_session:
+        async with db_session.begin():
+            # Get the key and verify ownership
+            statement = select(PublicSigningKey).where(
+                PublicSigningKey.fingerprint == fingerprint, 
PublicSigningKey.apache_uid == session.uid
+            )
+            key = (await db_session.execute(statement)).scalar_one_or_none()
+
+            if not key:
+                await flash("Key not found or not owned by you", "error")
+                return redirect(url_for("root_keys_review"))
+
+            # Delete the key
+            await db_session.delete(key)
+
+    await flash("Key deleted successfully", "success")
+    return redirect(url_for("root_keys_review"))
+
+
 @APP.route("/keys/add", methods=["GET", "POST"])
 @require(Requirements.committer)
 async def root_keys_add() -> str:
-    """Add a new GPG key to the user's account."""
+    """Add a new public signing key to the user's account."""
     session = await session_read()
     if session is None:
         raise ASFQuartException("Not authenticated", errorcode=401)
@@ -497,29 +535,6 @@ async def root_keys_add() -> str:
     )
 
 
-@APP.route("/user/keys/delete")
-@require(Requirements.committer)
-async def root_user_keys_delete() -> str:
-    """Debug endpoint to delete all of a user's keys."""
-    session = await session_read()
-    if session is None:
-        raise ASFQuartException("Not authenticated", errorcode=401)
-
-    async with get_session() as db_session:
-        async with db_session.begin():
-            # Get all keys for the user
-            # TODO: Use session.apache_uid instead of session.uid?
-            statement = 
select(PublicSigningKey).where(PublicSigningKey.apache_uid == session.uid)
-            keys = (await db_session.execute(statement)).scalars().all()
-            count = len(keys)
-
-            # Delete all keys
-            for key in keys:
-                await db_session.delete(key)
-
-        return f"Deleted {count} keys"
-
-
 @APP.route("/candidate/review")
 @require(Requirements.committer)
 async def root_candidate_review() -> str:
diff --git a/atr/templates/candidate-review.html 
b/atr/templates/candidate-review.html
index 40f3e5a..3f61da2 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -122,20 +122,20 @@
             </tr>
           {% endif %}
           <tr>
-            <th>Original Filename</th>
+            <th>Artifact Filename</th>
             <td>{{ package.filename }}</td>
           </tr>
           <tr>
-            <th>File Hash (SHA3)</th>
-            <td>{{ package.id_sha3 }}</td>
+            <th>Artifact Hash (SHA3-256)</th>
+            <td>{{ package.artifact_sha3 }}</td>
           </tr>
           <tr>
-            <th>Signature Hash (SHA3)</th>
-            <td>{{ package.signature_sha3 }}</td>
+            <th>Artifact Hash (SHA-512)</th>
+            <td>{{ package.sha512 }}</td>
           </tr>
           <tr>
-            <th>SHA-512</th>
-            <td>{{ package.sha512 }}</td>
+            <th>Signature Hash (SHA3-256)</th>
+            <td>{{ package.signature_sha3 }}</td>
           </tr>
           <tr>
             <th>Uploaded</th>
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 65ad794..c1e3918 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -66,11 +66,6 @@
           <a href="{{ url_for('root_keys_add') }}"
              {% if request.endpoint == 'root_keys_add' %}class="active"{% 
endif %}>Add signing key</a>
         </li>
-        <li>
-          <a href="{{ url_for('root_user_keys_delete') }}"
-             {% if request.endpoint == 'root_user_keys_delete' 
%}class="active"{% endif %}>Delete keys</a>
-          <span class="warning">(!)</span>
-        </li>
       </ul>
 
       {% if is_admin(current_user.uid) %}
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index 6455709..8a811cd 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -72,6 +72,38 @@
           background: #d4edda;
           border-radius: 4px;
       }
+
+      .key-details {
+          margin-top: 1rem;
+          padding: 1rem;
+          background: #f8f9fa;
+          border-radius: 4px;
+      }
+
+      .key-details summary {
+          font-weight: bold;
+          cursor: pointer;
+      }
+
+      .key-details pre {
+          margin-top: 1rem;
+          white-space: pre-wrap;
+      }
+
+      .status-message {
+          margin-top: 2rem;
+          padding: 1rem;
+          background: #f8f9fa;
+          border-radius: 4px;
+      }
+
+      .status-message.success {
+          background: #d4edda;
+      }
+
+      .status-message.error {
+          background: #f8d7da;
+      }
   </style>
 {% endblock stylesheets %}
 
@@ -85,12 +117,11 @@
     </p>
   </div>
 
-  {% if success %}
-    <div class="success-message">
-      <h2>Success</h2>
-      <p>{{ success }}</p>
-    </div>
-  {% endif %}
+  {% with messages = get_flashed_messages(with_categories=true) %}
+    {% if messages %}
+      {% for category, message in messages %}<div class="status-message {{ 
category }}">{{ message }}</div>{% endfor %}
+    {% endif %}
+  {% endwith %}
 
   {% if user_keys %}
     <div class="existing-keys">
@@ -131,11 +162,30 @@
                 </tr>
               </tbody>
             </table>
+
+            <!-- TODO: We could link to a downloadable version of the key 
instead -->
+            <details class="key-details">
+              <summary>View whole key</summary>
+              <pre class="key-text">{{ key.ascii_armored_key }}</pre>
+            </details>
+
+            <form method="post"
+                  action="{{ url_for('root_keys_delete') }}"
+                  class="delete-key-form">
+              <input type="hidden" name="fingerprint" value="{{ 
key.fingerprint }}" />
+              <button type="submit" class="delete-button">Delete key</button>
+            </form>
           </div>
         {% endfor %}
       </div>
     </div>
   {% else %}
-    <p>You haven't added any signing keys yet.</p>
+    <h2>Keys</h2>
+    <p>
+      <strong>You haven't added any signing keys yet.</strong>
+    </p>
+    <p>
+      <a href="{{ url_for('root_keys_add') }}">Add a key</a>
+    </p>
   {% endif %}
 {% endblock content %}
diff --git a/atr/templates/secret/update-pmcs.html 
b/atr/templates/secret/update-pmcs.html
index 48bc217..274147b 100644
--- a/atr/templates/secret/update-pmcs.html
+++ b/atr/templates/secret/update-pmcs.html
@@ -39,9 +39,31 @@
           color: #856404;
       }
 
+      div.warning p:last-child {
+          margin-bottom: 0;
+      }
+
       div.warning strong {
           color: #533f03;
       }
+
+      .status-message {
+          margin: 1.5rem 0;
+          padding: 1rem;
+          border-radius: 4px;
+      }
+
+      .status-message.success {
+          background: #d4edda;
+          border: 1px solid #c3e6cb;
+          color: #155724;
+      }
+
+      .status-message.error {
+          background: #f8d7da;
+          border: 1px solid #f5c6cb;
+          color: #721c24;
+      }
   </style>
 {% endblock stylesheets %}
 
@@ -49,6 +71,12 @@
   <h1>Update PMCs</h1>
   <p class="intro">This page allows you to update the PMC information in the 
database from committee-info.json.</p>
 
+  {% with messages = get_flashed_messages(with_categories=true) %}
+    {% if messages %}
+      {% for category, message in messages %}<div class="status-message {{ 
category }}">{{ message }}</div>{% endfor %}
+    {% endif %}
+  {% endwith %}
+
   <div class="warning">
     <p>
       <strong>Note:</strong> This operation will update all PMC information, 
including member lists and release manager assignments.
diff --git a/migrations/versions/b561e6142755_initial_schema.py 
b/migrations/versions/1779875a3f38_initial_schema.py
similarity index 84%
rename from migrations/versions/b561e6142755_initial_schema.py
rename to migrations/versions/1779875a3f38_initial_schema.py
index 991d9e4..0210d4c 100644
--- a/migrations/versions/b561e6142755_initial_schema.py
+++ b/migrations/versions/1779875a3f38_initial_schema.py
@@ -1,15 +1,15 @@
 """initial_schema
 
-Revision ID: b561e6142755
+Revision ID: 1779875a3f38
 Revises:
-Create Date: 2025-02-19 18:52:21.878941
+Create Date: 2025-02-19 19:55:33.587351
 
 """
 
 from collections.abc import Sequence
 
 # revision identifiers, used by Alembic.
-revision: str = "b561e6142755"
+revision: str = "1779875a3f38"
 down_revision: str | None = None
 branch_labels: str | Sequence[str] | None = None
 depends_on: str | Sequence[str] | None = None


---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org
For additional commands, e-mail: dev-h...@tooling.apache.org

Reply via email to