secrets-engine/src/secrets_engine/authorization.py
tegwick 5aaa49a2ec
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Wait within decision validity bounds and preserve failed preflight evidence
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
2026-09-14 03:46:28 +02:00

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)