2026-06-28 12:28:45 +02:00
|
|
|
"""secrets-engine command-line interface.
|
|
|
|
|
|
|
|
|
|
Command surface (FR7):
|
|
|
|
|
catalog list
|
|
|
|
|
catalog show <catalog-id>
|
|
|
|
|
decision inspect <decision-or-ccr-id>
|
|
|
|
|
plan <decision-or-ref> --stage <stage>
|
|
|
|
|
apply <decision-or-ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
|
|
|
|
|
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
|
2026-08-23 12:33:38 +02:00
|
|
|
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
|
2026-06-29 16:58:16 +02:00
|
|
|
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
2026-06-28 12:28:45 +02:00
|
|
|
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
|
|
|
|
route <catalog-id> [--json]
|
|
|
|
|
revoke <catalog-id>
|
2026-08-23 12:05:58 +02:00
|
|
|
lifecycle suspend|deactivate|destroy <catalog-id>
|
2026-08-23 12:33:38 +02:00
|
|
|
audit <catalog-id> [--json]
|
2026-06-28 12:28:45 +02:00
|
|
|
|
|
|
|
|
Every privileged action is decision-gated and writes non-secret evidence.
|
|
|
|
|
`plan` and `apply --dry-run` never mutate OpenBao.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from secrets_engine import __version__
|
|
|
|
|
from secrets_engine.apply import apply_plan
|
|
|
|
|
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
|
2026-06-29 16:58:16 +02:00
|
|
|
from secrets_engine.errors import DecisionError, SecretsEngineError
|
2026-08-23 12:58:12 +02:00
|
|
|
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
2026-06-28 12:28:45 +02:00
|
|
|
from secrets_engine.openbao import OpenBaoClient
|
|
|
|
|
from secrets_engine.plan import build_plan
|
|
|
|
|
from secrets_engine.provision import provision_from_file, provision_generated
|
|
|
|
|
from secrets_engine.routing import route_lane
|
|
|
|
|
from secrets_engine.verify import run_verification
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_lane_and_decision(cfg: Config, ref: str, stage: str):
|
|
|
|
|
"""Map a decision/ccr/lane ref to a catalog entry + resolved decision.
|
|
|
|
|
|
|
|
|
|
For the MVP the catalog's approval.decision_ref ties a lane to its decision,
|
|
|
|
|
and a lane may be addressed directly by catalog id. We match `ref` against
|
|
|
|
|
both catalog ids and decision_refs.
|
|
|
|
|
"""
|
|
|
|
|
entries = load_catalog(cfg.catalog_dir)
|
|
|
|
|
# direct catalog id
|
|
|
|
|
if ref in entries:
|
|
|
|
|
return entries[ref]
|
|
|
|
|
# by decision_ref
|
|
|
|
|
for entry in entries.values():
|
|
|
|
|
if entry.approval.get("decision_ref") == ref:
|
|
|
|
|
return entry
|
|
|
|
|
from secrets_engine.errors import CatalogError
|
|
|
|
|
|
|
|
|
|
raise CatalogError(
|
|
|
|
|
f"no lane matches '{ref}' (by catalog id or decision_ref); "
|
|
|
|
|
f"known lanes: {', '.join(sorted(entries)) or 'none'}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _writer(cfg: Config) -> EvidenceWriter:
|
|
|
|
|
return EvidenceWriter(evidence_dir=cfg.evidence_dir, hub_url=cfg.hub_url, topic_id=cfg.topic_id)
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 12:58:12 +02:00
|
|
|
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 {},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 08:20:33 +02:00
|
|
|
def _require_lane_approval(cfg: Config, entry):
|
|
|
|
|
"""Resolve and enforce the lane approval for a privileged live action."""
|
|
|
|
|
if not entry.approval_required():
|
|
|
|
|
return None
|
|
|
|
|
decision = resolve_decision(
|
|
|
|
|
hub_url=cfg.hub_url,
|
|
|
|
|
repo_root=repo_root(),
|
|
|
|
|
decision_ref=entry.approval.get("decision_ref", entry.id),
|
|
|
|
|
)
|
|
|
|
|
require_approved(entry, decision)
|
|
|
|
|
return decision
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
# -- command handlers ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_catalog_list(cfg: Config, args) -> int:
|
|
|
|
|
entries = load_catalog(cfg.catalog_dir)
|
|
|
|
|
if not entries:
|
|
|
|
|
print("(no catalog entries)")
|
|
|
|
|
return 0
|
|
|
|
|
for e in entries.values():
|
2026-06-29 16:58:16 +02:00
|
|
|
print(
|
|
|
|
|
f"{e.id:32s} kind={e.kind:15s} stage={e.stage:5s} "
|
|
|
|
|
f"owner={e.owner:18s} {e.mount}/{e.path}"
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_catalog_show(cfg: Config, args) -> int:
|
|
|
|
|
e = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
print(f"id: {e.id}")
|
2026-06-29 16:58:16 +02:00
|
|
|
print(f"kind: {e.kind}")
|
2026-06-28 12:28:45 +02:00
|
|
|
print(f"owner: {e.owner}")
|
|
|
|
|
print(f"stage: {e.stage}")
|
2026-06-29 16:58:16 +02:00
|
|
|
if e.kind == "auth-capability":
|
|
|
|
|
print(f"openbao: mount={e.mount} allowed={sorted(e.auth_allowed_paths)}")
|
|
|
|
|
print(f"approle: {e.role_name} policy={e.policy_name}")
|
|
|
|
|
else:
|
2026-08-21 08:20:33 +02:00
|
|
|
print(
|
|
|
|
|
f"openbao: {e.mount}/{e.path} fields={e.fields} "
|
|
|
|
|
f"mount_management={e.mount_management}"
|
|
|
|
|
)
|
|
|
|
|
print(
|
|
|
|
|
f"delivery auth: {e.delivery_auth_method}/"
|
|
|
|
|
f"{e.delivery_auth_management} role={e.role_name if e.has_delivery_auth else '-'}"
|
|
|
|
|
)
|
|
|
|
|
print(f"workload: {e.workload_delivery}")
|
2026-06-28 12:28:45 +02:00
|
|
|
print(f"consumers: {[c['name'] for c in e.consumers]}")
|
|
|
|
|
print(f"delivery: {e.delivery_modes}")
|
|
|
|
|
print(f"approval: {e.approval.get('model')} ref={e.approval.get('decision_ref','')}")
|
|
|
|
|
print(f"verification: {e.verification}")
|
|
|
|
|
print(f"rotation: {e.rotation}")
|
|
|
|
|
print(f"deactivation: {e.deactivation}")
|
2026-08-21 08:20:33 +02:00
|
|
|
if e.risk:
|
|
|
|
|
print(f"risk: {e.risk}")
|
2026-06-28 12:28:45 +02:00
|
|
|
print(f"description: {e.description.strip()}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_decision_inspect(cfg: Config, args) -> int:
|
|
|
|
|
d = resolve_decision(hub_url=cfg.hub_url, repo_root=repo_root(), decision_ref=args.ref)
|
|
|
|
|
print(f"decision: {d.id}")
|
|
|
|
|
print(f"title: {d.title}")
|
|
|
|
|
print(f"status: {d.status} ({'APPROVED' if d.is_approved() else 'NOT approved'})")
|
|
|
|
|
print(f"source: {d.source}")
|
|
|
|
|
if d.superseded_by:
|
|
|
|
|
print(f"superseded: {d.superseded_by}")
|
|
|
|
|
if d.review_url:
|
|
|
|
|
print(f"review: {d.review_url}")
|
|
|
|
|
_writer(cfg).record(
|
|
|
|
|
"decision-inspect", result=d.status, decision_id=d.id, hub=False
|
|
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_plan(cfg: Config, args) -> int:
|
|
|
|
|
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
|
|
|
|
decision = None
|
|
|
|
|
if entry.approval_required():
|
2026-06-29 16:58:16 +02:00
|
|
|
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
|
2026-06-28 12:28:45 +02:00
|
|
|
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
|
|
|
|
|
print(plan.render())
|
|
|
|
|
_writer(cfg).record(
|
|
|
|
|
"plan", result="rendered", catalog_id=entry.id, stage=args.stage,
|
|
|
|
|
decision_id=decision.id if decision else "",
|
|
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_apply(cfg: Config, args) -> int:
|
|
|
|
|
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
|
|
|
|
if args.dry_run:
|
2026-08-23 12:58:12 +02:00
|
|
|
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 ""
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
print(plan.render())
|
|
|
|
|
print("\n(dry-run: no OpenBao mutation performed)")
|
2026-08-23 12:58:12 +02:00
|
|
|
_writer(cfg).record(
|
|
|
|
|
"apply",
|
|
|
|
|
result="dry-run",
|
|
|
|
|
catalog_id=entry.id,
|
|
|
|
|
stage=args.stage,
|
|
|
|
|
decision_id=decision.id if decision else "",
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
2026-08-23 12:58:12 +02:00
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_provision(cfg: Config, args) -> int:
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
2026-08-23 12:58:12 +02:00
|
|
|
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})
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_verify(cfg: Config, args) -> int:
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
2026-08-23 12:05:58 +02:00
|
|
|
fields = [args.field] if args.field else list(entry.fields)
|
2026-06-28 12:28:45 +02:00
|
|
|
positive = args.positive or not args.negative
|
|
|
|
|
negative = args.negative or not args.positive
|
2026-08-23 12:58:12 +02:00
|
|
|
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
|
2026-08-23 12:33:38 +02:00
|
|
|
)
|
2026-08-23 12:58:12 +02:00
|
|
|
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.
|
2026-08-23 12:05:58 +02:00
|
|
|
results.extend(
|
|
|
|
|
run_verification(
|
2026-08-23 12:58:12 +02:00
|
|
|
client,
|
|
|
|
|
entry,
|
|
|
|
|
fields[0],
|
|
|
|
|
positive=False,
|
|
|
|
|
negative=True,
|
|
|
|
|
unrelated_token=unrelated_token,
|
2026-08-23 12:05:58 +02:00
|
|
|
)
|
|
|
|
|
)
|
2026-08-23 12:58:12 +02:00
|
|
|
else:
|
|
|
|
|
results = run_verification(
|
|
|
|
|
client, entry, "", positive=positive, negative=negative
|
2026-08-23 12:05:58 +02:00
|
|
|
)
|
2026-08-23 12:58:12 +02:00
|
|
|
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)},
|
2026-08-23 12:05:58 +02:00
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
return rc
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 16:58:16 +02:00
|
|
|
def cmd_handoff(cfg: Config, args) -> int:
|
|
|
|
|
from secrets_engine.errors import ProvisioningError
|
|
|
|
|
from secrets_engine.handoff import write_approle_handoff
|
|
|
|
|
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
2026-08-23 12:58:12 +02:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-06-29 16:58:16 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
def cmd_exec(cfg: Config, args) -> int:
|
|
|
|
|
from secrets_engine.exec_delivery import exec_with_secret
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog)
|
|
|
|
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
2026-08-23 12:05:58 +02:00
|
|
|
session_detail: dict[str, object] = {}
|
2026-08-23 12:58:12 +02:00
|
|
|
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
|
|
|
|
|
)
|
2026-08-23 12:05:58 +02:00
|
|
|
rc = exec_with_secret(
|
|
|
|
|
client,
|
|
|
|
|
entry,
|
|
|
|
|
field,
|
|
|
|
|
args.command,
|
|
|
|
|
mode=args.mode,
|
|
|
|
|
session_evidence=session_detail,
|
|
|
|
|
)
|
2026-08-23 12:58:12 +02:00
|
|
|
evidence.finish(f"exit-{rc}")
|
2026-06-28 12:28:45 +02:00
|
|
|
return rc
|
|
|
|
|
|
|
|
|
|
|
feat(policy): netkingdom maturity-gated publication-scope policy
Token scope is now bound to package maturity, gated on netkingdom's own maturity:
- maturity-build -> gitea-wide, maturity-test -> org-wide, maturity-prod -> repo-scoped
(scope narrows as stakes rise; broad tokens only for low-stakes build artifacts)
- the graduated table is DORMANT until netkingdom reaches production grade; until
then every lane clamps to repo-scope, injected as NPM_AUTH_TOKEN (fail-safe)
- token env-var name signals blast radius: NPM_AUTH_TOKEN (repo default),
NPM_AUTH_COULOMB_TOKEN (org), NPM_AUTH_GITEA_TOKEN (gitea), NPM_AUTH_WHYNOT_TOKEN
(npm scope, defined but unused), NPM_AUTH_WHYNOTDESIGN (explicit repo)
netkingdom is at maturity-build today, so whynot-design resolves to repo-scope /
NPM_AUTH_TOKEN. Flip netkingdom_maturity to maturity-prod to activate graduation.
- policies/netkingdom-publication-scope.yaml: the policy data + gate
- publication_policy.py: load + resolve (clamp/active, env naming, override)
- exec delivery injects under the resolved env-var name (was fixed SE_NPM_TOKEN)
- catalog lane carries delivery_config.npm.maturity
- new CLI: `secrets-engine policy publication <lane>`
- docs/publication-scope-policy.md; tests for clamp, graduation, naming, override
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:14:46 +02:00
|
|
|
def cmd_policy_publication(cfg: Config, args) -> int:
|
|
|
|
|
from secrets_engine.publication_policy import PublicationPolicy, resolve
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
npm = entry.npm
|
|
|
|
|
if not npm:
|
|
|
|
|
from secrets_engine.errors import PolicyGuardError
|
|
|
|
|
raise PolicyGuardError(f"lane '{entry.id}' has no npm delivery config")
|
|
|
|
|
policy = PublicationPolicy.load(cfg.policy_dir)
|
|
|
|
|
res = resolve(
|
|
|
|
|
policy,
|
|
|
|
|
org=entry.org, repo=entry.repo, npm_scope=npm.get("scope", ""),
|
|
|
|
|
package_maturity=npm.get("maturity", "maturity-build"),
|
|
|
|
|
token_env_override=npm.get("token_env", ""),
|
|
|
|
|
)
|
|
|
|
|
print(f"lane: {entry.id} ({entry.owner})")
|
|
|
|
|
print(f"netkingdom maturity: {policy.netkingdom_maturity} "
|
|
|
|
|
f"(production_grade={policy.production_grade})")
|
|
|
|
|
print(res.render())
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
def cmd_route(cfg: Config, args) -> int:
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
client = OpenBaoClient.resolve(cfg.bao_addr)
|
|
|
|
|
result = route_lane(entry, hub_url=cfg.hub_url, repo_root=repo_root(), client=client)
|
|
|
|
|
if args.json:
|
|
|
|
|
import json
|
|
|
|
|
print(json.dumps(result.to_json(), indent=2))
|
|
|
|
|
else:
|
2026-06-29 16:58:16 +02:00
|
|
|
print(
|
|
|
|
|
f"lane: {result.catalog_id} "
|
|
|
|
|
f"(kind={result.kind}, owner={result.owner}, stage={result.stage})"
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
print(f"decision: {result.decision_status} ref={result.decision_ref}")
|
2026-06-29 16:58:16 +02:00
|
|
|
material_label = "handoff_ready" if result.kind == "auth-capability" else "value_present"
|
|
|
|
|
print(f"applied: {result.metadata_applied} {material_label}: {result.value_present}")
|
2026-06-28 12:28:45 +02:00
|
|
|
print(f"ready: {result.ready}")
|
|
|
|
|
if result.missing:
|
|
|
|
|
print(f"missing: {result.missing}")
|
|
|
|
|
print(f"next: {result.next_command}")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_revoke(cfg: Config, args) -> int:
|
2026-08-23 12:05:58 +02:00
|
|
|
from secrets_engine.lifecycle import (
|
|
|
|
|
apply_lifecycle_plan,
|
|
|
|
|
build_native_deactivation_plan,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
2026-08-23 12:05:58 +02:00
|
|
|
plan = build_native_deactivation_plan(entry)
|
|
|
|
|
if args.dry_run:
|
|
|
|
|
print(plan.render())
|
|
|
|
|
print("\n(dry-run: no OpenBao mutation performed)")
|
2026-06-29 16:58:16 +02:00
|
|
|
return 0
|
2026-08-23 12:58:12 +02:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-08-23 12:05:58 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_lifecycle(cfg: Config, args) -> int:
|
|
|
|
|
from secrets_engine.lifecycle import (
|
|
|
|
|
apply_lifecycle_plan,
|
|
|
|
|
build_lifecycle_plan,
|
|
|
|
|
require_destroy_confirmation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
plan = build_lifecycle_plan(entry, args.operation)
|
2026-06-28 12:28:45 +02:00
|
|
|
if args.dry_run:
|
2026-08-23 12:05:58 +02:00
|
|
|
print(plan.render())
|
|
|
|
|
print("\n(dry-run: no OpenBao mutation performed)")
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
2026-08-23 12:58:12 +02:00
|
|
|
with _privileged_evidence(
|
|
|
|
|
cfg,
|
|
|
|
|
entry,
|
2026-08-23 12:05:58 +02:00
|
|
|
f"lifecycle-{args.operation}",
|
2026-08-23 12:58:12 +02:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 12:33:38 +02:00
|
|
|
def cmd_audit(cfg: Config, args) -> int:
|
|
|
|
|
"""Summarize allowlisted non-secret evidence for one cataloged lane."""
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from secrets_engine.audit import summarize_lane_evidence
|
|
|
|
|
|
|
|
|
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
|
|
|
|
summary = summarize_lane_evidence(cfg.evidence_dir, entry.id)
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(summary.to_json(), indent=2, sort_keys=True))
|
|
|
|
|
else:
|
|
|
|
|
print(summary.render())
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
# -- parser ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
|
|
|
p = argparse.ArgumentParser(prog="secrets-engine", description=__doc__.splitlines()[0])
|
|
|
|
|
p.add_argument("--version", action="version", version=f"secrets-engine {__version__}")
|
|
|
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
|
|
|
|
|
|
def add_token_arg(sp):
|
|
|
|
|
sp.add_argument("--bootstrap-token-file", default=None,
|
|
|
|
|
help="path to a mode-0600 OpenBao token file (bootstrap only)")
|
|
|
|
|
|
|
|
|
|
cat = sub.add_parser("catalog", help="catalog operations")
|
|
|
|
|
catsub = cat.add_subparsers(dest="subcmd", required=True)
|
|
|
|
|
catsub.add_parser("list", help="list catalog lanes").set_defaults(func=cmd_catalog_list)
|
|
|
|
|
cshow = catsub.add_parser("show", help="show a lane")
|
|
|
|
|
cshow.add_argument("catalog_id")
|
|
|
|
|
cshow.set_defaults(func=cmd_catalog_show)
|
|
|
|
|
|
|
|
|
|
dec = sub.add_parser("decision", help="decision operations")
|
|
|
|
|
decsub = dec.add_subparsers(dest="subcmd", required=True)
|
|
|
|
|
dinsp = decsub.add_parser("inspect", help="inspect a decision/CCR")
|
|
|
|
|
dinsp.add_argument("ref")
|
|
|
|
|
dinsp.set_defaults(func=cmd_decision_inspect)
|
|
|
|
|
|
|
|
|
|
pl = sub.add_parser("plan", help="render a guarded plan (no mutation)")
|
|
|
|
|
pl.add_argument("ref", help="decision/ccr id or catalog id")
|
|
|
|
|
pl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
|
|
|
|
pl.set_defaults(func=cmd_plan)
|
|
|
|
|
|
|
|
|
|
ap = sub.add_parser("apply", help="apply approved metadata to OpenBao")
|
|
|
|
|
ap.add_argument("ref")
|
|
|
|
|
ap.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
|
|
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
|
|
|
add_token_arg(ap)
|
|
|
|
|
ap.set_defaults(func=cmd_apply)
|
|
|
|
|
|
|
|
|
|
pr = sub.add_parser("provision", help="provision a value without printing it")
|
|
|
|
|
pr.add_argument("catalog_id")
|
|
|
|
|
pr.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
|
|
|
|
pr.add_argument("--field", default=None)
|
|
|
|
|
g = pr.add_mutually_exclusive_group(required=True)
|
|
|
|
|
g.add_argument("--from-file", help="mode-0600 file holding the value, outside repos")
|
|
|
|
|
g.add_argument("--generate", action="store_true", help="generate (build/test only)")
|
|
|
|
|
add_token_arg(pr)
|
|
|
|
|
pr.set_defaults(func=cmd_provision)
|
|
|
|
|
|
|
|
|
|
ve = sub.add_parser("verify", help="positive/negative verification (no value printed)")
|
|
|
|
|
ve.add_argument("catalog_id")
|
|
|
|
|
ve.add_argument("--field", default=None)
|
|
|
|
|
ve.add_argument("--positive", action="store_true")
|
|
|
|
|
ve.add_argument("--negative", action="store_true")
|
2026-08-23 12:33:38 +02:00
|
|
|
ve.add_argument(
|
|
|
|
|
"--negative-token-file",
|
|
|
|
|
default=None,
|
|
|
|
|
help="mode-0600 out-of-repo token for a real unrelated identity",
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
add_token_arg(ve)
|
|
|
|
|
ve.set_defaults(func=cmd_verify)
|
|
|
|
|
|
2026-06-29 16:58:16 +02:00
|
|
|
ha = sub.add_parser("handoff", help="write AppRole role_id/secret_id handoff files")
|
|
|
|
|
ha.add_argument("catalog_id")
|
|
|
|
|
ha.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
|
|
|
|
ha.add_argument("--role-id-file", required=True)
|
|
|
|
|
ha.add_argument("--secret-id-file", required=True)
|
|
|
|
|
add_token_arg(ha)
|
|
|
|
|
ha.set_defaults(func=cmd_handoff)
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
|
|
|
|
|
ex.add_argument("--catalog", required=True)
|
|
|
|
|
ex.add_argument("--field", default=None)
|
|
|
|
|
ex.add_argument("--mode", default="auto", choices=("auto", "npm-config", "exec-env"))
|
|
|
|
|
add_token_arg(ex)
|
|
|
|
|
ex.add_argument("command", nargs=argparse.REMAINDER,
|
|
|
|
|
help="command after '--'")
|
|
|
|
|
ex.set_defaults(func=cmd_exec)
|
|
|
|
|
|
feat(policy): netkingdom maturity-gated publication-scope policy
Token scope is now bound to package maturity, gated on netkingdom's own maturity:
- maturity-build -> gitea-wide, maturity-test -> org-wide, maturity-prod -> repo-scoped
(scope narrows as stakes rise; broad tokens only for low-stakes build artifacts)
- the graduated table is DORMANT until netkingdom reaches production grade; until
then every lane clamps to repo-scope, injected as NPM_AUTH_TOKEN (fail-safe)
- token env-var name signals blast radius: NPM_AUTH_TOKEN (repo default),
NPM_AUTH_COULOMB_TOKEN (org), NPM_AUTH_GITEA_TOKEN (gitea), NPM_AUTH_WHYNOT_TOKEN
(npm scope, defined but unused), NPM_AUTH_WHYNOTDESIGN (explicit repo)
netkingdom is at maturity-build today, so whynot-design resolves to repo-scope /
NPM_AUTH_TOKEN. Flip netkingdom_maturity to maturity-prod to activate graduation.
- policies/netkingdom-publication-scope.yaml: the policy data + gate
- publication_policy.py: load + resolve (clamp/active, env naming, override)
- exec delivery injects under the resolved env-var name (was fixed SE_NPM_TOKEN)
- catalog lane carries delivery_config.npm.maturity
- new CLI: `secrets-engine policy publication <lane>`
- docs/publication-scope-policy.md; tests for clamp, graduation, naming, override
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:14:46 +02:00
|
|
|
po = sub.add_parser("policy", help="inspect secrets-engine policies")
|
|
|
|
|
posub = po.add_subparsers(dest="subcmd", required=True)
|
|
|
|
|
popub = posub.add_parser("publication", help="resolve a lane's publication scope + token env")
|
|
|
|
|
popub.add_argument("catalog_id")
|
|
|
|
|
popub.set_defaults(func=cmd_policy_publication)
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
ro = sub.add_parser("route", help="ops-warden routing pointer for a lane")
|
|
|
|
|
ro.add_argument("catalog_id")
|
|
|
|
|
ro.add_argument("--json", action="store_true")
|
|
|
|
|
ro.set_defaults(func=cmd_route)
|
|
|
|
|
|
2026-08-23 12:05:58 +02:00
|
|
|
rv = sub.add_parser(
|
|
|
|
|
"revoke",
|
|
|
|
|
help="deactivate native policy/AppRole access (preserves KV custody)",
|
|
|
|
|
)
|
2026-06-28 12:28:45 +02:00
|
|
|
rv.add_argument("catalog_id")
|
|
|
|
|
rv.add_argument("--dry-run", action="store_true")
|
|
|
|
|
add_token_arg(rv)
|
|
|
|
|
rv.set_defaults(func=cmd_revoke)
|
|
|
|
|
|
2026-08-23 12:05:58 +02:00
|
|
|
lc = sub.add_parser("lifecycle", help="explicit lane lifecycle operations")
|
|
|
|
|
lcsub = lc.add_subparsers(dest="operation", required=True)
|
|
|
|
|
for operation, help_text in (
|
|
|
|
|
("suspend", "stop new native logins; preserve policy and KV custody"),
|
|
|
|
|
("deactivate", "remove native AppRole/policy; preserve KV custody"),
|
|
|
|
|
("destroy", "irreversibly delete KV metadata after deactivation"),
|
|
|
|
|
):
|
|
|
|
|
lp = lcsub.add_parser(operation, help=help_text)
|
|
|
|
|
lp.add_argument("catalog_id")
|
|
|
|
|
lp.add_argument("--dry-run", action="store_true")
|
|
|
|
|
if operation == "destroy":
|
|
|
|
|
lp.add_argument(
|
|
|
|
|
"--confirm-destroy",
|
|
|
|
|
default="",
|
|
|
|
|
help="exact catalog id required for live irreversible deletion",
|
|
|
|
|
)
|
|
|
|
|
add_token_arg(lp)
|
|
|
|
|
lp.set_defaults(func=cmd_lifecycle)
|
|
|
|
|
|
2026-08-23 12:33:38 +02:00
|
|
|
au = sub.add_parser("audit", help="summarize non-secret local lane evidence")
|
|
|
|
|
au.add_argument("catalog_id")
|
|
|
|
|
au.add_argument("--json", action="store_true")
|
|
|
|
|
au.set_defaults(func=cmd_audit)
|
|
|
|
|
|
2026-06-28 12:28:45 +02:00
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
|
|
|
parser = build_parser()
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
# `exec` REMAINDER includes a leading '--'; strip it.
|
|
|
|
|
if getattr(args, "command", None) and args.command and args.command[0] == "--":
|
|
|
|
|
args.command = args.command[1:]
|
|
|
|
|
cfg = Config.load()
|
|
|
|
|
try:
|
|
|
|
|
return args.func(cfg, args)
|
|
|
|
|
except SecretsEngineError as e:
|
|
|
|
|
print(f"error: {e}", file=sys.stderr)
|
|
|
|
|
return e.exit_code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|