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
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from secrets_engine.catalog import CatalogEntry
|
|
from secrets_engine.errors import DecisionError
|
|
|
|
SCHEMA_VERSION = "0.1"
|
|
CONTRACT_VERSION = "flex-auth.decision-record.v1"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidatedDecision:
|
|
decision_id: str
|
|
action: str
|
|
subject_id: str
|
|
expires_at: str
|
|
|
|
|
|
def build_action_request(
|
|
entry: CatalogEntry,
|
|
action: str,
|
|
*,
|
|
subject_id: str,
|
|
subject_type: str,
|
|
purpose: str,
|
|
fields: list[str] | tuple[str, ...] = (),
|
|
policy_targets: list[str] | tuple[str, ...] = (),
|
|
auth_targets: list[str] | tuple[str, ...] = (),
|
|
request_id: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Build the exact normalized secrets-engine profile for flex-auth."""
|
|
if not action or not subject_id or not subject_type or not purpose:
|
|
raise DecisionError(
|
|
"action request requires action, subject id/type, and purpose"
|
|
)
|
|
request: dict[str, Any] = {}
|
|
if request_id:
|
|
request["id"] = request_id
|
|
request.update(
|
|
{
|
|
"subject": {"id": subject_id, "type": subject_type},
|
|
"action": action,
|
|
"resource": {
|
|
"id": f"catalog:{entry.id}",
|
|
"type": "secret-catalog-lane",
|
|
"system": "secrets-engine",
|
|
"attributes": {
|
|
"stage": entry.stage,
|
|
"fields": sorted(set(fields)),
|
|
"policy_targets": sorted(set(policy_targets)),
|
|
"auth_targets": sorted(set(auth_targets)),
|
|
},
|
|
},
|
|
"context": {"purpose": purpose},
|
|
}
|
|
)
|
|
return request
|
|
|
|
|
|
def _required_dict(container: dict[str, Any], name: str) -> dict[str, Any]:
|
|
value = container.get(name)
|
|
if not isinstance(value, dict):
|
|
raise DecisionError(f"action authorization requires object '{name}'")
|
|
return value
|
|
|
|
|
|
def _required_text(container: dict[str, Any], name: str) -> str:
|
|
value = container.get(name)
|
|
if not isinstance(value, str) or not value:
|
|
raise DecisionError(f"action authorization requires non-empty '{name}'")
|
|
return value
|
|
|
|
|
|
def _parse_time(value: object, name: str) -> datetime:
|
|
if not isinstance(value, str):
|
|
raise DecisionError(f"action authorization requires timestamp '{name}'")
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as e:
|
|
raise DecisionError(f"action authorization has invalid timestamp '{name}'") from e
|
|
if parsed.tzinfo is None:
|
|
raise DecisionError(f"action authorization timestamp '{name}' needs timezone")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _sorted_map(value: object) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
return {key: _canonical_map_value(value[key]) for key in sorted(value)}
|
|
|
|
|
|
def _canonical_map_value(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return _sorted_map(value)
|
|
if isinstance(value, list):
|
|
return [_canonical_map_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _subject_ref(value: object) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise DecisionError("action authorization subject must be an object")
|
|
subject: dict[str, Any] = {"id": _required_text(value, "id")}
|
|
for name in ("type", "tenant"):
|
|
if value.get(name):
|
|
subject[name] = _required_text(value, name)
|
|
if value.get("attributes") is not None:
|
|
subject["attributes"] = _sorted_map(value.get("attributes"))
|
|
return subject
|
|
|
|
|
|
def _resource_ref(value: object) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise DecisionError("action authorization resource must be an object")
|
|
resource: dict[str, Any] = {"id": _required_text(value, "id")}
|
|
for name in ("type", "system", "tenant"):
|
|
if value.get(name):
|
|
resource[name] = _required_text(value, name)
|
|
if value.get("attributes") is not None:
|
|
resource["attributes"] = _sorted_map(value.get("attributes"))
|
|
return resource
|
|
|
|
|
|
def canonical_check_request(request: object) -> dict[str, Any]:
|
|
"""Match Go encoding/json field order used by flex-auth request digests."""
|
|
if not isinstance(request, dict):
|
|
raise DecisionError("action authorization request must be an object")
|
|
canonical: dict[str, Any] = {}
|
|
if request.get("id"):
|
|
canonical["id"] = _required_text(request, "id")
|
|
if request.get("tenant"):
|
|
canonical["tenant"] = _required_text(request, "tenant")
|
|
canonical["subject"] = _subject_ref(request.get("subject"))
|
|
canonical["action"] = _required_text(request, "action")
|
|
canonical["resource"] = _resource_ref(request.get("resource"))
|
|
if request.get("context") is not None:
|
|
canonical["context"] = _sorted_map(request.get("context"))
|
|
if request.get("caring_context") is not None:
|
|
canonical["caring_context"] = _canonical_map_value(
|
|
request.get("caring_context")
|
|
)
|
|
if request.get("policy_version"):
|
|
canonical["policy_version"] = _required_text(request, "policy_version")
|
|
return canonical
|
|
|
|
|
|
def request_digest(request: object) -> str:
|
|
canonical = canonical_check_request(request)
|
|
encoded = json.dumps(
|
|
canonical, ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8")
|
|
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _require_exact_target_sets(request: dict[str, Any]) -> None:
|
|
resource = _required_dict(request, "resource")
|
|
attributes = resource.get("attributes", {})
|
|
if not isinstance(attributes, dict):
|
|
raise DecisionError("action authorization resource attributes must be an object")
|
|
for name in ("fields", "policy_targets", "auth_targets"):
|
|
values = attributes.get(name, [])
|
|
if not isinstance(values, list) or not all(
|
|
isinstance(item, str) and item for item in values
|
|
):
|
|
raise DecisionError(f"action authorization target set '{name}' is invalid")
|
|
if values != sorted(set(values)):
|
|
raise DecisionError(
|
|
f"action authorization target set '{name}' must be sorted and unique"
|
|
)
|
|
|
|
|
|
def validate_decision_envelope(
|
|
envelope: object,
|
|
expected_request: object,
|
|
*,
|
|
accepted_policy_packages: set[str],
|
|
accepted_policy_versions: set[str],
|
|
now: datetime | None = None,
|
|
) -> 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("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(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(envelope, "binding")
|
|
bound_request: dict[str, Any] = {}
|
|
if binding.get("tenant"):
|
|
bound_request["tenant"] = binding["tenant"]
|
|
bound_request.update(
|
|
{
|
|
"subject": binding.get("subject"),
|
|
"action": binding.get("action"),
|
|
"resource": binding.get("resource"),
|
|
"context": binding.get("context", {}),
|
|
}
|
|
)
|
|
expected_bound: dict[str, Any] = {}
|
|
if expected.get("tenant"):
|
|
expected_bound["tenant"] = expected["tenant"]
|
|
expected_bound.update(
|
|
{
|
|
"subject": expected["subject"],
|
|
"action": expected["action"],
|
|
"resource": expected["resource"],
|
|
"context": expected.get("context", {}),
|
|
}
|
|
)
|
|
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(expected):
|
|
raise DecisionError("flex-auth request digest does not match request")
|
|
if _subject_ref(envelope.get("subject")) != expected["subject"]:
|
|
raise DecisionError("flex-auth decision subject does not match request")
|
|
if _resource_ref(envelope.get("resource")) != expected["resource"]:
|
|
raise DecisionError("flex-auth decision resource does not match request")
|
|
|
|
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 provenance.get("policy_version") not in accepted_policy_versions:
|
|
raise DecisionError("flex-auth policy version is not accepted")
|
|
|
|
return ValidatedDecision(
|
|
decision_id=decision_id,
|
|
action=expected["action"],
|
|
subject_id=expected["subject"]["id"],
|
|
expires_at=expires.isoformat(),
|
|
)
|