173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""API-only Gitea deploy-key lifecycle canary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Mapping, Protocol, Sequence
|
|
|
|
from .errors import CredentialUnavailable, StateConflict
|
|
|
|
|
|
GITEA_REFERENCE = "systemd-credential:gitea-admin-api-candidate"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class EphemeralKeyProjection:
|
|
public_key: str
|
|
private_key_reference: str
|
|
|
|
|
|
class KeyProjectionProvider(Protocol):
|
|
def create(self, task_id: str, branch: str) -> EphemeralKeyProjection: ...
|
|
|
|
def delete(self, reference: str) -> None: ...
|
|
|
|
def is_present(self, reference: str) -> bool: ...
|
|
|
|
|
|
class GiteaDeployKeyProvider(Protocol):
|
|
simulation: bool
|
|
|
|
def list_deploy_keys(
|
|
self, repository: str, credential_reference: str
|
|
) -> Sequence[Mapping[str, Any]]: ...
|
|
|
|
def create_deploy_key(
|
|
self,
|
|
repository: str,
|
|
title: str,
|
|
public_key: str,
|
|
credential_reference: str,
|
|
) -> Mapping[str, Any]: ...
|
|
|
|
def delete_deploy_key(
|
|
self, repository: str, key_id: int, credential_reference: str
|
|
) -> None: ...
|
|
|
|
|
|
class SSHAuthenticationProbe(Protocol):
|
|
def authenticate(
|
|
self, repository: str, branch: str, private_key_reference: str
|
|
) -> bool: ...
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class GiteaCanaryResult:
|
|
key_created: bool
|
|
key_deleted: bool
|
|
api_key_absent_after_delete: bool
|
|
post_revoke_auth_rejected: bool
|
|
credential_projection_deleted: bool
|
|
gitea_admin_api_lifecycle_canary: bool
|
|
simulation: bool
|
|
secrets_included: bool = False
|
|
|
|
|
|
class GiteaDeployKeyCanary:
|
|
"""Exercise only repository deploy-key list/create/delete provider operations."""
|
|
|
|
repository = "aios/aios"
|
|
|
|
def __init__(
|
|
self,
|
|
gitea: GiteaDeployKeyProvider,
|
|
projections: KeyProjectionProvider,
|
|
ssh_probe: SSHAuthenticationProbe,
|
|
) -> None:
|
|
self.gitea = gitea
|
|
self.projections = projections
|
|
self.ssh_probe = ssh_probe
|
|
|
|
def run(
|
|
self, *, task_id: str, branch: str, credential_reference: str
|
|
) -> GiteaCanaryResult:
|
|
if credential_reference != GITEA_REFERENCE:
|
|
raise CredentialUnavailable(
|
|
"BLOCKED_GITEA_ADMIN_CREDENTIAL_REQUIRED",
|
|
"exact Candidate Gitea Admin API reference is required",
|
|
)
|
|
if not task_id or not branch.startswith("patch/"):
|
|
raise StateConflict("GITEA_CANARY_BINDING_INVALID", "task and patch branch are required")
|
|
projection: EphemeralKeyProjection | None = None
|
|
key_id: int | None = None
|
|
key_created = False
|
|
key_deleted = False
|
|
api_absent = False
|
|
auth_rejected = False
|
|
projection_deleted = False
|
|
title = f"aios-candidate-{task_id}"
|
|
try:
|
|
projection = self.projections.create(task_id, branch)
|
|
if (
|
|
not projection.public_key.startswith("ssh-ed25519 ")
|
|
or not projection.private_key_reference.startswith("systemd-credential:")
|
|
):
|
|
raise CredentialUnavailable(
|
|
"GITEA_CANARY_KEY_PROJECTION_INVALID",
|
|
"ephemeral key projection is not reference-only",
|
|
)
|
|
before = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
|
if any(item.get("title") == title for item in before):
|
|
raise StateConflict("GITEA_CANARY_KEY_COLLISION", "canary key title already exists")
|
|
created = self.gitea.create_deploy_key(
|
|
self.repository,
|
|
title,
|
|
projection.public_key,
|
|
credential_reference,
|
|
)
|
|
raw_id = created.get("id")
|
|
if not isinstance(raw_id, int) or created.get("key") != projection.public_key:
|
|
raise StateConflict("GITEA_CANARY_CREATE_INVALID", "created key response is invalid")
|
|
key_id = raw_id
|
|
key_created = True
|
|
visible = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
|
if not any(item.get("id") == key_id for item in visible):
|
|
raise StateConflict("GITEA_CANARY_KEY_NOT_VISIBLE", "created key is not listed")
|
|
if not self.ssh_probe.authenticate(
|
|
self.repository, branch, projection.private_key_reference
|
|
):
|
|
raise StateConflict("GITEA_CANARY_SSH_AUTH_FAILED", "deploy key could not authenticate")
|
|
self.gitea.delete_deploy_key(self.repository, key_id, credential_reference)
|
|
key_deleted = True
|
|
key_id = None
|
|
after = self.gitea.list_deploy_keys(self.repository, credential_reference)
|
|
api_absent = not any(item.get("title") == title for item in after)
|
|
if not api_absent:
|
|
raise StateConflict("GITEA_CANARY_DELETE_UNVERIFIED", "deleted key remains visible")
|
|
self.projections.delete(projection.private_key_reference)
|
|
projection_deleted = not self.projections.is_present(projection.private_key_reference)
|
|
auth_rejected = not self.ssh_probe.authenticate(
|
|
self.repository, branch, projection.private_key_reference
|
|
)
|
|
if not projection_deleted or not auth_rejected:
|
|
raise StateConflict(
|
|
"GITEA_CANARY_REVOCATION_UNVERIFIED",
|
|
"projection deletion or post-revocation SSH rejection failed",
|
|
)
|
|
simulated = bool(getattr(self.gitea, "simulation", True))
|
|
return GiteaCanaryResult(
|
|
key_created=key_created,
|
|
key_deleted=key_deleted,
|
|
api_key_absent_after_delete=api_absent,
|
|
post_revoke_auth_rejected=auth_rejected,
|
|
credential_projection_deleted=projection_deleted,
|
|
gitea_admin_api_lifecycle_canary=not simulated,
|
|
simulation=simulated,
|
|
secrets_included=False,
|
|
)
|
|
finally:
|
|
if key_id is not None:
|
|
try:
|
|
self.gitea.delete_deploy_key(
|
|
self.repository, key_id, credential_reference
|
|
)
|
|
except Exception:
|
|
pass
|
|
if projection is not None and self.projections.is_present(
|
|
projection.private_key_reference
|
|
):
|
|
try:
|
|
self.projections.delete(projection.private_key_reference)
|
|
except Exception:
|
|
pass
|