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