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]