feat: implement the PIP claim + validate authorization join
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

resolve_consume_binding was a `return None` stub, so protocol step 1 of
docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the
validation join never existed. validate_action_authorization had no caller
in src/ at all - it was reachable only from tests. Production fail-closed
was correct, but for an undocumented second reason, and WP-0007-T04's
"what remains is not local engine work" was wrong.

The join now reproduces the exact CheckRequest via build_action_request,
fetches the durable ActionAuthorization, and validates request binding,
digest, validity, authority, policy pin, and distinct-approver threshold
before offering a consume binding. _require_lane_approval threads the exact
field set for provision/rotate/verify/exec so the digest covers the real
proposed action.

Deliberate choices:
- The approval-engine object id is never inferred from a State Hub decision
  UUID; flex-auth stated GET /decisions/{uuid} is not the durable object.
- No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is
  example vocabulary, not a published package.
- A half-configured join raises rather than returning None, so a partial
  deployment cannot be mistaken for an unconfigured one.

Behavior is unchanged today: every new input is absent by default, so
production still fails closed and plan/--dry-run still work. 234 tests pass.

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
This commit is contained in:
tegwick 2026-09-06 01:01:55 +02:00
parent ebcc36ecab
commit 627810b478
10 changed files with 456 additions and 25 deletions

View file

@ -1,7 +1,8 @@
# Approval consumption (PEP) # Approval consumption (PEP)
Status: engine consumer implemented; live production remains fail-closed Status: engine consumer and the PIP claim/validate join are implemented; live
until a durable consume binding is served. production remains fail-closed until approval-engine and access-engine actually
serve the durable objects.
Normative protocol: `gate-house/docs/contracts/approval-consumption.md` Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
(`GH-DEC-2026-003`). Implementation surface: (`GH-DEC-2026-003`). Implementation surface:
@ -18,6 +19,20 @@ does not redefine it.
4. PEP OpenBao call → only after consume succeeds 4. PEP OpenBao call → only after consume succeeds
``` ```
Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the
exact CheckRequest with `build_action_request`, fetches the durable
`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with
`validate_action_authorization` before returning a consume binding. Until
2026-09-06 that function was a `return None` stub and the validator was
unreachable from `src/`; the join now exists.
The approval-engine object id is never inferred from a State Hub decision UUID.
It comes from catalog `approval.authorization_id` or a served
`decision.authorization_id`. The Check request `id` is an opaque correlator the
PEP cannot regenerate, so the served one is adopted; every security-relevant
field is still compared exactly and the binding digest is recomputed against the
served request.
Every live privileged production handler passes `_require_lane_approval`, Every live privileged production handler passes `_require_lane_approval`,
which calls `require_production_consume` before `OpenBaoClient.resolve`. which calls `require_production_consume` before `OpenBaoClient.resolve`.
Dry-run and `plan` do not consume. Build/test remain fail-open relative to Dry-run and `plan` do not consume. Build/test remain fail-open relative to
@ -29,6 +44,9 @@ path.
| Condition | Result | | Condition | Result |
| --- | --- | | --- | --- |
| No served consume binding | refuse; no OpenBao | | No served consume binding | refuse; no OpenBao |
| URL/token/authorization id all unset | binding is `None` → refuse; no OpenBao |
| Partially configured join (missing subject or policy pin) | raise; never degrade to "unconfigured" |
| Claim digest, action, or field set mismatch | refuse; no OpenBao |
| Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao | | Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao |
| HTTP 409 / different digest | refuse; no OpenBao | | HTTP 409 / different digest | refuse; no OpenBao |
| Same digest after consume | idempotent success; OpenBao may proceed | | Same digest after consume | idempotent success; OpenBao may proceed |
@ -39,6 +57,24 @@ The consume response is mutation evidence (`status=consumed` plus the
presented digest). It is not a permission. Evidence records approval id, presented digest). It is not a permission. Evidence records approval id,
digest, idempotence, and consumed-at only. No token, secret, or accessor. digest, idempotence, and consumed-at only. No token, secret, or accessor.
## Required configuration
The join is absent by default, so an unconfigured engine behaves exactly as it
did before. Production additionally needs:
| Variable | Meaning |
| --- | --- |
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin |
| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold |
There is deliberately no default policy pin. flex-auth stated that the published
`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope,
not a live package, so treating them as a default would pin production to a
package nobody publishes.
## What this does not do ## What this does not do
- It does not enable live production. Unreachable-engine stance still - It does not enable live production. Unreachable-engine stance still

View file

@ -18,6 +18,11 @@ from typing import Any, Callable
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from secrets_engine.authorization import (
build_action_request,
request_digest,
validate_action_authorization,
)
from secrets_engine.errors import DecisionError from secrets_engine.errors import DecisionError
from secrets_engine.openbao import read_strict_token_file from secrets_engine.openbao import read_strict_token_file
from secrets_engine.pep_stance import demo_exception_enabled from secrets_engine.pep_stance import demo_exception_enabled
@ -59,18 +64,162 @@ class ConsumedApproval:
return payload return payload
def resolve_consume_binding( def _authorization_id(entry: Any, decision: Any) -> str:
_cfg: Any, """Non-secret approval-engine object id for this lane, or "" if unbound.
_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 flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID.
(SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). Tests may replace this hook. It is never the State Hub decision UUID, so it is not inferred from one.
""" """
return None 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 fetch_action_authorization(
*,
base_url: str,
token_file: Path,
authorization_id: str,
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
) -> dict[str, Any]:
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed."""
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 = read_strict_token_file(Path(token_file), purpose="approval claim credential")
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 the authorization")
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 ActionAuthorization (PIP + validate).
Returns None only when this repo holds no configured serving path, which
keeps production fail-closed exactly as it was before the join existed.
Anything configured-but-wrong raises instead of degrading to None: a
half-configured PEP must not look like an unconfigured one.
"""
base_url = str(getattr(cfg, "approval_url", "") or "")
token_file = getattr(cfg, "approval_token_file", None)
authorization_id = _authorization_id(entry, decision)
if not base_url or not token_file or not authorization_id:
return None
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"
)
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 published example vocabulary "
"(secrets-engine.lifecycle/v1) is not a live pin"
)
purpose = _request_purpose(entry)
if not purpose:
raise DecisionError(
"authorization join requires a declared approval/consumer purpose"
)
envelope = fetch_action_authorization(
base_url=base_url,
token_file=Path(token_file),
authorization_id=authorization_id,
opener=opener or urlopen,
)
# The Check request id is an opaque correlator chosen by the requester, so
# the PEP cannot regenerate it and adopts the served one. Every
# security-relevant field (subject, action, resource, context) is still
# compared exactly by validate_action_authorization, and the served
# binding digest is recomputed against the served request, so adopting the
# id cannot let a mismatched request validate.
served_request = envelope.get("request")
served_id = ""
if isinstance(served_request, dict):
served_id = str(served_request.get("id", "") or "")
expected_request = 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,
request_id=served_id,
)
validated = validate_action_authorization(
envelope,
expected_request,
accepted_policy_packages={package},
accepted_policy_versions={version},
minimum_approval_count=int(getattr(cfg, "authorization_min_approvals", 1) or 1),
)
if validated.action != action:
raise DecisionError("action authorization does not bind this action")
return ConsumeBinding(
approval_id=validated.authorization_id,
request_digest=request_digest(expected_request),
decision_id=validated.decision_id,
)
def consume_approval( def consume_approval(

View file

@ -109,6 +109,8 @@ def _require_lane_approval(
entry, entry,
action: str = "", action: str = "",
evidence: PrivilegedActionEvidence | None = None, evidence: PrivilegedActionEvidence | None = None,
*,
fields: tuple[str, ...] = (),
): ):
"""Apply published PEP stance, resolve lane approval, then CAS-consume. """Apply published PEP stance, resolve lane approval, then CAS-consume.
@ -136,7 +138,9 @@ def _require_lane_approval(
require_production_consume( require_production_consume(
cfg, cfg,
entry, entry,
binding=resolve_consume_binding(cfg, entry, action or "unknown", decision), binding=resolve_consume_binding(
cfg, entry, action or "unknown", decision, fields=fields
),
evidence=evidence, evidence=evidence,
) )
return decision return decision
@ -311,7 +315,9 @@ def cmd_provision(cfg: Config, args) -> int:
raise ProvisioningError( raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'" f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
) )
decision = _require_lane_approval(cfg, entry, "provision", evidence) decision = _require_lane_approval(
cfg, entry, "provision", evidence, fields=(field,) if field else ()
)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_provision_state(cfg.evidence_dir, entry.id) require_provision_state(cfg.evidence_dir, entry.id)
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
@ -346,7 +352,9 @@ def cmd_rotate(cfg: Config, args) -> int:
raise ProvisioningError( raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'" f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
) )
decision = _require_lane_approval(cfg, entry, "rotate", evidence) decision = _require_lane_approval(
cfg, entry, "rotate", evidence, fields=(field,) if field else ()
)
evidence.mark_approved(decision) evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
f = rotate_from_file(client, entry, field, Path(args.from_file)) f = rotate_from_file(client, entry, field, Path(args.from_file))
@ -386,7 +394,9 @@ def cmd_verify(cfg: Config, args) -> int:
"negative_requested": negative, "negative_requested": negative,
}, },
) as evidence: ) as evidence:
decision = _require_lane_approval(cfg, entry, "verify", evidence) decision = _require_lane_approval(
cfg, entry, "verify", evidence, fields=tuple(fields)
)
evidence.mark_approved(decision) evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
if entry.stores_kv_value() and not fields: if entry.stores_kv_value() and not fields:
@ -540,7 +550,9 @@ def cmd_exec(cfg: Config, args) -> int:
}, },
) as evidence: ) as evidence:
# require approval + readiness before running. # require approval + readiness before running.
decision = _require_lane_approval(cfg, entry, "exec", evidence) decision = _require_lane_approval(
cfg, entry, "exec", evidence, fields=(field,) if field else ()
)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "exec") require_delivery_state(cfg.evidence_dir, entry.id, "exec")
if not args.command: if not args.command:

View file

@ -19,6 +19,15 @@ def repo_root() -> Path:
return Path.cwd() return Path.cwd()
def _positive_int(raw: str, default: int = 1) -> int:
"""Parse a positive approval threshold. Anything malformed keeps the default."""
try:
value = int(raw)
except (TypeError, ValueError):
return default
return value if value >= 1 else default
@dataclass(frozen=True) @dataclass(frozen=True)
class Config: class Config:
catalog_dir: Path catalog_dir: Path
@ -33,6 +42,13 @@ class Config:
keycape_issuer: str = "" keycape_issuer: str = ""
keycape_client_secret_file: Path | None = None keycape_client_secret_file: Path | None = None
openbao_jwt_login_file: Path | None = None openbao_jwt_login_file: Path | None = None
# PIP/PDP join (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). All absent by
# default: an unset value fails production closed exactly as before.
authorization_subject_id: str = ""
authorization_subject_type: str = ""
authorization_policy_package: str = ""
authorization_policy_version: str = ""
authorization_min_approvals: int = 1
@classmethod @classmethod
def load(cls) -> "Config": def load(cls) -> "Config":
@ -55,4 +71,19 @@ class Config:
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""), keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None, keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None,
openbao_jwt_login_file=Path(jwt_login) if jwt_login else None, openbao_jwt_login_file=Path(jwt_login) if jwt_login else None,
authorization_subject_id=os.environ.get(
"SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID", ""
),
authorization_subject_type=os.environ.get(
"SECRETS_ENGINE_AUTHORIZATION_SUBJECT_TYPE", ""
),
authorization_policy_package=os.environ.get(
"SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE", ""
),
authorization_policy_version=os.environ.get(
"SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION", ""
),
authorization_min_approvals=_positive_int(
os.environ.get("SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS", "")
),
) )

View file

@ -293,7 +293,7 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
cli, cli,
"resolve_consume_binding", "resolve_consume_binding",
lambda *_args: _binding(), lambda *_args, **_kwargs: _binding(),
) )
monkeypatch.setattr( monkeypatch.setattr(
"secrets_engine.approval_consume.urlopen", "secrets_engine.approval_consume.urlopen",
@ -328,7 +328,7 @@ def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args: _binding()) monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args, **_kwargs: _binding())
monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener) monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr( monkeypatch.setattr(

View file

@ -0,0 +1,163 @@
"""PIP claim + validate join (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02).
These cover the seam that was previously a `return None` stub: the engine now
reproduces the exact CheckRequest, fetches the durable ActionAuthorization, and
validates it before offering a consume binding. A half-configured PEP must
raise rather than look like an unconfigured one.
"""
import copy
import io
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from secrets_engine.approval_consume import resolve_consume_binding
from secrets_engine.authorization import build_action_request, request_digest
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
from tests.test_action_authorization import _envelope
from tests.test_catalog import VALID
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
class _Cfg:
def __init__(self, token_file, **over):
self.approval_url = "https://approval.example"
self.approval_token_file = token_file
self.authorization_subject_id = "user:alice"
self.authorization_subject_type = "Human"
self.authorization_policy_package = "secrets-engine.lifecycle"
self.authorization_policy_version = "v1"
self.authorization_min_approvals = 2
for k, v in over.items():
setattr(self, k, v)
def _entry():
raw = copy.deepcopy(VALID)
raw["approval"] = dict(raw.get("approval") or {})
raw["approval"]["authorization_id"] = AUTH_ID
raw["approval"]["purpose"] = "contract-test"
return validate_entry(raw)
def _token(tmp_path):
f = tmp_path / "approval.token"
f.write_text("token-value\n")
f.chmod(0o600)
return f
def _served(**over):
"""A served envelope whose validity window is live now."""
env = copy.deepcopy(_envelope())
now = datetime.now(timezone.utc)
env["validity"] = {
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
approved = (now - timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
for approval in env["approvals"]["entries"]:
approval["approved_at"] = approved
env.update(over)
return env
def _opener(envelope, status=200):
def _open(request, timeout=None):
body = json.dumps(envelope).encode()
resp = io.BytesIO(body)
resp.status = status
resp.__enter__ = lambda s=resp: s
resp.__exit__ = lambda s, *a: False
return resp
return _open
def _resolve(cfg, entry, envelope, action="deactivate"):
return resolve_consume_binding(
cfg, entry, action, None,
fields=("api_token",),
policy_targets=(entry.policy_name,),
auth_targets=(entry.role_name,),
opener=_opener(envelope),
)
def test_unconfigured_serving_path_stays_fail_closed(tmp_path):
"""No URL/token/authorization id: None, exactly as before the join existed."""
cfg = _Cfg(None, approval_url="", approval_token_file=None)
assert resolve_consume_binding(cfg, _entry(), "deactivate", None) is None
def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
entry = _entry()
cfg = _Cfg(_token(tmp_path))
binding = _resolve(cfg, entry, _served())
assert binding is not None
assert binding.approval_id == AUTH_ID
expected = build_action_request(
entry, "deactivate",
subject_id="user:alice", subject_type="Human", purpose="contract-test",
fields=["api_token"],
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
request_id="check:test-lane-deactivate",
)
assert binding.request_digest == request_digest(expected)
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
"""Half-configured must not be mistaken for unconfigured."""
cfg = _Cfg(_token(tmp_path), authorization_subject_id="")
with pytest.raises(DecisionError, match="SUBJECT_ID"):
_resolve(cfg, _entry(), _served())
def test_example_policy_names_are_not_an_implicit_pin(tmp_path):
"""flex-auth: the published example vocabulary is not a live pin."""
cfg = _Cfg(_token(tmp_path), authorization_policy_package="")
with pytest.raises(DecisionError, match="policy .*pin"):
_resolve(cfg, _entry(), _served())
def test_wrong_field_set_fails_closed(tmp_path):
"""A different proposed field set must not match the served digest."""
entry = _entry()
cfg = _Cfg(_token(tmp_path))
with pytest.raises(DecisionError):
resolve_consume_binding(
cfg, entry, "deactivate", None,
fields=("some_other_field",),
policy_targets=(entry.policy_name,),
auth_targets=(entry.role_name,),
opener=_opener(_served()),
)
def test_action_mismatch_fails_closed(tmp_path):
"""A destroy must never ride a deactivate authorization."""
entry = _entry()
cfg = _Cfg(_token(tmp_path))
with pytest.raises(DecisionError):
_resolve(cfg, entry, _served(), action="destroy")
def test_unreachable_approval_engine_fails_closed(tmp_path):
from urllib.error import URLError
def _boom(request, timeout=None):
raise URLError("no route")
with pytest.raises(DecisionError, match="unreachable"):
resolve_consume_binding(
_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
fields=("api_token",), opener=_boom,
)
def test_superseded_authorization_fails_closed(tmp_path):
with pytest.raises(DecisionError):
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(status="superseded"))

View file

@ -80,7 +80,7 @@ def test_verify_defaults_to_every_declared_field_and_one_path_denial(
records = [] records = []
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args: None) monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object()) monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
def fake_verify( def fake_verify(
@ -130,7 +130,9 @@ def test_live_destroy_fails_before_approval_or_backend_until_action_contract(
monkeypatch.setattr( monkeypatch.setattr(
cli, cli,
"_require_lane_approval", "_require_lane_approval",
lambda *_args: pytest.fail("coarse lane approval must not authorize destroy"), lambda *_args, **_kwargs: pytest.fail(
"coarse lane approval must not authorize destroy"
),
) )
monkeypatch.setattr( monkeypatch.setattr(
cli.OpenBaoClient, cli.OpenBaoClient,

View file

@ -43,7 +43,7 @@ def test_provision_backend_exception_has_attempt_and_terminal_evidence(
): ):
entry = validate_entry(copy.deepcopy(VALID)) entry = validate_entry(copy.deepcopy(VALID))
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry) monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args: None) monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object()) monkeypatch.setattr(cli.OpenBaoClient, "resolve", lambda *_args, **_kwargs: object())
monkeypatch.setattr( monkeypatch.setattr(
cli, cli,
@ -78,7 +78,7 @@ def test_provision_decision_rejection_is_recorded_before_backend(
monkeypatch.setattr( monkeypatch.setattr(
cli, cli,
"_require_lane_approval", "_require_lane_approval",
lambda *_args: (_ for _ in ()).throw(DecisionError("not approved")), lambda *_args, **_kwargs: (_ for _ in ()).throw(DecisionError("not approved")),
) )
monkeypatch.setattr( monkeypatch.setattr(
cli.OpenBaoClient, cli.OpenBaoClient,

View file

@ -8,7 +8,7 @@ status: active
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-08-23" created: "2026-08-23"
updated: "2026-09-02" updated: "2026-09-06"
state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3" state_hub_workstream_id: "68a39be1-bd9c-5133-ad64-e7bca892aaf3"
--- ---
@ -243,6 +243,36 @@ exception. Live destroy remains disabled independently. State Hub endpoint and
authenticated approval storage are still outstanding. flex-auth corrected its authenticated approval storage are still outstanding. flex-auth corrected its
example digest and added a complete binding regression assertion in `d402408`. example digest and added a complete binding regression assertion in `d402408`.
Correction 2026-09-06. The 2026-08-29 note above ("What remains is not local
engine work") was wrong. `resolve_consume_binding` was a `return None` stub, so
protocol step 1 (PIP `GET /v1/approvals/{id}/claim`) and the validation join
were never implemented, and `validate_action_authorization` — the validator this
plan called "shipped" — had no caller in `src/` at all. Only step 3 (CAS
consume) and the OpenBao gate were real. Production fail-closed was therefore
correct but for a second, undocumented reason.
The join is now implemented. `resolve_consume_binding` reproduces the exact
CheckRequest via `build_action_request`, fetches the durable
ActionAuthorization, and validates request binding, digest, validity, authority,
policy pin, and distinct-approver threshold before returning a binding.
`_require_lane_approval` threads the exact field set for provision, rotate,
verify, and exec so the digest covers the real proposed action. Eight tests in
`tests/test_consume_binding_join.py` cover unconfigured, half-configured, digest
mismatch, action mismatch, unreachable, and superseded paths.
Two deliberate choices, both recorded in `docs/approval-consumption.md`:
the approval-engine object id is never inferred from a State Hub decision UUID
(flex-auth: `GET /decisions/{uuid}` is not the durable object); and there is no
default policy pin, because flex-auth stated `secrets-engine.lifecycle`/`v1` is
example vocabulary rather than a published package.
Behavior today is bit-for-bit unchanged: every new input is absent by default,
so an unconfigured engine still fails production closed, and `plan`/`--dry-run`
still work. This task stays `wait`, but the remaining constraint is now purely
deployment: approval-engine must serve the claim endpoint and access-engine must
serve Check. Probed 2026-09-06 — neither is reachable, and State Hub exposes
only `/decisions/`.
Define and enforce the decision contract needed by production commands. A Define and enforce the decision contract needed by production commands. A
resolved approval must bind at least: resolved approval must bind at least:

View file

@ -8,7 +8,7 @@ status: active
owner: grok owner: grok
topic_slug: custodian topic_slug: custodian
created: "2026-08-29" created: "2026-08-29"
updated: "2026-09-02" updated: "2026-09-06"
state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0" state_hub_workstream_id: "9c9e5164-b2f5-5ea2-a557-5368d65e9fe0"
--- ---
@ -103,6 +103,14 @@ binding fail closed with no OpenBao call. Production still also fail-closes
on the unpublished durable ActionAuthorization / consume-binding serving on the unpublished durable ActionAuthorization / consume-binding serving
path, so this task remains `wait`. path, so this task remains `wait`.
Correction 2026-09-06. See `SECRETS-WP-0007-T04` for the full note. Summary: the
consume-before-OpenBao gate was real, but the PIP claim + validate join was a
`return None` stub and `validate_action_authorization` had no production caller,
so this task's "blocked on the durable serving path" framing hid an unimplemented
local seam. The join is now implemented and tested; the remaining blocker is
genuinely external (approval-engine claim endpoint and access-engine Check, both
unreachable as of 2026-09-06).
Blocked on the durable ActionAuthorization serving path owned with Blocked on the durable ActionAuthorization serving path owned with
`SECRETS-WP-0007-T04` / State Hub / `access-engine`. `SECRETS-WP-0007-T04` / State Hub / `access-engine`.