secrets-engine/src/secrets_engine/decision_check.py

74 lines
2.8 KiB
Python
Raw Normal View History

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 <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
2026-09-06 14:56:02 +02:00
"""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