secrets-engine/src/secrets_engine/evidence_class.py
tegwick afd1c8e593
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Add response-wrapped operator handoff
secrets-engine wrap writes a single-use OpenBao wrap token to a mode-0600
out-of-repo file and never prints it. KV reads and AppRole secret_ids are
wrapped with a 15m TTL cap. Unwrapped secret payloads fail closed.
Production wrap remains fail-closed.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
2026-09-02 08:12:42 +02:00

166 lines
5 KiB
Python

"""Load-bearing vs attributive evidence classification (§9.6).
The YAML file is the declaration. ``SHIPPED_RULES`` is the pin that makes
drift fail the test. No function here grants or denies an action based on
whether a local evidence record exists.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from secrets_engine.config import repo_root
from secrets_engine.errors import SecretsEngineError
KIND_LOAD_BEARING = "load-bearing"
KIND_ATTRIBUTIVE = "attributive"
KIND_HEARTBEAT = "heartbeat"
# First match wins. Destroy is always load-bearing; production control
# mutations are load-bearing; everything else is attributive.
SHIPPED_RULES = (
{
"id": "heartbeat",
"kind": KIND_HEARTBEAT,
"actions": ("evidence-heartbeat",),
"stages": ("build", "test", "prod", "unknown"),
},
{
"id": "destroy",
"kind": KIND_LOAD_BEARING,
"actions": ("lifecycle-destroy",),
"stages": ("build", "test", "prod", "unknown"),
},
{
"id": "production-control-mutation",
"kind": KIND_LOAD_BEARING,
"actions": (
"revoke",
"lifecycle-suspend",
"lifecycle-deactivate",
"provision",
"session-revoke",
"wrap",
),
"stages": ("prod",),
},
{
"id": "default-attributive",
"kind": KIND_ATTRIBUTIVE,
"actions": ("*",),
"stages": ("build", "test", "prod", "unknown"),
},
)
class EvidenceClassificationError(SecretsEngineError):
"""Classification file missing or malformed."""
exit_code = 1
@dataclass(frozen=True)
class EvidenceClass:
kind: str
rule_id: str
action: str
stage: str
queued_locally: bool
@property
def completeness_claimed(self) -> bool:
return False
def classification_path() -> Path:
override = os.environ.get("SECRETS_ENGINE_EVIDENCE_CLASSIFICATION", "")
if override:
return Path(override)
return repo_root() / "evidence-classification.yaml"
def _normalize_rules(raw: Any) -> tuple[dict[str, Any], ...]:
if not isinstance(raw, list) or not raw:
raise EvidenceClassificationError(
"evidence-classification.yaml must list at least one rule"
)
rules: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
raise EvidenceClassificationError("each classification rule must be a map")
kind = str(item.get("kind", ""))
if kind not in {KIND_LOAD_BEARING, KIND_ATTRIBUTIVE, KIND_HEARTBEAT}:
raise EvidenceClassificationError(f"unknown evidence kind {kind!r}")
actions = tuple(str(a) for a in item.get("actions") or ())
stages = tuple(str(s) for s in item.get("stages") or ())
if not actions or not stages:
raise EvidenceClassificationError(
f"rule {item.get('id')!r} needs actions and stages"
)
rules.append(
{
"id": str(item.get("id", "")),
"kind": kind,
"actions": actions,
"stages": stages,
}
)
return tuple(rules)
def load_classification_rules(
path: Path | None = None,
) -> tuple[dict[str, Any], ...]:
target = path or classification_path()
try:
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise EvidenceClassificationError(
f"unable to load evidence classification {target}: {exc}"
) from exc
if data.get("completeness_claimed") is not False:
raise EvidenceClassificationError(
f"{target} must declare completeness_claimed: false"
)
if data.get("no_control_branches_on_presence") is not True:
raise EvidenceClassificationError(
f"{target} must declare no_control_branches_on_presence: true"
)
return _normalize_rules(data.get("rules"))
def classify(
action: str,
stage: str,
*,
rules: tuple[dict[str, Any], ...] | None = None,
) -> EvidenceClass:
"""Return the evidence class for an action/stage. Never a permission."""
table = rules if rules is not None else load_classification_rules()
stage_key = stage if stage else "unknown"
for rule in table:
actions = rule["actions"]
stages = rule["stages"]
if "*" not in actions and action not in actions:
continue
if stage_key not in stages and "*" not in stages:
continue
kind = str(rule["kind"])
return EvidenceClass(
kind=kind,
rule_id=str(rule["id"]),
action=action,
stage=stage_key,
queued_locally=kind in {KIND_LOAD_BEARING, KIND_HEARTBEAT},
)
return EvidenceClass(
kind=KIND_ATTRIBUTIVE,
rule_id="implicit-attributive",
action=action,
stage=stage_key,
queued_locally=False,
)