From a53671e0af3a25cb383b37cb978d1ff73c8aa16a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 08:41:41 -0400 Subject: [PATCH] docs(secrets): interactive CLI console design (approved) --- ...26-07-19-secrets-interactive-cli-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/plans/2026-07-19-secrets-interactive-cli-design.md diff --git a/docs/plans/2026-07-19-secrets-interactive-cli-design.md b/docs/plans/2026-07-19-secrets-interactive-cli-design.md new file mode 100644 index 0000000..bfd1539 --- /dev/null +++ b/docs/plans/2026-07-19-secrets-interactive-cli-design.md @@ -0,0 +1,104 @@ +# Design: `secret` interactive console (ZB.MOM.WW.Secrets CLI, 0.3.0) + +**Date:** 2026-07-19 · **Status:** Approved · **Execution:** git worktree `worktree-secrets-interactive-cli` (another agent is active on `main`) + +## 1. Purpose & posture + +Extend the existing headless `secret` CLI (`src/ZB.MOM.WW.Secrets.Cli`) with a menu-driven +Spectre.Console TUI for two operator jobs: + +1. **Deployment setup** — seed a deployment's secrets before first startup (apps fail closed + when a `${secret:...}` reference is missing). +2. **Lockout recovery** — diagnose and fix wrong/lost-KEK and missing-secret states. + +No authentication: the tool only runs locally with direct file/DB access — the same trust +model as the existing headless verbs. All existing verbs stay byte-identical for scripts. + +## 2. Entry point + +`secret` with **no args in a real terminal** (`!Console.IsInputRedirected && +!Console.IsOutputRedirected`) launches the TUI. Redirected I/O keeps today's +usage-and-exit-2 behavior, so nothing scriptable changes. All other `Program.cs` paths are +untouched. + +## 3. Target selection & sessions + +First screen picks a **store target**: + +- **Recent targets** — persisted to `~/.zb-secrets/recent-targets.json` (names and paths + only; NEVER key material). +- **Browse** to an app's `appsettings.json`. +- **Manual entry** — db path + KEK source. + +`TargetConfigReader` loads the app's real config the way the app would +(`appsettings.json` + optional `appsettings..json` overlay + environment +variables; section `Secrets` by default, overridable) and binds `SecretsOptions` +(+ `SqlServerSecretsOptions` when present) — operating on exactly what the app will read at +startup, so a fix cannot land in the wrong store. + +`SecretsSessionFactory` constructs the stack directly — `SecretsSqliteConnectionFactory` / +`SqlServerSecretStore`, `MasterKeyProviderFactory`, `AesGcmEnvelopeCipher`, the matching +migrator — no per-target DI host. If the configured KEK env var isn't set in the CLI's +shell (common: it exists only in the service's environment), the session opens **degraded**: +metadata ops work; decrypt/seal ops prompt for the key via masked input or file path, held +in memory only. + +Dependency changes to `ZB.MOM.WW.Secrets.Cli`: add `Spectre.Console` and a project +reference to `ZB.MOM.WW.Secrets.Replicator.SqlServer` (to open SQL-hub stores such as +ScadaBridge central). + +## 4. Menu actions + +| Action | Behavior | +|---|---| +| List / set-rotate / get / delete | Thin TUI wrappers over the same core seams `SecretCommands` uses. Masked value entry; tables for listings; **get** reveals once after confirm, never cached. | +| **Reference audit + seeding** | Scan the target app's fully-composed config for `${secret:NAME}` tokens (same regex as `SecretReferenceExpander`); report each OK / missing / tombstoned / undecryptable; walk through seeding every non-OK one with masked prompts. This is the deployment-setup flow. | +| **KEK doctor** | Verify the session KEK against the store: per-row KekId match + actual DEK-unwrap probe; report row classes (OK / wrong-KEK / corrupt). Guided remedies: `rewrap-all` (old key from env/file/masked paste, reusing `KekRotationService`) or re-set affected secrets when the old KEK is truly lost. This is the lockout flow. | +| **Bundle export / import** | Export rows as ciphertext-only JSON (name, metadata, wrapped DEK, kekId, ciphertext — safe at rest, useless without the KEK). Import into another store; conflicts resolved by core `SecretLastWriterWins` with per-row override prompt. Cross-KEK import offers inline rewrap given the source KEK. | +| Switch target / quit | Rebuild session for a new target; quit exits 0. | + +## 5. Structure + +New `Interactive/` layer inside `ZB.MOM.WW.Secrets.Cli`: + +- `InteractiveShell` — menu loop; the ONLY class that touches `IAnsiConsole`. +- UI-free services: `TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`, + `KekDoctor`, `SecretBundleCodec` + import/export service, `RecentTargetsStore`. + +Services never touch the console; the shell never touches the store directly. Secret values +are never written to recents, bundles (plaintext), logs, or screen except the explicit +reveal action. + +## 6. Error handling + +Every action catches `SecretDecryptionException` / `ArgumentException` / IO / SQL failures +and renders a red panel with a remedy hint (usually "run KEK doctor"), returning to the +menu — the shell never crashes out mid-recovery. Ctrl-C at a prompt cancels back to the +menu; at the menu it exits 0. + +## 7. Testing + +- Unit tests for all services in `tests/ZB.MOM.WW.Secrets.Tests/Cli/` (temp SQLite stores, + fake KEK providers — same style as existing tests). +- Shell flows scripted via `Spectre.Console.Testing`'s `TestConsole`: menu navigation, + masked entry, degraded-KEK path, audit→seed walk-through, doctor→rewrap. +- Pin test: non-TTY no-args still prints usage / exit 2. +- Suite stays 0-warning (`TreatWarningsAsErrors` posture of the repo). + +## 8. Versioning & execution + +- `Directory.Build.props` version bumps `0.2.3` → `0.3.0` (new feature; CLI is unpacked but + the 5 packages ship together — publish decision deferred to rollout). +- Implemented on worktree branch `worktree-secrets-interactive-cli`; merge to `main` when + the full suite is green. + +## Alternatives considered + +- **Separate `ZB.MOM.WW.Secrets.Cli.Interactive` library** — cleaner layering if another + front-end ever hosts the flows; rejected as YAGNI for a one-exe operator tool. +- **Prompts inside `SecretCommands`** — least code but couples UI into the tested headless + layer and breaks its TextWriter-only contract; rejected. +- **REPL shell / wizards-only UX** — rejected in favor of discoverable menus (operators + under lockout stress shouldn't need to remember verbs). +- **Own profiles file as source of truth** — rejected: can drift from what the app actually + reads; parsing the app's own config is the honest source.