Require declared human control in factory credential delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
d1c13b5dd6
commit
2b0d04e8e1
11 changed files with 451 additions and 27 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue