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
commit 3aa22ffb404eae5776f292460a5b1504d671e756 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..1b186c65 --- /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 +import atr.storage.types as types +import atr.storage.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 = atr.storage.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 = atr.storage.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]
