This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 7c939006 Omit hashes from PAT records
7c939006 is described below

commit 7c939006372f788a322b14e78fe6c835fb798f89
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Mar 5 15:38:42 2026 +0000

    Omit hashes from PAT records
---
 atr/get/tokens.py              |   4 +-
 atr/storage/readers/tokens.py  |  13 ++--
 atr/storage/types.py           |  23 +++++++
 atr/storage/writers/tokens.py  |   5 +-
 tests/unit/test_tokens_safe.py | 138 +++++++++++++++++++++++++++++++++++++++++
 5 files changed, 175 insertions(+), 8 deletions(-)

diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index 49592225..3947a270 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -20,10 +20,10 @@ from typing import Literal
 import atr.blueprints.get as get
 import atr.form as form
 import atr.htm as htm
-import atr.models.sql as sql
 import atr.post as post
 import atr.shared as shared
 import atr.storage as storage
+import atr.storage.types as types
 import atr.template as template
 import atr.util as util
 import atr.web as web
@@ -91,7 +91,7 @@ async def tokens(_session: web.Committer, _tokens: 
Literal["tokens"]) -> str:
     )
 
 
-def _build_tokens_table(page: htm.Block, tokens_list: 
list[sql.PersonalAccessToken]) -> None:
+def _build_tokens_table(page: htm.Block, tokens_list: 
list[types.PersonalAccessTokenSafe]) -> None:
     if not tokens_list:
         page.p["No tokens found."]
         return
diff --git a/atr/storage/readers/tokens.py b/atr/storage/readers/tokens.py
index c0ebac0d..02436346 100644
--- a/atr/storage/readers/tokens.py
+++ b/atr/storage/readers/tokens.py
@@ -23,6 +23,7 @@ import sqlmodel
 import atr.db as db
 import atr.models.sql as sql
 import atr.storage as storage
+import atr.storage.types as types
 
 
 class GeneralPublic:
@@ -40,7 +41,7 @@ class FoundationCommitter(GeneralPublic):
         self.__read_as = read_as
         self.__data = data
 
-    async def own_personal_access_tokens(self) -> 
list[sql.PersonalAccessToken]:
+    async def own_personal_access_tokens(self) -> 
list[types.PersonalAccessTokenSafe]:
         asf_uid = self.__read.authorisation.asf_uid
         if asf_uid is None:
             raise ValueError("Not authorized")
@@ -50,9 +51,10 @@ class FoundationCommitter(GeneralPublic):
             .where(sql.PersonalAccessToken.asfuid == asf_uid)
             .order_by(via(sql.PersonalAccessToken.created))
         )
-        return await self.__data.query_all(stmt)
+        tokens = await self.__data.query_all(stmt)
+        return [types.PersonalAccessTokenSafe.from_sql(token) for token in 
tokens]
 
-    async def most_recent_jwt_pat(self) -> sql.PersonalAccessToken | None:
+    async def most_recent_jwt_pat(self) -> types.PersonalAccessTokenSafe | 
None:
         # , asf_uid: str | None = None
         # if asf_uid is None:
         asf_uid = self.__read.authorisation.asf_uid
@@ -66,4 +68,7 @@ class FoundationCommitter(GeneralPublic):
             .order_by(via(sql.PersonalAccessToken.last_used).desc())
             .limit(1)
         )
-        return await self.__data.query_one_or_none(stmt)
+        token = await self.__data.query_one_or_none(stmt)
+        if token is None:
+            return None
+        return types.PersonalAccessTokenSafe.from_sql(token)
diff --git a/atr/storage/types.py b/atr/storage/types.py
index eb7209a5..c78ec358 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import dataclasses
+import datetime
 import enum
 import pathlib
 from collections.abc import Callable
@@ -73,6 +74,28 @@ class PathInfo(schema.Strict):
     warnings: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
 
 
+class PersonalAccessTokenSafe(schema.Strict):
+    asfuid: str
+    created: datetime.datetime
+    expires: datetime.datetime
+    id: int
+    label: str | None
+    last_used: datetime.datetime | None
+
+    @classmethod
+    def from_sql(cls, token: sql.PersonalAccessToken) -> 
"PersonalAccessTokenSafe":
+        if token.id is None:
+            raise ValueError("PAT must have an ID before being returned")
+        return cls(
+            id=token.id,
+            asfuid=token.asfuid,
+            created=token.created,
+            expires=token.expires,
+            label=token.label,
+            last_used=token.last_used,
+        )
+
+
 @dataclasses.dataclass
 class ChecksSubset:
     checks: list[sql.CheckResult]
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index 9a0dab53..7ade0050 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -31,6 +31,7 @@ import atr.log as log
 import atr.mail as mail
 import atr.models.sql as sql
 import atr.storage as storage
+import atr.storage.types as types
 
 # TODO: Check that this is known and that its emails are correctly discarded
 NOREPLY_EMAIL_ADDRESS: Final[str] = "[email protected]"
@@ -62,7 +63,7 @@ class FoundationCommitter(GeneralPublic):
 
     async def add_token(
         self, token_hash: str, created: datetime.datetime, expires: 
datetime.datetime, label: str | None
-    ) -> sql.PersonalAccessToken:
+    ) -> types.PersonalAccessTokenSafe:
         if not label:
             raise ValueError("Label is required")
         pat = sql.PersonalAccessToken(
@@ -82,7 +83,7 @@ class FoundationCommitter(GeneralPublic):
             "If you did not create this token, please revoke it immediately.",
         )
         await self.__write_as.mail.send(message)
-        return pat
+        return types.PersonalAccessTokenSafe.from_sql(pat)
 
     async def delete_token(self, token_id: int) -> None:
         pat = await self.__data.query_one_or_none(
diff --git a/tests/unit/test_tokens_safe.py b/tests/unit/test_tokens_safe.py
new file mode 100644
index 00000000..c84cd081
--- /dev/null
+++ b/tests/unit/test_tokens_safe.py
@@ -0,0 +1,138 @@
+# 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 unittest.mock as mock
+
+import pytest
+
+import atr.models.sql as sql
+import atr.storage.readers.tokens as readers_tokens
+import atr.storage.types as types
+import atr.storage.writers.tokens as writers_tokens
+
+
+class FakeAuthorisation:
+    def __init__(self, asf_uid: str | None):
+        self.asf_uid = asf_uid
+
+
+class FakeRead:
+    def __init__(self, asf_uid: str | None):
+        self.authorisation = FakeAuthorisation(asf_uid)
+
+
+class FakeReadData:
+    def __init__(self, tokens: list[sql.PersonalAccessToken], most_recent: 
sql.PersonalAccessToken | None):
+        self._tokens = tokens
+        self._most_recent = most_recent
+
+    async def query_all(self, _stmt):
+        return self._tokens
+
+    async def query_one_or_none(self, _stmt):
+        return self._most_recent
+
+
+class FakeWrite:
+    def __init__(self, asf_uid: str | None):
+        self.authorisation = FakeAuthorisation(asf_uid)
+
+
+class FakeWriteAs:
+    def __init__(self):
+        self.mail = mock.MagicMock()
+        self.mail.send = mock.AsyncMock(return_value=("mid", []))
+
+
+class FakeWriteData:
+    def __init__(self):
+        self._added: sql.PersonalAccessToken | None = None
+
+    def add(self, pat: sql.PersonalAccessToken) -> None:
+        self._added = pat
+
+    async def commit(self) -> None:
+        if self._added is None:
+            return
+        self._added.id = 101
+
+
+def test_personal_access_token_safe_excludes_token_hash() -> None:
+    token = sql.PersonalAccessToken(
+        id=5,
+        asfuid="test",
+        
token_hash="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+        created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+        expires=datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC),
+        label="unit",
+        last_used=None,
+    )
+
+    safe = types.PersonalAccessTokenSafe.from_sql(token)
+
+    assert safe.id == 5
+    assert safe.asfuid == "test"
+    assert "token_hash" not in safe.model_dump()
+    assert not hasattr(safe, "token_hash")
+
+
[email protected]
+async def test_reader_pat_methods_return_safe_tokens() -> None:
+    created = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+    token = sql.PersonalAccessToken(
+        id=7,
+        asfuid="test",
+        
token_hash="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+        created=created,
+        expires=created + datetime.timedelta(days=180),
+        label="reader-test",
+        last_used=created + datetime.timedelta(days=1),
+    )
+    read = FakeRead("test")
+    data = FakeReadData([token], token)
+    reader = readers_tokens.FoundationCommitter(read, mock.MagicMock(), data)
+
+    tokens_list = await reader.own_personal_access_tokens()
+    most_recent = await reader.most_recent_jwt_pat()
+
+    assert len(tokens_list) == 1
+    assert isinstance(tokens_list[0], types.PersonalAccessTokenSafe)
+    assert "token_hash" not in tokens_list[0].model_dump()
+    assert isinstance(most_recent, types.PersonalAccessTokenSafe)
+    assert most_recent is not None
+    assert "token_hash" not in most_recent.model_dump()
+
+
[email protected]
+async def test_writer_add_token_returns_safe_token() -> None:
+    created = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+    write = FakeWrite("test")
+    write_as = FakeWriteAs()
+    data = FakeWriteData()
+    writer = writers_tokens.FoundationCommitter(write, write_as, data)
+
+    safe = await writer.add_token(
+        
token_hash="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+        created=created,
+        expires=created + datetime.timedelta(days=180),
+        label="writer-test",
+    )
+
+    assert isinstance(safe, types.PersonalAccessTokenSafe)
+    assert safe.id == 101
+    assert "token_hash" not in safe.model_dump()


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

Reply via email to