From 627810b4784f4646ade0794a4fb411896043a51d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 01:01:55 +0200 Subject: [PATCH] feat: implement the PIP claim + validate authorization join 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 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 --- docs/approval-consumption.md | 40 ++++- src/secrets_engine/approval_consume.py | 169 ++++++++++++++++-- src/secrets_engine/cli.py | 22 ++- src/secrets_engine/config.py | 31 ++++ tests/test_approval_consume.py | 4 +- tests/test_consume_binding_join.py | 163 +++++++++++++++++ tests/test_multifield_readiness.py | 6 +- tests/test_privileged_cli_evidence.py | 4 +- ...-WP-0007-production-lifecycle-hardening.md | 32 +++- ...-0008-layer-model-lifecycle-conformance.md | 10 +- 10 files changed, 456 insertions(+), 25 deletions(-) create mode 100644 tests/test_consume_binding_join.py diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 7790b30..9438155 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -1,7 +1,8 @@ # Approval consumption (PEP) -Status: engine consumer implemented; live production remains fail-closed -until a durable consume binding is served. +Status: engine consumer and the PIP claim/validate join are implemented; live +production remains fail-closed until approval-engine and access-engine actually +serve the durable objects. Normative protocol: `gate-house/docs/contracts/approval-consumption.md` (`GH-DEC-2026-003`). Implementation surface: @@ -18,6 +19,20 @@ does not redefine it. 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`, which calls `require_production_consume` before `OpenBaoClient.resolve`. Dry-run and `plan` do not consume. Build/test remain fail-open relative to @@ -29,6 +44,9 @@ path. | Condition | Result | | --- | --- | | 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 | | HTTP 409 / different digest | refuse; no OpenBao | | 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, 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 - It does not enable live production. Unreachable-engine stance still diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 37a307e..36676a1 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -18,6 +18,11 @@ from typing import Any, Callable from urllib.error import HTTPError, URLError 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.openbao import read_strict_token_file from secrets_engine.pep_stance import demo_exception_enabled @@ -59,18 +64,162 @@ class ConsumedApproval: return payload -def resolve_consume_binding( - _cfg: Any, - _entry: Any, - _action: str, - _decision: Any, -) -> ConsumeBinding | None: - """Return the served consume binding, or None if it is not available. +def _authorization_id(entry: Any, decision: Any) -> str: + """Non-secret approval-engine object id for this lane, or "" if unbound. - The durable ActionAuthorization / approval serving path is still external - (SECRETS-WP-0007-T04 / SECRETS-WP-0008-T02). Tests may replace this hook. + 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. """ - 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( diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 215b97a..a58590e 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -109,6 +109,8 @@ def _require_lane_approval( entry, action: str = "", evidence: PrivilegedActionEvidence | None = None, + *, + fields: tuple[str, ...] = (), ): """Apply published PEP stance, resolve lane approval, then CAS-consume. @@ -136,7 +138,9 @@ def _require_lane_approval( require_production_consume( cfg, 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, ) return decision @@ -311,7 +315,9 @@ def cmd_provision(cfg: Config, args) -> int: raise ProvisioningError( 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) require_provision_state(cfg.evidence_dir, entry.id) with _open_backend(cfg, args, evidence) as client: @@ -346,7 +352,9 @@ def cmd_rotate(cfg: Config, args) -> int: raise ProvisioningError( 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) with _open_backend(cfg, args, evidence) as client: 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, }, ) 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) with _open_backend(cfg, args, evidence) as client: if entry.stores_kv_value() and not fields: @@ -540,7 +550,9 @@ def cmd_exec(cfg: Config, args) -> int: }, ) as evidence: # 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) require_delivery_state(cfg.evidence_dir, entry.id, "exec") if not args.command: diff --git a/src/secrets_engine/config.py b/src/secrets_engine/config.py index f73b611..dbf72f1 100644 --- a/src/secrets_engine/config.py +++ b/src/secrets_engine/config.py @@ -19,6 +19,15 @@ def repo_root() -> Path: 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) class Config: catalog_dir: Path @@ -33,6 +42,13 @@ class Config: keycape_issuer: str = "" keycape_client_secret_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 def load(cls) -> "Config": @@ -55,4 +71,19 @@ class Config: keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""), keycape_client_secret_file=Path(keycape_secret) if keycape_secret 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", "") + ), ) diff --git a/tests/test_approval_consume.py b/tests/test_approval_consume.py index 6dbf664..6001f38 100644 --- a/tests/test_approval_consume.py +++ b/tests/test_approval_consume.py @@ -293,7 +293,7 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch): monkeypatch.setattr( cli, "resolve_consume_binding", - lambda *_args: _binding(), + lambda *_args, **_kwargs: _binding(), ) monkeypatch.setattr( "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, "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.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) monkeypatch.setattr( diff --git a/tests/test_consume_binding_join.py b/tests/test_consume_binding_join.py new file mode 100644 index 0000000..600cf2e --- /dev/null +++ b/tests/test_consume_binding_join.py @@ -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")) diff --git a/tests/test_multifield_readiness.py b/tests/test_multifield_readiness.py index 985ae8b..21a7c69 100644 --- a/tests/test_multifield_readiness.py +++ b/tests/test_multifield_readiness.py @@ -80,7 +80,7 @@ def test_verify_defaults_to_every_declared_field_and_one_path_denial( records = [] 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()) def fake_verify( @@ -130,7 +130,9 @@ def test_live_destroy_fails_before_approval_or_backend_until_action_contract( monkeypatch.setattr( cli, "_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( cli.OpenBaoClient, diff --git a/tests/test_privileged_cli_evidence.py b/tests/test_privileged_cli_evidence.py index e726ab5..b1f729b 100644 --- a/tests/test_privileged_cli_evidence.py +++ b/tests/test_privileged_cli_evidence.py @@ -43,7 +43,7 @@ def test_provision_backend_exception_has_attempt_and_terminal_evidence( ): entry = validate_entry(copy.deepcopy(VALID)) 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, @@ -78,7 +78,7 @@ def test_provision_decision_rejection_is_recorded_before_backend( monkeypatch.setattr( cli, "_require_lane_approval", - lambda *_args: (_ for _ in ()).throw(DecisionError("not approved")), + lambda *_args, **_kwargs: (_ for _ in ()).throw(DecisionError("not approved")), ) monkeypatch.setattr( cli.OpenBaoClient, diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index 04aa952..a5ffe96 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: custodian created: "2026-08-23" -updated: "2026-09-02" +updated: "2026-09-06" 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 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 resolved approval must bind at least: diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index 12536d2..4717203 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -8,7 +8,7 @@ status: active owner: grok topic_slug: custodian created: "2026-08-29" -updated: "2026-09-02" +updated: "2026-09-06" 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 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 `SECRETS-WP-0007-T04` / State Hub / `access-engine`.