212 lines
12 KiB
Python
212 lines
12 KiB
Python
"""Scoped task-branch grants and durable compare-and-swap ownership ledger."""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from gitea_api_policy import PolicyError, _validate_ref, _validate_repo, _validate_sha
|
|
|
|
MAX_GRANT_BYTES = 4096
|
|
MAX_GRANT_SECONDS = 300
|
|
MAX_ANCESTRY_STEPS = 1024
|
|
KEY_FILE = Path(os.environ.get("HERMES_SCM_TASK_GRANT_KEY_FILE", "/vault/secrets/scm-task-grant"))
|
|
LEDGER_PATH = Path(os.environ.get("HERMES_SCM_TASK_LEDGER", "/scm-state/task-branches.db"))
|
|
ADOPTIONS_PATH = Path(os.environ.get("HERMES_SCM_TASK_ADOPTIONS", "/scm-adoptions/task-branch-adoptions.json"))
|
|
REQUIRED = frozenset({"repo", "ref", "base", "board", "root_task_id", "assignment_task_id", "run", "ordinal", "expires", "expected_old", "new_head", "continuation_kind"})
|
|
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
|
|
ZERO_SHA = "0" * 40
|
|
|
|
|
|
def _key() -> bytes:
|
|
try:
|
|
value = KEY_FILE.read_text(encoding="ascii").strip()
|
|
except OSError as exc:
|
|
raise PolicyError("SCM task grant key is unavailable") from exc
|
|
if not re.fullmatch(r"[0-9a-f]{64}", value):
|
|
raise PolicyError("SCM task grant key is invalid")
|
|
return value.encode("ascii")
|
|
|
|
|
|
def _b64(value: bytes) -> str:
|
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _unb64(value: str) -> bytes:
|
|
if not isinstance(value, str) or len(value) > MAX_GRANT_BYTES or not value.isascii():
|
|
raise PolicyError("SCM task grant is invalid")
|
|
try:
|
|
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
except ValueError as exc:
|
|
raise PolicyError("SCM task grant is invalid") from exc
|
|
|
|
|
|
def sign_grant(claims: dict[str, Any], key: bytes | None = None) -> str:
|
|
"""Create a compact signed grant; mediator callers hold the key privately."""
|
|
if set(claims) != REQUIRED:
|
|
raise PolicyError("SCM task grant claims are invalid")
|
|
payload = json.dumps(claims, sort_keys=True, separators=(",", ":")).encode("ascii")
|
|
signature = hmac.new(key or _key(), payload, hashlib.sha256).digest()
|
|
return _b64(payload) + "." + _b64(signature)
|
|
|
|
|
|
def verify_grant(token: str, *, now: int | None = None, key: bytes | None = None) -> dict[str, Any]:
|
|
"""Verify one short-lived grant and normalize only safe repository fields."""
|
|
parts = token.split(".") if isinstance(token, str) else []
|
|
if len(parts) != 2:
|
|
raise PolicyError("SCM task grant is invalid")
|
|
payload, signature = _unb64(parts[0]), _unb64(parts[1])
|
|
if not hmac.compare_digest(hmac.new(key or _key(), payload, hashlib.sha256).digest(), signature):
|
|
raise PolicyError("SCM task grant signature is invalid")
|
|
try:
|
|
claims = json.loads(payload)
|
|
except (TypeError, ValueError) as exc:
|
|
raise PolicyError("SCM task grant is invalid") from exc
|
|
if not isinstance(claims, dict) or set(claims) != REQUIRED:
|
|
raise PolicyError("SCM task grant claims are invalid")
|
|
claims["repo"] = _validate_repo(claims["repo"])
|
|
claims["ref"] = _validate_ref(claims["ref"], "task branch")
|
|
claims["base"] = _validate_ref(claims["base"], "base branch")
|
|
claims["expected_old"] = _validate_sha(claims["expected_old"], "expected old SHA")
|
|
claims["new_head"] = _validate_sha(claims["new_head"], "new head SHA")
|
|
if not isinstance(claims["ordinal"], int) or isinstance(claims["ordinal"], bool):
|
|
raise PolicyError("SCM task grant ordinal is invalid")
|
|
if claims["continuation_kind"] not in {"", "repair", "review"}:
|
|
raise PolicyError("SCM task grant continuation is invalid")
|
|
expiry = claims["expires"]
|
|
current = int(time.time()) if now is None else now
|
|
if not isinstance(expiry, int) or not current < expiry <= current + MAX_GRANT_SECONDS:
|
|
raise PolicyError("SCM task grant is expired")
|
|
if not all(isinstance(claims[name], str) and IDENTIFIER.fullmatch(claims[name]) for name in ("board", "root_task_id", "assignment_task_id", "run")):
|
|
raise PolicyError("SCM task grant binding is invalid")
|
|
return claims
|
|
|
|
|
|
class TaskLedger:
|
|
"""Broker-owned branch ownership state; no silent claim of existing refs."""
|
|
def __init__(self, path: Path = LEDGER_PATH) -> None:
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
self.path = path
|
|
with self._connect() as con:
|
|
con.execute("create table if not exists task_branches (repo text not null, ref text not null, board text not null, root_task_id text not null, latest_head text not null, pr_number integer, primary key(repo, ref))")
|
|
con.execute("create table if not exists task_roots (repo text not null, board text not null, root_task_id text not null, ref text not null, primary key(repo,board,root_task_id))")
|
|
|
|
def _connect(self):
|
|
return sqlite3.connect(self.path, timeout=5, isolation_level="IMMEDIATE")
|
|
|
|
@staticmethod
|
|
def _owner(claims: dict[str, Any]) -> tuple[str, str]:
|
|
return claims["board"], claims["root_task_id"]
|
|
|
|
def get(self, repo: str, ref: str) -> tuple[str, str, str] | None:
|
|
with self._connect() as con:
|
|
row = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (repo, ref)).fetchone()
|
|
return tuple(row) if row is not None else None
|
|
|
|
@staticmethod
|
|
def _adoption(claims: dict[str, Any], remote_head: str | None) -> bool:
|
|
"""Accept only an operator-reviewed exact migration record.
|
|
|
|
The ConfigMap is mounted read-only and starts empty. A task grant can
|
|
consume a matching record but cannot create, edit, or broaden one.
|
|
"""
|
|
try:
|
|
raw = ADOPTIONS_PATH.read_bytes()
|
|
value = json.loads(raw)
|
|
except (OSError, ValueError, TypeError):
|
|
return False
|
|
if not isinstance(value, dict) or len(raw) > 64 * 1024:
|
|
return False
|
|
record = value.get(f"{claims['repo']}/{claims['ref']}")
|
|
if not isinstance(record, dict):
|
|
return False
|
|
return (
|
|
record.get("board") == claims["board"]
|
|
and record.get("root_task_id") == claims["root_task_id"]
|
|
and record.get("latest_head") == remote_head == claims["expected_old"]
|
|
)
|
|
|
|
def seed_adoption(self, record: dict[str, Any], remote_head: str | None) -> None:
|
|
"""Import one reviewed legacy branch only when Forgejo still matches it."""
|
|
required = {"repo", "ref", "board", "root_task_id", "latest_head", "pr_number"}
|
|
if set(record) != required or remote_head is None:
|
|
raise PolicyError("task branch adoption record is invalid")
|
|
repo = _validate_repo(record["repo"])
|
|
ref = _validate_ref(record["ref"], "task branch")
|
|
head = _validate_sha(record["latest_head"], "adoption head")
|
|
if not all(isinstance(record[name], str) and IDENTIFIER.fullmatch(record[name]) for name in ("board", "root_task_id")):
|
|
raise PolicyError("task branch adoption record is invalid")
|
|
if not isinstance(record["pr_number"], int) or isinstance(record["pr_number"], bool) or record["pr_number"] < 1:
|
|
raise PolicyError("task branch adoption pull request is invalid")
|
|
with self._connect() as con:
|
|
existing = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (repo, ref)).fetchone()
|
|
root = con.execute("select ref from task_roots where repo=? and board=? and root_task_id=?", (repo, record["board"], record["root_task_id"])).fetchone()
|
|
if existing is not None:
|
|
if tuple(existing[:2]) != (record["board"], record["root_task_id"]):
|
|
raise PolicyError("task branch adoption conflicts with ledger")
|
|
# A broker-confirmed later revision is legitimate. The static
|
|
# import record only proves the first seed, never rewrites it.
|
|
return
|
|
if head != remote_head:
|
|
raise PolicyError("task branch adoption does not match its live head")
|
|
if root is not None and root[0] != ref:
|
|
raise PolicyError("task branch adoption forks a logical task")
|
|
con.execute("insert into task_branches(repo,ref,board,root_task_id,latest_head,pr_number) values(?,?,?,?,?,?)", (repo, ref, record["board"], record["root_task_id"], head, record["pr_number"]))
|
|
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (repo, record["board"], record["root_task_id"], ref))
|
|
|
|
def register(self, claims: dict[str, Any], *, remote_head: str | None) -> None:
|
|
"""Create only absent refs; adoption is an explicit operator action."""
|
|
with self._connect() as con:
|
|
row = con.execute("select board,root_task_id,latest_head from task_branches where repo=? and ref=?", (claims["repo"], claims["ref"])).fetchone()
|
|
owner = self._owner(claims)
|
|
root = con.execute("select ref from task_roots where repo=? and board=? and root_task_id=?", (claims["repo"], *owner)).fetchone()
|
|
if root is not None and root[0] != claims["ref"]:
|
|
raise PolicyError("logical task already owns a different branch")
|
|
if row is not None and tuple(row[:2]) != owner:
|
|
raise PolicyError("task branch is owned by another task")
|
|
if row is None:
|
|
if remote_head is not None:
|
|
if not self._adoption(claims, remote_head):
|
|
raise PolicyError("existing unregistered task branch requires operator adoption")
|
|
con.execute("insert into task_branches(repo,ref,board,root_task_id,latest_head) values(?,?,?,?,?)", (claims["repo"], claims["ref"], owner[0], owner[1], remote_head))
|
|
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (claims["repo"], *owner, claims["ref"]))
|
|
return
|
|
if claims["expected_old"] != ZERO_SHA:
|
|
raise PolicyError("new task branch must use the zero expected head")
|
|
con.execute(
|
|
"insert into task_branches(repo,ref,board,root_task_id,latest_head) values(?,?,?,?,?)",
|
|
(claims["repo"], claims["ref"], owner[0], owner[1], ZERO_SHA),
|
|
)
|
|
con.execute("insert into task_roots(repo,board,root_task_id,ref) values(?,?,?,?)", (claims["repo"], *owner, claims["ref"]))
|
|
elif remote_head != row[2]:
|
|
raise PolicyError("task branch head changed; fetch and merge before retrying")
|
|
|
|
def authorize_update(self, claims: dict[str, Any]) -> None:
|
|
row = self.get(claims["repo"], claims["ref"])
|
|
if row is None:
|
|
raise PolicyError("task branch is not registered")
|
|
if row[:2] != self._owner(claims):
|
|
raise PolicyError("task branch is owned by another task")
|
|
if row[2] != claims["expected_old"]:
|
|
raise PolicyError("task branch head changed; fetch and merge before retrying")
|
|
|
|
def authorize_current(self, claims: dict[str, Any]) -> None:
|
|
"""Authorize post-push PR prose only at the broker-confirmed new head."""
|
|
row = self.get(claims["repo"], claims["ref"])
|
|
if row is None or row[:2] != self._owner(claims) or row[2] != claims["new_head"]:
|
|
raise PolicyError("task branch head changed; fetch and merge before retrying")
|
|
|
|
def commit(self, claims: dict[str, Any]) -> None:
|
|
self.authorize_update(claims)
|
|
with self._connect() as con:
|
|
changed = con.execute("update task_branches set latest_head=? where repo=? and ref=? and latest_head=?", (claims["new_head"], claims["repo"], claims["ref"], claims["expected_old"])).rowcount
|
|
if changed != 1:
|
|
raise PolicyError("task branch changed during broker update")
|