"""Reconciler authorization gate for post-merge moot-lease cleanup (#745). ``gitea_cleanup_post_merge_moot_lease`` (#515) posts a terminal ``phase: released`` lease marker — a real, durable mutation of the PR lease ledger. Before #745 it was gated on permissions alone (``gitea.read`` to enter, ``gitea.pr.comment`` to apply) with no canonical task and no role binding, so any profile carrying ``gitea.pr.comment`` reached the mutation path while the reconciler could not satisfy the operator-required resolve-exact-task -> mutation sequence. This module holds the pure half of that gate: * the canonical task name and its tool-name alias; * an **append-only** in-process ledger of read-only dry-run assessments; * ``assess_apply_authorization``, which decides whether an apply may proceed. Apply is authorized only when all of the following hold: * the session resolved exactly the cleanup task (no other task substitutes); * the active profile role is ``reconciler``; * a prior dry run in this session recorded ``lease_moot`` and ``cleanup_allowed`` for the *same* repository, PR, lease session, candidate head and lease marker id; * the live assessment still agrees with that evidence, so a lease superseded between the dry run and the apply fails closed; * any caller-supplied expectations match the live lease exactly. Everything else fails closed. The ledger is only ever appended to — a superseded dry run stays visible as history instead of being rewritten — which keeps the cleanup audit trail append-only end to end. """ from __future__ import annotations from datetime import datetime, timezone from typing import Any CLEANUP_TASK = "cleanup_post_merge_moot_lease" CLEANUP_TOOL_ALIAS = "gitea_cleanup_post_merge_moot_lease" REQUIRED_ROLE = "reconciler" REQUIRED_PERMISSION = "gitea.pr.comment" # The read-only assessment stays reachable under gitea.read for every role — # the convention shared with cleanup_stale_review_decision_lock and # cleanup_obsolete_reviewer_comment_lease — so any namespace can diagnose a # stuck lease. Only the apply path demands CLEANUP_TASK + REQUIRED_ROLE. ASSESSMENT_PERMISSION = "gitea.read" _DRY_RUN_LEDGER: list[dict[str, Any]] = [] def _norm(value: Any) -> str: return str(value or "").strip() def _norm_comment_id(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): return None def record_dry_run( *, pr_number: int, repository_slug: str | None, lease_moot: bool, cleanup_allowed: bool, session_id: str | None, candidate_head: str | None, lease_comment_id: Any, recorded_at: datetime | None = None, ) -> dict[str, Any]: """Append one read-only assessment to the dry-run ledger. Never rewrites or removes a prior entry: repeated dry runs accumulate and ``latest_dry_run`` returns the newest matching one. """ entry = { "task": CLEANUP_TASK, "pr_number": int(pr_number), "repository_slug": _norm(repository_slug) or None, "lease_moot": bool(lease_moot), "cleanup_allowed": bool(cleanup_allowed), "session_id": _norm(session_id) or None, "candidate_head": _norm(candidate_head) or None, "lease_comment_id": _norm_comment_id(lease_comment_id), "recorded_at": (recorded_at or datetime.now(timezone.utc)).isoformat(), } _DRY_RUN_LEDGER.append(entry) return dict(entry) def dry_run_history() -> tuple[dict[str, Any], ...]: """Immutable view of every recorded dry run, oldest first.""" return tuple(dict(entry) for entry in _DRY_RUN_LEDGER) def latest_dry_run( *, pr_number: int, repository_slug: str | None ) -> dict[str, Any] | None: """Newest dry-run evidence for this repository + PR, or None.""" wanted_repo = _norm(repository_slug) for entry in reversed(_DRY_RUN_LEDGER): if entry["pr_number"] != int(pr_number): continue if _norm(entry.get("repository_slug")) != wanted_repo: continue return dict(entry) return None def _reset_for_testing() -> None: """Drop ledger state between tests. Never called by production paths.""" _DRY_RUN_LEDGER.clear() def assess_apply_authorization( *, pr_number: int, repository_slug: str | None, resolved_task: str | None, active_role_kind: str | None, assessment: dict[str, Any], evidence: dict[str, Any] | None, expected_session_id: str | None = None, expected_candidate_head: str | None = None, expected_lease_comment_id: Any = None, ) -> dict[str, Any]: """Decide whether a moot-lease cleanup apply is authorized (fail closed). Returns ``{"allowed", "reasons", "blocker_kind", "evidence_matched", ...}``. ``allowed`` is True only when every check passes; each failure contributes a reason so the caller can report all of them together. """ reasons: list[str] = [] blocker_kind: str | None = None def _block(kind: str, reason: str) -> None: nonlocal blocker_kind reasons.append(reason) if blocker_kind is None: blocker_kind = kind # 1. Exact resolved cleanup task. Resolving any other task — including a # sibling reconciler task — does not authorize this mutation. if _norm(resolved_task) != CLEANUP_TASK: _block( "unresolved_cleanup_task", "post-merge moot-lease cleanup requires the session to resolve " f"task '{CLEANUP_TASK}' immediately before apply; resolved task is " f"{resolved_task!r} (fail closed)", ) # 2. Dedicated reconciler role, enforced independently of the permission. if _norm(active_role_kind) != REQUIRED_ROLE: _block( "wrong_role", f"profile role {active_role_kind!r} cannot apply post-merge " f"moot-lease cleanup; required role is {REQUIRED_ROLE} even when " f"{REQUIRED_PERMISSION} is present (fail closed)", ) # 3. Canonical repository identity must be established, never inferred from # request parameters. if not _norm(repository_slug): _block( "repository_binding", "no canonical repository identity could be established for the " "cleanup target (fail closed)", ) # 4. The live safety assessment must still say the lease is moot/cleanable. if not assessment.get("is_moot") or not assessment.get("cleanup_allowed"): _block( "lease_not_moot", "live assessment does not report a moot, cleanable lease on PR " f"#{pr_number} (lease_moot={bool(assessment.get('is_moot'))}, " f"cleanup_allowed={bool(assessment.get('cleanup_allowed'))}) " "(fail closed)", ) live = assessment.get("active_lease") or {} live_session = _norm(live.get("session_id")) live_head = _norm(live.get("candidate_head")) live_comment_id = _norm_comment_id(live.get("comment_id")) # 5. A lease missing identifying fields is malformed and unsafe to act on. if not live_session or not live_head or live_comment_id is None: _block( "malformed_lease", "active lease is malformed: session_id / candidate_head / " "comment_id must all be present to authorize cleanup " f"(session_id={live.get('session_id')!r}, " f"candidate_head={live.get('candidate_head')!r}, " f"comment_id={live.get('comment_id')!r}) (fail closed)", ) # 6. Caller expectations, when supplied, must match the live lease exactly. if expected_session_id is not None and _norm(expected_session_id) != live_session: _block( "lease_mismatch", f"expected lease session {expected_session_id!r} does not match the " f"live lease session {live.get('session_id')!r} (fail closed)", ) if ( expected_candidate_head is not None and _norm(expected_candidate_head) != live_head ): _block( "lease_mismatch", f"expected candidate head {expected_candidate_head!r} does not " f"match the live lease head {live.get('candidate_head')!r} " "(fail closed)", ) if expected_lease_comment_id is not None and ( _norm_comment_id(expected_lease_comment_id) != live_comment_id ): _block( "lease_mismatch", f"expected lease marker {expected_lease_comment_id!r} does not " f"match the live lease marker {live.get('comment_id')!r} " "(fail closed)", ) # 7. Matching dry-run evidence recorded earlier in this session. evidence_matched = False if evidence is None: _block( "missing_dry_run_evidence", "no read-only dry run recorded for this repository and PR; run the " "tool with apply=false and confirm lease_moot / cleanup_allowed " "before applying (fail closed)", ) elif not evidence.get("lease_moot") or not evidence.get("cleanup_allowed"): _block( "dry_run_not_allowed", "recorded dry run did not report an allowed cleanup " f"(lease_moot={bool(evidence.get('lease_moot'))}, " f"cleanup_allowed={bool(evidence.get('cleanup_allowed'))}) " "(fail closed)", ) elif int(evidence.get("pr_number") or -1) != int(pr_number) or _norm( evidence.get("repository_slug") ) != _norm(repository_slug): _block( "dry_run_mismatch", "recorded dry run targets a different repository or PR " f"({evidence.get('repository_slug')}#{evidence.get('pr_number')} vs " f"{repository_slug}#{pr_number}) (fail closed)", ) elif ( _norm(evidence.get("session_id")) != live_session or _norm(evidence.get("candidate_head")) != live_head or _norm_comment_id(evidence.get("lease_comment_id")) != live_comment_id ): _block( "superseded_lease", "the lease changed after the recorded dry run (dry run: " f"session={evidence.get('session_id')!r}, " f"head={evidence.get('candidate_head')!r}, " f"marker={evidence.get('lease_comment_id')!r}; live: " f"session={live.get('session_id')!r}, " f"head={live.get('candidate_head')!r}, " f"marker={live.get('comment_id')!r}); re-run the dry run " "(fail closed)", ) else: evidence_matched = True return { "allowed": not reasons, "reasons": reasons, "blocker_kind": blocker_kind, "evidence_matched": evidence_matched, "required_task": CLEANUP_TASK, "required_role_kind": REQUIRED_ROLE, "required_permission": REQUIRED_PERMISSION, }