Implement GH-DEC-2026-003 consume-before-OpenBao PEP gate
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
tegwick 2026-09-02 01:06:50 +02:00
parent 465c0d7049
commit 4b4d556d62
14 changed files with 724 additions and 20 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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,

View file

@ -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.