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 f8fae6a  Add pages for user key management
f8fae6a is described below

commit f8fae6a21db1d37f27c1594c8f8f4345e285f6cb
Author: Sean B. Palmer <s...@miscoranda.com>
AuthorDate: Fri Feb 14 16:08:56 2025 +0200

    Add pages for user key management
---
 .gitignore                       |   1 +
 atr/routes.py                    | 155 ++++++++++++++++++++++++++-
 atr/templates/pages.html         |  33 ++++++
 atr/templates/user-keys-add.html | 222 +++++++++++++++++++++++++++++++++++++++
 poetry.lock                      |  14 ++-
 pyproject.toml                   |   2 +
 uv.lock                          |  11 ++
 7 files changed, 435 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index 6b9b7c9..ca93b00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 *.pyc
+.DS_Store
 .env
 .ruff_cache/
 .venv-poetry/
diff --git a/atr/routes.py b/atr/routes.py
index 56ba7b8..f65a69d 100644
--- a/atr/routes.py
+++ b/atr/routes.py
@@ -19,17 +19,21 @@
 
 import hashlib
 import json
+import pprint
 from pathlib import Path
-from typing import List, Tuple
+from typing import List, Tuple, Optional
+import datetime
+import asyncio
 
 from asfquart import APP
 from asfquart.auth import Requirements as R, require
 from asfquart.base import ASFQuartException
-from asfquart.session import read as session_read
+from asfquart.session import read as session_read, ClientSession
 from quart import current_app, render_template, request
 from sqlmodel import Session, select
 from sqlalchemy.exc import IntegrityError
 import httpx
+import gnupg
 
 from .models import (
     DistributionChannel,
@@ -404,6 +408,65 @@ async def root_secret() -> str:
     return "Secret stuff!"
 
 
+@APP.route("/user/keys/add", methods=["GET", "POST"])
+@require(R.committer)
+async def root_user_keys_add() -> str:
+    "Add a new GPG key to the user's account."
+    session = await session_read()
+    if session is None:
+        raise ASFQuartException("Not authenticated", errorcode=401)
+
+    error = None
+    key_info = None
+    user_keys = []
+
+    # Get all existing keys for the user
+    with Session(current_app.config["engine"]) as db_session:
+        statement = select(PublicSigningKey).where(PublicSigningKey.user_id == 
session.uid)
+        user_keys = db_session.exec(statement).all()
+
+    if request.method == "POST":
+        form = await request.form
+        public_key = form.get("public_key")
+        if not public_key:
+            # Shouldn't happen, so we can raise an exception
+            raise ASFQuartException("Public key is required", errorcode=400)
+        error, key_info = await user_keys_add(session, public_key)
+
+    return await render_template(
+        "user-keys-add.html",
+        asf_id=session.uid,
+        pmc_memberships=session.committees,
+        error=error,
+        key_info=key_info,
+        user_keys=user_keys,
+    )
+
+
+@APP.route("/user/keys/delete")
+@require(R.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)
+
+    with Session(current_app.config["engine"]) as db_session:
+        # Get all keys for the user
+        # TODO: Might be clearer if user_id were "asf_id"
+        # But then we'd also want session.uid to be session.asf_id instead
+        statement = select(PublicSigningKey).where(PublicSigningKey.user_id == 
session.uid)
+        keys = db_session.exec(statement).all()
+        count = len(keys)
+
+        # Delete all keys
+        for key in keys:
+            db_session.delete(key)
+        db_session.commit()
+
+        return f"Deleted {count} keys"
+
+
 @APP.route("/user/uploads")
 @require(R.committer)
 async def root_user_uploads() -> str:
@@ -449,3 +512,91 @@ async def save_file_by_hash(file, base_dir: Path) -> 
Tuple[Path, str]:
         path.write_bytes(data)
 
     return path, file_hash
+
+
+async def user_keys_add(session: ClientSession, public_key: str) -> Tuple[str, 
Optional[dict]]:
+    if not public_key:
+        return ("Public key is required", None)
+
+    # Import the key into GPG to validate and extract info
+    # TODO: We'll just assume for now that gnupg.GPG() doesn't need to be async
+    gpg = gnupg.GPG()
+    import_result = await asyncio.to_thread(gpg.import_keys, public_key)
+
+    if not import_result.fingerprints:
+        return ("Invalid public key format", None)
+
+    fingerprint = import_result.fingerprints[0]
+    # Get key details
+    # We could probably use import_result instead
+    # But this way it shows that they've really been imported
+    keys = await asyncio.to_thread(gpg.list_keys)
+    # Then we have the properties listed here:
+    # https://gnupg.readthedocs.io/en/latest/#listing-keys
+    # Note that "fingerprint" is not listed there, but we have it anyway...
+    key = next((k for k in keys if k["fingerprint"] == fingerprint), None)
+    if not key:
+        return ("Failed to import key", None)
+    if (key.get("algo") == "1") and (int(key.get("length", "0")) < 2048):
+        # https://infra.apache.org/release-signing.html#note
+        # Says that keys must be at least 2048 bits
+        return ("Key is not long enough; must be at least 2048 bits", None)
+
+    # Store key in database
+    with Session(current_app.config["engine"]) as db_session:
+        return await user_keys_add_session(session, public_key, key, 
db_session)
+
+
+async def user_keys_add_session(
+    session: ClientSession, public_key: str, key: dict, db_session: Session
+) -> Tuple[str, Optional[dict]]:
+    # Check if key already exists
+    statement = select(PublicSigningKey).where(PublicSigningKey.user_id == 
session.uid)
+    existing_key = db_session.exec(statement).first()
+
+    if existing_key:
+        # TODO: We should allow more than one key per user
+        return ("You already have a key registered", None)
+
+    if not session.uid:
+        return ("You must be signed in to add a key", None)
+
+    # Create new key record
+    key_record = PublicSigningKey(
+        user_id=session.uid,
+        public_key=public_key,
+        key_type=key.get("type", "unknown"),
+        expiration=datetime.datetime.fromtimestamp(int(key["expires"]))
+        if key.get("expires")
+        else datetime.datetime.max,
+    )
+    db_session.add(key_record)
+
+    # Link key to user's PMCs
+    for pmc_name in session.committees:
+        statement = select(PMC).where(PMC.project_name == pmc_name)
+        pmc = db_session.exec(statement).first()
+        if pmc and pmc.id and session.uid:
+            link = PMCKeyLink(pmc_id=pmc.id, key_user_id=session.uid)
+            db_session.add(link)
+        else:
+            # TODO: Log? Add to "error"?
+            continue
+
+    try:
+        db_session.commit()
+    except IntegrityError:
+        db_session.rollback()
+        return ("Failed to save key", None)
+
+    return (
+        "",
+        {
+            "key_id": key["keyid"],
+            "fingerprint": key["fingerprint"],
+            "user_id": key["uids"][0] if key.get("uids") else "Unknown",
+            "creation_date": datetime.datetime.fromtimestamp(int(key["date"])),
+            "expiration_date": 
datetime.datetime.fromtimestamp(int(key["expires"])) if key.get("expires") else 
None,
+            "data": pprint.pformat(key),
+        },
+    )
diff --git a/atr/templates/pages.html b/atr/templates/pages.html
index 1d56336..b018e11 100644
--- a/atr/templates/pages.html
+++ b/atr/templates/pages.html
@@ -58,6 +58,13 @@
             background: #e6ffe6;
             border: 1px solid #ccebcc;
         }
+
+        .access-requirement.warning {
+            background: #ffe6e6;
+            border: 1px solid #ffcccc;
+            color: #cc0000;
+            font-weight: bold;
+        }
     </style>
   </head>
   <body>
@@ -155,6 +162,32 @@
         </div>
       </div>
 
+      <div class="endpoint-group">
+        <h2>User Management</h2>
+
+        <div class="endpoint">
+          <h3>
+            <a href="{{ url_for('root_user_keys_add') }}">/user/keys/add</a>
+          </h3>
+          <div class="endpoint-description">Add a GPG public key to your 
account for signing releases.</div>
+          <div class="endpoint-meta">
+            Access: <span class="access-requirement committer">Committer</span>
+          </div>
+        </div>
+
+        <div class="endpoint">
+          <h3>
+            <a href="{{ url_for('root_user_keys_delete') 
}}">/user/keys/delete</a>
+          </h3>
+          <div class="endpoint-description">Debug endpoint to delete all GPG 
keys associated with your account.</div>
+          <div class="endpoint-meta">
+            Access: <span class="access-requirement committer">Committer</span>
+            <br />
+            <span class="access-requirement warning">Warning: This is a debug 
endpoint that immediately deletes all your keys without confirmation!</span>
+          </div>
+        </div>
+      </div>
+
       <div class="endpoint-group">
         <h2>Administration</h2>
 
diff --git a/atr/templates/user-keys-add.html b/atr/templates/user-keys-add.html
new file mode 100644
index 0000000..af8ce40
--- /dev/null
+++ b/atr/templates/user-keys-add.html
@@ -0,0 +1,222 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+    <meta name="description" content="Add a GPG public key to your account." />
+    <title>ATR | Add GPG Key</title>
+    <link rel="stylesheet" href="{{ url_for('static', filename='root.css') }}" 
/>
+    <style>
+        .form-group {
+            margin-bottom: 1rem;
+        }
+
+        .form-group label {
+            display: block;
+            margin-bottom: 0.5rem;
+        }
+
+        .error-message {
+            color: #dc3545;
+            margin-top: 0.25rem;
+        }
+
+        input,
+        textarea {
+            font-family: monospace;
+            padding: 0.5rem;
+        }
+
+        textarea {
+            width: 100%;
+            min-height: 200px;
+        }
+
+        .key-info {
+            margin-top: 1rem;
+            padding: 1rem;
+            background: #f8f9fa;
+            border-radius: 4px;
+        }
+
+        .key-info h3 {
+            margin-top: 0;
+        }
+
+        .key-info dl {
+            margin: 0;
+            display: grid;
+            grid-template-columns: auto 1fr;
+            gap: 0.5rem 1rem;
+        }
+
+        .key-info dt {
+            font-weight: bold;
+        }
+
+        .key-info dd {
+            margin: 0;
+        }
+
+        button {
+            margin-top: 1rem;
+            padding: 0.5rem 1rem;
+        }
+
+        .navigation {
+            margin-top: 2rem;
+        }
+
+        .success-message {
+            color: #28a745;
+            margin: 1rem 0;
+            padding: 1rem;
+            background: #d4edda;
+            border-radius: 4px;
+        }
+
+        .existing-keys {
+            margin: 2rem 0;
+            padding: 1rem;
+            background: #f8f9fa;
+            border-radius: 4px;
+        }
+
+        .keys-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+            gap: 1rem;
+            margin-top: 1rem;
+        }
+
+        .key-card {
+            padding: 1rem;
+            background: white;
+            border: 1px solid #dee2e6;
+            border-radius: 4px;
+        }
+
+        .key-card h3 {
+            margin-top: 0;
+            margin-bottom: 1rem;
+        }
+
+        .delete-key-form {
+            margin-top: 1rem;
+        }
+
+        .delete-button {
+            background: #dc3545;
+            color: white;
+            border: none;
+            padding: 0.5rem 1rem;
+            border-radius: 4px;
+            cursor: pointer;
+        }
+
+        .delete-button:hover {
+            background: #c82333;
+        }
+    </style>
+  </head>
+  <body>
+    <h1>Add GPG Public Key</h1>
+    <p class="intro">Add your GPG public key to use for signing release 
artifacts.</p>
+
+    <div class="user-info">
+      <p>
+        Welcome, <strong>{{ asf_id }}</strong>! You are authenticated as an 
ASF committer.
+      </p>
+    </div>
+
+    {% if key_info %}
+      <div class="key-info">
+        <h3>Success: Added Key</h3>
+        <dl>
+          <dt>Key ID</dt>
+          <dd>
+            {{ key_info.key_id }}
+          </dd>
+          <dt>Fingerprint</dt>
+          <dd>
+            {{ key_info.fingerprint }}
+          </dd>
+          <dt>User ID</dt>
+          <dd>
+            {{ key_info.user_id }}
+          </dd>
+          <dt>Created</dt>
+          <dd>
+            {{ key_info.creation_date }}
+          </dd>
+          <dt>Expires</dt>
+          <dd>
+            {{ key_info.expiration_date or 'Never' }}
+          </dd>
+          <dt>Key Data</dt>
+          <dd>
+            <pre>{{ key_info.data }}</pre>
+          </dd>
+        </dl>
+      </div>
+    {% endif %}
+
+    {% if error %}
+      <div class="error-message">
+        <h2>Error: Did Not Add Key</h2>
+        <p>{{ error }}</p>
+      </div>
+    {% endif %}
+
+    {% if success %}
+      <div class="success-message">
+        <h2>Success</h2>
+        <p>{{ success }}</p>
+      </div>
+    {% endif %}
+
+    {% if user_keys %}
+      <div class="existing-keys">
+        <h2>Your Existing Keys</h2>
+        <div class="keys-grid">
+          {% for key in user_keys %}
+            <div class="key-card">
+              <h3>Key Details</h3>
+              <dl>
+                <dt>Key Type</dt>
+                <dd>
+                  {{ key.key_type }}
+                </dd>
+                <dt>Expires</dt>
+                <dd>
+                  {{ key.expiration.strftime("%Y-%m-%d %H:%M:%S") if 
key.expiration else 'Never' }}
+                </dd>
+              </dl>
+            </div>
+          {% endfor %}
+        </div>
+      </div>
+    {% endif %}
+
+    <form method="post">
+      <div class="form-group">
+        <label for="public_key">Public Key:</label>
+        <textarea id="public_key"
+                  name="public_key"
+                  required
+                  placeholder="Paste your GPG public key here (in 
ASCII-armored format)"
+                  aria-describedby="key-help"></textarea>
+        <small id="key-help">
+          Your public key should be in ASCII-armored format, starting with 
"-----BEGIN PGP PUBLIC KEY BLOCK-----"
+        </small>
+      </div>
+
+      <button type="submit">Add Key</button>
+    </form>
+
+    <div class="navigation">
+      <a href="{{ url_for('root_user_uploads') }}">Back to Your Uploads</a>
+      <a href="{{ url_for('root_pages') }}">Return to Main Page</a>
+    </div>
+  </body>
+</html>
diff --git a/poetry.lock b/poetry.lock
index 97a223e..b541eb2 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1912,6 +1912,18 @@ pytest = ">=6.2.5"
 [package.extras]
 dev = ["pre-commit", "pytest-asyncio", "tox"]
 
+[[package]]
+name = "python-gnupg"
+version = "0.5.4"
+description = "A wrapper for the Gnu Privacy Guard (GPG or GnuPG)"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+    {file = "python-gnupg-0.5.4.tar.gz", hash = 
"sha256:f2fdb5fb29615c77c2743e1cb3d9314353a6e87b10c37d238d91ae1c6feae086"},
+    {file = "python_gnupg-0.5.4-py2.py3-none-any.whl", hash = 
"sha256:40ce25cde9df29af91fe931ce9df3ce544e14a37f62b13ca878c897217b2de6c"},
+]
+
 [[package]]
 name = "pyyaml"
 version = "6.0.2"
@@ -2593,4 +2605,4 @@ propcache = ">=0.2.0"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"211ad6b84f4ca3f7e8f96e8d70440fde5bffca23d51f015b205d2c037337b764"
+content-hash = 
"ef8075690ef0c336342f273462d074b3c73b8eea01e608cdfbbee93787178303"
diff --git a/pyproject.toml b/pyproject.toml
index f8ca0ee..2766522 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,7 @@ dependencies = [
   "cryptography~=44.0",
   "httpx~=0.27",
   "hypercorn~=0.17",
+  "python-gnupg~=0.5",
   "sqlmodel~=0.0",
 ]
 
@@ -44,6 +45,7 @@ asfquart = { path = "./asfquart", develop = true }
 cryptography = "~=44.0"
 httpx = "~=0.27"
 hypercorn = "~=0.17"
+python-gnupg = "~=0.5"
 sqlmodel = "~=0.0"
 
 [tool.pyright]
diff --git a/uv.lock b/uv.lock
index 6bd7588..2417b89 100644
--- a/uv.lock
+++ b/uv.lock
@@ -921,6 +921,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl";,
 hash = 
"sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size 
= 9863 },
 ]
 
+[[package]]
+name = "python-gnupg"
+version = "0.5.4"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/f1/3e/ba0dc69c9f4e0aeb24d93175230ef057c151790a7516012f61014918992d/python-gnupg-0.5.4.tar.gz";,
 hash = 
"sha256:f2fdb5fb29615c77c2743e1cb3d9314353a6e87b10c37d238d91ae1c6feae086", size 
= 65705 }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/7b/5b/6666ed5a0d3ce4d5444af62e373d5ba8ab253a03487c86f2f9f1078e7c31/python_gnupg-0.5.4-py2.py3-none-any.whl";,
 hash = 
"sha256:40ce25cde9df29af91fe931ce9df3ce544e14a37f62b13ca878c897217b2de6c", size 
= 21730 },
+]
+
 [[package]]
 name = "pyyaml"
 version = "6.0.2"
@@ -1083,6 +1092,7 @@ dependencies = [
     { name = "cryptography" },
     { name = "httpx" },
     { name = "hypercorn" },
+    { name = "python-gnupg" },
     { name = "sqlmodel" },
 ]
 
@@ -1102,6 +1112,7 @@ requires-dist = [
     { name = "cryptography", specifier = "~=44.0" },
     { name = "httpx", specifier = "~=0.27" },
     { name = "hypercorn", specifier = "~=0.17" },
+    { name = "python-gnupg", specifier = "~=0.5" },
     { name = "sqlmodel", specifier = "~=0.0" },
 ]
 


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

Reply via email to