docs(secrets): interactive console implementation plan (14 tasks)
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
# `secret` Interactive Console Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
> Design (approved): `docs/plans/2026-07-19-secrets-interactive-cli-design.md`
|
||||
> Execute in worktree branch `worktree-secrets-interactive-cli` (root: `/Users/dohertj2/Desktop/scadaproj/.claude/worktrees/secrets-interactive-cli`). All paths below are relative to `ZB.MOM.WW.Secrets/` inside that worktree. Run all commands from that `ZB.MOM.WW.Secrets/` directory.
|
||||
|
||||
**Goal:** Menu-driven Spectre.Console TUI inside the existing `secret` CLI for deployment secret-seeding and lockout recovery, launched by no-args-in-a-TTY; all headless verbs unchanged.
|
||||
|
||||
**Architecture:** New `Interactive/` layer in `src/ZB.MOM.WW.Secrets.Cli`: UI-free services (`TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`, `KekDoctor`, `BundleService`, `RecentTargetsStore`) + an `InteractiveShell` that owns `IAnsiConsole` and dispatches to `IInteractiveFlow` implementations (one file per flow, so flows parallelize). Sessions are built per target by constructing the store stack directly (SQLite or SQL-Server) — no per-target DI host. Degraded mode when the KEK is unavailable in the CLI's shell.
|
||||
|
||||
**Tech Stack:** .NET 10, Spectre.Console (+ Spectre.Console.Testing), xunit 2.9.3, existing core seams (`ISecretStore`, `ISecretCipher`, `IMasterKeyProvider`, `KekRotationService`, `SecretLastWriterWins`).
|
||||
|
||||
**Global rules for every task:**
|
||||
- TDD: write the failing test first, see it fail, implement, see it pass, commit.
|
||||
- Test command: `dotnet test ZB.MOM.WW.Secrets.slnx` (fully offline; must end 0-warning — the repo treats warnings as errors in CI posture).
|
||||
- Secret values must NEVER appear in recents JSON, bundles, logs, or non-reveal screen output. XML-doc every public member (family CommentChecker convention).
|
||||
- Match surrounding code style: file-scoped namespaces, `sealed` classes, records for data, `ConfigureAwait(false)` in library-ish code.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package + project wiring
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (everything depends on it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props`
|
||||
- Modify: `src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj`
|
||||
- Modify: `tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj`
|
||||
|
||||
**Step 1:** Add to `Directory.Packages.props` (new `<!-- Interactive console -->` group next to the Test group):
|
||||
|
||||
```xml
|
||||
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
|
||||
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
|
||||
```
|
||||
|
||||
(If restore says 0.50.0 doesn't exist on the feed, use the latest stable `dotnet package search Spectre.Console` reports and keep both packages on the same version.)
|
||||
|
||||
**Step 2:** In the Cli csproj add `<PackageReference Include="Spectre.Console" />` to its ItemGroup and a ProjectReference to `..\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj` (SQL-hub targets like ScadaBridge central).
|
||||
|
||||
**Step 3:** In the Tests csproj add `<PackageReference Include="Spectre.Console.Testing" />`.
|
||||
|
||||
**Step 4:** Run: `dotnet build ZB.MOM.WW.Secrets.slnx` → Expected: Build succeeded, 0 warnings.
|
||||
|
||||
**Step 5:** Commit: `git add -A && git commit -m "feat(secrets-cli): wire Spectre.Console + SqlServer store refs for interactive console"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: RecentTargetsStore
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** `RecentTargetsStoreTests` against a temp file path (constructor takes the JSON file path; production default `Path.Combine(Environment.GetFolderPath(SpecialFolder.UserProfile), ".zb-secrets", "recent-targets.json")` supplied by the caller, tests pass a temp path):
|
||||
- `Load_returns_empty_when_file_missing`
|
||||
- `Touch_then_load_roundtrips_name_and_path`
|
||||
- `Touch_existing_path_moves_it_first_and_updates_stamp` (inject a `TimeProvider`)
|
||||
- `Load_tolerates_corrupt_json_by_returning_empty`
|
||||
- `Touch_caps_list_at_ten_entries`
|
||||
- `Saved_json_contains_no_key_material_fields` (serialize, assert raw text has only `name`/`appSettingsPath`/`lastUsedUtc` properties)
|
||||
|
||||
**Step 2:** Run `dotnet test ZB.MOM.WW.Secrets.slnx --filter "FullyQualifiedName~RecentTargetsStoreTests"` → FAIL (type missing).
|
||||
|
||||
**Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
/// <summary>Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material).</summary>
|
||||
public sealed class RecentTargetsStore
|
||||
{
|
||||
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
|
||||
|
||||
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... }
|
||||
public IReadOnlyList<RecentTarget> Load() { ... } // missing/corrupt file => []
|
||||
public void Touch(string name, string appSettingsPath) { ... } // upsert by path, newest-first, cap 10, CreateDirectory, atomic write (temp+move)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4:** Re-run the filter → PASS. **Step 5:** Commit `feat(secrets-cli): recent-targets store for the interactive console`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: TargetConfigReader + StoreTarget
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs`
|
||||
- Read for reference: `src/ZB.MOM.WW.Secrets/SecretsOptions.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/SqlServerSecretsOptions.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** temp dir fixtures writing real `appsettings.json` files:
|
||||
- `Reads_secrets_section_and_binds_options` (SqlitePath, MasterKey source/env-var)
|
||||
- `Environment_overlay_wins_over_base` (`appsettings.Production.json` overrides SqlitePath when `environment: "Production"` passed)
|
||||
- `Binds_sqlserver_options_when_section_present` (`Secrets:SqlServer:ConnectionString`)
|
||||
- `SqlitePath_is_resolved_relative_to_the_app_directory` (relative `secrets.db` → absolute under the appsettings dir — the app's CWD at runtime; this is the honest-store guarantee)
|
||||
- `Missing_file_throws_FileNotFoundException_with_path`
|
||||
- `Custom_section_path_is_honored` (e.g. `MySecrets`)
|
||||
|
||||
**Step 2:** Run filter `TargetConfigReaderTests` → FAIL.
|
||||
|
||||
**Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
|
||||
public sealed record StoreTarget(
|
||||
string Name, // display name (defaults to appsettings parent dir name)
|
||||
string? AppSettingsPath, // null for manual-entry targets
|
||||
SecretsOptions Secrets, // SqlitePath already made absolute
|
||||
SqlServerSecretsOptions? SqlServer,
|
||||
IConfigurationRoot Configuration); // full composed config, consumed by ReferenceAuditor
|
||||
|
||||
public sealed class TargetConfigReader
|
||||
{
|
||||
/// <summary>Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars).</summary>
|
||||
public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... }
|
||||
/// <summary>Builds a manual target from explicit db path + master-key options (no appsettings).</summary>
|
||||
public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`Read` uses `new ConfigurationBuilder().SetBasePath(dir).AddJsonFile(fileName).AddJsonFile($"appsettings.{environment}.json", optional: true).AddEnvironmentVariables().Build()`, binds `SecretsOptions` from `sectionPath`, binds `SqlServerSecretsOptions` from `sectionPath + ":SqlServer"` only when that section has children, and absolutizes `SqlitePath` against `dir`.
|
||||
|
||||
**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): target config reader — operate on the app's own composed Secrets config`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider
|
||||
|
||||
**Classification:** high-risk (crypto/KEK wiring; degraded-mode correctness is the lockout path)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Tasks 5-12 depend on it)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs`
|
||||
- Read for reference: `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs` (KekId derivation to mirror), `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/` (how SqlServer store + migrator are constructed), `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs`, `.../LiteralMasterKeyProviderTests.cs`
|
||||
|
||||
**Step 1 (failing tests):**
|
||||
- `LiteralMasterKeyProvider`: `Accepts_base64_32_byte_key`, `Rejects_wrong_length`, `Derives_same_kekid_as_environment_provider_for_same_material` (set a temp env var, compare `KekId` — pins the sha256 derivation match so rewraps interoperate), `Explicit_kekid_wins`.
|
||||
- `SecretsSessionFactory` (temp SQLite targets):
|
||||
- `Opens_full_session_when_env_kek_present` (`KekAvailable == true`, cipher + store usable, schema migrated — assert a `set`-style upsert roundtrips)
|
||||
- `Opens_degraded_session_when_env_var_unset` (`KekAvailable == false`, `Store` still lists, `Cipher` is null)
|
||||
- `Override_kek_upgrades_degraded_session` (create with `LiteralMasterKeyProvider` override → full)
|
||||
- `SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning` (connstr containing `${secret:` → `session.Warnings` non-empty, store is the SQLite one)
|
||||
|
||||
**Step 2:** filter `SecretsSessionFactoryTests|LiteralMasterKeyProviderTests` → FAIL.
|
||||
|
||||
**Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
|
||||
public sealed class SecretsSession
|
||||
{
|
||||
public required StoreTarget Target { get; init; }
|
||||
public required ISecretStore Store { get; init; }
|
||||
public required ISecretsStoreMigrator Migrator { get; init; }
|
||||
public IMasterKeyProvider? MasterKey { get; init; } // null => degraded
|
||||
public ISecretCipher? Cipher { get; init; } // null => degraded
|
||||
public required string StoreKind { get; init; } // "sqlite" | "sqlserver"
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
public bool KekAvailable => Cipher is not null;
|
||||
}
|
||||
|
||||
public sealed class SecretsSessionFactory
|
||||
{
|
||||
/// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
|
||||
public async Task<SecretsSession> OpenAsync(StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Factory logic: pick SqlServer store when `target.SqlServer` is set AND its `ConnectionString` doesn't contain `${secret:` (else warn + SQLite fallback); construct exactly what the DI extensions construct (`SecretsSqliteConnectionFactory`/`SqliteSecretStore`/`SqliteSecretsStoreMigrator`, or the SqlServer equivalents); KEK = `overrideKek ?? try MasterKeyProviderFactory.Create(...)` — wrap creation AND a probe call (compute `KekId`) in try/catch, catching only the provider's documented failure exceptions; on failure record the reason in `Warnings` and open degraded. Always `await migrator.MigrateAsync(ct)` before returning. `LiteralMasterKeyProvider` holds the 32 raw bytes pasted by the operator, mirrors the base class's derived-KekId scheme.
|
||||
|
||||
**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: ReferenceAuditor
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 6, Task 7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs`
|
||||
- Read for reference: `src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs` (token regex — reuse the exact pattern `\$\{secret:([^}]+)\}`)
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** in-memory `IConfigurationRoot` + temp SQLite session:
|
||||
- `Finds_all_distinct_secret_references_with_their_config_paths` (two keys referencing the same name → one finding, two paths)
|
||||
- `Classifies_present_decryptable_secret_as_Ok`
|
||||
- `Classifies_absent_secret_as_Missing`
|
||||
- `Classifies_tombstoned_secret_as_Tombstoned`
|
||||
- `Classifies_wrong_kek_row_as_Undecryptable` (seed row via a cipher on KEK-A, audit with session on KEK-B)
|
||||
- `Degraded_session_reports_PresenceOnly_status` (KEK unavailable → Ok becomes `PresentUnverified`)
|
||||
|
||||
**Step 2:** filter → FAIL. **Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
public enum ReferenceStatus { Ok, PresentUnverified, Missing, Tombstoned, Undecryptable }
|
||||
public sealed record ReferenceFinding(string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);
|
||||
|
||||
/// <summary>Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.</summary>
|
||||
public sealed class ReferenceAuditor
|
||||
{
|
||||
public async Task<IReadOnlyList<ReferenceFinding>> AuditAsync(SecretsSession session, CancellationToken ct) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Walk `session.Target.Configuration.AsEnumerable()` values with the regex; for each distinct name: `store.GetAsync` → null ⇒ Missing; `IsDeleted` ⇒ Tombstoned; degraded ⇒ PresentUnverified; else `cipher.Decrypt` in try/catch(`SecretDecryptionException`) ⇒ Ok/Undecryptable (discard plaintext immediately; never store it in the finding).
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: KekDoctor
|
||||
|
||||
**Classification:** high-risk (drives rewrap remediation)
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs`
|
||||
- Read for reference: `src/ZB.MOM.WW.Secrets/Rotation/KekRotationService.cs`, `src/ZB.MOM.WW.Secrets/Rotation/RewrapReport.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** temp store seeded under KEK-A and KEK-B:
|
||||
- `Diagnose_reports_ok_rows_under_session_kek`
|
||||
- `Diagnose_reports_wrong_kek_rows_with_their_kekid` (session on B, rows on A → `WrongKek`, foreign KekId surfaced so the operator knows which key to hunt for)
|
||||
- `Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails` (tamper `WrapTag` byte)
|
||||
- `Diagnose_throws_on_degraded_session` (`InvalidOperationException` — doctor needs a KEK)
|
||||
- `RewrapAll_moves_wrong_kek_rows_onto_session_kek` (delegate to `KekRotationService`; assert report counts + rows now decrypt)
|
||||
|
||||
**Step 2:** FAIL. **Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
public enum RowKekStatus { Ok, WrongKek, Corrupt }
|
||||
public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
|
||||
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows)
|
||||
{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); }
|
||||
|
||||
/// <summary>Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy.</summary>
|
||||
public sealed class KekDoctor
|
||||
{
|
||||
public async Task<KekDoctorReport> DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... }
|
||||
public Task<RewrapReport> RewrapAllAsync(SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct)
|
||||
=> new KekRotationService(session.Store, session.Cipher!).RewrapAllAsync(oldKek, session.MasterKey!, ct);
|
||||
}
|
||||
```
|
||||
|
||||
Diagnosis probe: KekId mismatch ⇒ WrongKek (no decrypt attempt); match ⇒ `cipher.Decrypt` try/catch ⇒ Ok/Corrupt. Include tombstoned rows (they still block rewrap-all).
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): KEK doctor — per-row diagnosis + guided rewrap remedy`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: SecretBundle export/import
|
||||
|
||||
**Classification:** high-risk (data movement between stores, LWW + cross-KEK rewrap)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs` (DTOs + codec)
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs`
|
||||
- Read for reference: `src/ZB.MOM.WW.Secrets.Abstractions/SecretLastWriterWins.cs`, `src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs`
|
||||
|
||||
**Step 1 (failing tests):**
|
||||
- `Export_then_import_into_empty_store_roundtrips_rows_byte_identical`
|
||||
- `Bundle_json_contains_no_plaintext` (export a known value; assert raw file text doesn't contain it)
|
||||
- `Import_conflict_resolved_by_last_writer_wins` (newer local row survives; newer bundle row replaces)
|
||||
- `Import_conflict_callback_can_force_bundle_row` (per-row override delegate)
|
||||
- `Cross_kek_import_rewraps_when_source_kek_supplied` (bundle from KEK-A into session on KEK-B + `oldKek` → rows land on B and decrypt)
|
||||
- `Cross_kek_import_without_source_kek_skips_rows_and_reports_them`
|
||||
|
||||
**Step 2:** FAIL. **Step 3 (implement):** DTO `SecretBundle { int FormatVersion = 1; DateTimeOffset ExportedUtc; string SourceKekId; List<BundleEntry> Entries; }` where `BundleEntry` mirrors every `StoredSecret` field (byte[] as base64, `SecretName` as string). `BundleService`:
|
||||
|
||||
```csharp
|
||||
public sealed class BundleService
|
||||
{
|
||||
public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... }
|
||||
public async Task<BundleImportReport> ImportAsync(
|
||||
SecretsSession session, string path, IMasterKeyProvider? sourceKek,
|
||||
Func<StoredSecret /*existing*/, StoredSecret /*incoming*/, bool /*takeIncoming*/>? conflictOverride,
|
||||
CancellationToken ct) { ... }
|
||||
}
|
||||
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
|
||||
```
|
||||
|
||||
Import per entry: rewrap via `session.Cipher.Rewrap(row, sourceKek, session.MasterKey)` when `row.KekId != session.MasterKey.KekId` and `sourceKek` matches, else count `SkippedForeignKek`; conflict when the store already has the name → default `SecretLastWriterWins`, `conflictOverride` (when supplied) decides instead; upsert winners. `ExportedUtc` from an injected `TimeProvider`.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Tasks 9-12 depend on the flow seam)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** with `Spectre.Console.Testing.TestConsole` (`Interactive()` profile, `PushKey`/`PushTextWithEnter` scripted input) and a fake flow:
|
||||
- `Shell_shows_target_picker_then_menu_and_quits` (recents + manual entry path; select Quit → returns 0)
|
||||
- `Menu_lists_registered_flows_by_title_plus_builtins` (Switch target, Quit)
|
||||
- `Degraded_session_flow_requiring_kek_prompts_for_key_first` (fake flow `RequiresKek = true`, degraded session → shell asks masked key/file prompt, upgrades session via factory override, then runs the flow)
|
||||
- `Flow_exception_renders_error_panel_and_returns_to_menu` (fake flow throws `SecretDecryptionException` → output contains remedy hint text `KEK doctor`, shell loops, next Quit exits cleanly)
|
||||
- `Ctrl_c_at_menu_exits_zero`
|
||||
|
||||
**Step 2:** FAIL. **Step 3 (implement):**
|
||||
|
||||
```csharp
|
||||
/// <summary>One menu action of the interactive console. Implementations are UI-owning but store-logic-free.</summary>
|
||||
public interface IInteractiveFlow
|
||||
{
|
||||
string Title { get; }
|
||||
bool RequiresKek { get; }
|
||||
Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>The menu loop: target selection, session lifecycle, flow dispatch, error containment.</summary>
|
||||
public sealed class InteractiveShell
|
||||
{
|
||||
public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader,
|
||||
SecretsSessionFactory sessions, IReadOnlyList<IInteractiveFlow> flows) { ... }
|
||||
public async Task<int> RunAsync(CancellationToken ct) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Header panel per loop: target name, store kind, `KEK: OK` / `KEK: UNAVAILABLE (degraded)`. Menu = flows' titles + `Switch store target` + `Quit`. Degraded + `RequiresKek` ⇒ prompt (choice: base64 paste via `TextPrompt<string>().Secret()`, or key-file path) → `LiteralMasterKeyProvider`/file provider → reopen session with override. Every flow call wrapped in try/catch rendering `new Panel(...)` in red with the remedy hint; loop continues.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: CRUD flows (list / set-rotate / reveal / delete)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 11, Task 12
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** each flow run against a TestConsole + real temp-SQLite session:
|
||||
- List: `Renders_metadata_table_never_values` (name/contentType/kekId/revision/updated columns; `RequiresKek == false`)
|
||||
- Set: `Masked_prompt_seals_and_upserts` (typed value lands decryptable; typed value NOT in console output), `Existing_name_confirms_overwrite`
|
||||
- Reveal: `Confirm_then_prints_value_once`, `Decline_prints_nothing`
|
||||
- Delete: `Confirm_tombstones_row`, `Decline_leaves_row`
|
||||
|
||||
**Step 2:** FAIL. **Step 3:** Implement the four flows (each ~40 lines): name via `TextPrompt<string>` (validated through `SecretName`), value via `.Secret()`, content type via `SelectionPrompt<SecretContentType>`, description optional; seal with `session.Cipher.Encrypt` + actor `Environment.UserName` (same stamps as `SecretCommands.UpsertSealedAsync`). Reveal decrypts via `session.Cipher.Decrypt(store.GetAsync(...))` — no resolver, no cache.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): CRUD flows for the interactive console`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Reference-audit + seeding flow
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9, Task 11, Task 12
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs`
|
||||
|
||||
**Step 1 (failing tests):** TestConsole + session whose target config references three secrets (one Ok, one Missing, one Tombstoned):
|
||||
- `Renders_status_table_for_all_references`
|
||||
- `Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones` (masked prompts; afterwards re-audit reports all Ok)
|
||||
- `Skipped_references_are_left_untouched`
|
||||
- `Manual_target_without_appsettings_reports_nothing_to_audit`
|
||||
|
||||
**Step 2:** FAIL. **Step 3:** Implement: run `ReferenceAuditor.AuditAsync`, render table (status color-coded: green Ok / yellow PresentUnverified / red Missing/Tombstoned/Undecryptable + the config paths), then for each non-Ok finding `ConfirmationPrompt("Seed '<name>' now?")` → masked value + content-type prompts → seal + upsert (tombstoned: upsert revives), finish with a re-audit summary line. `RequiresKek == true`.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): deployment seeding flow — audit ${secret:} refs and fill the gaps`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: KEK-doctor flow
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9, Task 10, Task 12
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs`
|
||||
|
||||
**Step 1 (failing tests):**
|
||||
- `Healthy_store_renders_all_ok_summary`
|
||||
- `Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds` (script: choose "Rewrap from old KEK", choose env-var source pointing at KEK-A, confirm → rows decrypt under session KEK; output shows report counts)
|
||||
- `Rewrap_requires_explicit_confirmation` (decline → store untouched)
|
||||
- `Lost_kek_path_offers_reset_of_affected_secrets` (choose "Old KEK is lost" → per-row confirm+masked re-set; declined rows stay wrong-KEK)
|
||||
|
||||
**Step 2:** FAIL. **Step 3:** Implement: `DiagnoseAsync` → summary table (session KekId, per-status counts, the foreign KekIds seen); if unhealthy, `SelectionPrompt`: *Rewrap from old KEK* (old key via env-var name / file path / masked base64 → provider; `ConfirmationPrompt` showing exactly what will happen; then `KekDoctor.RewrapAllAsync`, render `RewrapReport` counts) / *Old KEK is lost — re-set affected secrets* (per wrong-KEK row: confirm + masked new value → seal under session KEK, overwriting) / *Back*. `RequiresKek == true`.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): lockout-recovery flow — KEK diagnosis, guided rewrap, lost-KEK reset`.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Bundle flows (export / import)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 9, Task 10, Task 11
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs`
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs`
|
||||
|
||||
**Step 1 (failing tests):**
|
||||
- `Export_prompts_for_path_and_reports_row_count`
|
||||
- `Import_reports_counts_and_applies_lww`
|
||||
- `Import_conflict_prompt_lets_operator_pick_per_row` (script both choices)
|
||||
- `Import_foreign_kek_prompts_for_source_key_then_rewraps`
|
||||
|
||||
**Step 2:** FAIL. **Step 3:** Implement over `BundleService`; export path prompt defaults to `secrets-bundle-<target>-<yyyyMMdd>.json`; import wires the flow's per-row `ConfirmationPrompt` into `conflictOverride` and, when `SkippedForeignKek` would be non-zero (bundle `SourceKekId` ≠ session KekId), first offers the source-key prompt (env/file/masked). Render final `BundleImportReport` as a table.
|
||||
|
||||
**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): bundle export/import flows`.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Program.cs entry point + non-TTY pin
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (integrates everything)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.Secrets.Cli/Program.cs` (ONLY the `args.Length == 0` branch + composition)
|
||||
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs`
|
||||
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs`
|
||||
|
||||
**Step 1 (failing tests):**
|
||||
- `ShouldEnterInteractive_true_only_for_no_args_and_real_tty` (pure static: `(args, inputRedirected, outputRedirected)` truth table — pins that any redirection keeps usage/exit-2)
|
||||
- `CreateShell_composes_all_five_flows_plus_builtins` (flow titles present exactly once: List, Set/rotate, Reveal, Delete, Reference audit & seed, KEK doctor, Bundle export, Bundle import)
|
||||
|
||||
**Step 2:** FAIL. **Step 3:** `InteractiveEntry` exposes `ShouldEnterInteractive(...)` and `CreateShell(IAnsiConsole console)` (news up recents store at `~/.zb-secrets/recent-targets.json`, reader, factory, all flows). `Program.cs`: replace the bare `return Usage();` with
|
||||
|
||||
```csharp
|
||||
if (args.Length == 0)
|
||||
{
|
||||
if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
|
||||
return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(ct);
|
||||
return Usage();
|
||||
}
|
||||
```
|
||||
|
||||
(The interactive path builds its own sessions per target, so move the host-based migration call so it only runs for the headless verbs — verify with the existing headless tests.)
|
||||
|
||||
**Step 4:** `dotnet test ZB.MOM.WW.Secrets.slnx` (FULL suite — protects the headless verbs) → all pass. Manual smoke: `dotnet run --project src/ZB.MOM.WW.Secrets.Cli < /dev/null` → usage, exit 2. **Step 5:** Commit `feat(secrets-cli): launch interactive console on bare secret in a TTY`.
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Version bump, docs, final green run
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `Directory.Build.props` (`<Version>` 0.2.3 → 0.3.0)
|
||||
- Modify: `README.md` (CLI section: interactive console paragraph + screenshot-style text sample)
|
||||
- Create: `docs/operations/interactive-console.md` (operator runbook: launching, deployment-seeding walk-through, lockout playbook incl. degraded mode + rewrap, bundle notes; mirror the tone of `docs/operations/kek-rotation.md`)
|
||||
|
||||
**Step 1:** Make the edits. **Step 2:** `dotnet build ZB.MOM.WW.Secrets.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.Secrets.slnx` → full suite green. **Step 3:** Commit `docs(secrets): interactive console runbook + 0.3.0 version bump`.
|
||||
|
||||
**Step 4 (handoff, not merge):** Do NOT merge to `main` from inside this task — another agent is active on `main`. Leave the worktree branch `worktree-secrets-interactive-cli` fully committed; merging/publishing is a separate user-decision step (family convention: publish the 5 packages + app bumps only on explicit rollout).
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
1 ─┬─ 2 ─┐
|
||||
└─ 3 ─┴─ 4 ─┬─ 5 ─┐
|
||||
├─ 6 ─┤
|
||||
├─ 7 ─┤
|
||||
└─ 8 ─┼─ 9 ──┐
|
||||
├─ 10 ─┤
|
||||
├─ 11 ─┼─ 13 ── 14
|
||||
└─ 12 ─┘
|
||||
```
|
||||
(9-12 also depend on their service tasks: 10→5, 11→6, 12→7.)
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-19-secrets-interactive-cli.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Package + project wiring", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: RecentTargetsStore", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: TargetConfigReader + StoreTarget", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 5, "subject": "Task 5: ReferenceAuditor", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: KekDoctor", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 7, "subject": "Task 7: SecretBundle export/import", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 8, "subject": "Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 9, "subject": "Task 9: CRUD flows (list / set-rotate / reveal / delete)", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: Reference-audit + seeding flow", "status": "pending", "blockedBy": [5, 8]},
|
||||
{"id": 11, "subject": "Task 11: KEK-doctor flow", "status": "pending", "blockedBy": [6, 8]},
|
||||
{"id": 12, "subject": "Task 12: Bundle flows (export / import)", "status": "pending", "blockedBy": [7, 8]},
|
||||
{"id": 13, "subject": "Task 13: Program.cs entry point + non-TTY pin", "status": "pending", "blockedBy": [9, 10, 11, 12]},
|
||||
{"id": 14, "subject": "Task 14: Version bump, docs, final green run", "status": "pending", "blockedBy": [13]}
|
||||
],
|
||||
"lastUpdated": "2026-07-19T00:00:00Z"
|
||||
}
|
||||
Reference in New Issue
Block a user