From cecfa54575b2a312786ffb770fa58b95f50f1411 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 13:47:57 +0800 Subject: [PATCH] feat(governance): expose credential escrow intake readiness --- .gitea/workflows/cd.yaml | 6 + apps/api/src/api/v1/agents.py | 33 +++ ...ential_escrow_evidence_intake_readiness.py | 232 ++++++++++++++++++ ...al_escrow_evidence_intake_readiness_api.py | 133 ++++++++++ docs/LOGBOOK.md | 14 ++ .../test_cd_controlled_runtime_profile.py | 7 + 6 files changed, 425 insertions(+) create mode 100644 apps/api/src/services/credential_escrow_evidence_intake_readiness.py create mode 100644 apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 66a651e3..c0022a89 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -224,6 +224,8 @@ jobs: ;; apps/api/src/services/heartbeat_report_service.py) ;; + apps/api/src/services/credential_escrow_evidence_intake_readiness.py) + ;; apps/api/Dockerfile) ;; apps/api/src/services/awoooi_gitea_onboarding_warning_step_dashboard.py) @@ -280,6 +282,8 @@ jobs: ;; apps/api/tests/test_delivery_closure_workbench_api.py) ;; + apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py) + ;; apps/api/tests/e2e_network_test.py) ;; apps/api/tests/test_p0_cicd_baseline_source_readiness_api.py) @@ -397,6 +401,7 @@ jobs: src/services/decision_fusion.py \ src/services/delivery_closure_workbench.py \ src/services/heartbeat_report_service.py \ + src/services/credential_escrow_evidence_intake_readiness.py \ src/services/awoooi_gitea_onboarding_warning_step_dashboard.py \ src/services/awoooi_gitea_onboarding_warning_step_owner_package.py \ src/services/awoooi_gitea_onboarding_warning_step_owner_response_preflight.py \ @@ -418,6 +423,7 @@ jobs: tests/test_approval_pending_visibility.py \ tests/test_awooop_operator_timeline_labels.py::test_outbound_timeline_title_labels_runbook_review \ tests/test_delivery_closure_workbench_api.py \ + tests/test_credential_escrow_evidence_intake_readiness_api.py \ tests/e2e_network_test.py::TestHMACVerification::test_valid_hmac_signature \ tests/test_p0_cicd_baseline_source_readiness_api.py \ tests/test_product_awoooi_manifest_standard_api.py \ diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index e23347e5..b5b5de33 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -316,6 +316,9 @@ from src.services.backup_notification_policy import ( from src.services.backup_restore_drill_approval_package_template import ( load_latest_backup_restore_drill_approval_package_template, ) +from src.services.credential_escrow_evidence_intake_readiness import ( + load_latest_credential_escrow_evidence_intake_readiness, +) from src.services.delivery_closure_workbench import ( load_delivery_closure_workbench, ) @@ -3678,6 +3681,36 @@ async def get_backup_dr_readiness_matrix() -> dict[str, Any]: ) from exc +@router.get( + "/credential-escrow-evidence-intake-readiness", + response_model=dict[str, Any], + summary="取得 P0-005 credential escrow evidence intake readiness", + description=( + "讀取已提交的 P0-005 credential escrow evidence intake 只讀投影;" + "此端點只回傳 redacted evidence-ref metadata 與目前缺項," + "不讀 credential、不寫 escrow marker、不執行 backup/restore/offsite sync、" + "不觸發 workflow、不碰 host/K8s、不讀 raw session/SQLite。" + ), +) +async def get_credential_escrow_evidence_intake_readiness() -> dict[str, Any]: + """Return P0-005 credential escrow evidence intake readiness.""" + try: + return await asyncio.to_thread( + load_latest_credential_escrow_evidence_intake_readiness + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error("credential_escrow_evidence_intake_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="P0-005 credential escrow evidence intake readiness 快照無效", + ) from exc + + @router.get( "/backup-notification-policy", response_model=dict[str, Any], diff --git a/apps/api/src/services/credential_escrow_evidence_intake_readiness.py b/apps/api/src/services/credential_escrow_evidence_intake_readiness.py new file mode 100644 index 00000000..8dc0390b --- /dev/null +++ b/apps/api/src/services/credential_escrow_evidence_intake_readiness.py @@ -0,0 +1,232 @@ +"""P0-005 credential escrow evidence intake readiness. + +This read-only projection turns the Backup / DR escrow blocker into a direct +API contract. It only reads committed, redacted metadata snapshots; it never +reads credential values, writes escrow markers, triggers backups/restores, or +touches hosts. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.backup_dr_readiness_matrix import load_latest_backup_dr_readiness_matrix +from src.services.snapshot_paths import default_security_dir + +_DEFAULT_SECURITY_DIR = default_security_dir(Path(__file__)) +_OWNER_REQUEST_FILE = "credential-escrow-evidence-owner-request.snapshot.json" +_SCHEMA_VERSION = "credential_escrow_evidence_intake_readiness_v1" +_OWNER_REQUEST_SCOPE = "credential_escrow_evidence_owner_request" + + +def load_latest_credential_escrow_evidence_intake_readiness( + security_dir: Path | None = None, + backup_dr_readiness_matrix: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Load P0-005 credential escrow evidence intake readiness.""" + directory = security_dir or _DEFAULT_SECURITY_DIR + path = directory / _OWNER_REQUEST_FILE + with path.open(encoding="utf-8") as handle: + owner_request = json.load(handle) + + if not isinstance(owner_request, dict): + raise ValueError(f"{path}: expected JSON object") + matrix = backup_dr_readiness_matrix or load_latest_backup_dr_readiness_matrix() + if not isinstance(matrix, dict): + raise ValueError("backup_dr_readiness_matrix: expected JSON object") + + _require_owner_request(owner_request, str(path)) + _require_backup_rollups(matrix, "backup_dr_readiness_matrix") + payload = _build_payload(owner_request, matrix, path) + _require_operation_boundaries(payload, str(path)) + _require_rollup_consistency(payload, str(path)) + return payload + + +def _build_payload( + owner_request: dict[str, Any], + matrix: dict[str, Any], + owner_request_path: Path, +) -> dict[str, Any]: + rollups = _dict(matrix.get("rollups")) + missing_items = [_normalize_item(item) for item in _list(owner_request.get("missing_items"))] + blocked_items = [ + item["item_id"] + for item in missing_items + if item.get("status") != "verified" + ] + effective_missing = _int(rollups.get("credential_escrow_effective_missing_count")) + forbidden_values = _strings(owner_request.get("forbidden_values")) + status = str( + rollups.get("credential_escrow_intake_status") + or "blocked_waiting_non_secret_credential_escrow_evidence" + ) + if effective_missing == 0 and not blocked_items: + status = "ready_for_escrow_marker_review" + + payload = { + "schema_version": _SCHEMA_VERSION, + "generated_at": owner_request.get("generated_at"), + "priority": "P0-005", + "scope": "credential_escrow_evidence_intake", + "status": status, + "readback": { + "workplan_id": "P0-005", + "workplan_title": "產品資料與備份 contract", + "source_owner_request_ref": ( + f"docs/security/{owner_request_path.name}" + ), + "source_backup_dr_readiness_matrix_ref": ( + "docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json" + ), + "safe_next_step": ( + "collect_redacted_non_secret_evidence_refs_then_rerun_preflight" + ), + }, + "required_evidence_items": missing_items, + "forbidden_values": forbidden_values, + "next_actions": [ + "collect_redacted_non_secret_evidence_refs_for_all_missing_items", + "rerun_owner_response_preflight_without_secret_values", + "keep_credential_marker_write_closed_until_preflight_accepts_all_items", + ], + "blocked_operations": [ + "credential_marker_write", + "credential_read", + "secret_plaintext_export", + "restore_execution", + "offsite_sync_execution", + "backup_execution", + "workflow_dispatch", + "host_or_k8s_write", + ], + "rollups": { + "required_item_count": len(missing_items), + "missing_item_count": len(blocked_items), + "blocked_item_ids": blocked_items, + "effective_escrow_missing_count": effective_missing, + "owner_response_received_count": _int( + rollups.get("credential_escrow_owner_response_received_count") + ), + "owner_response_accepted_count": _int( + rollups.get("credential_escrow_owner_response_accepted_count") + ), + "runtime_gate_count": _int(rollups.get("credential_escrow_runtime_gate_count")), + "credential_marker_write_authorized_count": _int( + rollups.get("credential_marker_write_authorized_count") + ), + "secret_value_collection_allowed": bool( + rollups.get("credential_escrow_secret_value_collection_allowed") + ), + "forbidden_true_field_count": _int( + rollups.get("credential_escrow_forbidden_true_field_count") + ), + "forbidden_value_count": len(forbidden_values), + }, + "operation_boundaries": { + "read_only_api_allowed": True, + "backup_execution_allowed": False, + "restore_execution_allowed": False, + "offsite_sync_execution_allowed": False, + "credential_marker_write_allowed": False, + "credential_read_allowed": False, + "secret_plaintext_allowed": False, + "secret_value_collection_allowed": False, + "workflow_trigger_allowed": False, + "host_or_k8s_write_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + }, + } + return payload + + +def _normalize_item(item: Any) -> dict[str, Any]: + data = _dict(item) + return { + "item_id": str(data.get("item") or data.get("item_id") or ""), + "status": str(data.get("status") or "missing"), + "allowed_evidence_id_types": _strings(data.get("allowed_evidence_id_types")), + "contains_secret_value_allowed": False, + "required_non_secret_evidence_ref": True, + } + + +def _require_owner_request(payload: dict[str, Any], label: str) -> None: + if payload.get("scope") != _OWNER_REQUEST_SCOPE: + raise ValueError(f"{label}: scope must be {_OWNER_REQUEST_SCOPE}") + if payload.get("gates", {}).get("secret_value_collection_authorized") is not False: + raise ValueError(f"{label}: secret value collection must remain false") + if payload.get("gates", {}).get("marker_write_completed") is not False: + raise ValueError(f"{label}: marker_write_completed must remain false") + if not _list(payload.get("missing_items")): + raise ValueError(f"{label}: missing_items must be present") + + +def _require_backup_rollups(payload: dict[str, Any], label: str) -> None: + rollups = _dict(payload.get("rollups")) + if rollups.get("credential_escrow_secret_value_collection_allowed") is not False: + raise ValueError(f"{label}: secret value collection must remain false") + if _int(rollups.get("credential_marker_write_authorized_count")) != 0: + raise ValueError(f"{label}: credential marker write must remain closed") + + +def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: + boundaries = _dict(payload.get("operation_boundaries")) + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + blocked_flags = { + "backup_execution_allowed", + "restore_execution_allowed", + "offsite_sync_execution_allowed", + "credential_marker_write_allowed", + "credential_read_allowed", + "secret_plaintext_allowed", + "secret_value_collection_allowed", + "workflow_trigger_allowed", + "host_or_k8s_write_allowed", + "raw_session_or_sqlite_read_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: operation boundaries must remain false: {allowed}") + + +def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: + items = _list(payload.get("required_evidence_items")) + blocked = [ + str(_dict(item).get("item_id") or "") + for item in items + if _dict(item).get("status") != "verified" + ] + rollups = _dict(payload.get("rollups")) + if rollups.get("required_item_count") != len(items): + raise ValueError(f"{label}: required_item_count mismatch") + if rollups.get("missing_item_count") != len(blocked): + raise ValueError(f"{label}: missing_item_count mismatch") + if rollups.get("blocked_item_ids") != blocked: + raise ValueError(f"{label}: blocked_item_ids mismatch") + if rollups.get("secret_value_collection_allowed") is not False: + raise ValueError(f"{label}: secret_value_collection_allowed must be false") + if rollups.get("credential_marker_write_authorized_count") != 0: + raise ValueError(f"{label}: marker write must remain closed") + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in _list(value) if str(item)] + + +def _int(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py b/apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py new file mode 100644 index 00000000..6b48f9f4 --- /dev/null +++ b/apps/api/tests/test_credential_escrow_evidence_intake_readiness_api.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router +from src.services.credential_escrow_evidence_intake_readiness import ( + load_latest_credential_escrow_evidence_intake_readiness, +) + + +def test_credential_escrow_evidence_intake_reports_p0_blocker(): + payload = load_latest_credential_escrow_evidence_intake_readiness() + + assert payload["schema_version"] == "credential_escrow_evidence_intake_readiness_v1" + assert payload["priority"] == "P0-005" + assert payload["status"] == "blocked_waiting_non_secret_credential_escrow_evidence" + assert payload["rollups"]["required_item_count"] == 5 + assert payload["rollups"]["missing_item_count"] == 5 + assert payload["rollups"]["effective_escrow_missing_count"] == 5 + assert payload["rollups"]["owner_response_received_count"] == 0 + assert payload["rollups"]["owner_response_accepted_count"] == 0 + assert payload["rollups"]["runtime_gate_count"] == 0 + assert payload["rollups"]["credential_marker_write_authorized_count"] == 0 + assert payload["rollups"]["secret_value_collection_allowed"] is False + assert payload["operation_boundaries"]["credential_marker_write_allowed"] is False + assert payload["operation_boundaries"]["credential_read_allowed"] is False + assert payload["operation_boundaries"]["secret_plaintext_allowed"] is False + assert payload["operation_boundaries"]["raw_session_or_sqlite_read_allowed"] is False + assert "restic_repository_password" in payload["rollups"]["blocked_item_ids"] + assert all( + item["contains_secret_value_allowed"] is False + for item in payload["required_evidence_items"] + ) + + +def test_credential_escrow_evidence_intake_endpoint_returns_readiness(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/credential-escrow-evidence-intake-readiness") + + assert response.status_code == 200 + data = response.json() + assert data["priority"] == "P0-005" + assert data["rollups"]["missing_item_count"] == 5 + assert data["operation_boundaries"]["restore_execution_allowed"] is False + assert data["operation_boundaries"]["workflow_trigger_allowed"] is False + + +def test_credential_escrow_evidence_intake_rejects_secret_collection(tmp_path): + security_dir = tmp_path / "security" + security_dir.mkdir() + owner_request = { + "scope": "credential_escrow_evidence_owner_request", + "generated_at": "2026-06-29T00:00:00+08:00", + "missing_items": [ + { + "item": "restic_repository_password", + "status": "missing", + "allowed_evidence_id_types": ["vault_item_id"], + } + ], + "forbidden_values": ["password"], + "gates": { + "secret_value_collection_authorized": True, + "marker_write_completed": False, + }, + } + (security_dir / "credential-escrow-evidence-owner-request.snapshot.json").write_text( + json.dumps(owner_request), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="secret value collection"): + load_latest_credential_escrow_evidence_intake_readiness( + security_dir, + backup_dr_readiness_matrix=_backup_matrix(), + ) + + +def test_credential_escrow_evidence_intake_rejects_marker_write_authorization(tmp_path): + security_dir = tmp_path / "security" + security_dir.mkdir() + owner_request = { + "scope": "credential_escrow_evidence_owner_request", + "generated_at": "2026-06-29T00:00:00+08:00", + "missing_items": [ + { + "item": "restic_repository_password", + "status": "missing", + "allowed_evidence_id_types": ["vault_item_id"], + } + ], + "forbidden_values": ["password"], + "gates": { + "secret_value_collection_authorized": False, + "marker_write_completed": False, + }, + } + (security_dir / "credential-escrow-evidence-owner-request.snapshot.json").write_text( + json.dumps(owner_request), + encoding="utf-8", + ) + matrix = _backup_matrix() + matrix["rollups"]["credential_marker_write_authorized_count"] = 1 + + with pytest.raises(ValueError, match="credential marker write"): + load_latest_credential_escrow_evidence_intake_readiness( + security_dir, + backup_dr_readiness_matrix=matrix, + ) + + +def _backup_matrix() -> dict: + return { + "rollups": { + "credential_escrow_intake_status": ( + "blocked_waiting_non_secret_credential_escrow_evidence" + ), + "credential_escrow_effective_missing_count": 1, + "credential_escrow_owner_response_received_count": 0, + "credential_escrow_owner_response_accepted_count": 0, + "credential_escrow_runtime_gate_count": 0, + "credential_marker_write_authorized_count": 0, + "credential_escrow_secret_value_collection_allowed": False, + "credential_escrow_forbidden_true_field_count": 0, + } + } diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index b02de043..a2f84ccc 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,17 @@ +## 2026-06-29 — 13:40 P0-005 credential escrow evidence intake 只讀投影 + +**完成內容**: +- 接續 P0-002 / P0-004 production readback green 後,按順位推進 P0-005。 +- 新增 `credential_escrow_evidence_intake_readiness` loader 與 `/api/v1/agents/credential-escrow-evidence-intake-readiness`,把五個 missing escrow evidence items、禁止 secret collection、禁止 marker write 與 safe next step 集中成 P0-005 專屬只讀 API。 +- 此投影只讀 `docs/security/credential-escrow-evidence-owner-request.snapshot.json` 與 Backup / DR readiness matrix,不讀 `/backup` runtime secret、不寫 marker、不執行 backup / restore / offsite sync。 +- Gitea CD controlled-runtime profile 納入此 service/test,避免 docs/readback 小修回退 heavy B5。 + +**驗證目標**: +- Focused:`test_credential_escrow_evidence_intake_readiness_api.py`、`test_backup_dr_readiness_matrix_api.py`、`test_delivery_closure_workbench_api.py`、`ops/runner/test_cd_controlled_runtime_profile.py`。 +- Source / workflow guards:`py_compile`、`guard-gitea-runner-pressure.py`、`check-gitea-step-env-secrets.js`、`git diff --check`。 + +**邊界**:未使用 GitHub / `gh` / GitHub API;未 workflow_dispatch;未讀 token / cookie / session / secret / auth / `.env`;未讀 raw sessions / SQLite;未操作 host / Docker / K8s / DB;未寫 credential marker;未 force push。 + ## 2026-06-29 — 13:35 P0-002 product manifest API image packaging 修補 **完成內容**: diff --git a/ops/runner/test_cd_controlled_runtime_profile.py b/ops/runner/test_cd_controlled_runtime_profile.py index b2a6cb37..f04801e6 100644 --- a/ops/runner/test_cd_controlled_runtime_profile.py +++ b/ops/runner/test_cd_controlled_runtime_profile.py @@ -27,6 +27,13 @@ def test_product_manifest_changes_stay_on_controlled_runtime_profile() -> None: assert "tests/test_product_awoooi_manifest_standard_api.py" in text +def test_credential_escrow_intake_stays_on_controlled_runtime_profile() -> None: + text = _workflow_text() + assert "apps/api/src/services/credential_escrow_evidence_intake_readiness.py)" in text + assert "src/services/credential_escrow_evidence_intake_readiness.py" in text + assert "tests/test_credential_escrow_evidence_intake_readiness_api.py" in text + + def test_p0_onboarding_readiness_sources_stay_on_controlled_runtime_profile() -> None: text = _workflow_text() expected_sources = [