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
171
src/secrets_engine/catalog.py
Normal file
171
src/secrets_engine/catalog.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""Catalog: the non-secret registry of secret lanes and grants.
|
||||
|
||||
A catalog entry describes *where* a secret lives in OpenBao, *who* may consume
|
||||
it, *how* it is delivered, and *what* approval/verification/rotation it requires.
|
||||
It never contains a secret value. Loading and validation are strict: a malformed
|
||||
or under-specified lane is rejected rather than silently defaulted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from secrets_engine.errors import CatalogError
|
||||
from secrets_engine.redact import looks_secret
|
||||
|
||||
VALID_STAGES = ("build", "test", "prod")
|
||||
VALID_DELIVERY_MODES = ("exec-env", "exec-file", "npm-config", "wrapped", "read-check")
|
||||
VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"id",
|
||||
"owner",
|
||||
"stage",
|
||||
"mount",
|
||||
"path",
|
||||
"fields",
|
||||
"consumers",
|
||||
"delivery_modes",
|
||||
"approval",
|
||||
"verification",
|
||||
"rotation",
|
||||
"deactivation",
|
||||
"audit",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
id: str
|
||||
owner: str
|
||||
stage: str
|
||||
mount: str
|
||||
path: str
|
||||
fields: list[str]
|
||||
consumers: list[dict[str, Any]]
|
||||
delivery_modes: list[str]
|
||||
approval: dict[str, Any]
|
||||
verification: dict[str, Any]
|
||||
rotation: dict[str, Any]
|
||||
deactivation: dict[str, Any]
|
||||
audit: dict[str, Any]
|
||||
description: str = ""
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def kv_data_path(self) -> str:
|
||||
"""Full KV v2 *data* path used for read/write of the value."""
|
||||
return f"{self.mount}/data/{self.path}"
|
||||
|
||||
@property
|
||||
def kv_logical_path(self) -> str:
|
||||
"""KV v2 logical path (used inside ACL policy capabilities)."""
|
||||
return f"{self.mount}/data/{self.path}"
|
||||
|
||||
@property
|
||||
def policy_name(self) -> str:
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
@property
|
||||
def role_name(self) -> str:
|
||||
return f"se-{self.stage}-{self.id}"
|
||||
|
||||
def approval_required(self) -> bool:
|
||||
return self.approval.get("model") != "bootstrap-only"
|
||||
|
||||
|
||||
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
|
||||
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
|
||||
if not isinstance(data, dict):
|
||||
raise CatalogError(f"{source}: catalog entry must be a mapping")
|
||||
|
||||
missing = [k for k in REQUIRED_FIELDS if k not in data or data[k] in (None, "", [], {})]
|
||||
if missing:
|
||||
raise CatalogError(f"{source}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
stage = data["stage"]
|
||||
if stage not in VALID_STAGES:
|
||||
raise CatalogError(
|
||||
f"{source}: stage '{stage}' invalid; must be one of {VALID_STAGES}"
|
||||
)
|
||||
|
||||
modes = data["delivery_modes"]
|
||||
if not isinstance(modes, list) or not modes:
|
||||
raise CatalogError(f"{source}: delivery_modes must be a non-empty list")
|
||||
bad_modes = [m for m in modes if m not in VALID_DELIVERY_MODES]
|
||||
if bad_modes:
|
||||
raise CatalogError(
|
||||
f"{source}: unknown delivery_modes {bad_modes}; allowed {VALID_DELIVERY_MODES}"
|
||||
)
|
||||
|
||||
fields = data["fields"]
|
||||
if not isinstance(fields, list) or not all(isinstance(f, str) for f in fields):
|
||||
raise CatalogError(f"{source}: fields must be a list of strings")
|
||||
|
||||
consumers = data["consumers"]
|
||||
if not isinstance(consumers, list) or not consumers:
|
||||
raise CatalogError(f"{source}: consumers must be a non-empty list")
|
||||
for c in consumers:
|
||||
if not isinstance(c, dict) or "name" not in c or "auth" not in c:
|
||||
raise CatalogError(
|
||||
f"{source}: each consumer needs at least 'name' and 'auth'"
|
||||
)
|
||||
|
||||
approval = data["approval"]
|
||||
if not isinstance(approval, dict) or "model" not in approval:
|
||||
raise CatalogError(f"{source}: approval must include a 'model'")
|
||||
if approval["model"] not in VALID_APPROVAL_MODELS:
|
||||
raise CatalogError(
|
||||
f"{source}: approval.model '{approval['model']}' invalid; "
|
||||
f"allowed {VALID_APPROVAL_MODELS}"
|
||||
)
|
||||
|
||||
# A path must never leak a value through a field name suggesting inline secrets.
|
||||
if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")):
|
||||
raise CatalogError(f"{source}: catalog entries must not contain secret values")
|
||||
|
||||
# Guard against accidentally broad mount/path.
|
||||
path = data["path"]
|
||||
if "*" in path or "*" in data["mount"]:
|
||||
raise CatalogError(f"{source}: wildcard mount/path not allowed in catalog")
|
||||
|
||||
known = {f.name for f in CatalogEntry.__dataclass_fields__.values()} - {"raw"}
|
||||
kwargs = {k: v for k, v in data.items() if k in known}
|
||||
return CatalogEntry(raw=data, **kwargs)
|
||||
|
||||
|
||||
def load_entry(path: Path) -> CatalogEntry:
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as e:
|
||||
raise CatalogError(f"catalog file not found: {path}") from e
|
||||
except yaml.YAMLError as e:
|
||||
raise CatalogError(f"{path}: YAML parse error: {e}") from e
|
||||
return validate_entry(data, source=str(path))
|
||||
|
||||
|
||||
def load_catalog(catalog_dir: Path) -> dict[str, CatalogEntry]:
|
||||
"""Load and validate every *.yaml in the catalog directory."""
|
||||
catalog_dir = Path(catalog_dir)
|
||||
if not catalog_dir.exists():
|
||||
return {}
|
||||
entries: dict[str, CatalogEntry] = {}
|
||||
for path in sorted(catalog_dir.glob("*.yaml")):
|
||||
entry = load_entry(path)
|
||||
if entry.id in entries:
|
||||
raise CatalogError(f"duplicate catalog id '{entry.id}' in {path}")
|
||||
entries[entry.id] = entry
|
||||
return entries
|
||||
|
||||
|
||||
def get_entry(catalog_dir: Path, catalog_id: str) -> CatalogEntry:
|
||||
entries = load_catalog(catalog_dir)
|
||||
if catalog_id not in entries:
|
||||
raise CatalogError(
|
||||
f"catalog id '{catalog_id}' not found in {catalog_dir} "
|
||||
f"(known: {', '.join(sorted(entries)) or 'none'})"
|
||||
)
|
||||
return entries[catalog_id]
|
||||
Loading…
Add table
Add a link
Reference in a new issue