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
This commit is contained in:
parent
465c0d7049
commit
4b4d556d62
14 changed files with 724 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
49
docs/approval-consumption.md
Normal file
49
docs/approval-consumption.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
21
layer.yaml
21
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
|
||||
|
|
|
|||
213
src/secrets_engine/approval_consume.py
Normal file
213
src/secrets_engine/approval_consume.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
353
tests/test_approval_consume.py
Normal file
353
tests/test_approval_consume.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue