Files
awoooi/scripts/security/public-gateway-owner-response-acceptance.py
Your Name 9b8ca2c509
All checks were successful
Code Review / ai-code-review (push) Successful in 24s
CD Pipeline / tests (push) Successful in 1m46s
CD Pipeline / build-and-deploy (push) Successful in 6m27s
CD Pipeline / post-deploy-checks (push) Successful in 2m59s
feat(iwooos): 強化 public gateway 緊急變更回補
2026-06-15 14:06:23 +08:00

487 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS Public Gateway owner response acceptance 只讀帳本產生器。
本工具讀取 Public Gateway live conf export、redacted export intake 與 rendered
diff gate 草稿,建立未來 owner response 如何收件、補證、隔離、拒收或進
reviewer review 的 metadata-only acceptance ledger。它不讀 live conf、不執行
nginx -t、不 reload、不做 route smoke、不連 DNS / TLS、不 renew cert。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
ACCEPTANCE_FIELDS = [
"acceptance_candidate_id",
"diff_gate_id",
"intake_id",
"export_request_id",
"config_id",
"control_tier",
"host",
"live_path",
"owner_response_ref",
"owner_role_or_team",
"decision",
"decision_reason",
"affected_routes",
"redacted_live_conf_ref",
"source_to_live_rendered_diff_ref",
"nginx_test_plan_ref",
"route_smoke_plan_ref",
"maintenance_window",
"rollback_owner",
"postcheck_plan",
"change_actor_or_source_ref",
"change_time_window",
"cross_project_impact_ref",
"communication_sync_ref",
"change_intent_or_ticket_ref",
"pre_change_approval_ref",
"break_glass_reason_ref",
"route_health_impact_ref",
"rollback_validation_ref",
"post_change_monitoring_window",
"reviewer_outcome",
"followup_owner",
"not_approval",
]
REQUIRED_OWNER_RESPONSE_FIELDS = [
"owner_role_or_team",
"decision",
"decision_reason",
"affected_routes",
"redacted_live_conf_ref",
"source_to_live_rendered_diff_ref",
"nginx_test_plan_ref",
"route_smoke_plan_ref",
"maintenance_window",
"rollback_owner",
"postcheck_plan",
"change_actor_or_source_ref",
"change_time_window",
"cross_project_impact_ref",
"communication_sync_ref",
"change_intent_or_ticket_ref",
"pre_change_approval_ref",
"break_glass_reason_ref",
"route_health_impact_ref",
"rollback_validation_ref",
"post_change_monitoring_window",
"followup_owner",
]
REVIEWER_CHECKS = [
{
"check_id": "owner_identity_present",
"instruction": "owner role / team 必須可追溯,不能只寫個人暱稱或聊天同意。",
},
{
"check_id": "decision_reason_present",
"instruction": "decision 與 decision reason 必須同時存在,且不得包含機敏值。",
},
{
"check_id": "redacted_refs_only",
"instruction": "evidence 只能是脫敏 ref、hash、ticket、commit 或 artifact pointer。",
},
{
"check_id": "raw_conf_absent",
"instruction": "不得出現 raw live Nginx conf、完整 upstream payload 或未遮蔽 host secret。",
},
{
"check_id": "secret_value_absent",
"instruction": "不得出現 token、cookie、private key、完整憑證內容或 secret derivative。",
},
{
"check_id": "route_scope_matches_snapshot",
"instruction": "affected routes 必須能對回 committed public gateway snapshot 的 config_id。",
},
{
"check_id": "rendered_diff_ref_not_payload",
"instruction": "rendered diff 只能是 ref不得把 diff payload 直接貼進 owner response。",
},
{
"check_id": "nginx_test_separate_approval",
"instruction": "`nginx -t` 只能作為後續人工批准包,不得由 owner response 自動觸發。",
},
{
"check_id": "route_smoke_plan_present",
"instruction": "route smoke plan 必須列 affected routes、預期 status、TLS / WebSocket / ACME checks。",
},
{
"check_id": "maintenance_window_present",
"instruction": "任何未來變更都必須有維護窗口或明確禁止窗口。",
},
{
"check_id": "rollback_owner_present",
"instruction": "rollback owner 與 rollback ref 必須存在,且不可指向 raw secret。",
},
{
"check_id": "change_actor_or_source_ref_present",
"instruction": "若曾有手動或緊急變更,必須提供脫敏 actor / source ref不得要求個人帳密或 raw shell history。",
},
{
"check_id": "change_time_window_present",
"instruction": "必須提供變更或事故時間窗,讓 route smoke、告警與跨專案影響可對齊。",
},
{
"check_id": "cross_project_impact_review_present",
"instruction": "必須提供跨產品 / 跨專案影響 ref避免只修單一路由卻讓其他服務斷線。",
},
{
"check_id": "communication_sync_ref_present",
"instruction": "必須提供通知或協調 ref證明相關專案 owner 已收到影響與回復狀態。",
},
{
"check_id": "change_intent_or_ticket_ref_present",
"instruction": "每次 Public Gateway / Nginx 變更都必須有 change intent、ticket、incident 或 maintenance ref不接受口頭或聊天截圖當唯一依據。",
},
{
"check_id": "pre_change_approval_or_break_glass_present",
"instruction": "正常變更需有 pre-change approval ref緊急變更需有 break-glass reason ref且不得把 break-glass 當成事前批准。",
},
{
"check_id": "route_health_impact_present",
"instruction": "必須提供 route health / service health impact ref確認受影響 domain、upstream、WebSocket、ACME 與 API 是否恢復。",
},
{
"check_id": "rollback_validation_present",
"instruction": "必須提供 rollback validation ref證明回滾路徑、回滾 owner 與回滾後驗證方式可追溯。",
},
{
"check_id": "post_change_monitoring_window_present",
"instruction": "必須提供 post-change monitoring window讓告警、route smoke 與跨專案回復狀態能對齊。",
},
{
"check_id": "manual_change_not_silent",
"instruction": "任何手動或緊急 gateway 變更不得靜默收件;若缺 actor、意圖、影響、通知或回滾驗證必須走補件或拒收。",
},
{
"check_id": "counts_transition_safe",
"instruction": "只有 reviewer record 可更新 received / accepted / rejected不得同時開 runtime gate。",
},
]
OUTCOME_LANES = [
{
"lane_id": "waiting_owner_response",
"meaning": "尚未收到 owner response所有 accepted / runtime count 維持 0。",
},
{
"lane_id": "quarantine_raw_payload",
"meaning": "收到 raw conf、payload 或不可保存內容時,只能隔離,不得進 reviewer review。",
},
{
"lane_id": "reject_secret_or_unredacted_conf",
"meaning": "出現 secret value、未脫敏 live conf 或 credential derivative 時直接拒收。",
},
{
"lane_id": "request_supplement",
"meaning": "欄位不足、scope 不清或 evidence ref 不可追溯時要求補件。",
},
{
"lane_id": "ready_for_rendered_diff_review",
"meaning": "metadata 合格後,只能進 rendered diff reviewer review不自動執行。",
},
{
"lane_id": "owner_review_only_update",
"meaning": "只允許更新只讀 owner review ledger不得修改 Nginx、DNS、TLS 或 workflow。",
},
{
"lane_id": "emergency_change_backfill_required",
"meaning": "手動或緊急 gateway 變更只能進事後補件,不得因 break-glass 而自動接受或開 runtime gate。",
},
{
"lane_id": "waiting_runtime_gate",
"meaning": "即使 owner response acceptedruntime gate 仍等待獨立人工批准。",
},
]
BLOCKED_ACTIONS = [
"read_live_conf_over_ssh",
"store_raw_live_conf",
"accept_unredacted_live_conf",
"collect_secret_value",
"render_diff_from_unredacted_payload",
"mark_owner_response_accepted_without_reviewer_record",
"nginx_test_without_separate_approval",
"nginx_reload_without_separate_approval",
"route_smoke_without_matrix",
"dns_probe_without_approval",
"tls_probe_without_approval",
"certbot_renew_without_approval",
"modify_nginx_conf",
"modify_dns_tls_config",
"change_public_route",
"write_production_host",
"accept_unknown_change_actor",
"accept_missing_change_time_window",
"skip_cross_project_impact_review",
"skip_incident_communication_sync",
"accept_silent_manual_nginx_change",
"treat_break_glass_as_approval",
"mark_gateway_change_resolved_without_route_health",
"skip_rollback_validation",
"skip_post_change_monitoring",
"hide_cross_project_route_impact",
"open_runtime_gate",
"add_action_button",
]
def git_short_sha(root: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except Exception:
return "unknown"
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def acceptance_candidate(diff_gate: dict[str, Any]) -> dict[str, Any]:
config_id = diff_gate["config_id"]
return {
"acceptance_candidate_id": f"public_gateway_owner_response_acceptance:{config_id}",
"status": "waiting_owner_response",
"diff_gate_id": diff_gate["diff_gate_id"],
"intake_id": diff_gate["intake_id"],
"export_request_id": diff_gate["export_request_id"],
"config_id": config_id,
"control_tier": diff_gate["control_tier"],
"host": diff_gate["host"],
"live_path": diff_gate["live_path"],
"owner_response_ref": None,
"owner_role_or_team": "pending_owner_response",
"decision": "pending_owner_response",
"decision_reason": "pending_owner_response",
"affected_routes": [],
"redacted_live_conf_ref": None,
"source_to_live_rendered_diff_ref": None,
"nginx_test_plan_ref": None,
"route_smoke_plan_ref": None,
"maintenance_window": "pending_owner_response",
"rollback_owner": "pending_owner_response",
"postcheck_plan": "pending_owner_response",
"change_actor_or_source_ref": None,
"change_time_window": "pending_owner_response",
"cross_project_impact_ref": None,
"communication_sync_ref": None,
"change_intent_or_ticket_ref": None,
"pre_change_approval_ref": None,
"break_glass_reason_ref": None,
"route_health_impact_ref": None,
"rollback_validation_ref": None,
"post_change_monitoring_window": "pending_owner_response",
"reviewer_outcome": "waiting_owner_response",
"followup_owner": "pending_owner_response",
"acceptance_fields": ACCEPTANCE_FIELDS,
"required_owner_response_fields": REQUIRED_OWNER_RESPONSE_FIELDS,
"reviewer_checks": [item["check_id"] for item in REVIEWER_CHECKS],
"outcome_lanes": [item["lane_id"] for item in OUTCOME_LANES],
"blocked_actions": BLOCKED_ACTIONS,
"not_approval": True,
"request_sent": False,
"recipient_confirmed": False,
"owner_response_received": False,
"owner_response_accepted": False,
"owner_response_rejected": False,
"owner_response_quarantined": False,
"supplement_requested": False,
"redacted_export_received": False,
"accepted_redacted_export": False,
"rendered_diff_candidate": False,
"rendered_diff_ready": False,
"nginx_test_authorized": False,
"nginx_test_executed": False,
"nginx_reload_authorized": False,
"nginx_reload_executed": False,
"route_smoke_authorized": False,
"route_smoke_executed": False,
"dns_tls_probe_authorized": False,
"certbot_renew_authorized": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
}
def build_report(
root: Path,
export_report: dict[str, Any],
intake_report: dict[str, Any],
diff_gate_report: dict[str, Any],
generated_at: str | None,
) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
diff_gate_candidates = diff_gate_report.get("diff_gate_candidates", [])
acceptance_candidates = [acceptance_candidate(item) for item in diff_gate_candidates]
c0_candidates = [item for item in acceptance_candidates if item["control_tier"] == "C0"]
c1_candidates = [item for item in acceptance_candidates if item["control_tier"] == "C1"]
return {
"schema_version": "public_gateway_owner_response_acceptance_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"source_export_request_schema_version": export_report.get("schema_version"),
"source_export_request_status": export_report.get("status"),
"source_intake_preflight_schema_version": intake_report.get("schema_version"),
"source_intake_preflight_status": intake_report.get("status"),
"source_rendered_diff_gate_schema_version": diff_gate_report.get("schema_version"),
"source_rendered_diff_gate_status": diff_gate_report.get("status"),
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"summary": {
"source_export_request_count": export_report.get("summary", {}).get("export_request_count", 0),
"source_intake_candidate_count": intake_report.get("summary", {}).get("intake_candidate_count", 0),
"source_diff_gate_candidate_count": diff_gate_report.get("summary", {}).get("diff_gate_candidate_count", 0),
"acceptance_candidate_count": len(acceptance_candidates),
"c0_acceptance_candidate_count": len(c0_candidates),
"c1_acceptance_candidate_count": len(c1_candidates),
"acceptance_field_count": len(ACCEPTANCE_FIELDS),
"required_owner_response_field_count": len(REQUIRED_OWNER_RESPONSE_FIELDS),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"request_sent_count": 0,
"recipient_confirmed_count": 0,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"owner_response_rejected_count": 0,
"owner_response_quarantined_count": 0,
"supplement_requested_count": 0,
"redacted_export_received_count": 0,
"accepted_redacted_export_count": 0,
"rendered_diff_candidate_count": 0,
"rendered_diff_ready_count": 0,
"nginx_test_authorized_count": 0,
"nginx_test_executed_count": 0,
"nginx_reload_authorized_count": 0,
"nginx_reload_executed_count": 0,
"route_smoke_authorized_count": 0,
"route_smoke_executed_count": 0,
"dns_tls_probe_authorized_count": 0,
"certbot_renew_authorized_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"change_actor_identified_count": 0,
"change_time_window_accepted_count": 0,
"cross_project_impact_accepted_count": 0,
"communication_sync_accepted_count": 0,
"change_intent_accepted_count": 0,
"pre_change_approval_accepted_count": 0,
"break_glass_reason_accepted_count": 0,
"route_health_impact_accepted_count": 0,
"rollback_validation_accepted_count": 0,
"post_change_monitoring_window_accepted_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
},
"execution_boundaries": {
"request_dispatch_authorized": False,
"owner_response_accepted": False,
"raw_live_conf_storage_allowed": False,
"host_live_conf_read_authorized": False,
"secret_value_collection_allowed": False,
"rendered_diff_authorized": False,
"nginx_test_authorized": False,
"nginx_test_executed": False,
"nginx_reload_authorized": False,
"nginx_reload_executed": False,
"route_smoke_authorized": False,
"route_smoke_executed": False,
"dns_tls_probe_authorized": False,
"certbot_renew_authorized": False,
"production_write_authorized": False,
"runtime_execution_authorized": False,
"action_buttons_allowed": False,
"not_authorization": True,
},
"acceptance_fields": ACCEPTANCE_FIELDS,
"required_owner_response_fields": REQUIRED_OWNER_RESPONSE_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"acceptance_candidates": acceptance_candidates,
"next_steps": [
"等待 owner response未收到前不得更新 accepted count。",
"收到回覆後先走 raw payload / secret / scope / evidence ref 檢查,不合格即隔離、拒收或補件。",
"metadata 合格也只能進 rendered diff reviewer review`nginx -t`、reload、route smoke 與 production write 仍需獨立人工批准。",
"若涉及手動或緊急 gateway 變更,必須先補 change actor/source、time window、cross-project impact、communication sync、change intent、break-glass reason、route health impact、rollback validation 與 post-change monitoring refs。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS Public Gateway owner response acceptance 只讀帳本產生器")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--export-request-report",
default="docs/security/public-gateway-live-conf-export-request.snapshot.json",
help="public-gateway-live-conf-export-request.py 輸出的 JSON",
)
parser.add_argument(
"--intake-preflight-report",
default="docs/security/public-gateway-redacted-export-intake-preflight.snapshot.json",
help="public-gateway-redacted-export-intake-preflight.py 輸出的 JSON",
)
parser.add_argument(
"--rendered-diff-gate-report",
default="docs/security/public-gateway-rendered-diff-gate-draft.snapshot.json",
help="public-gateway-rendered-diff-gate-draft.py 輸出的 JSON",
)
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
export_report = load_json(root / args.export_request_report)
intake_report = load_json(root / args.intake_preflight_report)
diff_gate_report = load_json(root / args.rendered_diff_gate_report)
report = build_report(root, export_report, intake_report, diff_gate_report, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
summary = report["summary"]
print(
"PUBLIC_GATEWAY_OWNER_RESPONSE_ACCEPTANCE_OK "
f"candidates={summary['acceptance_candidate_count']} "
f"c0={summary['c0_acceptance_candidate_count']} "
f"checks={summary['reviewer_check_count']} "
f"lanes={summary['outcome_lane_count']} "
f"accepted={summary['owner_response_accepted_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())