You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/08/05 13:32:35 UTC

(tooling-trusted-release) branch main updated: Track last time of use for PATs

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 9f1641a  Track last time of use for PATs
9f1641a is described below

commit 9f1641a8cae8cf0205506334bcabba12987e00c5
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue Aug 5 14:31:59 2025 +0100

    Track last time of use for PATs
---
 atr/blueprints/api/api.py     | 14 +++++---------
 atr/routes/tokens.py          | 13 +++++++------
 atr/storage/writers/tokens.py | 31 ++++++++++++++++++++++++++-----
 3 files changed, 38 insertions(+), 20 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index b501c20..a5eae63 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -17,7 +17,6 @@
 
 
 import base64
-import datetime
 import hashlib
 import pathlib
 from typing import Any
@@ -337,18 +336,15 @@ async def jwt_create(data: models.api.JwtCreateArgs) -> DictResponse:
     """
     # Expects {"asfuid": "uid", "pat": "pat-token"}
     # Returns {"asfuid": "uid", "jwt": "jwt-token"}
-    token_hash = hashlib.sha3_256(data.pat.encode()).hexdigest()
-    pat_rec = await _get_pat(data.asfuid, token_hash)
-
-    now = datetime.datetime.now(datetime.UTC)
-    if (pat_rec is None) or (pat_rec.expires < now):
-        raise exceptions.Unauthorized("Invalid PAT")
+    asf_uid = data.asfuid
+    async with storage.write(asf_uid) as write:
+        wafc = write.as_foundation_committer()
+        jwt = await wafc.tokens.issue_jwt(data.pat)
 
-    jwt_token = jwtoken.issue(data.asfuid)
     return models.api.JwtCreateResults(
         endpoint="/jwt/create",
         asfuid=data.asfuid,
-        jwt=jwt_token,
+        jwt=jwt,
     ).model_dump(), 200
 
 
diff --git a/atr/routes/tokens.py b/atr/routes/tokens.py
index 89b38e7..e287a93 100644
--- a/atr/routes/tokens.py
+++ b/atr/routes/tokens.py
@@ -108,9 +108,10 @@ async def tokens(session: routes.CommitterSession) -> str | response.Response:
 
     issue_jwt_elem = div[
         p[
-            "Generate a JSON Web Token (JWT) to authenticate calls to ATR's private API routes. "
-            "Treat the token like a password and include it in the Authorization header "
-            "as a Bearer token when invoking the protected endpoints.",
+            """Generate a JSON Web Token (JWT) to authenticate calls to ATR's
+            private API routes. Treat the token like a password and include it
+            in the Authorization header as a Bearer token when invoking the
+            protected endpoints."""
             # p["Example"],
         ],
         issue_form_elem,
@@ -121,9 +122,9 @@ async def tokens(session: routes.CommitterSession) -> str | response.Response:
         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.",
+            """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"],
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index 72e49d4..e503386 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -18,15 +18,16 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+import datetime
+import hashlib
+
+import sqlmodel
 
 import atr.db as db
+import atr.jwtoken as jwtoken
 import atr.models.sql as sql
 import atr.storage as storage
 
-if TYPE_CHECKING:
-    import datetime
-
 
 class GeneralPublic:
     def __init__(
@@ -47,7 +48,10 @@ class FoundationCommitter(GeneralPublic):
         self.__credentials = credentials
         self.__write = write
         self.__data = data
-        self.__asf_uid = write.authorisation.asf_uid
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
 
     async def add_token(
         self, uid: str, token_hash: str, created: datetime.datetime, expires: datetime.datetime, label: str | None
@@ -63,6 +67,23 @@ class FoundationCommitter(GeneralPublic):
         await self.__data.commit()
         return pat
 
+    async def issue_jwt(self, pat_text: str) -> str:
+        pat_hash = hashlib.sha3_256(pat_text.encode()).hexdigest()
+        pat = await self.__data.query_one_or_none(
+            sqlmodel.select(sql.PersonalAccessToken).where(
+                sql.PersonalAccessToken.asfuid == self.__asf_uid,
+                sql.PersonalAccessToken.token_hash == pat_hash,
+            )
+        )
+        if pat is None:
+            raise storage.AccessError("Invalid PAT")
+        if pat.expires < datetime.datetime.now(datetime.UTC):
+            raise storage.AccessError("Expired PAT")
+        issued_jwt = jwtoken.issue(self.__asf_uid)
+        pat.last_used = datetime.datetime.now(datetime.UTC)
+        await self.__data.commit()
+        return issued_jwt
+
 
 class CommitteeParticipant(FoundationCommitter):
     def __init__(


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org