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 2f8931b  Create a new API group for interactions from GitHub
2f8931b is described below

commit 2f8931b75448bfae4085b1d847b0c8c98de29ccf
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Sep 4 15:34:24 2025 +0100

    Create a new API group for interactions from GitHub
---
 atr/blueprints/api/api.py | 53 +++++++++++++++---------------
 atr/db/interaction.py     | 82 ++++++++++++++++++++++++++---------------------
 atr/models/api.py         | 33 +++++++++----------
 3 files changed, 87 insertions(+), 81 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index ee6c187..b36c148 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -36,7 +36,6 @@ import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.jwtoken as jwtoken
-import atr.ldap as ldap
 import atr.log as log
 import atr.models as models
 import atr.models.sql as sql
@@ -261,6 +260,31 @@ async def committees_list() -> DictResponse:
     ).model_dump(), 200
 
 
[email protected]("/github/ssh/register", methods=["POST"])
+@quart_schema.validate_request(models.api.GithubSshRegisterArgs)
+async def github_ssh_register(data: models.api.GithubSshRegisterArgs) -> 
DictResponse:
+    """
+    Register an SSH key sent with a corroborating GitHub OIDC JWT.
+    """
+    log.info(f"SSH key: {data.ssh_key}")
+
+    payload, asf_uid, project = await interaction.github_trusted_jwt(data.jwt)
+    async with 
storage.write_as_committee_member(util.unwrap(project.committee).name, asf_uid) 
as wacm:
+        fingerprint, expires = await wacm.ssh.add_workflow_key(
+            payload["actor"],
+            payload["actor_id"],
+            project.name,
+            data.ssh_key,
+        )
+
+    return models.api.GithubSshRegisterResults(
+        endpoint="/github/ssh/register",
+        fingerprint=fingerprint,
+        project=project.name,
+        expires=expires,
+    ).model_dump(), 200
+
+
 @api.BLUEPRINT.route("/ignore/add", methods=["POST"])
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
@@ -351,33 +375,6 @@ async def jwt_create(data: models.api.JwtCreateArgs) -> 
DictResponse:
     ).model_dump(), 200
 
 
[email protected]("/jwt/github", methods=["POST"])
-@quart_schema.validate_request(models.api.JwtGithubArgs)
-async def jwt_github(data: models.api.JwtGithubArgs) -> DictResponse:
-    """
-    Register an SSH key sent with a corroborating GitHub OIDC JWT.
-    """
-    log.info(f"SSH key: {data.ssh_key}")
-
-    payload = await jwtoken.verify_github_oidc(data.jwt)
-    asf_uid = await ldap.github_to_apache(payload["actor_id"])
-    project = await interaction.trusted_project(payload["repository"], 
payload["workflow_ref"])
-    async with 
storage.write_as_committee_member(util.unwrap(project.committee).name, asf_uid) 
as wacm:
-        fingerprint, expires = await wacm.ssh.add_workflow_key(
-            payload["actor"],
-            payload["actor_id"],
-            project.name,
-            data.ssh_key,
-        )
-
-    return models.api.JwtGithubResults(
-        endpoint="/jwt/github",
-        fingerprint=fingerprint,
-        project=project.name,
-        expires=expires,
-    ).model_dump(), 200
-
-
 @api.BLUEPRINT.route("/key/add", methods=["POST"])
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 17bba07..19b459b 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -18,6 +18,7 @@
 import contextlib
 import pathlib
 from collections.abc import AsyncGenerator, Sequence
+from typing import Any
 
 import aiofiles.os
 import aioshutil
@@ -27,6 +28,8 @@ import sqlalchemy
 import sqlmodel
 
 import atr.db as db
+import atr.jwtoken as jwtoken
+import atr.ldap as ldap
 import atr.log as log
 import atr.models.sql as sql
 import atr.registry as registry
@@ -71,6 +74,13 @@ async def full_releases(project: sql.Project) -> 
list[sql.Release]:
     return await releases_by_phase(project, sql.ReleasePhase.RELEASE)
 
 
+async def github_trusted_jwt(jwt: str) -> tuple[dict[str, Any], str, 
sql.Project]:
+    payload = await jwtoken.verify_github_oidc(jwt)
+    asf_uid = await ldap.github_to_apache(payload["actor_id"])
+    project = await _trusted_project(payload["repository"], 
payload["workflow_ref"])
+    return payload, asf_uid, project
+
+
 async def has_failing_checks(release: sql.Release, revision_number: str, 
caller_data: db.Session | None = None) -> bool:
     async with db.ensure_session(caller_data) as data:
         query = (
@@ -220,42 +230,6 @@ async def tasks_ongoing_revision(
         return task_count, latest_revision
 
 
-async def trusted_project(repository: str, workflow_ref: str) -> sql.Project:
-    # Debugging
-    log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
-
-    if not repository.startswith("apache/"):
-        raise InteractionError("Repository must start with 'apache/'")
-    repository_name = repository.removeprefix("apache/")
-    if not workflow_ref.startswith(repository + "/"):
-        raise InteractionError(f"Workflow ref must start with repository, got 
{workflow_ref}")
-    workflow_path_at = workflow_ref.removeprefix(repository + "/")
-    if "@" not in workflow_path_at:
-        raise InteractionError(f"Workflow path must contain '@', got 
{workflow_path_at}")
-    workflow_path = workflow_path_at.rsplit("@", 1)[0]
-    if not workflow_path.startswith(".github/workflows/"):
-        raise InteractionError(f"Workflow path must start with 
'.github/workflows/', got {workflow_path}")
-    # TODO: If a policy is reused between projects, we can't get the project
-    async with db.session() as db_data:
-        policy = await db_data.release_policy(
-            github_repository_name=repository_name, 
github_workflow_path=workflow_path
-        ).demand(
-            InteractionError(
-                f"No release policy found for repository name 
{repository_name} and workflow path {workflow_path}"
-            )
-        )
-        project = await db_data.project(release_policy_id=policy.id).demand(
-            InteractionError(f"Project for release policy {policy.id} not 
found")
-        )
-    if project.committee is None:
-        raise InteractionError(f"Project {project.name} has no committee")
-    if project.committee.name not in 
registry.GITHUB_AUTOMATED_RELEASE_COMMITTEES:
-        raise InteractionError(f"Project {project.name} is not in a committee 
that can make releases")
-    log.info(f"Release policy: {policy}")
-    log.info(f"Project: {project}")
-    return project
-
-
 async def unfinished_releases(asfuid: str) -> dict[str, list[sql.Release]]:
     releases: dict[str, list[sql.Release]] = {}
     async with db.session() as data:
@@ -346,3 +320,39 @@ async def _delete_release_data_filesystem(release_dir: 
pathlib.Path, release_nam
             f"Database records for '{release_name}' deleted, but failed to 
delete filesystem directory: {e!s}",
             "warning",
         )
+
+
+async def _trusted_project(repository: str, workflow_ref: str) -> sql.Project:
+    # Debugging
+    log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
+
+    if not repository.startswith("apache/"):
+        raise InteractionError("Repository must start with 'apache/'")
+    repository_name = repository.removeprefix("apache/")
+    if not workflow_ref.startswith(repository + "/"):
+        raise InteractionError(f"Workflow ref must start with repository, got 
{workflow_ref}")
+    workflow_path_at = workflow_ref.removeprefix(repository + "/")
+    if "@" not in workflow_path_at:
+        raise InteractionError(f"Workflow path must contain '@', got 
{workflow_path_at}")
+    workflow_path = workflow_path_at.rsplit("@", 1)[0]
+    if not workflow_path.startswith(".github/workflows/"):
+        raise InteractionError(f"Workflow path must start with 
'.github/workflows/', got {workflow_path}")
+    # TODO: If a policy is reused between projects, we can't get the project
+    async with db.session() as db_data:
+        policy = await db_data.release_policy(
+            github_repository_name=repository_name, 
github_workflow_path=workflow_path
+        ).demand(
+            InteractionError(
+                f"No release policy found for repository name 
{repository_name} and workflow path {workflow_path}"
+            )
+        )
+        project = await db_data.project(release_policy_id=policy.id).demand(
+            InteractionError(f"Project for release policy {policy.id} not 
found")
+        )
+    if project.committee is None:
+        raise InteractionError(f"Project {project.name} has no committee")
+    if project.committee.name not in 
registry.GITHUB_AUTOMATED_RELEASE_COMMITTEES:
+        raise InteractionError(f"Project {project.name} is not in a committee 
that can make releases")
+    log.info(f"Release policy: {policy}")
+    log.info(f"Project: {project}")
+    return project
diff --git a/atr/models/api.py b/atr/models/api.py
index 78e8700..fb67cc8 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -71,6 +71,20 @@ class CommitteesListResults(schema.Strict):
     committees: Sequence[sql.Committee]
 
 
+class GithubSshRegisterArgs(schema.Strict):
+    jwt: str = schema.Field(..., 
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
+    ssh_key: str = schema.Field(
+        ..., **example("ssh-ed25519 
AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl")
+    )
+
+
+class GithubSshRegisterResults(schema.Strict):
+    endpoint: Literal["/github/ssh/register"] = schema.Field(alias="endpoint")
+    fingerprint: str = schema.Field(..., 
**example("SHA256:0123456789abcdef0123456789abcdef01234567"))
+    project: str = schema.Field(..., **example("example"))
+    expires: int = schema.Field(..., **example(1713547200))
+
+
 class IgnoreAddArgs(schema.Strict):
     committee_name: str = schema.Field(..., **example("example"))
     release_glob: str | None = schema.Field(default=None, 
**example("example-0.0.*"))
@@ -115,20 +129,6 @@ class JwtCreateResults(schema.Strict):
     jwt: str = schema.Field(..., 
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
 
 
-class JwtGithubArgs(schema.Strict):
-    jwt: str = schema.Field(..., 
**example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI="))
-    ssh_key: str = schema.Field(
-        ..., **example("ssh-ed25519 
AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl")
-    )
-
-
-class JwtGithubResults(schema.Strict):
-    endpoint: Literal["/jwt/github"] = schema.Field(alias="endpoint")
-    fingerprint: str = schema.Field(..., 
**example("SHA256:0123456789abcdef0123456789abcdef01234567"))
-    project: str = schema.Field(..., **example("example"))
-    expires: int = schema.Field(..., **example(1713547200))
-
-
 class KeyAddArgs(schema.Strict):
     asfuid: str = schema.Field(..., **example("user"))
     key: str = schema.Field(
@@ -434,11 +434,10 @@ Results = Annotated[
     | CommitteeKeysResults
     | CommitteeProjectsResults
     | CommitteesListResults
+    | GithubSshRegisterResults
     | IgnoreAddResults
     | IgnoreDeleteResults
     | IgnoreListResults
-    | JwtCreateResults
-    | JwtGithubResults
     | KeyAddResults
     | KeyDeleteResults
     | KeyGetResults
@@ -487,11 +486,11 @@ validate_committee_get = validator(CommitteeGetResults)
 validate_committee_keys = validator(CommitteeKeysResults)
 validate_committee_projects = validator(CommitteeProjectsResults)
 validate_committees_list = validator(CommitteesListResults)
+validate_github_ssh_register = validator(GithubSshRegisterResults)
 validate_ignore_add = validator(IgnoreAddResults)
 validate_ignore_delete = validator(IgnoreDeleteResults)
 validate_ignore_list = validator(IgnoreListResults)
 validate_jwt_create = validator(JwtCreateResults)
-validate_jwt_github = validator(JwtGithubResults)
 validate_key_add = validator(KeyAddResults)
 validate_key_delete = validator(KeyDeleteResults)
 validate_key_get = validator(KeyGetResults)


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

Reply via email to