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 <[email protected]>
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"
+
+
[email protected]("/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,
)
[email protected]("/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:
)
[email protected]("/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: [email protected]
For additional commands, e-mail: [email protected]