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.
|
access-engine decision record naming the request it was rendered for.
|
||||||
This engine may automate the work after that decision; it does not render,
|
This engine may automate the work after that decision; it does not render,
|
||||||
cache, or substitute one. Compiled catalog data that would determine an
|
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
|
### 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)
|
- 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)
|
- 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)
|
- 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
|
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
|
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
|
- Production live actions fail closed until the durable State Hub
|
||||||
action-authorization endpoint is available; local approval mirrors are
|
action-authorization endpoint is available; local approval mirrors are
|
||||||
throwaway-demo material only.
|
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
|
- Temporary bootstrap OpenBao credentials must live outside repos, use mode
|
||||||
0600, be revocable, and be removed after narrower auth is working.
|
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,
|
- Implemented consumer validator: exact action request, decision digest/binding,
|
||||||
validity, State Hub provenance, and caller-selected minimum distinct approval
|
validity, State Hub provenance, and caller-selected minimum distinct approval
|
||||||
count.
|
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
|
- Remaining: State Hub stores/serves the durable object and production handlers
|
||||||
resolve it; all production live actions currently fail closed.
|
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
|
A coordination message to kings-guard records this contract. Assent or contest
|
||||||
belongs in kings-guard's own voice.
|
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
|
owner_status: proposed
|
||||||
contract: docs/secret-use-evidence-contract.md
|
contract: docs/secret-use-evidence-contract.md
|
||||||
blocked_on: >-
|
blocked_on: >-
|
||||||
Contract published; kings-guard has not assented. Local JSONL, the
|
Contract published; kings-guard assented 2026-09-01 (message
|
||||||
load-bearing outbox, and `route`/`audit` are not this observation
|
75ebd2cc-a166-4676-94aa-deef2791c0c9) and will not treat this as an
|
||||||
API. Completeness is not claimed.
|
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"
|
review: "2026-11-28"
|
||||||
consequence: >-
|
consequence: >-
|
||||||
kings-guard secret-abuse posture stays fixture-driven.
|
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
|
endpoint that would serve the object is not yet available; production
|
||||||
live actions therefore fail closed (see pep-stance.yaml).
|
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
|
- id: key-cape-service-auth-scaffold
|
||||||
target: key-cape
|
target: key-cape
|
||||||
layer: tooling
|
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 import __version__
|
||||||
from secrets_engine.apply import apply_plan
|
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.catalog import get_entry, load_catalog
|
||||||
from secrets_engine.config import Config, repo_root
|
from secrets_engine.config import Config, repo_root
|
||||||
from secrets_engine.decisions import require_approved, resolve_decision
|
from secrets_engine.decisions import require_approved, resolve_decision
|
||||||
|
|
@ -91,27 +95,35 @@ def _require_lane_approval(
|
||||||
action: str = "",
|
action: str = "",
|
||||||
evidence: PrivilegedActionEvidence | None = None,
|
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
|
Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable
|
||||||
access-engine decision record is not served yet, so that row refuses live
|
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
|
production work. If that residue is ever bypassed, GH-DEC-2026-003 still
|
||||||
row. Build/test ``fail_open`` still requires the existing lane-approval
|
requires a successful approval-engine consume before any OpenBao call.
|
||||||
check — a tracked gap until SECRETS-WP-0008-T02.
|
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")
|
stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown")
|
||||||
if evidence is not None:
|
if evidence is not None:
|
||||||
evidence.mark_stance(stance)
|
evidence.mark_stance(stance)
|
||||||
if not entry.approval_required():
|
decision = None
|
||||||
return None
|
if entry.approval_required():
|
||||||
decision = resolve_decision(
|
decision = resolve_decision(
|
||||||
hub_url=cfg.hub_url,
|
hub_url=cfg.hub_url,
|
||||||
repo_root=repo_root(),
|
repo_root=repo_root(),
|
||||||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
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
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,13 @@ class Config:
|
||||||
hub_url: str
|
hub_url: str
|
||||||
bao_addr: str
|
bao_addr: str
|
||||||
topic_id: str
|
topic_id: str
|
||||||
|
approval_url: str = ""
|
||||||
|
approval_token_file: Path | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> "Config":
|
def load(cls) -> "Config":
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
|
token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "")
|
||||||
return cls(
|
return cls(
|
||||||
catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")),
|
catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")),
|
||||||
policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")),
|
policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")),
|
||||||
|
|
@ -40,4 +43,6 @@ class Config:
|
||||||
topic_id=os.environ.get(
|
topic_id=os.environ.get(
|
||||||
"SECRETS_ENGINE_TOPIC_ID", "cee7bedf-2b48-46ef-8601-006474f2ad7a"
|
"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"
|
approval_status: str = "pending"
|
||||||
completed: bool = False
|
completed: bool = False
|
||||||
stance: dict[str, Any] = field(default_factory=dict)
|
stance: dict[str, Any] = field(default_factory=dict)
|
||||||
|
consume: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.approval_required:
|
if not self.approval_required:
|
||||||
|
|
@ -252,6 +253,8 @@ class PrivilegedActionEvidence:
|
||||||
)
|
)
|
||||||
if self.stance:
|
if self.stance:
|
||||||
merged.update(self.stance)
|
merged.update(self.stance)
|
||||||
|
if self.consume:
|
||||||
|
merged.update(self.consume)
|
||||||
if extra:
|
if extra:
|
||||||
merged.update(extra)
|
merged.update(extra)
|
||||||
return merged
|
return merged
|
||||||
|
|
@ -273,6 +276,17 @@ class PrivilegedActionEvidence:
|
||||||
self.decision_id = str(getattr(decision, "id", ""))
|
self.decision_id = str(getattr(decision, "id", ""))
|
||||||
self.approval_status = "approved"
|
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:
|
def mark_stance(self, stance: object | None) -> None:
|
||||||
if stance is None:
|
if stance is None:
|
||||||
return
|
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"}))
|
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")
|
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)
|
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):
|
def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-23"
|
created: "2026-08-23"
|
||||||
updated: "2026-08-29"
|
updated: "2026-09-02"
|
||||||
state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3"
|
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
|
`SECRETS-WP-0008-T02`. Do not reopen live production on hub status or local
|
||||||
fixtures.
|
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
|
Progress 2026-08-23. flex-auth revision `c473f19` now supplies execute-time
|
||||||
structured binding and the proposed durable `ActionAuthorization` schema. The
|
structured binding and the proposed durable `ActionAuthorization` schema. The
|
||||||
engine builds and fail-closed validates the exact secrets-engine profile,
|
engine builds and fail-closed validates the exact secrets-engine profile,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-29"
|
created: "2026-08-29"
|
||||||
updated: "2026-08-29"
|
updated: "2026-09-02"
|
||||||
state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0"
|
state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -94,6 +94,15 @@ priority: high
|
||||||
state_hub_task_id: "3eb9cff8-1441-5437-9e92-a2b655c82d04"
|
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
|
Blocked on the durable ActionAuthorization serving path owned with
|
||||||
`SECRETS-WP-0007-T04` / State Hub / `access-engine`.
|
`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
|
`docs/secret-use-evidence-contract.md`. `layer.yaml` owner_status remains
|
||||||
proposed. `route`/`audit` are still not this surface.
|
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
|
kings-guard's secret-abuse posture is fixture-driven because no engine exposes
|
||||||
lease, revocation, mount, rotation, and delivery-session metadata. `route` and
|
lease, revocation, mount, rotation, and delivery-session metadata. `route` and
|
||||||
`audit` are operator summaries over local JSONL and are not that surface.
|
`audit` are operator summaries over local JSONL and are not that surface.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue