You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/06/08 16:43:40 UTC
(tooling-trusted-releases) branch arm updated (4025c0de -> 0d862bff)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 4025c0de Constrain recipient policy update from API to accepted domain/subdomains
discard fd11fbb5 Collapse API decorators into one
discard 2cf9c3f4 Initial commit - trying to enable arbitrary emails from .asf.yaml
add 2daa19fe Update .asf.yaml
new 90d89183 #1305 enable arbitrary emails from .asf.yaml
new fabaf2bf Collapse API decorators into one
new 0d862bff Constrain recipient policy update from API to accepted domain/subdomains
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (4025c0de)
\
N -- N -- N refs/heads/arm (0d862bff)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.asf.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 02/03: Collapse API decorators into one
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit fabaf2bf914043126f52725d8557f0fadead42fe
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Jun 8 16:40:02 2026 +0100
Collapse API decorators into one
---
atr/api/__init__.py | 368 ++++++++++++++++++---------------
atr/blueprints/api.py | 184 +++++++++--------
atr/blueprints/api_auth.py | 155 +++++---------
atr/docs/authentication-security.md | 4 +-
atr/docs/authorization-security.md | 2 +-
atr/docs/overview-of-the-code.md | 2 +-
atr/jwtoken.py | 52 +++--
tests/unit/test_api_auth_decorators.py | 176 ++++------------
tests/unit/test_api_route_factory.py | 136 ++++++++++++
tests/unit/test_vote_resolution.py | 12 +-
10 files changed, 558 insertions(+), 533 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index b414244a..48414a72 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -24,13 +24,12 @@ import asfquart.base as base
import openpgp
import pydantic
import quart
-import quart_rate_limiter as rate_limiter
-import quart_schema
import sqlalchemy
import sqlmodel
import werkzeug.exceptions as exceptions
import atr.blueprints.api as api
+import atr.blueprints.api_auth as api_auth
import atr.cle as cle
import atr.config as config
import atr.constants as constants
@@ -66,9 +65,10 @@ type DictResponse = tuple[dict[str, Any], int]
ROUTES_MODULE: Final[Literal[True]] = True
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ChecksListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ChecksListResults, 200),
+)
async def checks_list(
_checks_list: Literal["checks/list"],
project_key: safe.ProjectKey,
@@ -101,9 +101,10 @@ async def checks_list(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ChecksListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ChecksListResults, 200),
+)
async def checks_list_revision(
_checks_list: Literal["checks/list"],
project_key: safe.ProjectKey,
@@ -143,9 +144,10 @@ async def checks_list_revision(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ChecksOngoingResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ChecksOngoingResults, 200),
+)
async def checks_ongoing(
_checks_ongoing: Literal["checks/ongoing"],
project_key: safe.ProjectKey,
@@ -182,9 +184,10 @@ async def checks_ongoing(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.CatalogProjectResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.CatalogProjectResults, 200),
+)
async def catalog_project(
_catalog_project: Literal["catalog/project"],
project_key: safe.ProjectKey,
@@ -221,8 +224,7 @@ async def catalog_project(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
+@api.typed(auth_scheme=api_auth.Auth.PUBLIC)
async def cle_project(
_cle_project: Literal["cle/project"],
project_key: safe.ProjectKey,
@@ -251,8 +253,7 @@ async def cle_project(
return cle.project_document(project, events, releases, now=datetime.datetime.now(datetime.UTC)), 200
-@api.typed
-@api.auth.public
+@api.typed(auth_scheme=api_auth.Auth.PUBLIC)
async def cle_release(
_cle_release: Literal["cle/release"],
project_key: safe.ProjectKey,
@@ -295,9 +296,10 @@ async def cle_release(
), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.CommitteeGetResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.CommitteeGetResults, 200),
+)
async def committee_get(
_committee_get: Literal["committee/get"],
name: safe.CommitteeKey,
@@ -322,9 +324,10 @@ async def committee_get(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.CommitteeKeysResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.CommitteeKeysResults, 200),
+)
async def committee_keys(
_committee_keys: Literal["committee/keys"],
name: safe.CommitteeKey,
@@ -349,9 +352,10 @@ async def committee_keys(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.CommitteeProjectsResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.CommitteeProjectsResults, 200),
+)
async def committee_projects(
_committee_projects: Literal["committee/projects"],
name: safe.CommitteeKey,
@@ -376,9 +380,10 @@ async def committee_projects(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.CommitteesListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.CommitteesListResults, 200),
+)
async def committees_list(
_committees_list: Literal["committees/list"],
) -> DictResponse:
@@ -397,9 +402,10 @@ async def committees_list(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.body_oidc
+@api.typed(
+ auth_scheme=api_auth.Auth.BODY_OIDC,
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def distribute_ssh_register(
_distribute_ssh_register: Literal["distribute/ssh/register"],
data: models.api.DistributeSshRegisterArgs,
@@ -444,9 +450,10 @@ async def distribute_ssh_register(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.DistributionRecordResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.DistributionRecordResults, 200),
+)
async def distribution_record(
_distribution_record: Literal["distribution/record"],
data: models.api.DistributionRecordArgs,
@@ -488,8 +495,7 @@ async def distribution_record(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.body_oidc
+@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def distribution_record_from_workflow(
_distribute_record_from_workflow: Literal["distribute/record_from_workflow"],
data: models.api.DistributionRecordFromWorkflowArgs,
@@ -536,9 +542,10 @@ async def distribution_record_from_workflow(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.IgnoreAddResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.IgnoreAddResults, 200),
+)
async def ignore_add(
_ignore_add: Literal["ignore/add"],
data: models.api.IgnoreAddArgs,
@@ -569,9 +576,10 @@ async def ignore_add(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.IgnoreDeleteResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.IgnoreDeleteResults, 200),
+)
async def ignore_delete(
_ignore_delete: Literal["ignore/delete"],
data: models.api.IgnoreDeleteArgs,
@@ -596,9 +604,10 @@ async def ignore_delete(
# TODO: Rename to ignores
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.IgnoreListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.IgnoreListResults, 200),
+)
async def ignore_list(
_ignore_list: Literal["ignore/list"],
project_key: safe.ProjectKey,
@@ -617,9 +626,10 @@ async def ignore_list(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.pat
+@api.typed(
+ auth_scheme=api_auth.Auth.PAT,
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def jwt_create(
_jwt_create: Literal["jwt/create"],
data: models.api.JwtCreateArgs,
@@ -652,10 +662,11 @@ async def jwt_create(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.KeyAddResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.KeyAddResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def key_add(
_key_add: Literal["key/add"],
data: models.api.KeyAddArgs,
@@ -696,10 +707,11 @@ async def key_add(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.KeyDeleteResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.KeyDeleteResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def key_delete(
_key_delete: Literal["key/delete"],
data: models.api.KeyDeleteArgs,
@@ -735,10 +747,11 @@ async def key_delete(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.public
-@quart_schema.validate_response(models.api.KeyGetResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.KeyGetResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def key_get(
_key_get: Literal["key/get"],
fingerprint: unsafe.UnsafeStr,
@@ -760,10 +773,11 @@ async def key_get(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.KeysUploadResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.KeysUploadResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def keys_upload(
_keys_upload: Literal["keys/upload"],
data: models.api.KeysUploadArgs,
@@ -826,10 +840,11 @@ async def keys_upload(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.public
-@quart_schema.validate_response(models.api.KeysUserResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.KeysUserResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def keys_user(
_keys_user: Literal["keys/user"],
asf_uid: unsafe.UnsafeStr,
@@ -847,9 +862,10 @@ async def keys_user(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.PolicyGetResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.PolicyGetResults, 200),
+)
async def policy_get(
_policy_get: Literal["policy/get"],
project_key: safe.ProjectKey,
@@ -897,9 +913,10 @@ async def policy_get(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.PolicyUpdateResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.PolicyUpdateResults, 200),
+)
async def policy_update(
_policy_update: Literal["policy/update"],
data: models.api.PolicyUpdateArgs,
@@ -925,9 +942,10 @@ async def policy_update(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.system_bearer
-@quart_schema.validate_response(models.api.ProjectConfigResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.SYSTEM_BEARER,
+ response=(models.api.ProjectConfigResults, 200),
+)
async def project_config_upsert(
_project_config: Literal["project/config"],
data: models.api.ProjectConfigArgs,
@@ -959,9 +977,10 @@ async def project_config_upsert(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ProjectGetResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ProjectGetResults, 200),
+)
async def project_get(
_project_get: Literal["project/get"],
project_key: safe.ProjectKey,
@@ -979,9 +998,10 @@ async def project_get(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ProjectReleasesResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ProjectReleasesResults, 200),
+)
async def project_releases(
_project_releases: Literal["project/releases"],
project_key: safe.ProjectKey,
@@ -1000,9 +1020,10 @@ async def project_releases(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ProjectsListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ProjectsListResults, 200),
+)
async def projects_list(
_projects_list: Literal["projects/list"],
) -> DictResponse:
@@ -1020,8 +1041,7 @@ async def projects_list(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.body_oidc
+@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def publisher_distribution_record(
_publisher_distribution_record: Literal["publisher/distribution/record"],
data: models.api.PublisherDistributionRecordArgs,
@@ -1074,8 +1094,7 @@ async def publisher_distribution_record(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.body_oidc
+@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def publisher_release_announce(
_publisher_release_announce: Literal["publisher/release/announce"],
data: models.api.PublisherReleaseAnnounceArgs,
@@ -1113,9 +1132,10 @@ async def publisher_release_announce(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.body_oidc
+@api.typed(
+ auth_scheme=api_auth.Auth.BODY_OIDC,
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def publisher_ssh_register(
_publisher_ssh_register: Literal["publisher/ssh/register"],
data: models.api.PublisherSshRegisterArgs,
@@ -1153,8 +1173,7 @@ async def publisher_ssh_register(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.body_oidc
+@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def publisher_vote_resolve(
_publisher_vote_resolve: Literal["publisher/vote/resolve"],
data: models.api.PublisherVoteResolveArgs,
@@ -1191,10 +1210,11 @@ async def publisher_vote_resolve(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(5, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.ReleaseAnnounceResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.ReleaseAnnounceResults, 201),
+ rate_limit=(5, datetime.timedelta(hours=1)),
+)
async def release_announce(
_release_announce: Literal["release/announce"],
data: models.api.ReleaseAnnounceArgs,
@@ -1232,10 +1252,11 @@ async def release_announce(
).model_dump(mode="json"), 201
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.ReleaseCreateResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.ReleaseCreateResults, 201),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def release_create(
_release_create: Literal["release/create"],
data: models.api.ReleaseCreateArgs,
@@ -1263,9 +1284,10 @@ async def release_create(
# TODO: Duplicates the below
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.ReleaseDeleteResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.ReleaseDeleteResults, 200),
+)
async def release_delete(
_release_delete: Literal["release/delete"],
data: models.api.ReleaseDeleteArgs,
@@ -1291,9 +1313,10 @@ async def release_delete(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ReleaseGetResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ReleaseGetResults, 200),
+)
async def release_get(
_release_get: Literal["release/get"],
project_key: safe.ProjectKey,
@@ -1313,9 +1336,10 @@ async def release_get(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ReleasePathsResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ReleasePathsResults, 200),
+)
async def release_paths(
_release_paths: Literal["release/paths"],
project_key: safe.ProjectKey,
@@ -1345,9 +1369,10 @@ async def release_paths(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ReleaseRevisionsResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ReleaseRevisionsResults, 200),
+)
async def release_revisions(
_release_revisions: Literal["release/revisions"],
project_key: safe.ProjectKey,
@@ -1370,9 +1395,10 @@ async def release_revisions(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.ReleaseUploadResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.ReleaseUploadResults, 201),
+)
async def release_upload(
_release_upload: Literal["release/upload"],
data: models.api.ReleaseUploadArgs,
@@ -1410,9 +1436,10 @@ async def release_upload(
).model_dump(mode="json"), 201
-@api.typed
-@api.auth.public
-@quart_schema.validate_response(models.api.ReleasesListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ReleasesListResults, 200),
+)
async def releases_list(
_releases_list: Literal["releases/list"],
query_args: models.api.ReleasesListQuery,
@@ -1460,10 +1487,11 @@ async def releases_list(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.SignatureProvenanceResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.SignatureProvenanceResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def signature_provenance(
_signature_provenance: Literal["signature/provenance"],
data: models.api.SignatureProvenanceArgs,
@@ -1535,10 +1563,11 @@ async def signature_provenance(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.SshKeyAddResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.SshKeyAddResults, 201),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def ssh_key_add(
_ssh_key_add: Literal["ssh-key/add"],
data: models.api.SshKeyAddArgs,
@@ -1564,10 +1593,11 @@ async def ssh_key_add(
).model_dump(mode="json"), 201
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.SshKeyDeleteResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.SshKeyDeleteResults, 201),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def ssh_key_delete(
_ssh_key_delete: Literal["ssh-key/delete"],
data: models.api.SshKeyDeleteArgs,
@@ -1589,9 +1619,10 @@ async def ssh_key_delete(
).model_dump(mode="json"), 201
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.public
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def ssh_keys_list(
_ssh_keys_list: Literal["ssh-keys/list"],
asf_uid: unsafe.UnsafeStr,
@@ -1627,8 +1658,7 @@ async def ssh_keys_list(
).model_dump(mode="json"), 200
-@api.typed
-@api.auth.body_oidc
+@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def update_distribution_task_status(
_distribute_task_status: Literal["distribute/task/status"],
data: models.api.DistributeStatusUpdateArgs,
@@ -1658,10 +1688,11 @@ async def update_distribution_task_status(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.UserInfoResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.UserInfoResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def user_info(
_user_info: Literal["user/info"],
) -> DictResponse:
@@ -1681,10 +1712,11 @@ async def user_info(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.public
-@quart_schema.validate_response(models.api.UsersListResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.UsersListResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def users_list(
_users_list: Literal["users/list"],
) -> DictResponse:
@@ -1730,10 +1762,11 @@ async def users_list(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(60, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.VoteCastResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.VoteCastResults, 200),
+ rate_limit=(60, datetime.timedelta(hours=1)),
+)
async def vote_cast(
_vote_cast: Literal["vote/cast"],
data: models.api.VoteCastArgs,
@@ -1768,10 +1801,11 @@ async def vote_cast(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.VoteResolveResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.VoteResolveResults, 200),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
async def vote_resolve(
_vote_resolve: Literal["vote/resolve"],
data: models.api.VoteResolveArgs,
@@ -1810,10 +1844,11 @@ async def vote_resolve(
).model_dump(mode="json"), 200
-@api.typed
-@rate_limiter.rate_limit(5, datetime.timedelta(hours=1))
-@api.auth.bearer
-@quart_schema.validate_response(models.api.VoteStartResults, 201)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.VoteStartResults, 201),
+ rate_limit=(5, datetime.timedelta(hours=1)),
+)
async def vote_start(
_vote_start: Literal["vote/start"],
data: models.api.VoteStartArgs,
@@ -1888,9 +1923,10 @@ async def vote_start(
).model_dump(mode="json"), 201
-@api.typed
-@api.auth.bearer
-@quart_schema.validate_response(models.api.VoteTabulateResults, 200)
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.VoteTabulateResults, 200),
+)
async def vote_tabulate(
_vote_tabulate: Literal["vote/tabulate"],
data: models.api.VoteTabulateArgs,
@@ -1954,8 +1990,7 @@ class TestProjectStatusArgs(pydantic.BaseModel):
project_key: safe.ProjectKey
-@api.typed
-@api.auth.public
+@api.typed(auth_scheme=api_auth.Auth.PUBLIC)
async def test_activate_project(
_test_activate_project: Literal["test/activate-project"],
data: TestProjectStatusArgs,
@@ -1977,8 +2012,7 @@ async def test_activate_project(
return {"ok": True, "project_key": str(data.project_key)}, 200
-@api.typed
-@api.auth.public
+@api.typed(auth_scheme=api_auth.Auth.PUBLIC)
async def test_archive_project(
_test_archive_project: Literal["test/archive-project"],
data: TestProjectStatusArgs,
diff --git a/atr/blueprints/api.py b/atr/blueprints/api.py
index 7471b91d..54416649 100644
--- a/atr/blueprints/api.py
+++ b/atr/blueprints/api.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import datetime
-import inspect
+import functools
import time
from collections.abc import Awaitable, Callable
from types import ModuleType
@@ -41,7 +41,7 @@ __all__ = ["auth", "register", "typed"]
_BLUEPRINT_NAME: Final = "api_blueprint"
_BLUEPRINT: Final = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/api")
_routes: list[str] = []
-_route_auth_levels: dict[str, auth.AuthLevel] = {}
+_route_auth_schemes: dict[str, str] = {}
def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
@@ -51,74 +51,73 @@ def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
return api, _routes
-def route_auth_levels() -> dict[str, auth.AuthLevel]:
- """Return a snapshot of the (route function name -> auth level) map."""
- return dict(_route_auth_levels)
+def route_auth_schemes() -> dict[str, str]:
+ """Return a snapshot of the (route function name -> auth scheme) map."""
+ return dict(_route_auth_schemes)
-def typed(func: Callable[..., Awaitable[Any]]) -> web.RouteFunction[Any]:
- """Decorator that derives the URL path from the function's type annotations.
+def typed(
+ *,
+ auth_scheme: auth.Auth,
+ response: tuple[type[pydantic.BaseModel], int] | None = None,
+ rate_limit: tuple[int, datetime.timedelta] | None = None,
+) -> Callable[[Callable[..., Awaitable[Any]]], web.RouteFunction[Any]]:
+ """Single decorator for an API route.
- - Literal["..."] parameters become literal path segments
- - safe.SafeType subclass parameters are validated from the URL path
- - pydantic.BaseModel subclass parameters are parsed from the JSON request body
- - dataclass parameters are parsed from the query string
- - str | None parameters create optional URL segments (two routes registered)
- - int, float use Quart's built-in type converters
- - HTTP method is POST if a body param is present, GET otherwise
-
- Routes must also declare an auth level via @api.auth.<level> (see
- atr.blueprints.api_auth); a missing marker raises TypeError at import.
+ Derives the URL from the handler's typed arguments, then composes the pipeline
+ in a fixed order: rate limit, header-credential auth, body parsing,
+ body-credential auth, handler. Header auth wraps outside body validation so an
+ unauthenticated request is rejected before its body is parsed.
"""
- original = inspect.unwrap(func)
- _require_auth_level(func, original)
- path, validated_params, literal_params, body_param, _, query_param, optional_params = common.build_api_path(
- original
- )
- method = "POST" if (body_param is not None) else "GET"
- body_safe_params = common.safe_params_for_type(body_param[1]) if (body_param is not None) else []
- query_safe_params = common.safe_params_for_type(query_param[1]) if (query_param is not None) else []
-
- async def wrapper(*_args: Any, **kwargs: Any) -> Any:
- await common.validate_params(kwargs, validated_params)
- kwargs.update(literal_params)
-
- if body_param is not None:
- await common.parse_body(body_param, body_safe_params, kwargs)
-
- if query_param is not None:
- await common.parse_query(query_param, query_safe_params, kwargs)
-
- start_time_ns = time.perf_counter_ns()
- response = await func(**kwargs)
- end_time_ns = time.perf_counter_ns()
- total_ms = (end_time_ns - start_time_ns) // 1_000_000
- log.performance(f"API {method} {path} {original.__name__} = 0 0 {total_ms}")
-
- return response
-
- endpoint = common.setup_wrapper(wrapper, original, _BLUEPRINT_NAME)
-
- # Replace the original quart request decorators
- if query_param is not None:
- wrapper = quart_schema.validate_querystring(query_param[1])(wrapper)
- if body_param is not None:
- wrapper = quart_schema.validate_request(body_param[1])(wrapper)
- # Examine `func` for quart attributes and re-attach to the wrapped function
- # This makes sure the OpenAPI documentation is preserved
- # Note: we don't update querystring or request as they're processed above using our detected types
- _copy_quart_attributes(func, wrapper)
+ def decorator(handler: Callable[..., Awaitable[Any]]) -> web.RouteFunction[Any]:
+ path, validated_params, literal_params, body_param, _, query_param, optional_params = common.build_api_path(
+ handler
+ )
+ method = "POST" if (body_param is not None) else "GET"
+ body_safe_params = common.safe_params_for_type(body_param[1]) if (body_param is not None) else []
+ query_safe_params = common.safe_params_for_type(query_param[1]) if (query_param is not None) else []
+
+ async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+ await common.validate_params(kwargs, validated_params)
+ kwargs.update(literal_params)
+
+ if body_param is not None:
+ await common.parse_body(body_param, body_safe_params, kwargs)
+ if query_param is not None:
+ await common.parse_query(query_param, query_safe_params, kwargs)
+
+ if auth_scheme in auth.BODY_SCHEMES:
+ body = kwargs.get(body_param[0]) if (body_param is not None) else None
+ await auth.authenticate_body(auth_scheme, body)
+
+ start_time_ns = time.perf_counter_ns()
+ result = await handler(**kwargs)
+ total_ms = (time.perf_counter_ns() - start_time_ns) // 1_000_000
+ log.performance(f"API {method} {path} {handler.__name__} = 0 0 {total_ms}")
+ return result
+
+ endpoint = common.setup_wrapper(wrapper, handler, _BLUEPRINT_NAME)
+ # Link the wrapper to the original handler so inspect.unwrap (and tests)
+ # can recover it; the decorators below chain __wrapped__ too. Empty
+ # assigned/updated means we only set __wrapped__, nothing else.
+ functools.update_wrapper(wrapper, handler, assigned=(), updated=())
+
+ view = _decorate_view(
+ wrapper,
+ auth_scheme=auth_scheme,
+ response=response,
+ query_param=query_param,
+ body_param=body_param,
+ rate_limit=rate_limit,
+ )
- # If there are optional params, we need two routes, one with the optional params omitted
- # and one with them all present.
- # AM 26/03/03: This actually only handles the case where there's some required and a single optional, but
- # that's the only case that existed in the original code. Theoretically we could count the optional params and
- # generate the correct number of routes, but that's lot of effort for little gain right now
- _add_url_rules(wrapper, path, endpoint, method, optional_params)
+ _add_url_rules(view, path, endpoint, method, optional_params)
+ common.register_route(handler, "api", _routes)
+ _route_auth_schemes[handler.__name__] = auth_scheme.value
+ return view
- common.register_route(original, "api", _routes)
- return wrapper
+ return decorator
def _add_url_rules(
@@ -148,13 +147,6 @@ async def _api_rate_limit() -> None:
pass
-def _copy_quart_attributes(src: Callable[..., Any], dst: Callable[..., Any]) -> None:
- """Copy quart schema attributes from src to dst to preserve OpenAPI documentation."""
- for attr in common.QUART_ATTRIBUTES:
- if hasattr(src, attr):
- setattr(dst, attr, getattr(src, attr))
-
-
def _exempt_blueprint(app: base.QuartApp) -> None:
csrf = app.extensions.get("csrf")
if csrf is not None:
@@ -221,22 +213,42 @@ def _json_error(
return quart.jsonify(payload), status_code
-def _require_auth_level(func: Callable[..., Any], original: Callable[..., Any]) -> auth.AuthLevel:
- """Detect the auth level marker on a route, or raise TypeError if absent."""
- level = getattr(func, auth.AUTH_LEVEL_ATTR, None) or getattr(original, auth.AUTH_LEVEL_ATTR, None)
- if level is None:
- raise TypeError(
- f"API route {original.__name__!r} in {original.__module__} is missing "
- "an auth decorator. Apply exactly one of @api.auth.public, "
- "@api.auth.bearer, @api.auth.system_bearer, @api.auth.body_oidc, or @api.auth.pat. "
- "See atr/blueprints/api_auth.py for details."
- )
- if level not in auth.VALID_LEVELS:
- raise TypeError(
- f"API route {original.__name__!r}: unknown auth level {level!r}. Valid levels: {sorted(auth.VALID_LEVELS)}"
- )
- _route_auth_levels[original.__name__] = level
- return level
+def _decorate_view(
+ view: Callable[..., Any],
+ *,
+ auth_scheme: auth.Auth,
+ response: tuple[type[pydantic.BaseModel], int] | None,
+ query_param: tuple[str, type] | None,
+ body_param: tuple[str, type[pydantic.BaseModel]] | None,
+ rate_limit: tuple[int, datetime.timedelta] | None,
+) -> Callable[..., Any]:
+ # Layer the OpenAPI/validation/auth/rate-limit decorators onto the view in the
+ # order they must run: security annotation, response and request validation,
+ # then header auth (outside body validation), then rate limiting (outermost).
+ view = auth.security_scheme_for(auth_scheme)(view)
+ if response is not None:
+ view = quart_schema.validate_response(response[0], response[1])(view)
+ if query_param is not None:
+ view = quart_schema.validate_querystring(query_param[1])(view)
+ if body_param is not None:
+ view = quart_schema.validate_request(body_param[1])(view)
+ if auth_scheme in auth.HEADER_SCHEMES:
+ view = _require_header_auth(auth_scheme, view)
+ if rate_limit is not None:
+ view = rate_limiter.rate_limit(rate_limit[0], rate_limit[1])(view)
+ return view
+
+
+def _require_header_auth(scheme: auth.Auth, inner: Callable[..., Any]) -> Callable[..., Any]:
+ # Wrap a view so header-credential auth runs before anything it wraps (body
+ # validation included). functools.wraps carries the inner OpenAPI attributes
+ # and the __wrapped__ chain up to the returned view.
+ @functools.wraps(inner)
+ async def checked(*args: Any, **kwargs: Any) -> Any:
+ await auth.authenticate_header(scheme)
+ return await inner(*args, **kwargs)
+
+ return checked
@_BLUEPRINT.record_once
diff --git a/atr/blueprints/api_auth.py b/atr/blueprints/api_auth.py
index 9745ba1e..40d1979e 100644
--- a/atr/blueprints/api_auth.py
+++ b/atr/blueprints/api_auth.py
@@ -15,13 +15,17 @@
# specific language governing permissions and limitations
# under the License.
-"""Authentication level decorators for API endpoints. See #1169."""
+"""Authentication levels and enforcers for API routes. See #1169.
+
+The Auth enum is the auth_scheme passed to @api.typed; authenticate_header and
+authenticate_body are the enforcers the route factory calls.
+"""
from __future__ import annotations
import dataclasses
-import functools
-from typing import TYPE_CHECKING, Final, Literal
+import enum
+from typing import TYPE_CHECKING, Final
import asfquart.base as base
import jwt as pyjwt
@@ -32,17 +36,26 @@ import quart_schema
import atr.jwtoken as jwtoken
if TYPE_CHECKING:
- from collections.abc import Awaitable, Callable, Coroutine
+ from collections.abc import Callable
from typing import Any
import atr.models.github as github
-AuthLevel = Literal["public", "bearer", "system_bearer", "body_oidc", "pat"]
+class Auth(enum.StrEnum):
+ PUBLIC = "public"
+ BEARER = "bearer"
+ SYSTEM_BEARER = "system_bearer"
+ BODY_OIDC = "body_oidc"
+ PAT = "pat"
-AUTH_LEVEL_ATTR: Final[str] = "_api_auth_level"
-VALID_LEVELS: Final[frozenset[AuthLevel]] = frozenset({"public", "bearer", "system_bearer", "body_oidc", "pat"})
+# Header-credential schemes authenticate from the Authorization header, so they
+# run before the body is parsed. Body-credential schemes carry the credential in
+# the request body, so they run after parsing. PUBLIC and PAT enforce nothing
+# here (PAT routes validate the PAT from the body themselves).
+HEADER_SCHEMES: Final[frozenset[Auth]] = frozenset({Auth.BEARER, Auth.SYSTEM_BEARER})
+BODY_SCHEMES: Final[frozenset[Auth]] = frozenset({Auth.BODY_OIDC})
_TP_CONTEXT_ATTR: Final[str] = "tp_context"
@@ -56,111 +69,49 @@ class TrustedPublisherContext:
publisher: str
-def bearer[**P, R](
- func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Awaitable[R]]:
- """Require an ATR-issued Bearer JWT in the Authorization header."""
- wrapped = jwtoken.require(func)
- wrapped = quart_schema.security_scheme([{"BearerAuth": []}])(wrapped)
- _mark("bearer", wrapped)
- return wrapped
-
-
-def body_oidc[**P, R](
- func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Awaitable[R]]:
- """Validate a Trusted Publisher OIDC token carried in the request body."""
-
- @functools.wraps(func)
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
- data = kwargs.get("data")
- if data is None:
- raise base.ASFQuartException(
- "Trusted Publisher auth requires a validated request body",
- errorcode=401,
- )
- publisher = getattr(data, "publisher", None)
- jwt = getattr(data, "jwt", None)
- if (not isinstance(publisher, str)) or (not isinstance(jwt, str)):
- raise base.ASFQuartException(
- "Trusted Publisher auth requires 'publisher' and 'jwt' string fields",
- errorcode=401,
- )
-
- # Lazy import to match the project convention: atr.db.interaction
- # pulls in much of the project, and other blueprint modules don't
- # import it at top level either.
- import atr.db.interaction as interaction
-
- try:
- payload, asf_uid = await interaction.validate_trusted_jwt(publisher, jwt)
- except base.ASFQuartException:
- # Already carries an HTTP status code (401 for credential
- # failures, 502 for upstream failures); let it propagate.
- raise
- except (interaction.InteractionError, pyjwt.InvalidTokenError, pydantic.ValidationError) as exc:
- # Credential-shaped failures: unsupported publisher, malformed
- # or expired JWT, payload that doesn't match the expected
- # TrustedPublisherPayload schema. All map to 401.
- raise base.ASFQuartException(f"Trusted Publisher auth failed: {exc}", errorcode=401) from exc
-
- quart.g.tp_context = TrustedPublisherContext(
- payload=payload,
- asf_uid=asf_uid,
- publisher=publisher,
+async def authenticate_body(scheme: Auth, data: Any) -> None:
+ # The validated request body carries the publisher and OIDC JWT.
+ if scheme is not Auth.BODY_OIDC:
+ return
+ if data is None:
+ raise base.ASFQuartException("Trusted Publisher auth requires a validated request body", errorcode=401)
+ publisher = getattr(data, "publisher", None)
+ jwt = getattr(data, "jwt", None)
+ if (not isinstance(publisher, str)) or (not isinstance(jwt, str)):
+ raise base.ASFQuartException(
+ "Trusted Publisher auth requires 'publisher' and 'jwt' string fields", errorcode=401
)
- return await func(*args, **kwargs)
-
- _mark("body_oidc", wrapper)
- return wrapper
-
-
-def pat[F: Callable[..., Awaitable[Any]]](func: F) -> F:
- """Marker for routes that accept a PAT in the body (jwt_create only)."""
- return _mark("pat", func)
+ # Lazy import: atr.db.interaction pulls in much of the project, so blueprint
+ # modules don't import it at the top level.
+ import atr.db.interaction as interaction
+ try:
+ payload, asf_uid = await interaction.validate_trusted_jwt(publisher, jwt)
+ except base.ASFQuartException:
+ raise
+ except (interaction.InteractionError, pyjwt.InvalidTokenError, pydantic.ValidationError) as exc:
+ raise base.ASFQuartException(f"Trusted Publisher auth failed: {exc}", errorcode=401) from exc
-def public[F: Callable[..., Awaitable[Any]]](func: F) -> F:
- """Marker for routes that require no authentication."""
- return _mark("public", func)
+ quart.g.tp_context = TrustedPublisherContext(payload=payload, asf_uid=asf_uid, publisher=publisher)
-def system_bearer[**P, R](
- func: Callable[P, Coroutine[Any, Any, R]],
-) -> Callable[P, Awaitable[R]]:
- """Require an ATR-issued Bearer JWT with system privileges (from a system PAT).
+async def authenticate_header(scheme: Auth) -> None:
+ # Header-credential auth: verify the Bearer JWT.
+ claims = await jwtoken.authenticate()
+ if (scheme is Auth.SYSTEM_BEARER) and (not claims.get("atr_sys")):
+ raise base.ASFQuartException("System privileges required", errorcode=403)
- Ordinary user JWTs are rejected.
- """
- @functools.wraps(func)
- async def checked(*args: P.args, **kwargs: P.kwargs) -> R:
- claims = getattr(quart.g, "jwt_claims", None)
- if not (isinstance(claims, dict) and claims.get("atr_sys")):
- raise base.ASFQuartException("System privileges required", errorcode=403)
- return await func(*args, **kwargs)
-
- # require() verifies the JWT and sets quart.g.jwt_claims before checked runs.
- wrapped = jwtoken.require(checked)
- wrapped = quart_schema.security_scheme([{"BearerAuth": []}])(wrapped)
- _mark("system_bearer", wrapped)
- return wrapped
+def security_scheme_for(scheme: Auth) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
+ # Bearer security scheme for header-credential schemes; no-op otherwise.
+ if scheme in HEADER_SCHEMES:
+ return quart_schema.security_scheme([{"BearerAuth": []}])
+ return lambda func: func
def trusted_publisher_context() -> TrustedPublisherContext:
- """Return the TrustedPublisherContext placed by @body_oidc on quart.g."""
+ """Return the TrustedPublisherContext that authenticate_body placed on quart.g."""
ctx = getattr(quart.g, _TP_CONTEXT_ATTR, None)
if ctx is None:
- raise RuntimeError("trusted_publisher_context() called outside a @api.auth.body_oidc handler")
+ raise RuntimeError("trusted_publisher_context() called outside a body_oidc route")
return ctx
-
-
-def _mark[F: Callable[..., Any]](level: AuthLevel, func: F) -> F:
- """Attach the auth-level sentinel to func; reject re-marking with a different level."""
- existing = getattr(func, AUTH_LEVEL_ATTR, None)
- if (existing is not None) and (existing != level):
- raise TypeError(
- f"{getattr(func, '__name__', func)!r}: cannot apply @api.auth.{level} on top of @api.auth.{existing}"
- )
- setattr(func, AUTH_LEVEL_ATTR, level)
- return func
diff --git a/atr/docs/authentication-security.md b/atr/docs/authentication-security.md
index 5a3f021d..26e2f637 100644
--- a/atr/docs/authentication-security.md
+++ b/atr/docs/authentication-security.md
@@ -146,7 +146,7 @@ Authorization: Bearer jwt_token_value
### Token handling
-The [`jwtoken`](/ref/atr/jwtoken.py) module handles JWT creation and verification. Protected API endpoints use the `@jwtoken.require` decorator, which extracts the JWT from the `Authorization` header, verifies its signature and required claims, and makes the user's ASF UID available to the handler. Verification applies a deliberate two minute leeway to the time based JWT checks to tolerate small clock skew between ATR and API clients.
+The [`jwtoken`](/ref/atr/jwtoken.py) module handles JWT creation and verification. Protected API endpoints declare a header auth scheme (`auth_scheme=api_auth.Auth.BEARER` or `SYSTEM_BEARER`); the route then runs `jwtoken.authenticate()`, which extracts the JWT from the `Authorization` header, verifies its signature and required claims, and makes the user's ASF UID available to the handler. Verification applies a deliberate two minute leeway to the time based JWT checks to tolerate small [...]
### System tokens
@@ -154,7 +154,7 @@ Most PATs belong to a committer and are tied to their LDAP account. ATR also sup
A system token is exchanged for a JWT at `/api/jwt`, requesting the `system` identity. The resulting JWT carries an additional `atr_sys` claim and `system` as its subject. Because that identity has no LDAP account, verification skips the LDAP active check for these JWTs. It still re-validates the backing PAT on every request, so revoking the PAT immediately invalidates every JWT issued from it. Two further checks apply: a system JWT must be backed by a system PAT, and its subject must be [...]
-Endpoints that should accept only system tokens are decorated with `@api.auth.system_bearer`, which rejects ordinary user JWTs. The committee membership check that gates committer-driven writes does not apply, so any committee authorisation for these endpoints must be established by the calling service before it reaches ATR. The only such endpoint at present is `/project/config`, which the `.asf.yaml` processor uses to create and update projects.
+Endpoints that should accept only system tokens declare `auth_scheme=api_auth.Auth.SYSTEM_BEARER` on their `@api.typed` route, which rejects ordinary user JWTs. The committee membership check that gates committer-driven writes does not apply, so any committee authorisation for these endpoints must be established by the calling service before it reaches ATR. The only such endpoint at present is `/project/config`, which the `.asf.yaml` processor uses to create and update projects.
## GitHub Actions OIDC (Trusted Publishing)
diff --git a/atr/docs/authorization-security.md b/atr/docs/authorization-security.md
index 129ac560..ed98942d 100644
--- a/atr/docs/authorization-security.md
+++ b/atr/docs/authorization-security.md
@@ -149,7 +149,7 @@ Token operations apply to the authenticated user:
**System tokens**:
* Allowed for: Foundation administrators only (not committer self-service)
-* Note: System tokens are PATs for a service identity rather than a person. Endpoints that accept them (via `@api.auth.system_bearer`) apply no committee membership check, so the calling service must establish any committee authorisation upstream. See [System tokens](authentication-security#system-tokens) in the authentication guide for the mechanism.
+* Note: System tokens are PATs for a service identity rather than a person. Endpoints that accept them (via `auth_scheme=api_auth.Auth.SYSTEM_BEARER`) apply no committee membership check, so the calling service must establish any committee authorisation upstream. See [System tokens](authentication-security#system-tokens) in the authentication guide for the mechanism.
## Implementation patterns
diff --git a/atr/docs/overview-of-the-code.md b/atr/docs/overview-of-the-code.md
index ea7b2d71..f99bd5a5 100644
--- a/atr/docs/overview-of-the-code.md
+++ b/atr/docs/overview-of-the-code.md
@@ -70,7 +70,7 @@ Tasks themselves are defined in the ATR [`tasks`](/ref/atr/tasks/) directory. Th
## API
-The ATR API provides programmatic access to most ATR functionality. API endpoints are defined in [`api`](/ref/atr/api/__init__.py), and their URL paths are prefixed with `/api/`. The API uses [OpenAPI](https://www.openapis.org/) for documentation, which is automatically generated from the endpoint definitions and served at `/api/docs`. Users send requests with a [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) created from a [PAT](https://en.wikipedia.org/wiki/Personal_access_token). [...]
+The ATR API provides programmatic access to most ATR functionality. API endpoints are defined in [`api`](/ref/atr/api/__init__.py), and their URL paths are prefixed with `/api/`. The API uses [OpenAPI](https://www.openapis.org/) for documentation, which is automatically generated from the endpoint definitions and served at `/api/docs`. Users send requests with a [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) created from a [PAT](https://en.wikipedia.org/wiki/Personal_access_token). [...]
API request and response models are defined in [`models.api`](/ref/atr/models/api.py) using Pydantic. Each endpoint has an associated request model that validates incoming data, and a response model that validates outgoing data. The API returns JSON in all cases, with appropriate HTTP status codes.
diff --git a/atr/jwtoken.py b/atr/jwtoken.py
index ebb56251..7482569c 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -18,12 +18,11 @@
from __future__ import annotations
import datetime as datetime
-import functools
import os
import pathlib
import secrets as secrets
import urllib.parse as parse
-from typing import TYPE_CHECKING, Any, Final
+from typing import Any, Final
import aiohttp
import asfquart
@@ -58,9 +57,6 @@ _JWT_KEY_PATH: Final[pathlib.Path] = pathlib.Path("secrets/generated/jwt_secret_
_JWT_KEY_TMP_PATH: Final[pathlib.Path] = pathlib.Path("secrets/generated/jwt_secret_key.txt.tmp")
_JWT_KEY_HEX_LENGTH: Final[int] = (256 // 8) * 2
-if TYPE_CHECKING:
- from collections.abc import Awaitable, Callable, Coroutine
-
def activate_signing_key(key: str) -> None:
app = asfquart.APP
@@ -92,30 +88,28 @@ def issue(uid: str, *, ttl: int = _ATR_JWT_TTL, pat_hash: str | None = None, sys
return jwt.encode(payload, _signing_key(), algorithm=_ALGORITHM)
-def require[**P, R](func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Awaitable[R]]:
- @functools.wraps(func)
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
- token = _extract_bearer_token(quart.request)
- try:
- claims = await verify(token)
- except jwt.ExpiredSignatureError as exc:
- log.auth_failure("jwt_token", "jwt_token_expired")
- raise base.ASFQuartException("Token has expired", errorcode=401) from exc
- except jwt.InvalidSignatureError as exc:
- log.auth_failure("jwt_token", "jwt_signature_invalid")
- raise base.ASFQuartException("Token signature verification failed", errorcode=401) from exc
- except jwt.InvalidTokenError as exc:
- log.auth_failure("jwt_token", "jwt_token_invalid")
- raise base.ASFQuartException("Invalid Bearer JWT format", errorcode=401) from exc
- except jwt.PyJWTError as exc:
- log.auth_failure("jwt_token", "jwt_token_invalid_2")
- raise base.ASFQuartException(f"Invalid Bearer JWT: {exc}", errorcode=401) from exc
-
- quart.g.jwt_claims = claims
- log.auth_success("jwt_token")
- return await func(*args, **kwargs)
-
- return wrapper
+async def authenticate() -> dict[str, Any]:
+ # Verify the request's Bearer JWT, stash the claims on quart.g, and return
+ # them. Raises ASFQuartException(401) on any token problem.
+ token = _extract_bearer_token(quart.request)
+ try:
+ claims = await verify(token)
+ except jwt.ExpiredSignatureError as exc:
+ log.auth_failure("jwt_token", "jwt_token_expired")
+ raise base.ASFQuartException("Token has expired", errorcode=401) from exc
+ except jwt.InvalidSignatureError as exc:
+ log.auth_failure("jwt_token", "jwt_signature_invalid")
+ raise base.ASFQuartException("Token signature verification failed", errorcode=401) from exc
+ except jwt.InvalidTokenError as exc:
+ log.auth_failure("jwt_token", "jwt_token_invalid")
+ raise base.ASFQuartException("Invalid Bearer JWT format", errorcode=401) from exc
+ except jwt.PyJWTError as exc:
+ log.auth_failure("jwt_token", "jwt_token_invalid_2")
+ raise base.ASFQuartException(f"Invalid Bearer JWT: {exc}", errorcode=401) from exc
+
+ quart.g.jwt_claims = claims
+ log.auth_success("jwt_token")
+ return claims
def setup_signing_key(app: base.QuartApp) -> None:
diff --git a/tests/unit/test_api_auth_decorators.py b/tests/unit/test_api_auth_decorators.py
index 8bd91aae..a416659b 100644
--- a/tests/unit/test_api_auth_decorators.py
+++ b/tests/unit/test_api_auth_decorators.py
@@ -15,114 +15,38 @@
# specific language governing permissions and limitations
# under the License.
-"""Tests that every API route declares an explicit authentication level.
+"""Tests for the API route auth enforcers and scheme distribution.
-See issue #1169. These tests are the enforcement mechanism for the
-"fail-closed" property: a new route that forgets an auth decorator
-either fails to import (the ``typed()`` enforcer) or fails this test
-(the coverage assertion).
+See issue #1169. The "fail-closed" property is now structural: @api.typed takes
+auth_scheme as a required keyword-only argument typed as the Auth enum, so a
+route can't be registered without a valid scheme - the type checker enforces it.
+The distribution test below is a heads-up against an endpoint's scheme silently
+changing.
"""
-from typing import Literal
-
import pytest
import atr.blueprints.api as api_blueprint
import atr.blueprints.api_auth as api_auth
-def test_every_api_route_has_an_auth_level() -> None:
- """Every function registered via @api.typed must carry an auth level.
+def test_expected_scheme_distribution() -> None:
+ """Sanity check: the headline counts from the #1169 audit still hold.
- This is the coverage test. If it fails, some route somewhere in
- ``atr/api/__init__.py`` is missing an ``@api.auth.<level>`` decorator.
- (The ``typed()`` decorator also catches this at import time, so a
- failure here usually means a bug in the enforcer itself or in the
- ordering of decorators.)
+ A heads-up, not a rigid drift check, that catches obvious regressions like
+ "the PAT endpoint accidentally became public".
"""
# Importing atr.api triggers registration of every route.
import atr.api # noqa: F401
- levels = api_blueprint.route_auth_levels()
- assert levels, "no API routes were registered; did atr.api fail to import?"
-
- invalid = {name: lvl for name, lvl in levels.items() if lvl not in api_auth.VALID_LEVELS}
- assert not invalid, f"routes with invalid auth level: {invalid}"
-
-
-def test_typed_rejects_route_without_auth_decorator() -> None:
- """A route missing any ``@api.auth.*`` decorator must fail at import time.
-
- This is the fail-closed guarantee. A developer adding a new endpoint
- and forgetting the auth decorator gets a TypeError from ``typed()``
- before the server can even start.
- """
-
- # Minimal stand-in for a real route. No auth decorator applied.
- async def fake_route(_fake: Literal["fake"]) -> tuple[dict, int]:
- return {}, 200
-
- with pytest.raises(TypeError, match="missing an auth decorator"):
- api_blueprint.typed(fake_route)
-
-
-def test_typed_accepts_route_with_each_auth_level() -> None:
- """Each of the four auth levels must satisfy ``typed()``'s check."""
- for level in ("public", "bearer", "body_oidc", "pat"):
-
- async def fake_route(_fake: Literal["fake"]) -> tuple[dict, int]:
- return {}, 200
-
- decorator = getattr(api_auth, level)
- marked = decorator(fake_route)
- # typed() does more than just the auth check (URL rule registration,
- # etc.), so we can't call it here without a full Quart app context.
- # But we can confirm the marker attribute made it through, which is
- # what typed() keys off.
- assert getattr(marked, api_auth.AUTH_LEVEL_ATTR, None) == level, (
- f"@api.auth.{level} did not set {api_auth.AUTH_LEVEL_ATTR!r}"
- )
-
-
-def test_auth_levels_cannot_be_stacked() -> None:
- """Applying two different auth decorators to the same route must fail."""
-
- async def fake_route(_fake: Literal["fake"]) -> tuple[dict, int]:
- return {}, 200
-
- marked = api_auth.public(fake_route)
- with pytest.raises(TypeError, match=r"cannot apply @api\.auth"):
- api_auth.bearer(marked)
+ schemes = api_blueprint.route_auth_schemes()
+ assert schemes, "no API routes were registered; did atr.api fail to import?"
+ counts = {member.value: 0 for member in api_auth.Auth}
+ for scheme in schemes.values():
+ counts[scheme] += 1
-def test_applying_same_auth_level_twice_is_idempotent() -> None:
- """Re-applying the same level must not raise; it's a harmless no-op."""
-
- async def fake_route(_fake: Literal["fake"]) -> tuple[dict, int]:
- return {}, 200
-
- first = api_auth.public(fake_route)
- second = api_auth.public(first)
- assert getattr(second, api_auth.AUTH_LEVEL_ATTR) == "public"
-
-
-def test_expected_level_distribution() -> None:
- """Sanity check: the headline counts from the #1169 audit hold.
-
- 18 bearer, 7 body_oidc, 1 pat, 20 public = 46 total.
-
- Not meant as a rigid drift check (that's the registry test below), but a
- heads-up that catches obvious regressions like "the PAT endpoint
- accidentally got marked public".
- """
- import atr.api # noqa: F401
-
- levels = api_blueprint.route_auth_levels()
- counts = {lvl: 0 for lvl in api_auth.VALID_LEVELS}
- for lvl in levels.values():
- counts[lvl] += 1
-
- assert counts["pat"] == 1, f"expected exactly one @api.auth.pat route, got {counts['pat']}"
+ assert counts["pat"] == 1, f"expected exactly one pat route, got {counts['pat']}"
assert counts["bearer"] >= 15, f"bearer count dropped unexpectedly: {counts['bearer']}"
assert counts["body_oidc"] >= 5, f"body_oidc count dropped unexpectedly: {counts['body_oidc']}"
assert sum(counts.values()) >= 40, f"total route count dropped unexpectedly: {counts}"
@@ -132,7 +56,7 @@ def test_expected_level_distribution() -> None:
async def test_public_endpoint_accepts_unauthenticated_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A @api.auth.public route must respond without any credentials.
+ """A public route must respond without any credentials.
Uses ``/api/committees/list`` as the representative public endpoint
because it takes no path parameters and no body.
@@ -152,7 +76,7 @@ async def test_public_endpoint_accepts_unauthenticated_request(
# 200 on success, 500 if DB isn't reachable in the test env - either way
# we know we passed authentication, which is what we're testing.
assert response.status_code != 401, (
- f"public endpoint returned 401, which means @api.auth.public is "
+ f"public endpoint returned 401, which means the public scheme is "
f"enforcing auth it shouldn't: body={await response.get_data()!r}"
)
@@ -161,7 +85,7 @@ async def test_public_endpoint_accepts_unauthenticated_request(
async def test_bearer_endpoint_rejects_unauthenticated_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A @api.auth.bearer route must return 401 without a Bearer token.
+ """A bearer route must return 401 without a Bearer token.
Uses ``/api/user/info`` as the representative bearer endpoint: simple
GET, no path or body params to fabricate.
@@ -183,23 +107,23 @@ async def test_bearer_endpoint_rejects_unauthenticated_request(
)
-# Note: there is no HTTP-level negative test for @api.auth.body_oidc here.
+# Note: there is no HTTP-level negative test for the body_oidc scheme here.
# Driving a body_oidc endpoint through the test client requires the full
# QuartSchema(app, security_schemes=...) initialization that server.py does
# (it populates QUART_SCHEMA_CONVERT_CASING etc.), which the asfquart test
-# fixture doesn't replicate. The decorator's auth behavior is already
+# fixture doesn't replicate. The authenticate_body behaviour is already
# covered at the unit level by:
# - test_body_oidc_populates_quart_g_with_context (happy path)
-# - test_body_oidc_missing_body_raises_bad_request
+# - test_body_oidc_missing_body_raises_unauthorized
# - test_body_oidc_rejects_body_without_jwt_fields
# A proper HTTP-level test belongs in the e2e suite where the real app
# initialization runs.
# ---------------------------------------------------------------------------
-# TrustedPublisherContext handoff from decorator to handler.
+# TrustedPublisherContext handoff to the handler.
#
-# The body_oidc decorator's contract has two halves: reject invalid tokens
+# authenticate_body's contract has two halves: reject invalid tokens
# (covered above), and make the verified payload available to the handler.
# The tests below cover the second half.
# ---------------------------------------------------------------------------
@@ -250,10 +174,10 @@ def _fake_trusted_payload(**overrides: object) -> object:
def test_trusted_publisher_context_raises_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """trusted_publisher_context() must raise if called outside a body_oidc handler.
+ """trusted_publisher_context() must raise if called outside a body_oidc route.
Silently returning ``None`` would let a handler misattribute actions
- to "no one" when the decorator hadn't run. Making misuse loud is the
+ to "no one" when authenticate_body hadn't run. Making misuse loud is the
safer default.
"""
import asfquart
@@ -264,7 +188,7 @@ def test_trusted_publisher_context_raises_when_unset(
async def _assert_raises() -> None:
async with app.test_request_context("/"):
- with pytest.raises(RuntimeError, match=r"outside a @api\.auth\.body_oidc"):
+ with pytest.raises(RuntimeError, match=r"outside a body_oidc route"):
api_auth.trusted_publisher_context()
import asyncio
@@ -276,13 +200,13 @@ def test_trusted_publisher_context_raises_when_unset(
async def test_body_oidc_populates_quart_g_with_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """On successful validation, @api.auth.body_oidc stashes the verified
- context on ``quart.g.tp_context`` and the handler reads it via
+ """On successful validation, authenticate_body stashes the verified context
+ on ``quart.g.tp_context`` and the handler reads it via
``trusted_publisher_context()``.
- We stub ``interaction.validate_trusted_jwt`` so the test doesn't hit
- the network or need a real OIDC token. That's the right seam: the
- decorator's job is the handoff, not the crypto. Crypto is covered by
+ We stub ``interaction.validate_trusted_jwt`` so the test doesn't hit the
+ network or need a real OIDC token. That's the right seam: authenticate_body's
+ job is the handoff, not the crypto. Crypto is covered by
``jwtoken.verify_github_oidc``'s own tests.
"""
import asfquart
@@ -305,43 +229,29 @@ async def test_body_oidc_populates_quart_g_with_context(
publisher: str
jwt: str
- captured: dict[str, object] = {}
-
- @api_auth.body_oidc
- async def handler(data: FakeBody) -> tuple[dict, int]:
- ctx = api_auth.trusted_publisher_context()
- captured["payload"] = ctx.payload
- captured["asf_uid"] = ctx.asf_uid
- captured["publisher"] = ctx.publisher
- return {}, 200
-
app = asfquart.construct("test")
async with app.test_request_context("/"):
- await handler(data=FakeBody(publisher="github", jwt="fake.jwt.token"))
-
- assert captured["payload"] is fake_payload
- assert captured["asf_uid"] == "alice"
- assert captured["publisher"] == "github"
+ await api_auth.authenticate_body(api_auth.Auth.BODY_OIDC, FakeBody(publisher="github", jwt="fake.jwt.token"))
+ ctx = api_auth.trusted_publisher_context()
+ assert ctx.payload is fake_payload
+ assert ctx.asf_uid == "alice"
+ assert ctx.publisher == "github"
@pytest.mark.asyncio
async def test_body_oidc_missing_body_raises_unauthorized(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """A body_oidc handler without a ``data`` kwarg must raise 401."""
+ """A body_oidc route with no request body must raise 401."""
import asfquart
import asfquart.base as base
monkeypatch.setattr("asfquart.APP", None)
- @api_auth.body_oidc
- async def handler() -> tuple[dict, int]:
- return {}, 200
-
app = asfquart.construct("test")
async with app.test_request_context("/"):
with pytest.raises(base.ASFQuartException) as excinfo:
- await handler()
+ await api_auth.authenticate_body(api_auth.Auth.BODY_OIDC, None)
assert excinfo.value.errorcode == 401
@@ -359,14 +269,10 @@ async def test_body_oidc_rejects_body_without_jwt_fields(
class BodyWithoutJwt(pydantic.BaseModel):
name: str
- @api_auth.body_oidc
- async def handler(data: BodyWithoutJwt) -> tuple[dict, int]:
- return {}, 200
-
app = asfquart.construct("test")
async with app.test_request_context("/"):
with pytest.raises(base.ASFQuartException) as excinfo:
- await handler(data=BodyWithoutJwt(name="hi"))
+ await api_auth.authenticate_body(api_auth.Auth.BODY_OIDC, BodyWithoutJwt(name="hi"))
assert excinfo.value.errorcode == 401
@@ -375,7 +281,7 @@ async def test_body_oidc_rejects_body_without_jwt_fields(
#
# These helpers sit on the security-relevant path for every body_oidc
# endpoint: they take an already-verified OIDC payload and do the
-# project/release/phase lookup. The decorator-level tests above cover
+# project/release/phase lookup. The authenticate_body tests above cover
# the auth edge; these cover the post-auth lookup edge.
# ---------------------------------------------------------------------------
diff --git a/tests/unit/test_api_route_factory.py b/tests/unit/test_api_route_factory.py
new file mode 100644
index 00000000..35e1ea66
--- /dev/null
+++ b/tests/unit/test_api_route_factory.py
@@ -0,0 +1,136 @@
+# 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.
+
+"""Tests for the api.route factory's auth enforcers.
+
+These cover the level-dispatched auth callables the factory composes:
+authenticate_header (header-credential, pre-body), authenticate_body
+(body-credential, post-body), and the OpenAPI security annotation. The
+auth-before-body-parse ordering at the HTTP level is exercised against a
+migrated route in the e2e/Phase 2 suite, where the full app initialises.
+"""
+
+import asfquart.base as base
+import pytest
+import quart_schema
+
+import atr.blueprints.api_auth as api_auth
+import atr.jwtoken as jwtoken
+
+
+def test_auth_enum_matches_the_declared_schemes() -> None:
+ assert {member.value for member in api_auth.Auth} == {"public", "bearer", "system_bearer", "body_oidc", "pat"}
+ assert api_auth.HEADER_SCHEMES == {api_auth.Auth.BEARER, api_auth.Auth.SYSTEM_BEARER}
+ assert api_auth.BODY_SCHEMES == {api_auth.Auth.BODY_OIDC}
+
+
+@pytest.mark.asyncio
+async def test_authenticate_header_requires_system_claim_for_system_bearer(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def fake_authenticate() -> dict[str, object]:
+ return {"sub": "alice"}
+
+ monkeypatch.setattr(jwtoken, "authenticate", fake_authenticate)
+
+ with pytest.raises(base.ASFQuartException) as excinfo:
+ await api_auth.authenticate_header(api_auth.Auth.SYSTEM_BEARER)
+ assert excinfo.value.errorcode == 403
+
+
+@pytest.mark.asyncio
+async def test_authenticate_header_accepts_a_valid_token(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def fake_authenticate() -> dict[str, object]:
+ return {"sub": "alice", "atr_sys": True}
+
+ monkeypatch.setattr(jwtoken, "authenticate", fake_authenticate)
+
+ # Neither call should raise.
+ await api_auth.authenticate_header(api_auth.Auth.BEARER)
+ await api_auth.authenticate_header(api_auth.Auth.SYSTEM_BEARER)
+
+
+@pytest.mark.asyncio
+async def test_authenticate_header_propagates_token_failures(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def fake_authenticate() -> dict[str, object]:
+ raise base.ASFQuartException("Invalid Bearer JWT format", errorcode=401)
+
+ monkeypatch.setattr(jwtoken, "authenticate", fake_authenticate)
+
+ with pytest.raises(base.ASFQuartException) as excinfo:
+ await api_auth.authenticate_header(api_auth.Auth.BEARER)
+ assert excinfo.value.errorcode == 401
+
+
+@pytest.mark.asyncio
+async def test_authenticate_body_is_a_noop_for_non_body_levels() -> None:
+ # PAT routes validate the credential themselves; nothing to enforce here.
+ await api_auth.authenticate_body(api_auth.Auth.PAT, None)
+ await api_auth.authenticate_body(api_auth.Auth.PUBLIC, None)
+
+
+@pytest.mark.asyncio
+async def test_header_auth_runs_before_body_validation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A header-credential route must reject an unauthenticated request before
+ its body is validated.
+
+ Uses POST /api/project/config (system_bearer, with a request body). A
+ malformed body sent without a Bearer token must return 401 (auth), not a
+ 400/500 from body validation - proving header auth wraps outside
+ validation.
+ """
+ import asfquart
+
+ import atr.blueprints as blueprints
+
+ monkeypatch.setattr("atr.blueprints._export_routes", lambda _: None)
+ monkeypatch.setattr("asfquart.APP", None)
+
+ app = asfquart.construct("test")
+ blueprints.register(app)
+
+ client = app.test_client()
+ response = await client.post("/api/project/config", json={"not": "a valid body"})
+ assert response.status_code == 401, (
+ f"expected 401 (auth before body validation), got {response.status_code}: {await response.get_data()!r}"
+ )
+
+
+def test_security_scheme_is_applied_only_for_header_levels() -> None:
+ attribute = quart_schema.openapi.QUART_SCHEMA_SECURITY_ATTRIBUTE
+
+ for level in (api_auth.Auth.BEARER, api_auth.Auth.SYSTEM_BEARER):
+
+ async def handler() -> tuple[dict, int]:
+ return {}, 200
+
+ decorated = api_auth.security_scheme_for(level)(handler)
+ assert hasattr(decorated, attribute), f"{level} should carry the bearer security scheme"
+
+ for level in (api_auth.Auth.PUBLIC, api_auth.Auth.BODY_OIDC, api_auth.Auth.PAT):
+
+ async def handler() -> tuple[dict, int]:
+ return {}, 200
+
+ decorated = api_auth.security_scheme_for(level)(handler)
+ assert decorated is handler, f"{level} should not annotate a security scheme"
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index f095f455..8ba046b7 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -17,6 +17,7 @@
import contextlib
import datetime
+import inspect
import unittest.mock as mock
from types import SimpleNamespace
@@ -1282,16 +1283,7 @@ async def test_vote_tabulate_api_passes_trusted_receipt_exclusions(monkeypatch:
def _api_vote_tabulate_handler():
- wrapper = atr.api.vote_tabulate.__wrapped__
- if wrapper.__closure__ is None:
- raise AssertionError("Expected api.vote_tabulate wrapper closure")
- for name, cell in zip(wrapper.__code__.co_freevars, wrapper.__closure__):
- if name == "func":
- handler = cell.cell_contents
- while hasattr(handler, "__wrapped__"):
- handler = handler.__wrapped__
- return handler
- raise AssertionError("Could not find wrapped API handler")
+ return inspect.unwrap(atr.api.vote_tabulate)
def _candidate_release(podling_thread_id: str | None = None) -> SimpleNamespace:
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 03/03: Constrain recipient policy update from API to accepted domain/subdomains
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 0d862bffac24fc95f9d7a075504265d5b1524bfb
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Jun 8 17:10:56 2026 +0100
Constrain recipient policy update from API to accepted domain/subdomains
---
atr/models/api.py | 18 ++++++++++
atr/models/validation.py | 18 ++++++++++
tests/unit/test_project_config_args.py | 60 ++++++++++++++++++++++++++++++++++
3 files changed, 96 insertions(+)
diff --git a/atr/models/api.py b/atr/models/api.py
index f48e18ed..4a8dac02 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -351,6 +351,9 @@ class RecipientDefaults(schema.Strict):
cc: list[pydantic.EmailStr] = schema.factory(list)
bcc: list[pydantic.EmailStr] = schema.factory(list)
+ def addresses(self) -> list[str]:
+ return [str(address) for address in (self.to, *self.cc, *self.bcc) if address]
+
class PolicyGetResults(schema.Strict):
# quart_schema.validate_response runs the response dict (produced by
@@ -532,6 +535,21 @@ class ProjectConfigArgs(schema.Strict):
project: ProjectConfigProjectArgs | None = None
policy: ProjectConfigPolicyArgs | None = None
+ @pydantic.model_validator(mode="after")
+ def _validate_recipient_domains(self) -> Self:
+ # This is the .asf.yaml route, where recipients arrive verbatim, so we
+ # keep vote mail on the committee's own lists and announce mail on
+ # apache.org. The UI route has its own constraints and doesn't come
+ # through here.
+ if self.policy is None:
+ return self
+ committee_key = str(self.committee_key)
+ if self.policy.vote_recipients is not None:
+ validation.validate_vote_recipients(committee_key, self.policy.vote_recipients.addresses())
+ if self.policy.announce_recipients is not None:
+ validation.validate_announce_recipients(self.policy.announce_recipients.addresses())
+ return self
+
class ProjectConfigResults(schema.Strict):
endpoint: Literal["/project/config"] = schema.alias("endpoint")
diff --git a/atr/models/validation.py b/atr/models/validation.py
index fb68b565..6d59059f 100644
--- a/atr/models/validation.py
+++ b/atr/models/validation.py
@@ -86,6 +86,13 @@ def pagination_args_validate(query_args: Any) -> None:
raise ValueError("Minimum offset less than 0 is nonsense")
+def validate_announce_recipients(recipients: list[str]) -> None:
+ for recipient in recipients:
+ domain = _email_domain(recipient)
+ if (domain != "apache.org") and (not domain.endswith(".apache.org")):
+ raise ValueError(f"Announce recipient '{recipient}' must be an apache.org address.")
+
+
def validate_github_repository_name(github_repository_name: str | None) -> None:
if github_repository_name and ("/" in github_repository_name):
raise ValueError("GitHub repository name must not contain a slash.")
@@ -110,3 +117,14 @@ def validate_trusted_publishing_workflow_paths(paths: list[str]) -> None:
for path in paths:
if not path.startswith(".github/workflows/"):
raise ValueError("GitHub workflow paths must start with '.github/workflows/'.")
+
+
+def validate_vote_recipients(committee_key: str, recipients: list[str]) -> None:
+ expected = f"{committee_key}.apache.org"
+ for recipient in recipients:
+ if _email_domain(recipient) != expected:
+ raise ValueError(f"Vote recipient '{recipient}' must be on '{expected}'.")
+
+
+def _email_domain(address: str) -> str:
+ return address.rpartition("@")[2].lower()
diff --git a/tests/unit/test_project_config_args.py b/tests/unit/test_project_config_args.py
new file mode 100644
index 00000000..aca9e41c
--- /dev/null
+++ b/tests/unit/test_project_config_args.py
@@ -0,0 +1,60 @@
+# 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 re
+
+import pydantic
+import pytest
+
+import atr.models.api as api
+
+
+def _project_config(**policy: object) -> api.ProjectConfigArgs:
+ return api.ProjectConfigArgs.model_validate(
+ {"project_key": "tooling", "committee_key": "tooling", "policy": policy}
+ )
+
+
+def test_project_config_accepts_vote_recipients_on_committee_domain() -> None:
+ args = _project_config(vote_recipients={"to": "private@tooling.apache.org", "cc": ["dev@tooling.apache.org"]})
+
+ assert args.policy is not None
+ assert args.policy.vote_recipients is not None
+ assert args.policy.vote_recipients.to == "private@tooling.apache.org"
+
+
+def test_project_config_rejects_vote_recipient_on_another_committee() -> None:
+ with pytest.raises(pydantic.ValidationError, match=re.escape("must be on 'tooling.apache.org'")):
+ _project_config(vote_recipients={"to": "dev@other.apache.org"})
+
+
+def test_project_config_rejects_vote_recipient_off_foundation() -> None:
+ with pytest.raises(pydantic.ValidationError, match=re.escape("must be on 'tooling.apache.org'")):
+ _project_config(vote_recipients={"to": "private@tooling.apache.org", "cc": ["someone@example.org"]})
+
+
+def test_project_config_accepts_announce_recipients_on_any_apache_address() -> None:
+ args = _project_config(announce_recipients={"to": "announce@apache.org", "cc": ["dev@other.apache.org"]})
+
+ assert args.policy is not None
+ assert args.policy.announce_recipients is not None
+ assert args.policy.announce_recipients.to == "announce@apache.org"
+
+
+def test_project_config_rejects_announce_recipient_off_foundation() -> None:
+ with pytest.raises(pydantic.ValidationError, match=re.escape("must be an apache.org address")):
+ _project_config(announce_recipients={"to": "someone@example.org"})
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/03: #1305 enable arbitrary emails from .asf.yaml
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 90d89183c4fb6dce62f532151d6d84ce618c3279
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Sun Jun 7 14:47:53 2026 +0100
#1305 enable arbitrary emails from .asf.yaml
---
atr/api/__init__.py | 1 +
atr/get/announce.py | 4 +++-
atr/get/projects.py | 6 ++++++
atr/get/resolve.py | 4 +++-
atr/get/voting.py | 1 +
atr/post/announce.py | 9 ++++++---
atr/post/voting.py | 1 +
atr/storage/writers/announce.py | 16 ++++++++++------
atr/storage/writers/vote.py | 5 ++++-
atr/tasks/vote.py | 7 ++++---
atr/util.py | 26 ++++++++++++++++++++++----
tests/unit/test_vote_end_notify.py | 2 +-
tests/unit/test_vote_recipients.py | 3 ++-
tests/unit/test_vote_resolution.py | 24 +++++++++++++++++++++---
14 files changed, 85 insertions(+), 24 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index d2f1fafb..b414244a 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1849,6 +1849,7 @@ async def vote_start(
asf_uid,
committee_key,
is_podling=is_podling,
+ project=release.project,
)
if data.email_to not in permitted_recipients:
raise exceptions.Forbidden("Invalid mailing list choice")
diff --git a/atr/get/announce.py b/atr/get/announce.py
index f7be6555..f8959a03 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -98,7 +98,9 @@ async def selected(
revision_number=release.unwrap_revision_number,
)
- permitted_recipients = util.permitted_announce_recipients(session.uid, committee_key=util.unwrap(committee.key))
+ permitted_recipients = util.permitted_announce_recipients(
+ session.uid, committee_key=util.unwrap(committee.key), project=release.project
+ )
content = await _render_page(
release=release,
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 80eae5e1..b067c1fa 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -582,6 +582,12 @@ def _recipient_grid_widget(project: sql.Project, action: sql.RecipientAction) ->
is_podling = bool(committee is not None and committee.is_podling)
options = util.configurable_recipients(action, committee_key, is_podling=is_podling)
stored_to, stored_cc, stored_bcc = project.policy_recipients(action)
+ # Include any already-stored recipients (eg set via .asf.yaml) so a saved
+ # value remains a selectable option even when it's outside the committee
+ # defaults.
+ for address in (stored_to, *stored_cc, *stored_bcc):
+ if address and (address not in options):
+ options.append(address)
default_to = stored_to if (stored_to in options) else (options[0] if options else None)
return htm.div("#email_to")[
render.html_recipients_to_radios(options, default_to=default_to),
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index a7e72619..9b99de96 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -296,7 +296,9 @@ async def selected( # noqa: C901
# to add further cc/bcc with inherited recipients locked.
defaults["result_email_to"] = vote_recipient or "the original vote thread recipients"
permitted_recipients = (
- util.permitted_voting_recipients(session.uid, release.committee.key) if (release.committee is not None) else []
+ util.permitted_voting_recipients(session.uid, release.committee.key, project=release.project)
+ if (release.committee is not None)
+ else []
)
original_cc = list(latest_vote_task.task_args.get("email_cc", [])) if (latest_vote_task is not None) else []
original_bcc = list(latest_vote_task.task_args.get("email_bcc", [])) if (latest_vote_task is not None) else []
diff --git a/atr/get/voting.py b/atr/get/voting.py
index cb152d3b..d930168c 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -79,6 +79,7 @@ async def selected(
session.uid,
committee.key,
is_podling=committee.is_podling,
+ project=release.project,
)
second_round_recipients = (
util.permitted_podling_second_round_recipients(session.uid) if committee.is_podling else []
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 555cf19b..faa1a9aa 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -86,7 +86,7 @@ async def selected(
if (committee := release.project.committee) is None:
raise ValueError("Release has no committee")
- if response := await _validate_recipients(session, announce_form, util.unwrap(committee.key)):
+ if response := await _validate_recipients(session, announce_form, util.unwrap(committee.key), release.project):
return response
if announce_form.revision_number != preview_revision_number:
@@ -168,9 +168,12 @@ async def _validate_distributions(
async def _validate_recipients(
- session: web.Committer, announce_form: shared.announce.AnnounceForm, committee_key: str
+ session: web.Committer,
+ announce_form: shared.announce.AnnounceForm,
+ committee_key: str,
+ project: sql.Project,
) -> web.WerkzeugResponse | None:
- permitted = util.permitted_announce_recipients(session.uid, committee_key=committee_key)
+ permitted = util.permitted_announce_recipients(session.uid, committee_key=committee_key, project=project)
addresses = [announce_form.email_to, *announce_form.email_cc, *announce_form.email_bcc]
for addr in addresses:
if addr not in permitted:
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 3c62806c..6cb664c6 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -151,6 +151,7 @@ async def selected( # noqa: C901
session.uid,
committee.key,
is_podling=committee.is_podling,
+ project=release.project,
)
all_addrs = [start_voting_form.email_to, *start_voting_form.email_cc, *start_voting_form.email_bcc]
for addr in all_addrs:
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 045a3372..a7bab189 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -123,12 +123,6 @@ class CommitteeMember(CommitteeParticipant):
email_cc: list[str] | None = None,
email_bcc: list[str] | None = None,
) -> None:
- permitted = util.permitted_announce_recipients(self.__asf_uid, committee_key=self.__committee_key)
- all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
- for addr in all_addrs:
- if addr not in permitted:
- raise storage.AccessError(f"You are not permitted to send announcements to {addr}", status=403)
-
unfinished_dir: str = ""
finished_dir: str = ""
@@ -148,6 +142,16 @@ class CommitteeMember(CommitteeParticipant):
)
)
storage.ensure_project_active(release.project)
+
+ # Loaded the release first so a project's stored announce recipients can
+ # be folded into the permitted set before we validate the addresses.
+ permitted = util.permitted_announce_recipients(
+ self.__asf_uid, committee_key=self.__committee_key, project=release.project
+ )
+ all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
+ for addr in all_addrs:
+ if addr not in permitted:
+ raise storage.AccessError(f"You are not permitted to send announcements to {addr}", status=403)
if (committee := release.project.committee) is None:
raise storage.AccessError("Release has no committee - Invalid state", status=500)
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 7d2dd0ba..3e0c173a 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -392,6 +392,7 @@ class CommitteeParticipant(FoundationCommitter):
self.__asf_uid,
committee.key,
is_podling=committee.is_podling,
+ project=release.project,
)
all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
for addr in all_addrs:
@@ -514,7 +515,9 @@ class ReleaseManager(CommitteeParticipant):
storage.ensure_project_active(release.project)
additions = (additional_cc or []) + (additional_bcc or [])
if (release.committee is not None) and additions:
- permitted = set(util.permitted_voting_recipients(self.__asf_uid, release.committee.key))
+ permitted = set(
+ util.permitted_voting_recipients(self.__asf_uid, release.committee.key, project=release.project)
+ )
for address in additions:
if address not in permitted:
raise storage.AccessError(f"Invalid recipient selection: {address}", status=400)
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 80bed965..0b9ea608 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -289,9 +289,9 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
raise VoteInitiationError(f"Invalid destination email address: {addr}")
async with db.session() as data:
- release = await data.release(key=task_args.release_key, _project=True, _committee=True).demand(
- VoteInitiationError(f"Release {task_args.release_key!s} not found")
- )
+ release = await data.release(
+ key=task_args.release_key, _project=True, _committee=True, _project_release_policy=True
+ ).demand(VoteInitiationError(f"Release {task_args.release_key!s} not found"))
latest_revision_number = release.latest_revision_number
if latest_revision_number is None:
raise VoteInitiationError(f"No revisions found for release {task_args.release_key!s}")
@@ -345,6 +345,7 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
task_args.initiator_id,
release.committee.key,
is_podling=release.committee.is_podling,
+ project=release.project,
)
for addr in all_addrs:
if addr not in permitted_recipients:
diff --git a/atr/util.py b/atr/util.py
index f8f079cc..a49a9217 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -940,7 +940,9 @@ def paths_to_inodes(directory: os.PathLike) -> dict[str, int]:
return result
-def permitted_announce_recipients(asf_uid: str, committee_key: str | None = None) -> list[str]:
+def permitted_announce_recipients(
+ asf_uid: str, committee_key: str | None = None, *, project: sql.Project | None = None
+) -> list[str]:
if config.get().ATR_STATUS == "ALPHA":
return [USER_TESTS_ADDRESS, f"{asf_uid}@apache.org"]
recipients = ["announce@apache.org"]
@@ -953,6 +955,7 @@ def permitted_announce_recipients(asf_uid: str, committee_key: str | None = None
f"private@{committee_key}.apache.org",
]
)
+ _add_policy_recipients(recipients, project, sql.RecipientAction.ANNOUNCE)
return recipients
@@ -965,8 +968,10 @@ def permitted_archive_roots(basename_from_filename: str) -> list[str]:
return [basename_from_filename]
-def permitted_podling_first_round_recipients(asf_uid: str, committee_key: str, *, is_podling: bool) -> list[str]:
- recipients = permitted_voting_recipients(asf_uid, committee_key)
+def permitted_podling_first_round_recipients(
+ asf_uid: str, committee_key: str, *, is_podling: bool, project: sql.Project | None = None
+) -> list[str]:
+ recipients = permitted_voting_recipients(asf_uid, committee_key, project=project)
if not is_podling:
return recipients
return [recipient for recipient in recipients if (recipient != f"private@{committee_key}.apache.org")]
@@ -980,7 +985,7 @@ def permitted_podling_second_round_recipients(asf_uid: str) -> list[str]:
return recipients
-def permitted_voting_recipients(asf_uid: str, committee_key: str) -> list[str]:
+def permitted_voting_recipients(asf_uid: str, committee_key: str, *, project: sql.Project | None = None) -> list[str]:
recipients = [
f"dev@{committee_key}.apache.org",
f"private@{committee_key}.apache.org",
@@ -988,6 +993,7 @@ def permitted_voting_recipients(asf_uid: str, committee_key: str) -> list[str]:
if config.get().ATR_STATUS == "ALPHA":
recipients.append(USER_TESTS_ADDRESS)
recipients.append(f"{asf_uid}@apache.org")
+ _add_policy_recipients(recipients, project, sql.RecipientAction.VOTE)
return recipients
@@ -1491,6 +1497,18 @@ async def write_session(user_session: sql.UserSession) -> None:
quart.g._user_session = user_session
+def _add_policy_recipients(recipients: list[str], project: sql.Project | None, action: sql.RecipientAction) -> None:
+ # Append any recipients a project has configured in policy (eg via
+ # .asf.yaml) that aren't already permitted, so a saved list is offered as a
+ # choice and accepted when the email is sent.
+ if project is None:
+ return
+ to, cc, bcc = project.policy_recipients(action)
+ for address in (to, *cc, *bcc):
+ if address and (address not in recipients):
+ recipients.append(address)
+
+
def _asf_uid_from_email(email: str) -> str | None:
ldap_params = ldap.SearchParameters(email_query=email)
ldap.search(ldap_params)
diff --git a/tests/unit/test_vote_end_notify.py b/tests/unit/test_vote_end_notify.py
index b055cf23..fca5d508 100644
--- a/tests/unit/test_vote_end_notify.py
+++ b/tests/unit/test_vote_end_notify.py
@@ -594,7 +594,7 @@ def _patch_initiate_dependencies(monkeypatch, release: SimpleNamespace) -> None:
monkeypatch.setattr(
task_vote.util,
"permitted_podling_first_round_recipients",
- lambda uid, committee_key, *, is_podling: ["dev@project.apache.org"],
+ lambda uid, committee_key, *, is_podling, project=None: ["dev@project.apache.org"],
)
mock_send = mock.AsyncMock(return_value=("vote-mid@apache.org", []))
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index 5b4a76bc..57b4fd39 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -260,7 +260,7 @@ async def test_worker_uses_first_round_validation_for_non_podling(
monkeypatch.setattr("atr.config.get", lambda: SimpleNamespace(ATR_STATUS="ALPHA"))
monkeypatch.setattr(
"atr.util.permitted_voting_recipients",
- lambda uid, committee_key: ["dev@project.apache.org"],
+ lambda uid, committee_key, *, project=None: ["dev@project.apache.org"],
)
task_args = _make_initiate_args(email_to="dev@project.apache.org")
@@ -319,6 +319,7 @@ def _make_release(
is_podling=is_podling,
key="testproject",
),
+ project=SimpleNamespace(policy_recipients=lambda action: ("", [], [])),
podling_thread_id=podling_thread_id,
)
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 9aba9982..f095f455 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -67,7 +67,12 @@ def test_automatic_vote_resolve_section_links_to_standard_resolve(monkeypatch: p
vote_mode=sql.VoteMode.EMAIL,
effective_vote_mode=sql.VoteMode.EMAIL,
release_policy=SimpleNamespace(vote_mode=sql.VoteMode.EMAIL),
- project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE, is_active=True),
+ project=SimpleNamespace(
+ key="project",
+ status=sql.ProjectStatus.ACTIVE,
+ is_active=True,
+ policy_recipients=lambda action: ("", [], []),
+ ),
version="1.0.0",
)
@@ -365,7 +370,12 @@ async def test_manual_resolve_page_explains_cancellation_notice_url(
monkeypatch.setattr(sessions, "form_error_pop", _no_form_errors)
release = SimpleNamespace(
- project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE, is_active=True),
+ project=SimpleNamespace(
+ key="project",
+ status=sql.ProjectStatus.ACTIVE,
+ is_active=True,
+ policy_recipients=lambda action: ("", [], []),
+ ),
version="1.0.0",
short_display_name="Project 1.0.0",
)
@@ -421,7 +431,12 @@ def test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch: pytest
vote_mode=sql.VoteMode.MANUAL,
effective_vote_mode=sql.VoteMode.MANUAL,
release_policy=SimpleNamespace(vote_mode=sql.VoteMode.MANUAL),
- project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE, is_active=True),
+ project=SimpleNamespace(
+ key="project",
+ status=sql.ProjectStatus.ACTIVE,
+ is_active=True,
+ policy_recipients=lambda action: ("", [], []),
+ ),
version="1.0.0",
)
@@ -803,6 +818,7 @@ async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
status=sql.ProjectStatus.ACTIVE,
is_active=True,
display_name="Project",
+ policy_recipients=lambda action: ("", [], []),
),
version="1.0.0",
)
@@ -1302,6 +1318,7 @@ def _candidate_release(podling_thread_id: str | None = None) -> SimpleNamespace:
short_display_name="Project",
release_policy=None,
policy_finish_vote_template="{{ATR_TALLY}}",
+ policy_recipients=lambda action: ("", [], []),
committee=SimpleNamespace(
key="project",
display_name="Project",
@@ -1390,6 +1407,7 @@ def _manual_candidate_release(podling_thread_id: str | None = None) -> SimpleNam
is_active=True,
display_name="Project",
release_policy=None,
+ policy_recipients=lambda action: ("", [], []),
committee=SimpleNamespace(
key="project",
is_podling=False,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org