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 c0fc94e  Add an API endpoint to associate an OpenSSH key with a user 
account
c0fc94e is described below

commit c0fc94e355c3602e2385c7c89d88eecc741ed489
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jul 16 17:08:36 2025 +0100

    Add an API endpoint to associate an OpenSSH key with a user account
---
 atr/blueprints/api/api.py | 38 ++++++++++++++++++++++++++++++++++++++
 atr/db/__init__.py        | 22 ++++++++++++++++++++--
 atr/db/interaction.py     | 28 ++++++++++++++++++++--------
 atr/models/api.py         | 14 ++++++++++++++
 atr/tasks/__init__.py     |  5 ++---
 5 files changed, 94 insertions(+), 13 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 6e6aa2a..18b95ca 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -241,6 +241,7 @@ async def draft_delete(data: models.api.DraftDeleteArgs) -> 
DictResponse:
     ).model_dump(), 200
 
 
+# This is the only POST endpoint that does not require a JWT
 @api.BLUEPRINT.route("/jwt", methods=["POST"])
 @quart_schema.validate_request(models.api.JwtArgs)
 async def jwt(data: models.api.JwtArgs) -> DictResponse:
@@ -290,6 +291,43 @@ async def keys_endpoint(query_args: models.api.KeysQuery) 
-> DictResponse:
         ).model_dump(), 200
 
 
[email protected]("/keys/add", methods=["POST"])
[email protected]
+@quart_schema.security_scheme([{"BearerAuth": []}])
+@quart_schema.validate_request(models.api.KeysAddArgs)
+@quart_schema.validate_response(models.api.KeysAddResults, 200)
+async def keys_add(data: models.api.KeysAddArgs) -> DictResponse:
+    asf_uid = _jwt_asf_uid()
+    selected_committee_names = []
+    if data.committees:
+        selected_committee_names[:] = data.committees.split(",")
+
+    async with db.session() as db_data:
+        member_of_committees = await 
interaction.user_committees_member(asf_uid, caller_data=db_data)
+        selected_committees = await 
db_data.committee(name_in=selected_committee_names).all()
+        committee_is_podling = {c.name: c.is_podling for c in 
selected_committees}
+        for committee in selected_committees:
+            if committee not in member_of_committees:
+                raise exceptions.BadRequest(f"You are not a member of 
committee {committee.name}")
+        added_keys = await interaction.key_user_add(asf_uid, data.key, 
selected_committee_names)
+        fingerprints = []
+        for key_info in added_keys:
+            if key_info:
+                fingerprint = key_info.get("fingerprint", "").upper()
+                fingerprints.append(fingerprint)
+                for committee_name in selected_committee_names:
+                    is_podling = committee_is_podling[committee_name]
+                    await keys.autogenerate_keys_file(committee_name, 
is_podling)
+
+        if not added_keys:
+            raise exceptions.BadRequest("No keys were added.")
+    return models.api.KeysAddResults(
+        endpoint="/keys/add",
+        success="Key added",
+        fingerprints=fingerprints,
+    ).model_dump(), 200
+
+
 @api.BLUEPRINT.route("/keys/committee/<committee>")
 @quart_schema.validate_response(models.api.KeysUserResults, 200)
 async def keys_committee(committee: str) -> DictResponse:
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index ffe9def..00f568a 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -207,10 +207,14 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         committers: Opt[list[str]] = NOT_SET,
         release_managers: Opt[list[str]] = NOT_SET,
         name_in: Opt[list[str]] = NOT_SET,
+        has_member: Opt[str] = NOT_SET,
+        has_committer: Opt[str] = NOT_SET,
+        has_participant: Opt[str] = NOT_SET,
         _child_committees: bool = False,
         _projects: bool = False,
         _public_signing_keys: bool = False,
     ) -> Query[sql.Committee]:
+        via = sql.validate_instrumented_attribute
         query = sqlmodel.select(sql.Committee)
 
         if is_defined(name):
@@ -229,8 +233,16 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(sql.Committee.release_managers == 
release_managers)
 
         if is_defined(name_in):
-            models_committee_name = 
sql.validate_instrumented_attribute(sql.Committee.name)
-            query = query.where(models_committee_name.in_(name_in))
+            query = query.where(via(sql.Committee.name).in_(name_in))
+        if is_defined(has_member):
+            query = 
query.where(via(sql.Committee.committee_members).contains(has_member))
+        if is_defined(has_committer):
+            query = 
query.where(via(sql.Committee.committers).contains(has_committer))
+        if is_defined(has_participant):
+            query = query.where(
+                via(sql.Committee.committee_members).contains(has_participant)
+                or via(sql.Committee.committers).contains(has_participant)
+            )
 
         if _child_committees:
             query = 
query.options(select_in_load(sql.Committee.child_committees))
@@ -637,6 +649,12 @@ async def create_async_engine(app_config: 
type[config.AppConfig]) -> sqlalchemy.
     return engine
 
 
+def ensure_session(caller_data: Session | None) -> Session | 
contextlib.nullcontext[Session]:
+    if caller_data is None:
+        return session()
+    return contextlib.nullcontext(caller_data)
+
+
 async def get_project_release_policy(data: Session, project_name: str) -> 
sql.ReleasePolicy | None:
     """Fetch the ReleasePolicy for a project."""
     project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index e85a103..ab15aeb 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -22,7 +22,7 @@ import logging
 import pathlib
 import pprint
 import re
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Sequence
 from typing import Final
 
 import aiofiles.os
@@ -65,12 +65,6 @@ class PathInfo(schema.Strict):
     warnings: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
 
 
-def ensure_session(caller_data: db.Session | None) -> db.Session | 
contextlib.nullcontext[db.Session]:
-    if caller_data is None:
-        return db.session()
-    return contextlib.nullcontext(caller_data)
-
-
 @contextlib.asynccontextmanager
 async def ephemeral_gpg_home() -> AsyncGenerator[str]:
     """Create a temporary directory for an isolated GPG home, and clean it up 
on exit."""
@@ -79,7 +73,7 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
 
 
 async def has_failing_checks(release: sql.Release, revision_number: str, 
caller_data: db.Session | None = None) -> bool:
-    async with ensure_session(caller_data) as data:
+    async with db.ensure_session(caller_data) as data:
         query = (
             sqlmodel.select(sqlalchemy.func.count())
             .select_from(sql.CheckResult)
@@ -463,6 +457,24 @@ async def upload_keys_bytes(
     return results, success_count, error_count, submitted_committees
 
 
+# This function cannot go in user.py because it causes a circular import
+async def user_committees_committer(asf_uid: str, caller_data: db.Session | 
None = None) -> Sequence[sql.Committee]:
+    async with db.ensure_session(caller_data) as data:
+        return await data.committee(has_committer=asf_uid).all()
+
+
+# This function cannot go in user.py because it causes a circular import
+async def user_committees_member(asf_uid: str, caller_data: db.Session | None 
= None) -> Sequence[sql.Committee]:
+    async with db.ensure_session(caller_data) as data:
+        return await data.committee(has_member=asf_uid).all()
+
+
+# This function cannot go in user.py because it causes a circular import
+async def user_committees_participant(asf_uid: str, caller_data: db.Session | 
None = None) -> Sequence[sql.Committee]:
+    async with db.ensure_session(caller_data) as data:
+        return await data.committee(has_participant=asf_uid).all()
+
+
 async def _delete_release_data_downloads(release: sql.Release) -> None:
     # Delete hard links from the downloads directory
     finished_dir = util.release_directory(release)
diff --git a/atr/models/api.py b/atr/models/api.py
index 4d51105..be659ef 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -101,6 +101,18 @@ class JwtResults(schema.Strict):
     jwt: str
 
 
+class KeysAddArgs(schema.Strict):
+    asfuid: str
+    key: str
+    committees: str
+
+
+class KeysAddResults(schema.Strict):
+    endpoint: Literal["/keys/add"] = schema.Field(alias="endpoint")
+    success: str
+    fingerprints: list[str]
+
+
 @dataclasses.dataclass
 class KeysQuery:
     offset: int = 0
@@ -304,6 +316,7 @@ Results = Annotated[
     | DraftDeleteResults
     | JwtResults
     | KeysResults
+    | KeysAddResults
     | KeysGetResults
     | KeysCommitteeResults
     | KeysUserResults
@@ -352,6 +365,7 @@ validate_committees_projects = 
validator(CommitteesProjectsResults)
 validate_draft_delete = validator(DraftDeleteResults)
 validate_jwt = validator(JwtResults)
 validate_keys = validator(KeysResults)
+validate_keys_add = validator(KeysAddResults)
 validate_keys_committee = validator(KeysCommitteeResults)
 validate_keys_get = validator(KeysGetResults)
 validate_keys_user = validator(KeysUserResults)
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 052234a..4efaa3c 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -19,7 +19,6 @@ from collections.abc import Awaitable, Callable, Coroutine
 from typing import Any, Final
 
 import atr.db as db
-import atr.db.interaction as interaction
 import atr.models.results as results
 import atr.models.sql as sql
 import atr.tasks.checks.hashing as hashing
@@ -64,7 +63,7 @@ async def draft_checks(
     revision_path = util.get_unfinished_dir() / project_name / release_version 
/ revision_number
     relative_paths = [path async for path in 
util.paths_recursive(revision_path)]
 
-    async with interaction.ensure_session(caller_data) as data:
+    async with db.ensure_session(caller_data) as data:
         release = await data.release(name=sql.release_name(project_name, 
release_version), _committee=True).demand(
             RuntimeError("Release not found")
         )
@@ -98,7 +97,7 @@ async def keys_import_file(
     release_name: str, revision_number: str, abs_keys_path: str, caller_data: 
db.Session | None = None
 ) -> None:
     """Import a KEYS file from a draft release candidate revision."""
-    async with interaction.ensure_session(caller_data) as data:
+    async with db.ensure_session(caller_data) as data:
         data.add(
             sql.Task(
                 status=sql.TaskStatus.QUEUED,


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

Reply via email to