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]