gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released` lease marker — a durable mutation of the PR lease ledger — but had no entry in the canonical task-capability map and no role binding. Entry gated on gitea.read, apply gated only on gitea.pr.comment, so any profile holding the comment permission (author, reviewer, merger) reached the mutation path, while the reconciler could not satisfy the operator-required resolve-exact-task -> mutation sequence because no cleanup task was resolvable at all. The apply path also called verify_preflight_purity(remote) with no task/org/repo, skipping the resolved-task, canonical-root and explicit-target checks its siblings perform, and passed request org/repo straight into _resolve. Capability map and router: - Map `cleanup_post_merge_moot_lease` and the tool-name alias `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler. Both names carry an identical contract; unknown names keep failing closed on the map's KeyError. - Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so the map and the router cannot disagree (the #723 defect-A class). Tool enforcement (apply path only): - Require the session to have resolved exactly the cleanup task; resolving a different task, including a sibling reconciler task, does not authorize it. - Require the reconciler role, checked independently of the permission gate. - Require gitea.pr.comment. - Validate explicit org/repo against the canonical repository identity derived from the session binding, so request parameters can never redirect the mutation, and forward worktree_path/task/org/repo to verify_preflight_purity for canonical-root, workspace and anti-stomp binding (#733/#739). - Require matching dry-run evidence proving lease_moot and cleanup_allowed for the same PR, lease session, candidate head and lease marker id, with optional caller expectations checked against the live lease. New post_merge_moot_lease_gate module holds the pure authorization logic and an append-only dry-run ledger: entries are only ever appended, lookup is newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded, mismatched, malformed and foreign-repository leases all fail closed; already-terminal cleanup stays idempotent. The read-only apply=false assessment deliberately stays reachable under gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a stuck lease from any attached namespace. This choice is documented in the map, the tool docstring and docs/gitea-execution-profiles.md, and is directly tested. _delete_branch_repository_binding_block is generalized into _repository_binding_block(required_permission=...) and retained as a thin delete-path alias so #733/#739 coverage keeps exercising its permission label. Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35 subtests) covers the map/router contract, alias parity, unknown-name rejection, role and task gates, dry-run/apply sequencing, superseded and malformed leases, repository binding, ledger append-only behavior, idempotency, and asserts no production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py is updated from the permission-only model to reconciler + dry-run evidence, with a new negative test pinning that a merger can no longer apply. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""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,
|
|
}
|