Implement SECRETS-WP-0008 unblocked layer-model obligations
Load pep-stance.yaml as the live unreachable-engine gate and record named stance fields on privileged evidence. Classify evidence, queue load-bearing records in a local outbox, and add heartbeat/drain commands that never sit on a mutation path. Publish proposed SSH-CA and secret-use evidence contracts without adding an OpenBao SSH-CA write. T02 (access-engine decision records) and T06 (no standing credential) stay wait on external endpoints. Assistant: grok Assistant-Session: 01a04cea-cb33-7c63-bad7-c1b0f9f0076b
This commit is contained in:
parent
57f6c4fa65
commit
3cd9955ac9
16 changed files with 1041 additions and 77 deletions
|
|
@ -14,6 +14,7 @@ Command surface (FR7):
|
|||
revoke <catalog-id>
|
||||
lifecycle suspend|deactivate|destroy <catalog-id>
|
||||
audit <catalog-id> [--json]
|
||||
evidence heartbeat|drain|classify
|
||||
|
||||
Every privileged action is decision-gated and writes non-secret evidence.
|
||||
`plan` and `apply --dry-run` never mutate OpenBao.
|
||||
|
|
@ -21,10 +22,8 @@ Every privileged action is decision-gated and writes non-secret evidence.
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from secrets_engine import __version__
|
||||
from secrets_engine.apply import apply_plan
|
||||
|
|
@ -33,6 +32,7 @@ from secrets_engine.config import Config, repo_root
|
|||
from secrets_engine.decisions import require_approved, resolve_decision
|
||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
||||
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.provision import provision_from_file, provision_generated
|
||||
|
|
@ -85,28 +85,23 @@ def _privileged_evidence(
|
|||
)
|
||||
|
||||
|
||||
def _unsafe_local_demo_enabled(cfg: Config) -> bool:
|
||||
"""Return true only for an explicit, offline, loopback-only demo."""
|
||||
host = (urlparse(cfg.bao_addr).hostname or "").lower()
|
||||
return (
|
||||
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||
and not cfg.hub_url
|
||||
and host in {"127.0.0.1", "localhost", "::1"}
|
||||
)
|
||||
def _require_lane_approval(
|
||||
cfg: Config,
|
||||
entry,
|
||||
action: str = "",
|
||||
evidence: PrivilegedActionEvidence | None = None,
|
||||
):
|
||||
"""Apply published PEP stance, then resolve lane approval.
|
||||
|
||||
|
||||
def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
||||
"""Resolve approval for a live action, failing production closed.
|
||||
|
||||
The durable State Hub action-authorization endpoint is not available yet.
|
||||
Production therefore cannot rely on a coarse lane decision. The one narrow
|
||||
exception is an explicit offline demo against a loopback OpenBao instance.
|
||||
Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable
|
||||
access-engine decision record is not served yet, so that row refuses live
|
||||
production work. The three-factor unsafe-demo exception is not a stance
|
||||
row. Build/test ``fail_open`` still requires the existing lane-approval
|
||||
check — a tracked gap until SECRETS-WP-0008-T02.
|
||||
"""
|
||||
if entry.stage == "prod" and not _unsafe_local_demo_enabled(cfg):
|
||||
raise DecisionError(
|
||||
f"production action '{action or 'unknown'}' requires a durable "
|
||||
"State Hub action authorization; live production remains disabled"
|
||||
)
|
||||
stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown")
|
||||
if evidence is not None:
|
||||
evidence.mark_stance(stance)
|
||||
if not entry.approval_required():
|
||||
return None
|
||||
decision = resolve_decision(
|
||||
|
|
@ -115,6 +110,8 @@ def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
|||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
if evidence is not None:
|
||||
evidence.mark_stance(with_decision(stance, decision))
|
||||
return decision
|
||||
|
||||
|
||||
|
|
@ -229,7 +226,7 @@ def cmd_apply(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "apply")
|
||||
decision = _require_lane_approval(cfg, entry, "apply", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
plan = build_plan(
|
||||
entry, args.stage, decision_id=decision.id if decision else ""
|
||||
|
|
@ -258,7 +255,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, "provision")
|
||||
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -291,7 +288,7 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
"negative_requested": negative,
|
||||
},
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "verify")
|
||||
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -367,7 +364,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, "handoff")
|
||||
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -419,7 +416,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
},
|
||||
) as evidence:
|
||||
# require approval + readiness before running.
|
||||
decision = _require_lane_approval(cfg, entry, "exec")
|
||||
decision = _require_lane_approval(cfg, entry, "exec", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
if not args.command:
|
||||
from secrets_engine.errors import DeliveryError
|
||||
|
|
@ -498,7 +495,7 @@ def cmd_revoke(cfg: Config, args) -> int:
|
|||
with _privileged_evidence(
|
||||
cfg, entry, "revoke", detail={"operation": plan.operation}
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate")
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -546,7 +543,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
|||
"live destroy is disabled until an exact-action destruction "
|
||||
"approval contract is available; use --dry-run to inspect targets"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -579,6 +576,65 @@ def cmd_audit(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_heartbeat(cfg: Config, args) -> int:
|
||||
"""Write a positive load-bearing heartbeat. Not a permission check."""
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence import write_heartbeat
|
||||
|
||||
record = write_heartbeat(_writer(cfg), stage=args.stage)
|
||||
if args.json:
|
||||
print(json.dumps(record, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"heartbeat {record['result']} queued={record.get('outbox_queued')} "
|
||||
f"completeness_claimed={record.get('completeness_claimed')}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_drain(cfg: Config, args) -> int:
|
||||
"""Best-effort drain of the local load-bearing outbox. Never a gate."""
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence import drain_outbox
|
||||
|
||||
result = drain_outbox(_writer(cfg), audit_core_url=args.audit_core_url)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"outbox drain queued={result['queued']} "
|
||||
f"delivered={result['delivered']} failed={result['failed']} "
|
||||
f"skipped={result['skipped']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_classify(cfg: Config, args) -> int:
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence_class import classify
|
||||
|
||||
classified = classify(args.action, args.stage)
|
||||
payload = {
|
||||
"action": classified.action,
|
||||
"stage": classified.stage,
|
||||
"kind": classified.kind,
|
||||
"rule_id": classified.rule_id,
|
||||
"queued_locally": classified.queued_locally,
|
||||
"completeness_claimed": classified.completeness_claimed,
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"{classified.action}/{classified.stage}: {classified.kind} "
|
||||
f"rule={classified.rule_id} queued={classified.queued_locally}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# -- parser ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -700,6 +756,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
au.add_argument("--json", action="store_true")
|
||||
au.set_defaults(func=cmd_audit)
|
||||
|
||||
ev = sub.add_parser("evidence", help="load-bearing evidence heartbeat and outbox")
|
||||
evsub = ev.add_subparsers(dest="subcmd", required=True)
|
||||
hb = evsub.add_parser(
|
||||
"heartbeat",
|
||||
help="emit a positive nothing-to-report claim (not a permission check)",
|
||||
)
|
||||
hb.add_argument("--stage", default="prod", choices=("build", "test", "prod"))
|
||||
hb.add_argument("--json", action="store_true")
|
||||
hb.set_defaults(func=cmd_evidence_heartbeat)
|
||||
dr = evsub.add_parser(
|
||||
"drain",
|
||||
help="best-effort drain of the local outbox; never blocks a mutation",
|
||||
)
|
||||
dr.add_argument(
|
||||
"--audit-core-url",
|
||||
default="",
|
||||
help="optional audit-core base URL; empty skips delivery and keeps files",
|
||||
)
|
||||
dr.add_argument("--json", action="store_true")
|
||||
dr.set_defaults(func=cmd_evidence_drain)
|
||||
cl = evsub.add_parser("classify", help="show the §9.6 class for an action/stage")
|
||||
cl.add_argument("action")
|
||||
cl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||
cl.add_argument("--json", action="store_true")
|
||||
cl.set_defaults(func=cmd_evidence_classify)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ class DecisionError(SecretsEngineError):
|
|||
|
||||
exit_code = 3
|
||||
|
||||
def __init__(self, message: str, *, stance: dict[str, object] | None = None):
|
||||
super().__init__(message)
|
||||
self.stance = dict(stance or {})
|
||||
|
||||
|
||||
class PolicyGuardError(SecretsEngineError):
|
||||
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence_class import KIND_ATTRIBUTIVE, classify
|
||||
from secrets_engine.redact import looks_secret, redact_text
|
||||
|
||||
# Keys that must never carry a value into evidence regardless of nesting.
|
||||
|
|
@ -64,6 +65,21 @@ class EvidenceWriter:
|
|||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
def outbox_dir(self) -> Path:
|
||||
path = self.evidence_dir / "outbox"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def _queue_outbox(self, record: dict[str, Any]) -> str:
|
||||
"""Durably queue a load-bearing record. Never talks to audit-core."""
|
||||
record_id = str(record.get("record_id") or uuid.uuid4())
|
||||
path = self.outbox_dir() / f"{record_id}.json"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
return record_id
|
||||
|
||||
def record(
|
||||
self,
|
||||
action: str,
|
||||
|
|
@ -75,9 +91,18 @@ class EvidenceWriter:
|
|||
detail: dict[str, Any] | None = None,
|
||||
hub: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Append one non-secret evidence record. Returns the stored record."""
|
||||
"""Append one non-secret evidence record. Returns the stored record.
|
||||
|
||||
Load-bearing records are queued locally first. An audit-core outage
|
||||
cannot occur here because this method never contacts audit-core.
|
||||
Completeness is never claimed. Presence or absence of a record is
|
||||
not consulted as a permission.
|
||||
"""
|
||||
record_id = str(uuid.uuid4())
|
||||
hub_requested = bool(hub and self.hub_url)
|
||||
evidence_class = classify(action, stage)
|
||||
hub_requested = bool(
|
||||
hub and self.hub_url and evidence_class.kind == KIND_ATTRIBUTIVE
|
||||
)
|
||||
record = {
|
||||
"record_id": record_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
|
|
@ -89,7 +114,13 @@ class EvidenceWriter:
|
|||
"decision_id": decision_id,
|
||||
"detail": _scrub(detail or {}),
|
||||
"hub_delivery_requested": hub_requested,
|
||||
"evidence_kind": evidence_class.kind,
|
||||
"evidence_rule": evidence_class.rule_id,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
if evidence_class.queued_locally:
|
||||
self._queue_outbox(record)
|
||||
record["outbox_queued"] = True
|
||||
self._append_local(record)
|
||||
if hub_requested:
|
||||
delivery = self._post_hub(
|
||||
|
|
@ -205,6 +236,7 @@ class PrivilegedActionEvidence:
|
|||
decision_id: str = ""
|
||||
approval_status: str = "pending"
|
||||
completed: bool = False
|
||||
stance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.approval_required:
|
||||
|
|
@ -218,6 +250,8 @@ class PrivilegedActionEvidence:
|
|||
"decision_ref": self.decision_ref,
|
||||
}
|
||||
)
|
||||
if self.stance:
|
||||
merged.update(self.stance)
|
||||
if extra:
|
||||
merged.update(extra)
|
||||
return merged
|
||||
|
|
@ -239,6 +273,17 @@ class PrivilegedActionEvidence:
|
|||
self.decision_id = str(getattr(decision, "id", ""))
|
||||
self.approval_status = "approved"
|
||||
|
||||
def mark_stance(self, stance: object | None) -> None:
|
||||
if stance is None:
|
||||
return
|
||||
if hasattr(stance, "as_evidence"):
|
||||
payload = stance.as_evidence()
|
||||
elif isinstance(stance, dict):
|
||||
payload = stance
|
||||
else:
|
||||
return
|
||||
self.stance = {key: value for key, value in payload.items() if value != ""}
|
||||
|
||||
def finish(
|
||||
self, result: str, *, detail: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -259,6 +304,8 @@ class PrivilegedActionEvidence:
|
|||
return False
|
||||
if isinstance(exc, DecisionError):
|
||||
self.approval_status = "rejected"
|
||||
if getattr(exc, "stance", None):
|
||||
self.mark_stance(exc.stance)
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
result = "interrupted"
|
||||
elif isinstance(exc, SecretsEngineError):
|
||||
|
|
@ -274,3 +321,76 @@ class PrivilegedActionEvidence:
|
|||
detail=self._detail({"error_type": type(exc).__name__}),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def write_heartbeat(writer: EvidenceWriter, *, stage: str = "prod") -> dict[str, Any]:
|
||||
"""Positive claim that can go missing. Not a permission and not silence."""
|
||||
queued = 0
|
||||
outbox = writer.evidence_dir / "outbox"
|
||||
if outbox.is_dir():
|
||||
queued = sum(1 for path in outbox.glob("*.json") if path.is_file())
|
||||
return writer.record(
|
||||
"evidence-heartbeat",
|
||||
result="nothing-to-report",
|
||||
stage=stage,
|
||||
detail={
|
||||
"form": "heartbeat",
|
||||
"outbox_depth": queued,
|
||||
"completeness_claimed": False,
|
||||
},
|
||||
hub=False,
|
||||
)
|
||||
|
||||
|
||||
def drain_outbox(
|
||||
writer: EvidenceWriter,
|
||||
*,
|
||||
audit_core_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Best-effort delivery of queued load-bearing records.
|
||||
|
||||
Never called from a mutation path. An audit-core outage leaves files in
|
||||
place and does not raise into a revoke/destroy handler.
|
||||
"""
|
||||
outbox = writer.evidence_dir / "outbox"
|
||||
if not outbox.is_dir():
|
||||
return {
|
||||
"queued": 0,
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
files = sorted(path for path in outbox.glob("*.json") if path.is_file())
|
||||
queued = len(files)
|
||||
if not audit_core_url:
|
||||
return {
|
||||
"queued": queued,
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"skipped": queued,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
delivered = 0
|
||||
failed = 0
|
||||
for path in files:
|
||||
try:
|
||||
payload = path.read_text(encoding="utf-8").encode()
|
||||
req = urllib.request.Request(
|
||||
audit_core_url.rstrip("/") + "/v1/events",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
path.unlink()
|
||||
delivered += 1
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
failed += 1
|
||||
return {
|
||||
"queued": queued,
|
||||
"delivered": delivered,
|
||||
"failed": failed,
|
||||
"skipped": 0,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
|
|
|
|||
164
src/secrets_engine/evidence_class.py
Normal file
164
src/secrets_engine/evidence_class.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""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",
|
||||
),
|
||||
"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,
|
||||
)
|
||||
141
src/secrets_engine/pep_stance.py
Normal file
141
src/secrets_engine/pep_stance.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Published PEP unreachable-engine stance (security-layer-model v0.7 §6.4).
|
||||
|
||||
Runtime reads ``pep-stance.yaml``. ``SHIPPED_STANCE`` is the pin that makes
|
||||
drift between the published map and this module fail the conformance test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
SHIPPED_STANCE = {
|
||||
"build": "fail_open",
|
||||
"test": "fail_open",
|
||||
"prod": "fail_closed",
|
||||
"unknown": "fail_closed",
|
||||
}
|
||||
REQUIRED_STAGES = tuple(SHIPPED_STANCE)
|
||||
VALID_MODES = frozenset({"fail_open", "fail_closed"})
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StanceApplication:
|
||||
"""Named residue applied when access-engine is unreachable."""
|
||||
|
||||
stage: str
|
||||
failure_mode: str
|
||||
action: str
|
||||
demo_exception: bool = False
|
||||
decision_id: str = ""
|
||||
|
||||
def as_evidence(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"stance_stage": self.stage,
|
||||
"stance_failure_mode": self.failure_mode,
|
||||
"stance_demo_exception": self.demo_exception,
|
||||
}
|
||||
if self.decision_id:
|
||||
payload["stance_decision_id"] = self.decision_id
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PepStanceMap:
|
||||
stance: dict[str, str]
|
||||
path: Path
|
||||
|
||||
def for_stage(self, stage: str) -> tuple[str, str]:
|
||||
key = stage if stage in self.stance else "unknown"
|
||||
mode = self.stance.get(key, "")
|
||||
if mode not in VALID_MODES:
|
||||
raise DecisionError(
|
||||
f"pep-stance.yaml has no usable mode for stage {stage!r}"
|
||||
)
|
||||
return key, mode
|
||||
|
||||
|
||||
def pep_stance_path() -> Path:
|
||||
override = os.environ.get("SECRETS_ENGINE_PEP_STANCE", "")
|
||||
if override:
|
||||
return Path(override)
|
||||
return repo_root() / "pep-stance.yaml"
|
||||
|
||||
|
||||
def load_pep_stance(path: Path | None = None) -> PepStanceMap:
|
||||
target = path or pep_stance_path()
|
||||
try:
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise DecisionError(f"unable to load PEP stance map {target}: {exc}") from exc
|
||||
raw = data.get("stance")
|
||||
if not isinstance(raw, dict):
|
||||
raise DecisionError(f"{target} is missing a stance map")
|
||||
stance = {str(key): str(value) for key, value in raw.items()}
|
||||
missing = [stage for stage in REQUIRED_STAGES if stage not in stance]
|
||||
if missing:
|
||||
raise DecisionError(
|
||||
f"{target} is not total; missing stages {missing}"
|
||||
)
|
||||
unknown_modes = {
|
||||
f"{stage}={mode}"
|
||||
for stage, mode in stance.items()
|
||||
if mode not in VALID_MODES
|
||||
}
|
||||
if unknown_modes:
|
||||
raise DecisionError(f"{target} has invalid modes: {sorted(unknown_modes)}")
|
||||
return PepStanceMap(stance=stance, path=target)
|
||||
|
||||
|
||||
def demo_exception_enabled(cfg: Any) -> bool:
|
||||
"""Three-factor throwaway exception; not a stance row."""
|
||||
host = (urlparse(getattr(cfg, "bao_addr", "")).hostname or "").lower()
|
||||
return (
|
||||
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||
and not getattr(cfg, "hub_url", "")
|
||||
and host in LOOPBACK_HOSTS
|
||||
)
|
||||
|
||||
|
||||
def apply_unreachable_engine_stance(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
*,
|
||||
stance_map: PepStanceMap | None = None,
|
||||
) -> StanceApplication:
|
||||
"""Apply the published unreachable-engine residue for a live action.
|
||||
|
||||
``fail_closed`` without the demo exception raises ``DecisionError`` carrying
|
||||
named stance fields. ``fail_open`` is the documented residue: continue to
|
||||
the existing lane-approval check, which is itself a gap until T02.
|
||||
"""
|
||||
loaded = stance_map or load_pep_stance()
|
||||
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
|
||||
demo = demo_exception_enabled(cfg)
|
||||
applied = StanceApplication(
|
||||
stage=stage,
|
||||
failure_mode=mode,
|
||||
action=action or "unknown",
|
||||
demo_exception=bool(demo and mode == "fail_closed"),
|
||||
)
|
||||
if mode == "fail_closed" and not demo:
|
||||
raise DecisionError(
|
||||
f"production action '{applied.action}' requires a durable "
|
||||
"access-engine decision record; live production remains disabled",
|
||||
stance=applied.as_evidence(),
|
||||
)
|
||||
return applied
|
||||
|
||||
|
||||
def with_decision(stance: StanceApplication, decision: Any) -> StanceApplication:
|
||||
decision_id = str(getattr(decision, "id", "") or "")
|
||||
return replace(stance, decision_id=decision_id)
|
||||
Loading…
Add table
Add a link
Reference in a new issue