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
12
src/secrets_engine/__init__.py
Normal file
12
src/secrets_engine/__init__.py
Normal 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"
|
||||
4
src/secrets_engine/__main__.py
Normal file
4
src/secrets_engine/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from secrets_engine.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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)
|
||||
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]
|
||||
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())
|
||||
43
src/secrets_engine/config.py
Normal file
43
src/secrets_engine/config.py
Normal 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"
|
||||
),
|
||||
)
|
||||
115
src/secrets_engine/decisions.py
Normal file
115
src/secrets_engine/decisions.py
Normal 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}')"
|
||||
)
|
||||
54
src/secrets_engine/errors.py
Normal file
54
src/secrets_engine/errors.py
Normal 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
|
||||
116
src/secrets_engine/evidence.py
Normal file
116
src/secrets_engine/evidence.py
Normal 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
|
||||
152
src/secrets_engine/exec_delivery.py
Normal file
152
src/secrets_engine/exec_delivery.py
Normal 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.
|
||||
232
src/secrets_engine/openbao.py
Normal file
232
src/secrets_engine/openbao.py
Normal 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}"])
|
||||
90
src/secrets_engine/plan.py
Normal file
90
src/secrets_engine/plan.py
Normal 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,
|
||||
)
|
||||
75
src/secrets_engine/provision.py
Normal file
75
src/secrets_engine/provision.py
Normal 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
|
||||
42
src/secrets_engine/redact.py
Normal file
42
src/secrets_engine/redact.py
Normal 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
138
src/secrets_engine/roles.py
Normal 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)
|
||||
99
src/secrets_engine/routing.py
Normal file
99
src/secrets_engine/routing.py
Normal 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,
|
||||
)
|
||||
81
src/secrets_engine/verify.py
Normal file
81
src/secrets_engine/verify.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue