feat(mvp): working secrets-engine CLI for the whynot-design npm publish lane
Implements SECRETS-WP-0002 end to end as a uv-managed Python package: - catalog: non-secret lane registry + strict validator (build/test/prod) - stage roles + OpenBao ACL policies; guards refuse wildcards, sys/, identity/, admin names, and cross-stage paths before any backend call - plan/apply: dry-run-first, idempotent policy + approle apply, decision-gated - decisions: State Hub lookup with local-fixture fallback; non-secret evidence to JSONL + hub progress, scrubbed of any value - provision/verify: mode-0600 file import + generated test values; positive/ negative checks that never print the value - exec delivery: `exec --catalog ... -- npm publish` injects the token via a temp .npmrc for the child only, cleaned up on exit/failure/interrupt - ops-warden routing contract + hardening backlog docs - 34 tests incl. live OpenBao integration; scripts/demo-e2e.sh runs the full chain against a throwaway bao dev server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
58c24cff53
commit
a852d3f1ff
47 changed files with 3743 additions and 122 deletions
339
src/secrets_engine/cli.py
Normal file
339
src/secrets_engine/cli.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"""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
|
||||
verify <catalog-id> [--positive] [--negative] --field NAME
|
||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
||||
route <catalog-id> [--json]
|
||||
revoke <catalog-id>
|
||||
|
||||
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
|
||||
from secrets_engine.errors import SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter
|
||||
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)
|
||||
|
||||
|
||||
# -- 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():
|
||||
print(f"{e.id:32s} stage={e.stage:5s} owner={e.owner:18s} {e.mount}/{e.path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_catalog_show(cfg: Config, args) -> int:
|
||||
e = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
print(f"id: {e.id}")
|
||||
print(f"owner: {e.owner}")
|
||||
print(f"stage: {e.stage}")
|
||||
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
|
||||
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}")
|
||||
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():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
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)
|
||||
decision = None
|
||||
if entry.approval_required():
|
||||
decision = resolve_decision(
|
||||
hub_url=cfg.hub_url, repo_root=repo_root(),
|
||||
decision_ref=entry.approval.get("decision_ref", args.ref),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
plan = build_plan(entry, args.stage, decision_id=decision.id if decision else "")
|
||||
w = _writer(cfg)
|
||||
if args.dry_run:
|
||||
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 "")
|
||||
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})
|
||||
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}'")
|
||||
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,
|
||||
detail={"field": f})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(cfg: Config, args) -> int:
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||
positive = args.positive or not args.negative
|
||||
negative = args.negative or not args.positive
|
||||
results = run_verification(client, entry, field, positive=positive, negative=negative)
|
||||
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, detail=r.detail)
|
||||
return rc
|
||||
|
||||
|
||||
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.
|
||||
if entry.approval_required():
|
||||
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)
|
||||
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,
|
||||
detail={"command": args.command[0], "mode": args.mode})
|
||||
rc = exec_with_secret(client, entry, field, args.command, mode=args.mode)
|
||||
w.record("exec", result=f"exit-{rc}", catalog_id=entry.id, stage=entry.stage,
|
||||
detail={"command": args.command[0]})
|
||||
return rc
|
||||
|
||||
|
||||
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:
|
||||
print(f"lane: {result.catalog_id} (owner={result.owner}, stage={result.stage})")
|
||||
print(f"decision: {result.decision_status} ref={result.decision_ref}")
|
||||
print(f"applied: {result.metadata_applied} value_present: {result.value_present}")
|
||||
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:
|
||||
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||
client = OpenBaoClient.resolve(cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file)
|
||||
if args.dry_run:
|
||||
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
|
||||
f"and approle {entry.role_name}")
|
||||
return 0
|
||||
client.kv_delete_metadata(entry.mount, entry.path)
|
||||
print(f"revoked lane '{entry.id}': KV metadata deleted at {entry.mount}/{entry.path}")
|
||||
_writer(cfg).record("revoke", result="deactivated", catalog_id=entry.id, stage=entry.stage)
|
||||
return 0
|
||||
|
||||
|
||||
# -- 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")
|
||||
add_token_arg(ve)
|
||||
ve.set_defaults(func=cmd_verify)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
rv = sub.add_parser("revoke", help="deactivate a lane (delete KV metadata)")
|
||||
rv.add_argument("catalog_id")
|
||||
rv.add_argument("--dry-run", action="store_true")
|
||||
add_token_arg(rv)
|
||||
rv.set_defaults(func=cmd_revoke)
|
||||
|
||||
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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue