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]