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