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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
173
tests/authorization_stub.py
Normal file
173
tests/authorization_stub.py
Normal file
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
236
tests/test_integration_authorization.py
Normal file
236
tests/test_integration_authorization.py
Normal file
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue