feat: add auth-capability lanes and pilot closeout
Add the warden-sign auth-capability lane, AppRole handoff, verification guards, docs, and tests. Point the whynot-design pilot at the canonical decision and add the real publish closeout preflight/runbook.
This commit is contained in:
parent
a621fbaffd
commit
6382139890
27 changed files with 1455 additions and 107 deletions
|
|
@ -7,7 +7,8 @@ Command surface (FR7):
|
|||
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
|
||||
verify <catalog-id> [--positive] [--negative] [--field NAME]
|
||||
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
|
||||
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
|
||||
route <catalog-id> [--json]
|
||||
revoke <catalog-id>
|
||||
|
|
@ -26,7 +27,7 @@ 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.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import build_plan
|
||||
|
|
@ -71,16 +72,24 @@ def cmd_catalog_list(cfg: Config, args) -> int:
|
|||
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}")
|
||||
print(
|
||||
f"{e.id:32s} kind={e.kind:15s} stage={e.stage:5s} "
|
||||
f"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"kind: {e.kind}")
|
||||
print(f"owner: {e.owner}")
|
||||
print(f"stage: {e.stage}")
|
||||
print(f"openbao: {e.mount}/{e.path} fields={e.fields}")
|
||||
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:
|
||||
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','')}")
|
||||
|
|
@ -111,11 +120,13 @@ 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)
|
||||
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())
|
||||
_writer(cfg).record(
|
||||
|
|
@ -129,11 +140,16 @@ 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)
|
||||
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:
|
||||
|
|
@ -174,6 +190,9 @@ 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 "")
|
||||
if entry.stores_kv_value() and not field:
|
||||
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
|
||||
results = run_verification(client, entry, field, positive=positive, negative=negative)
|
||||
|
|
@ -187,6 +206,53 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
return rc
|
||||
|
||||
|
||||
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)
|
||||
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 = None
|
||||
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)
|
||||
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,
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
|
@ -240,9 +306,13 @@ def cmd_route(cfg: Config, args) -> int:
|
|||
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"lane: {result.catalog_id} "
|
||||
f"(kind={result.kind}, 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}")
|
||||
material_label = "handoff_ready" if result.kind == "auth-capability" else "value_present"
|
||||
print(f"applied: {result.metadata_applied} {material_label}: {result.value_present}")
|
||||
print(f"ready: {result.ready}")
|
||||
if result.missing:
|
||||
print(f"missing: {result.missing}")
|
||||
|
|
@ -253,6 +323,21 @@ def cmd_route(cfg: Config, args) -> int:
|
|||
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 entry.kind == "auth-capability":
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"(dry-run) would delete approle {entry.role_name} "
|
||||
f"and policy {entry.policy_name}"
|
||||
)
|
||||
return 0
|
||||
client.delete_approle(entry.role_name)
|
||||
client.delete_policy(entry.policy_name)
|
||||
print(
|
||||
f"revoked lane '{entry.id}': deleted approle {entry.role_name} "
|
||||
f"and policy {entry.policy_name}"
|
||||
)
|
||||
_writer(cfg).record("revoke", result="auth-capability-deactivated", catalog_id=entry.id, stage=entry.stage)
|
||||
return 0
|
||||
if args.dry_run:
|
||||
print(f"(dry-run) would delete KV metadata {entry.mount}/{entry.path} "
|
||||
f"and approle {entry.role_name}")
|
||||
|
|
@ -318,6 +403,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
add_token_arg(ve)
|
||||
ve.set_defaults(func=cmd_verify)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue