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
69
src/secrets_engine/apply.py
Normal file
69
src/secrets_engine/apply.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Apply a guarded plan to OpenBao. Idempotent and decision-gated.
|
||||
|
||||
Apply only ever writes *metadata* (KV mount, ACL policy, approle role). It does
|
||||
NOT write secret values — that is the separate, more constrained `provision` step.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import Plan
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
applied: list[str]
|
||||
skipped: list[str]
|
||||
|
||||
def render(self) -> str:
|
||||
out = []
|
||||
for a in self.applied:
|
||||
out.append(f" applied: {a}")
|
||||
for s in self.skipped:
|
||||
out.append(f" unchanged: {s}")
|
||||
return "\n".join(out) or " (nothing to do)"
|
||||
|
||||
|
||||
def apply_plan(client: OpenBaoClient, entry: CatalogEntry, plan: Plan, ttl: str = "30m") -> ApplyResult:
|
||||
"""Execute the plan's metadata actions idempotently.
|
||||
|
||||
Idempotency: KV mount and approle are enable-if-absent; the policy is written
|
||||
only when its current body differs from the desired HCL.
|
||||
"""
|
||||
applied: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
# 1. KV mount.
|
||||
if client.kv_mount_exists(entry.mount):
|
||||
skipped.append(f"kv-mount {entry.mount} (already present)")
|
||||
else:
|
||||
client.ensure_kv_mount(entry.mount)
|
||||
applied.append(f"kv-mount {entry.mount}")
|
||||
|
||||
# 2. Consumer ACL policy (write only if changed).
|
||||
current = client.read_policy(plan.policy_name)
|
||||
if current and _normalize(current) == _normalize(plan.policy_hcl):
|
||||
skipped.append(f"policy {plan.policy_name} (unchanged)")
|
||||
else:
|
||||
client.write_policy(plan.policy_name, plan.policy_hcl)
|
||||
applied.append(f"policy {plan.policy_name}")
|
||||
|
||||
# 3. Consumer approle bound to that policy.
|
||||
client.ensure_approle_enabled()
|
||||
client.write_approle(plan.role_name, [plan.policy_name], ttl=ttl)
|
||||
applied.append(f"approle {plan.role_name} -> [{plan.policy_name}]")
|
||||
|
||||
return ApplyResult(applied=applied, skipped=skipped)
|
||||
|
||||
|
||||
def _normalize(hcl: str) -> str:
|
||||
"""Compare policy bodies ignoring comments and whitespace noise."""
|
||||
lines = []
|
||||
for raw in hcl.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
lines.append(" ".join(line.split()))
|
||||
return "\n".join(lines)
|
||||
Loading…
Add table
Add a link
Reference in a new issue