Require declared human control in factory credential delivery
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 20:16:55 +02:00
parent d1c13b5dd6
commit 2b0d04e8e1
11 changed files with 451 additions and 27 deletions

View file

@ -102,6 +102,7 @@ def _validate_observation(
claim: object,
*,
approval_id: str,
require_human_control: bool = False,
now: datetime | None = None,
) -> dict[str, Any]:
"""Validate issuer, shape, validity and freshness; no action correspondence."""
@ -135,6 +136,8 @@ def _validate_observation(
binding = claim.get("binding")
if not isinstance(binding, dict):
raise DecisionError("approval claim binding must be an object")
if require_human_control and binding.get("human_control") is not True:
raise DecisionError("approval claim does not declare binding.human_control true; request a human-controlled approval at issue")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
freshness = claim.get("freshness")
if not isinstance(freshness, dict):
@ -154,13 +157,15 @@ def _validate_observation(
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
require_human_control: bool = False,
now: datetime | None = None) -> dict[str, Any]:
"""Validate a fresh fact for submission to the PDP, not authority to consume.
Action correspondence cannot be established until the evaluator returns its
enriched approval binding. This function deliberately makes no such claim.
"""
observed = _validate_observation(claim, approval_id=approval_id, now=now)
observed = _validate_observation(claim, approval_id=approval_id,
require_human_control=require_human_control, now=now)
binding = observed["binding"]
if binding.get("pdp_path") is not True:
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
@ -172,15 +177,18 @@ def observe_pdp_approval_claim(claim: object, *, approval_id: str,
def validate_approval_claim(claim: object, *, approval_id: str,
expected_binding_digest: str = "", expected_pdp_digest: str = "",
require_human_control: bool = False,
now: datetime | None = None) -> dict[str, Any]:
"""Validate the fact and compare an independently supplied binding.
A PDP digest supplied here must come from the evaluated decision, never a
local reconstruction of the unenriched request.
"""
observed = _validate_observation(claim, approval_id=approval_id, now=now)
observed = _validate_observation(claim, approval_id=approval_id,
require_human_control=require_human_control, now=now)
if expected_pdp_digest:
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
observe_pdp_approval_claim(observed, approval_id=approval_id,
require_human_control=require_human_control, now=now)
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
raise DecisionError("approval claim pdp digest does not match the request")
elif expected_binding_digest:

View file

@ -20,6 +20,7 @@ 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 observe_pdp_approval_claim, validate_approval_claim
from secrets_engine.catalog import human_control_required
from secrets_engine.decision_check import check_decision
from secrets_engine.authorization import (
build_action_request,
@ -40,6 +41,7 @@ class ConsumeBinding:
approval_id: str
request_digest: str
decision_id: str = ""
human_control: bool = False
@dataclass(frozen=True)
@ -68,6 +70,7 @@ class AuthorizedAction:
"authorization_decision_id": self.decision_id,
"authorization_expires_at": self.expires_at,
"request_digest": self.binding.request_digest,
"approval_human_control": self.binding.human_control,
}
@ -222,13 +225,17 @@ def resolve_approval_observation(
) -> ApprovalObservation | None:
"""Observe a fresh fact and prepare the claim-bearing request (step 1).
Missing serving coordinates return None, preserving the production refusal.
Missing serving coordinates return None for undeclared lanes. A declared
human control raises instead, so no stage/demo fallback can discharge it.
Correspondence and a consume binding require authorize_action's PDP step.
"""
base_url = str(getattr(cfg, "approval_url", "") or "")
auth_configured = approval_auth_configured(cfg)
authorization_id = _authorization_id(entry, decision)
required_human_control = human_control_required(entry)
if not base_url or not auth_configured or not authorization_id:
if required_human_control:
raise DecisionError("human_control requires a served approval claim and exact action binding")
return None
expected_request = _expected_request(
@ -242,7 +249,8 @@ def resolve_approval_observation(
authorization_id=authorization_id,
opener=opener or credential_urlopen,
)
observe_pdp_approval_claim(claim, approval_id=authorization_id)
observe_pdp_approval_claim(claim, approval_id=authorization_id,
require_human_control=required_human_control)
expected_request["context"]["approval"] = claim
return ApprovalObservation(authorization_id, expected_request, claim)
@ -310,6 +318,7 @@ def authorize_action(
validate_approval_claim(
observation.claim, approval_id=observation.approval_id,
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
require_human_control=expected_request["context"].get("human_control") is True,
)
if validated.action != action:
raise DecisionError("access-engine decision does not bind this action")
@ -318,6 +327,7 @@ def authorize_action(
approval_id=observation.approval_id,
request_digest=validated.request_digest,
decision_id=validated.decision_id,
human_control=observation.claim["binding"].get("human_control") is True,
),
decision_id=validated.decision_id,
expires_at=validated.expires_at,
@ -413,16 +423,21 @@ def require_production_consume(
evidence: Any = None,
opener: Callable[..., Any] | None = None,
) -> ConsumedApproval | None:
"""CAS-consume before a production OpenBao call. No-op off the prod path.
"""CAS-consume before production or an explicitly human-controlled action.
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.
Undeclared build/test and the unsafe-demo exception retain their existing
behavior. Declared human controls require a verified binding and consume
regardless of stage/demo; missing inputs cannot fall back to a lane review.
"""
if getattr(entry, "stage", "") != "prod":
return None
if demo_exception_enabled(cfg):
return None
required_human_control = human_control_required(entry)
if required_human_control:
if binding is None or binding.human_control is not True:
raise DecisionError("human_control requires an observed declared-human consume binding")
else:
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 "

View file

@ -14,7 +14,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.catalog import CatalogEntry, human_control_required
from secrets_engine.errors import DecisionError
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
@ -105,6 +105,10 @@ def build_action_request(
"context": {"purpose": purpose},
}
)
if human_control_required(entry):
# Bind the requested control to the exact PDP submission. This is a
# requirement on the observed approval, not an authorization verdict.
request["context"]["human_control"] = True
if action == "exec":
from secrets_engine.exec_owner import owner_digest

View file

@ -199,6 +199,15 @@ class CatalogEntry:
return self.kind == "kv"
def human_control_required(entry: Any) -> bool:
"""Declared workflow requirement; never inferred from stage or approvers."""
approval = getattr(entry, "approval", {})
value = approval.get("human_control", False)
if not isinstance(value, bool):
raise CatalogError("approval.human_control must be a boolean")
return value
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
if not isinstance(data, dict):
@ -339,6 +348,12 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
f"allowed {VALID_APPROVAL_MODELS}"
)
human_control = approval.get("human_control", False)
if not isinstance(human_control, bool):
raise CatalogError(f"{source}: approval.human_control must be a boolean")
if human_control and approval["model"] == "bootstrap-only":
raise CatalogError(f"{source}: human_control cannot use bootstrap-only approval")
risk = data.get("risk", {})
if not isinstance(risk, dict):
raise CatalogError(f"{source}: risk must be a mapping")