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 45d9d27  Add Personal Access Tokens and a page to manage them
45d9d27 is described below

commit 45d9d2752baed6f8661122487100152d646fe050
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jul 3 17:05:22 2025 +0100

    Add Personal Access Tokens and a page to manage them
---
 atr/db/models.py                                |  12 ++
 atr/routes/modules.py                           |   1 +
 atr/routes/root.py                              |   8 -
 atr/routes/tokens.py                            | 240 ++++++++++++++++++++++++
 atr/templates/blank.html                        |  13 ++
 migrations/versions/0015_2025.07.03_cb10d8d3.py |  43 +++++
 poetry.lock                                     |  17 +-
 pyproject.toml                                  |   1 +
 8 files changed, 326 insertions(+), 9 deletions(-)

diff --git a/atr/db/models.py b/atr/db/models.py
index 08f01e4..f7db4b0 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -798,3 +798,15 @@ def check_release_name(_mapper: orm.Mapper, _connection: 
sqlalchemy.Connection,
 def release_name(project_name: str, version_name: str) -> str:
     """Return the release name for a given project and version."""
     return f"{project_name}-{version_name}"
+
+
+class PersonalAccessToken(sqlmodel.SQLModel, table=True):
+    id: int | None = sqlmodel.Field(default=None, primary_key=True)
+    asfuid: str = sqlmodel.Field(index=True)
+    token_hash: str = sqlmodel.Field(unique=True)
+    created: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
+    )
+    expires: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    last_used: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
+    label: str | None = None
diff --git a/atr/routes/modules.py b/atr/routes/modules.py
index 920dfe9..8d47782 100644
--- a/atr/routes/modules.py
+++ b/atr/routes/modules.py
@@ -34,6 +34,7 @@ import atr.routes.resolve as resolve
 import atr.routes.revisions as revisions
 import atr.routes.root as root
 import atr.routes.start as start
+import atr.routes.tokens as tokens
 import atr.routes.upload as upload
 import atr.routes.vote as vote
 import atr.routes.voting as voting
diff --git a/atr/routes/root.py b/atr/routes/root.py
index 4638de1..72817a6 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -18,14 +18,12 @@
 """root.py"""
 
 import asfquart.session
-import quart
 import sqlalchemy.orm as orm
 import sqlmodel
 import werkzeug.wrappers.response as response
 
 import atr.db as db
 import atr.db.models as models
-import atr.jwtoken as jwtoken
 import atr.routes as routes
 import atr.template as template
 import atr.user as user
@@ -113,12 +111,6 @@ async def todo(session: routes.CommitterSession) -> str:
     return await template.render("todo.html")
 
 
[email protected]("/token")
-async def token(session: routes.CommitterSession) -> quart.Response:
-    token = jwtoken.issue(session.uid)
-    return quart.jsonify({"token": token})
-
-
 @routes.committer("/tutorial")
 async def tutorial(session: routes.CommitterSession) -> str:
     """Tutorial page."""
diff --git a/atr/routes/tokens.py b/atr/routes/tokens.py
new file mode 100644
index 0000000..d9f83f1
--- /dev/null
+++ b/atr/routes/tokens.py
@@ -0,0 +1,240 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import datetime
+import hashlib
+import logging
+import secrets
+import time
+from typing import Final
+
+import markupsafe
+import quart
+import sqlmodel
+import werkzeug.datastructures as datastructures
+import werkzeug.wrappers.response as response
+import wtforms
+import wtforms.fields.core as core
+from htpy import Element, code, div, form, h1, h2, p, strong, table, tbody, 
td, th, thead, tr
+
+import atr.db as db
+import atr.db.models as models
+import atr.routes as routes
+import atr.template as templates
+import atr.util as util
+
+_EXPIRY_DAYS: Final[int] = 180
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
+type Fragment = Element | core.Field | str
+
+
+class AddTokenForm(util.QuartFormTyped):
+    csrf_token: wtforms.Field
+    label = wtforms.StringField(
+        "Label",
+        validators=[wtforms.validators.Optional(), 
wtforms.validators.Length(max=100)],
+        render_kw={"placeholder": "E.g. CI bot"},
+    )
+    submit = wtforms.SubmitField("Generate token")
+
+
+class DeleteTokenForm(util.QuartFormTyped):
+    csrf_token: wtforms.Field
+    token_id = 
wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
+    submit = wtforms.SubmitField("Delete")
+
+
[email protected]("/tokens", methods=["GET", "POST"])
+async def tokens(session: routes.CommitterSession) -> str | response.Response:
+    request_form = await quart.request.form
+
+    if is_post := quart.request.method == "POST":
+        maybe_response = await _handle_post(session, request_form)
+        if maybe_response is not None:
+            return maybe_response
+
+    add_form = await AddTokenForm.create_form(data=request_form if is_post 
else None)
+
+    start = time.perf_counter_ns()
+    tokens_list = await _fetch_tokens(session.uid)
+    end = time.perf_counter_ns()
+    _LOGGER.info("Tokens list fetched in %dms", (end - start) / 1_000_000)
+
+    start = time.perf_counter_ns()
+    add_form_elem = _build_add_form_element(add_form)
+    tokens_table = _build_tokens_table(tokens_list)
+
+    content_elem = div[
+        h1["Tokens"],
+        h2["Personal Access Tokens (PATs)"],
+        p[
+            "Generate tokens for API access. For security, the plaintext token 
"
+            "is shown only once when you create it. You can revoke tokens you 
no "
+            "longer need.",
+        ],
+        div(".card.mb-4")[
+            div(".card-header")["Generate new token"],
+            div(".card-body")[add_form_elem],
+        ],
+        tokens_table,
+    ]
+    end = time.perf_counter_ns()
+    _LOGGER.info("Content elem built in %dms", (end - start) / 1_000_000)
+
+    start = time.perf_counter_ns()
+    rendered = await templates.render(
+        "blank.html",
+        title="Tokens",
+        description="Manage your Personal Access Tokens.",
+        content=content_elem,
+    )
+    end = time.perf_counter_ns()
+    _LOGGER.info("Rendered in %dms", (end - start) / 1_000_000)
+
+    return rendered
+
+
+def _as_markup(fragment: Fragment) -> markupsafe.Markup:
+    return markupsafe.Markup(str(fragment))
+
+
+def _build_add_form_element(a_form: AddTokenForm) -> markupsafe.Markup:
+    elem = form(method="post", action=util.as_url(tokens))[
+        _as_markup(a_form.csrf_token),
+        div(".mb-3")[
+            a_form.label.label,
+            a_form.label(class_="form-control"),
+        ],
+        a_form.submit(class_="btn btn-primary"),
+    ]
+    return _as_markup(elem)
+
+
+def _build_delete_form_element(token_id: int | None) -> markupsafe.Markup:
+    d_form = DeleteTokenForm()
+    d_form.token_id.data = "" if token_id is None else str(token_id)
+    elem = form(".mb-0", method="post", action=util.as_url(tokens))[
+        _as_markup(d_form.csrf_token),
+        _as_markup(d_form.token_id),
+        d_form.submit(class_="btn btn-sm btn-danger"),
+    ]
+    return _as_markup(elem)
+
+
+def _build_tokens_table(tokens_list: list[models.PersonalAccessToken]) -> 
markupsafe.Markup:
+    if not tokens_list:
+        return _as_markup(p["No tokens found."])
+
+    rows = [
+        tr(".align-middle")[
+            td[t.label or ""],
+            td[util.format_datetime(t.created)],
+            td[util.format_datetime(t.expires)],
+            td[util.format_datetime(t.last_used) if t.last_used else "Never"],
+            td[_build_delete_form_element(t.id)],
+        ]
+        for t in tokens_list
+    ]
+
+    table_elem = table(".table.table-striped")[
+        thead[
+            tr[
+                th["Label"],
+                th["Created"],
+                th["Expires"],
+                th["Last used"],
+                th[""],
+            ]
+        ],
+        tbody[rows],
+    ]
+    return _as_markup(table_elem)
+
+
+async def _create_token(uid: str, label: str | None) -> str:
+    plaintext = secrets.token_urlsafe(32)
+    token_hash = hashlib.sha3_256(plaintext.encode()).hexdigest()
+    created = datetime.datetime.now(datetime.UTC)
+    expires = created + datetime.timedelta(days=_EXPIRY_DAYS)
+
+    async with db.session() as data:
+        async with data.begin():
+            pat = models.PersonalAccessToken(
+                asfuid=uid,
+                token_hash=token_hash,
+                created=created,
+                expires=expires,
+                label=label,
+            )
+            data.add(pat)
+    return plaintext
+
+
+async def _delete_token(uid: str, token_id: int) -> None:
+    async with db.session() as data:
+        async with data.begin():
+            stmt = sqlmodel.select(models.PersonalAccessToken).where(
+                models.PersonalAccessToken.id == token_id,
+                models.PersonalAccessToken.asfuid == uid,
+            )
+            pat = (await data.execute(stmt)).scalar_one_or_none()
+            if pat:
+                await data.delete(pat)
+
+
+async def _fetch_tokens(uid: str) -> list[models.PersonalAccessToken]:
+    via = models.validate_instrumented_attribute
+    async with db.session() as data:
+        stmt = (
+            sqlmodel.select(models.PersonalAccessToken)
+            .where(models.PersonalAccessToken.asfuid == uid)
+            .order_by(via(models.PersonalAccessToken.created))
+        )
+        return list((await data.execute(stmt)).scalars())
+
+
+async def _handle_post(
+    session: routes.CommitterSession, request_form: datastructures.MultiDict
+) -> response.Response | None:
+    if "token_id" in request_form:
+        del_form = await DeleteTokenForm.create_form(data=request_form)
+        if await del_form.validate_on_submit():
+            token_id_val = int(str(del_form.token_id.data))
+            await _delete_token(session.uid, token_id_val)
+            await quart.flash("Token deleted successfully", "success")
+            return await session.redirect(tokens)
+        await quart.flash("Invalid delete request", "error")
+        return None
+
+    add_form = await AddTokenForm.create_form(data=request_form)
+    if await add_form.validate_on_submit():
+        label_val = str(add_form.label.data) if add_form.label.data else None
+        plaintext = await _create_token(session.uid, label_val)
+        success_msg = div[
+            p[
+                strong["Your new token"],
+                " is ",
+                code(".bg-light.border.rounded.px-1")[plaintext],
+            ],
+            p(".mb-0")["Copy it now as you will not be able to see it again."],
+        ]
+        await quart.flash(_as_markup(success_msg), "success")
+        return await session.redirect(tokens)
+
+    return None
diff --git a/atr/templates/blank.html b/atr/templates/blank.html
new file mode 100644
index 0000000..51e23d3
--- /dev/null
+++ b/atr/templates/blank.html
@@ -0,0 +1,13 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  {{ title }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+  {{ description }}
+{% endblock description %}
+
+{% block content %}
+  {{ content }}
+{% endblock content %}
diff --git a/migrations/versions/0015_2025.07.03_cb10d8d3.py 
b/migrations/versions/0015_2025.07.03_cb10d8d3.py
new file mode 100644
index 0000000..59e390f
--- /dev/null
+++ b/migrations/versions/0015_2025.07.03_cb10d8d3.py
@@ -0,0 +1,43 @@
+"""Add PATs
+
+Revision ID: 0015_2025.07.03_cb10d8d3
+Revises: 0014_2025.07.02_dd73e63e
+Create Date: 2025-07-03 14:36:50.267367+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.db.models
+
+# Revision identifiers, used by Alembic
+revision: str = "0015_2025.07.03_cb10d8d3"
+down_revision: str | None = "0014_2025.07.02_dd73e63e"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "personalaccesstoken",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("asfuid", sa.String(), nullable=False),
+        sa.Column("token_hash", sa.String(), nullable=False),
+        sa.Column("created", atr.db.models.UTCDateTime(timezone=True), 
nullable=True),
+        sa.Column("expires", atr.db.models.UTCDateTime(timezone=True), 
nullable=True),
+        sa.Column("last_used", atr.db.models.UTCDateTime(timezone=True), 
nullable=True),
+        sa.Column("label", sa.String(), nullable=True),
+        sa.PrimaryKeyConstraint("id", name=op.f("pk_personalaccesstoken")),
+        sa.UniqueConstraint("token_hash", 
name=op.f("uq_personalaccesstoken_token_hash")),
+    )
+    with op.batch_alter_table("personalaccesstoken", schema=None) as batch_op:
+        batch_op.create_index(batch_op.f("ix_personalaccesstoken_asfuid"), 
["asfuid"], unique=False)
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("personalaccesstoken", schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f("ix_personalaccesstoken_asfuid"))
+
+    op.drop_table("personalaccesstoken")
diff --git a/poetry.lock b/poetry.lock
index 43ececc..96a0ccd 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1164,6 +1164,21 @@ files = [
     {file = "hpack-4.1.0.tar.gz", hash = 
"sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"},
 ]
 
+[[package]]
+name = "htpy"
+version = "25.7.0"
+description = "htpy - HTML in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+    {file = "htpy-25.7.0-py3-none-any.whl", hash = 
"sha256:439a568e208890e4cf8d6629662d88af9fa4f7e6737b59d3674aac62414d2497"},
+    {file = "htpy-25.7.0.tar.gz", hash = 
"sha256:7dceccdca7f177a91354aec0b417d8ac9f2f103c225ec888e63cd0e769f1b2db"},
+]
+
+[package.dependencies]
+markupsafe = ">=2.0.0"
+
 [[package]]
 name = "hypercorn"
 version = "0.17.3"
@@ -3166,4 +3181,4 @@ propcache = ">=0.2.1"
 [metadata]
 lock-version = "2.1"
 python-versions = "~=3.13"
-content-hash = 
"cf3130d7e028316482b7e62383c05fe183f64284f99b788b5aea94be162934d8"
+content-hash = 
"90ea9e07b07f883138297fa9a2aac6656cc8db5d7ca25e28cb8bcd6b13027803"
diff --git a/pyproject.toml b/pyproject.toml
index 54ad11b..547f475 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ dependencies = [
   "rich~=14.0.0",
   "sqlmodel~=0.0.24",
   "pyjwt (>=2.10.1,<3.0.0)",
+  "htpy (>=25.7.0,<26.0.0)",
 ]
 
 [dependency-groups]


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

Reply via email to