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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue