Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
423 lines
18 KiB
Python
423 lines
18 KiB
Python
"""Fail-closed consumer validation for flex-auth action authorizations.
|
|
|
|
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
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from secrets_engine.catalog import CatalogEntry, human_control_required
|
|
from secrets_engine.errors import DecisionError
|
|
|
|
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
|
|
#: The single tenant the published policy package scopes this engine to.
|
|
#:
|
|
#: `secrets-engine.catalog-lane.lifecycle` v2 reads
|
|
#: ``request_tenant := object.get(input, "tenant", "")`` and allows only
|
|
#: ``known_tenant := "tenant:platform"``; anything else, **an absent tenant
|
|
#: included**, matches its ``wrong_tenant`` first-denial branch. Sending no
|
|
#: tenant is therefore not a neutral omission, it is a denial.
|
|
#:
|
|
#: v1 had no tenant rule at all and failed open -- a `rotate` sent under
|
|
#: ``tenant:coulomb`` returned allow against the deployed package. That is why
|
|
#: v1 is superseded rather than amended, and why this value is pinned against
|
|
#: the vendored replay fixtures in ``tests/test_decision_replay.py`` instead of
|
|
#: being left to a deployment to supply correctly.
|
|
#:
|
|
#: This is not the KeyCape JWT tenant. ``service_auth.TENANT`` is
|
|
#: ``tenant:coulomb``, which is the exact value this package denies. Whether
|
|
#: those name one tenant or two layers is an open owner question -- see
|
|
#: ``docs/tenant-alignment.md`` -- and is deliberately not resolved by reusing
|
|
#: one constant for both.
|
|
REQUEST_TENANT = "tenant:platform"
|
|
|
|
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
|
|
request_digest: str
|
|
submitted_request_digest: 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 = "",
|
|
tenant: str = REQUEST_TENANT,
|
|
) -> dict[str, Any]:
|
|
"""Build the exact normalized secrets-engine profile for flex-auth.
|
|
|
|
``tenant`` is required and defaults to the package's published
|
|
``known_tenant``. It is hashed into the request digest and compared by the
|
|
package's ``wrong_tenant`` branch, so an omitted tenant is a denial rather
|
|
than a field the evaluator ignores.
|
|
"""
|
|
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"
|
|
)
|
|
if not tenant:
|
|
raise DecisionError(
|
|
"action request requires a tenant; the policy package denies an "
|
|
"absent tenant as wrong_tenant rather than ignoring it"
|
|
)
|
|
request: dict[str, Any] = {}
|
|
if request_id:
|
|
request["id"] = request_id
|
|
request.update(
|
|
{
|
|
"tenant": tenant,
|
|
"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,
|
|
# Bind the actual non-secret custody/delivery specification, not
|
|
# just names which can be reused for a different backend target.
|
|
# Approval identifiers are excluded: adding the newly issued
|
|
# approval must not change its own approval-free binding.
|
|
"catalog_target": copy.deepcopy({
|
|
name: getattr(entry, name)
|
|
for name in (
|
|
"kind", "org", "repo", "mount", "path", "fields",
|
|
"mount_management", "consumers", "delivery_modes",
|
|
"delivery_auth", "delivery_config", "auth_capability",
|
|
"workload_delivery",
|
|
)
|
|
}),
|
|
},
|
|
}
|
|
)
|
|
if human_control_required(entry):
|
|
# Bind the requested control to the exact PDP submission. This is a
|
|
# requirement on the observed approval, not an authorization verdict.
|
|
request["context"]["human_control"] = True
|
|
if action == "exec":
|
|
from secrets_engine.exec_owner import owner_digest
|
|
|
|
digest = owner_digest(entry)
|
|
if digest is not None:
|
|
request["context"]["exec_owner_sha256"] = digest
|
|
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
|
|
|
|
|
|
#: Correlation-only or separately-hashed fields, excluded from the digest
|
|
#: material by docs/canonical-request-digest.md "What is hashed".
|
|
_UNHASHED_FIELDS = ("id", "policy_version", "caring_context")
|
|
|
|
|
|
def digest_material(request: object) -> dict[str, Any]:
|
|
"""The exact tuple flex-auth hashes: tenant, subject, action, resource, context.
|
|
|
|
``id`` is correlation only, ``policy_version`` is recorded in provenance, and
|
|
``caring_context`` is hashed separately as
|
|
``provenance.input_claim_digests.caring_context``. Including any of them
|
|
produces a digest that matches no real DecisionEnvelope, which fails closed
|
|
against every correctly issued decision.
|
|
"""
|
|
canonical = canonical_check_request(request)
|
|
material = {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
|
|
# flex-auth tags Context `json:"context,omitempty"`, which drops the key for
|
|
# an empty map as well as a nil one. Keeping an empty object here would hash
|
|
# different material than the evaluator did.
|
|
if material.get("context") == {}:
|
|
del material["context"]
|
|
return material
|
|
|
|
|
|
def request_digest(request: object) -> str:
|
|
canonical = digest_material(request)
|
|
encoded = json.dumps(
|
|
canonical, ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8")
|
|
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
#: The context key an approval claim travels on, per flex-auth's
|
|
#: ``ApprovalContextKey``.
|
|
APPROVAL_CONTEXT_KEY = "approval"
|
|
|
|
|
|
def approval_binding_digest(request: object) -> str:
|
|
"""flex-auth's ``binding.approval_binding_digest`` for this request.
|
|
|
|
The same canonical digest with ``context.approval`` removed, so it is stable
|
|
across attaching the claim. A request that carries no claim has no separate
|
|
binding digest, and flex-auth returns the plain request digest there.
|
|
|
|
This exists because a ``pdp_digest`` recorded at approval-issue time can
|
|
never equal the ``request_digest`` of the request that later carries the
|
|
claim: the claim is part of the hashed context. See
|
|
``flex-auth/docs/canonical-request-digest.md`` "The approval-binding digest"
|
|
and FLEX-DEC-2026-007. It is deliberately NOT a replay identity.
|
|
"""
|
|
canonical = canonical_check_request(request)
|
|
context = canonical.get("context")
|
|
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
|
|
return request_digest(request)
|
|
stripped = dict(canonical)
|
|
stripped["context"] = {
|
|
k: v for k, v in context.items() if k != APPROVAL_CONTEXT_KEY
|
|
}
|
|
return request_digest(stripped)
|
|
|
|
|
|
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 _check_approval_binding_digest(
|
|
binding: dict[str, Any], expected: dict[str, Any], expected_digest: str,
|
|
) -> None:
|
|
"""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 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(
|
|
envelope: object,
|
|
expected_request: object,
|
|
*,
|
|
accepted_policy_packages: set[str],
|
|
accepted_policy_versions: set[str],
|
|
expected_approval_binding_digest: 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")
|
|
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")
|
|
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(),
|
|
request_digest=evaluated,
|
|
submitted_request_digest=submitted,
|
|
)
|
|
|
|
|
|
def wait_for_decision_start(envelope: object, *, clock=None, sleeper=None) -> None:
|
|
"""Wait at most two seconds for an owner's future start, never waive it.
|
|
|
|
Full decision/claim validation still runs afterward against the real clock.
|
|
Longer skew refuses rather than holding an approval claim until it is stale.
|
|
"""
|
|
current_time = clock or (lambda: datetime.now(timezone.utc))
|
|
sleep = sleeper or time.sleep
|
|
if not isinstance(envelope, dict):
|
|
return
|
|
lifetime = _required_dict(envelope, "lifetime")
|
|
if lifetime.get("not_before") is None:
|
|
return
|
|
start = _parse_time(lifetime["not_before"], "not_before")
|
|
delay = (start - current_time()).total_seconds()
|
|
if delay > 2:
|
|
raise DecisionError("flex-auth decision start exceeds bounded wait")
|
|
if delay > 0:
|
|
sleep(delay)
|