Use bounded Railiance time for protected validity checks
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
5841d8e88a
commit
f4b4dd6b17
8 changed files with 514 additions and 14 deletions
47
src/secrets_engine/application_time.py
Normal file
47
src/secrets_engine/application_time.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Consumer-owned bounded time adapter; explicit opt-in, no configured fallback."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from functools import lru_cache
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _clock(path):
|
||||
try:
|
||||
from railiance_clock.client import Clock, FileTrust
|
||||
return Clock(FileTrust(path))
|
||||
except (ImportError, OSError, ValueError, TypeError, KeyError) as exc:
|
||||
raise DecisionError("Railiance clock unavailable or untrusted") from exc
|
||||
|
||||
def read_window(cfg):
|
||||
path = getattr(cfg, "clock_trust_file", None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return _clock(str(path)).read()
|
||||
except (OSError, ValueError) as exc:
|
||||
raise DecisionError("Railiance clock unavailable or untrusted") from exc
|
||||
|
||||
def validity_bounds(now=None, window=None):
|
||||
if window is None:
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
return current, current
|
||||
if now is not None:
|
||||
raise DecisionError("choose interval evidence or explicit test time, not both")
|
||||
try:
|
||||
lower, upper = window.lower_ns, window.upper_ns
|
||||
if type(lower) is not int or type(upper) is not int or not 0 <= lower <= upper:
|
||||
raise ValueError("invalid interval")
|
||||
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
return epoch + timedelta(microseconds=lower // 1000), epoch + timedelta(microseconds=(upper + 999) // 1000)
|
||||
except (ValueError, TypeError, AttributeError, OverflowError) as exc:
|
||||
raise DecisionError("invalid Railiance time interval") from exc
|
||||
|
||||
def recheck_binding(cfg, binding):
|
||||
if not getattr(cfg, "clock_trust_file", None):
|
||||
return
|
||||
if not binding.decision_expires_at:
|
||||
raise DecisionError("bounded-time consume requires decision validity")
|
||||
lower, upper = validity_bounds(window=read_window(cfg))
|
||||
expires = datetime.fromisoformat(binding.decision_expires_at.replace("Z", "+00:00"))
|
||||
start = datetime.fromisoformat(binding.decision_not_before.replace("Z", "+00:00")) if binding.decision_not_before else None
|
||||
if upper >= expires or (start is not None and lower < start):
|
||||
raise DecisionError("decision validity does not contain the Railiance interval")
|
||||
|
|
@ -18,6 +18,7 @@ from datetime import datetime, timezone
|
|||
from typing import Any
|
||||
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.application_time import validity_bounds
|
||||
|
||||
SCHEMA_VERSION = "0.1"
|
||||
KIND = "approval-claim"
|
||||
|
|
@ -104,6 +105,7 @@ def _validate_observation(
|
|||
approval_id: str,
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None,
|
||||
time_window=None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate issuer, shape, validity and freshness; no action correspondence."""
|
||||
if not isinstance(claim, dict):
|
||||
|
|
@ -138,34 +140,34 @@ def _validate_observation(
|
|||
raise DecisionError("approval claim binding must be an object")
|
||||
if require_human_control and binding.get("human_control") is not True:
|
||||
raise DecisionError("approval claim does not declare binding.human_control true; request a human-controlled approval at issue")
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
lower, upper = validity_bounds(now, time_window)
|
||||
freshness = claim.get("freshness")
|
||||
if not isinstance(freshness, dict):
|
||||
raise DecisionError("approval claim freshness must be an object")
|
||||
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= current:
|
||||
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= upper:
|
||||
raise DecisionError("approval claim observation is stale; re-fetch")
|
||||
|
||||
validity = claim.get("validity")
|
||||
if not isinstance(validity, dict):
|
||||
raise DecisionError("approval claim validity must be an object")
|
||||
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= current:
|
||||
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= upper:
|
||||
raise DecisionError("approval claim validity window has expired")
|
||||
if validity.get("not_before") is not None:
|
||||
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
|
||||
if _parse_time(validity.get("not_before"), "validity.not_before") > lower:
|
||||
raise DecisionError("approval claim is not yet valid")
|
||||
return claim
|
||||
|
||||
|
||||
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
now: datetime | None = None, time_window=None) -> dict[str, Any]:
|
||||
"""Validate a fresh fact for submission to the PDP, not authority to consume.
|
||||
|
||||
Action correspondence cannot be established until the evaluator returns its
|
||||
enriched approval binding. This function deliberately makes no such claim.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
require_human_control=require_human_control, now=now, time_window=time_window)
|
||||
binding = observed["binding"]
|
||||
if binding.get("pdp_path") is not True:
|
||||
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
|
||||
|
|
@ -178,17 +180,17 @@ def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
|||
def validate_approval_claim(claim: object, *, approval_id: str,
|
||||
expected_binding_digest: str = "", expected_pdp_digest: str = "",
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
now: datetime | None = None, time_window=None) -> dict[str, Any]:
|
||||
"""Validate the fact and compare an independently supplied binding.
|
||||
|
||||
A PDP digest supplied here must come from the evaluated decision, never a
|
||||
local reconstruction of the unenriched request.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
require_human_control=require_human_control, now=now, time_window=time_window)
|
||||
if expected_pdp_digest:
|
||||
observe_pdp_approval_claim(observed, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
require_human_control=require_human_control, now=now, time_window=time_window)
|
||||
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from secrets_engine.authorization import (
|
|||
wait_for_decision_start,
|
||||
)
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.application_time import read_window, recheck_binding
|
||||
from secrets_engine.openbao import read_strict_token_file
|
||||
from secrets_engine.pep_stance import demo_exception_enabled
|
||||
|
||||
|
|
@ -43,6 +44,8 @@ class ConsumeBinding:
|
|||
request_digest: str
|
||||
decision_id: str = ""
|
||||
human_control: bool = False
|
||||
decision_not_before: str = ""
|
||||
decision_expires_at: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -256,7 +259,7 @@ def resolve_approval_observation(
|
|||
opener=opener or credential_urlopen,
|
||||
)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id,
|
||||
require_human_control=required_human_control)
|
||||
require_human_control=required_human_control, time_window=read_window(cfg))
|
||||
expected_request["context"]["approval"] = claim
|
||||
return ApprovalObservation(authorization_id, expected_request, claim)
|
||||
|
||||
|
|
@ -313,19 +316,23 @@ def authorize_action(
|
|||
request=expected_request,
|
||||
opener=pdp_opener or urlopen,
|
||||
)
|
||||
wait_for_decision_start(envelope)
|
||||
if not getattr(cfg, "clock_trust_file", None):
|
||||
wait_for_decision_start(envelope)
|
||||
window = read_window(cfg)
|
||||
validated = validate_decision_envelope(
|
||||
envelope,
|
||||
expected_request,
|
||||
accepted_policy_packages={package},
|
||||
accepted_policy_versions={version},
|
||||
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
|
||||
time_window=window,
|
||||
)
|
||||
# Recheck freshness after the network call before issuing a consume binding.
|
||||
validate_approval_claim(
|
||||
observation.claim, approval_id=observation.approval_id,
|
||||
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
|
||||
require_human_control=expected_request["context"].get("human_control") is True,
|
||||
time_window=window,
|
||||
)
|
||||
if validated.action != action:
|
||||
raise DecisionError("access-engine decision does not bind this action")
|
||||
|
|
@ -335,6 +342,8 @@ def authorize_action(
|
|||
request_digest=validated.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
human_control=observation.claim["binding"].get("human_control") is True,
|
||||
decision_not_before=envelope["lifetime"].get("not_before") or "",
|
||||
decision_expires_at=validated.expires_at,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
|
|
@ -462,6 +471,7 @@ def require_production_consume(
|
|||
"production OpenBao call requires approval-engine consume; "
|
||||
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
|
||||
)
|
||||
recheck_binding(cfg, binding)
|
||||
consumed = consume_approval(
|
||||
base_url=base_url,
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
|
||||
|
|
@ -470,6 +480,7 @@ def require_production_consume(
|
|||
)
|
||||
if evidence is not None and hasattr(evidence, "mark_consumed"):
|
||||
evidence.mark_consumed(consumed)
|
||||
recheck_binding(cfg, binding)
|
||||
return consumed
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from typing import Any
|
|||
|
||||
from secrets_engine.catalog import CatalogEntry, human_control_required
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.application_time import validity_bounds
|
||||
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
|
@ -335,6 +336,7 @@ def validate_decision_envelope(
|
|||
accepted_policy_versions: set[str],
|
||||
expected_approval_binding_digest: str = "",
|
||||
now: datetime | None = None,
|
||||
time_window=None,
|
||||
) -> ValidatedDecision:
|
||||
"""Validate a flex-auth DecisionEnvelope against the proposed action.
|
||||
|
||||
|
|
@ -377,13 +379,13 @@ def validate_decision_envelope(
|
|||
raise DecisionError("flex-auth evaluated request digest is malformed")
|
||||
_check_approval_binding_digest(binding, expected, expected_approval_binding_digest)
|
||||
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
lower, upper = validity_bounds(now, time_window)
|
||||
lifetime = _required_dict(envelope, "lifetime")
|
||||
expires = _parse_time(lifetime.get("expires_at"), "expires_at")
|
||||
if current >= expires:
|
||||
if upper >= expires:
|
||||
raise DecisionError("flex-auth decision lifetime has expired")
|
||||
if lifetime.get("not_before") is not None:
|
||||
if current < _parse_time(lifetime.get("not_before"), "not_before"):
|
||||
if lower < _parse_time(lifetime.get("not_before"), "not_before"):
|
||||
raise DecisionError("flex-auth decision lifetime has not started")
|
||||
|
||||
provenance = _required_dict(envelope, "provenance")
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ class Config:
|
|||
authorization_min_approvals: int = 1
|
||||
pdp_url: str = ""
|
||||
pdp_token_file: Path | None = None
|
||||
clock_trust_file: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Config":
|
||||
|
|
@ -94,4 +95,5 @@ class Config:
|
|||
),
|
||||
pdp_url=os.environ.get("SECRETS_ENGINE_PDP_URL", ""),
|
||||
pdp_token_file=Path(pdp_token) if pdp_token else None,
|
||||
clock_trust_file=Path(os.environ["SECRETS_ENGINE_CLOCK_TRUST_FILE"]) if os.environ.get("SECRETS_ENGINE_CLOCK_TRUST_FILE") else None,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue