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
This commit is contained in:
parent
e8144f315c
commit
f62d3fe789
9 changed files with 675 additions and 44 deletions
73
src/secrets_engine/decision_check.py
Normal file
73
src/secrets_engine/decision_check.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue