Record privileged action failure evidence
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0217e-8c4c-7383-be6b-f50a6e485306
This commit is contained in:
tegwick 2026-08-23 12:58:12 +02:00
parent c4504c6de9
commit f579f3761c
8 changed files with 517 additions and 189 deletions

View file

@ -30,7 +30,7 @@ from secrets_engine.catalog import get_entry, load_catalog
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
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.plan import build_plan
from secrets_engine.provision import provision_from_file, provision_generated
@ -65,6 +65,24 @@ def _writer(cfg: Config) -> EvidenceWriter:
return EvidenceWriter(evidence_dir=cfg.evidence_dir, hub_url=cfg.hub_url, topic_id=cfg.topic_id)
def _privileged_evidence(
cfg: Config,
entry,
action: str,
*,
detail: dict[str, object] | None = None,
) -> PrivilegedActionEvidence:
return PrivilegedActionEvidence(
writer=_writer(cfg),
action=action,
catalog_id=entry.id,
stage=entry.stage,
decision_ref=entry.approval.get("decision_ref", ""),
approval_required=entry.approval_required(),
detail=detail or {},
)
def _require_lane_approval(cfg: Config, entry):
"""Resolve and enforce the lane approval for a privileged live action."""
if not entry.approval_required():
@ -163,105 +181,153 @@ def cmd_plan(cfg: Config, args) -> int:
def cmd_apply(cfg: Config, args) -> int:
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
decision = None
if entry.approval_required():
try:
decision = resolve_decision(
hub_url=cfg.hub_url, repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
except DecisionError:
if not args.dry_run:
raise
if not args.dry_run:
require_approved(entry, decision)
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
w = _writer(cfg)
if args.dry_run:
decision = None
if entry.approval_required():
try:
decision = resolve_decision(
hub_url=cfg.hub_url,
repo_root=repo_root(),
decision_ref=entry.approval.get("decision_ref", args.ref),
)
except DecisionError:
decision = None
plan = build_plan(
entry, args.stage, decision_id=decision.id if decision else ""
)
print(plan.render())
print("\n(dry-run: no OpenBao mutation performed)")
w.record("apply", result="dry-run", catalog_id=entry.id, stage=args.stage,
decision_id=decision.id if decision else "")
_writer(cfg).record(
"apply",
result="dry-run",
catalog_id=entry.id,
stage=args.stage,
decision_id=decision.id if decision else "",
)
return 0
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
result = apply_plan(client, entry, plan)
print(result.render())
w.record("apply", result="applied", catalog_id=entry.id, stage=args.stage,
decision_id=decision.id if decision else "",
detail={"applied": result.applied, "skipped": result.skipped})
with _privileged_evidence(cfg, entry, "apply") as evidence:
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
plan = build_plan(
entry, args.stage, decision_id=decision.id if decision else ""
)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_plan(client, entry, plan)
print(result.render())
evidence.finish(
"applied",
detail={"applied": result.applied, "skipped": result.skipped},
)
return 0
def cmd_provision(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.stage != entry.stage:
from secrets_engine.errors import ProvisioningError
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
decision = _require_lane_approval(cfg, entry)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
field = args.field or (entry.fields[0] if entry.fields else "")
if args.generate:
f = provision_generated(client, entry, field)
mode = "generated"
else:
f = provision_from_file(client, entry, field, Path(args.from_file))
mode = "from-file"
print(f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed")
_writer(cfg).record("provision", result=mode, catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "", detail={"field": f})
with _privileged_evidence(
cfg, entry, "provision", detail={"field": field}
) as evidence:
if args.stage != entry.stage:
from secrets_engine.errors import ProvisioningError
raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
)
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
if args.generate:
f = provision_generated(client, entry, field)
mode = "generated"
else:
f = provision_from_file(client, entry, field, Path(args.from_file))
mode = "from-file"
print(
f"provisioned lane '{entry.id}' field '{f}' ({mode}) — value not displayed"
)
evidence.finish(mode, detail={"field": f})
return 0
def cmd_verify(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
decision = _require_lane_approval(cfg, entry)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
fields = [args.field] if args.field else list(entry.fields)
if entry.stores_kv_value() and not fields:
from secrets_engine.errors import VerificationError
raise VerificationError(f"lane '{entry.id}' has no field to verify")
positive = args.positive or not args.negative
negative = args.negative or not args.positive
unrelated_token = None
if entry.stores_kv_value() and negative and args.negative_token_file:
from secrets_engine.openbao import read_strict_token_file
unrelated_token = read_strict_token_file(
Path(args.negative_token_file), purpose="negative verification token"
with _privileged_evidence(
cfg,
entry,
"verify",
detail={
"fields": fields,
"positive_requested": positive,
"negative_requested": negative,
},
) as evidence:
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
if entry.stores_kv_value():
results = []
if positive:
for field in fields:
if entry.stores_kv_value() and not fields:
from secrets_engine.errors import VerificationError
raise VerificationError(f"lane '{entry.id}' has no field to verify")
unrelated_token = None
if entry.stores_kv_value() and negative and args.negative_token_file:
from secrets_engine.openbao import read_strict_token_file
unrelated_token = read_strict_token_file(
Path(args.negative_token_file),
purpose="negative verification token",
)
if entry.stores_kv_value():
results = []
if positive:
for field in fields:
results.extend(
run_verification(
client, entry, field, positive=True, negative=False
)
)
if negative:
# Denial is path-scoped; one real unrelated probe covers the path.
results.extend(
run_verification(
client, entry, field, positive=True, negative=False
client,
entry,
fields[0],
positive=False,
negative=True,
unrelated_token=unrelated_token,
)
)
if negative:
# Denial is path-scoped, so one probe covers every field on this path.
results.extend(
run_verification(
client,
entry,
fields[0],
positive=False,
negative=True,
unrelated_token=unrelated_token,
)
else:
results = run_verification(
client, entry, "", positive=positive, negative=negative
)
else:
results = run_verification(
client, entry, "", positive=positive, negative=negative
rc = 0
for result in results:
print(result.render())
if not result.passed:
rc = 7
evidence.writer.record(
"verify-check",
result=f"{result.check}:{'pass' if result.passed else 'fail'}",
catalog_id=entry.id,
stage=entry.stage,
decision_id=evidence.decision_id,
detail=result.detail,
)
evidence.finish(
"pass" if rc == 0 else "verification-failed",
detail={"check_count": len(results)},
)
rc = 0
for r in results:
print(r.render())
if not r.passed:
rc = 7
_writer(cfg).record("verify", result=f"{r.check}:{'pass' if r.passed else 'fail'}",
catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "", detail=r.detail)
return rc
@ -270,58 +336,76 @@ def cmd_handoff(cfg: Config, args) -> int:
from secrets_engine.handoff import write_approle_handoff
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.stage != entry.stage:
raise ProvisioningError(f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'")
if entry.kind != "auth-capability":
raise ProvisioningError(f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability")
decision = _require_lane_approval(cfg, entry)
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
result = write_approle_handoff(
client,
entry,
role_id_file=Path(args.role_id_file),
secret_id_file=Path(args.secret_id_file),
)
print(f"wrote AppRole handoff material for lane '{entry.id}' — secret_id not displayed")
print(f" role: {result.role_name}")
print(f" role_id_file: {result.role_id_file}")
print(f" secret_id_file: {result.secret_id_file}")
print(f" token_ttl: {result.token_ttl}")
print(f" secret_id_ttl: {result.secret_id_ttl}")
_writer(cfg).record(
"handoff",
result="secret-id-written",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"role": result.role_name,
"role_id_file": result.role_id_file,
"secret_id_file": result.secret_id_file,
"token_ttl": result.token_ttl,
"secret_id_ttl": result.secret_id_ttl,
"secret_id_num_uses": result.secret_id_num_uses,
},
)
with _privileged_evidence(cfg, entry, "handoff") as evidence:
if args.stage != entry.stage:
raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
)
if entry.kind != "auth-capability":
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
)
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = write_approle_handoff(
client,
entry,
role_id_file=Path(args.role_id_file),
secret_id_file=Path(args.secret_id_file),
)
print(
f"wrote AppRole handoff material for lane '{entry.id}'"
"secret_id not displayed"
)
print(f" role: {result.role_name}")
print(f" role_id_file: {result.role_id_file}")
print(f" secret_id_file: {result.secret_id_file}")
print(f" token_ttl: {result.token_ttl}")
print(f" secret_id_ttl: {result.secret_id_ttl}")
evidence.finish(
"secret-id-written",
detail={
"role": result.role_name,
"role_id_file": result.role_id_file,
"secret_id_file": result.secret_id_file,
"token_ttl": result.token_ttl,
"secret_id_ttl": result.secret_id_ttl,
"secret_id_num_uses": result.secret_id_num_uses,
},
)
return 0
def cmd_exec(cfg: Config, args) -> int:
from secrets_engine.exec_delivery import exec_with_secret
entry = get_entry(cfg.catalog_dir, args.catalog)
# require approval + readiness before running.
decision = _require_lane_approval(cfg, entry)
if not args.command:
from secrets_engine.errors import DeliveryError
raise DeliveryError("no command after '--'")
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
field = args.field or (entry.fields[0] if entry.fields else "")
w = _writer(cfg)
w.record("exec", result="attempt", catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "",
detail={"command": args.command[0], "mode": args.mode})
session_detail: dict[str, object] = {}
try:
command_name = args.command[0] if args.command else ""
with _privileged_evidence(
cfg,
entry,
"exec",
detail={
"command": command_name,
"mode": args.mode,
"field": field,
"session": session_detail,
},
) as evidence:
# require approval + readiness before running.
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
if not args.command:
from secrets_engine.errors import DeliveryError
raise DeliveryError("no command after '--'")
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
rc = exec_with_secret(
client,
entry,
@ -330,23 +414,7 @@ def cmd_exec(cfg: Config, args) -> int:
mode=args.mode,
session_evidence=session_detail,
)
except SecretsEngineError as e:
w.record(
"exec",
result=f"failed-{type(e).__name__}",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"command": args.command[0],
"mode": args.mode,
"session": session_detail,
},
)
raise
w.record("exec", result=f"exit-{rc}", catalog_id=entry.id, stage=entry.stage,
decision_id=decision.id if decision else "",
detail={"command": args.command[0], "session": session_detail})
evidence.finish(f"exit-{rc}")
return rc
@ -400,30 +468,29 @@ def cmd_revoke(cfg: Config, args) -> int:
)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
decision = None if args.dry_run else _require_lane_approval(cfg, entry)
plan = build_native_deactivation_plan(entry)
if args.dry_run:
print(plan.render())
print("\n(dry-run: no OpenBao mutation performed)")
return 0
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
_writer(cfg).record(
"revoke",
result="native-access-deactivated",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"operation": plan.operation,
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
with _privileged_evidence(
cfg, entry, "revoke", detail={"operation": plan.operation}
) as evidence:
decision = _require_lane_approval(cfg, entry)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
evidence.finish(
"native-access-deactivated",
detail={
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
return 0
@ -435,41 +502,43 @@ def cmd_lifecycle(cfg: Config, args) -> int:
)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.operation == "destroy" and not args.dry_run:
require_destroy_confirmation(entry, args.confirm_destroy)
# A lane-level approval is not an authorization to erase custody. Keep
# the destructive live path closed until T04 supplies a canonical,
# exact-action, expiring and dual-control-capable decision contract.
from secrets_engine.errors import PolicyGuardError
raise PolicyGuardError(
"live destroy is disabled until an exact-action destruction "
"approval contract is available; use --dry-run to inspect targets"
)
decision = None if args.dry_run else _require_lane_approval(cfg, entry)
plan = build_lifecycle_plan(entry, args.operation)
if args.dry_run:
print(plan.render())
print("\n(dry-run: no OpenBao mutation performed)")
return 0
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
_writer(cfg).record(
with _privileged_evidence(
cfg,
entry,
f"lifecycle-{args.operation}",
result="applied",
catalog_id=entry.id,
stage=entry.stage,
decision_id=decision.id if decision else "",
detail={
"operation": plan.operation,
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
detail={"operation": plan.operation},
) as evidence:
if args.operation == "destroy":
require_destroy_confirmation(entry, args.confirm_destroy)
# A lane-level approval is not an authorization to erase custody.
# Keep the destructive live path closed until T04 supplies a
# canonical exact-action and dual-control-capable decision contract.
from secrets_engine.errors import PolicyGuardError
raise PolicyGuardError(
"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)
evidence.mark_approved(decision)
client = OpenBaoClient.resolve(
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
)
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
evidence.finish(
"applied",
detail={
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
return 0