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
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue