Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""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.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
|
from secrets_engine.approval_claim import validate_approval_claim
|
|
from secrets_engine.decision_check import check_decision
|
|
from secrets_engine.authorization import (
|
|
build_action_request,
|
|
request_digest,
|
|
validate_decision_envelope,
|
|
)
|
|
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 AuthorizedAction:
|
|
"""A validated claim and decision for one exact proposed action.
|
|
|
|
Holding this is not authority to act: GH-DEC-2026-003 still requires a
|
|
successful CAS consume before the OpenBao call.
|
|
"""
|
|
|
|
binding: ConsumeBinding
|
|
decision_id: str
|
|
expires_at: str
|
|
|
|
def as_evidence(self) -> dict[str, object]:
|
|
return {
|
|
"authorization_decision_id": self.decision_id,
|
|
"authorization_expires_at": self.expires_at,
|
|
"request_digest": self.binding.request_digest,
|
|
}
|
|
|
|
|
|
@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 _authorization_id(entry: Any, decision: Any) -> str:
|
|
"""Non-secret approval-engine object id for this lane, or "" if unbound.
|
|
|
|
flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID.
|
|
It is never the State Hub decision UUID, so it is not inferred from one.
|
|
"""
|
|
approval = getattr(entry, "approval", None) or {}
|
|
if isinstance(approval, dict):
|
|
declared = str(approval.get("authorization_id", "") or "").strip()
|
|
if declared:
|
|
return declared
|
|
for attr in ("authorization_id", "action_authorization_id"):
|
|
served = str(getattr(decision, attr, "") or "").strip()
|
|
if served:
|
|
return served
|
|
return ""
|
|
|
|
|
|
def _request_purpose(entry: Any) -> str:
|
|
"""Declared purpose for the request context. Never invented at call time."""
|
|
approval = getattr(entry, "approval", None) or {}
|
|
if isinstance(approval, dict):
|
|
declared = str(approval.get("purpose", "") or "").strip()
|
|
if declared:
|
|
return declared
|
|
for consumer in getattr(entry, "consumers", None) or []:
|
|
if isinstance(consumer, dict):
|
|
declared = str(consumer.get("purpose", "") or "").strip()
|
|
if declared:
|
|
return declared
|
|
return ""
|
|
|
|
|
|
def _expected_request(
|
|
cfg: Any,
|
|
entry: Any,
|
|
action: str,
|
|
*,
|
|
fields: tuple[str, ...] = (),
|
|
policy_targets: tuple[str, ...] = (),
|
|
auth_targets: tuple[str, ...] = (),
|
|
) -> dict[str, Any]:
|
|
"""Build the exact CheckRequest both steps must agree on.
|
|
|
|
Steps 1 and 2 must describe the same proposed action or the digests cannot
|
|
correspond, so neither builds its own.
|
|
"""
|
|
subject_id = str(getattr(cfg, "authorization_subject_id", "") or "")
|
|
subject_type = str(getattr(cfg, "authorization_subject_type", "") or "")
|
|
if not subject_id or not subject_type:
|
|
raise DecisionError(
|
|
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
|
|
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
|
|
)
|
|
purpose = _request_purpose(entry)
|
|
if not purpose:
|
|
raise DecisionError(
|
|
"authorization join requires a declared approval/consumer purpose"
|
|
)
|
|
return build_action_request(
|
|
entry,
|
|
action,
|
|
subject_id=subject_id,
|
|
subject_type=subject_type,
|
|
purpose=purpose,
|
|
fields=fields,
|
|
policy_targets=policy_targets,
|
|
auth_targets=auth_targets,
|
|
)
|
|
|
|
|
|
def fetch_approval_claim(
|
|
*,
|
|
base_url: str,
|
|
token_file: Path | None = None,
|
|
token_provider: Callable[[], str] | None = None,
|
|
authorization_id: str,
|
|
timeout_seconds: float = 3,
|
|
opener: Callable[..., Any] = credential_urlopen,
|
|
) -> dict[str, Any]:
|
|
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed.
|
|
|
|
The body is approval-engine's approval-claim, not a flex-auth
|
|
ActionAuthorization -- that object is deferred and was never ratified
|
|
(GH-DEC-2026-005 / FLEX-DEC-2026-006).
|
|
"""
|
|
if not base_url or not base_url.startswith(("http://", "https://")):
|
|
raise DecisionError("approval-engine claim URL is missing or invalid")
|
|
ident = authorization_id.strip()
|
|
if not ident or "/" in ident or any(ch.isspace() for ch in ident):
|
|
raise DecisionError("approval claim requires a concrete authorization id")
|
|
token = _request_token(token_file, token_provider)
|
|
request = Request(
|
|
base_url.rstrip("/") + f"/v1/approvals/{ident}/claim",
|
|
method="GET",
|
|
)
|
|
request.add_header("Authorization", f"Bearer {token}")
|
|
request.add_header("Accept", "application/json")
|
|
try:
|
|
with opener(request, timeout=timeout_seconds) as response:
|
|
if getattr(response, "status", 200) != 200:
|
|
raise DecisionError("approval claim did not return a claim")
|
|
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
|
except HTTPError as e:
|
|
raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e
|
|
except URLError as e:
|
|
raise DecisionError("approval-engine is unreachable for claim") from e
|
|
except json.JSONDecodeError as e:
|
|
raise DecisionError("approval claim returned a non-JSON body") from e
|
|
if not isinstance(payload, dict):
|
|
raise DecisionError("approval claim returned a non-object body")
|
|
return payload
|
|
|
|
|
|
def resolve_consume_binding(
|
|
cfg: Any,
|
|
entry: Any,
|
|
action: str,
|
|
decision: Any,
|
|
*,
|
|
fields: tuple[str, ...] = (),
|
|
policy_targets: tuple[str, ...] = (),
|
|
auth_targets: tuple[str, ...] = (),
|
|
opener: Callable[..., Any] | None = None,
|
|
) -> ConsumeBinding | None:
|
|
"""Join the proposed action to a served approval-claim (step 1).
|
|
|
|
Returns None only when no serving path is configured at all, keeping
|
|
production fail-closed exactly as it was before the join existed. Anything
|
|
configured-but-wrong raises: a half-configured PEP must not look like an
|
|
unconfigured one.
|
|
"""
|
|
base_url = str(getattr(cfg, "approval_url", "") or "")
|
|
auth_configured = approval_auth_configured(cfg)
|
|
authorization_id = _authorization_id(entry, decision)
|
|
if not base_url or not auth_configured or not authorization_id:
|
|
return None
|
|
|
|
expected_request = _expected_request(
|
|
cfg, entry, action,
|
|
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
|
)
|
|
|
|
# Two different digests over the same proposed action, by contract; they are
|
|
# never compared to each other.
|
|
#
|
|
# Only the PDP digest is usable for the action/target correspondence today.
|
|
# The claim's binding.action and binding.target speak approval-engine's
|
|
# vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours
|
|
# speaks the catalog's ("destroy", "catalog:<id>"), and no mapping between
|
|
# them is published. flex-auth makes no cross-check either and states the
|
|
# correspondence is ours, via pdp_digest. Computing a native digest from our
|
|
# own vocabulary would compare two different languages and never match --
|
|
# the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do
|
|
# not compute one, and validate_approval_claim fails closed with a named
|
|
# reason when the issuer recorded no pdp_digest.
|
|
pdp_digest = request_digest(expected_request)
|
|
|
|
claim = fetch_approval_claim(
|
|
base_url=base_url,
|
|
token_provider=lambda: approval_token(cfg, scope="approval:read"),
|
|
authorization_id=authorization_id,
|
|
opener=opener or credential_urlopen,
|
|
)
|
|
validate_approval_claim(
|
|
claim,
|
|
approval_id=authorization_id,
|
|
expected_pdp_digest=pdp_digest,
|
|
)
|
|
# No action comparison here. The claim's binding.action is approval-engine
|
|
# vocabulary ("secrets.kv.destroy") and ours is the catalog's ("destroy");
|
|
# comparing them would fail against every real claim, which is the same
|
|
# cross-vocabulary mistake the native digest made. The tie to this exact
|
|
# action is pdp_digest, checked above.
|
|
return ConsumeBinding(
|
|
approval_id=authorization_id,
|
|
request_digest=pdp_digest,
|
|
decision_id="",
|
|
)
|
|
|
|
|
|
def authorize_action(
|
|
cfg: Any,
|
|
entry: Any,
|
|
action: str,
|
|
decision: Any = None,
|
|
*,
|
|
fields: tuple[str, ...] = (),
|
|
policy_targets: tuple[str, ...] = (),
|
|
auth_targets: tuple[str, ...] = (),
|
|
opener: Callable[..., Any] | None = None,
|
|
pdp_opener: Callable[..., Any] | None = None,
|
|
) -> AuthorizedAction | None:
|
|
"""Run steps 1 and 2 for one proposed action, or return None if unserved.
|
|
|
|
Step 1 validates the approval-claim; step 2 obtains and validates the
|
|
flex-auth DecisionEnvelope. Returning None means no serving path is
|
|
configured at all, which leaves production fail-closed. A configured but
|
|
failing path raises: a partial deployment must not read as an absent one.
|
|
"""
|
|
binding = resolve_consume_binding(
|
|
cfg, entry, action, decision,
|
|
fields=fields,
|
|
policy_targets=policy_targets,
|
|
auth_targets=auth_targets,
|
|
opener=opener,
|
|
)
|
|
if binding is None:
|
|
return None
|
|
|
|
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
|
|
pdp_token = getattr(cfg, "pdp_token_file", None)
|
|
if not pdp_url or not pdp_token:
|
|
raise DecisionError(
|
|
"production action requires an access-engine decision; "
|
|
"SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset"
|
|
)
|
|
package = str(getattr(cfg, "authorization_policy_package", "") or "")
|
|
version = str(getattr(cfg, "authorization_policy_version", "") or "")
|
|
if not package or not version:
|
|
raise DecisionError(
|
|
"authorization join requires an explicitly configured policy "
|
|
"package/version pin; the reserved coordinate is a reservation, "
|
|
"not a publication, and must not be used as a default"
|
|
)
|
|
|
|
expected_request = _expected_request(
|
|
cfg, entry, action,
|
|
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
|
|
)
|
|
envelope = check_decision(
|
|
base_url=pdp_url,
|
|
token_file=Path(pdp_token),
|
|
request=expected_request,
|
|
opener=pdp_opener or urlopen,
|
|
)
|
|
validated = validate_decision_envelope(
|
|
envelope,
|
|
expected_request,
|
|
accepted_policy_packages={package},
|
|
accepted_policy_versions={version},
|
|
# The claim's pdp_digest, established in step 1. If this request carried
|
|
# the claim in context, the decision must name the same claim-free
|
|
# envelope in binding.approval_binding_digest (FLEX-DEC-2026-007).
|
|
expected_approval_binding_digest=binding.request_digest,
|
|
)
|
|
if validated.action != action:
|
|
raise DecisionError("access-engine decision does not bind this action")
|
|
return AuthorizedAction(
|
|
binding=ConsumeBinding(
|
|
approval_id=binding.approval_id,
|
|
request_digest=binding.request_digest,
|
|
decision_id=validated.decision_id,
|
|
),
|
|
decision_id=validated.decision_id,
|
|
expires_at=validated.expires_at,
|
|
)
|
|
|
|
|
|
def consume_approval(
|
|
*,
|
|
base_url: str,
|
|
token_file: Path | None = None,
|
|
token_provider: Callable[[], str] | None = None,
|
|
binding: ConsumeBinding,
|
|
timeout_seconds: float = 3,
|
|
opener: Callable[..., Any] = credential_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 = _request_token(token_file, token_provider)
|
|
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 "")
|
|
auth_configured = approval_auth_configured(cfg)
|
|
if not base_url:
|
|
raise DecisionError(
|
|
"production OpenBao call requires approval-engine consume; "
|
|
"SECRETS_ENGINE_APPROVAL_URL is unset"
|
|
)
|
|
if not auth_configured:
|
|
raise DecisionError(
|
|
"production OpenBao call requires approval-engine consume; "
|
|
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
|
|
)
|
|
consumed = consume_approval(
|
|
base_url=base_url,
|
|
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
|
|
binding=binding,
|
|
opener=opener or credential_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"
|
|
|
|
|
|
def _request_token(
|
|
token_file: Path | None, token_provider: Callable[[], str] | None,
|
|
) -> str:
|
|
if (token_file is None) == (token_provider is None):
|
|
raise DecisionError("approval request requires exactly one credential provider")
|
|
token = (
|
|
token_provider() if token_provider is not None
|
|
else read_strict_token_file(Path(token_file), purpose="approval credential")
|
|
)
|
|
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
|
|
raise DecisionError("approval credential is invalid")
|
|
return token
|