501 lines
18 KiB
Python

#!/usr/bin/env python3
"""Use the private Atlas Forgejo API through a least-authority PR boundary."""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable
from pathlib import Path
import deadline_http
from gitea_api_policy import (
PolicyError,
_draft_title,
_reject_forbidden,
_validate_body,
_validate_pr_number,
_validate_pr_number_segment,
_validate_query,
_validate_ref,
_validate_ref_bounds,
_validate_repo,
_validate_sha,
canonical_api_repo_path,
)
CANONICAL_BASE_URL = "https://scm.bstein.dev"
ALLOWED_OWNER = "titan"
DEFAULT_TOKEN_FILE = Path("/vault/secrets/gitea-token")
CREATE_PATH_RE = re.compile(r"/api/v1/repos/titan/([^/]+)/pulls\Z")
MAX_ERROR_BYTES = 8192
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
MAX_API_TARGET_LENGTH = 768
MAX_API_PATH_LENGTH = 512
RAW_API_TARGET_RE = re.compile(r"[A-Za-z0-9/_.?&=-]+\Z")
def _safe_urlopen(request: urllib.request.Request, timeout: int):
"""Open one exchange under a killable absolute wall-clock deadline.
The helper process rejects redirects before urllib can copy
authentication headers, and its whole connect/send/read lifetime
shares one hard deadline.
"""
return deadline_http.open_bounded(
request, maximum=MAX_RESPONSE_BYTES, timeout=timeout
)
def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
"""Read the token from the pod-lifetime Vault projection."""
token = path.read_text(encoding="utf-8").strip()
if not token:
raise ValueError(f"runtime credential is empty: {path}")
return token
def configured_base_url() -> str:
"""Reject attempts to redirect credentials away from the canonical origin."""
configured = os.environ.get("GITEA_BASE_URL", CANONICAL_BASE_URL)
if configured != CANONICAL_BASE_URL:
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
return configured
def _split_api_path(path: str) -> urllib.parse.SplitResult:
if not isinstance(path, str) or len(path) > MAX_API_TARGET_LENGTH:
raise PolicyError("API target exceeds the safe size limit")
if (
not path.isascii()
or any(ord(character) < 32 or ord(character) == 127 for character in path)
or "\\" in path
or not RAW_API_TARGET_RE.fullmatch(path)
):
raise PolicyError("API target contains non-canonical raw characters")
target = urllib.parse.urlsplit(path)
canonical = urllib.parse.urlunsplit(("", "", target.path, target.query, ""))
if canonical != path:
raise PolicyError("API target is not in exact canonical form")
if target.scheme or target.netloc or target.fragment:
raise PolicyError("API path must be relative to the Atlas SCM origin")
if not target.path.startswith("/api/v1/"):
raise PolicyError("API path must start with /api/v1/")
if len(target.path) > MAX_API_PATH_LENGTH:
raise PolicyError("API path exceeds the safe size limit")
segments = target.path.split("/")
if "//" in target.path or any(segment in {".", ".."} for segment in segments):
raise PolicyError("encoded or non-canonical API paths are not allowed")
return target._replace(path=canonical_api_repo_path(target.path))
def _authorize_read(
target: urllib.parse.SplitResult, *, forbidden: tuple[str, ...] = ()
) -> str:
"""Allow only identity, repository, PR, branch, commit, and status reads."""
if target.path == "/api/v1/user":
# The broker holds the only forge credential, so this answers with the
# broker identity's own account metadata (name, is_admin) and nothing
# else; acceptance tooling uses it to prove that identity holds no
# administrative rights. No query, no other /user route.
_validate_query(target, set())
return "identity"
prefix = "/api/v1/repos/titan/"
remainder = target.path.removeprefix(prefix)
if remainder == target.path:
raise PolicyError("reads are limited to explicit Atlas repository metadata")
repo, separator, suffix = remainder.partition("/")
_reject_forbidden(repo, "repository", forbidden)
_validate_repo(repo)
if not separator:
_validate_query(target, set())
return "repository"
if suffix == "pulls":
_validate_query(target, {"page", "limit", "state"})
return "pull-list"
content_match = re.fullmatch(r"pulls/([^/]+)\.(?:patch|diff)", suffix)
if content_match:
_validate_pr_number_segment(content_match.group(1))
raise PolicyError("repository API route is outside the metadata read allowlist")
pull_match = re.fullmatch(r"pulls/([^/]+)", suffix)
if pull_match:
_validate_pr_number_segment(pull_match.group(1))
_validate_query(target, set())
return "pull"
evidence_match = re.fullmatch(r"pulls/([^/]+)/(?:commits|files)", suffix)
if evidence_match:
_validate_pr_number_segment(evidence_match.group(1))
_validate_query(target, {"page", "limit"})
return "pull-evidence"
if suffix == "branches":
_validate_query(target, {"page", "limit"})
return "branch-list"
branch_match = re.fullmatch(r"branches/(.+)", suffix)
if branch_match:
_validate_ref(branch_match.group(1), "branch")
_validate_query(target, set())
return "branch"
if suffix == "commits":
_validate_query(target, {"page", "limit"})
return "commit-list"
if re.fullmatch(r"git/commits/[0-9a-fA-F]{40}", suffix):
_validate_query(target, set())
return "commit"
if re.fullmatch(
r"(?:commits/[0-9a-fA-F]{40}/status|commits/[0-9a-fA-F]{40}/statuses|statuses/[0-9a-fA-F]{40})",
suffix,
):
_validate_query(target, {"page", "limit"})
return "status"
raise PolicyError("repository API route is outside the metadata read allowlist")
def api_url(base_url: str, path: str) -> str:
"""Return a canonical same-origin URL without forwarding credentials."""
if base_url != CANONICAL_BASE_URL:
raise PolicyError("Forgejo origin is fixed to the private Atlas SCM service")
target = _split_api_path(path)
return urllib.parse.urlunsplit(
("https", "scm.bstein.dev", target.path, target.query, "")
)
def authorize_request(
method: str,
path: str,
data: object | None,
*,
forbidden: tuple[str, ...] = (),
) -> str:
"""Validate one request and return its bounded operation name."""
normalized_method = method.upper()
target = _split_api_path(path)
if normalized_method == "GET":
if data is not None:
raise PolicyError("read operations cannot include a request body")
return _authorize_read(target, forbidden=forbidden)
if target.query:
raise PolicyError("mutating operations cannot include query parameters")
if normalized_method != "POST":
raise PolicyError("only read and create-draft operations are available")
match = CREATE_PATH_RE.fullmatch(target.path)
if not match:
raise PolicyError("POST is limited to creating an Atlas draft pull request")
_reject_forbidden(match.group(1), "repository", forbidden)
_validate_repo(match.group(1))
if not isinstance(data, dict) or set(data) != {"base", "body", "head", "title"}:
raise PolicyError("draft creation accepts only base, body, head, and title")
if data["title"] != _draft_title(data["title"], forbidden=forbidden):
raise PolicyError("new pull requests must use the Gitea draft-title prefix")
_validate_body(data["body"], forbidden=forbidden)
_validate_ref_bounds(data["base"], "base", forbidden=forbidden)
_validate_ref_bounds(data["head"], "head", forbidden=forbidden)
_validate_ref(data["base"], "base", forbidden=forbidden)
_validate_ref(data["head"], "head", forbidden=forbidden)
return "create-draft"
def build_request(
method: str,
path: str,
*,
base_url: str,
token: str,
data: object | None = None,
) -> urllib.request.Request:
"""Build an authorized request without putting the token in its URL or body."""
authorize_request(method, path, data, forbidden=(token,))
return _build_request(method, path, base_url=base_url, token=token, data=data)
def _build_request(
method: str,
path: str,
*,
base_url: str,
token: str,
data: object | None = None,
) -> urllib.request.Request:
"""Build one already-authorized upstream request."""
payload = None
if data is not None:
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
return urllib.request.Request(
api_url(base_url, path),
data=payload,
method=method.upper(),
headers={
"Accept": "application/json",
"Authorization": f"token {token}",
"Content-Type": "application/json",
"User-Agent": "hermes-atlas-pr-client/2",
},
)
def redact_bytes(value: bytes, token: str) -> bytes:
"""Remove exact runtime-token and authorization-header values from output."""
redacted = value.replace(token.encode("utf-8"), b"[REDACTED]")
return re.sub(
rb"(?i)(authorization\s*[:=]\s*)(?:token|bearer)?\s*[^\s,;\"}]+",
rb"\1[REDACTED]",
redacted,
)
def _reject_response_credential(value: bytes, token: str) -> None:
"""Fail closed if an upstream body reflects any ordinary token encoding."""
raw = token.encode("utf-8")
forms = {
raw,
base64.b64encode(raw),
base64.b64encode(b"hermes-automation:" + raw),
urllib.parse.quote(token, safe="").encode("ascii"),
}
if any(form and form in value for form in forms):
raise PolicyError("Forgejo response contains runtime credential material")
def _request(
method: str,
path: str,
data: object | None,
*,
token: str,
opener: Callable[..., object] = _safe_urlopen,
authorized: bool = False,
expected_status: int,
) -> bytes:
builder = _build_request if authorized else build_request
request = builder(method, path, base_url=configured_base_url(), token=token, data=data)
with opener(request, timeout=30) as response: # type: ignore[attr-defined]
status = getattr(response, "status", None)
if status is None and hasattr(response, "getcode"):
status = response.getcode() # type: ignore[attr-defined]
if status != expected_status:
raise PolicyError("Forgejo returned an unexpected HTTP status")
if response.headers.get_content_type() != "application/json": # type: ignore[attr-defined]
raise PolicyError("Forgejo returned an unexpected response type")
body = response.read(MAX_RESPONSE_BYTES + 1) # type: ignore[attr-defined]
if len(body) > MAX_RESPONSE_BYTES:
raise PolicyError("Forgejo response exceeds the safe size limit")
_reject_response_credential(body, token)
return redact_bytes(body, token)
def read(
path: str, *, token: str, opener: Callable[..., object] = _safe_urlopen
) -> bytes:
"""Read one explicitly allowed Atlas repository metadata resource."""
return _request(
"GET", path, None, token=token, opener=opener, expected_status=200
)
def _nested(document: dict[str, object], *keys: str) -> object:
value: object = document
for key in keys:
if not isinstance(value, dict) or key not in value:
raise PolicyError("Forgejo omitted required pull-request evidence")
value = value[key]
return value
def _require_create_response(
value: bytes,
*,
repo: str,
base: str,
head: str,
head_sha: str,
title: str,
body: str,
) -> dict[str, object]:
"""Require exact server evidence for the intended open draft handoff."""
try:
document = json.loads(value)
except json.JSONDecodeError as exc:
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
if not isinstance(document, dict):
raise PolicyError("Forgejo returned invalid pull-request metadata")
number = _validate_pr_number(document.get("number"))
full_name = f"{ALLOWED_OWNER}/{repo}"
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
checks = (
document.get("state") == "open",
document.get("draft") is True,
document.get("merged") is False,
document.get("html_url") == expected_html,
document.get("url") == expected_html,
document.get("title") == title,
document.get("body") == body,
_nested(document, "base", "ref") == base,
_nested(document, "base", "repo", "full_name") == full_name,
_nested(document, "head", "ref") == head,
_nested(document, "head", "sha") == head_sha,
_nested(document, "head", "repo", "full_name") == full_name,
)
if not all(checks):
raise PolicyError("Forgejo did not confirm the exact draft pull-request state")
return document
def create_draft(
repo: str,
*,
base: str,
head: str,
head_sha: str,
title: str,
body: str,
token: str,
opener: Callable[..., object] = _safe_urlopen,
) -> bytes:
"""Create and verify a same-repository draft pull request for human review."""
_reject_forbidden(repo, "repository", (token,))
repo = _validate_repo(repo)
title = _draft_title(title, forbidden=(token,))
body = _validate_body(body, forbidden=(token,))
head_sha = _validate_sha(head_sha)
_validate_ref_bounds(base, "base", forbidden=(token,))
_validate_ref_bounds(head, "head", forbidden=(token,))
base = _validate_ref(base, "base", forbidden=(token,))
head = _validate_ref(head, "head", forbidden=(token,))
data = {"base": base, "body": body, "head": head, "title": title}
result = _request(
"POST",
f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls",
data,
token=token,
opener=opener,
authorized=True,
expected_status=201,
)
_require_create_response(
result,
repo=repo,
base=base,
head=head,
head_sha=head_sha,
title=title,
body=body,
)
return result
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the intentionally narrow read and create-draft operations."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="validate and describe the request without reading a token or using the network",
)
subparsers = parser.add_subparsers(dest="operation", required=True)
read_parser = subparsers.add_parser("read", help="read allowed Atlas metadata")
read_parser.add_argument("path")
create_parser = subparsers.add_parser(
"create-draft", help="create a same-repository draft pull request"
)
create_parser.add_argument("repo")
create_parser.add_argument("--base", required=True)
create_parser.add_argument("--head", required=True)
create_parser.add_argument("--head-sha", required=True)
create_parser.add_argument("--title", required=True)
create_parser.add_argument("--body", default="")
return parser.parse_args(argv)
def _operation(
args: argparse.Namespace, *, validate_refs: bool = True
) -> tuple[str, str, object | None]:
if args.operation == "read":
return "GET", args.path, None
repo = _validate_repo(args.repo)
_validate_sha(args.head_sha)
body = _validate_body(args.body)
title = _draft_title(args.title)
_validate_ref_bounds(args.base, "base")
_validate_ref_bounds(args.head, "head")
base = _validate_ref(args.base, "base") if validate_refs else args.base
head = _validate_ref(args.head, "head") if validate_refs else args.head
data = {
"base": base,
"body": body,
"head": head,
"title": title,
}
return "POST", f"/api/v1/repos/{ALLOWED_OWNER}/{repo}/pulls", data
def _write_body(body: bytes) -> None:
if body:
sys.stdout.buffer.write(body)
if not body.endswith(b"\n"):
sys.stdout.buffer.write(b"\n")
def main(argv: list[str] | None = None) -> int:
"""Validate, optionally describe, and execute one safe Forgejo operation."""
try:
args = parse_args(argv)
if args.dry_run:
method, path, data = _operation(args, validate_refs=False)
operation = authorize_request(method, path, data)
print(
json.dumps(
{
"dry_run": True,
"host": "scm.bstein.dev",
"operation": operation,
"owner": ALLOWED_OWNER,
"path": path,
"request_fields": sorted(data)
if isinstance(data, dict)
else [],
},
sort_keys=True,
)
)
return 0
method, path, data = _operation(args, validate_refs=False)
from scm_broker_client import create_draft as broker_create_draft
from scm_broker_client import read as broker_read
if args.operation == "read":
body = broker_read(path)
else:
assert isinstance(data, dict)
body = broker_create_draft(
args.repo,
base=str(data["base"]),
head=str(data["head"]),
head_sha=args.head_sha,
title=str(data["title"]),
body=str(data["body"]),
)
_write_body(body)
return 0
except urllib.error.HTTPError as exc:
exc.read(MAX_ERROR_BYTES)
print(f"SCM broker request failed with HTTP {exc.code}", file=sys.stderr)
return 1
except (OSError, PolicyError, ValueError, json.JSONDecodeError):
print(
"Forgejo request rejected or unavailable; no credential was disclosed",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())