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:
tegwick 2026-06-28 12:28:45 +02:00
parent 58c24cff53
commit a852d3f1ff
47 changed files with 3743 additions and 122 deletions

View file

@ -0,0 +1,12 @@
"""secrets-engine: decision-aware workflow layer over OpenBao.
OpenBao enforces custody, policy, lease, and audit. This package owns the
operator/agent interaction model: catalog, decision checks, plan/apply, safe
provisioning, verification, exec-time delivery, and non-secret evidence.
Hard rule enforced throughout the code: raw secret *values* never enter logs,
evidence, State Hub payloads, return values, or stdout. Values live only in
OpenBao, transient process memory, and short-lived mode-0600 files.
"""
__version__ = "0.1.0"

View file

@ -0,0 +1,4 @@
from secrets_engine.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View 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)

View 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]

339
src/secrets_engine/cli.py Normal file
View 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())

View file

@ -0,0 +1,43 @@
"""Runtime configuration resolved from environment and repo layout.
Nothing here is a secret. Backend auth (BAO_TOKEN / bootstrap token files) is
resolved lazily inside the backend adapter, never cached on disk by this module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def repo_root() -> Path:
"""Repo root = nearest ancestor containing pyproject.toml (fallback: cwd)."""
here = Path(__file__).resolve()
for parent in (here, *here.parents):
if (parent / "pyproject.toml").exists():
return parent
return Path.cwd()
@dataclass(frozen=True)
class Config:
catalog_dir: Path
policy_dir: Path
evidence_dir: Path
hub_url: str
bao_addr: str
topic_id: str
@classmethod
def load(cls) -> "Config":
root = repo_root()
return cls(
catalog_dir=Path(os.environ.get("SECRETS_ENGINE_CATALOG", root / "catalog")),
policy_dir=Path(os.environ.get("SECRETS_ENGINE_POLICIES", root / "policies")),
evidence_dir=Path(os.environ.get("SECRETS_ENGINE_EVIDENCE", root / ".evidence")),
hub_url=os.environ.get("SECRETS_ENGINE_HUB_URL", "http://127.0.0.1:8000"),
bao_addr=os.environ.get("BAO_ADDR", os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")),
topic_id=os.environ.get(
"SECRETS_ENGINE_TOPIC_ID", "cee7bedf-2b48-46ef-8601-006474f2ad7a"
),
)

View file

@ -0,0 +1,115 @@
"""Decision integration.
Privileged actions require an approved decision (or approved CCR) unless the lane
is explicitly `bootstrap-only` or the caller passes --dry-run. Decisions are
looked up from State Hub by id; when the hub has no record yet (common during the
pilot) a local approval fixture under `.decisions/<ref>.yaml` can stand in, so the
end-to-end chain is testable before the canonical hub decision object exists.
No secret values are ever read from or written to a decision.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DecisionError
APPROVED_STATUSES = {"resolved", "approved", "accepted"}
@dataclass
class Decision:
id: str
title: str
status: str
superseded_by: str | None
source: str # "hub" | "local-fixture"
review_url: str = ""
raw: dict[str, Any] | None = None
def is_approved(self) -> bool:
return self.status.lower() in APPROVED_STATUSES and not self.superseded_by
def _hub_get(hub_url: str, decision_id: str) -> dict[str, Any] | None:
url = hub_url.rstrip("/") + f"/decisions/{decision_id}"
try:
with urllib.request.urlopen(url, timeout=3) as resp:
return json.loads(resp.read())
except (urllib.error.URLError, OSError, ValueError):
return None
def _local_fixture(repo_root: Path, ref: str) -> dict[str, Any] | None:
path = repo_root / ".decisions" / f"{ref}.yaml"
if not path.exists():
return None
try:
return yaml.safe_load(path.read_text(encoding="utf-8")) or None
except yaml.YAMLError as e:
raise DecisionError(f"{path}: invalid decision fixture: {e}") from e
def resolve_decision(
*,
hub_url: str,
repo_root: Path,
decision_ref: str,
) -> Decision:
"""Resolve a decision by id (hub) or slug (local fixture). Raises if absent."""
if not decision_ref:
raise DecisionError("no decision reference provided")
doc = _hub_get(hub_url, decision_ref)
if doc:
return Decision(
id=doc.get("id", decision_ref),
title=doc.get("title", ""),
status=doc.get("status", "unknown"),
superseded_by=doc.get("superseded_by"),
source="hub",
review_url=f"{hub_url.rstrip('/')}/decisions/{doc.get('id', decision_ref)}",
raw=doc,
)
fixture = _local_fixture(repo_root, decision_ref)
if fixture:
return Decision(
id=fixture.get("id", decision_ref),
title=fixture.get("title", decision_ref),
status=fixture.get("status", "unknown"),
superseded_by=fixture.get("superseded_by"),
source="local-fixture",
review_url=fixture.get("review_url", ""),
raw=fixture,
)
raise DecisionError(
f"decision '{decision_ref}' not found in State Hub or local fixtures"
)
def require_approved(entry: CatalogEntry, decision: Decision | None) -> None:
"""Enforce the lane's approval model. Raises DecisionError if not satisfied."""
if not entry.approval_required():
return # bootstrap-only lane
if decision is None:
raise DecisionError(
f"lane '{entry.id}' requires an approved decision; none resolved"
)
if decision.superseded_by:
raise DecisionError(
f"decision '{decision.id}' is superseded by '{decision.superseded_by}'"
)
if not decision.is_approved():
raise DecisionError(
f"decision '{decision.id}' is not approved (status='{decision.status}')"
)

View file

@ -0,0 +1,54 @@
"""Typed errors with stable exit codes for the CLI.
Exit codes are part of the contract so callers (ops-warden, CI) can branch on
outcome without parsing text.
"""
from __future__ import annotations
class SecretsEngineError(Exception):
"""Base class. ``exit_code`` is the process exit status."""
exit_code = 1
class CatalogError(SecretsEngineError):
"""Catalog file missing, unparseable, or schema-invalid."""
exit_code = 2
class DecisionError(SecretsEngineError):
"""Decision/CCR missing, denied, superseded, stale, or unapproved."""
exit_code = 3
class PolicyGuardError(SecretsEngineError):
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""
exit_code = 4
class BackendError(SecretsEngineError):
"""OpenBao backend call failed or is unreachable."""
exit_code = 5
class ProvisioningError(SecretsEngineError):
"""Provisioning input invalid (bad file mode, inside repo, missing field)."""
exit_code = 6
class VerificationError(SecretsEngineError):
"""A verification check did not produce the expected result."""
exit_code = 7
class DeliveryError(SecretsEngineError):
"""Exec-time delivery could not be set up safely."""
exit_code = 8

View file

@ -0,0 +1,116 @@
"""Non-secret evidence writer.
Every privileged or noteworthy action emits an evidence record to a local
append-only JSONL log and, best-effort, to the State Hub progress API. Records
are scrubbed of anything that looks like a secret value before they are written.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from secrets_engine.redact import looks_secret, redact_text
# Keys that must never carry a value into evidence regardless of nesting.
_FORBIDDEN_VALUE_KEYS = {"value", "secret", "token", "password", "raw"}
def _scrub(obj: Any) -> Any:
"""Recursively drop secret-looking keys and redact token shapes in strings."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if k.lower() in _FORBIDDEN_VALUE_KEYS or looks_secret(k):
out[k] = "<omitted: non-secret evidence only>"
else:
out[k] = _scrub(v)
return out
if isinstance(obj, list):
return [_scrub(v) for v in obj]
if isinstance(obj, str):
return redact_text(obj)
return obj
@dataclass
class EvidenceWriter:
evidence_dir: Path
hub_url: str = ""
topic_id: str = ""
workstream_id: str = ""
author: str = "secrets-engine"
actor: str = field(default_factory=lambda: os.environ.get("USER", "unknown"))
def __post_init__(self) -> None:
self.evidence_dir = Path(self.evidence_dir)
def _log_path(self) -> Path:
self.evidence_dir.mkdir(parents=True, exist_ok=True)
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return self.evidence_dir / f"evidence-{day}.jsonl"
def record(
self,
action: str,
*,
result: str,
catalog_id: str = "",
stage: str = "",
decision_id: str = "",
detail: dict[str, Any] | None = None,
hub: bool = True,
) -> dict[str, Any]:
"""Append one non-secret evidence record. Returns the stored record."""
record = {
"ts": datetime.now(timezone.utc).isoformat(),
"action": action,
"result": result,
"actor": self.actor,
"catalog_id": catalog_id,
"stage": stage,
"decision_id": decision_id,
"detail": _scrub(detail or {}),
}
path = self._log_path()
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
if hub and self.hub_url:
self._post_hub(action, result, catalog_id, stage, decision_id)
return record
def _post_hub(
self, action: str, result: str, catalog_id: str, stage: str, decision_id: str
) -> None:
"""Best-effort progress note to State Hub. Never raises; never sends values."""
if not self.topic_id:
return
summary = f"secrets-engine {action}: {result}"
if catalog_id:
summary += f" [{catalog_id}{'/' + stage if stage else ''}]"
payload: dict[str, Any] = {
"topic_id": self.topic_id,
"event_type": "note",
"summary": summary,
"author": self.author,
}
if self.workstream_id:
payload["workstream_id"] = self.workstream_id
if decision_id:
payload["detail"] = {"decision_id": decision_id, "catalog_id": catalog_id}
try:
req = urllib.request.Request(
self.hub_url.rstrip("/") + "/progress/",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=3).read()
except (urllib.error.URLError, OSError, ValueError):
# Hub being offline must never block secret work or leak anything.
pass

View file

@ -0,0 +1,152 @@
"""Exec-time delivery: make a secret available only to a child process.
The default and preferred delivery mode. The value is fetched from OpenBao,
injected into the child's environment / a temp config, the child runs, and the
injection is destroyed afterward on success, failure, or interruption.
Supported here:
- npm-config: write a temporary .npmrc with the auth token and point the child
at it via NPM_CONFIG_USERCONFIG. Preferred for `npm publish`.
- exec-env: inject the value as an environment variable for the child only.
The parent shell never sees the value; the value is never logged. Child stdout/
stderr is streamed through a redactor as a backstop.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DeliveryError
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.redact import redact_text
def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
"""Read the field value via an approle-scoped token. Held in memory only."""
try:
token = client.approle_login_token(entry.role_name)
except Exception as e:
raise DeliveryError(f"could not obtain scoped token for delivery: {e}") from e
scoped = OpenBaoClient(addr=client.addr, token=token, bao_bin=client.bao_bin)
proc = scoped._run(["kv", "get", "-format=json", f"{entry.mount}/{entry.path}"])
if proc.returncode != 0:
raise DeliveryError(f"scoped read failed for lane '{entry.id}' (denied or absent)")
try:
data = json.loads(proc.stdout)["data"]["data"]
except (json.JSONDecodeError, KeyError) as e:
raise DeliveryError(f"malformed KV response for lane '{entry.id}'") from e
if field not in data:
raise DeliveryError(f"field '{field}' absent in lane '{entry.id}'")
return data[field]
@contextmanager
def _npm_userconfig(token: str) -> Iterator[Path]:
"""Write a mode-0600 temp .npmrc, yield its path, delete it unconditionally."""
fd, name = tempfile.mkstemp(prefix="se-npmrc-", suffix=".ini")
path = Path(name)
try:
os.fchmod(fd, 0o600)
# Registry-scoped auth token; child npm reads this via NPM_CONFIG_USERCONFIG.
with os.fdopen(fd, "w") as fh:
fh.write("//registry.npmjs.org/:_authToken=${SE_NPM_TOKEN}\n")
yield path
finally:
try:
path.unlink()
except FileNotFoundError:
pass
def _stream_redacted(proc: subprocess.Popen, secret: str) -> None:
"""Stream child output through the redactor (backstop)."""
assert proc.stdout is not None
for line in proc.stdout:
sys.stdout.write(redact_text(line, extra=[secret]))
sys.stdout.flush()
def exec_with_secret(
client: OpenBaoClient,
entry: CatalogEntry,
field: str,
command: list[str],
*,
mode: str = "auto",
) -> int:
"""Run `command` with the lane's secret injected for the child only.
Returns the child's exit code. Raises DeliveryError if setup is unsafe.
"""
if not command:
raise DeliveryError("no command given to exec")
declared = set(entry.delivery_modes)
if mode == "auto":
mode = "npm-config" if "npm-config" in declared else (
"exec-env" if "exec-env" in declared else ""
)
if not mode:
raise DeliveryError(
f"lane '{entry.id}' declares no exec-capable delivery mode "
f"({sorted(declared)})"
)
if mode not in declared:
raise DeliveryError(
f"delivery mode '{mode}' not permitted for lane '{entry.id}' "
f"(allowed {sorted(declared)})"
)
value = _fetch_value(client, entry, field)
child_env = dict(os.environ)
if mode == "npm-config":
with _npm_userconfig(value) as npmrc:
child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc)
child_env["SE_NPM_TOKEN"] = value
rc = _spawn(command, child_env, value)
return rc
if mode == "exec-env":
# Inject under a conventional name derived from the field.
env_name = field.upper()
child_env[env_name] = value
return _spawn(command, child_env, value)
raise DeliveryError(f"unsupported delivery mode '{mode}'")
def _spawn(command: list[str], env: dict[str, str], secret: str) -> int:
"""Spawn the child, stream redacted output, propagate signals, ensure cleanup."""
try:
proc = subprocess.Popen(
command,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
except FileNotFoundError as e:
raise DeliveryError(f"command not found: {command[0]}") from e
def _forward(signum, _frame):
proc.send_signal(signum)
old_int = signal.signal(signal.SIGINT, _forward)
old_term = signal.signal(signal.SIGTERM, _forward)
try:
_stream_redacted(proc, secret)
return proc.wait()
finally:
signal.signal(signal.SIGINT, old_int)
signal.signal(signal.SIGTERM, old_term)
# env dict goes out of scope; the temp npmrc is removed by its context mgr.

View file

@ -0,0 +1,232 @@
"""OpenBao backend adapter.
Thin wrapper over the `bao` CLI. Isolated here so the rest of the engine speaks
in lanes/plans, not in OpenBao endpoint quirks (FR: "isolate backend adapter").
Auth resolution order for the token:
1. explicit bootstrap token file (--bootstrap-token-file), mode-checked;
2. BAO_TOKEN / VAULT_TOKEN environment variable;
3. otherwise unauthenticated (only dry-run / read-health works).
This adapter NEVER returns a secret value to its callers except through the
narrow `read_field_present()` (boolean) and the exec-delivery path, which writes
straight into a child process and never logs.
"""
from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
from dataclasses import dataclass
from pathlib import Path
from secrets_engine.errors import BackendError, ProvisioningError
def _check_token_file(path: Path) -> str:
"""Read a bootstrap token file after enforcing mode-0600 and out-of-repo."""
if not path.exists():
raise ProvisioningError(f"bootstrap token file not found: {path}")
st = path.stat()
if st.st_mode & 0o077:
raise ProvisioningError(
f"bootstrap token file {path} is group/other-accessible "
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
)
# Refuse a token file living inside a Git worktree.
for parent in path.resolve().parents:
if (parent / ".git").exists():
raise ProvisioningError(
f"bootstrap token file {path} is inside a Git worktree ({parent}); "
"store it outside any repo"
)
token = path.read_text(encoding="utf-8").strip()
if not token:
raise ProvisioningError(f"bootstrap token file {path} is empty")
return token
@dataclass
class OpenBaoClient:
addr: str
token: str = ""
bao_bin: str = ""
@classmethod
def resolve(
cls, addr: str, *, bootstrap_token_file: str | Path | None = None
) -> "OpenBaoClient":
token = ""
if bootstrap_token_file:
token = _check_token_file(Path(bootstrap_token_file))
else:
token = os.environ.get("BAO_TOKEN", os.environ.get("VAULT_TOKEN", ""))
bao_bin = shutil.which("bao") or shutil.which("vault") or ""
return cls(addr=addr, token=token, bao_bin=bao_bin)
# -- low level ---------------------------------------------------------
def _run(self, args: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess:
if not self.bao_bin:
raise BackendError(
"no 'bao' (or 'vault') CLI on PATH; cannot reach OpenBao backend"
)
env = dict(os.environ)
env["BAO_ADDR"] = self.addr
env["VAULT_ADDR"] = self.addr
if self.token:
env["BAO_TOKEN"] = self.token
env["VAULT_TOKEN"] = self.token
try:
return subprocess.run(
[self.bao_bin, *args],
input=stdin,
env=env,
capture_output=True,
text=True,
timeout=30,
)
except FileNotFoundError as e:
raise BackendError(f"backend binary not runnable: {e}") from e
except subprocess.TimeoutExpired as e:
raise BackendError(f"backend call timed out: {' '.join(args)}") from e
def _run_ok(self, args: list[str], *, stdin: str | None = None) -> str:
proc = self._run(args, stdin=stdin)
if proc.returncode != 0:
# stderr from bao does not contain the secret value for these calls.
raise BackendError(
f"bao {' '.join(args[:2])} failed (exit {proc.returncode}): "
f"{proc.stderr.strip() or proc.stdout.strip()}"
)
return proc.stdout
# -- health / capabilities --------------------------------------------
def is_reachable(self) -> bool:
if not self.bao_bin:
return False
proc = self._run(["status", "-format=json"])
# status returns non-zero when sealed but still reachable; treat any
# parseable JSON as reachable.
try:
json.loads(proc.stdout or "{}")
return True
except json.JSONDecodeError:
return proc.returncode == 0
# -- policies ----------------------------------------------------------
def write_policy(self, name: str, hcl: str) -> None:
self._run_ok(["policy", "write", name, "-"], stdin=hcl)
def read_policy(self, name: str) -> str | None:
proc = self._run(["policy", "read", name])
if proc.returncode != 0:
return None
return proc.stdout
# -- approle -----------------------------------------------------------
def ensure_approle_enabled(self) -> None:
proc = self._run(["auth", "list", "-format=json"])
if proc.returncode == 0:
try:
methods = json.loads(proc.stdout)
if "approle/" in methods:
return
except json.JSONDecodeError:
pass
enable = self._run(["auth", "enable", "approle"])
if enable.returncode != 0 and "already in use" not in enable.stderr:
raise BackendError(f"could not enable approle: {enable.stderr.strip()}")
def write_approle(self, role_name: str, policies: list[str], ttl: str = "30m") -> None:
self._run_ok(
[
"write",
f"auth/approle/role/{role_name}",
f"token_policies={','.join(policies)}",
f"token_ttl={ttl}",
f"token_max_ttl={ttl}",
"secret_id_num_uses=0",
"token_num_uses=0",
]
)
def approle_login_token(self, role_name: str) -> str:
"""Login as the approle and return a scoped child token. Used only for
verification / exec delivery; never logged."""
role_id = self._run_ok(
["read", "-field=role_id", f"auth/approle/role/{role_name}/role-id"]
).strip()
secret_id = self._run_ok(
["write", "-field=secret_id", "-f", f"auth/approle/role/{role_name}/secret-id"]
).strip()
token = self._run_ok(
[
"write",
"-field=token",
"auth/approle/login",
f"role_id={role_id}",
f"secret_id={secret_id}",
]
).strip()
return token
# -- KV v2 -------------------------------------------------------------
def kv_mount_exists(self, mount: str) -> bool:
proc = self._run(["secrets", "list", "-format=json"])
if proc.returncode != 0:
return False
try:
return f"{mount}/" in json.loads(proc.stdout)
except json.JSONDecodeError:
return False
def ensure_kv_mount(self, mount: str) -> None:
if self.kv_mount_exists(mount):
return
enable = self._run(["secrets", "enable", "-path", mount, "kv-v2"])
if enable.returncode != 0 and "already in use" not in enable.stderr:
raise BackendError(f"could not enable kv at {mount}: {enable.stderr.strip()}")
def kv_put(self, mount: str, path: str, field: str, value: str) -> None:
"""Write a single field. `value` is a secret and is passed via stdin-free
argv only as a key=value to the local CLI; it is never logged or returned."""
self._run_ok(["kv", "put", f"{mount}/{path}", f"{field}={value}"])
def kv_metadata_exists(self, mount: str, path: str) -> bool:
proc = self._run(["kv", "metadata", "get", "-format=json", f"{mount}/{path}"])
return proc.returncode == 0
def kv_field_present(self, mount: str, path: str, field: str, *, token: str | None = None) -> bool:
"""Return whether `field` exists at the path — WITHOUT returning its value.
If `token` is given, the read is attempted as that (scoped) token, so a
True/False result doubles as a positive/negative access check.
"""
client = self
if token is not None:
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"])
if proc.returncode != 0:
return False
try:
doc = json.loads(proc.stdout)
except json.JSONDecodeError:
return False
data = doc.get("data", {}).get("data", {})
return field in data and bool(data[field])
def kv_can_read(self, mount: str, path: str, *, token: str) -> bool:
"""True iff `token` is permitted to read the path at all (no value used)."""
client = OpenBaoClient(addr=self.addr, token=token, bao_bin=self.bao_bin)
proc = client._run(["kv", "get", "-format=json", f"{mount}/{path}"])
return proc.returncode == 0
def kv_delete_metadata(self, mount: str, path: str) -> None:
self._run_ok(["kv", "metadata", "delete", f"{mount}/{path}"])

View file

@ -0,0 +1,90 @@
"""Planning: turn an approved request into a concrete, guarded set of actions.
A Plan is a human-reviewable list of OpenBao actions (policy write, approle
write, KV mount). Building a plan runs every safety guard, so a plan that exists
is, by construction, in-bounds. Apply just executes a built plan.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import PolicyGuardError
from secrets_engine.roles import (
StageRole,
assert_path_in_stage,
consumer_policy_for,
)
@dataclass
class PlanAction:
kind: str # "kv-mount" | "policy" | "approle"
target: str # human-readable target
detail: dict[str, Any] = field(default_factory=dict)
def render(self) -> str:
d = ", ".join(f"{k}={v}" for k, v in self.detail.items() if k != "hcl")
return f" [{self.kind}] {self.target}" + (f" ({d})" if d else "")
@dataclass
class Plan:
catalog_id: str
stage: str
decision_id: str
policy_name: str
role_name: str
actions: list[PlanAction]
policy_hcl: str
def render(self) -> str:
lines = [
f"Plan for lane '{self.catalog_id}' (stage={self.stage})",
f" decision: {self.decision_id or '<none>'}",
f" stage role: secrets-engine-{self.stage}",
f" consumer policy: {self.policy_name}",
f" consumer approle: {self.role_name}",
" actions:",
]
lines.extend(a.render() for a in self.actions)
lines.append("")
lines.append(" generated consumer policy (HCL):")
lines.extend(" " + ln for ln in self.policy_hcl.splitlines())
return "\n".join(lines)
def build_plan(entry: CatalogEntry, stage: str, *, decision_id: str = "") -> Plan:
"""Construct and fully guard a plan. Raises PolicyGuardError on any violation."""
if stage != entry.stage:
raise PolicyGuardError(
f"stage mismatch: lane '{entry.id}' is stage '{entry.stage}', "
f"refusing to apply as '{stage}'"
)
StageRole.for_stage(stage) # validates stage name
assert_path_in_stage(entry) # path must be in-stage, no wildcards
policy_name, policy_hcl = consumer_policy_for(entry) # runs assert_policy_safe
actions = [
PlanAction("kv-mount", entry.mount, {"type": "kv-v2"}),
PlanAction(
"policy",
policy_name,
{"paths": f"{entry.mount}/data/{entry.path}"},
),
PlanAction(
"approle",
entry.role_name,
{"token_policies": policy_name, "auth": "approle"},
),
]
return Plan(
catalog_id=entry.id,
stage=stage,
decision_id=decision_id,
policy_name=policy_name,
role_name=entry.role_name,
actions=actions,
policy_hcl=policy_hcl,
)

View file

@ -0,0 +1,75 @@
"""Provisioning: get a secret value into OpenBao without it touching coordination
surfaces.
Modes:
- from-file: read a value from a mode-0600 file outside the repo, write it to
OpenBao, and (caller's choice) leave the source file for the operator to shred.
- generate: mint a random non-production value for build/test lanes only.
The value is held only in process memory and passed straight to the backend. It
is never logged, returned, or written to evidence.
"""
from __future__ import annotations
import secrets as _secrets
import string
from pathlib import Path
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import ProvisioningError
from secrets_engine.openbao import OpenBaoClient
def _read_value_file(path: Path) -> str:
if not path.exists():
raise ProvisioningError(f"value file not found: {path}")
st = path.stat()
if st.st_mode & 0o077:
raise ProvisioningError(
f"value file {path} is group/other-accessible "
f"(mode {oct(st.st_mode & 0o777)}); must be 0600"
)
for parent in path.resolve().parents:
if (parent / ".git").exists():
raise ProvisioningError(
f"value file {path} is inside a Git worktree ({parent}); "
"keep secret material outside repos"
)
value = path.read_text(encoding="utf-8").strip()
if not value:
raise ProvisioningError(f"value file {path} is empty")
return value
def provision_from_file(
client: OpenBaoClient, entry: CatalogEntry, field: str, file_path: Path
) -> str:
"""Import a value from a strict-permission file. Returns the field name only."""
if field not in entry.fields:
raise ProvisioningError(
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
)
value = _read_value_file(Path(file_path))
client.ensure_kv_mount(entry.mount)
client.kv_put(entry.mount, entry.path, field, value)
del value
return field
def provision_generated(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
"""Generate a random NON-PRODUCTION value for build/test lanes only."""
if entry.stage == "prod":
raise ProvisioningError(
f"refusing to generate a value for prod lane '{entry.id}'; "
"production values must be provisioned, not generated"
)
if field not in entry.fields:
raise ProvisioningError(
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
)
alphabet = string.ascii_letters + string.digits
value = "test-" + "".join(_secrets.choice(alphabet) for _ in range(32))
client.ensure_kv_mount(entry.mount)
client.kv_put(entry.mount, entry.path, field, value)
del value
return field

View file

@ -0,0 +1,42 @@
"""Defense-in-depth redaction of secret-like material.
This is a backstop, not the primary control. The primary control is that secret
values are never passed into evidence/log code paths in the first place. Redaction
catches the case where a value leaks into child-process output we control.
"""
from __future__ import annotations
import re
from typing import Iterable
REDACTED = "***REDACTED***"
# Token shapes we proactively mask in child-process output.
_PATTERNS = [
re.compile(r"npm_[A-Za-z0-9]{8,}"), # npm automation/publish tokens
re.compile(r"(?:hv|hvs|hvb|s)\.[A-Za-z0-9._-]{16,}"), # vault/openbao tokens
re.compile(r"gh[pousr]_[A-Za-z0-9]{16,}"), # github tokens
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # slack tokens
re.compile(r"AKIA[0-9A-Z]{16}"), # aws access key id
]
def redact_text(text: str, extra: Iterable[str] = ()) -> str:
"""Mask known token shapes and any caller-supplied literal values."""
if not text:
return text
for literal in extra:
if literal and len(literal) >= 4:
text = text.replace(literal, REDACTED)
for pat in _PATTERNS:
text = pat.sub(REDACTED, text)
return text
def looks_secret(name: str) -> bool:
"""Heuristic: does a field/key name suggest it carries a secret value?"""
lowered = name.lower()
return any(
marker in lowered
for marker in ("token", "secret", "password", "passwd", "apikey", "api_key", "key", "credential")
)

138
src/secrets_engine/roles.py Normal file
View file

@ -0,0 +1,138 @@
"""Stage role/policy generation and the safety guards that keep them narrow.
Three stage roles exist: secrets-engine-build, -test, -prod. Each is confined to
its own KV prefix and a small, explicit capability set. The guards in this module
are the heart of the product promise: a generated plan that would grant broad
power (root, sudo, sys/, auth/ admin, wildcard mounts, cross-stage paths) is
rejected before it can ever reach OpenBao.
"""
from __future__ import annotations
from dataclasses import dataclass
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import PolicyGuardError
STAGES = ("build", "test", "prod")
# Per-stage KV path prefix each role is allowed to touch. A lane whose path does
# not sit under its stage prefix is out of bounds.
STAGE_PREFIX = {
"build": "build/",
"test": "test/",
"prod": "", # prod lanes use their own owner-scoped paths (no shared prefix)
}
# Capabilities a stage role's *own* policy may carry. Anything else is broad.
ALLOWED_CAPABILITIES = {"create", "read", "update", "delete", "list"}
# Substrings that, if they appear in a policy path, mean the plan is too broad.
FORBIDDEN_PATH_MARKERS = (
"sys/",
"auth/token/",
"identity/",
"sudo",
"+/", # single-level wildcard
)
# Capability names that confer admin/root and must never appear in a stage policy.
FORBIDDEN_CAPABILITIES = {"sudo", "root", "deny-all-bypass"}
# Policy/role names that smell like broad admin and are refused outright.
FORBIDDEN_NAME_MARKERS = ("root", "admin", "superuser", "platform-admin", "sys")
@dataclass(frozen=True)
class StageRole:
stage: str
policy_name: str
role_name: str
prefix: str
@classmethod
def for_stage(cls, stage: str) -> "StageRole":
if stage not in STAGES:
raise PolicyGuardError(f"unknown stage '{stage}'; allowed {STAGES}")
return cls(
stage=stage,
policy_name=f"secrets-engine-{stage}",
role_name=f"secrets-engine-{stage}",
prefix=STAGE_PREFIX[stage],
)
def assert_path_in_stage(entry: CatalogEntry) -> None:
"""Reject a lane whose path is wildcarded or outside its stage prefix."""
if "*" in entry.path or "+" in entry.path:
raise PolicyGuardError(
f"lane '{entry.id}': wildcard path '{entry.path}' is not allowed"
)
prefix = STAGE_PREFIX[entry.stage]
if entry.stage in ("build", "test") and not entry.path.startswith(prefix):
raise PolicyGuardError(
f"lane '{entry.id}': {entry.stage} path must start with '{prefix}' "
f"(got '{entry.path}')"
)
if entry.stage in ("build", "test"):
# A build/test lane must not reach into another stage's prefix.
for other, oprefix in STAGE_PREFIX.items():
if other != entry.stage and oprefix and entry.path.startswith(oprefix):
raise PolicyGuardError(
f"lane '{entry.id}': {entry.stage} lane reaches into "
f"'{oprefix}' ({other} territory)"
)
def assert_policy_safe(policy_name: str, paths: dict[str, list[str]]) -> None:
"""Reject a policy document that is too broad to be a stage policy."""
lowered = policy_name.lower()
for marker in FORBIDDEN_NAME_MARKERS:
if marker in lowered:
raise PolicyGuardError(
f"policy name '{policy_name}' resembles broad admin (marker '{marker}')"
)
for path, caps in paths.items():
for marker in FORBIDDEN_PATH_MARKERS:
if marker in path:
raise PolicyGuardError(
f"policy '{policy_name}': path '{path}' is out of bounds "
f"(marker '{marker}')"
)
if path.strip() in ("*", "/", "secret/*", "+"):
raise PolicyGuardError(
f"policy '{policy_name}': wildcard path '{path}' not allowed"
)
bad_caps = set(caps) - ALLOWED_CAPABILITIES
if bad_caps & FORBIDDEN_CAPABILITIES or bad_caps:
raise PolicyGuardError(
f"policy '{policy_name}': capabilities {sorted(bad_caps)} not allowed "
f"(allowed {sorted(ALLOWED_CAPABILITIES)})"
)
def lane_policy_paths(entry: CatalogEntry) -> dict[str, list[str]]:
"""The minimal KV v2 paths + capabilities a consumer policy needs for a lane."""
data_path = f"{entry.mount}/data/{entry.path}"
meta_path = f"{entry.mount}/metadata/{entry.path}"
return {
data_path: ["read"],
meta_path: ["read"],
}
def render_policy_hcl(policy_name: str, paths: dict[str, list[str]]) -> str:
"""Render an OpenBao ACL policy in HCL. Validates safety first."""
assert_policy_safe(policy_name, paths)
blocks = [f'# Generated by secrets-engine for policy "{policy_name}"']
for path, caps in paths.items():
cap_list = ", ".join(f'"{c}"' for c in caps)
blocks.append(f'path "{path}" {{\n capabilities = [{cap_list}]\n}}')
return "\n\n".join(blocks) + "\n"
def consumer_policy_for(entry: CatalogEntry) -> tuple[str, str]:
"""Return (policy_name, hcl) for the lane's approved consumer."""
assert_path_in_stage(entry)
paths = lane_policy_paths(entry)
name = entry.policy_name
return name, render_policy_hcl(name, paths)

View file

@ -0,0 +1,99 @@
"""ops-warden routing contract.
ops-warden routes non-SSH credential needs here. It must NOT vend secret values.
A route result is a pointer: catalog id, readiness, decision status, and the safe
next command. This module computes that pointer for a lane. No value is read.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.decisions import Decision, resolve_decision
from secrets_engine.errors import DecisionError
from secrets_engine.openbao import OpenBaoClient
@dataclass
class RouteResult:
catalog_id: str
owner: str
stage: str
decision_status: str
decision_ref: str
review_url: str
metadata_applied: bool
value_present: bool
ready: bool
next_command: str
missing: str
def to_json(self) -> dict[str, Any]:
return asdict(self)
def route_lane(
entry: CatalogEntry,
*,
hub_url: str,
repo_root: Path,
client: OpenBaoClient | None = None,
) -> RouteResult:
"""Build the front-door routing pointer for a lane. Never reads the value."""
decision_status = "n/a (bootstrap-only)"
decision_ref = entry.approval.get("decision_ref", "")
review_url = ""
decision: Decision | None = None
if entry.approval_required():
try:
decision = resolve_decision(
hub_url=hub_url, repo_root=repo_root, decision_ref=decision_ref
)
decision_status = decision.status
review_url = decision.review_url
except DecisionError:
decision_status = "missing"
metadata_applied = False
value_present = False
if client is not None and client.is_reachable():
metadata_applied = client.read_policy(entry.policy_name) is not None
# Presence check uses the engine's own token; reports boolean only.
field = entry.fields[0] if entry.fields else ""
if field:
value_present = client.kv_field_present(entry.mount, entry.path, field)
approved = decision is None or decision.is_approved()
ready = approved and metadata_applied and value_present
if not approved:
missing = f"approved decision for '{decision_ref}'"
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
elif not metadata_applied:
missing = "OpenBao policy/role apply"
next_command = f"secrets-engine apply {decision_ref or entry.id} --stage {entry.stage}"
elif not value_present:
missing = "provisioned secret value"
next_command = (
f"secrets-engine provision {entry.id} --stage {entry.stage} "
f"--field {entry.fields[0]} --from-file <path>"
)
else:
missing = ""
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>"
return RouteResult(
catalog_id=entry.id,
owner=entry.owner,
stage=entry.stage,
decision_status=decision_status,
decision_ref=decision_ref,
review_url=review_url,
metadata_applied=metadata_applied,
value_present=value_present,
ready=ready,
next_command=next_command,
missing=missing,
)

View file

@ -0,0 +1,81 @@
"""Verification: prove access (or denial) without printing the value.
Positive: the approved consumer (via its approle-scoped token) CAN read the lane.
Negative: an unrelated/unscoped token CANNOT read the lane.
Each check returns a boolean + a non-secret evidence dict. The secret value is
never read into the result.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import VerificationError
from secrets_engine.openbao import OpenBaoClient
@dataclass
class VerifyResult:
check: str # "positive" | "negative"
passed: bool
detail: dict[str, Any]
def render(self) -> str:
status = "PASS" if self.passed else "FAIL"
return f" {self.check} check: {status} ({self.detail.get('reason', '')})"
def verify_positive(client: OpenBaoClient, entry: CatalogEntry, field: str) -> VerifyResult:
"""Approved consumer token must be able to read the field."""
try:
token = client.approle_login_token(entry.role_name)
except Exception as e: # backend errors -> failed verification, not a value leak
return VerifyResult(
"positive",
False,
{"reason": f"could not obtain approle token: {e}", "path": entry.path},
)
present = client.kv_field_present(entry.mount, entry.path, field, token=token)
return VerifyResult(
"positive",
present,
{
"reason": "approved consumer can read lane field"
if present
else "approved consumer could NOT read field",
"path": entry.path,
"field": field,
"role": entry.role_name,
},
)
def verify_negative(client: OpenBaoClient, entry: CatalogEntry) -> VerifyResult:
"""An unrelated token must be denied. Uses an empty (invalid) token."""
# An empty/garbage token stands in for an unrelated consumer.
denied = not client.kv_can_read(entry.mount, entry.path, token="se-unrelated-denied")
return VerifyResult(
"negative",
denied,
{
"reason": "unrelated token denied read"
if denied
else "unrelated token was ABLE to read (LEAK RISK)",
"path": entry.path,
},
)
def run_verification(
client: OpenBaoClient, entry: CatalogEntry, field: str, *, positive: bool, negative: bool
) -> list[VerifyResult]:
results: list[VerifyResult] = []
if positive:
results.append(verify_positive(client, entry, field))
if negative:
results.append(verify_negative(client, entry))
if not results:
raise VerificationError("no verification check selected (use --positive/--negative)")
return results