diff --git a/interfaces/RAILIANCE-WP-0024-owner-reviews.json b/interfaces/RAILIANCE-WP-0024-owner-reviews.json index 44f7317..d1a403e 100644 --- a/interfaces/RAILIANCE-WP-0024-owner-reviews.json +++ b/interfaces/RAILIANCE-WP-0024-owner-reviews.json @@ -140,7 +140,7 @@ "docs/audit-core-database-lease-recovery.md": "5aa8f2723d7216d775990e0b2cd770290284b62d185526a9dffe7d6a4f925d95", "docs/openbao-reboot-snapshot-receipt.example.json": "b95903a3e1aacb607eecc3f0440f08e62480d09da47bd7ab96c3eb8814f2eaee", "docs/railiance01-coordinated-reboot.md": "9969c2f498587710154e8d9a650fb8a13159e9e7b99918291dbdf577b131b7e7", - "scripts/audit-core-database-lease-recovery.py": "c87fb026c527fc17116067c5742d324340707ce4aecf440d0d4e618db9b872a1", + "scripts/audit-core-database-lease-recovery.py": "0a214a492af863a0e396625b50fbd795623f7e4b928240cb43f9becfac4fd387", "scripts/audit-core-recovery-preflight.py": "248c663bad31860dd4f62870db00f6d624143d18c18f86634a39ceda12c7bc66" } } diff --git a/scripts/audit-core-database-lease-recovery.py b/scripts/audit-core-database-lease-recovery.py index 08b1ff8..e80d933 100755 --- a/scripts/audit-core-database-lease-recovery.py +++ b/scripts/audit-core-database-lease-recovery.py @@ -220,11 +220,52 @@ def secret_state(remote: Any) -> dict[str, Any]: } +ATTEST_CRONJOB = "audit-core-attest-chain" +ATTEST_GUARD = timedelta(minutes=20) + + +def next_daily_run(schedule: str, time_zone: str | None, now: datetime) -> datetime: + """Next run of a fixed daily "M H * * *" UTC schedule; anything else fails closed.""" + fields = schedule.split() + if ( + time_zone not in ("Etc/UTC", "UTC") + or len(fields) != 5 + or fields[2:] != ["*", "*", "*"] + or not fields[0].isdigit() + or not fields[1].isdigit() + ): + raise ProcedureError("attest-chain schedule is not a fixed daily UTC time; choose the window manually") + run = now.replace(hour=int(fields[1]), minute=int(fields[0]), second=0, microsecond=0) + return run if run >= now - ATTEST_GUARD else run + timedelta(days=1) + + +def assert_attest_chain_quiet(cronjob: dict[str, Any], now: datetime) -> None: + """The attest CronJob mounts the same database Secret; never revoke under it.""" + spec = cronjob.get("spec", {}) + if cronjob.get("status", {}).get("active"): + raise ProcedureError("an attest-chain Job is active; wait for it to finish") + if spec.get("suspend"): + return + run = next_daily_run(str(spec.get("schedule", "")), spec.get("timeZone"), now) + if abs(run - now) < ATTEST_GUARD: + raise ProcedureError("window is within 20 minutes of the attest-chain schedule") + + +def attest_chain_state(remote: Any) -> None: + cronjob = remote.kubectl_json( + ["-n", "audit-core", "get", "cronjob", ATTEST_CRONJOB], + label="read attest-chain schedule", + ) + assert_attest_chain_quiet(cronjob, datetime.now(UTC)) + + def pod_state(remote: Any) -> dict[str, Any]: - pods = remote.kubectl_json( - ["-n", "audit-core", "get", "pods", "-l", "app.kubernetes.io/name=audit-core"], - label="read audit-core pod metadata", - ).get("items", []) + pods = PREFLIGHT.serving_pods( + remote.kubectl_json( + ["-n", "audit-core", "get", "pods", "-l", "app.kubernetes.io/name=audit-core"], + label="read audit-core pod metadata", + ) + ) if len(pods) != 1: raise ProcedureError(f"expected one audit-core pod, observed {len(pods)}") statuses = pods[0].get("status", {}).get("containerStatuses", []) @@ -304,6 +345,7 @@ def exercise(args: argparse.Namespace) -> dict[str, Any]: if not preflight["ready_for_live_execution"]: raise ProcedureError("database lease recovery preflight is not ready") + attest_chain_state(remote) before_pod = pod_state(remote) before_secret = secret_state(remote) lease = authority.single_runtime_lease() diff --git a/tests/test_audit_core_database_lease_recovery.py b/tests/test_audit_core_database_lease_recovery.py index 198b013..0ac983b 100644 --- a/tests/test_audit_core_database_lease_recovery.py +++ b/tests/test_audit_core_database_lease_recovery.py @@ -39,6 +39,31 @@ def approved_receipt(now: datetime) -> dict: class DatabaseLeaseRecoveryTests(unittest.TestCase): + def test_attest_chain_guard_refuses_active_job_and_schedule_overlap(self) -> None: + cronjob = {"spec": {"schedule": "17 3 * * *", "timeZone": "Etc/UTC"}, "status": {}} + quiet = datetime(2026, 10, 15, 10, 0, tzinfo=UTC) + module.assert_attest_chain_quiet(cronjob, quiet) + for near in (datetime(2026, 10, 15, 3, 0, tzinfo=UTC), datetime(2026, 10, 15, 3, 30, tzinfo=UTC)): + with self.assertRaises(module.ProcedureError): + module.assert_attest_chain_quiet(cronjob, near) + with self.assertRaises(module.ProcedureError): + module.assert_attest_chain_quiet({**cronjob, "status": {"active": [{"name": "j"}]}}, quiet) + with self.assertRaises(module.ProcedureError): + module.assert_attest_chain_quiet( + {"spec": {"schedule": "*/5 * * * *", "timeZone": "Etc/UTC"}, "status": {}}, quiet + ) + + def test_pod_state_ignores_attest_chain_job_pods(self) -> None: + class Remote: + def kubectl_json(self, args, label): + owned = lambda kind, uid: { + "metadata": {"uid": uid, "ownerReferences": [{"kind": kind}]}, + "status": {"containerStatuses": [{"restartCount": 0}]}, + } + return {"items": [owned("ReplicaSet", "serving"), owned("Job", "attest")]} + + self.assertEqual("serving", module.pod_state(Remote())["uid"]) + def test_approval_requires_exact_owner_and_window_contract(self) -> None: now = datetime(2026, 8, 22, 12, 0, tzinfo=UTC) with tempfile.TemporaryDirectory() as directory: diff --git a/workplans/RPF-WP-0015-audit-core-custody-and-recovery-coordination.md b/workplans/RPF-WP-0015-audit-core-custody-and-recovery-coordination.md index 8f68770..c2a43f6 100644 --- a/workplans/RPF-WP-0015-audit-core-custody-and-recovery-coordination.md +++ b/workplans/RPF-WP-0015-audit-core-custody-and-recovery-coordination.md @@ -378,3 +378,17 @@ Both preflights then pass every automated check with no secret value observed; o operator gates remain. The review contract's preflight hash is refreshed, so owner receipts must be renewed against it. Unrelated pre-existing test failures (credential change, factory audit delivery, informed_decision import) are unchanged. + +Owner review round 2026-10-14 (contract b31836ca): railiance-platform approved; +audit-core, rapp-postgres, railiance-cluster and railiance-infra requested changes +(hub messages 570ca050, a190118e, 4e9d4e58, e3b7bb24). T02 fixes applied: the live +harness now uses the preflight's serving-pod filter and refuses while an attest-chain +Job is active or within 20 minutes of its 03:17 UTC schedule (the Job mounts the same +database Secret). rapp-postgres confirms revocation terminates the role's sessions, so +readiness does drop. audit-core registered the T02 load driver (8c8bcf4, receipt +2de1c6e7). Still open for T02: a T02 synthetic-sender projection lane (no generic one +exists; the -02 Whitehat procedure must not be copied). Open for T03: the reboot +checklist revisions requested by cluster/infra/postgres/platform (full-cluster recovery +checks, pre-reboot failing-pod baseline, all CNPG clusters, firewall/boot-id/time sync, +named console path, infra abort ownership, ops-warden login revision re-acceptance and +stop-on-credential-output rule, PVC/cluster UID capture).