fix(workflow): fail-closed key version, strict incident evidence, archive-gated clear (#709)
Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8). F6 — HMAC key-version validation fails closed: - verify_authorization_artifact validates key_version BEFORE any MAC work, so an attacker-chosen version can never select the signing key. - Require exactly one nonempty, well-formed key-version field; missing, empty, unknown, malformed, duplicated (including identical-valued and nested aliases), and mismatched versions all fail. No versionless legacy fallback. - Artifact version must equal the configured active version; production now requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit default made rotation ambiguous). Version stays inside the signed material. F7 — strictly canonical incident evidence: - Replace substring/first-match parsing with an exact schema: marker on line 1, every field once, fixed order, no duplicate/unknown/empty/conflicting fields in or outside the signed block. The parsed body is re-rendered and compared for exact equality before acceptance. - content_digest now binds the full recovery scope: repository identity, PR, decision-lock identity, destroyed subject, recovery action, recorded and expected head, incident issue, evidence author, minting actor, key version, nonce and issued_at. - Actor identity is the immutable user id with login consistency; conflicting ids/logins and display-name-only identities fail closed. Edited comments are rejected. The independent-author rule is preserved and enforced by stable id. - build_canonical_incident_body is the single source of the accepted format and refuses to emit ambiguous evidence. F8 — archival is a prerequisite for clearing terminal evidence: - _clear_decision_lock_for_profile no longer swallows archive failures. It requires a successful write plus a durable read-back matching the PR/head, and otherwise returns a structured, retry-safe failure that retains the lock and records actionable recovery evidence. - Fixes a latent bug the read-back exposed: the archive payload inherited the source lock's session_profile_lock, so save_state keyed the archive under the reviewer profile instead of the archive identity and it never read back. Adversarial regressions added for every listed case: key-version missing/empty/ unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields, duplicate identical and conflicting fields, conflicting actor ids/names, digest-preserving substitution, decision-lock and recovery-action substitution, cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/ partial-readback with proof the lock survives, exactly-one permitted clear, and retry after archive failure. No existing assertion was weakened. Validation: focused 150 passed in three module orders; full tests/ 2809 passed, 6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in tests/test_webui_audit.py:8), 161 subtests passed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+167
-17
@@ -1824,6 +1824,8 @@ _UNSET = object()
|
||||
# Best-effort identity cache keyed by host, so an enabled audit trail resolves
|
||||
# the authenticated username at most once per host per process.
|
||||
_IDENTITY_CACHE: dict = {}
|
||||
# Stable actor identity (id + login) for recovery evidence binding (#709 F7).
|
||||
_ACTOR_IDENTITY_CACHE: dict = {}
|
||||
|
||||
|
||||
def _authenticated_username(host: str):
|
||||
@@ -1846,6 +1848,33 @@ def _authenticated_username(host: str):
|
||||
return user
|
||||
|
||||
|
||||
def _authenticated_actor(host: str) -> dict:
|
||||
"""Resolve the authenticated actor's stable identity (#709 F7 review 438).
|
||||
|
||||
Display names are mutable, so recovery evidence is bound to the immutable
|
||||
numeric user id with the login carried alongside for consistency checks.
|
||||
Read-only and fail-soft; never surfaces credential material.
|
||||
"""
|
||||
cached = _ACTOR_IDENTITY_CACHE.get(host)
|
||||
if cached is not None:
|
||||
return dict(cached)
|
||||
actor: dict = {"user_id": None, "login": None}
|
||||
try:
|
||||
header = get_auth_header(host)
|
||||
if header:
|
||||
who = api_request("GET", gitea_url(host, "/api/v1/user"), header)
|
||||
if isinstance(who, dict):
|
||||
raw_id = who.get("id")
|
||||
actor = {
|
||||
"user_id": int(raw_id) if isinstance(raw_id, int) else None,
|
||||
"login": (who.get("login") or None),
|
||||
}
|
||||
except Exception:
|
||||
actor = {"user_id": None, "login": None}
|
||||
_ACTOR_IDENTITY_CACHE[host] = dict(actor)
|
||||
return dict(actor)
|
||||
|
||||
|
||||
def _ensure_matching_profile(required_permission: str, required_role: str, remote: str | None, host: str | None = None) -> str | None:
|
||||
"""Check if the active profile is allowed to perform *required_permission*.
|
||||
If not, automatically switch to the first matching usable configured profile.
|
||||
@@ -1891,6 +1920,7 @@ def _ensure_matching_profile(required_permission: str, required_role: str, remot
|
||||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||||
if h:
|
||||
_IDENTITY_CACHE.pop(h, None)
|
||||
_ACTOR_IDENTITY_CACHE.pop(h, None)
|
||||
username = _authenticated_username(h) if h else None
|
||||
# Update mutation authority
|
||||
global _MUTATION_AUTHORITY
|
||||
@@ -4658,28 +4688,108 @@ def _clear_decision_lock_for_profile(
|
||||
),
|
||||
}
|
||||
|
||||
# Archive then clear — only after exact identity validation.
|
||||
# Archive then clear — archival is a hard prerequisite (#709 F8 review 438).
|
||||
# A failed, empty, or unconfirmed archive must never be followed by a clear:
|
||||
# that is exactly the terminal-evidence destruction #709 exists to prevent.
|
||||
archive_identity = f"{profile_identity}-archive-pr{pr_number}"
|
||||
archive_payload = {
|
||||
**dict(lock),
|
||||
"archived_reason": "post_merge_cross_profile_cleanup",
|
||||
"archived_for_pr": pr_number,
|
||||
"archived_for_head": want_head,
|
||||
"archived_remote": remote,
|
||||
"archived_org": org,
|
||||
"archived_repo": repo,
|
||||
"recovery_critical": True,
|
||||
"kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
# The copied lock carries the *source* profile's identity. Leaving it in
|
||||
# place makes save_state key the archive under that profile instead of
|
||||
# the archive identity, so the archive would silently overwrite/land
|
||||
# elsewhere and never read back (#709 F8 review 438).
|
||||
"profile_identity": archive_identity,
|
||||
"session_profile_lock": archive_identity,
|
||||
"archived_from_profile_identity": profile_identity,
|
||||
}
|
||||
|
||||
def _archive_failure(step: str, detail: str) -> dict:
|
||||
"""Retain the terminal lock and report an actionable, retryable failure."""
|
||||
try:
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=int(pr_number),
|
||||
head_sha=want_head,
|
||||
merge_commit_sha=None,
|
||||
target_profile_identity=profile_identity,
|
||||
failed_step=step,
|
||||
error=detail,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
detail = f"{detail}; recovery record write also failed: {_redact(str(exc))}"
|
||||
return {
|
||||
"profile_identity": profile_identity,
|
||||
"cleared": False,
|
||||
"archive_ok": False,
|
||||
"archive_failed_step": step,
|
||||
"archive_identity": archive_identity,
|
||||
"terminal_lock_retained": True,
|
||||
"recovery_required": True,
|
||||
"retry_safe": True,
|
||||
"reason": (
|
||||
f"decision-lock archival failed at {step}: {detail}. Terminal "
|
||||
"evidence retained and NOT cleared; resolve the archive failure "
|
||||
"and retry this cleanup (fail closed, #709 F8 review 438)"
|
||||
),
|
||||
"prior_summary": stale_review_decision_lock.lock_summary(lock),
|
||||
}
|
||||
|
||||
try:
|
||||
mcp_session_state.save_state(
|
||||
archived = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
payload={
|
||||
**dict(lock),
|
||||
"archived_reason": "post_merge_cross_profile_cleanup",
|
||||
"archived_for_pr": pr_number,
|
||||
"archived_for_head": want_head,
|
||||
"archived_remote": remote,
|
||||
"archived_org": org,
|
||||
"archived_repo": repo,
|
||||
"recovery_critical": True,
|
||||
"kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
},
|
||||
payload=archive_payload,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=f"{profile_identity}-archive-pr{pr_number}",
|
||||
profile_identity=archive_identity,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _archive_failure("archive_save_state", _redact(str(exc)))
|
||||
if not archived:
|
||||
return _archive_failure(
|
||||
"archive_save_state",
|
||||
"save_state returned no durable archive record (false/empty result)",
|
||||
)
|
||||
|
||||
# Durable read-back: a write that cannot be re-read is not an archive.
|
||||
try:
|
||||
confirmed = mcp_session_state.load_state_for_profile(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
profile_identity=archive_identity,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
skip_identity_match=True,
|
||||
enforce_repo_scope=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _archive_failure("archive_readback", _redact(str(exc)))
|
||||
if not confirmed:
|
||||
return _archive_failure(
|
||||
"archive_readback",
|
||||
"archive record could not be read back from durable session state",
|
||||
)
|
||||
if int(confirmed.get("archived_for_pr") or -1) != int(
|
||||
pr_number
|
||||
) or not stale_review_decision_lock.heads_equal(
|
||||
confirmed.get("archived_for_head"), want_head
|
||||
):
|
||||
return _archive_failure(
|
||||
"archive_readback",
|
||||
"archive read-back does not match the PR/head being cleaned "
|
||||
"(partial or stale archive)",
|
||||
)
|
||||
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=profile_identity,
|
||||
@@ -4695,9 +4805,11 @@ def _clear_decision_lock_for_profile(
|
||||
return {
|
||||
"profile_identity": profile_identity,
|
||||
"cleared": True,
|
||||
"archive_ok": True,
|
||||
"archive_identity": archive_identity,
|
||||
"reason": (
|
||||
f"cleared terminal lock for merged PR #{pr_number} "
|
||||
f"at head {want_head[:12]}… (exact-scope)"
|
||||
f"at head {want_head[:12]}… (exact-scope; durable archive confirmed)"
|
||||
),
|
||||
"prior_summary": stale_review_decision_lock.lock_summary(lock),
|
||||
}
|
||||
@@ -5133,8 +5245,10 @@ def gitea_issue_irrecoverable_provenance_authorization(
|
||||
expected_head_sha: str,
|
||||
incident_issue: int,
|
||||
incident_comment_id: int,
|
||||
decision_lock_id: str = "",
|
||||
confirmation: str = "",
|
||||
destroyed_subject: str | None = None,
|
||||
recorded_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
@@ -5186,6 +5300,13 @@ def gitea_issue_irrecoverable_provenance_authorization(
|
||||
)
|
||||
return report
|
||||
|
||||
if not (decision_lock_id or "").strip():
|
||||
report["reasons"].append(
|
||||
"decision_lock_id is required so evidence is bound to the exact "
|
||||
"decision lock being recovered (fail closed, #709 F7 review 438)"
|
||||
)
|
||||
return report
|
||||
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
@@ -5195,6 +5316,13 @@ def gitea_issue_irrecoverable_provenance_authorization(
|
||||
"authenticated identity could not be verified (fail closed)"
|
||||
)
|
||||
return report
|
||||
actor_identity = _authenticated_actor(h)
|
||||
if actor_identity.get("user_id") is None:
|
||||
report["reasons"].append(
|
||||
"authenticated actor id could not be verified; display-name-only "
|
||||
"identity is not accepted (fail closed, #709 F7 review 438)"
|
||||
)
|
||||
return report
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
|
||||
@@ -5237,6 +5365,11 @@ def gitea_issue_irrecoverable_provenance_authorization(
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
comment_err = _redact(str(exc))
|
||||
try:
|
||||
active_key_version = irp.auth_key_version()
|
||||
except irp.AuthSecretError as exc:
|
||||
report["reasons"].append(str(exc))
|
||||
return report
|
||||
incident_gate = irp.assess_incident_evidence(
|
||||
incident_issue=incident_issue,
|
||||
incident_comment_id=incident_comment_id,
|
||||
@@ -5247,6 +5380,11 @@ def gitea_issue_irrecoverable_provenance_authorization(
|
||||
expected_repo=r,
|
||||
expected_pr_number=pr_number,
|
||||
expected_head_sha=str(expected_head_sha),
|
||||
expected_recorded_head_sha=recorded_head_sha,
|
||||
expected_decision_lock_id=decision_lock_id.strip(),
|
||||
expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
|
||||
expected_key_version=active_key_version,
|
||||
mint_actor_id=actor_identity.get("user_id"),
|
||||
mint_actor_username=actor,
|
||||
reject_self_authored=True,
|
||||
)
|
||||
@@ -5338,6 +5476,7 @@ def gitea_record_irrecoverable_decision_lock_provenance(
|
||||
incident_issue: int | None = None,
|
||||
incident_comment_id: int | None = None,
|
||||
authorization_id: str | None = None,
|
||||
decision_lock_id: str = "",
|
||||
destroyed_subject: str | None = None,
|
||||
incident_ref: str | None = None,
|
||||
# Deprecated: retained so callers that still pass it get an explicit deny.
|
||||
@@ -5477,6 +5616,11 @@ def gitea_record_irrecoverable_decision_lock_provenance(
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
comment_err = _redact(str(exc))
|
||||
try:
|
||||
active_key_version = irp.auth_key_version()
|
||||
except irp.AuthSecretError as exc:
|
||||
report["reasons"].append(str(exc))
|
||||
return report
|
||||
incident_gate = irp.assess_incident_evidence(
|
||||
incident_issue=incident_issue,
|
||||
incident_comment_id=incident_comment_id,
|
||||
@@ -5487,6 +5631,10 @@ def gitea_record_irrecoverable_decision_lock_provenance(
|
||||
expected_repo=r,
|
||||
expected_pr_number=pr_number,
|
||||
expected_head_sha=str(expected_head_sha),
|
||||
expected_decision_lock_id=(decision_lock_id or "").strip() or None,
|
||||
expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
|
||||
expected_key_version=active_key_version,
|
||||
mint_actor_id=_authenticated_actor(h).get("user_id"),
|
||||
mint_actor_username=actor,
|
||||
reject_self_authored=True,
|
||||
)
|
||||
@@ -8869,6 +9017,7 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
|
||||
if tok:
|
||||
gitea_config._active_profile_override = p_name
|
||||
_IDENTITY_CACHE.clear()
|
||||
_ACTOR_IDENTITY_CACHE.clear()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -11989,6 +12138,7 @@ def gitea_activate_profile(
|
||||
# 3. Clear identity cache to force a fresh verification
|
||||
if h:
|
||||
_IDENTITY_CACHE.pop(h, None)
|
||||
_ACTOR_IDENTITY_CACHE.pop(h, None)
|
||||
|
||||
# 4. Resolve fresh identity
|
||||
after_profile = get_profile()["profile_name"]
|
||||
|
||||
Reference in New Issue
Block a user