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]