From 044df6fe5b17d2f192179e37f687a4d103619220 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 14 Oct 2026 18:19:38 +0200 Subject: [PATCH] RPF-WP-0015-T02: select the lease issued for the mounted refresh; leave predecessors ESO refresh (15m) and role TTL (15m, max 1h) overlap, so the first window saw two live handles and aborted before revocation. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 680842@bnt-lap001 Assistant-Session: 54eb9e8e-7d6b-4f0c-b7a6-a6db62075883 --- docs/audit-core-database-lease-recovery.md | 15 +++-- .../RAILIANCE-WP-0024-owner-reviews.json | 4 +- scripts/audit-core-database-lease-recovery.py | 60 +++++++++++++------ ...test_audit_core_database_lease_recovery.py | 18 ++++++ 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/docs/audit-core-database-lease-recovery.md b/docs/audit-core-database-lease-recovery.md index 85d2adb..c6bbd3b 100644 --- a/docs/audit-core-database-lease-recovery.md +++ b/docs/audit-core-database-lease-recovery.md @@ -11,7 +11,7 @@ pod. It does not authorize a live revocation. | Boundary | Exact scope | Responsible owner | | --- | --- | --- | | Consumer | Deployment `audit-core/audit-core`; Secret `audit-core/audit-core-database`; ExternalSecret `audit-core/audit-core-database` | `audit-core` | -| Lease | `database/creds/audit-core-runtime`; exactly one live handle whose issue time coheres with the current ExternalSecret refresh | `railiance-platform` custody operator | +| Lease | `database/creds/audit-core-runtime`; the one handle issued for the current ExternalSecret refresh (older predecessors are left alone) | `railiance-platform` custody operator | | Database | CNPG `databases/platform-pg`, database `audit_core`, runtime group `audit_core_app` | `rapp-postgres` | | Load | One separately approved synthetic sender/tenant contract; no production event or identity | `audit-core` + load owner | | Coordination and evidence | `RAILIANCE-WP-0024-T02` | `railiance-platform` | @@ -181,10 +181,15 @@ bodies and clear all transient JSON from memory or mode-`0600` temporary files. 3. Inside the attended process, list only lease handles below `sys/leases/lookup/database/creds/audit-core-runtime`. OpenBao's lease lookup returns issue/expiry/TTL metadata but not the leased username, so exact - selection is deliberately stricter: there must be exactly one live handle - under that exact runtime prefix, and its issue/expiry interval must contain - the current database Secret refresh time. Abort for database-owner - disambiguation if zero or multiple handles exist. Retain only a one-way + selection is deliberately strict: at most 8 handles may exist under that + exact runtime prefix; exactly one must have been issued within 5 seconds of + the current database Secret refresh (the mounted credential), with at least + five minutes of TTL left; every other handle must be an older predecessor. + ESO refreshes every 15 minutes while the role TTL is 15 minutes (max 1 hour), + so a predecessor can legitimately still be live; it is never revoked. Abort + if no handle, several handles, or a newer unmatched handle match the refresh. + (2026-10-14: the first attended window aborted here under the earlier + "exactly one handle" rule with two live handles.) Retain only a one-way handle fingerprint in evidence and clear the lookup response immediately. 4. Recheck that the Secret `resourceVersion`, pod UID, and selected handle have not changed. Revoke only that handle. Do not revoke the role prefix and do diff --git a/interfaces/RAILIANCE-WP-0024-owner-reviews.json b/interfaces/RAILIANCE-WP-0024-owner-reviews.json index 83bdf94..8cc9ba0 100644 --- a/interfaces/RAILIANCE-WP-0024-owner-reviews.json +++ b/interfaces/RAILIANCE-WP-0024-owner-reviews.json @@ -142,10 +142,10 @@ }, "artifact_sha256": { "docs/audit-core-database-lease-approval.example.json": "87e57e3290074508c6517938acf8c7fc1a75d977b8ff630f247a4ff5d2ee45c6", - "docs/audit-core-database-lease-recovery.md": "ae9e6b0d6cf60514cfabbd1fd0a9818d33dbbac9291a94539a4e049492c48f21", + "docs/audit-core-database-lease-recovery.md": "8dfd3fb59c551df0346d2569674227bb2f4e763c3bf058453035591073e20794", "docs/openbao-reboot-snapshot-receipt.example.json": "b95903a3e1aacb607eecc3f0440f08e62480d09da47bd7ab96c3eb8814f2eaee", "docs/railiance01-coordinated-reboot.md": "6fd6301f808449ad6519764d994b05aee41a96be879b917ee1d18d8a65d5f6fd", - "scripts/audit-core-database-lease-recovery.py": "3066718d06ce29b98fa8ee513bd967e0f48c47db2d66b5083c591f5f1c2bddd6", + "scripts/audit-core-database-lease-recovery.py": "121ade87315606b11dc7366c11a0484dc73bdbeef3d5c59c2cb75a2de3292bc8", "scripts/audit-core-recovery-preflight.py": "05e89ae9d0b9a636b67d9a8d0c203ac746c97208e32061cb23f1ca785eda8e5c", "scripts/audit_core_synthetic_sender_lane.py": "608145f43b5adea110c4c73a2f567370fcfb603ef73ca8230775de9bf7687fa6", "scripts/t02-attended-window.py": "1faca370d14de9a55a3f04e55f6046105b5d3a606a4d79dadbe72c495ab75077" diff --git a/scripts/audit-core-database-lease-recovery.py b/scripts/audit-core-database-lease-recovery.py index 46ffd2f..948b0b8 100755 --- a/scripts/audit-core-database-lease-recovery.py +++ b/scripts/audit-core-database-lease-recovery.py @@ -160,21 +160,7 @@ class Authority: def bao(self, args: list[str], *, label: str) -> str: return safe_run(["bao", *args], label=label, env=self.env).stdout.strip() - def single_runtime_lease(self) -> dict[str, Any]: - raw = self.bao( - ["list", "-format=json", f"sys/leases/lookup/{LEASE_PREFIX}"], - label="list exact runtime lease handles", - ) - try: - handles = json.loads(raw) - except json.JSONDecodeError as exc: - raise ProcedureError("runtime lease list is invalid JSON") from exc - if not isinstance(handles, list) or len(handles) != 1: - count = len(handles) if isinstance(handles, list) else "unknown" - raise ProcedureError( - f"exact runtime prefix must contain one live handle (observed {count})" - ) - lease_id = f"{LEASE_PREFIX}/{handles[0]}" + def _lookup(self, lease_id: str) -> dict[str, Any]: lookup_raw = self.bao( ["write", "-format=json", "sys/leases/lookup", f"lease_id={lease_id}"], label="lookup exact runtime lease metadata", @@ -194,6 +180,28 @@ class Authority: "ttl": ttl, } + def runtime_lease_for(self, secret: dict[str, Any]) -> dict[str, Any]: + """The one handle issued for the mounted Secret's refresh. + + ESO refreshes every 15m while the role's TTL is 15m (max 1h), so the + predecessor of the mounted credential can still be live. Select only the + handle coherent with the Secret refresh; every other handle must be an + older predecessor, which is never revoked. + """ + raw = self.bao( + ["list", "-format=json", f"sys/leases/lookup/{LEASE_PREFIX}"], + label="list exact runtime lease handles", + ) + try: + handles = json.loads(raw) + except json.JSONDecodeError as exc: + raise ProcedureError("runtime lease list is invalid JSON") from exc + if not isinstance(handles, list) or not 1 <= len(handles) <= MAX_RUNTIME_HANDLES: + count = len(handles) if isinstance(handles, list) else "unknown" + raise ProcedureError(f"exact runtime prefix has an unexpected handle count ({count})") + leases = [self._lookup(f"{LEASE_PREFIX}/{handle}") for handle in handles] + return select_runtime_lease(leases, secret) + def revoke(self, lease_id: str) -> None: self.bao(["lease", "revoke", lease_id], label="revoke exact runtime lease") @@ -307,6 +315,24 @@ def pod_state(remote: Any) -> dict[str, Any]: } +MAX_RUNTIME_HANDLES = 8 + + +def select_runtime_lease(leases: list[dict[str, Any]], secret: dict[str, Any]) -> dict[str, Any]: + coherent = [ + lease for lease in leases + if abs((lease["issue_time"] - secret["refresh_time"]).total_seconds()) <= 5 + ] + if len(coherent) != 1: + raise ProcedureError( + f"exactly one runtime lease must match the mounted Secret refresh (observed {len(coherent)})" + ) + selected = coherent[0] + if any(lease["issue_time"] >= selected["issue_time"] for lease in leases if lease is not selected): + raise ProcedureError("a runtime lease newer than the mounted credential exists") + return {**selected, "predecessor_handles": len(leases) - 1} + + def assert_lease_matches_refresh(lease: dict[str, Any], secret: dict[str, Any]) -> None: delta = abs((lease["issue_time"] - secret["refresh_time"]).total_seconds()) if ( @@ -380,7 +406,7 @@ def exercise(args: argparse.Namespace) -> dict[str, Any]: attest_chain_state(remote) before_pod = pod_state(remote) before_secret = secret_state(remote) - lease = authority.single_runtime_lease() + lease = authority.runtime_lease_for(before_secret) assert_lease_matches_refresh(lease, before_secret) baseline_load = driver.run("baseline") if baseline_load.get("status") != "ready": @@ -395,7 +421,7 @@ def exercise(args: argparse.Namespace) -> dict[str, Any]: # Close the race with ESO's ordinary refresh before the destructive step. if secret_state(remote) != before_secret or pod_state(remote) != before_pod: raise ProcedureError("baseline changed before revocation") - current = authority.single_runtime_lease() + current = authority.runtime_lease_for(before_secret) if current["fingerprint"] != lease["fingerprint"]: raise ProcedureError("runtime lease changed before revocation") authority.revoke(lease["id"]) diff --git a/tests/test_audit_core_database_lease_recovery.py b/tests/test_audit_core_database_lease_recovery.py index 3c5d27a..671f237 100644 --- a/tests/test_audit_core_database_lease_recovery.py +++ b/tests/test_audit_core_database_lease_recovery.py @@ -81,6 +81,24 @@ class DatabaseLeaseRecoveryTests(unittest.TestCase): with self.assertRaises(module.ProcedureError): module.load_approval(path, now, require_open_window=True) + def test_select_runtime_lease_ignores_older_predecessors_only(self) -> None: + refresh = datetime(2026, 10, 14, 16, 14, 7, tzinfo=UTC) + def lease(name: str, issued: datetime) -> dict: + return {"id": name, "fingerprint": name, "issue_time": issued, + "expire_time": issued + timedelta(minutes=15), "ttl": 600} + current = lease("current", refresh + timedelta(seconds=1)) + older = lease("older", refresh - timedelta(minutes=15)) + secret = {"refresh_time": refresh} + selected = module.select_runtime_lease([older, current], secret) + self.assertEqual("current", selected["id"]) + self.assertEqual(1, selected["predecessor_handles"]) + with self.assertRaises(module.ProcedureError): # nothing matches the mounted refresh + module.select_runtime_lease([older], secret) + with self.assertRaises(module.ProcedureError): # a newer unmatched handle exists + module.select_runtime_lease([current, lease("newer", refresh + timedelta(minutes=1))], secret) + with self.assertRaises(module.ProcedureError): # two handles match the refresh + module.select_runtime_lease([current, lease("twin", refresh)], secret) + 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: