fix: bind approval consumption to actual Flex Auth submissions
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
89bc31460f
commit
ee4e901611
23 changed files with 612 additions and 1023 deletions
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -97,21 +98,13 @@ def _parse_time(value: object, name: str) -> datetime:
|
|||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_approval_claim(
|
||||
def _validate_observation(
|
||||
claim: object,
|
||||
*,
|
||||
approval_id: str,
|
||||
expected_binding_digest: str = "",
|
||||
expected_pdp_digest: str = "",
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the published consumer checks. Any failure means do not act.
|
||||
|
||||
Implements ``approval-claim.md`` "Required verification": issuer, valid_now,
|
||||
not consumed, binding digest match (native or PDP), freshness, reason_code.
|
||||
The distinct-approver threshold is folded into ``valid_now`` by the issuer;
|
||||
the claim does not expose approver entries, so it cannot be re-checked here.
|
||||
"""
|
||||
"""Validate issuer, shape, validity and freshness; no action correspondence."""
|
||||
if not isinstance(claim, dict):
|
||||
raise DecisionError("approval claim must be an object")
|
||||
if claim.get("schema_version") != SCHEMA_VERSION:
|
||||
|
|
@ -142,42 +135,6 @@ def validate_approval_claim(
|
|||
binding = claim.get("binding")
|
||||
if not isinstance(binding, dict):
|
||||
raise DecisionError("approval claim binding must be an object")
|
||||
native = str(binding.get("digest", "") or "")
|
||||
pdp = str(binding.get("pdp_digest", "") or "")
|
||||
# Prefer the PDP digest: it is expressed in flex-auth's vocabulary, which is
|
||||
# the one the caller actually proposed. The native digest is over
|
||||
# approval-engine's own vocabulary and is only comparable when the caller
|
||||
# supplies a binding built in that vocabulary (see the note in
|
||||
# resolve_consume_binding about the missing mapping).
|
||||
if expected_pdp_digest:
|
||||
# GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's
|
||||
# DECLARATION that this approval was requested against a bound
|
||||
# CheckRequest, and it guarantees pdp_digest is non-null. Path intent is
|
||||
# never inferred from a pdp_digest that merely happens to be present --
|
||||
# a digest recorded for some other reason is not a declaration anybody
|
||||
# made, and approvals issued before schema v3 carry pdp_path false
|
||||
# regardless of any digest they hold.
|
||||
if binding.get("pdp_path") is not True:
|
||||
raise DecisionError(
|
||||
"approval claim does not declare binding.pdp_path; it was not "
|
||||
"issued against a bound CheckRequest and cannot authorize this "
|
||||
"action (GH-DEC-2026-008). Request an approval bound at issue."
|
||||
)
|
||||
if not pdp:
|
||||
raise DecisionError(
|
||||
"approval claim declares pdp_path but records no pdp_digest, and "
|
||||
"no published mapping exists between approval-engine and "
|
||||
"secrets-engine action/target vocabularies; the claim cannot be "
|
||||
"tied to this exact action"
|
||||
)
|
||||
if pdp != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
if native != expected_binding_digest:
|
||||
raise DecisionError("approval claim binding digest does not match the request")
|
||||
else:
|
||||
raise DecisionError("approval claim comparison requires an expected digest")
|
||||
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
freshness = claim.get("freshness")
|
||||
if not isinstance(freshness, dict):
|
||||
|
|
@ -194,3 +151,41 @@ def validate_approval_claim(
|
|||
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
|
||||
raise DecisionError("approval claim is not yet valid")
|
||||
return claim
|
||||
|
||||
|
||||
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
||||
now: datetime | None = 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, now=now)
|
||||
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")
|
||||
pdp = binding.get("pdp_digest")
|
||||
if not isinstance(pdp, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", pdp):
|
||||
raise DecisionError("approval claim records no canonical pdp_digest; no published mapping permits a native fallback")
|
||||
return observed
|
||||
|
||||
|
||||
def validate_approval_claim(claim: object, *, approval_id: str,
|
||||
expected_binding_digest: str = "", expected_pdp_digest: str = "",
|
||||
now: datetime | None = 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, now=now)
|
||||
if expected_pdp_digest:
|
||||
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
|
||||
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
if observed["binding"].get("digest") != expected_binding_digest:
|
||||
raise DecisionError("approval claim binding digest does not match the request")
|
||||
else:
|
||||
raise DecisionError("approval claim comparison requires an expected digest")
|
||||
return observed
|
||||
|
|
|
|||
|
|
@ -12,18 +12,17 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
||||
from secrets_engine.approval_claim import validate_approval_claim
|
||||
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
|
||||
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
|
||||
|
|
@ -43,6 +42,15 @@ class ConsumeBinding:
|
|||
decision_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalObservation:
|
||||
"""Fresh approval input awaiting PDP correspondence; never a consume binding."""
|
||||
|
||||
approval_id: str
|
||||
request: dict[str, Any] = field(repr=False)
|
||||
claim: dict[str, Any] = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedAction:
|
||||
"""A validated claim and decision for one exact proposed action.
|
||||
|
|
@ -201,7 +209,7 @@ def fetch_approval_claim(
|
|||
return payload
|
||||
|
||||
|
||||
def resolve_consume_binding(
|
||||
def resolve_approval_observation(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
|
|
@ -211,13 +219,11 @@ def resolve_consume_binding(
|
|||
policy_targets: tuple[str, ...] = (),
|
||||
auth_targets: tuple[str, ...] = (),
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ConsumeBinding | None:
|
||||
"""Join the proposed action to a served approval-claim (step 1).
|
||||
) -> ApprovalObservation | None:
|
||||
"""Observe a fresh fact and prepare the claim-bearing request (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.
|
||||
Missing serving coordinates return None, preserving the production refusal.
|
||||
Correspondence and a consume binding require authorize_action's PDP step.
|
||||
"""
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
auth_configured = approval_auth_configured(cfg)
|
||||
|
|
@ -230,42 +236,15 @@ def resolve_consume_binding(
|
|||
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.
|
||||
#
|
||||
# Only the PDP digest is usable for the action/target correspondence today.
|
||||
# The claim's binding.action and binding.target speak approval-engine's
|
||||
# vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours
|
||||
# speaks the catalog's ("destroy", "catalog:<id>"), and no mapping between
|
||||
# them is published. flex-auth makes no cross-check either and states the
|
||||
# correspondence is ours, via pdp_digest. Computing a native digest from our
|
||||
# own vocabulary would compare two different languages and never match --
|
||||
# the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do
|
||||
# not compute one, and validate_approval_claim fails closed with a named
|
||||
# reason when the issuer recorded no pdp_digest.
|
||||
pdp_digest = request_digest(expected_request)
|
||||
|
||||
claim = fetch_approval_claim(
|
||||
base_url=base_url,
|
||||
token_provider=lambda: approval_token(cfg, scope="approval:read"),
|
||||
authorization_id=authorization_id,
|
||||
opener=opener or credential_urlopen,
|
||||
)
|
||||
validate_approval_claim(
|
||||
claim,
|
||||
approval_id=authorization_id,
|
||||
expected_pdp_digest=pdp_digest,
|
||||
)
|
||||
# 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,
|
||||
decision_id="",
|
||||
)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id)
|
||||
expected_request["context"]["approval"] = claim
|
||||
return ApprovalObservation(authorization_id, expected_request, claim)
|
||||
|
||||
|
||||
def authorize_action(
|
||||
|
|
@ -287,14 +266,14 @@ def authorize_action(
|
|||
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(
|
||||
observation = resolve_approval_observation(
|
||||
cfg, entry, action, decision,
|
||||
fields=fields,
|
||||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
opener=opener,
|
||||
)
|
||||
if binding is None:
|
||||
if observation is None:
|
||||
return None
|
||||
|
||||
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
|
||||
|
|
@ -313,10 +292,7 @@ def authorize_action(
|
|||
"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,
|
||||
)
|
||||
expected_request = observation.request
|
||||
envelope = check_decision(
|
||||
base_url=pdp_url,
|
||||
token_file=Path(pdp_token),
|
||||
|
|
@ -328,17 +304,19 @@ def authorize_action(
|
|||
expected_request,
|
||||
accepted_policy_packages={package},
|
||||
accepted_policy_versions={version},
|
||||
# The claim's pdp_digest, established in step 1. If this request carried
|
||||
# the claim in context, the decision must name the same claim-free
|
||||
# envelope in binding.approval_binding_digest (FLEX-DEC-2026-007).
|
||||
expected_approval_binding_digest=binding.request_digest,
|
||||
expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"],
|
||||
)
|
||||
# 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"],
|
||||
)
|
||||
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,
|
||||
approval_id=observation.approval_id,
|
||||
request_digest=validated.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Fail-closed consumer validation for flex-auth action authorizations.
|
||||
|
||||
The canonical contract is flex-auth revision c473f19. State Hub does not yet
|
||||
provide the durable authoritative endpoint, so this module validates supplied
|
||||
objects but does not resolve or enable production actions by itself.
|
||||
Replay identity follows FLEX-DEC-2026-012. The authenticated evaluator owns
|
||||
registry enrichment; the consumer binds its exact submitted request and checks
|
||||
policy, lifetime and approval correspondence before consumption.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -50,6 +50,8 @@ class ValidatedDecision:
|
|||
action: str
|
||||
subject_id: str
|
||||
expires_at: str
|
||||
request_digest: str
|
||||
submitted_request_digest: str
|
||||
|
||||
|
||||
def build_action_request(
|
||||
|
|
@ -272,158 +274,30 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _check_approval_binding_digest(
|
||||
binding: dict[str, Any],
|
||||
expected: dict[str, Any],
|
||||
expected_digest: str,
|
||||
binding: dict[str, Any], expected: dict[str, Any], expected_digest: str,
|
||||
) -> None:
|
||||
"""Tie an approval claim to this exact request (FLEX-DEC-2026-007).
|
||||
|
||||
``binding.approval_binding_digest`` is the canonical request digest computed
|
||||
with ``context.approval`` removed, and it appears only when the request
|
||||
carried a claim there. It is the ONLY sound comparand for a claim's
|
||||
``pdp_digest``: a digest recorded at issue time can never equal the
|
||||
``request_digest`` of the request that carries the claim, because the claim
|
||||
is part of the hashed material. Comparing against ``request_digest`` fails
|
||||
closed forever; comparing against nothing fails open.
|
||||
|
||||
It is deliberately NOT a replay identity -- two requests differing only in
|
||||
which approval was presented share it while their decisions differ -- so it
|
||||
is checked here in addition to ``request_digest``, never instead of it.
|
||||
|
||||
When our own request is claim-free the field is absent by contract, and the
|
||||
identity already holds transitively: step 1 compared the claim's pdp_digest
|
||||
to this same canonical digest of the claim-free request.
|
||||
"""
|
||||
"""Compare evaluator-origin values; never reproduce registry enrichment."""
|
||||
context = expected.get("context", {})
|
||||
carried = APPROVAL_CONTEXT_KEY in context
|
||||
present = binding.get("approval_binding_digest")
|
||||
if present is not None:
|
||||
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
|
||||
raise DecisionError("flex-auth approval binding digest is malformed")
|
||||
if present != approval_binding_digest(expected):
|
||||
raise DecisionError(
|
||||
"flex-auth approval binding digest does not match this request "
|
||||
"with the approval claim removed"
|
||||
)
|
||||
if expected_digest:
|
||||
if present is None:
|
||||
context = expected.get("context")
|
||||
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
|
||||
# Claim-free request: no approval_binding_digest is emitted and
|
||||
# step 1 already bound the claim to this canonical digest.
|
||||
return
|
||||
raise DecisionError(
|
||||
"request carried an approval claim but the decision records no "
|
||||
"approval_binding_digest; the claim cannot be tied to it"
|
||||
)
|
||||
if present != expected_digest:
|
||||
raise DecisionError(
|
||||
"approval claim pdp digest does not match the decision's "
|
||||
"approval binding digest"
|
||||
)
|
||||
|
||||
|
||||
#: Fields the evaluator's documented normalization may add to a ref, per
|
||||
#: canonical-request-digest.md "Normalization": the request tenant is copied onto
|
||||
#: subject and resource when they omit it, and a registry hit copies type,
|
||||
#: tenant, and selected attributes onto the refs the digest sees.
|
||||
_ENRICHABLE_REF_FIELDS = ("type", "tenant", "attributes")
|
||||
|
||||
|
||||
def _require_ref_corresponds(bound: Any, sent: dict[str, Any], tenant: str, name: str) -> None:
|
||||
"""The bound ref must be our ref, plus only documented enrichment."""
|
||||
if not isinstance(bound, dict):
|
||||
raise DecisionError(f"flex-auth decision binding {name} must be an object")
|
||||
for key, value in sent.items():
|
||||
if key == "attributes":
|
||||
continue
|
||||
if bound.get(key) != value:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.{key} does not match the "
|
||||
"proposed action"
|
||||
)
|
||||
for key in bound:
|
||||
if key not in sent and key not in _ENRICHABLE_REF_FIELDS:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name} carries unexpected field "
|
||||
f"'{key}'; enrichment may add only {_ENRICHABLE_REF_FIELDS}"
|
||||
)
|
||||
# Normalization rule 1: an enriched tenant is the REQUEST's tenant. A ref
|
||||
# bearing some other tenant would be a cross-tenant binding wearing our
|
||||
# request's clothes.
|
||||
bound_tenant = bound.get("tenant")
|
||||
if bound_tenant is not None and tenant and bound_tenant != tenant:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.tenant '{bound_tenant}' is not "
|
||||
f"the request tenant '{tenant}'"
|
||||
)
|
||||
sent_attributes = sent.get("attributes")
|
||||
if isinstance(sent_attributes, dict):
|
||||
bound_attributes = bound.get("attributes")
|
||||
if not isinstance(bound_attributes, dict):
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.attributes must be an object"
|
||||
)
|
||||
# Every attribute we proposed must survive unchanged. The registry may
|
||||
# add its own; it may not restate ours differently. stage, fields,
|
||||
# policy_targets and auth_targets are security-bearing and are ours.
|
||||
for key, value in sent_attributes.items():
|
||||
if bound_attributes.get(key) != value:
|
||||
raise DecisionError(
|
||||
f"flex-auth decision binding {name}.attributes.{key} does "
|
||||
"not match the proposed action"
|
||||
)
|
||||
|
||||
|
||||
def _require_binding_corresponds(
|
||||
binding: dict[str, Any], expected: dict[str, Any]
|
||||
) -> None:
|
||||
"""Compare structured binding fields to the proposed action.
|
||||
|
||||
This is what canonical-request-digest.md tells a consumer to do, and why it
|
||||
is not a digest comparison: "A consumer that re-hashes the original
|
||||
unenriched request will not match a decision that turned on registry
|
||||
attributes. Compare structured binding fields to the proposed action, and
|
||||
treat request_digest as the evaluator's statement of what it hashed."
|
||||
|
||||
The evaluator normalizes before hashing -- it copies the request tenant onto
|
||||
subject and resource, and a registry hit copies type, tenant and selected
|
||||
attributes onto the refs. Those additions are material we never sent and
|
||||
cannot reproduce, so byte-equality against our own request is unsatisfiable
|
||||
against every real decision.
|
||||
|
||||
What must still hold, and is enforced here: everything we DID propose
|
||||
survives unchanged, enrichment appears only where the contract permits it,
|
||||
and an enriched tenant is our request's tenant rather than another one.
|
||||
"""
|
||||
tenant = str(expected.get("tenant", "") or "")
|
||||
if tenant and binding.get("tenant") != tenant:
|
||||
raise DecisionError("flex-auth decision binding tenant does not match request")
|
||||
if binding.get("action") != expected["action"]:
|
||||
raise DecisionError("flex-auth decision binding action does not match request")
|
||||
if binding.get("context", {}) != expected.get("context", {}):
|
||||
raise DecisionError("flex-auth decision binding context does not match request")
|
||||
_require_ref_corresponds(binding.get("subject"), expected["subject"], tenant, "subject")
|
||||
_require_ref_corresponds(binding.get("resource"), expected["resource"], tenant, "resource")
|
||||
|
||||
|
||||
def binding_tuple(binding: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The normalized tuple the binding carries, for independent re-hashing.
|
||||
|
||||
canonical-request-digest.md: "To recompute independently, hash the same
|
||||
normalized tuple the binding carries (tenant, subject, action, resource,
|
||||
context)."
|
||||
"""
|
||||
tuple_: dict[str, Any] = {}
|
||||
if binding.get("tenant"):
|
||||
tuple_["tenant"] = binding["tenant"]
|
||||
tuple_.update(
|
||||
{
|
||||
"subject": binding.get("subject"),
|
||||
"action": binding.get("action"),
|
||||
"resource": binding.get("resource"),
|
||||
"context": binding.get("context", {}),
|
||||
}
|
||||
)
|
||||
return tuple_
|
||||
if not carried:
|
||||
if present is not None or expected_digest:
|
||||
raise DecisionError("approval binding requires a carried approval claim")
|
||||
return
|
||||
if present is None:
|
||||
raise DecisionError("request carried an approval claim but the decision records no approval_binding_digest")
|
||||
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
|
||||
raise DecisionError("flex-auth approval binding digest is malformed")
|
||||
claim = context[APPROVAL_CONTEXT_KEY]
|
||||
claim_binding = claim.get("binding", {}) if isinstance(claim, dict) else {}
|
||||
if not isinstance(claim_binding, dict):
|
||||
raise DecisionError("carried approval claim binding must be an object")
|
||||
recorded = claim_binding.get("pdp_digest")
|
||||
if (claim_binding.get("pdp_path") is not True
|
||||
or not isinstance(recorded, str) or not DIGEST_RE.fullmatch(recorded)):
|
||||
raise DecisionError("carried approval claim requires a canonical pdp_path binding")
|
||||
if present != recorded or (expected_digest and present != expected_digest):
|
||||
raise DecisionError("approval claim pdp digest does not match the decision's approval binding digest")
|
||||
|
||||
|
||||
def validate_decision_envelope(
|
||||
|
|
@ -466,25 +340,15 @@ def validate_decision_envelope(
|
|||
raise DecisionError("flex-auth decision request id does not match request")
|
||||
|
||||
binding = _required_dict(envelope, "binding")
|
||||
_require_binding_corresponds(binding, expected)
|
||||
# The digest is the evaluator's statement of what it hashed, so it is
|
||||
# verified for self-consistency against the tuple the binding carries --
|
||||
# never against our unenriched request, which no real decision can match.
|
||||
bound_tuple = binding_tuple(binding)
|
||||
if binding.get("request_digest") != request_digest(bound_tuple):
|
||||
raise DecisionError(
|
||||
"flex-auth request digest does not match the binding it carries"
|
||||
)
|
||||
_check_approval_binding_digest(binding, bound_tuple, expected_approval_binding_digest)
|
||||
# The envelope's top-level refs are enriched too, so they get the same
|
||||
# correspondence rule rather than byte-equality against what we sent.
|
||||
_tenant = str(expected.get("tenant", "") or "")
|
||||
_require_ref_corresponds(
|
||||
_subject_ref(envelope.get("subject")), expected["subject"], _tenant, "subject"
|
||||
)
|
||||
_require_ref_corresponds(
|
||||
_resource_ref(envelope.get("resource")), expected["resource"], _tenant, "resource"
|
||||
)
|
||||
submitted = binding.get("submitted_request_digest")
|
||||
if submitted != request_digest(expected):
|
||||
raise DecisionError("flex-auth submitted request digest is missing or does not match the proposed action")
|
||||
# Keep the evaluator's enriched digest as audit/consume evidence. It is not
|
||||
# locally computable, and registry facts may override caller attributes.
|
||||
evaluated = binding.get("request_digest")
|
||||
if not isinstance(evaluated, str) or not DIGEST_RE.fullmatch(evaluated):
|
||||
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)
|
||||
lifetime = _required_dict(envelope, "lifetime")
|
||||
|
|
@ -506,4 +370,6 @@ def validate_decision_envelope(
|
|||
action=expected["action"],
|
||||
subject_id=expected["subject"]["id"],
|
||||
expires_at=expires.isoformat(),
|
||||
request_digest=evaluated,
|
||||
submitted_request_digest=submitted,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ from secrets_engine.apply import apply_plan
|
|||
from secrets_engine.approval_consume import (
|
||||
authorize_action,
|
||||
require_production_consume,
|
||||
resolve_consume_binding,
|
||||
)
|
||||
from secrets_engine.catalog import get_entry, load_catalog
|
||||
from secrets_engine.config import Config, repo_root
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue