feat: split the validator by owning layer per GH-DEC-2026-005
gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo.
1. Split validate_action_authorization. The claim from approval-engine now
carries the approval fact (issuer, valid_now, consumption, binding digest,
freshness, reason_code) via approval_claim.validate_approval_claim; the
flex-auth DecisionEnvelope carries the decision (effect, binding match,
request digest, lifetime, policy pin) via validate_decision_envelope.
ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and
cannot be served from a step-1 call; nothing validates it now.
2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement.
State Hub is a read model with no runtime approval authority, so the check
failed closed against every correctly issued record. flex-auth traced the
constant to their own fixture and fixed it at source.
Two consequences recorded rather than buried: there are now two distinct
digests over the same action (approval-engine native over
{action,actor,principal,purpose,target}, and the flex-auth CheckRequest
digest) which are never compared to each other; and the distinct-approver
threshold is no longer checked here, since the claim exposes no approver
entries and approval-engine folds it into valid_now.
The canonical request digest is unchanged and its contract test is preserved
verbatim. Production still fails closed. 251 tests pass.
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
dbd3694f71
commit
7b4b9e386e
8 changed files with 694 additions and 325 deletions
|
|
@ -17,12 +17,11 @@ from secrets_engine.catalog import CatalogEntry
|
|||
from secrets_engine.errors import DecisionError
|
||||
|
||||
SCHEMA_VERSION = "0.1"
|
||||
AUTHORITY = "state-hub"
|
||||
CONTRACT_VERSION = "flex-auth.decision-record.v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedActionAuthorization:
|
||||
authorization_id: str
|
||||
class ValidatedDecision:
|
||||
decision_id: str
|
||||
action: str
|
||||
subject_id: str
|
||||
|
|
@ -182,91 +181,45 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
|||
)
|
||||
|
||||
|
||||
def validate_action_authorization(
|
||||
def validate_decision_envelope(
|
||||
envelope: object,
|
||||
expected_request: object,
|
||||
*,
|
||||
accepted_policy_packages: set[str],
|
||||
accepted_policy_versions: set[str],
|
||||
minimum_approval_count: int = 1,
|
||||
now: datetime | None = None,
|
||||
) -> ValidatedActionAuthorization:
|
||||
"""Validate exact request binding and dual control; never parse prose."""
|
||||
if minimum_approval_count < 1:
|
||||
raise DecisionError("minimum approval count must be positive")
|
||||
) -> ValidatedDecision:
|
||||
"""Validate a flex-auth DecisionEnvelope against the proposed action.
|
||||
|
||||
This is step 2 of GH-DEC-2026-003. It owns exactly the decision-layer
|
||||
checks: effect, exact CheckRequest binding, canonical request digest,
|
||||
lifetime, and the policy package/version pin. The approval fact -- validity,
|
||||
supersession, consumption, distinct approvers -- belongs to the
|
||||
approval-engine claim and is NOT re-checked here (GH-DEC-2026-005: a PIP
|
||||
must not republish the PDP's decision, and neither layer republishes the
|
||||
other's data).
|
||||
|
||||
There is deliberately no authority constant. State Hub is a read model and
|
||||
holds no runtime approval authority; requiring it fails closed against every
|
||||
correctly issued record.
|
||||
"""
|
||||
if not accepted_policy_packages or not accepted_policy_versions:
|
||||
raise DecisionError("accepted flex-auth policy package/version is required")
|
||||
if not isinstance(envelope, dict):
|
||||
raise DecisionError("action authorization must be an object")
|
||||
if envelope.get("schema_version") != SCHEMA_VERSION:
|
||||
raise DecisionError("unsupported action authorization schema version")
|
||||
authorization_id = _required_text(envelope, "id")
|
||||
try:
|
||||
parsed_authorization_id = uuid.UUID(authorization_id)
|
||||
except ValueError as e:
|
||||
raise DecisionError("action authorization id must be a canonical UUID") from e
|
||||
if str(parsed_authorization_id) != authorization_id:
|
||||
raise DecisionError("action authorization id must be a canonical UUID")
|
||||
if envelope.get("status") != "approved":
|
||||
raise DecisionError("action authorization status is not approved")
|
||||
if envelope.get("superseded_by"):
|
||||
raise DecisionError("action authorization is superseded")
|
||||
provenance = _required_dict(envelope, "provenance")
|
||||
if provenance.get("authority") != AUTHORITY:
|
||||
raise DecisionError("action authorization authority is not State Hub")
|
||||
|
||||
request = canonical_check_request(envelope.get("request"))
|
||||
expected = canonical_check_request(expected_request)
|
||||
_require_exact_target_sets(request)
|
||||
_require_exact_target_sets(expected)
|
||||
if request != expected:
|
||||
raise DecisionError("action authorization request does not exactly match action")
|
||||
|
||||
validity = _required_dict(envelope, "validity")
|
||||
expires = _parse_time(validity.get("expires_at"), "expires_at")
|
||||
not_before = (
|
||||
_parse_time(validity.get("not_before"), "not_before")
|
||||
if validity.get("not_before") is not None
|
||||
else None
|
||||
)
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
if not_before is not None and current < not_before:
|
||||
raise DecisionError("action authorization window has not started")
|
||||
if current >= expires:
|
||||
raise DecisionError("action authorization has expired")
|
||||
|
||||
approvals = _required_dict(envelope, "approvals")
|
||||
required_count = approvals.get("required_count")
|
||||
entries = approvals.get("entries")
|
||||
if not isinstance(required_count, int) or required_count < 1:
|
||||
raise DecisionError("action authorization approval count is invalid")
|
||||
if required_count < minimum_approval_count:
|
||||
raise DecisionError("action authorization approval threshold is insufficient")
|
||||
if not isinstance(entries, list):
|
||||
raise DecisionError("action authorization approval entries are invalid")
|
||||
approvers: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise DecisionError("action authorization approval entry is invalid")
|
||||
subject_id = _required_text(entry, "subject_id")
|
||||
approved_at = _parse_time(entry.get("approved_at"), "approved_at")
|
||||
if approved_at > current or approved_at >= expires:
|
||||
raise DecisionError("action authorization approval time is outside window")
|
||||
if not_before is not None and approved_at < not_before:
|
||||
raise DecisionError("action authorization approval time is outside window")
|
||||
if subject_id in approvers:
|
||||
raise DecisionError("action authorization contains duplicate approver")
|
||||
approvers.add(subject_id)
|
||||
if len(approvers) < required_count:
|
||||
raise DecisionError("action authorization has insufficient distinct approvals")
|
||||
|
||||
decision = _required_dict(envelope, "decision")
|
||||
if decision.get("effect") != "allow":
|
||||
raise DecisionError("decision envelope must be an object")
|
||||
contract = envelope.get("contract_version")
|
||||
if contract is not None and contract != CONTRACT_VERSION:
|
||||
raise DecisionError("unsupported decision envelope contract version")
|
||||
if envelope.get("effect") != "allow":
|
||||
raise DecisionError("flex-auth decision effect is not allow")
|
||||
decision_id = _required_text(decision, "id")
|
||||
if request.get("id") and decision.get("request_id") != request["id"]:
|
||||
decision_id = _required_text(envelope, "id")
|
||||
|
||||
expected = canonical_check_request(expected_request)
|
||||
_require_exact_target_sets(expected)
|
||||
if expected.get("id") and envelope.get("request_id") not in (None, expected["id"]):
|
||||
raise DecisionError("flex-auth decision request id does not match request")
|
||||
binding = _required_dict(decision, "binding")
|
||||
|
||||
binding = _required_dict(envelope, "binding")
|
||||
bound_request: dict[str, Any] = {}
|
||||
if binding.get("tenant"):
|
||||
bound_request["tenant"] = binding["tenant"]
|
||||
|
|
@ -279,36 +232,43 @@ def validate_action_authorization(
|
|||
}
|
||||
)
|
||||
expected_bound: dict[str, Any] = {}
|
||||
if request.get("tenant"):
|
||||
expected_bound["tenant"] = request["tenant"]
|
||||
if expected.get("tenant"):
|
||||
expected_bound["tenant"] = expected["tenant"]
|
||||
expected_bound.update(
|
||||
{
|
||||
"subject": request["subject"],
|
||||
"action": request["action"],
|
||||
"resource": request["resource"],
|
||||
"context": request.get("context", {}),
|
||||
"subject": expected["subject"],
|
||||
"action": expected["action"],
|
||||
"resource": expected["resource"],
|
||||
"context": expected.get("context", {}),
|
||||
}
|
||||
)
|
||||
if canonical_check_request(bound_request) != canonical_check_request(
|
||||
expected_bound
|
||||
):
|
||||
if canonical_check_request(bound_request) != canonical_check_request(expected_bound):
|
||||
raise DecisionError("flex-auth decision binding does not match request")
|
||||
if binding.get("request_digest") != request_digest(request):
|
||||
if binding.get("request_digest") != request_digest(expected):
|
||||
raise DecisionError("flex-auth request digest does not match request")
|
||||
if _subject_ref(decision.get("subject")) != request["subject"]:
|
||||
if _subject_ref(envelope.get("subject")) != expected["subject"]:
|
||||
raise DecisionError("flex-auth decision subject does not match request")
|
||||
if _resource_ref(decision.get("resource")) != request["resource"]:
|
||||
if _resource_ref(envelope.get("resource")) != expected["resource"]:
|
||||
raise DecisionError("flex-auth decision resource does not match request")
|
||||
decision_provenance = _required_dict(decision, "provenance")
|
||||
if decision_provenance.get("policy_package") not in accepted_policy_packages:
|
||||
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
lifetime = _required_dict(envelope, "lifetime")
|
||||
expires = _parse_time(lifetime.get("expires_at"), "expires_at")
|
||||
if current >= 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"):
|
||||
raise DecisionError("flex-auth decision lifetime has not started")
|
||||
|
||||
provenance = _required_dict(envelope, "provenance")
|
||||
if provenance.get("policy_package") not in accepted_policy_packages:
|
||||
raise DecisionError("flex-auth policy package is not accepted")
|
||||
if decision_provenance.get("policy_version") not in accepted_policy_versions:
|
||||
if provenance.get("policy_version") not in accepted_policy_versions:
|
||||
raise DecisionError("flex-auth policy version is not accepted")
|
||||
|
||||
return ValidatedActionAuthorization(
|
||||
authorization_id=authorization_id,
|
||||
return ValidatedDecision(
|
||||
decision_id=decision_id,
|
||||
action=request["action"],
|
||||
subject_id=request["subject"]["id"],
|
||||
action=expected["action"],
|
||||
subject_id=expected["subject"]["id"],
|
||||
expires_at=expires.isoformat(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue