secrets-engine/src/secrets_engine/pep_stance.py
tegwick f62d3fe789 feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.

- decision_check.check_decision performs the PDP call; an unreachable or
  non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
  AuthorizedAction. Both steps build the same CheckRequest via a shared
  _expected_request, since two descriptions of the action cannot produce
  corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
  defines fail_closed as no side effect WITHOUT a durable decision record,
  so holding a validated one means the residue does not apply. Not a bypass:
  both steps must have succeeded and CAS consume still precedes OpenBao.
  Unconfigured still returns None and fails closed.

The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.

Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:56:02 +02:00

152 lines
5.1 KiB
Python

"""Published PEP unreachable-engine stance (security-layer-model v0.7 §6.4).
Runtime reads ``pep-stance.yaml``. ``SHIPPED_STANCE`` is the pin that makes
drift between the published map and this module fail the conformance test.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import yaml
from secrets_engine.config import repo_root
from secrets_engine.errors import DecisionError
SHIPPED_STANCE = {
"build": "fail_open",
"test": "fail_open",
"prod": "fail_closed",
"unknown": "fail_closed",
}
REQUIRED_STAGES = tuple(SHIPPED_STANCE)
VALID_MODES = frozenset({"fail_open", "fail_closed"})
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
@dataclass(frozen=True)
class StanceApplication:
"""Named residue applied when access-engine is unreachable."""
stage: str
failure_mode: str
action: str
demo_exception: bool = False
decision_id: str = ""
authorized: bool = False
def as_evidence(self) -> dict[str, object]:
payload: dict[str, object] = {
"stance_stage": self.stage,
"stance_failure_mode": self.failure_mode,
"stance_demo_exception": self.demo_exception,
"stance_authorized": self.authorized,
}
if self.decision_id:
payload["stance_decision_id"] = self.decision_id
return payload
@dataclass(frozen=True)
class PepStanceMap:
stance: dict[str, str]
path: Path
def for_stage(self, stage: str) -> tuple[str, str]:
key = stage if stage in self.stance else "unknown"
mode = self.stance.get(key, "")
if mode not in VALID_MODES:
raise DecisionError(
f"pep-stance.yaml has no usable mode for stage {stage!r}"
)
return key, mode
def pep_stance_path() -> Path:
override = os.environ.get("SECRETS_ENGINE_PEP_STANCE", "")
if override:
return Path(override)
return repo_root() / "pep-stance.yaml"
def load_pep_stance(path: Path | None = None) -> PepStanceMap:
target = path or pep_stance_path()
try:
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise DecisionError(f"unable to load PEP stance map {target}: {exc}") from exc
raw = data.get("stance")
if not isinstance(raw, dict):
raise DecisionError(f"{target} is missing a stance map")
stance = {str(key): str(value) for key, value in raw.items()}
missing = [stage for stage in REQUIRED_STAGES if stage not in stance]
if missing:
raise DecisionError(
f"{target} is not total; missing stages {missing}"
)
unknown_modes = {
f"{stage}={mode}"
for stage, mode in stance.items()
if mode not in VALID_MODES
}
if unknown_modes:
raise DecisionError(f"{target} has invalid modes: {sorted(unknown_modes)}")
return PepStanceMap(stance=stance, path=target)
def demo_exception_enabled(cfg: Any) -> bool:
"""Three-factor throwaway exception; not a stance row."""
host = (urlparse(getattr(cfg, "bao_addr", "")).hostname or "").lower()
return (
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
and not getattr(cfg, "hub_url", "")
and host in LOOPBACK_HOSTS
)
def apply_unreachable_engine_stance(
cfg: Any,
entry: Any,
action: str,
*,
stance_map: PepStanceMap | None = None,
authorized: bool = False,
) -> StanceApplication:
"""Apply the published unreachable-engine residue for a live action.
``fail_closed`` is the *unreachable-engine* residue, not a blanket ban: the
published map defines it as no protected side effect without a durable
access-engine decision record. ``authorized=True`` means the caller already
obtained and validated that record for this exact action, so the engine was
reachable and the residue does not apply. It is never a bypass -- the caller
must have completed steps 1 and 2, and GH-DEC-2026-003 still requires a
successful CAS consume before any OpenBao call.
Without such a record, ``fail_closed`` raises ``DecisionError`` carrying
named stance fields. ``fail_open`` is the documented residue for build/test.
"""
loaded = stance_map or load_pep_stance()
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
demo = demo_exception_enabled(cfg)
applied = StanceApplication(
stage=stage,
failure_mode=mode,
action=action or "unknown",
demo_exception=bool(demo and mode == "fail_closed"),
authorized=bool(authorized),
)
if mode == "fail_closed" and not demo and not authorized:
raise DecisionError(
f"production action '{applied.action}' requires a durable "
"access-engine decision record; live production remains disabled",
stance=applied.as_evidence(),
)
return applied
def with_decision(stance: StanceApplication, decision: Any) -> StanceApplication:
decision_id = str(getattr(decision, "id", "") or "")
return replace(stance, decision_id=decision_id)