This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new d3d4529 Add API endpoints for listing OpenPGP keys by committee and
by user
d3d4529 is described below
commit d3d45294bba3407ef297478409d76704a7c1073e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jul 16 16:25:14 2025 +0100
Add API endpoints for listing OpenPGP keys by committee and by user
---
atr/blueprints/api/api.py | 110 ++++++++++++++++++++++++++++++++++++----------
atr/models/api.py | 35 ++++++++++++---
atr/models/sql.py | 10 +++++
3 files changed, 125 insertions(+), 30 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index c0c310c..6e6aa2a 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -88,7 +88,7 @@ async def announce_post(data: models.api.AnnounceArgs) ->
DictResponse:
@api.BLUEPRINT.route("/checks/list/<project>/<version>")
@quart_schema.validate_response(models.api.ChecksListResults, 200)
-async def checks_list_project_version(project: str, version: str) ->
DictResponse:
+async def checks_list(project: str, version: str) -> DictResponse:
"""List all check results for a given release."""
_simple_check(project, version)
# TODO: Merge with checks_list_project_version_revision
@@ -103,7 +103,7 @@ async def checks_list_project_version(project: str,
version: str) -> DictRespons
@api.BLUEPRINT.route("/checks/list/<project>/<version>/<revision>")
@quart_schema.validate_response(models.api.ChecksListResults, 200)
-async def checks_list_project_version_revision(project: str, version: str,
revision: str) -> DictResponse:
+async def checks_list_revision(project: str, version: str, revision: str) ->
DictResponse:
"""List all check results for a specific revision of a release."""
_simple_check(project, version, revision)
async with db.session() as data:
@@ -130,7 +130,7 @@ async def checks_list_project_version_revision(project:
str, version: str, revis
@api.BLUEPRINT.route("/checks/ongoing/<project>/<version>")
@api.BLUEPRINT.route("/checks/ongoing/<project>/<version>/<revision>")
@quart_schema.validate_response(models.api.ChecksOngoingResults, 200)
-async def checks_ongoing_project_version(
+async def checks_ongoing(
project: str,
version: str,
revision: str | None = None,
@@ -160,7 +160,7 @@ async def checks_ongoing_project_version(
# TODO: Rename all paths to avoid clashes
@api.BLUEPRINT.route("/committees/<name>")
@quart_schema.validate_response(models.api.CommitteesResults, 200)
-async def committees_name(name: str) -> DictResponse:
+async def committees(name: str) -> DictResponse:
"""Get a specific committee by name."""
_simple_check(name)
async with db.session() as data:
@@ -243,7 +243,7 @@ async def draft_delete(data: models.api.DraftDeleteArgs) ->
DictResponse:
@api.BLUEPRINT.route("/jwt", methods=["POST"])
@quart_schema.validate_request(models.api.JwtArgs)
-async def jwt_post(data: models.api.JwtArgs) -> DictResponse:
+async def jwt(data: models.api.JwtArgs) -> DictResponse:
"""Generate a JWT from a valid PAT."""
# Expects {"asfuid": "uid", "pat": "pat-token"}
# Returns {"asfuid": "uid", "jwt": "jwt-token"}
@@ -262,24 +262,14 @@ async def jwt_post(data: models.api.JwtArgs) ->
DictResponse:
).model_dump(), 200
[email protected]("/key/<fingerprint>")
-@quart_schema.validate_response(models.api.KeyResults, 200)
-async def key(fingerprint: str) -> DictResponse:
- """Return a single public signing key by fingerprint."""
- _simple_check(fingerprint)
- async with db.session() as data:
- key = await
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
- return models.api.KeyResults(
- endpoint="/key",
- key=key,
- ).model_dump(), 200
-
-
@api.BLUEPRINT.route("/keys")
@quart_schema.validate_querystring(models.api.KeysQuery)
@quart_schema.validate_response(models.api.KeysResults, 200)
-async def public_keys(query_args: models.api.KeysQuery) -> DictResponse:
+async def keys_endpoint(query_args: models.api.KeysQuery) -> DictResponse:
"""List all public signing keys with pagination support."""
+ # TODO: Rather than pagination, let's support keys by committee and by user
+ # That way, consumers can scroll through committees or users
+ # Which performs logical pagination, rather than arbitrary window
pagination
_pagination_args_validate(query_args)
via = sql.validate_instrumented_attribute
async with db.session() as data:
@@ -300,11 +290,51 @@ async def public_keys(query_args: models.api.KeysQuery)
-> DictResponse:
).model_dump(), 200
[email protected]("/keys/committee/<committee>")
+@quart_schema.validate_response(models.api.KeysUserResults, 200)
+async def keys_committee(committee: str) -> DictResponse:
+ """Return all public signing keys for a specific committee."""
+ _simple_check(committee)
+ async with db.session() as data:
+ committee_object = await data.committee(name=committee,
_public_signing_keys=True).demand(exceptions.NotFound())
+ keys = committee_object.public_signing_keys
+ return models.api.KeysCommitteeResults(
+ endpoint="/keys/committee",
+ keys=keys,
+ ).model_dump(), 200
+
+
[email protected]("/keys/get/<fingerprint>")
+@quart_schema.validate_response(models.api.KeysGetResults, 200)
+async def keys_get(fingerprint: str) -> DictResponse:
+ """Return a single public signing key by fingerprint."""
+ _simple_check(fingerprint)
+ async with db.session() as data:
+ key = await
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
+ return models.api.KeysGetResults(
+ endpoint="/keys/get",
+ key=key,
+ ).model_dump(), 200
+
+
[email protected]("/keys/user/<asf_uid>")
+@quart_schema.validate_response(models.api.KeysUserResults, 200)
+async def keys_user(asf_uid: str) -> DictResponse:
+ """Return all public signing keys for a specific user."""
+ _simple_check(asf_uid)
+ async with db.session() as data:
+ keys = await data.public_signing_key(apache_uid=asf_uid).all()
+ return models.api.KeysUserResults(
+ endpoint="/keys/user",
+ keys=keys,
+ ).model_dump(), 200
+
+
# TODO: Call this release/paths
@api.BLUEPRINT.route("/list/<project>/<version>")
@api.BLUEPRINT.route("/list/<project>/<version>/<revision>")
@quart_schema.validate_response(models.api.ListResults, 200)
-async def list_project_version(project: str, version: str, revision: str |
None = None) -> DictResponse:
+async def list_endpoint(project: str, version: str, revision: str | None =
None) -> DictResponse:
_simple_check(project, version, revision)
async with db.session() as data:
release_name = sql.release_name(project, version)
@@ -483,7 +513,7 @@ async def releases_project(project: str, query_args:
models.api.ReleasesProjectQ
# @quart_schema.validate_response(sql.Release, 200)
@api.BLUEPRINT.route("/releases/version/<project>/<version>")
@quart_schema.validate_response(models.api.ReleasesVersionResults, 200)
-async def releases_project_version(project: str, version: str) -> DictResponse:
+async def releases_version(project: str, version: str) -> DictResponse:
"""Return a single release by project and version."""
_simple_check(project, version)
async with db.session() as data:
@@ -498,7 +528,7 @@ async def releases_project_version(project: str, version:
str) -> DictResponse:
# TODO: Rename this to revisions? I.e. /revisions/<project>/<version>
@api.BLUEPRINT.route("/releases/revisions/<project>/<version>")
@quart_schema.validate_response(models.api.ReleasesRevisionsResults, 200)
-async def releases_project_version_revisions(project: str, version: str) ->
DictResponse:
+async def releases_revisions(project: str, version: str) -> DictResponse:
"""List all revisions for a given release."""
_simple_check(project, version)
async with db.session() as data:
@@ -512,7 +542,7 @@ async def releases_project_version_revisions(project: str,
version: str) -> Dict
@api.BLUEPRINT.route("/revisions/<project>/<version>")
@quart_schema.validate_response(models.api.RevisionsResults, 200)
-async def revisions_project_version(project: str, version: str) ->
DictResponse:
+async def revisions(project: str, version: str) -> DictResponse:
_simple_check(project, version)
async with db.session() as data:
release_name = sql.release_name(project, version)
@@ -608,6 +638,40 @@ async def tasks(query_args: models.api.TasksQuery) ->
DictResponse:
).model_dump(), 200
[email protected]("/users/list")
+@quart_schema.validate_response(models.api.UsersListResults, 200)
+async def users_list() -> DictResponse:
+ """List all known users."""
+ # This is not a list of all ASF users, but only those known to ATR
+ # It is not even a list of users who have logged in to ATR
+ # Only those who has stored certain kinds of data:
+ # PersonalAccessToken.asfuid
+ # SSHKey.asf_uid
+ # PublicSigningKey.apache_uid
+ # Revision.asfuid
+ async with db.session() as data:
+ # TODO: Combine these queries
+ via = sql.validate_instrumented_attribute
+ result = await
data.execute(sqlalchemy.select(via(sql.PersonalAccessToken.asfuid)).distinct())
+ pat_uids = set(result.scalars().all())
+
+ result = await
data.execute(sqlalchemy.select(via(sql.SSHKey.asf_uid)).distinct())
+ ssh_uids = set(result.scalars().all())
+
+ result = await
data.execute(sqlalchemy.select(via(sql.PublicSigningKey.apache_uid)).distinct())
+ public_signing_uids = set(result.scalars().all())
+
+ result = await
data.execute(sqlalchemy.select(via(sql.Revision.asfuid)).distinct())
+ revision_uids = set(result.scalars().all())
+
+ users = pat_uids | ssh_uids | public_signing_uids | revision_uids
+ users -= {None}
+ return models.api.UsersListResults(
+ endpoint="/users/list",
+ users=sorted(users),
+ ).model_dump(), 200
+
+
@api.BLUEPRINT.route("/vote/resolve", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
diff --git a/atr/models/api.py b/atr/models/api.py
index 171b3f6..4d51105 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -101,11 +101,6 @@ class JwtResults(schema.Strict):
jwt: str
-class KeyResults(schema.Strict):
- endpoint: Literal["/key"] = schema.Field(alias="endpoint")
- key: sql.PublicSigningKey
-
-
@dataclasses.dataclass
class KeysQuery:
offset: int = 0
@@ -118,6 +113,21 @@ class KeysResults(schema.Strict):
count: int
+class KeysCommitteeResults(schema.Strict):
+ endpoint: Literal["/keys/committee"] = schema.Field(alias="endpoint")
+ keys: Sequence[sql.PublicSigningKey]
+
+
+class KeysGetResults(schema.Strict):
+ endpoint: Literal["/keys/get"] = schema.Field(alias="endpoint")
+ key: sql.PublicSigningKey
+
+
+class KeysUserResults(schema.Strict):
+ endpoint: Literal["/keys/user"] = schema.Field(alias="endpoint")
+ keys: Sequence[sql.PublicSigningKey]
+
+
class ProjectResults(schema.Strict):
endpoint: Literal["/project"] = schema.Field(alias="endpoint")
project: sql.Project
@@ -238,6 +248,11 @@ class TasksResults(schema.Strict):
count: int
+class UsersListResults(schema.Strict):
+ endpoint: Literal["/users/list"] = schema.Field(alias="endpoint")
+ users: Sequence[str]
+
+
class VoteResolveArgs(schema.Strict):
project: str
version: str
@@ -288,8 +303,10 @@ Results = Annotated[
| CommitteesProjectsResults
| DraftDeleteResults
| JwtResults
- | KeyResults
| KeysResults
+ | KeysGetResults
+ | KeysCommitteeResults
+ | KeysUserResults
| ListResults
| ProjectResults
| ProjectReleasesResults
@@ -305,6 +322,7 @@ Results = Annotated[
| SshDeleteResults
| SshListResults
| TasksResults
+ | UsersListResults
| VoteResolveResults
| VoteStartResults
| UploadResults,
@@ -333,8 +351,10 @@ validate_committees_list = validator(CommitteesListResults)
validate_committees_projects = validator(CommitteesProjectsResults)
validate_draft_delete = validator(DraftDeleteResults)
validate_jwt = validator(JwtResults)
-validate_key = validator(KeyResults)
validate_keys = validator(KeysResults)
+validate_keys_committee = validator(KeysCommitteeResults)
+validate_keys_get = validator(KeysGetResults)
+validate_keys_user = validator(KeysUserResults)
validate_list = validator(ListResults)
validate_project = validator(ProjectResults)
validate_project_releases = validator(ProjectReleasesResults)
@@ -350,6 +370,7 @@ validate_ssh_add = validator(SshAddResults)
validate_ssh_delete = validator(SshDeleteResults)
validate_ssh_list = validator(SshListResults)
validate_tasks = validator(TasksResults)
+validate_users_list = validator(UsersListResults)
validate_vote_resolve = validator(VoteResolveResults)
validate_vote_start = validator(VoteStartResults)
validate_upload = validator(UploadResults)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 90b6584..31d2de5 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -705,6 +705,16 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
# M-M: Committee -> [PublicSigningKey]
committees: list[Committee] =
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+ def model_post_init(self, _context):
+ if isinstance(self.created, str):
+ self.created =
datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+
+ if isinstance(self.latest_self_signature, str):
+ self.latest_self_signature =
datetime.datetime.fromisoformat(self.latest_self_signature.rstrip("Z"))
+
+ if isinstance(self.expires, str):
+ self.expires =
datetime.datetime.fromisoformat(self.expires.rstrip("Z"))
+
# ReleasePolicy: Project
class ReleasePolicy(sqlmodel.SQLModel, table=True):
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]