From 4b4d556d6264a5da933f7e14aae0fe27ba1f4300 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 2 Sep 2026 01:06:50 +0200 Subject: [PATCH] Implement GH-DEC-2026-003 consume-before-OpenBao PEP gate Every live privileged production handler CAS-consumes through approval-engine before OpenBao. Conflict, unavailability, or a missing binding fail closed. Live production remains disabled until the durable decision record is served. Record kings-guard assent on the secret-use evidence contract. Assistant: grok Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00 --- INTENT.md | 5 +- README.md | 4 + docs/approval-consumption.md | 49 +++ docs/hardening-backlog.md | 3 + docs/secret-use-evidence-contract.md | 9 + layer.yaml | 21 +- src/secrets_engine/approval_consume.py | 213 +++++++++++ src/secrets_engine/cli.py | 38 +- src/secrets_engine/config.py | 5 + src/secrets_engine/evidence.py | 14 + tests/test_approval_consume.py | 353 ++++++++++++++++++ tests/test_layer_conformance.py | 5 +- ...-WP-0007-production-lifecycle-hardening.md | 9 +- ...-0008-layer-model-lifecycle-conformance.md | 16 +- 14 files changed, 724 insertions(+), 20 deletions(-) create mode 100644 docs/approval-consumption.md create mode 100644 src/secrets_engine/approval_consume.py create mode 100644 tests/test_approval_consume.py diff --git a/INTENT.md b/INTENT.md index b9af15c..73fb754 100644 --- a/INTENT.md +++ b/INTENT.md @@ -112,7 +112,10 @@ Secret establishment, access, rotation, and deactivation start from an access-engine decision record naming the request it was rendered for. This engine may automate the work after that decision; it does not render, cache, or substitute one. Compiled catalog data that would determine an -outcome before the engine runs is still deciding, and is forbidden. +outcome before the engine runs is still deciding, and is forbidden. For a +privileged production side effect, a successful approval-engine CAS consume +must precede the OpenBao call (`GH-DEC-2026-003`). Holding an ALLOW is not +authority to act. ### 2. OpenBao is Tooling; this engine is the API over it diff --git a/README.md b/README.md index cc7beda..7135dda 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ SECRETS_ENGINE_HUB_URL="" bash scripts/demo-e2e.sh - Hardening backlog (exit bootstrap mode): [docs/hardening-backlog.md](docs/hardening-backlog.md) - Existing-lane catalog admission: [docs/catalog-admission.md](docs/catalog-admission.md) - KeyCape service-auth consumer boundary: [docs/service-auth.md](docs/service-auth.md) +- Approval consume-before-OpenBao (GH-DEC-2026-003): [docs/approval-consumption.md](docs/approval-consumption.md) The implementation is a Python package (`src/secrets_engine/`). OpenBao is reached only through the `bao` CLI adapter (`openbao.py`); the rest of the code @@ -77,5 +78,8 @@ speaks in lanes and guarded plans. - Production live actions fail closed until the durable State Hub action-authorization endpoint is available; local approval mirrors are throwaway-demo material only. +- A privileged production OpenBao call also requires a successful + approval-engine CAS consume first. Conflict or unavailability means do + not write. - Temporary bootstrap OpenBao credentials must live outside repos, use mode 0600, be revocable, and be removed after narrower auth is working. diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md new file mode 100644 index 0000000..7790b30 --- /dev/null +++ b/docs/approval-consumption.md @@ -0,0 +1,49 @@ +# Approval consumption (PEP) + +Status: engine consumer implemented; live production remains fail-closed +until a durable consume binding is served. + +Normative protocol: `gate-house/docs/contracts/approval-consumption.md` +(`GH-DEC-2026-003`). Implementation surface: +`approval-engine/docs/approval-consumption.md`. This document records how +secrets-engine, as the PEP for OpenBao writes, consumes that protocol. It +does not redefine it. + +## Sequence + +```text +1. PIP GET /v1/approvals/{id}/claim +2. PDP access-engine Check / decide → ALLOW +3. PEP POST /v1/approvals/{id}/consume → CAS +4. PEP OpenBao call → only after consume succeeds +``` + +Every live privileged production handler passes `_require_lane_approval`, +which calls `require_production_consume` before `OpenBaoClient.resolve`. +Dry-run and `plan` do not consume. Build/test remain fail-open relative to +approval-engine. The three-factor unsafe-demo exception is not a consume +path. + +## Fail closed + +| Condition | Result | +| --- | --- | +| No served consume binding | refuse; no OpenBao | +| Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao | +| HTTP 409 / different digest | refuse; no OpenBao | +| Same digest after consume | idempotent success; OpenBao may proceed | +| 401/403/404/503/unreachable | refuse; no OpenBao | +| Side effect fails after consume | approval is spent; no unconsume | + +The consume response is mutation evidence (`status=consumed` plus the +presented digest). It is not a permission. Evidence records approval id, +digest, idempotence, and consumed-at only. No token, secret, or accessor. + +## What this does not do + +- It does not enable live production. Unreachable-engine stance still + fail-closes production until an access-engine decision record is served + (`SECRETS-WP-0007-T04` / `SECRETS-WP-0008-T02`). +- It does not render or cache an authorization decision. +- It does not infer consumption from a decision record or from local + evidence. diff --git a/docs/hardening-backlog.md b/docs/hardening-backlog.md index 79b54dc..c7c9690 100644 --- a/docs/hardening-backlog.md +++ b/docs/hardening-backlog.md @@ -50,6 +50,9 @@ contents in this repo. - Implemented consumer validator: exact action request, decision digest/binding, validity, State Hub provenance, and caller-selected minimum distinct approval count. +- Implemented PEP consume-before-OpenBao (`GH-DEC-2026-003`): production + handlers share `require_production_consume` and fail closed on conflict, + unavailability, or a missing consume binding. - Remaining: State Hub stores/serves the durable object and production handlers resolve it; all production live actions currently fail closed. diff --git a/docs/secret-use-evidence-contract.md b/docs/secret-use-evidence-contract.md index 63dafc5..ae94cf7 100644 --- a/docs/secret-use-evidence-contract.md +++ b/docs/secret-use-evidence-contract.md @@ -76,3 +76,12 @@ holds: nothing is observed in production until kings-guard reports otherwise. A coordination message to kings-guard records this contract. Assent or contest belongs in kings-guard's own voice. + +**2026-09-01:** kings-guard assented, in its own voice (State Hub message +`75ebd2cc-a166-4676-94aa-deef2791c0c9`), to this document as a proposed +Engine / Lifecycle read-only non-secret surface. Assent covers: no secret +values; destination audit-core; completeness not claimed; heartbeat rather +than rate monitoring for load-bearing classes; kings-guard reads this engine +surface, never OpenBao. kings-guard will not consume it as an observation +input until the surface ships and publishes its cadence declaration. +`layer.yaml` `owner_status` remains `proposed` until that surface exists. diff --git a/layer.yaml b/layer.yaml index 673d122..9b39e48 100644 --- a/layer.yaml +++ b/layer.yaml @@ -88,9 +88,11 @@ proposed_capabilities: owner_status: proposed contract: docs/secret-use-evidence-contract.md blocked_on: >- - Contract published; kings-guard has not assented. Local JSONL, the - load-bearing outbox, and `route`/`audit` are not this observation - API. Completeness is not claimed. + Contract published; kings-guard assented 2026-09-01 (message + 75ebd2cc-a166-4676-94aa-deef2791c0c9) and will not treat this as an + observation input until the surface ships and publishes its cadence + declaration. Local JSONL, the load-bearing outbox, and `route`/`audit` + are not this observation API. Completeness is not claimed. review: "2026-11-28" consequence: >- kings-guard secret-abuse posture stays fixture-driven. @@ -123,6 +125,19 @@ non_tooling_clients: endpoint that would serve the object is not yet available; production live actions therefore fail closed (see pep-stance.yaml). + - id: approval-engine-consume + target: approval-engine + layer: engine + modules: + - src/secrets_engine/approval_consume.py + operation: "POST /v1/approvals/{id}/consume before a production OpenBao write" + write: true + note: >- + PEP CAS consume under GH-DEC-2026-003. Not a decision surface. Conflict, + unavailability, or a missing binding fail closed; OpenBao is not + called. Production also remains fail-closed until a durable consume + binding is served. + - id: key-cape-service-auth-scaffold target: key-cape layer: tooling diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py new file mode 100644 index 0000000..37a307e --- /dev/null +++ b/src/secrets_engine/approval_consume.py @@ -0,0 +1,213 @@ +"""PEP consume-before-side-effect client (GH-DEC-2026-003). + +The PEP that is about to cause a protected OpenBao write MUST obtain a +successful approval-engine CAS consume first. Holding a claim or an ALLOW is +not authority to act. Conflict, unavailability, or a missing binding means +do not call OpenBao. + +This module does not render an authorization decision. The consume response +is mutation evidence, never a permission. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from secrets_engine.errors import DecisionError +from secrets_engine.openbao import read_strict_token_file +from secrets_engine.pep_stance import demo_exception_enabled + +DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_MAX_BODY = 256 * 1024 + + +@dataclass(frozen=True) +class ConsumeBinding: + """Inputs the PEP presents to approval-engine consume.""" + + approval_id: str + request_digest: str + decision_id: str = "" + + +@dataclass(frozen=True) +class ConsumedApproval: + """Non-secret confirmation that consume succeeded for this request.""" + + approval_id: str + request_digest: str + decision_id: str = "" + idempotent: bool = False + consumed_at: str = "" + + def as_evidence(self) -> dict[str, object]: + payload: dict[str, object] = { + "approval_consumed": True, + "approval_id": self.approval_id, + "request_digest": self.request_digest, + "approval_consume_idempotent": self.idempotent, + } + if self.decision_id: + payload["decision_id"] = self.decision_id + if self.consumed_at: + payload["approval_consumed_at"] = self.consumed_at + return payload + + +def resolve_consume_binding( + _cfg: Any, + _entry: Any, + _action: str, + _decision: Any, +) -> ConsumeBinding | None: + """Return the served consume binding, or None if it is not available. + + The durable ActionAuthorization / approval serving path is still external + (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). Tests may replace this hook. + """ + return None + + +def consume_approval( + *, + base_url: str, + token_file: Path, + binding: ConsumeBinding, + timeout_seconds: float = 3, + opener: Callable[..., Any] = urlopen, +) -> ConsumedApproval: + """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" + if not base_url or not base_url.startswith(("http://", "https://")): + raise DecisionError("approval-engine consume URL is missing or invalid") + approval_id = binding.approval_id.strip() + if not approval_id or "/" in approval_id or any(ch.isspace() for ch in approval_id): + raise DecisionError("approval consume requires a concrete approval id") + if not DIGEST_RE.fullmatch(binding.request_digest): + raise DecisionError("approval consume requires the canonical request digest") + + token = read_strict_token_file(Path(token_file), purpose="approval consume credential") + body: dict[str, str] = {"request_digest": binding.request_digest} + if binding.decision_id: + body["decision_id"] = binding.decision_id + encoded = json.dumps(body).encode("utf-8") + request = Request( + base_url.rstrip("/") + f"/v1/approvals/{approval_id}/consume", + data=encoded, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + response = opener(request, timeout=timeout_seconds) + try: + status = int(response.getcode()) + raw = response.read(_MAX_BODY + 1) + finally: + response.close() + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + try: + exc.read(_MAX_BODY) + except Exception: + pass + raise DecisionError(_status_message(status)) from None + except (URLError, TimeoutError, OSError): + raise DecisionError( + "approval-engine unreachable; OpenBao must not be called" + ) from None + + if status != 200 or len(raw) > _MAX_BODY: + raise DecisionError(_status_message(status if status != 200 else 502)) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DecisionError("approval consume returned invalid JSON") from exc + if not isinstance(payload, dict): + raise DecisionError("approval consume returned invalid payload") + if payload.get("status") != "consumed": + raise DecisionError("approval consumption was not confirmed") + if payload.get("request_digest") != binding.request_digest: + raise DecisionError("approval consume digest does not match the request") + decision_id = payload.get("decision_id") + if decision_id is not None and ( + not isinstance(decision_id, str) or not decision_id + ): + raise DecisionError("approval consume returned an invalid decision id") + consumed_at = payload.get("consumed_at") + if consumed_at is not None and not isinstance(consumed_at, str): + raise DecisionError("approval consume returned an invalid consumed_at") + return ConsumedApproval( + approval_id=str(payload.get("approval_id") or approval_id), + request_digest=binding.request_digest, + decision_id=decision_id or binding.decision_id, + idempotent=bool(payload.get("idempotent")), + consumed_at=consumed_at or "", + ) + + +def require_production_consume( + cfg: Any, + entry: Any, + *, + binding: ConsumeBinding | None, + evidence: Any = None, + opener: Callable[..., Any] | None = None, +) -> ConsumedApproval | None: + """CAS-consume before a production OpenBao call. No-op off the prod path. + + Build/test remain fail-open relative to approval-engine. The three-factor + unsafe-demo exception is not a consume path. Missing binding, URL, or + credential fail closed so a stance bypass cannot reach OpenBao. + """ + if getattr(entry, "stage", "") != "prod": + return None + if demo_exception_enabled(cfg): + return None + if binding is None: + raise DecisionError( + "production OpenBao call requires CAS consume of an approval " + "after an access-engine ALLOW; no durable consume binding is served" + ) + base_url = str(getattr(cfg, "approval_url", "") or "") + token_file = getattr(cfg, "approval_token_file", None) + if not base_url: + raise DecisionError( + "production OpenBao call requires approval-engine consume; " + "SECRETS_ENGINE_APPROVAL_URL is unset" + ) + if not token_file: + raise DecisionError( + "production OpenBao call requires approval-engine consume; " + "SECRETS_ENGINE_APPROVAL_TOKEN_FILE is unset" + ) + consumed = consume_approval( + base_url=base_url, + token_file=Path(token_file), + binding=binding, + opener=opener or urlopen, + ) + if evidence is not None and hasattr(evidence, "mark_consumed"): + evidence.mark_consumed(consumed) + return consumed + + +def _status_message(status: int) -> str: + if status == 409: + return "approval consume conflict; OpenBao must not be called" + if status == 404: + return "approval not found; OpenBao must not be called" + if status in {401, 403}: + return "approval consume unauthorized; OpenBao must not be called" + if status == 503: + return "approval-engine unavailable; OpenBao must not be called" + if status == 0: + return "approval-engine unreachable; OpenBao must not be called" + return "approval consume failed; OpenBao must not be called" diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 83672b6..bdc796a 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -27,6 +27,10 @@ from pathlib import Path from secrets_engine import __version__ from secrets_engine.apply import apply_plan +from secrets_engine.approval_consume import ( + require_production_consume, + resolve_consume_binding, +) from secrets_engine.catalog import get_entry, load_catalog from secrets_engine.config import Config, repo_root from secrets_engine.decisions import require_approved, resolve_decision @@ -91,27 +95,35 @@ def _require_lane_approval( action: str = "", evidence: PrivilegedActionEvidence | None = None, ): - """Apply published PEP stance, then resolve lane approval. + """Apply published PEP stance, resolve lane approval, then CAS-consume. Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable access-engine decision record is not served yet, so that row refuses live - production work. The three-factor unsafe-demo exception is not a stance - row. Build/test ``fail_open`` still requires the existing lane-approval - check — a tracked gap until SECRETS-WP-0008-T02. + production work. If that residue is ever bypassed, GH-DEC-2026-003 still + requires a successful approval-engine consume before any OpenBao call. + The three-factor unsafe-demo exception is not a stance row and is not a + consume path. Build/test ``fail_open`` still requires the existing + lane-approval check — a tracked gap until SECRETS-WP-0008-T02. """ stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown") if evidence is not None: evidence.mark_stance(stance) - if not entry.approval_required(): - return None - decision = resolve_decision( - hub_url=cfg.hub_url, - repo_root=repo_root(), - decision_ref=entry.approval.get("decision_ref", entry.id), + decision = None + if entry.approval_required(): + decision = resolve_decision( + hub_url=cfg.hub_url, + repo_root=repo_root(), + decision_ref=entry.approval.get("decision_ref", entry.id), + ) + require_approved(entry, decision) + if evidence is not None: + evidence.mark_stance(with_decision(stance, decision)) + require_production_consume( + cfg, + entry, + binding=resolve_consume_binding(cfg, entry, action or "unknown", decision), + evidence=evidence, ) - require_approved(entry, decision) - if evidence is not None: - evidence.mark_stance(with_decision(stance, decision)) return decision diff --git a/src/secrets_engine/config.py b/src/secrets_engine/config.py index 2671d46..ababa5a 100644 --- a/src/secrets_engine/config.py +++ b/src/secrets_engine/config.py @@ -27,10 +27,13 @@ class Config: hub_url: str bao_addr: str topic_id: str + approval_url: str = "" + approval_token_file: Path | None = None @classmethod def load(cls) -> "Config": root = repo_root() + token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "") return cls( catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")), policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")), @@ -40,4 +43,6 @@ class Config: topic_id=os.environ.get( "SECRETS_ENGINE_TOPIC_ID", "cee7bedf-2b48-46ef-8601-006474f2ad7a" ), + approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""), + approval_token_file=Path(token_file) if token_file else None, ) diff --git a/src/secrets_engine/evidence.py b/src/secrets_engine/evidence.py index eacb8c0..3984da2 100644 --- a/src/secrets_engine/evidence.py +++ b/src/secrets_engine/evidence.py @@ -237,6 +237,7 @@ class PrivilegedActionEvidence: approval_status: str = "pending" completed: bool = False stance: dict[str, Any] = field(default_factory=dict) + consume: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if not self.approval_required: @@ -252,6 +253,8 @@ class PrivilegedActionEvidence: ) if self.stance: merged.update(self.stance) + if self.consume: + merged.update(self.consume) if extra: merged.update(extra) return merged @@ -273,6 +276,17 @@ class PrivilegedActionEvidence: self.decision_id = str(getattr(decision, "id", "")) self.approval_status = "approved" + def mark_consumed(self, consumed: object | None) -> None: + if consumed is None: + return + if hasattr(consumed, "as_evidence"): + payload = consumed.as_evidence() + elif isinstance(consumed, dict): + payload = consumed + else: + return + self.consume = {key: value for key, value in payload.items() if value != ""} + def mark_stance(self, stance: object | None) -> None: if stance is None: return diff --git a/tests/test_approval_consume.py b/tests/test_approval_consume.py new file mode 100644 index 0000000..6dbf664 --- /dev/null +++ b/tests/test_approval_consume.py @@ -0,0 +1,353 @@ +import copy +import json +from io import BytesIO +from types import SimpleNamespace +from urllib.error import HTTPError, URLError +from urllib.request import Request + +import pytest + +from secrets_engine import cli +from secrets_engine.approval_consume import ( + ConsumeBinding, + consume_approval, + require_production_consume, + resolve_consume_binding, +) +from secrets_engine.catalog import validate_entry +from secrets_engine.config import Config +from secrets_engine.errors import DecisionError, ProvisioningError +from secrets_engine.pep_stance import StanceApplication +from tests.test_catalog import VALID + +DIGEST = "sha256:" + ("ab" * 32) +TOKEN = "test-approval-consume-credential" + + +def _binding(**overrides): + payload = { + "approval_id": "appr_test-1", + "request_digest": DIGEST, + "decision_id": "decision:test", + } + payload.update(overrides) + return ConsumeBinding(**payload) + + +def _token_file(tmp_path, value=TOKEN, mode=0o600): + path = tmp_path / "approval.token" + path.write_text(value, encoding="utf-8") + path.chmod(mode) + return path + + +def _consumed_payload(**overrides): + payload = { + "approval_id": "appr_test-1", + "status": "consumed", + "request_digest": DIGEST, + "decision_id": "decision:test", + "consumed_at": "2026-09-02T12:00:00Z", + "idempotent": False, + } + payload.update(overrides) + return payload + + +class _Response: + def __init__(self, status, payload): + self._status = status + self._raw = json.dumps(payload).encode("utf-8") + + def getcode(self): + return self._status + + def read(self, _n=-1): + return self._raw + + def close(self): + return None + + +def _http_error(status): + return HTTPError( + "http://approval.test/v1/approvals/appr_test-1/consume", + status, + "error", + hdrs=None, + fp=BytesIO(b'{"error":"no"}'), + ) + + +def test_resolve_consume_binding_is_unserved(): + assert resolve_consume_binding(object(), object(), "apply", None) is None + + +def test_consume_success_and_same_digest_retry(tmp_path): + seen = [] + + def opener(request, timeout): + seen.append((request.full_url, request.get_header("Authorization"), timeout)) + assert request.get_method() == "POST" + body = json.loads(request.data.decode()) + assert body["request_digest"] == DIGEST + assert body["decision_id"] == "decision:test" + return _Response(200, _consumed_payload(idempotent=bool(seen[1:]))) + + first = consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=opener, + ) + retry = consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=opener, + ) + assert first.request_digest == DIGEST + assert first.idempotent is False + assert retry.idempotent is True + assert TOKEN not in repr(first) + assert all(call[1] == f"Bearer {TOKEN}" for call in seen) + + +@pytest.mark.parametrize( + "status,match", + [ + (409, "conflict"), + (404, "not found"), + (401, "unauthorized"), + (403, "unauthorized"), + (503, "unavailable"), + (500, "failed"), + ], +) +def test_consume_http_errors_fail_closed(tmp_path, status, match): + def opener(request, timeout): + raise _http_error(status) + + with pytest.raises(DecisionError, match=match) as raised: + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=opener, + ) + assert "OpenBao must not be called" in str(raised.value) + assert TOKEN not in str(raised.value) + + +def test_consume_unreachable_fails_closed(tmp_path): + def opener(request, timeout): + raise URLError("down") + + with pytest.raises(DecisionError, match="unreachable"): + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=opener, + ) + + +def test_consume_rejects_digest_mismatch_and_non_consumed_status(tmp_path): + def mismatch(request, timeout): + return _Response(200, _consumed_payload(request_digest="sha256:" + ("cd" * 32))) + + def not_consumed(request, timeout): + return _Response(200, _consumed_payload(status="approved")) + + with pytest.raises(DecisionError, match="digest does not match"): + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=mismatch, + ) + with pytest.raises(DecisionError, match="not confirmed"): + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=not_consumed, + ) + + +def test_consume_does_not_treat_effect_as_permission(tmp_path): + def opener(request, timeout): + return _Response( + 200, + _consumed_payload(effect="deny", allow=False, decision="no"), + ) + + consumed = consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(), + opener=opener, + ) + assert consumed.request_digest == DIGEST + assert "effect" not in consumed.as_evidence() + + +def test_token_file_guards(tmp_path): + with pytest.raises(ProvisioningError, match="must be 0600"): + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path, mode=0o644), + binding=_binding(), + opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"), + ) + with pytest.raises(DecisionError, match="canonical request digest"): + consume_approval( + base_url="http://approval.test", + token_file=_token_file(tmp_path), + binding=_binding(request_digest="not-a-digest"), + opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"), + ) + + +def test_require_production_consume_skips_non_prod_and_demo(tmp_path, monkeypatch): + test_entry = validate_entry(copy.deepcopy(VALID)) + prod_entry = validate_entry(dict(VALID, stage="prod")) + cfg = SimpleNamespace( + hub_url="", + bao_addr="http://127.0.0.1:8200", + approval_url="http://approval.test", + approval_token_file=_token_file(tmp_path), + ) + assert require_production_consume(cfg, test_entry, binding=_binding()) is None + monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1") + assert require_production_consume(cfg, prod_entry, binding=_binding()) is None + + +def test_require_production_consume_fails_closed_without_binding(): + entry = validate_entry(dict(VALID, stage="prod")) + cfg = SimpleNamespace( + hub_url="http://127.0.0.1:8000", + bao_addr="http://127.0.0.1:8200", + approval_url="http://approval.test", + approval_token_file=None, + ) + with pytest.raises(DecisionError, match="no durable consume binding"): + require_production_consume(cfg, entry, binding=None) + + +def _config(tmp_path, **overrides): + values = dict( + catalog_dir=tmp_path, + policy_dir=tmp_path, + evidence_dir=tmp_path / "evidence", + hub_url="", + bao_addr="http://127.0.0.1:8200", + topic_id="test-topic", + approval_url="http://approval.test", + approval_token_file=_token_file(tmp_path), + ) + values.update(overrides) + return Config(**values) + + +def _provision_args(entry): + return SimpleNamespace( + catalog_id=entry.id, + stage=entry.stage, + field="api_token", + generate=False, + from_file="/tmp/test-value-file", + bootstrap_token_file=None, + ) + + +def _allow_prod_stance(_cfg, _entry, action, **_kwargs): + return StanceApplication( + stage="prod", + failure_mode="fail_closed", + action=action, + demo_exception=False, + ) + + +def test_stance_bypass_still_cannot_reach_openbao_without_consume( + tmp_path, monkeypatch +): + entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"})) + monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) + monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) + monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) + monkeypatch.setattr( + cli.OpenBaoClient, + "resolve", + lambda *_args, **_kwargs: pytest.fail("backend must not be reached"), + ) + with pytest.raises(DecisionError, match="no durable consume binding"): + cli.cmd_provision(_config(tmp_path), _provision_args(entry)) + + +def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch): + entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"})) + monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) + monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) + monkeypatch.setattr( + cli, + "resolve_consume_binding", + lambda *_args: _binding(), + ) + monkeypatch.setattr( + "secrets_engine.approval_consume.urlopen", + lambda *_args, **_kwargs: (_ for _ in ()).throw(_http_error(409)), + ) + monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) + monkeypatch.setattr( + cli.OpenBaoClient, + "resolve", + lambda *_args, **_kwargs: pytest.fail("backend must not be reached"), + ) + with pytest.raises(DecisionError, match="conflict"): + cli.cmd_provision(_config(tmp_path), _provision_args(entry)) + records = [ + json.loads(line) + for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines() + ] + assert [record["result"] for record in records] == [ + "attempt", + "failed-DecisionError", + ] + assert TOKEN not in json.dumps(records) + + +def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch): + entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"})) + reached = {} + + def opener(request, timeout): + assert isinstance(request, Request) + return _Response(200, _consumed_payload()) + + monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) + monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) + monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args: _binding()) + monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener) + monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) + monkeypatch.setattr( + cli.OpenBaoClient, + "resolve", + lambda *_args, **_kwargs: reached.setdefault("openbao", True) or object(), + ) + monkeypatch.setattr( + cli, + "provision_from_file", + lambda *_args, **_kwargs: "api_token", + ) + rc = cli.cmd_provision(_config(tmp_path), _provision_args(entry)) + assert rc == 0 + assert reached.get("openbao") is True + records = [ + json.loads(line) + for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines() + ] + assert records[-1]["detail"]["approval_consumed"] is True + assert records[-1]["detail"]["approval_id"] == "appr_test-1" + assert TOKEN not in json.dumps(records) diff --git a/tests/test_layer_conformance.py b/tests/test_layer_conformance.py index 53e7b30..7930de9 100644 --- a/tests/test_layer_conformance.py +++ b/tests/test_layer_conformance.py @@ -130,7 +130,10 @@ def test_yaml_is_the_runtime_source(tmp_path, monkeypatch): entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"})) cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200") monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) - assert _require_lane_approval(cfg, entry, "apply") is None + # Runtime follows the YAML (prod is no longer the shipped fail_closed + # residue). GH-DEC-2026-003 still refuses OpenBao without CAS consume. + with pytest.raises(DecisionError, match="no durable consume binding"): + _require_lane_approval(cfg, entry, "apply") def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatch): diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index ad9adff..cc16ecd 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: custodian created: "2026-08-23" -updated: "2026-08-29" +updated: "2026-09-02" state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3" --- @@ -223,6 +223,13 @@ must serve the durable ActionAuthorization object. Paired with `SECRETS-WP-0008-T02`. Do not reopen live production on hub status or local fixtures. +Progress 2026-09-02. `GH-DEC-2026-003` consume-before-OpenBao is now a +shared production gate: `_require_lane_approval` CAS-consumes through +approval-engine before any OpenBao client is resolved. Tests prove 409, +unreachable, and missing-binding paths never call OpenBao. Live production +stays fail-closed until the durable object is served; this task remains +`wait`. + Progress 2026-08-23. flex-auth revision `c473f19` now supplies execute-time structured binding and the proposed durable `ActionAuthorization` schema. The engine builds and fail-closed validates the exact secrets-engine profile, diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index f203984..0fcc64d 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -8,7 +8,7 @@ status: active owner: grok topic_slug: custodian created: "2026-08-29" -updated: "2026-08-29" +updated: "2026-09-02" state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0" --- @@ -94,6 +94,15 @@ priority: high state_hub_task_id: "3eb9cff8-1441-5437-9e92-a2b655c82d04" ``` +Progress 2026-09-02. Gate House notice `632bdad9` (`GH-DEC-2026-003`): this +engine is the PEP for FLEX-WP-0017-T05 / OpenBao writes. The shared consume +function now lives in `src/secrets_engine/approval_consume.py` and every live +privileged production handler reaches it through `_require_lane_approval` +before `OpenBaoClient.resolve`. Conflict, unavailability, or a missing +binding fail closed with no OpenBao call. Production still also fail-closes +on the unpublished durable ActionAuthorization / consume-binding serving +path, so this task remains `wait`. + Blocked on the durable ActionAuthorization serving path owned with `SECRETS-WP-0007-T04` / State Hub / `access-engine`. @@ -190,6 +199,11 @@ Completed 2026-08-29. Contract published at `docs/secret-use-evidence-contract.md`. `layer.yaml` owner_status remains proposed. `route`/`audit` are still not this surface. +**2026-09-02:** kings-guard assented in its own voice (message +`75ebd2cc-a166-4676-94aa-deef2791c0c9`). It will not consume the surface as +an observation input until the surface ships and publishes its cadence +declaration. No Tooling contact will be opened to fill that wait. + kings-guard's secret-abuse posture is fixture-driven because no engine exposes lease, revocation, mount, rotation, and delivery-session metadata. `route` and `audit` are operator summaries over local JSONL and are not that surface.