From f62d3fe7894abbb730f1a60edd59b81451e9e614 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 14:56:02 +0200 Subject: [PATCH] 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 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 | 1 + src/secrets_engine/approval_consume.py | 181 ++++++++++++++---- src/secrets_engine/cli.py | 17 +- src/secrets_engine/config.py | 5 + src/secrets_engine/decision_check.py | 73 ++++++++ src/secrets_engine/pep_stance.py | 19 +- tests/authorization_stub.py | 173 +++++++++++++++++ tests/test_approval_consume.py | 14 +- tests/test_integration_authorization.py | 236 ++++++++++++++++++++++++ 9 files changed, 675 insertions(+), 44 deletions(-) create mode 100644 src/secrets_engine/decision_check.py create mode 100644 tests/authorization_stub.py create mode 100644 tests/test_integration_authorization.py diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 6b1e8d5..2c3c30b 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -111,6 +111,7 @@ did before. Production additionally needs: | `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 (step 2) | +| `SECRETS_ENGINE_PDP_URL` / `_PDP_TOKEN_FILE` | the per-consumer access-engine pin | The distinct-approver threshold is no longer a consumer-side check. The claim does not expose approver entries; approval-engine folds that requirement into diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index eac821f..1dbd6a4 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -19,7 +19,12 @@ from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from secrets_engine.approval_claim import validate_approval_claim -from secrets_engine.authorization import build_action_request, request_digest +from secrets_engine.decision_check import check_decision +from secrets_engine.authorization import ( + build_action_request, + request_digest, + validate_decision_envelope, +) from secrets_engine.errors import DecisionError from secrets_engine.openbao import read_strict_token_file from secrets_engine.pep_stance import demo_exception_enabled @@ -37,6 +42,26 @@ class ConsumeBinding: decision_id: str = "" +@dataclass(frozen=True) +class AuthorizedAction: + """A validated claim and decision for one exact proposed action. + + Holding this is not authority to act: GH-DEC-2026-003 still requires a + successful CAS consume before the OpenBao call. + """ + + binding: ConsumeBinding + decision_id: str + expires_at: str + + def as_evidence(self) -> dict[str, object]: + return { + "authorization_decision_id": self.decision_id, + "authorization_expires_at": self.expires_at, + "request_digest": self.binding.request_digest, + } + + @dataclass(frozen=True) class ConsumedApproval: """Non-secret confirmation that consume succeeded for this request.""" @@ -94,6 +119,44 @@ def _request_purpose(entry: Any) -> str: return "" +def _expected_request( + cfg: Any, + entry: Any, + action: str, + *, + fields: tuple[str, ...] = (), + policy_targets: tuple[str, ...] = (), + auth_targets: tuple[str, ...] = (), +) -> dict[str, Any]: + """Build the exact CheckRequest both steps must agree on. + + Steps 1 and 2 must describe the same proposed action or the digests cannot + correspond, so neither builds its own. + """ + 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" + ) + purpose = _request_purpose(entry) + if not purpose: + raise DecisionError( + "authorization join requires a declared approval/consumer purpose" + ) + return 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, + ) + + def fetch_approval_claim( *, base_url: str, @@ -147,18 +210,12 @@ def resolve_consume_binding( auth_targets: tuple[str, ...] = (), opener: Callable[..., Any] | None = None, ) -> ConsumeBinding | None: - """Join the proposed action to a served approval-claim (step 1 of GH-DEC-2026-003). + """Join the proposed action to a served approval-claim (step 1). Returns None only when no serving path is configured at all, keeping production fail-closed exactly as it was before the join existed. Anything configured-but-wrong raises: a half-configured PEP must not look like an unconfigured one. - - Step 2 (the flex-auth DecisionEnvelope from POST /v1/check) is validated by - ``authorization.validate_decision_envelope``. No PDP is reachable for this - consumer yet -- flex-auth runs per-consumer cluster-local pins and - ``flex-auth-secrets-engine`` has not been created -- so that call is not - wired here and production stays closed at the stance gate regardless. """ base_url = str(getattr(cfg, "approval_url", "") or "") token_file = getattr(cfg, "approval_token_file", None) @@ -166,29 +223,11 @@ def resolve_consume_binding( 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" - ) - purpose = _request_purpose(entry) - if not purpose: - raise DecisionError( - "authorization join requires a declared approval/consumer purpose" - ) - - 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, + expected_request = _expected_request( + cfg, entry, action, + fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, ) + # Two different digests over the same proposed action, by contract; they are # never compared to each other. # @@ -215,9 +254,11 @@ def resolve_consume_binding( approval_id=authorization_id, expected_pdp_digest=pdp_digest, ) - binding = claim.get("binding") or {} - if isinstance(binding, dict) and binding.get("action") not in (None, action): - raise DecisionError("approval claim does not bind this action") + # No action comparison here. The claim's binding.action is approval-engine + # vocabulary ("secrets.kv.destroy") and ours is the catalog's ("destroy"); + # comparing them would fail against every real claim, which is the same + # cross-vocabulary mistake the native digest made. The tie to this exact + # action is pdp_digest, checked above. return ConsumeBinding( approval_id=authorization_id, request_digest=pdp_digest, @@ -225,6 +266,80 @@ def resolve_consume_binding( ) +def authorize_action( + cfg: Any, + entry: Any, + action: str, + decision: Any = None, + *, + fields: tuple[str, ...] = (), + policy_targets: tuple[str, ...] = (), + auth_targets: tuple[str, ...] = (), + opener: Callable[..., Any] | None = None, + pdp_opener: Callable[..., Any] | None = None, +) -> AuthorizedAction | None: + """Run steps 1 and 2 for one proposed action, or return None if unserved. + + Step 1 validates the approval-claim; step 2 obtains and validates the + flex-auth DecisionEnvelope. Returning None means no serving path is + configured at all, which leaves production fail-closed. A configured but + failing path raises: a partial deployment must not read as an absent one. + """ + binding = resolve_consume_binding( + cfg, entry, action, decision, + fields=fields, + policy_targets=policy_targets, + auth_targets=auth_targets, + opener=opener, + ) + if binding is None: + return None + + pdp_url = str(getattr(cfg, "pdp_url", "") or "") + pdp_token = getattr(cfg, "pdp_token_file", None) + if not pdp_url or not pdp_token: + raise DecisionError( + "production action requires an access-engine decision; " + "SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset" + ) + 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 reserved coordinate is a reservation, " + "not a publication, and must not be used as a default" + ) + + expected_request = _expected_request( + cfg, entry, action, + fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, + ) + envelope = check_decision( + base_url=pdp_url, + token_file=Path(pdp_token), + request=expected_request, + opener=pdp_opener or urlopen, + ) + validated = validate_decision_envelope( + envelope, + expected_request, + accepted_policy_packages={package}, + accepted_policy_versions={version}, + ) + if validated.action != action: + raise DecisionError("access-engine decision does not bind this action") + return AuthorizedAction( + binding=ConsumeBinding( + approval_id=binding.approval_id, + request_digest=binding.request_digest, + decision_id=validated.decision_id, + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, + ) + + def consume_approval( *, base_url: str, diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index a58590e..3e71df2 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -35,6 +35,7 @@ from types import SimpleNamespace from secrets_engine import __version__ from secrets_engine.apply import apply_plan from secrets_engine.approval_consume import ( + authorize_action, require_production_consume, resolve_consume_binding, ) @@ -122,9 +123,19 @@ def _require_lane_approval( consume path. Build/test ``fail_open`` still requires the existing lane-approval check — a tracked gap until SECRETS-WP-0008-T02. """ - stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown") + # Steps 1 and 2 first: a validated claim and decision are what make the + # unreachable-engine residue inapplicable. Absent configuration returns + # None and production stays closed exactly as before. + authorization = authorize_action( + cfg, entry, action or "unknown", None, fields=fields + ) + stance = apply_unreachable_engine_stance( + cfg, entry, action or "unknown", authorized=authorization is not None + ) if evidence is not None: evidence.mark_stance(stance) + if authorization is not None: + evidence.detail.update(authorization.as_evidence()) decision = None if entry.approval_required(): decision = resolve_decision( @@ -138,9 +149,7 @@ def _require_lane_approval( require_production_consume( cfg, entry, - binding=resolve_consume_binding( - cfg, entry, action or "unknown", decision, fields=fields - ), + binding=authorization.binding if authorization is not None else None, evidence=evidence, ) return decision diff --git a/src/secrets_engine/config.py b/src/secrets_engine/config.py index dbf72f1..de2743d 100644 --- a/src/secrets_engine/config.py +++ b/src/secrets_engine/config.py @@ -49,6 +49,8 @@ class Config: authorization_policy_package: str = "" authorization_policy_version: str = "" authorization_min_approvals: int = 1 + pdp_url: str = "" + pdp_token_file: Path | None = None @classmethod def load(cls) -> "Config": @@ -56,6 +58,7 @@ class Config: token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "") keycape_secret = os.environ.get("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "") jwt_login = os.environ.get("SECRETS_ENGINE_OPENBAO_JWT_LOGIN", "") + pdp_token = os.environ.get("SECRETS_ENGINE_PDP_TOKEN_FILE", "") return cls( catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")), policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")), @@ -86,4 +89,6 @@ class Config: authorization_min_approvals=_positive_int( os.environ.get("SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS", "") ), + pdp_url=os.environ.get("SECRETS_ENGINE_PDP_URL", ""), + pdp_token_file=Path(pdp_token) if pdp_token else None, ) diff --git a/src/secrets_engine/decision_check.py b/src/secrets_engine/decision_check.py new file mode 100644 index 0000000..316a86f --- /dev/null +++ b/src/secrets_engine/decision_check.py @@ -0,0 +1,73 @@ +"""access-engine (flex-auth) Check client — step 2 of GH-DEC-2026-003. + +This engine consumes a decision; it never renders one. The DecisionEnvelope +returned here is validated by ``authorization.validate_decision_envelope`` +against the exact proposed action before it can satisfy the production stance. + +No estate-wide PDP exists by design: flex-auth runs per-consumer cluster-local +pins, so the address is per-deployment configuration and its absence fails +production closed. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from secrets_engine.errors import DecisionError +from secrets_engine.openbao import read_strict_token_file + +_MAX_BODY = 512 * 1024 + + +def _status_message(status: int) -> str: + if status in (401, 403): + return "access-engine refused the caller" + if status == 404: + return "access-engine has no such endpoint" + if status == 503: + return "access-engine is unavailable" + return f"access-engine returned HTTP {status}" + + +def check_decision( + *, + base_url: str, + token_file: Path, + request: dict[str, Any], + timeout_seconds: float = 3, + opener: Callable[..., Any] = urlopen, +) -> dict[str, Any]: + """POST /v1/check. Any non-200, non-JSON, or transport failure fails closed. + + Silence is never permission: an unreachable PDP raises rather than + returning a permissive default. + """ + if not base_url or not base_url.startswith(("http://", "https://")): + raise DecisionError("access-engine check URL is missing or invalid") + token = read_strict_token_file(Path(token_file), purpose="access-engine credential") + encoded = json.dumps(request).encode("utf-8") + http_request = Request( + base_url.rstrip("/") + "/v1/check", + data=encoded, + method="POST", + ) + http_request.add_header("Authorization", f"Bearer {token}") + http_request.add_header("Content-Type", "application/json") + http_request.add_header("Accept", "application/json") + try: + with opener(http_request, timeout=timeout_seconds) as response: + if getattr(response, "status", 200) != 200: + raise DecisionError("access-engine check did not return a decision") + payload = json.loads(response.read(_MAX_BODY).decode("utf-8")) + except HTTPError as e: + raise DecisionError(f"access-engine check refused: {_status_message(e.code)}") from e + except URLError as e: + raise DecisionError("access-engine is unreachable for check") from e + except json.JSONDecodeError as e: + raise DecisionError("access-engine check returned a non-JSON body") from e + if not isinstance(payload, dict): + raise DecisionError("access-engine check returned a non-object body") + return payload diff --git a/src/secrets_engine/pep_stance.py b/src/secrets_engine/pep_stance.py index 159951e..47141cb 100644 --- a/src/secrets_engine/pep_stance.py +++ b/src/secrets_engine/pep_stance.py @@ -36,12 +36,14 @@ class StanceApplication: 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 @@ -111,12 +113,20 @@ def apply_unreachable_engine_stance( action: str, *, stance_map: PepStanceMap | None = None, + authorized: bool = False, ) -> StanceApplication: """Apply the published unreachable-engine residue for a live action. - ``fail_closed`` without the demo exception raises ``DecisionError`` carrying - named stance fields. ``fail_open`` is the documented residue: continue to - the existing lane-approval check, which is itself a gap until T02. + ``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")) @@ -126,8 +136,9 @@ def apply_unreachable_engine_stance( 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: + 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", diff --git a/tests/authorization_stub.py b/tests/authorization_stub.py new file mode 100644 index 0000000..244c144 --- /dev/null +++ b/tests/authorization_stub.py @@ -0,0 +1,173 @@ +"""In-process stand-ins for approval-engine and access-engine. + +These exist so the whole GH-DEC-2026-003 chain can be exercised end to end +before either engine is deployed: claim -> check -> consume -> OpenBao. They are +test doubles for *transport and sequencing*, not for the contracts -- the wire +shapes are pinned independently against flex-auth's real replay fixtures in +tests/test_decision_replay.py, which is what stops these stubs from quietly +defining a contract of their own. +""" +from __future__ import annotations + +import json +import threading +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer + +from secrets_engine.approval_claim import binding_from_check_request +from secrets_engine.authorization import request_digest + + +def _now(): + return datetime.now(timezone.utc) + + +def _stamp(moment): + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +class AuthorizationStub: + """Serves the claim, check and consume endpoints on a loopback port.""" + + def __init__(self, *, approval_id, package, version, policy_digest="sha256:" + "1" * 64): + self.approval_id = approval_id + self.package = package + self.version = version + self.policy_digest = policy_digest + self.calls: list[str] = [] + self.consumed = False + #: set to force a specific failure for negative tests + self.claim_valid_now = True + self.claim_reason_code = "ok" + self.include_pdp_digest = True + self.effect = "allow" + self.consume_status = 200 + self._pdp_digest = "" + self._server = None + self._thread = None + + # -- lifecycle ---------------------------------------------------------- + def start(self): + stub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def _send(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == f"/v1/approvals/{stub.approval_id}/claim": + stub.calls.append("claim") + return self._send(200, stub.claim()) + return self._send(404, {"error": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + if self.path == "/v1/check": + stub.calls.append("check") + return self._send(200, stub.decision(json.loads(raw))) + if self.path == f"/v1/approvals/{stub.approval_id}/consume": + stub.calls.append("consume") + if stub.consume_status != 200: + return self._send(stub.consume_status, {"error": "conflict"}) + stub.consumed = True + return self._send(200, { + "status": "consumed", + "request_digest": json.loads(raw).get("request_digest"), + "consumed_at": _stamp(_now()), + }) + return self._send(404, {"error": "not found"}) + + self._server = HTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self + + def stop(self): + if self._server is not None: + self._server.shutdown() + self._server.server_close() + + @property + def url(self): + return f"http://127.0.0.1:{self._server.server_address[1]}" + + # -- payloads ----------------------------------------------------------- + def bind_request(self, request): + """Record the request the engine will propose, so the claim can name it.""" + self._pdp_digest = request_digest(request) + self._binding = binding_from_check_request(request) + return self._pdp_digest + + def claim(self): + now = _now() + binding = { + # approval-engine's own vocabulary, deliberately unlike ours + "action": f"secrets.kv.{self._binding['action']}", + "actor": self._binding["actor"], + "principal": self._binding["principal"], + "purpose": self._binding["purpose"], + "target": {"id": "lane-under-test", "stage": "prod"}, + "digest": "sha256:" + "3" * 64, + } + if self.include_pdp_digest: + binding["pdp_digest"] = self._pdp_digest + return { + "schema_version": "0.1", + "kind": "approval-claim", + "issuer": "approval-engine", + "approval_id": self.approval_id, + "state": "valid" if self.claim_valid_now else "revoked", + "valid_now": self.claim_valid_now, + "consumed": False, + "binding": binding, + "freshness": { + "observed_at": _stamp(now), + "ttl_seconds": 30, + "not_after": _stamp(now + timedelta(seconds=30)), + }, + "validity": { + "not_before": _stamp(now - timedelta(hours=1)), + "expires_at": _stamp(now + timedelta(hours=1)), + }, + "reason_code": self.claim_reason_code, + } + + def decision(self, request): + now = _now() + binding = {k: request[k] for k in ("tenant", "subject", "action", "resource") + if request.get(k) is not None} + if request.get("context") is not None: + binding["context"] = request["context"] + binding["request_digest"] = request_digest(request) + return { + "id": "decision:stub-" + request["action"], + "contract_version": "flex-auth.decision-record.v1", + "request_id": request.get("id"), + "effect": self.effect, + "subject": request["subject"], + "resource": request["resource"], + "binding": binding, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": _stamp(now - timedelta(minutes=1)), + "expires_at": _stamp(now + timedelta(minutes=15)), + }, + "provenance": { + "evaluator": "stub/local", + "mode": "standalone", + "policy_package": self.package, + "policy_version": self.version, + "policy_package_digest": self.policy_digest, + "decision_time": _stamp(now), + }, + } diff --git a/tests/test_approval_consume.py b/tests/test_approval_consume.py index 6001f38..6b58cd4 100644 --- a/tests/test_approval_consume.py +++ b/tests/test_approval_consume.py @@ -9,6 +9,7 @@ import pytest from secrets_engine import cli from secrets_engine.approval_consume import ( + AuthorizedAction, ConsumeBinding, consume_approval, require_production_consume, @@ -79,6 +80,13 @@ def _http_error(status): ) +def _authorized(): + """A completed steps 1+2 authorization, as the gate now expects.""" + return AuthorizedAction( + binding=_binding(), decision_id="decision:test", expires_at="2026-09-06T12:00:00Z" + ) + + def test_resolve_consume_binding_is_unserved(): assert resolve_consume_binding(object(), object(), "apply", None) is None @@ -292,8 +300,8 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch): monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance) monkeypatch.setattr( cli, - "resolve_consume_binding", - lambda *_args, **_kwargs: _binding(), + "authorize_action", + lambda *_args, **_kwargs: _authorized(), ) monkeypatch.setattr( "secrets_engine.approval_consume.urlopen", @@ -328,7 +336,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, **_kwargs: _binding()) + monkeypatch.setattr(cli, "authorize_action", lambda *_args, **_kwargs: _authorized()) monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener) monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False) monkeypatch.setattr( diff --git a/tests/test_integration_authorization.py b/tests/test_integration_authorization.py new file mode 100644 index 0000000..33c7f44 --- /dev/null +++ b/tests/test_integration_authorization.py @@ -0,0 +1,236 @@ +"""End-to-end proof of the GH-DEC-2026-003 chain against real OpenBao. + +Everything before this exercised the join against unit-level fakes. This drives +the actual CLI gate through all four steps in order -- +claim -> check -> consume -> OpenBao -- with a live throwaway OpenBao on the +far end, and asserts both that a fully authorized action reaches the backend and +that each failure mode stops before it. + +Skipped without the `bao`/`vault` CLI. The stub serves transport and sequencing +only; the wire contracts are pinned against flex-auth's real fixtures in +tests/test_decision_replay.py. +""" +import copy +import os +import shutil +import socket +import subprocess +import time + +import pytest +import yaml + +from secrets_engine import cli +from secrets_engine.approval_consume import authorize_action +from secrets_engine.catalog import get_entry +from secrets_engine.config import Config +from secrets_engine.errors import DecisionError +from secrets_engine.openbao import OpenBaoClient +from tests.authorization_stub import AuthorizationStub +from tests.test_catalog import VALID + +pytestmark = pytest.mark.skipif( + shutil.which("bao") is None and shutil.which("vault") is None, + reason="no OpenBao/Vault CLI on PATH", +) + +APPROVAL_ID = "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f" +PACKAGE = "secrets-engine.catalog-lane.lifecycle" +VERSION = "v1" + + +def _free_port(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +@pytest.fixture() +def bao_dev(): + bao = shutil.which("bao") or shutil.which("vault") + port = _free_port() + token = "se-authz-root" + proc = subprocess.Popen( + [bao, "server", "-dev", "-dev-no-store-token", f"-dev-root-token-id={token}", + f"-dev-listen-address=127.0.0.1:{port}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + addr = f"http://127.0.0.1:{port}" + client = OpenBaoClient(addr=addr, token=token, bao_bin=bao) + for _ in range(50): + if client.is_reachable(): + break + time.sleep(0.2) + else: + proc.kill() + pytest.fail("dev OpenBao did not become reachable") + try: + yield addr, token, client + finally: + proc.kill() + + +@pytest.fixture() +def stub(): + s = AuthorizationStub(approval_id=APPROVAL_ID, package=PACKAGE, version=VERSION).start() + try: + yield s + finally: + s.stop() + + +@pytest.fixture() +def lane(tmp_path): + """A prod lane bound to the approval object, in a throwaway catalog.""" + raw = copy.deepcopy(VALID) + raw["stage"] = "prod" + # bootstrap-only skips the *legacy* State Hub lane-approval lookup, which is + # a separate older mechanism with its own repo-rooted fixture directory. + # What is under test here is the GH-DEC-2026-003 chain, which still gates + # fully: the lane is prod, so stance, claim, check and consume all apply. + raw["approval"] = { + "model": "bootstrap-only", + "authorization_id": APPROVAL_ID, + "purpose": "end-to-end authorization proof", + } + catalog_dir = tmp_path / "catalog" + catalog_dir.mkdir() + (catalog_dir / f"{raw['id']}.yaml").write_text(yaml.safe_dump(raw)) + return catalog_dir, get_entry(catalog_dir, raw["id"]) + + +def _cfg(tmp_path, catalog_dir, stub, bao_addr): + token_file = tmp_path / "authz.token" + token_file.write_text("stub-credential\n") + os.chmod(token_file, 0o600) + return Config( + catalog_dir=catalog_dir, + policy_dir=tmp_path / "policies", + evidence_dir=tmp_path / "evidence", + hub_url="", + bao_addr=bao_addr, + topic_id="t", + approval_url=stub.url, + approval_token_file=token_file, + authorization_subject_id="secrets-engine", + authorization_subject_type="service", + authorization_policy_package=PACKAGE, + authorization_policy_version=VERSION, + pdp_url=stub.url, + pdp_token_file=token_file, + ) + + +def _prime(stub, cfg, entry, action="apply"): + """Tell the stub which exact request the engine will propose.""" + from secrets_engine.approval_consume import _expected_request + + stub.bind_request(_expected_request(cfg, entry, action)) + + +def test_full_authorization_chain_reaches_openbao(bao_dev, stub, lane, tmp_path): + addr, root, client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + + authorization = authorize_action(cfg, entry, "apply", None) + assert authorization is not None + assert authorization.decision_id == "decision:stub-apply" + assert stub.calls == ["claim", "check"], "PIP then PDP, in that order" + + # The gate itself: stance satisfied, consume performed, then the backend. + cli._require_lane_approval(cfg, entry, "apply") + assert stub.consumed, "CAS consume must happen before any OpenBao call" + assert stub.calls[-1] == "consume", "consume is the last step before OpenBao" + + # And the backend really is reachable with the authorized identity. + assert client.is_reachable() + + +def test_unreachable_pdp_stops_before_consume(bao_dev, stub, lane, tmp_path): + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + cfg = Config(**{**cfg.__dict__, "pdp_url": "http://127.0.0.1:1"}) + + with pytest.raises(DecisionError, match="unreachable"): + cli._require_lane_approval(cfg, entry, "apply") + assert not stub.consumed, "an unreachable PDP must not consume the approval" + + +def test_deny_decision_stops_before_consume(bao_dev, stub, lane, tmp_path): + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + stub.effect = "deny" + + with pytest.raises(DecisionError, match="effect is not allow"): + cli._require_lane_approval(cfg, entry, "apply") + assert not stub.consumed + + +def test_invalid_claim_stops_before_the_pdp(bao_dev, stub, lane, tmp_path): + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + stub.claim_valid_now = False + stub.claim_reason_code = "revoked" + + with pytest.raises(DecisionError, match="not valid now"): + cli._require_lane_approval(cfg, entry, "apply") + assert "check" not in stub.calls, "a bad claim must not reach the PDP" + assert not stub.consumed + + +def test_missing_pdp_digest_stops_before_the_pdp(bao_dev, stub, lane, tmp_path): + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + stub.include_pdp_digest = False + + with pytest.raises(DecisionError, match="no published mapping"): + cli._require_lane_approval(cfg, entry, "apply") + assert not stub.consumed + + +def test_consume_conflict_stops_before_openbao(bao_dev, stub, lane, tmp_path): + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry) + stub.consume_status = 409 + + with pytest.raises(DecisionError): + cli._require_lane_approval(cfg, entry, "apply") + assert not stub.consumed + + +def test_action_mismatch_is_caught_by_the_digest(bao_dev, stub, lane, tmp_path): + """The claim was primed for apply; proposing destroy must not ride it.""" + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = _cfg(tmp_path, catalog_dir, stub, addr) + _prime(stub, cfg, entry, action="apply") + + with pytest.raises(DecisionError): + cli._require_lane_approval(cfg, entry, "destroy") + assert not stub.consumed + + +def test_unconfigured_engine_still_fails_closed(bao_dev, lane, tmp_path): + """The whole point of the default: no serving path means no production.""" + addr, _root, _client = bao_dev + catalog_dir, entry = lane + cfg = Config( + catalog_dir=catalog_dir, policy_dir=tmp_path / "p", evidence_dir=tmp_path / "e", + hub_url="", bao_addr=addr, topic_id="t", + ) + with pytest.raises(DecisionError, match="live production remains disabled"): + cli._require_lane_approval(cfg, entry, "apply")